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:
@@ -1,7 +1,10 @@
|
||||
# dots-tts stack tunables. Copy to `.env` on irv-ml1 before deploying.
|
||||
|
||||
# ── 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 ──────────────────────────────────────────────────────────
|
||||
DOTS_BIND=0.0.0.0
|
||||
@@ -17,6 +20,7 @@ DOTS_MODEL=dots-studio/dots.tts-soar
|
||||
DOTS_DEFAULT_VOICE=donut
|
||||
DOTS_NUM_STEPS=10 # 10 = full quality @ RTF ~0.22; lower = faster/rougher
|
||||
DOTS_GUIDANCE_SCALE=1.2
|
||||
DOTS_CHUNK_MAX_CHARS=280 # max chars per generate() chunk (dots caps ~40s/~500 patches)
|
||||
|
||||
# ── host mounts ──────────────────────────────────────────────────────
|
||||
# HF cache holding the downloaded soar snapshot (~5GB). Reuse the burn-in cache.
|
||||
|
||||
@@ -12,11 +12,14 @@ RUN pip install --no-cache-dir uv
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Pin torch + deps to dots.tts upstream recommended constraints (the same set the
|
||||
# irv-ml1 venv installed against). ADD caches on the URL contents.
|
||||
ADD https://raw.githubusercontent.com/rednote-hilab/dots.tts/main/constraints/recommended.txt /tmp/rec.txt
|
||||
RUN uv pip install --system -c /tmp/rec.txt \
|
||||
dots.tts soundfile fastapi "uvicorn[standard]"
|
||||
# Pin the proven-working version set (captured from the running v1 image).
|
||||
# NOT using upstream constraints/recommended.txt: as of 2026-08-10 it pins
|
||||
# gradio==6.17.0, which does not exist on PyPI and makes a fresh resolve
|
||||
# unsatisfiable (upstream regression). dots.tts 0.2.1 pulls a working gradio
|
||||
# (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 /
|
||||
# inductor / triton, which JIT-compile kernels via gcc on model load. Without it
|
||||
|
||||
+62
-4
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user