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
+62 -4
View File
@@ -12,6 +12,7 @@ is that transcript; without it the model leaks reference audio into the output.
import io
import os
import glob
import re
import struct
import threading
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"))
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")
_rt = None
_voices: dict = {}
@@ -84,6 +99,39 @@ class SpeechRequest(BaseModel):
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:
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")
v = _voices[req.voice]
text = _sanitize(req.input)
chunks = _chunk(text)
kw = dict(
prompt_audio_path=v["wav"],
prompt_text=v["text"],
@@ -128,6 +178,7 @@ def speech(req: SpeechRequest):
guidance_scale=GUIDANCE,
normalize_text=True,
)
gap = np.zeros(int(0.08 * SAMPLE_RATE), dtype=np.float32) # 80ms seam between chunks
if req.stream:
def gen():
@@ -135,13 +186,20 @@ def speech(req: SpeechRequest):
# shared runtime mid-stream would corrupt both.
with _gen_lock:
yield _streaming_wav_header()
for chunk in rt.generate_stream(text=req.input, **kw):
yield _to_pcm16(chunk.float().cpu().squeeze().numpy())
for i, ch in enumerate(chunks):
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")
with _gen_lock:
res = rt.generate(text=req.input, **kw)
pcm = _to_pcm16(res["audio"].float().cpu().squeeze().numpy())
parts = []
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":
return Response(pcm, media_type="audio/L16;rate=48000")
return Response(_wav_bytes(pcm), media_type="audio/wav")