bec9411af3
Five workers built output URIs with inline f-strings, so the bucket-per-artifact layout was spread across worker_tts, worker_identity, worker_crop, worker_layers and worker_render. Moving a class between buckets meant a grep. They are now templates in transport.py, formatted at each call site. Three of those workers also each reimplemented the same parse to recover manga_id and chapter_id from an input uri, because the orchestrator does not send them. That is transport.ids_from_uri now, and it raises on a uri too short to carry the ids rather than returning a wrong pair. ruff.toml makes `ruff check .` exit 0, so CI can gate on it and a new finding means a new defect. Fixed: an implicit Optional in 8 signatures, an unparenthesized implicit concatenation in the ASS filter list, 5 subprocess.run calls now saying check=False out loud, an unused import, a duplicate exception handler and a non-executable shebang. Every rule left off carries its reason in ruff.toml. The ASYNC rules are off because ffmpeg on the event loop is real and already recorded at caveats/audit-open.md#blocking-event-loop. It needs a refactor per handler, not a lint fix. Checked: transport, collage, bubble_detect, test_vision_parse, worker_crop, worker_scene, worker_script, worker_identity, worker_tts, session_manager, worker_vision and worker_render self-checks all pass. worker_layers still fails on a missing legacy/qwen_layered_workflow.json, which predates this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
250 lines
11 KiB
Python
250 lines
11 KiB
Python
# worker_tts.py — stage 8 TTS. FastAPI :8006. dots.tts loads in-process (session-guarded).
|
|
# generates audio locally, uploads to minio, returns uri + duration. no local persistence.
|
|
import os, re, json, uuid, wave, contextlib, subprocess, logging, functools
|
|
from fastapi import FastAPI
|
|
from pydantic import BaseModel
|
|
import transport
|
|
|
|
log = logging.getLogger("tts")
|
|
|
|
app = FastAPI()
|
|
transport.install_logging(app, "tts")
|
|
SHM = "/dev/shm"
|
|
DOTS_MODEL = "rednote-hilab/dots.tts-base"
|
|
_tts = None
|
|
|
|
# B#3 one fixed voice: dots.tts samples a RANDOM speaker each call unless given a reference clip,
|
|
# so the narrator's timbre drifts panel-to-panel. clone from ONE reference for every synth.
|
|
# to pin YOUR OWN voice: set VOICE_REF=/path/to/clip.wav + VOICE_REF_TEXT="its exact transcript"
|
|
# (5-15s of clean speech works best). no edit/rebuild needed -- just the env vars on the worker.
|
|
# with no VOICE_REF, we bootstrap a seeded reference once and persist it; delete it to reroll.
|
|
VOICE_DIR = os.path.expanduser("~/.cache/manga-tts")
|
|
REF_WAV = os.environ.get("VOICE_REF") or os.path.join(VOICE_DIR, "narrator_ref.wav")
|
|
REF_TEXT = os.environ.get("VOICE_REF_TEXT", "The story continues as our hero steps forward into the unknown.")
|
|
REF_SEED = 20260713
|
|
|
|
|
|
def _load_tts():
|
|
global _tts
|
|
if _tts is None:
|
|
# dots.tts's vendored loader (models/dots_tts/model.py) calls AutoTokenizer.from_pretrained
|
|
# with no kwargs, so its Mistral-derived tokenizer loads with the buggy split regex. Default
|
|
# fix_mistral_regex=True at the transformers layer to get canonical tokenization + kill the warning.
|
|
# ponytail: monkeypatch because the loader exposes no passthrough; drop if dots_tts adds one.
|
|
import transformers
|
|
_orig = transformers.AutoTokenizer.from_pretrained.__func__
|
|
transformers.AutoTokenizer.from_pretrained = classmethod(
|
|
lambda cls, *a, **kw: _orig(cls, *a, **{"fix_mistral_regex": True, **kw})
|
|
)
|
|
# dots_tts/__init__.py refuses to import when torch and torchaudio minors differ. workpc runs
|
|
# Arch's torch 2.13 but pytorch.org ships no torchaudio past 2.11 for ROCm, so the pair can't
|
|
# be satisfied; 2.11 loads and resamples fine against 2.13. Lie to the guard for the import.
|
|
# ponytail: drop this once a torchaudio matching torch's minor exists for ROCm.
|
|
import torch, importlib.metadata as _md
|
|
_ver = _md.version
|
|
_md.version = lambda n: torch.__version__ if n == "torchaudio" else _ver(n)
|
|
try:
|
|
from dots_tts.runtime import DotsTtsRuntime
|
|
finally:
|
|
_md.version = _ver
|
|
_tts = DotsTtsRuntime.from_pretrained(DOTS_MODEL, precision="bfloat16")
|
|
return _tts
|
|
|
|
|
|
def _wav_duration(path: str) -> float:
|
|
with contextlib.closing(wave.open(path, "rb")) as w:
|
|
return round(w.getnframes() / float(w.getframerate()), 3)
|
|
|
|
|
|
class TTSInput(BaseModel):
|
|
text: str
|
|
speaker: str = "narrator" # multi-voice is v3
|
|
panel_id: str = ""
|
|
session_id: str = ""
|
|
panel_uri: str = "" # optional: if passed, audio is stored beside its panel
|
|
|
|
|
|
def _audio_uri(data: "TTSInput") -> str:
|
|
# prefer the panel's own manga/chapter prefix; the orchestrator currently doesn't pass it,
|
|
# so fall back to a flat panel_id-keyed key (matches homesrv's panel_id-keyed audio table).
|
|
# ponytail: flat key collides across chapters (as does the homesrv audio table); pass
|
|
# panel_uri from run_stage_tts to make it per-chapter unique.
|
|
if data.panel_uri:
|
|
manga_id, chapter_id = transport.ids_from_uri(data.panel_uri)
|
|
return transport.AUDIO_URI.format(manga_id=manga_id, chapter_id=chapter_id,
|
|
name=data.panel_id or 'p')
|
|
return transport.AUDIO_FLAT_URI.format(name=data.panel_id or 'p')
|
|
|
|
|
|
def _ensure_ref() -> str:
|
|
"""the fixed narrator reference clip; generate it once (seeded) and persist across restarts."""
|
|
if os.path.exists(REF_WAV):
|
|
return REF_WAV
|
|
os.makedirs(VOICE_DIR, exist_ok=True)
|
|
try:
|
|
import torch
|
|
torch.manual_seed(REF_SEED) # reproducible speaker for the bootstrap sample
|
|
except Exception:
|
|
pass
|
|
out = _load_tts().generate(text=REF_TEXT)
|
|
_write_wav(out["audio"], out["sample_rate"], REF_WAV)
|
|
return REF_WAV
|
|
|
|
|
|
def _calm(text: str) -> str:
|
|
"""dots.tts over-emotes on '!' (shouty prosody). soften exclamations to periods so the narrator
|
|
stays even. only the spoken text is calmed -- the burned subtitles keep the original '!'."""
|
|
return re.sub(r"\s*!+", ".", text)
|
|
|
|
|
|
# 158: dots.tts has no SSML/phoneme input, so proper nouns it mangles are fixed by respelling the SPOKEN
|
|
# text only (burned subtitles keep the original spelling — they're built elsewhere from the untouched
|
|
# script). Lazy v1: one global JSON map {term: phonetic}, ~8 lines, loaded once.
|
|
LEXICON_PATH = os.path.expanduser(os.environ.get("TTS_LEXICON", "~/.cache/manga-tts/lexicon.json"))
|
|
|
|
|
|
@functools.lru_cache(maxsize=1)
|
|
def _lexicon():
|
|
"""(compiled word-boundary pattern, {lower_term: phonetic}) or None. Cached; delete the file and
|
|
call _lexicon.cache_clear() to reload. Longest terms first so multi-word names match whole."""
|
|
try:
|
|
with open(LEXICON_PATH) as f:
|
|
m = json.load(f)
|
|
except (OSError, ValueError):
|
|
return None
|
|
m = {k: v for k, v in (m or {}).items() if k and v}
|
|
if not m:
|
|
return None
|
|
pat = re.compile(r"\b(" + "|".join(re.escape(k) for k in sorted(m, key=len, reverse=True)) + r")\b",
|
|
re.IGNORECASE)
|
|
return pat, {k.lower(): v for k, v in m.items()}
|
|
|
|
|
|
def _respell(text: str) -> str:
|
|
"""Substitute known proper nouns with their phonetic respelling (whole word, case-insensitive)."""
|
|
lex = _lexicon()
|
|
if not lex:
|
|
return text
|
|
pat, lookup = lex
|
|
return pat.sub(lambda mo: lookup[mo.group(0).lower()], text)
|
|
|
|
|
|
def _generate(text: str) -> str:
|
|
"""returns a local wav path. dots runtime returns {"audio": samples, "sample_rate": sr};
|
|
clone the fixed reference voice so every panel narrates in the same timbre."""
|
|
ref = _ensure_ref()
|
|
out = _load_tts().generate(text=_respell(_calm(text)), prompt_audio_path=ref, prompt_text=REF_TEXT)
|
|
return _write_wav(out["audio"], out["sample_rate"])
|
|
|
|
|
|
def _write_wav(audio, sample_rate: int, path: str | None = None) -> str:
|
|
import numpy as np, soundfile as sf
|
|
if hasattr(audio, "detach"): # torch tensor (possibly on GPU)
|
|
audio = audio.detach().cpu().numpy()
|
|
a = np.asarray(audio, dtype="float32").squeeze() # (samples,) mono
|
|
if path is None:
|
|
path = f"{SHM}/tts_{uuid.uuid4().hex[:8]}.wav"
|
|
sf.write(path, a, sample_rate, subtype="PCM_16")
|
|
return path
|
|
|
|
|
|
def _loudnorm(path: str) -> str:
|
|
"""EBU R128 loudness-normalize so narration volume is even panel-to-panel (#14).
|
|
Returns a normalized path; on any ffmpeg failure returns the original (never lose audio)."""
|
|
sr = wave.open(path, "rb").getframerate()
|
|
out = f"{SHM}/ln_{uuid.uuid4().hex[:8]}.wav"
|
|
try:
|
|
subprocess.run(
|
|
["ffmpeg", "-y", "-i", path, "-af", "loudnorm=I=-16:TP=-1.5:LRA=11",
|
|
"-ar", str(sr), out],
|
|
check=True, capture_output=True,
|
|
)
|
|
os.replace(out, path)
|
|
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
|
log.warning("loudnorm skipped for %s: %r", path, e)
|
|
if os.path.exists(out):
|
|
os.remove(out)
|
|
return path
|
|
|
|
|
|
@app.post("/tts")
|
|
async def tts(data: TTSInput):
|
|
local = _loudnorm(_generate(data.text))
|
|
uri = _audio_uri(data)
|
|
transport.put(local, uri)
|
|
dur = _wav_duration(local)
|
|
os.remove(local)
|
|
return {"audio_uri": uri, "duration": dur}
|
|
|
|
|
|
@app.post("/unload")
|
|
async def unload():
|
|
"""free the resident dots.tts so the session manager can hand the GPU to the next model."""
|
|
global _tts
|
|
was = _tts is not None
|
|
_tts = None
|
|
import gc; gc.collect()
|
|
try:
|
|
import torch; torch.cuda.empty_cache()
|
|
except Exception:
|
|
pass
|
|
return {"ok": True, "unloaded": was}
|
|
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {"status": "ok"}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# self-check: _write_wav renders a sample array (1.0s @16k) to a PCM-16 wav that
|
|
# wave.open reads back at the right duration; then exercise upload + uri.
|
|
import math
|
|
|
|
class _FakeMC:
|
|
def __init__(self): self.store = {}
|
|
def bucket_exists(self, b): return True
|
|
def make_bucket(self, b): pass
|
|
def fput_object(self, b, k, path): self.store[(b, k)] = open(path, "rb").read()
|
|
|
|
samples = [0.3 * math.sin(i / 8) for i in range(16000)] # bare python list -> np.asarray
|
|
local = _write_wav(samples, 16000)
|
|
dur = _wav_duration(local)
|
|
uri = transport.put(local, "s3://manga/m/c/audio/p001.wav", client=_FakeMC())
|
|
assert uri.endswith("audio/p001.wav") and abs(dur - 1.0) < 0.01, (uri, dur)
|
|
os.remove(local)
|
|
explicit = f"{SHM}/tts_selfcheck_ref.wav" # _write_wav honors an explicit path (ref clip)
|
|
assert _write_wav(samples, 16000, explicit) == explicit and os.path.exists(explicit)
|
|
os.remove(explicit)
|
|
assert _calm("Stop!! Now!") == "Stop. Now." and _calm("no bangs") == "no bangs" # even prosody
|
|
|
|
# _audio_uri: a panel uri puts the wav beside its chapter, no panel uri falls back to the flat key.
|
|
assert _audio_uri(TTSInput(text="x", panel_id="p003",
|
|
panel_uri="s3://panels/m1/c1/panels/p003.png")) \
|
|
== "s3://audio/m1/c1/audio/p003.wav"
|
|
assert _audio_uri(TTSInput(text="x", panel_id="p003")) == "s3://audio/_audio/p003.wav"
|
|
assert _audio_uri(TTSInput(text="x")) == "s3://audio/_audio/p.wav"
|
|
|
|
# 158: pronunciation lexicon respells whole words only, case-insensitive, spoken text only.
|
|
import tempfile, json as _json
|
|
globals()["LEXICON_PATH"] = os.path.join(tempfile.mkdtemp(), "lex.json")
|
|
with open(LEXICON_PATH, "w") as f:
|
|
_json.dump({"Najimi": "nah-jee-mee", "Choi Haeseon": "chwe hae-son"}, f)
|
|
_lexicon.cache_clear()
|
|
assert _respell("Then Najimi ran.") == "Then nah-jee-mee ran." # single name
|
|
assert _respell("with najimi today") == "with nah-jee-mee today" # case-insensitive
|
|
assert _respell("Najimist stays") == "Najimist stays" # word boundary (no substring)
|
|
assert _respell("Choi Haeseon smiled") == "chwe hae-son smiled" # multi-word term
|
|
os.remove(LEXICON_PATH); _lexicon.cache_clear()
|
|
assert _respell("Najimi ran.") == "Najimi ran." # no file -> passthrough
|
|
|
|
# loudnorm: with ffmpeg present the wav is normalized in place and stays readable at its sr;
|
|
# without ffmpeg it's a safe no-op returning the same path (audio never lost).
|
|
ln = _write_wav(samples, 16000)
|
|
have_ffmpeg = subprocess.run(["ffmpeg", "-version"], capture_output=True, check=False).returncode == 0 \
|
|
if __import__("shutil").which("ffmpeg") else False
|
|
assert _loudnorm(ln) == ln and os.path.exists(ln)
|
|
assert wave.open(ln, "rb").getframerate() == 16000
|
|
os.remove(ln)
|
|
print("worker_tts self-check ok" + ("" if have_ffmpeg else " (ffmpeg absent, loudnorm no-op tested)"))
|