Files
vh 76314624bb
ci / test (push) Has been cancelled
chatterbox-fast v0.1.0
Sub-second streaming TTS on Chatterbox-Turbo via adaptive buffer-ratchet
chunking. First audio in ~0.5s (vs ~5s one-shot) with no quality compromise —
chunk joins land on natural sentence pauses and the stream converges to one
large near-full-context chunk within 2-3 joins. Works because the engine runs
faster than realtime; the no-starvation guarantee is proven in a GPU-free
simulation (tests/test_scheduler.py).

- chatterbox_fast/scheduler.py: the adaptive-chunk scheduler (pure logic, no GPU)
- chatterbox_fast/app.py: FastAPI server (POST /tts streaming, /voices, /health)
- bench.py: streaming client (ground-truth TTFB + starvation check)
- Self-contained Dockerfile (slim base + chatterbox-tts from PyPI)
- Three public-domain LibriVox starter voices baked in (see voices/ATTRIBUTION.md)

MIT licensed.
2026-06-02 10:43:21 -07:00

344 lines
14 KiB
Python

"""chatterbox-fast — streaming TTS server (Phase 1 MVP).
A lean FastAPI server on the ChatterboxTurboTTS library that streams audio using
the adaptive buffer-ratchet scheduler in ``scheduler.py`` (the meat). Sub-second
time-to-first-audio while keeping turbo's full quality; workload is single-stream
interactive (see docs/design/chatterbox-fast-plan.md).
Endpoints:
POST /tts — StreamingResponse of audio chunks (raw PCM s16le default).
GET /health — model/voice readiness.
Config via env (all optional; sane dev defaults):
CBF_MODEL_DEVICE cuda | cuda:0 | cpu (default: cuda)
CBF_VOICES_DIR dir of predefined voice wavs (default: /refs)
CBF_DEFAULT_VOICE default reference wav path/name (default: first wav in dir)
CBF_BIND / CBF_PORT uvicorn bind (default: 0.0.0.0:8197)
"""
from __future__ import annotations
import logging
import os
import struct
import threading
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Iterator, Literal
import torch
from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from chatterbox_fast.scheduler import ChunkConfig, ChunkResult, stream_chunks
log = logging.getLogger("chatterbox-fast")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
# ── config ────────────────────────────────────────────────────────────────
DEVICE = os.environ.get("CBF_MODEL_DEVICE", "cuda")
VOICES_DIR = Path(os.environ.get("CBF_VOICES_DIR", "/refs"))
DEFAULT_VOICE_ENV = os.environ.get("CBF_DEFAULT_VOICE")
BIND = os.environ.get("CBF_BIND", "0.0.0.0")
PORT = int(os.environ.get("CBF_PORT", "8197"))
# Perf levers (plan §4 Phase 2). TF32 + flash/mem-efficient SDPA are low-risk on
# Ampere and free — default ON. Measured 2026-06-02: they do NOT move TTFA, which
# is bound by the sequential AR token decode (T3 Llama at batch-1), not matmul
# throughput. bf16 (the lever that WOULD help batch-1 decode) is DEFERRED: turbo
# loads fp32 and from_pretrained() exposes no dtype arg, so bf16 needs whole-model
# casting incl. the speaker-conditioning path and the dtype-sensitive vocoder —
# real surgery + quality risk for a TTFA gain not currently needed (~0.5s is fine).
PERF_TF32 = os.environ.get("CBF_TF32", "1") == "1"
PERF_SDPA_FLASH = os.environ.get("CBF_SDPA_FLASH", "1") == "1"
# Turbo sampling knobs validated in the spike (plan §2). CFG / exaggeration /
# min_p are ignored by turbo (it warns, harmless).
WARMUP_TEXT = "Warming up the streaming engine."
def _predefined_wavs() -> list[Path]:
"""Predefined voice wavs in VOICES_DIR, excluding `_`-prefixed scratch files
(bench/A-B outputs land as `_*.wav` in the same dir)."""
if not VOICES_DIR.is_dir():
return []
return sorted(p for p in VOICES_DIR.glob("*.wav") if not p.name.startswith("_"))
def _setup_perf() -> None:
"""Apply the safe, low-risk speed levers before model load."""
if PERF_TF32:
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
log.info("perf: TF32 matmul/cudnn enabled")
if PERF_SDPA_FLASH and DEVICE.startswith("cuda"):
try:
torch.backends.cuda.enable_flash_sdp(True)
torch.backends.cuda.enable_mem_efficient_sdp(True)
log.info("perf: flash + mem-efficient SDPA enabled")
except Exception as e: # pragma: no cover - backend-dependent
log.warning("perf: SDPA toggle failed: %s", e)
# ── model holder ──────────────────────────────────────────────────────────
class Engine:
"""Owns the single ChatterboxTurboTTS instance + a generation lock.
The workload is single-stream interactive, but prepare_conditionals mutates
model state, so all generation is serialized under one lock. Concurrent
multi-voice serving is a Phase-2 concern.
"""
def __init__(self) -> None:
self.model = None
self.sr = 24000
self.lock = threading.Lock()
self._current_voice: str | None = None
self.default_voice: str | None = None
def load(self) -> None:
from chatterbox.tts_turbo import ChatterboxTurboTTS
_setup_perf()
log.info("loading ChatterboxTurboTTS on %s", DEVICE)
t0 = time.perf_counter()
self.model = ChatterboxTurboTTS.from_pretrained(device=DEVICE)
self.sr = int(getattr(self.model, "sr", 24000))
self._log_model_dtype()
self.default_voice = self._discover_default_voice()
if self.default_voice:
self._prepare(self.default_voice)
self._warm()
log.info(
"model ready in %.1fs (sr=%d, default_voice=%s)",
time.perf_counter() - t0, self.sr, self.default_voice,
)
def _log_model_dtype(self) -> None:
for name in ("t3", "s3gen", "model"):
sub = getattr(self.model, name, None)
try:
if sub is not None:
dt = next(sub.parameters()).dtype
log.info("dtype[%s]=%s", name, dt)
except (StopIteration, AttributeError):
pass
def _discover_default_voice(self) -> str | None:
if DEFAULT_VOICE_ENV:
# Resolve a bare name ("glados_25s") the same way request-time voices
# are resolved — append .wav and look under VOICES_DIR.
return self.resolve_voice(DEFAULT_VOICE_ENV)
wavs = _predefined_wavs()
return str(wavs[0]) if wavs else None
def resolve_voice(self, voice: str | None) -> str:
if not voice:
if not self.default_voice:
raise HTTPException(503, "no default voice; set CBF_DEFAULT_VOICE")
return self.default_voice
p = Path(voice)
if p.is_absolute() and p.exists():
return str(p)
cand = VOICES_DIR / (voice if voice.endswith(".wav") else f"{voice}.wav")
if cand.exists():
return str(cand)
raise HTTPException(404, f"unknown voice {voice!r}")
def _prepare(self, voice_path: str, exaggeration: float = 0.5) -> None:
if voice_path == self._current_voice:
return
log.info("prepare_conditionals(%s)", voice_path)
self.model.prepare_conditionals(voice_path, exaggeration=exaggeration, norm_loudness=True)
self._current_voice = voice_path
def _warm(self) -> None:
with torch.inference_mode():
self.model.generate(WARMUP_TEXT, repetition_penalty=1.2, top_p=0.95,
temperature=0.8, top_k=1000)
if DEVICE.startswith("cuda"):
torch.cuda.synchronize()
def generate(self, text: str, knobs: "TTSRequest") -> tuple[torch.Tensor, float]:
"""Synthesize ``text`` → (wav tensor [1,T], audio_seconds). CUDA-synced
so the caller's clock delta is honest gen time."""
with torch.inference_mode():
wav = self.model.generate(
text,
repetition_penalty=knobs.repetition_penalty,
top_p=knobs.top_p,
temperature=knobs.temperature,
top_k=knobs.top_k,
)
if DEVICE.startswith("cuda"):
torch.cuda.synchronize()
audio_sec = wav.shape[-1] / self.sr
return wav, audio_sec
engine = Engine()
@asynccontextmanager
async def lifespan(app: FastAPI):
engine.load()
yield
app = FastAPI(title="chatterbox-fast", lifespan=lifespan)
# ── request / audio encoding ──────────────────────────────────────────────
class TTSRequest(BaseModel):
text: str
voice: str | None = None
format: Literal["pcm", "wav"] = "pcm"
stream: bool = True # False ⇒ whole-text one-shot (for A/B vs streaming)
exaggeration: float = 0.5
temperature: float = 0.8
top_p: float = 0.95
top_k: int = 1000
repetition_penalty: float = 1.2
seed: int = 0 # 0 ⇒ random; a fixed seed repeats a one-shot take (see note below)
# Scheduler overrides (None ⇒ ChunkConfig defaults).
margin: float | None = Field(default=None)
margin_first: float | None = Field(default=None)
rtf_prior: float | None = Field(default=None)
def _pcm16(wav: torch.Tensor) -> bytes:
a = wav.detach().to(torch.float32).clamp_(-1.0, 1.0).cpu().numpy().reshape(-1)
return (a * 32767.0).astype("<i2").tobytes()
def _wav_header(sr: int, data_len: int | None = None) -> bytes:
"""WAV header. data_len=None → streaming (length unknown, 0xFFFFFFFF sizes,
player reads to EOF); an int → correct RIFF/data sizes for a complete file."""
data_size = 0xFFFFFFFF if data_len is None else data_len
riff_size = 0xFFFFFFFF if data_len is None else 36 + data_len
return (
b"RIFF" + struct.pack("<I", riff_size) + b"WAVE"
+ b"fmt " + struct.pack("<IHHIIHH", 16, 1, 1, sr, sr * 2, 2, 16)
+ b"data" + struct.pack("<I", data_size)
)
def _chunk_config(req: TTSRequest) -> ChunkConfig:
cfg = ChunkConfig()
if req.margin is not None:
cfg.margin = req.margin
if req.margin_first is not None:
cfg.margin_first = req.margin_first
if req.rtf_prior is not None:
cfg.rtf_prior = req.rtf_prior
return cfg
# ── endpoints ─────────────────────────────────────────────────────────────
@app.get("/health")
def health() -> dict:
return {
"status": "ok" if engine.model is not None else "loading",
"sr": engine.sr,
"device": DEVICE,
"default_voice": engine.default_voice,
"voices_dir": str(VOICES_DIR),
}
@app.get("/voices")
def voices() -> dict:
"""Predefined voices = the *.wav stems in CBF_VOICES_DIR. Clone refs are
passed per-request as an absolute path, so they're not listed here."""
names = [p.stem for p in _predefined_wavs()]
default = Path(engine.default_voice).stem if engine.default_voice else None
return {"voices": names, "default": default}
@app.post("/tts")
def tts(req: TTSRequest) -> StreamingResponse:
if engine.model is None:
raise HTTPException(503, "model still loading")
if not req.text.strip():
raise HTTPException(400, "empty text")
voice_path = engine.resolve_voice(req.voice)
cfg = _chunk_config(req)
media = "audio/wav" if req.format == "wav" else "application/octet-stream"
def body() -> Iterator[bytes]:
# One request holds the lock for its whole stream (single-stream
# workload); concurrent callers queue rather than corrupt conditionals.
with engine.lock:
engine._prepare(voice_path, exaggeration=req.exaggeration)
# Seed once per request (under the lock). One-shot is then reproducible
# for a fixed seed + params; streaming is NOT — adaptive-chunk boundaries
# depend on live-measured RTF (wall-clock), so chunk splits vary run to run.
if req.seed:
torch.manual_seed(req.seed)
if DEVICE.startswith("cuda"):
torch.cuda.manual_seed_all(req.seed)
t_req = time.perf_counter()
first_audio_ms: float | None = None
if not req.stream:
# One-shot: the full length is known, so emit a correct-sized WAV
# header (a buffered consumer wants well-formed sizes).
wav, audio_sec = engine.generate(req.text, req)
first_audio_ms = (time.perf_counter() - t_req) * 1000
log.info("oneshot: %.0fms gen, %.2fs audio", first_audio_ms, audio_sec)
pcm = _pcm16(wav)
if req.format == "wav":
yield _wav_header(engine.sr, len(pcm))
yield pcm
return
# Streaming: length is unknown up front → open-ended WAV header.
if req.format == "wav":
yield _wav_header(engine.sr)
def _gen(text: str) -> tuple[torch.Tensor, float]:
return engine.generate(text, req)
total_audio = 0.0
for r in stream_chunks(req.text, generate=_gen, clock=time.perf_counter, cfg=cfg):
if first_audio_ms is None:
first_audio_ms = (time.perf_counter() - t_req) * 1000
total_audio += r.audio_sec
_log_chunk(r, first_audio_ms)
yield _pcm16(r.audio)
log.info("stream done: ttfa=%.0fms total_audio=%.2fs", first_audio_ms or 0, total_audio)
return StreamingResponse(body(), media_type=media)
def _log_chunk(r: ChunkResult, ttfa_ms: float) -> None:
tag = " STARVED" if r.starved else ""
if r.index == 0:
log.info("chunk 0: ttfa=%.0fms gen=%.0fms audio=%.2fs rtf=%.2f%s",
ttfa_ms, r.gen_time * 1000, r.audio_sec, r.rtf, tag)
else:
log.info("chunk %d: gen=%.0fms audio=%.2fs buf=%.2f%.2f rtf=%.2f%s",
r.index, r.gen_time * 1000, r.audio_sec,
r.buffer_before, r.buffer_after, r.rtf, tag)
def main() -> None:
"""Console entrypoint (`chatterbox-fast`) and Docker CMD."""
import uvicorn
uvicorn.run(app, host=BIND, port=PORT)
if __name__ == "__main__":
main()