From a80f6e958f83e168ee80c3b8b31faecd82e5a319 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Mon, 10 Aug 2026 21:54:35 -0700 Subject: [PATCH] =?UTF-8?q?fix(dots-tts):=20v3=20=E2=80=94=20clause-break?= =?UTF-8?q?=20(;=20:=20em-dash)=20=E2=86=92=20period=20pause=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dots' prosody honors a pause only for ellipsis (~+0.43s) and period (~+0.3s); comma/semicolon/colon/dash all run flat (~+0.03s vs no-punct), measured via a duration-over-N-runs pause probe against the live service. Two sub-causes for the flat clause reads: em-dashes regressed in v2 (the —→- fold made them read as word-joiners), and semicolons were never honored by dots at all. Operator ruled ellipsis "too much" → map semicolon, clause colon, and em-dash to a period in _sanitize (believable ~0.3s clause pause). Guards, pinned by tests: digit-guarded colon so times (3:45) and ratios (2:1) keep their colon; en-dash kept folding to hyphen so numeric ranges (10–20) don't become "10.20"; a genuine ellipsis retains its strong pause. Deployed to irv-ml1:8198 as local/dots-tts:v3 via the redeploy2 build → :8199-test → pause-gate → cutover pattern (gate measured +0.427s, live healthy). --- persistent-memory.md | 4 +- stacks/dots-tts/app.py | 14 +++++- stacks/dots-tts/test_sanitize.py | 73 ++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 stacks/dots-tts/test_sanitize.py diff --git a/persistent-memory.md b/persistent-memory.md index e46281b..49bb90f 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -111,7 +111,7 @@ no longer deployed sidecars here. See Recent decisions.) _As of 2026-08-10 — long session; the dots.tts TTS-engine + LFM2.5 arcs LANDED (full detail per arc in Recent decisions). Genuinely in-flight is thin:_ -- **dots.tts is the new fleet RP-TTS engine — LIVE + consumer-confirmed.** v2 on irv-ml1:8198 (curly-sanitize + sentence-chunking), ratatoskr migrated + verified clean end-to-end; chatterbox-fast RECLAIMED (:8197 stopped, revive `docker start chatterbox-fast`). Canonical voice corpus in `voices/`. ⚠ WATCH: deployed container GPU creeps ~6→14GB/8h (cache accumulation; a redeploy resets it); the **pinned Dockerfile is the only rebuild path** until upstream fixes the phantom `gradio==6.17.0` constraint. Full arc → Recent decisions. +- **dots.tts is the new fleet RP-TTS engine — LIVE + consumer-confirmed.** v3 on irv-ml1:8198 (curly-sanitize + sentence-chunking + clause-break→period pause mapping), ratatoskr migrated + verified clean end-to-end; chatterbox-fast RECLAIMED (:8197 stopped, revive `docker start chatterbox-fast`). Canonical voice corpus in `voices/`. ⚠ WATCH: deployed container GPU creeps ~6→14GB/8h (cache accumulation; a redeploy resets it); the **pinned Dockerfile is the only rebuild path** until upstream fixes the phantom `gradio==6.17.0` constraint. Full arc → Recent decisions. - **LFM2.5-2.6b bake-off alias for brokkr — DELIVERED, awaiting his bench.** `vllm-lfm25` ana-ml2:8021 + LiteLLM `lfm2.5-2.6b` (deepseek_r1 reasoning-parser, vendor sampling); PRODUCTION-CLEARED (operator .wav` + `transcripts/.txt` → per-engine ref sets DERIVED by `derive.py` reading `engines.yaml` profiles (dots/chatterbox/zonos); canonical wavs git-tracked (small/curated), `derived/` gitignored. **4 voices optimized + verified CLEAN for dots: donut, glados, emmie, miranda** (glados canonical is low-SR 16kHz — flagged upgrade candidate). ⚠ GPU GOTCHA: irv-ml1 native CUDA orders **A6000=device0** (ComfyUI-full) — pin the 3090 with `CUDA_DEVICE_ORDER=PCI_BUS_ID CUDA_VISIBLE_DEVICES=0`; and `PYTORCH_CUDA_ALLOC_CONF=expandable_segments` CONFLICTS with `optimize=True` CUDA graphs (curr_block error). Booths: `dots-vs-chatterbox`, `dots-voices-optimized`. **SHIPPED 2026-08-10:** operator A/B verdict "dots is very good" → containerized as a **thin FastAPI wrapper over DotsTtsRuntime** (chosen over SGLang Omni — Omni's batching is mf-only, unneeded for ratatoskr's single consumer; wrapper is SERIALIZED one-gen-at-a-time via a threading.Lock, Omni+mf = parked API-compatible escalation if multi-consumer ever lands). **LIVE on irv-ml1:8198** (`local/dots-tts:v1`, OpenAI `/v1/audio/speech` + `/health` + `/v1/voices`, container healthy, both stream + non-stream verified CLEAN, 4 voices donut/glados/emmie/miranda) alongside chatterbox :8197 (nothing repointed). Stack = `stacks/dots-tts/` (Dockerfile/app.py/compose/.env.example/README). ⚠ CONTAINER GOTCHA: `optimize=True` (torch.compile/inductor/triton) needs a **C compiler at RUNTIME** — slim image must `apt install build-essential` or model-load dies "Failed to find C compiler" (host venv had gcc ambient, masking it); persist `TORCHINDUCTOR_CACHE_DIR` to a mounted dir or every restart re-JITs ~5min. Corpus home = eshpfi `voices/` (operator ruled keep-here). **REMAINING: ratatoskr client cutover** to :8198 `/v1/audio/speech` (Phase-2 tail, peer-coupled — draft the ask). [[reference_chatterbox_fast_repo]] [[reference_zonos_tts_stack]] [[reference_verify_hf_repo_ids_before_pull]] diff --git a/stacks/dots-tts/app.py b/stacks/dots-tts/app.py index 45e7383..ff9dbe7 100644 --- a/stacks/dots-tts/app.py +++ b/stacks/dots-tts/app.py @@ -37,7 +37,7 @@ SAMPLE_RATE = 48000 # dots.tts fixed native output # expansion); the sanitize just removes the curly trigger the model chokes on. CURLY_MAP = str.maketrans({ "’": "'", "‘": "'", "“": '"', "”": '"', - "—": "-", "–": "-", "…": "...", " ": " ", + "–": "-", "…": "...", " ": " ", }) # dots caps a single generate() at ~500 audio patches (~40s). Long turns (RP # monologues) truncate without chunking, so split into <=CHUNK_MAX_CHARS pieces @@ -100,7 +100,17 @@ class SpeechRequest(BaseModel): def _sanitize(text: str) -> str: - return text.translate(CURLY_MAP) + """Fold curly punctuation to ASCII, then map the clause breaks dots runs flat + on (semicolon, clause colon, em-dash — each measured ~+0.03s vs no pause) to a + period, which dots honors as a believable ~0.3s pause (ellipsis, at ~+0.43s, + read as too much). Number contexts are guarded: times (3:45) and ratios (2:1) + keep their colon, and en-dash ranges (folded to hyphen in CURLY_MAP) never + become "10.20".""" + text = text.translate(CURLY_MAP) + text = re.sub(r"\s*;\s*", ". ", text) # semicolon -> period + text = re.sub(r"(? list: diff --git a/stacks/dots-tts/test_sanitize.py b/stacks/dots-tts/test_sanitize.py new file mode 100644 index 0000000..9d4325a --- /dev/null +++ b/stacks/dots-tts/test_sanitize.py @@ -0,0 +1,73 @@ +"""Unit tests for _sanitize — the text pre-fold dots.tts sees before synth. + +app.py imports dots_tts.runtime at module load (heavy, GPU-only), so we stub it +before import; _sanitize itself is pure and needs no model. + +Behavior under test (v3 clause-pause mapping): + * dots runs flat on ; : and em-dash (measured ~+0.03s vs none); a period gives + a believable ~0.3s clause pause. So map those clause breaks -> period. + * Guard number contexts: times (3:45) and ratios (2:1) keep their colon; + en-dash ranges (10-20) must NOT become "10.20". En-dash -> hyphen (as v2). + * Curly-apostrophe fix (the v2 reason this map exists) stays intact. + * A genuine ellipsis keeps its strong pause (-> "..."). +""" +import sys +import types + +# Stub the GPU-only runtime import so app.py loads on a CPU test box. +_stub = types.ModuleType("dots_tts.runtime") +_stub.DotsTtsRuntime = object # type: ignore[attr-defined] +sys.modules.setdefault("dots_tts", types.ModuleType("dots_tts")) +sys.modules["dots_tts.runtime"] = _stub + +import app # noqa: E402 + +s = app._sanitize + + +def test_semicolon_becomes_period(): + assert s("I waited; you left") == "I waited. you left" + + +def test_clause_colon_becomes_period(): + assert s("the truth: nobody knew") == "the truth. nobody knew" + + +def test_time_colon_preserved(): + # 3:45 must not become 3.45 ("three point four five") + assert "3:45" in s("meet me at 3:45 sharp") + + +def test_ratio_colon_preserved(): + assert "2:1" in s("the odds were 2:1 against") + + +def test_em_dash_becomes_period_spaced(): + assert s("you came — how touching") == "you came. how touching" + + +def test_em_dash_becomes_period_unspaced(): + assert s("you came—how touching") == "you came. how touching" + + +def test_en_dash_range_preserved_not_period(): + out = s("wait 10–20 minutes") + assert "10.20" not in out # the corruption we're guarding against + assert "10-20" in out # en-dash folds to hyphen (v2 behavior) + + +def test_curly_apostrophe_folds(): + # the original v2 bug: curly ' made "Donut's" -> "donut ess" + assert s("Donut’s treat") == "Donut's treat" + + +def test_curly_quotes_fold(): + assert s("“hi” there") == '"hi" there' + + +def test_ellipsis_keeps_strong_pause(): + assert s("wait… now") == "wait... now" + + +def test_no_doubled_spaces_introduced(): + assert " " not in s("a ; b : c — d")