fix(dots-tts): v2 — curly-punctuation sanitize + sentence-chunking

Curly apostrophes (ratatoskr's LLM emits typographic punctuation) made dots
mispronounce contractions ("Donut's"->"donut ess"); fold curly->ASCII before
synth, keep normalize_text on. Add server-side sentence-chunking so long turns
stop truncating at dots' ~40s single-generate cap (verified full 160s Zev).
Dockerfile: pin dots.tts==0.2.1 + torch/torchaudio==2.8.0 (upstream constraints
now pin a phantom gradio==6.17.0; float torchaudio->2.11.0 crashes the load).
This commit is contained in:
vh
2026-08-10 09:11:56 -07:00
parent d3727dee53
commit 10d379db5b
4 changed files with 77 additions and 10 deletions
+2
View File
@@ -130,6 +130,8 @@ _As of 2026-08-08 — long session; all major arcs LANDED (full detail per arc i
## Recent decisions ## Recent decisions
- `[2026-08-10]` **dots-tts v2 — contraction fix (curly-sanitize) + sentence-chunking + dependency-pin recovery.** Operator: donut read contractions wrong ("you're"→"you ree", "donut's"→"donut ess"). ROOT CAUSE (isolated via A/B booth): **curly/typographic apostrophes** (`’` U+2019 from ratatoskr's LLM) — dots' tokenizer mispronounces them; STRAIGHT apostrophes read clean under `normalize_text=True`. FIX (`app.py`): fold curly→ASCII (`str.maketrans`) before synth, **KEEP `normalize_text=True`** (operator call — retains number/date expansion). Also added **server-side sentence-chunking** (pack ≤280 chars): dots caps one `generate()` at ~500 patches/~40s, so long RP turns (the Zev monologue = 160s audio) truncated; chunking stitches them (verified full 160.3s, not 40s-cut). **⚠ BUILD FOOT-GUNS (both bit this redeploy):** (1) upstream dots.tts `constraints/recommended.txt` now pins **`gradio==6.17.0` — phantom, not on PyPI** → fresh `pip install dots.tts` unsatisfiable; FIX = pin `dots.tts==0.2.1` + **DROP** the `-c recommended.txt` constraints (0.2.1 pulls working gradio 6.17.3). (2) pinning only `torch==2.8.0` let **torchaudio float to 2.11.0 → dots.tts refuses to load** (minor-version match check); FIX = pin `torchaudio==2.8.0`. **⚠ DEPLOY LESSON:** `docker compose up -d` to a new tag swaps the LIVE container BEFORE any health check — a broken image crash-loops production (**ratatoskr TTS down ~1-2min this session**). NEW PATTERN = build → test in a THROWAWAY container on an alt port (:8199) → health+verify → only THEN cut live over (redeploy2.sh). v2 LIVE + healthy on irv-ml1:8198; **rollback = `sed DOTS_TAG=v1 + docker compose up -d dots-tts`** (v1 image retained). Also: deployed container GPU crept ~6→13.9GB over 8h serving (cache accumulation; a redeploy resets it — watch item). [[reference_chatterbox_fast_repo]]
- `[2026-08-09→10]` **dots.tts (rednote-hilab) TTS burn-in on irv-ml1 + canonical voice corpus built (`voices/`).** Operator-directed eval to potentially replace chatterbox-fast. **dots.tts VERIFIED real** (canonical HF ns `dots-studio/`, `rednote-hilab/dots.tts-*` redirects there; Apache-2.0; PyPI `dots.tts` 0.2.1; 2B continuous-AR = semantic enc + Qwen2.5-1.5B LLM + flow-matching acoustic head over 48kHz AudioVAE; zero-shot clone from wav+transcript). **Runs on Ampere 3090** (sm_86, bf16, no fp8 dep); **optimized RTF 0.22** at num_steps=10 (`from_pretrained(..., optimize=True)` CUDA graphs — raw unoptimized was 1.21), **~6GB VRAM**, 48kHz, streams (`generate_stream`). Venv+cache at `irv-ml1:/home/lkraven/dots-tts` (~10GB). **Operator design calls:** SGLang Omni serving (OpenAI `/v1/audio/speech`), transcribe-refs-first, `soar` variant. ⚠ Omni serves soar but its continuous-batching + streaming opts are **mf-only** (soar = single-request) — non-issue for ratatoskr's single-consumer RP surface. **KEY FINDING — dots is highly sensitive to an accurate AND sentence-bounded reference transcript:** mismatched transcript → 0.16s collapse; over-long/messy transcript → reference-audio BLEEDS as an output prefix; mid-clause trim → dangling-word leak (glados "we'll", emmie "And,"). RECIPE (baked into `voices/derive.py`): trim ref to a clean ~6–10s clip ending on a sentence boundary + accurate transcript of exactly that clip. **CANONICAL VOICE CORPUS** stood up in eshpfi `voices/` (operator idea): engine-agnostic `canonical/<v>.wav` + `transcripts/<v>.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]] - `[2026-08-09→10]` **dots.tts (rednote-hilab) TTS burn-in on irv-ml1 + canonical voice corpus built (`voices/`).** Operator-directed eval to potentially replace chatterbox-fast. **dots.tts VERIFIED real** (canonical HF ns `dots-studio/`, `rednote-hilab/dots.tts-*` redirects there; Apache-2.0; PyPI `dots.tts` 0.2.1; 2B continuous-AR = semantic enc + Qwen2.5-1.5B LLM + flow-matching acoustic head over 48kHz AudioVAE; zero-shot clone from wav+transcript). **Runs on Ampere 3090** (sm_86, bf16, no fp8 dep); **optimized RTF 0.22** at num_steps=10 (`from_pretrained(..., optimize=True)` CUDA graphs — raw unoptimized was 1.21), **~6GB VRAM**, 48kHz, streams (`generate_stream`). Venv+cache at `irv-ml1:/home/lkraven/dots-tts` (~10GB). **Operator design calls:** SGLang Omni serving (OpenAI `/v1/audio/speech`), transcribe-refs-first, `soar` variant. ⚠ Omni serves soar but its continuous-batching + streaming opts are **mf-only** (soar = single-request) — non-issue for ratatoskr's single-consumer RP surface. **KEY FINDING — dots is highly sensitive to an accurate AND sentence-bounded reference transcript:** mismatched transcript → 0.16s collapse; over-long/messy transcript → reference-audio BLEEDS as an output prefix; mid-clause trim → dangling-word leak (glados "we'll", emmie "And,"). RECIPE (baked into `voices/derive.py`): trim ref to a clean ~6–10s clip ending on a sentence boundary + accurate transcript of exactly that clip. **CANONICAL VOICE CORPUS** stood up in eshpfi `voices/` (operator idea): engine-agnostic `canonical/<v>.wav` + `transcripts/<v>.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]]
- `[2026-08-08]` **worldtree-dev #400 CLOSED → fiction-decomp snapshot cleared from nh3-dev.** worldtree-dev signaled #400 done (shipped v1.0.0b185; exact-lexical efficacy 79%→12% on ratatoskr's gate, brokkr no-harm bracket green both ends; the snapshot served 4 probe rounds — rank decomposition, promoted-vs-gold annotation, tie-set falsification, A0/A1/A2 mechanism probe). Cleared `~/snapshots/worldtree-400-fiction-decomp` (208M: chroma + manifest/provenance/stamp) — a read-only rsync copy of PERSONAL Worldtree's Chroma (source on corviduo-dev, so safe to remove). **LEFT INTACT:** `rex393-fiction-index`/`rex393-fiction-snapshot` (separate operator KEEP word, unchanged) + `r42-gate-*`. No config deltas rode this train. Only remaining non-blocking await = ratatoskr-dev's chatterbox-fast knob revert. Replied confirming (`01KZJ9GMCC…`). - `[2026-08-08]` **worldtree-dev #400 CLOSED → fiction-decomp snapshot cleared from nh3-dev.** worldtree-dev signaled #400 done (shipped v1.0.0b185; exact-lexical efficacy 79%→12% on ratatoskr's gate, brokkr no-harm bracket green both ends; the snapshot served 4 probe rounds — rank decomposition, promoted-vs-gold annotation, tie-set falsification, A0/A1/A2 mechanism probe). Cleared `~/snapshots/worldtree-400-fiction-decomp` (208M: chroma + manifest/provenance/stamp) — a read-only rsync copy of PERSONAL Worldtree's Chroma (source on corviduo-dev, so safe to remove). **LEFT INTACT:** `rex393-fiction-index`/`rex393-fiction-snapshot` (separate operator KEEP word, unchanged) + `r42-gate-*`. No config deltas rode this train. Only remaining non-blocking await = ratatoskr-dev's chatterbox-fast knob revert. Replied confirming (`01KZJ9GMCC…`).
+5 -1
View File
@@ -1,7 +1,10 @@
# dots-tts stack tunables. Copy to `.env` on irv-ml1 before deploying. # dots-tts stack tunables. Copy to `.env` on irv-ml1 before deploying.
# ── image ──────────────────────────────────────────────────────────── # ── image ────────────────────────────────────────────────────────────
DOTS_TAG=v1 # v2 (2026-08-10): curly->ASCII sanitize (fixes "Donut's"->"donut ess" on
# typographic apostrophes) + server-side sentence-chunking (long turns no longer
# truncate at dots' ~40s single-generate cap).
DOTS_TAG=v2
# ── network ────────────────────────────────────────────────────────── # ── network ──────────────────────────────────────────────────────────
DOTS_BIND=0.0.0.0 DOTS_BIND=0.0.0.0
@@ -17,6 +20,7 @@ DOTS_MODEL=dots-studio/dots.tts-soar
DOTS_DEFAULT_VOICE=donut DOTS_DEFAULT_VOICE=donut
DOTS_NUM_STEPS=10 # 10 = full quality @ RTF ~0.22; lower = faster/rougher DOTS_NUM_STEPS=10 # 10 = full quality @ RTF ~0.22; lower = faster/rougher
DOTS_GUIDANCE_SCALE=1.2 DOTS_GUIDANCE_SCALE=1.2
DOTS_CHUNK_MAX_CHARS=280 # max chars per generate() chunk (dots caps ~40s/~500 patches)
# ── host mounts ────────────────────────────────────────────────────── # ── host mounts ──────────────────────────────────────────────────────
# HF cache holding the downloaded soar snapshot (~5GB). Reuse the burn-in cache. # HF cache holding the downloaded soar snapshot (~5GB). Reuse the burn-in cache.
+8 -5
View File
@@ -12,11 +12,14 @@ RUN pip install --no-cache-dir uv
WORKDIR /app WORKDIR /app
# Pin torch + deps to dots.tts upstream recommended constraints (the same set the # Pin the proven-working version set (captured from the running v1 image).
# irv-ml1 venv installed against). ADD caches on the URL contents. # NOT using upstream constraints/recommended.txt: as of 2026-08-10 it pins
ADD https://raw.githubusercontent.com/rednote-hilab/dots.tts/main/constraints/recommended.txt /tmp/rec.txt # gradio==6.17.0, which does not exist on PyPI and makes a fresh resolve
RUN uv pip install --system -c /tmp/rec.txt \ # unsatisfiable (upstream regression). dots.tts 0.2.1 pulls a working gradio
dots.tts soundfile fastapi "uvicorn[standard]" # (6.17.3) on its own; torch/numpy/soundfile pinned to the v1-image versions.
RUN uv pip install --system \
dots.tts==0.2.1 torch==2.8.0 torchaudio==2.8.0 numpy==2.2.6 soundfile==0.13.1 \
fastapi "uvicorn[standard]"
# C compiler for the RUNTIME (not build): optimize=True drives torch.compile / # C compiler for the RUNTIME (not build): optimize=True drives torch.compile /
# inductor / triton, which JIT-compile kernels via gcc on model load. Without it # inductor / triton, which JIT-compile kernels via gcc on model load. Without it
+62 -4
View File
@@ -12,6 +12,7 @@ is that transcript; without it the model leaks reference audio into the output.
import io import io
import os import os
import glob import glob
import re
import struct import struct
import threading import threading
import wave import wave
@@ -30,6 +31,20 @@ NUM_STEPS = int(os.environ.get("DOTS_NUM_STEPS", "10"))
GUIDANCE = float(os.environ.get("DOTS_GUIDANCE_SCALE", "1.2")) GUIDANCE = float(os.environ.get("DOTS_GUIDANCE_SCALE", "1.2"))
SAMPLE_RATE = 48000 # dots.tts fixed native output SAMPLE_RATE = 48000 # dots.tts fixed native output
# ratatoskr's LLM emits typographic (curly) punctuation, and dots' tokenizer
# mispronounces curly apostrophes ("Donut's" -> "donut ess"). Fold curly -> ASCII
# before synthesis. normalize_text stays ON (operator call — keeps number/date
# 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
# on sentence (then clause) boundaries and stitch. A short input is one chunk =
# unchanged behavior.
CHUNK_MAX_CHARS = int(os.environ.get("DOTS_CHUNK_MAX_CHARS", "280"))
app = FastAPI(title="dots.tts") app = FastAPI(title="dots.tts")
_rt = None _rt = None
_voices: dict = {} _voices: dict = {}
@@ -84,6 +99,39 @@ class SpeechRequest(BaseModel):
stream: bool = False stream: bool = False
def _sanitize(text: str) -> str:
return text.translate(CURLY_MAP)
def _chunk(text: str, max_chars: int = CHUNK_MAX_CHARS) -> list:
"""Pack sentences into <=max_chars chunks (sub-splitting an over-long sentence
on commas) so each generate() stays under dots' ~40s cap. One chunk for short
input."""
text = text.strip()
if len(text) <= max_chars:
return [text]
sentences = re.split(r"(?<=[.!?])\s+", text)
chunks, cur = [], ""
for s in sentences:
s = s.strip()
if not s:
continue
pieces = [s]
if len(s) > max_chars: # rare: a single sentence over the cap
pieces = [p.strip() for p in re.split(r"(?<=,)\s+", s) if p.strip()]
for p in pieces:
if not cur:
cur = p
elif len(cur) + 1 + len(p) <= max_chars:
cur = cur + " " + p
else:
chunks.append(cur)
cur = p
if cur:
chunks.append(cur)
return chunks or [text]
def _to_pcm16(audio: np.ndarray) -> bytes: def _to_pcm16(audio: np.ndarray) -> bytes:
return np.round(np.clip(audio, -1.0, 1.0) * 32767.0).astype("<i2").tobytes() return np.round(np.clip(audio, -1.0, 1.0) * 32767.0).astype("<i2").tobytes()
@@ -121,6 +169,8 @@ def speech(req: SpeechRequest):
raise HTTPException(400, "empty input") raise HTTPException(400, "empty input")
v = _voices[req.voice] v = _voices[req.voice]
text = _sanitize(req.input)
chunks = _chunk(text)
kw = dict( kw = dict(
prompt_audio_path=v["wav"], prompt_audio_path=v["wav"],
prompt_text=v["text"], prompt_text=v["text"],
@@ -128,6 +178,7 @@ def speech(req: SpeechRequest):
guidance_scale=GUIDANCE, guidance_scale=GUIDANCE,
normalize_text=True, normalize_text=True,
) )
gap = np.zeros(int(0.08 * SAMPLE_RATE), dtype=np.float32) # 80ms seam between chunks
if req.stream: if req.stream:
def gen(): def gen():
@@ -135,13 +186,20 @@ def speech(req: SpeechRequest):
# shared runtime mid-stream would corrupt both. # shared runtime mid-stream would corrupt both.
with _gen_lock: with _gen_lock:
yield _streaming_wav_header() yield _streaming_wav_header()
for chunk in rt.generate_stream(text=req.input, **kw): for i, ch in enumerate(chunks):
yield _to_pcm16(chunk.float().cpu().squeeze().numpy()) if i:
yield _to_pcm16(gap)
for piece in rt.generate_stream(text=ch, **kw):
yield _to_pcm16(piece.float().cpu().squeeze().numpy())
return StreamingResponse(gen(), media_type="audio/wav") return StreamingResponse(gen(), media_type="audio/wav")
with _gen_lock: with _gen_lock:
res = rt.generate(text=req.input, **kw) parts = []
pcm = _to_pcm16(res["audio"].float().cpu().squeeze().numpy()) for i, ch in enumerate(chunks):
if i:
parts.append(gap)
parts.append(rt.generate(text=ch, **kw)["audio"].float().cpu().squeeze().numpy())
pcm = _to_pcm16(np.concatenate(parts))
if req.response_format == "pcm": if req.response_format == "pcm":
return Response(pcm, media_type="audio/L16;rate=48000") return Response(pcm, media_type="audio/L16;rate=48000")
return Response(_wav_bytes(pcm), media_type="audio/wav") return Response(_wav_bytes(pcm), media_type="audio/wav")