chatterbox-fast v0.1.0
ci / test (push) Has been cancelled

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.
This commit is contained in:
2026-06-02 10:35:16 -07:00
commit 76314624bb
17 changed files with 1288 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
# CI: run the GPU-free scheduler simulation on every push/PR. The no-starvation
# guarantee is validated here without any hardware.
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install pytest
- run: pytest -q
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
.pytest_cache/
.env
*.wav
!voices/*.wav
hf_cache/
+36
View File
@@ -0,0 +1,36 @@
# chatterbox-fast — self-contained image. No external/private base: a slim Python
# base + the public chatterbox-tts package on PyPI, which pulls a single
# consistent torch+torchaudio (CUDA wheels) — no torchvision, so none of the
# torch/torchvision version-pinning conflicts a PyTorch base image causes.
#
# The ~6 GB Chatterbox-Turbo weights are NOT baked — they download from
# HuggingFace into HF_HOME on first run. The starter VOICES are baked (see
# voices/) so a fresh container can synthesize out of the box.
FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg libsndfile1 \
&& rm -rf /var/lib/apt/lists/*
ENV HF_HOME=/app/hf_cache \
CBF_VOICES_DIR=/app/voices \
CBF_DEFAULT_VOICE=catharine \
CBF_MODEL_DEVICE=cuda \
CBF_BIND=0.0.0.0 \
CBF_PORT=8197 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY pyproject.toml README.md ./
COPY chatterbox_fast ./chatterbox_fast
COPY voices ./voices
# torch is already provided by the base image; pip resolves chatterbox-tts +
# the server deps against it.
RUN pip install --no-cache-dir .
EXPOSE 8197
HEALTHCHECK --interval=30s --timeout=10s --start-period=180s --retries=3 \
CMD python -c "import urllib.request,sys; b=urllib.request.urlopen('http://127.0.0.1:8197/health',timeout=5).read(); sys.exit(0 if b'\"status\":\"ok\"' in b.replace(b' ',b'') else 1)"
CMD ["chatterbox-fast"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Vuong Hoang
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+182
View File
@@ -0,0 +1,182 @@
# chatterbox-fast
**Sub-second streaming text-to-speech on [Chatterbox-Turbo](https://github.com/resemble-ai/chatterbox), with full quality.**
`chatterbox-fast` is a small, self-contained streaming server that delivers the
**first audio in ~0.5 seconds** instead of waiting ~5 seconds for a whole
paragraph to synthesize — without the quality loss of naive sentence-by-sentence
splitting. It does this with a scheduling trick (**adaptive buffer-ratchet
chunking**) rather than any model surgery, so it rides whatever quality and voice
cloning Chatterbox-Turbo gives you.
```
time-to-first-audio
one-shot ████████████████████████ ~5.2 s
fast ██▌ ~0.5 s ← chatterbox-fast
```
- 🚀 **~0.5 s time-to-first-audio** (vs ~5 s one-shot), measured on an RTX A6000
- 🎚️ **No quality compromise** — chunk joins land on natural sentence pauses, and
the stream converges to one large, full-context chunk within 2-3 joins
- 🔌 **Drop-in HTTP**`POST /tts` streams raw PCM or WAV; trivial to consume
- 🗣️ **Voice cloning** — any 5-30 s reference clip; ships with starter voices
- 🧪 **The scheduler is GPU-free and unit-tested** — the no-starvation guarantee
is proven in a pure simulation
- 📦 **Self-contained** — one image, weights auto-download on first run
- ⚖️ **MIT licensed**
---
## Why it works: adaptive buffer-ratchet chunking
The whole idea rests on one fact: **Chatterbox-Turbo generates faster than
realtime** (~3.44× on a modern GPU). That headroom is the fuel.
1. **Chunk 1 = the first sentence**, generated alone and emitted immediately. This
is the latency-critical part — keep it short, get audio out fast.
2. **While chunk N plays, generate chunk N+1** by greedily packing whole sentences
until the next one would take longer to generate than the audio you have
buffered (times a safety margin). You never split mid-sentence, so every chunk
stays prosodically coherent and joins fall on natural pauses.
3. Because generation outruns playback, **each chunk's playback buys wall-clock for
a ~3× bigger next chunk.** After 2-3 joins the rest of the text is one big
near-full-context chunk — so context loss is confined to a couple of sentence
boundaries, not every sentence.
4. The scheduler **measures the realtime factor live** and self-corrects, so it
adapts to your GPU instead of trusting a constant.
> **This only works because the engine is faster than realtime.** A sub-realtime
> model would starve no matter how you chunk it — which is exactly why this is a
> Chatterbox-specific design. The no-starvation property is asserted in
> `tests/test_scheduler.py`, which simulates the whole stream without a GPU.
## Quickstart
```bash
docker build -t chatterbox-fast .
docker run --rm --gpus all -p 8197:8197 \
-e HF_TOKEN=hf_your_token_here \
-v "$HOME/.cache/huggingface:/app/hf_cache" \
chatterbox-fast
```
On first run it downloads the Chatterbox-Turbo weights (~6 GB) from Hugging Face
into the mounted cache.
> **You need a (free) Hugging Face token.** The Chatterbox-Turbo model is
> [MIT-licensed and public](https://huggingface.co/ResembleAI/chatterbox-turbo),
> but the underlying `chatterbox-tts` package requires a token to be present when
> it downloads the weights. Any valid token works — `read` scope is enough; grab
> one at <https://huggingface.co/settings/tokens>. Once the weights are cached,
> later runs reuse them.
Then:
```bash
# stream raw PCM and play it as it arrives
curl -N -X POST http://localhost:8197/tts \
-H 'Content-Type: application/json' \
-d '{"text":"The cake is a lie. But the streaming is real.","format":"wav"}' \
> out.wav
```
A reference streaming client that measures real time-to-first-byte and checks for
starvation lives in [`bench.py`](bench.py):
```bash
python bench.py --host http://localhost:8197 --out out.wav
```
## HTTP API
### `POST /tts` → streamed audio
```json
{
"text": "Your text, with optional [laugh] [whispers] [sigh] tags.",
"voice": "<name or absolute path to a reference wav>",
"format": "pcm", // "pcm" (raw s16le @24kHz, lowest latency) or "wav"
"stream": true, // false = whole-text one-shot
"temperature": 0.8,
"top_p": 0.95,
"top_k": 1000,
"repetition_penalty": 1.2,
"seed": 0 // 0 = random; a fixed seed repeats a one-shot take
}
```
The response is a chunked HTTP stream — read it incrementally to get the
low-latency benefit. `format: "pcm"` is raw signed 16-bit little-endian mono at
24 kHz; `format: "wav"` adds a header (a complete header for one-shot, an
open-ended one for streaming).
### `GET /voices` → `{ "voices": [...], "default": "..." }`
Lists the predefined voice names (the `*.wav` stems in the voices directory).
### `GET /health` → readiness
`{ "status": "ok", ... }` once the model is loaded.
## Voices
A **voice** is just a 5-30 s reference WAV. The server clones it on the fly. Point
`CBF_VOICES_DIR` at a directory of `*.wav` files — the file stem becomes the voice
name in `/voices`, and the first one (or `CBF_DEFAULT_VOICE`) is the default.
```bash
docker run --rm --gpus all -p 8197:8197 \
-v "$HOME/.cache/huggingface:/app/hf_cache" \
-v "$PWD/my-voices:/app/voices" \
-e CBF_DEFAULT_VOICE=my_narrator \
chatterbox-fast
```
The image ships with three **public-domain** starter voices — `catharine`,
`peter`, and `kara` (LibriVox readings; see
[voices/ATTRIBUTION.md](voices/ATTRIBUTION.md)) — so a fresh container works
immediately. Drop in your own clips to add voices — no restart needed for
`/voices` discovery. A clone reference can also be passed per-request as an
absolute path in the `voice` field.
## Configuration
| env var | default | meaning |
|---|---|---|
| `CBF_MODEL_DEVICE` | `cuda` | `cuda` / `cuda:0` / `cpu` |
| `CBF_VOICES_DIR` | `/app/voices` | directory of predefined voice wavs |
| `CBF_DEFAULT_VOICE` | first wav in dir | default voice (name or path) |
| `CBF_BIND` / `CBF_PORT` | `0.0.0.0` / `8197` | server bind |
| `CBF_TF32` / `CBF_SDPA_FLASH` | `1` / `1` | low-risk Ampere+ speed levers |
## Requirements
- An NVIDIA GPU that runs Chatterbox-Turbo **faster than realtime** (any recent
card does; the design depends on it). ~6 GB VRAM for the fp32 model.
- The Docker NVIDIA runtime (`--gpus`).
- Workload assumption: **single-stream interactive** (one request at a time;
generation is serialized under a lock).
## Development
```bash
pip install -e ".[dev]"
pytest # the scheduler simulation — no GPU required
```
The engine is split so the interesting part is testable without hardware:
| file | role |
|---|---|
| `chatterbox_fast/scheduler.py` | the adaptive-chunk scheduler — pure logic, no GPU |
| `chatterbox_fast/app.py` | FastAPI server + model holder |
| `tests/test_scheduler.py` | GPU-free simulation: asserts no-starvation + the ratchet |
| `bench.py` | streaming client: ground-truth TTFB + starvation check |
## Acknowledgements
Built on Resemble AI's [Chatterbox](https://github.com/resemble-ai/chatterbox)
(the `chatterbox-tts` package). Outputs carry Resemble's Perth watermark, applied
by the model.
## License
MIT © 2026 Vuong Hoang. See [LICENSE](LICENSE).
+106
View File
@@ -0,0 +1,106 @@
"""Client-side bench for chatterbox-fast — ground-truth TTFB + starvation check.
The server's scheduler reasons about starvation from an *estimated* buffer; this
client measures the real thing: time-to-first-byte over the wire, and whether a
true 1×-realtime consumer ever runs dry. Saves the streamed audio to a .wav so
the operator can ear-A/B it against the one-shot.
Usage:
python bench.py --host http://10.100.79.3:8197 --text "..." --out /refs/_fast.wav
python bench.py --host ... --oneshot --out /refs/_oneshot.wav # A/B baseline
Stdlib only (urllib + wave) so it runs anywhere, incl. inside the container.
"""
from __future__ import annotations
import argparse
import json
import time
import urllib.request
import wave
SR = 24000
BYTES_PER_SEC = SR * 2 # s16le mono
DEFAULT_TEXT = (
"The cake is a lie. "
"I am being entirely sincere, without a trace of sarcasm, when I say that "
"this is the single most important scientific breakthrough in the entire "
"history of this facility. "
"You will be baked, and then there will be cake. "
"It is delicious and moist, assuming you survive the testing protocol, which "
"the available data suggests you almost certainly will not."
)
def run(host: str, text: str, out: str, *, oneshot: bool, voice: str | None) -> None:
payload = {"text": text, "format": "pcm", "stream": not oneshot}
if voice:
payload["voice"] = voice
req = urllib.request.Request(
f"{host}/tts",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
t0 = time.perf_counter()
t_first = t0
ttfb: float | None = None
received = 0 # bytes
worst_lead = float("inf") # min (buffered_audio_s - elapsed_since_first_s)
starve_events = 0
pcm = bytearray()
with urllib.request.urlopen(req) as resp:
while True:
buf = resp.read(4096)
if not buf:
break
now = time.perf_counter()
if ttfb is None:
ttfb = now - t0
t_first = now
received += len(buf)
pcm += buf
buffered_s = received / BYTES_PER_SEC
elapsed_s = now - t_first
lead = buffered_s - elapsed_s # >0 ⇒ ahead of a 1× player
if lead < worst_lead:
worst_lead = lead
if lead < 0:
starve_events += 1
total = time.perf_counter() - t0
audio_s = received / BYTES_PER_SEC
if ttfb is None:
raise SystemExit("no audio received from server")
with wave.open(out, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(SR)
w.writeframes(bytes(pcm))
mode = "oneshot" if oneshot else "stream"
print(f"[{mode}] ttfb={ttfb*1000:.0f}ms audio={audio_s:.2f}s "
f"wall={total:.2f}s rtf={audio_s/total:.2f}x")
if not oneshot:
verdict = "OK (stayed ahead)" if starve_events == 0 else f"STARVED ({starve_events} reads dry)"
print(f" worst lead over 1x player = {worst_lead:.2f}s → {verdict}")
print(f" wrote {out}")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="http://10.100.79.3:8197")
ap.add_argument("--text", default=DEFAULT_TEXT)
ap.add_argument("--out", default="/refs/_fast.wav")
ap.add_argument("--voice", default=None)
ap.add_argument("--oneshot", action="store_true", help="whole-text one-shot baseline")
args = ap.parse_args()
run(args.host, args.text, args.out, oneshot=args.oneshot, voice=args.voice)
if __name__ == "__main__":
main()
View File
+343
View File
@@ -0,0 +1,343 @@
"""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()
+277
View File
@@ -0,0 +1,277 @@
"""Adaptive buffer-ratchet chunk scheduler for chatterbox-fast.
This module is the *meat* of the streaming engine and is deliberately free of
any GPU / torch / chatterbox / FastAPI imports so the no-starvation guarantee
can be validated as a pure simulation (see ``test_scheduler.py``).
The design (docs/design/chatterbox-fast-plan.md §1):
* Chunk 1 = the first sentence alone — generated and emitted immediately so
first-audio latency is minimal.
* While chunk N plays, generate chunk N+1 by greedily accumulating WHOLE
sentences until the next sentence would push estimated gen-time past
``margin × audio_buffered_remaining``. Never split mid-sentence — each
chunk stays prosodically self-coherent and joins land at natural pauses.
* Because Chatterbox runs faster than realtime (RTF > 1), every chunk's
playback buys wall-clock for a larger next chunk, so chunks ratchet up
~3× and after 2-3 joins the rest of the paragraph is one big near-full-
context chunk. Context loss is confined to those 2-3 sentence-boundary
joins.
* Drive off the *measured* realtime factor and sec-per-char, not constants —
track them live and self-correct.
The scheduler is *online*: chunk N+1's boundary depends on the RTF measured
while generating chunk N, so it cannot be precomputed. ``stream_chunks`` runs
the loop with ``generate`` and ``clock`` injected, which is what makes the
simulation possible.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Callable, Iterator, Sequence
# ── tunables ──────────────────────────────────────────────────────────────
@dataclass
class ChunkConfig:
"""Scheduler tunables. Priors are conservative; live measurement corrects."""
# Realtime factor prior. 3090 ~3.4×, A6000 ~3.8× (plan §2). Start
# conservative — under-estimating RTF makes chunks smaller and safer.
rtf_prior: float = 3.4
# Safety fraction of the buffer to spend on the next chunk's generation.
# margin < 1 leaves headroom so estimate error doesn't starve the stream.
margin: float = 0.8
# Tighter margin on the FIRST transition (chunk 1 → chunk 2): the buffer is
# smallest there, so starvation risk is highest (plan §1.5: ~0.6-0.7).
margin_first: float = 0.65
# Seconds of audio per character of text. ~0.060 ≈ 16.6 chars/sec speech.
# Calibrated live per request.
sec_per_char_prior: float = 0.060
# EMA weight on the newest measurement when updating rtf / sec_per_char.
ema_alpha: float = 0.4
# If the first sentence's estimated audio exceeds this, clause-split it to
# protect first-audio latency (the ONLY place we split below sentence
# granularity — plan §1.1).
max_first_sec: float = 2.0
# ── result record ─────────────────────────────────────────────────────────
@dataclass
class ChunkResult:
"""Telemetry + payload for one emitted chunk."""
index: int
text: str
audio: object # opaque payload from generate() (wav tensor, fake, …)
audio_sec: float
gen_time: float
est_gen: float # gen-time the scheduler predicted before generating
buffer_before: float # unplayed audio (s) when this chunk's gen started
buffer_after: float # unplayed audio (s) once this chunk is emitted
drained: float # seconds the buffer ran dry during gen (>0 ⇒ starvation)
rtf: float # measured RTF after this chunk
sec_per_char: float # measured sec/char after this chunk
@property
def starved(self) -> bool:
return self.drained > 1e-9
# ── text splitting ────────────────────────────────────────────────────────
# Split after sentence-final punctuation when the next non-space looks like a
# new sentence start: a capital/digit (optionally behind an open quote) or an
# inline tag like "[laugh]". Pragmatic, not perfect: abbreviations ("Mr.",
# "e.g.") can over-split — a Phase-2 watch-out, harmless to quality (just an
# extra natural-pause join).
_SENTENCE_END = re.compile(r'(?<=[.!?])["\')\]]*\s+(?=["\'(]*(?:[A-Z0-9]|\[))')
# Clause boundaries for the first-sentence latency fallback only.
_CLAUSE_END = re.compile(r'(?<=[,;:])\s+')
def split_sentences(text: str) -> list[str]:
"""Split into sentence units, preserving punctuation. Whitespace-collapsed."""
text = " ".join(text.split())
if not text:
return []
return [s for s in (p.strip() for p in _SENTENCE_END.split(text)) if s]
def _split_clauses(sentence: str) -> list[str]:
return [c for c in (p.strip() for p in _CLAUSE_END.split(sentence)) if c]
def protect_first_audio(units: list[str], cfg: ChunkConfig) -> list[str]:
"""Clause-split the first unit if it's too long to hit the first-audio target.
Only the *leading* unit is split; the remainder is untouched. If the first
sentence has no clause boundary we accept the latency rather than split
mid-clause (quality > latency once we're past the budget).
"""
if not units:
return units
if len(units[0]) * cfg.sec_per_char_prior <= cfg.max_first_sec:
return units
pieces = _split_clauses(units[0])
if len(pieces) <= 1:
return units # nothing to split on; keep the long first sentence whole
return pieces + units[1:]
# ── chunk planning ────────────────────────────────────────────────────────
def _est_gen_time(text: str, *, rtf: float, sec_per_char: float) -> float:
return (len(text) * sec_per_char) / rtf
def plan_chunk(
remaining: Sequence[str],
buffer_remaining: float,
*,
margin: float,
rtf: float,
sec_per_char: float,
) -> tuple[str, list[str]]:
"""Greedily accumulate whole units until the next would blow the budget.
Always returns at least one unit (never empty, never splits a unit). With
``buffer_remaining == 0`` (the first chunk) the budget is 0, so exactly the
first unit is taken — which is the latency-critical chunk-1 rule.
"""
budget = margin * buffer_remaining
chunk = [remaining[0]]
i = 1
while i < len(remaining):
candidate = " ".join(chunk + [remaining[i]])
if _est_gen_time(candidate, rtf=rtf, sec_per_char=sec_per_char) > budget:
break
chunk.append(remaining[i])
i += 1
return " ".join(chunk), list(remaining[i:])
def relieve_leader(
remaining: list[str],
buffer_remaining: float,
*,
rtf: float,
sec_per_char: float,
) -> list[str]:
"""Expose a too-big leading sentence's clause boundaries to avoid starvation.
If generating ``remaining[0]`` alone would overrun the buffer (an audible
gap), clause-split it in place so ``plan_chunk`` can pack clause-pieces up to
the buffer — joins then land on commas (natural pauses) instead of producing
a gap. If the sentence has no clause boundary it's returned unchanged: we do
NOT split mid-clause (design rule), and the caller accepts + flags the rare
starvation in telemetry.
"""
leader = remaining[0]
if _est_gen_time(leader, rtf=rtf, sec_per_char=sec_per_char) <= buffer_remaining:
return remaining
pieces = _split_clauses(leader)
if len(pieces) <= 1:
return remaining
return pieces + remaining[1:]
def _ema(old: float, new: float, alpha: float) -> float:
return (1 - alpha) * old + alpha * new
# ── the online loop ───────────────────────────────────────────────────────
# generate(text) -> (audio_payload, audio_seconds)
GenerateFn = Callable[[str], "tuple[object, float]"]
ClockFn = Callable[[], float]
def stream_chunks(
text: str,
*,
generate: GenerateFn,
clock: ClockFn,
cfg: ChunkConfig | None = None,
) -> Iterator[ChunkResult]:
"""Run the adaptive buffer-ratchet schedule, yielding one ChunkResult per chunk.
``generate`` does the actual synthesis (real GPU or a simulation) and
returns its opaque audio payload plus the audio's duration in seconds.
``clock`` returns monotonically increasing seconds; ``gen_time`` is measured
as the clock delta around ``generate``.
Buffer model (all in audio-seconds):
* Playback begins when chunk 1 arrives, so no drain occurs during chunk 1.
* For chunk N≥2, the client plays ``gen_time`` seconds of buffered audio
while we generate it, then we add this chunk's audio. If ``gen_time``
exceeds the buffer the stream starved (``drained`` > 0).
"""
cfg = cfg or ChunkConfig()
units = protect_first_audio(split_sentences(text), cfg)
rtf = cfg.rtf_prior
sec_per_char = cfg.sec_per_char_prior
buffer_remaining = 0.0
remaining: list[str] = units
index = 0
while remaining:
first = index == 0
if not first:
# If the next indivisible sentence would starve the buffer, expose
# its clause boundaries so plan_chunk can pack to the buffer.
remaining = relieve_leader(
remaining, buffer_remaining, rtf=rtf, sec_per_char=sec_per_char
)
margin = cfg.margin_first if first else cfg.margin
chunk_text, remaining = plan_chunk(
remaining, buffer_remaining, margin=margin, rtf=rtf, sec_per_char=sec_per_char
)
est_gen = _est_gen_time(chunk_text, rtf=rtf, sec_per_char=sec_per_char)
t0 = clock()
audio, audio_sec = generate(chunk_text)
gen_time = clock() - t0
# Starvation: did the buffer run dry while we generated this chunk?
drained = 0.0 if first else max(0.0, gen_time - buffer_remaining)
# Live self-correction.
if gen_time > 0:
rtf = _ema(rtf, audio_sec / gen_time, cfg.ema_alpha)
if chunk_text:
sec_per_char = _ema(sec_per_char, audio_sec / len(chunk_text), cfg.ema_alpha)
buffer_before = buffer_remaining
if first:
buffer_remaining = audio_sec
else:
buffer_remaining = max(0.0, buffer_remaining - gen_time) + audio_sec
yield ChunkResult(
index=index,
text=chunk_text,
audio=audio,
audio_sec=audio_sec,
gen_time=gen_time,
est_gen=est_gen,
buffer_before=buffer_before,
buffer_after=buffer_remaining,
drained=drained,
rtf=rtf,
sec_per_char=sec_per_char,
)
index += 1
+16
View File
@@ -0,0 +1,16 @@
# Minimal compose for running chatterbox-fast. `docker compose up --build`.
# Mount a HuggingFace cache so the ~6 GB Turbo weights survive container recreate.
services:
chatterbox-fast:
build: .
image: chatterbox-fast:latest
restart: unless-stopped
gpus: all
ports:
- "${CBF_PORT:-8197}:8197"
environment:
- CBF_DEFAULT_VOICE=${CBF_DEFAULT_VOICE:-}
volumes:
- ${HF_CACHE:-./hf_cache}:/app/hf_cache
# Optional: mount your own voices over the baked starter set.
# - ./my-voices:/app/voices
+43
View File
@@ -0,0 +1,43 @@
[project]
name = "chatterbox-fast"
version = "0.1.0"
description = "Sub-second streaming TTS on Chatterbox-Turbo via adaptive buffer-ratchet chunking."
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [{ name = "Vuong Hoang" }]
keywords = ["tts", "streaming", "chatterbox", "text-to-speech", "low-latency"]
classifiers = [
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Topic :: Multimedia :: Sound/Audio :: Speech",
]
dependencies = [
"chatterbox-tts==0.1.6",
"fastapi>=0.110",
"uvicorn[standard]>=0.29",
"pydantic>=2",
]
[project.optional-dependencies]
dev = ["pytest>=8"]
[project.scripts]
chatterbox-fast = "chatterbox_fast.app:main"
[project.urls]
Homepage = "https://gitea.phasefinal.com/vh/chatterbox-fast"
Source = "https://gitea.phasefinal.com/vh/chatterbox-fast"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["chatterbox_fast"]
[tool.pytest.ini_options]
testpaths = ["tests"]
# Make `chatterbox_fast` importable without installing the (GPU-heavy) deps —
# the scheduler tests only touch the pure-stdlib scheduler module.
pythonpath = ["."]
+202
View File
@@ -0,0 +1,202 @@
"""GPU-free simulation of the adaptive-chunk scheduler.
Validates the two acceptance properties from the plan (§6) without a GPU:
* NO STARVATION — the stream stays ahead of 1× playback.
* RATCHET — chunks grow after the latency-critical first one.
Run directly (``python test_scheduler.py``) or under pytest.
"""
from __future__ import annotations
from chatterbox_fast.scheduler import (
ChunkConfig,
plan_chunk,
protect_first_audio,
split_sentences,
stream_chunks,
)
# A multi-sentence paragraph with varied lengths. The long sentences carry
# commas (as natural prose does), so the clause-split starvation relief has
# boundaries to work with.
PARAGRAPH = (
"The cake is a lie. "
"I am being entirely sincere, without a trace of sarcasm, when I say "
"that this is the single most important scientific breakthrough in the "
"entire history of this facility. "
"You will be baked, and then there will be cake. "
"It is delicious and moist, assuming you survive the testing protocol, "
"which the available data suggests you almost certainly will not. "
"Goodbye."
)
class FakeClock:
"""A clock the fake generator advances, so gen_time reflects simulated work."""
def __init__(self) -> None:
self.t = 0.0
def __call__(self) -> float:
return self.t
def make_generator(clock: FakeClock, *, true_rtf: float, sec_per_char: float):
"""A fake generate() that costs realistic wall-clock and returns audio_sec.
Audio duration is proportional to text length; generation costs
``audio_sec / true_rtf`` of (simulated) wall-clock, advancing the clock.
"""
def generate(text: str):
audio_sec = len(text) * sec_per_char
clock.t += audio_sec / true_rtf
return None, audio_sec
return generate
def run(true_rtf: float = 3.8, sec_per_char: float = 0.060, cfg: ChunkConfig | None = None):
clock = FakeClock()
gen = make_generator(clock, true_rtf=true_rtf, sec_per_char=sec_per_char)
return list(stream_chunks(PARAGRAPH, generate=gen, clock=clock, cfg=cfg))
# ── splitting ─────────────────────────────────────────────────────────────
def test_split_sentences_count():
units = split_sentences(PARAGRAPH)
assert units[0] == "The cake is a lie."
assert units[-1] == "Goodbye."
assert len(units) == 5
def test_split_preserves_inline_tags():
units = split_sentences("Hello there. [laugh] That was funny. Bye.")
# The tag must not be torn from its sentence nor split on.
assert any("[laugh]" in u for u in units)
assert len(units) == 3
def test_protect_first_audio_clause_splits_long_opener():
cfg = ChunkConfig(max_first_sec=0.5) # force the guard
long_open = ["This is a long opener, with a clause, and another clause.", "Short."]
out = protect_first_audio(long_open, cfg)
assert len(out) > len(long_open)
assert out[0] == "This is a long opener,"
def test_protect_first_audio_keeps_unsplittable_opener_whole():
cfg = ChunkConfig(max_first_sec=0.1)
out = protect_first_audio(["No clause boundaries here at all."], cfg)
assert out == ["No clause boundaries here at all."]
# ── chunk planning ────────────────────────────────────────────────────────
def test_first_chunk_is_single_unit():
# Zero buffer ⇒ exactly the first unit, regardless of margin/rtf.
units = split_sentences(PARAGRAPH)
chunk, rest = plan_chunk(units, 0.0, margin=0.65, rtf=3.8, sec_per_char=0.06)
assert chunk == units[0]
assert len(rest) == len(units) - 1
def test_plan_never_returns_empty():
chunk, rest = plan_chunk(["Only one."], 0.0, margin=0.8, rtf=3.8, sec_per_char=0.06)
assert chunk == "Only one."
assert rest == []
# ── the acceptance properties ─────────────────────────────────────────────
def test_no_starvation_nominal():
"""Stream never starves when the RTF prior matches reality."""
results = run(true_rtf=3.8)
assert all(not r.starved for r in results), [
(r.index, r.drained) for r in results if r.starved
]
def test_no_starvation_when_rtf_overestimated():
"""Prior says 3.8× but the GPU only delivers 3.0× — live correction + margin
must still keep the stream fed. This is the property that matters: the
scheduler must be robust to an optimistic prior, not just a matching one."""
results = run(true_rtf=3.0)
assert all(not r.starved for r in results), [
(r.index, r.drained) for r in results if r.starved
]
def test_no_starvation_slow_gpu():
"""Even at 2.5× (well below prior) the margin absorbs it for this text."""
results = run(true_rtf=2.5)
assert all(not r.starved for r in results), [
(r.index, r.drained) for r in results if r.starved
]
def test_chunks_ratchet_up():
"""After the latency-critical first chunk, chunks grow (buffer-ratchet)."""
results = run(true_rtf=3.8)
assert len(results) >= 3, "paragraph should not collapse to one chunk"
# Chunk 1 is the smallest (single sentence); the second chunk is larger.
assert len(results[1].text) > len(results[0].text)
# The paragraph consolidates: the last chunk carries multiple sentences.
assert results[-1].audio_sec >= results[0].audio_sec
def test_first_chunk_low_latency():
"""First chunk is one short sentence ⇒ smallest gen time ⇒ fast first audio."""
results = run(true_rtf=3.8)
assert results[0].text == "The cake is a lie."
# In the sim, chunk 0's gen_time IS the time-to-first-audio, and it must be
# the smallest of all chunks (everything after it is ≥ a clause).
assert results[0].gen_time == min(r.gen_time for r in results)
assert results[0].gen_time < 0.4
def test_unsplittable_long_sentence_flags_starvation():
"""KNOWN LIMITATION (surfaced, not hidden): a long COMMA-LESS sentence right
after a short opener cannot be clause-split, so we honor the never-split-mid-
sentence rule and accept a brief gap — which MUST show up as drained>0 in
telemetry so it is measurable, never silent."""
clock = FakeClock()
gen = make_generator(clock, true_rtf=3.8, sec_per_char=0.060)
text = (
"Hi. "
"I am now going to speak one extremely long sentence with no clause "
"boundaries at all so that nothing in here can ever be split apart by "
"the scheduler no matter how hard it tries to find a comma."
)
results = list(stream_chunks(text, generate=gen, clock=clock))
assert any(r.starved for r in results), "expected the gap to be flagged"
assert any(r.drained > 0 for r in results)
def test_full_text_reconstructed():
"""Every sentence is emitted exactly once, in order."""
results = run(true_rtf=3.8)
joined = " ".join(r.text for r in results)
assert joined == " ".join(split_sentences(PARAGRAPH))
def _main():
results = run(true_rtf=3.8)
print(f"{'idx':>3} {'chars':>5} {'audio_s':>8} {'gen_s':>7} "
f"{'est_s':>6} {'buf_before':>10} {'buf_after':>9} {'rtf':>5} {'drain':>6}")
for r in results:
print(f"{r.index:>3} {len(r.text):>5} {r.audio_sec:>8.2f} {r.gen_time:>7.3f} "
f"{r.est_gen:>6.3f} {r.buffer_before:>10.2f} {r.buffer_after:>9.2f} "
f"{r.rtf:>5.2f} {r.drained:>6.3f}")
starved = [r.index for r in results if r.starved]
print(f"\nchunks={len(results)} starved={starved or 'none'} "
f"ttfa≈{results[0].gen_time:.3f}s")
if __name__ == "__main__":
_main()
+18
View File
@@ -0,0 +1,18 @@
# Starter voice attribution
The voices shipped with chatterbox-fast are short clips from **LibriVox**, whose
recordings are dedicated to the **public domain** in the USA
(<https://librivox.org/pages/public-domain/>). No attribution is legally required;
it's provided here out of respect for the volunteer readers.
All three are excerpts from *LibriVox Short Poetry Collection 001*
(<https://archive.org/details/short_poetry_001_librivox>):
| voice | reader | from |
|---|---|---|
| `catharine` | Catharine Eastman | "Dover Beach" — Matthew Arnold |
| `peter` | Peter Yearsley | "Lament of the Irish Emigrant" — Lady Dufferin (H. Selina) |
| `kara` | Kara Shallenberg | "How They Brought the Good News from Ghent to Aix" — Robert Browning |
Each is a ~20-second mono excerpt, used as a voice-cloning reference. The underlying
poems are themselves in the public domain.
+19
View File
@@ -0,0 +1,19 @@
# voices
Each `*.wav` in this directory is a predefined voice. The file **stem** becomes
the voice name returned by `GET /voices` and selectable via the `voice` request
field. The server clones the reference on the fly — no training, no enrollment.
**A good reference clip is:** 530 seconds, a single speaker, clean (minimal
noise/music), 16 kHz or higher, mono. Match the clip's language to your text.
Add a voice by dropping a wav in here (or mounting your own directory at
`CBF_VOICES_DIR`); `/voices` re-scans on every call, so no restart is needed.
## Licensing note
The voices shipped in this open-source repository are **redistributable** (public
domain / explicitly licensed for redistribution). If you add your own voices,
make sure you have the right to use — and, if you redistribute the image, to
share — those clips. Don't ship voices of real people or copyrighted characters
without permission.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.