Files
esh-pfi-infrastructure/stacks/dots-tts/app.py
T
vh 10d379db5b 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).
2026-08-10 09:11:56 -07:00

206 lines
7.1 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""OpenAI-compatible /v1/audio/speech server over dots.tts (rednote-hilab).
Thin wrapper around DotsTtsRuntime — chosen over SGLang Omni because Omni's edge
(continuous batching) is MeanFlow-only and unneeded for a single-consumer surface,
while the raw runtime with optimize=True already streams at RTF ~0.22 on our 3090.
Voice registry: every <name>.wav (+ optional <name>.txt transcript) under
DOTS_VOICES_DIR becomes a callable voice. dots.tts REQUIRES an accurate,
sentence-bounded transcript to clone cleanly (see the voices/ corpus) — the .txt
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
import numpy as np
from fastapi import FastAPI, HTTPException
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel
from dots_tts.runtime import DotsTtsRuntime
MODEL = os.environ.get("DOTS_MODEL", "dots-studio/dots.tts-soar")
VOICES_DIR = os.environ.get("DOTS_VOICES_DIR", "/voices")
DEFAULT_VOICE = os.environ.get("DOTS_DEFAULT_VOICE", "donut")
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 = {}
# One DotsTtsRuntime, and it is NOT safe to call concurrently (CUDA-graph capture
# + shared state). uvicorn runs sync endpoints in a threadpool, so we must
# serialize generation ourselves: requests queue and run one at a time. This is
# the deliberate trade for the thin-wrapper design — no vLLM-style continuous
# batching. If concurrency demand appears, swap the backend to SGLang Omni + the
# mf variant behind this same API (see README).
_gen_lock = threading.Lock()
def _load_voices() -> dict:
reg = {}
for wav in sorted(glob.glob(os.path.join(VOICES_DIR, "*.wav"))):
name = os.path.splitext(os.path.basename(wav))[0]
txt = os.path.splitext(wav)[0] + ".txt"
reg[name] = {
"wav": wav,
"text": open(txt).read().strip() if os.path.exists(txt) else "",
}
return reg
@app.on_event("startup")
def _startup():
global _rt, _voices
_voices = _load_voices()
_rt = DotsTtsRuntime.from_pretrained(MODEL, precision="bfloat16", optimize=True)
@app.get("/health")
def health():
return {
"status": "ok" if _rt is not None else "loading",
"model": MODEL,
"sample_rate": SAMPLE_RATE,
"voices": sorted(_voices),
}
@app.get("/v1/voices")
def list_voices():
return {"voices": sorted(_voices)}
class SpeechRequest(BaseModel):
input: str
voice: str = DEFAULT_VOICE
model: str | None = None # accepted, ignored (single served model)
response_format: str = "wav" # wav | pcm
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()
def _wav_bytes(pcm: bytes) -> bytes:
buf = io.BytesIO()
w = wave.open(buf, "wb")
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(SAMPLE_RATE)
w.writeframes(pcm)
w.close()
return buf.getvalue()
def _streaming_wav_header() -> bytes:
"""WAV header with placeholder (max) sizes — lets a client start playing the
stream before the total length is known (the pattern the Zonos/chatterbox
consumers already expect)."""
return (
b"RIFF" + struct.pack("<I", 0xFFFFFFFF) + b"WAVE"
+ b"fmt " + struct.pack("<IHHIIHH", 16, 1, 1, SAMPLE_RATE, SAMPLE_RATE * 2, 2, 16)
+ b"data" + struct.pack("<I", 0xFFFFFFFF)
)
@app.post("/v1/audio/speech")
def speech(req: SpeechRequest):
rt = _rt
if rt is None:
raise HTTPException(503, "model still loading")
if req.voice not in _voices:
raise HTTPException(404, f"unknown voice '{req.voice}'; have {sorted(_voices)}")
if not req.input.strip():
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"],
num_steps=NUM_STEPS,
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():
# Hold the lock for the whole stream — a second generation on the
# shared runtime mid-stream would corrupt both.
with _gen_lock:
yield _streaming_wav_header()
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:
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")