Files
manga-recap-pipeline/worker_tts.py
T
kami ff6a512630 Reconstruct repo from Claude Code + codex transcripts
Working tree (including .git) was lost to an rm. Rebuilt by replaying Write/Edit/
Read/attachment events from 25 Claude sessions and 22 successful codex apply_patch
blocks into one timestamp-ordered timeline.

Verified against ground truth recorded in the transcripts: wc -l on 10 files and
ls -l on 5 files at 2026-07-18T13:13:44Z both match exactly; 18 files are
byte-identical to their newest ~/.claude/file-history blob.

See HANDOFF.md for sources, gaps, and how to rebuild .venv.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 02:42:41 +04:00

232 lines
9.9 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})
)
from dots_tts.runtime import DotsTtsRuntime
_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:
parts = data.panel_uri.replace("s3://", "").split("/")
return f"s3://manga/{parts[1]}/{parts[2]}/audio/{data.panel_id or 'p'}.wav"
return f"s3://manga/_audio/{data.panel_id or 'p'}.wav"
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
# 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).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)"))