feat: add OpenAI-compatible Chatterbox TTS service

This commit is contained in:
vince 2026-08-08 23:10:07 +02:00
commit 38a2a48f28
9 changed files with 620 additions and 0 deletions

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
# Reference clips are local operational data; keep the directory in Git only.
voices/*
!voices/.gitkeep

54
README.md Normal file
View file

@ -0,0 +1,54 @@
# Chatterbox TTS
An OpenAI-compatible, CUDA-backed FastAPI wrapper for
[ResembleAI Chatterbox](https://huggingface.co/ResembleAI/chatterbox).
The deployed service is available on the DimensionLab local network at
`https://chatterbox.dimensionlab.net`.
## API
`POST /v1/audio/speech` accepts the familiar OpenAI speech fields:
```json
{
"model": "chatterbox",
"input": "Hello from Chatterbox.",
"voice": "reference.wav",
"response_format": "wav",
"speed": 1.0
}
```
The response is WAV audio. `voice` is a relative path below the service's
local `voices/` directory; absolute paths, traversal, symlink escapes, and
unreadable files are rejected. Reference clips are operational data and are
intentionally not part of this repository.
Other endpoints:
- `GET /health`
- `GET /v1/models`
## Runtime behavior
- Uses the English Chatterbox model on CUDA.
- Serializes synthesis through one GPU lock.
- Unloads the model after 300 seconds of inactivity.
- Caps text input at 2,000 characters.
- Listens locally on `127.0.0.1:8881`; Caddy provides the HTTPS route.
## Development
```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
PYTHONPATH=. .venv/bin/pytest -q tests/test_app.py
```
The CUDA smoke test requires a valid local reference clip at
`voices/reference.wav`:
```bash
PYTHONPATH=. .venv/bin/pytest -q tests/test_synthesis.py
```

121
app.py Normal file
View file

@ -0,0 +1,121 @@
"""OpenAI-compatible speech endpoint backed by ResembleAI Chatterbox."""
import gc
import os
import threading
import time
from io import BytesIO
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel, Field
VOICES_DIR = Path(__file__).parent / "voices"
IDLE_UNLOAD_SECONDS = 300
class SpeechRequest(BaseModel):
model: str = "chatterbox"
input: str = Field(max_length=2000)
voice: str
response_format: str = "wav"
speed: float = 1.0
class ModelManager:
"""Owns the GPU model and releases it after a period of inactivity."""
def __init__(self) -> None:
self._model = None
self._unload_timer: threading.Timer | None = None
self._lock = threading.Lock()
self.last_request_time: float | None = None
def _cancel_unload_timer(self) -> None:
if self._unload_timer is not None:
self._unload_timer.cancel()
self._unload_timer = None
def _schedule_unload(self) -> None:
self._cancel_unload_timer()
self._unload_timer = threading.Timer(IDLE_UNLOAD_SECONDS, self.unload)
self._unload_timer.daemon = True
self._unload_timer.start()
def load_model(self):
if self._model is None:
from chatterbox.tts import ChatterboxTTS
self._model = ChatterboxTTS.from_pretrained(device="cuda")
return self._model
def generate(self, text: str, voice_path: Path, speed: float) -> bytes:
with self._lock:
self._cancel_unload_timer()
try:
model = self.load_model()
# Chatterbox does not expose a native speed control. Keep the
# OpenAI-compatible field accepted without changing speech speed.
del speed
audio = model.generate(text, audio_prompt_path=str(voice_path))
import torchaudio
buffer = BytesIO()
torchaudio.save(buffer, audio, model.sr, format="wav")
return buffer.getvalue()
finally:
if self._model is not None:
self.last_request_time = time.monotonic()
self._schedule_unload()
def unload(self) -> None:
with self._lock:
self._cancel_unload_timer()
if self._model is None:
return
self._model = None
gc.collect()
import torch
torch.cuda.empty_cache()
def resolve_voice(voice: str) -> Path:
candidate = Path(voice)
if candidate.is_absolute():
raise HTTPException(status_code=400, detail="voice must be a relative path")
voices_root = VOICES_DIR.resolve()
voice_path = Path(VOICES_DIR, voice).resolve()
if not voice_path.is_relative_to(voices_root):
raise HTTPException(status_code=400, detail="voice path escapes the voices directory")
if not voice_path.is_file() or not os.access(voice_path, os.R_OK):
raise HTTPException(status_code=400, detail="voice must be a readable regular file")
return voice_path
app = FastAPI()
model_manager = ModelManager()
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/v1/models")
def list_models() -> dict[str, list[dict[str, str]]]:
return {"data": [{"id": "chatterbox", "object": "model"}]}
@app.post("/v1/audio/speech")
def speech(request: SpeechRequest) -> Response:
if request.response_format != "wav":
raise HTTPException(status_code=400, detail="only wav response_format is supported")
voice_path = resolve_voice(request.voice)
audio = model_manager.generate(request.input, voice_path, request.speed)
return Response(content=audio, media_type="audio/wav")

View file

@ -0,0 +1,166 @@
# Chatterbox TTS Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Deploy an OpenAI-compatible Chatterbox text-to-speech service at `chatterbox.dimensionlab.net`.
**Architecture:** A FastAPI application on loopback port 8881 wraps the CUDA Chatterbox model and exposes the standard speech endpoint. The application confines reference audio to its `voices/` directory, serializes GPU synthesis, and unloads the cached model after exactly five idle minutes. A user systemd service supervises it, and Caddy exposes the public hostname.
**Tech Stack:** Python 3, FastAPI, Uvicorn, PyTorch, torchaudio, chatterbox-tts, systemd user units, Caddy.
## Global Constraints
- Do not change the Kokoro service or its Caddy route.
- Bind Uvicorn to `127.0.0.1:8881` only.
- Use the English `ResembleAI/chatterbox` model on CUDA.
- Accept reference audio only below `/home/vince/ai/apps/chatterbox-tts/voices`.
- Set model idle unload to exactly 300 seconds.
- Permit at most one in-flight model synthesis request.
- Limit `input` to 2,000 characters.
- Support WAV responses in the first release.
---
### Task 1: Create and test the API module
**Files:**
- Create: `/home/vince/ai/apps/chatterbox-tts/app.py`
- Create: `/home/vince/ai/apps/chatterbox-tts/tests/test_app.py`
- Create: `/home/vince/ai/apps/chatterbox-tts/requirements.txt`
- Create: `/home/vince/ai/apps/chatterbox-tts/voices/.gitkeep`
**Interfaces:**
- Produces: `app: FastAPI` with `GET /health`, `GET /v1/models`, and `POST /v1/audio/speech`.
- Produces: `resolve_voice_path(voice: str) -> Path` and `SpeechRequest` request validation.
- [ ] **Step 1: Write failing API tests**
```python
def test_models_and_health(client):
assert client.get('/health').json() == {'status': 'ok'}
assert client.get('/v1/models').json()['data'][0]['id'] == 'chatterbox'
def test_voice_must_stay_under_voice_directory(client):
response = client.post('/v1/audio/speech', json={
'input': 'Hello', 'voice': '../secret.wav', 'response_format': 'wav'
})
assert response.status_code == 400
def test_input_is_limited(client):
response = client.post('/v1/audio/speech', json={
'input': 'x' * 2001, 'voice': 'reference.wav', 'response_format': 'wav'
})
assert response.status_code == 422
```
- [ ] **Step 2: Run the tests to confirm they fail before implementation**
Run: `cd /home/vince/ai/apps/chatterbox-tts && .venv/bin/python -m pytest tests/test_app.py -v`
Expected: FAIL because `app.py` and the `app` module do not exist.
- [ ] **Step 3: Implement the API and GPU model manager**
Implement a `ModelManager` protected by `threading.Lock`. It must lazily call `ChatterboxTTS.from_pretrained(device='cuda')`, update `last_request_time` at synthesis start, and have a daemon that clears the model and calls `torch.cuda.empty_cache()` when idle for 300 seconds. Guard the full inference call with the same lock. Resolve `voice` with `Path(VOICES_DIR, voice).resolve()` and reject it unless `relative_to(VOICES_DIR.resolve())` succeeds. Use `torchaudio.save` to write the generated tensor to an in-memory WAV buffer.
```python
@app.post('/v1/audio/speech')
def speech(request: SpeechRequest) -> Response:
reference = resolve_voice_path(request.voice)
wav, sample_rate = model_manager.generate(request.input, reference)
buffer = io.BytesIO()
torchaudio.save(buffer, wav.cpu(), sample_rate, format='wav')
return Response(buffer.getvalue(), media_type='audio/wav')
```
- [ ] **Step 4: Run the focused tests**
Run: `cd /home/vince/ai/apps/chatterbox-tts && .venv/bin/python -m pytest tests/test_app.py -v`
Expected: PASS without loading the CUDA model for health, model-list, length, or invalid-path cases.
### Task 2: Install isolated dependencies and verify real synthesis
**Files:**
- Modify: `/home/vince/ai/apps/chatterbox-tts/requirements.txt`
- Create: `/home/vince/ai/apps/chatterbox-tts/tests/test_synthesis.py`
**Interfaces:**
- Consumes: `app.model_manager.generate(text, voice_path)`.
- Produces: a WAV-producing Chatterbox runtime in `.venv`.
- [ ] **Step 1: Write the failing real-synthesis test**
```python
def test_generate_returns_audio_tensor_and_sample_rate(reference_wav):
wav, sample_rate = model_manager.generate('A short Chatterbox smoke test.', reference_wav)
assert wav.numel() > 0
assert sample_rate > 0
```
- [ ] **Step 2: Install the project environment**
Run: `cd /home/vince/ai/apps/chatterbox-tts && python3 -m venv .venv && .venv/bin/pip install --upgrade pip && .venv/bin/pip install -r requirements.txt`
Expected: `chatterbox-tts`, CUDA-compatible PyTorch/torchaudio, FastAPI, Uvicorn, Pydantic, and pytest install into `.venv` only.
- [ ] **Step 3: Add a short, consented reference WAV to `voices/`**
Use one existing, locally owned test WAV as `voices/reference.wav`; do not copy any credentials or unrelated files.
- [ ] **Step 4: Run the real-synthesis test**
Run: `cd /home/vince/ai/apps/chatterbox-tts && .venv/bin/python -m pytest tests/test_synthesis.py -v -s`
Expected: PASS and the CUDA model is released after the test process exits.
### Task 3: Supervise and publish the service
**Files:**
- Create: `/home/vince/.config/systemd/user/chatterbox-tts.service`
- Create: `/etc/caddy/conf.d/chatterbox.caddy`
**Interfaces:**
- Consumes: `app:app` from `app.py`.
- Produces: loopback service on port 8881 and HTTPS public hostname.
- [ ] **Step 1: Create the systemd user unit**
Use `WorkingDirectory=/home/vince/ai/apps/chatterbox-tts` and:
```ini
ExecStart=/home/vince/ai/apps/chatterbox-tts/.venv/bin/uvicorn app:app --host 127.0.0.1 --port 8881
Restart=on-failure
RestartSec=5
```
- [ ] **Step 2: Create the Caddy route**
```caddyfile
chatterbox.dimensionlab.net {
import cloudflare_tls
reverse_proxy 127.0.0.1:8881
}
```
- [ ] **Step 3: Validate configuration and activate services**
Run: `systemctl --user daemon-reload && systemctl --user enable --now chatterbox-tts.service && /usr/local/bin/caddy-cloudflare validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy`
Expected: the user unit is active, Caddy validates, and the public route is loaded.
- [ ] **Step 4: Verify the full public API path**
Run:
```bash
curl -fsS https://chatterbox.dimensionlab.net/health
curl -fsS https://chatterbox.dimensionlab.net/v1/models
curl -fsS -o /tmp/chatterbox-smoke.wav \
-H 'Content-Type: application/json' \
-d '{"model":"chatterbox","input":"Service smoke test.","voice":"reference.wav","response_format":"wav"}' \
https://chatterbox.dimensionlab.net/v1/audio/speech
file /tmp/chatterbox-smoke.wav
```
Expected: health and models return JSON, speech returns an RIFF/WAVE file, and no listener is exposed on a non-loopback interface for port 8881.

View file

@ -0,0 +1,53 @@
# Chatterbox TTS service design
## Goal
Run ResembleAI Chatterbox as an independent GPU-backed text-to-speech service,
available at `https://chatterbox.dimensionlab.net`, while preserving the
existing Kokoro service unchanged.
## Architecture
`chatterbox-tts` is a small Python FastAPI application in
`/home/vince/ai/apps/chatterbox-tts`. A user-level systemd unit runs it on
loopback port 8881. Caddy terminates TLS for `chatterbox.dimensionlab.net` and
reverse-proxies that hostname to `127.0.0.1:8881`.
The application loads the English `ResembleAI/chatterbox` model on CUDA lazily.
It keeps the model resident while requests are active, then frees its GPU
allocation after five minutes without a synthesis request. A single-request GPU
lock serializes synthesis and model unload operations. This lets it coexist
with the existing Kokoro service and other GPU workloads without competing
requests exhausting VRAM.
## API contract
The service provides:
- `POST /v1/audio/speech`, accepting the OpenAI speech request fields
`model`, `input`, `voice`, `response_format`, and `speed`.
- `GET /v1/models`, returning the available Chatterbox model identifier.
- `GET /health`, returning a small health response without forcing model load.
`POST /v1/audio/speech` returns generated WAV audio. `model` defaults to
`chatterbox`; `response_format` accepts `wav`; and `input` is capped at 2,000
characters. The request's `voice` field is a path relative to the dedicated
`/home/vince/ai/apps/chatterbox-tts/voices` directory. The resolved path must
remain within that directory, exist, and be readable by the service user before
it is supplied as Chatterbox's reference clip. Invalid, missing, or escaping
paths fail with a clear 400 response. No upload endpoint or voice catalogue is
part of this first version.
## Failure handling
Malformed requests, unsupported formats, invalid paths, model-download/load
failures, and synthesis errors produce JSON errors. A process crash is restarted
by systemd. Caddy continues to expose only the public hostname; the Python
service is not publicly bound.
## Verification
Verify the unit reaches active status, loopback port 8881 serves `/health`, and
the public hostname reaches it through Caddy. Exercise `/v1/models` and a
short `/v1/audio/speech` request using a known local reference WAV, checking
that the response is valid WAV audio.

8
requirements.txt Normal file
View file

@ -0,0 +1,8 @@
fastapi
uvicorn[standard]
pydantic
pytest
setuptools<81
chatterbox-tts==0.1.7
torch==2.6.0
torchaudio==2.6.0

158
tests/test_app.py Normal file
View file

@ -0,0 +1,158 @@
import os
import time
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
import app as chatterbox_app
from app import ModelManager, app
client = TestClient(app)
def speech_payload(**overrides):
payload = {
"input": "Hello from Chatterbox.",
"voice": "reference.wav",
}
payload.update(overrides)
return payload
def configure_voices(monkeypatch, tmp_path):
voices_dir = tmp_path / "voices"
voices_dir.mkdir()
monkeypatch.setattr(chatterbox_app, "VOICES_DIR", voices_dir)
return voices_dir
def test_health_returns_ok_without_calling_model_loader(monkeypatch):
def fail_if_called(*args, **kwargs):
raise AssertionError("model loader must not be called")
monkeypatch.setattr(ModelManager, "load_model", fail_if_called)
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_models_lists_chatterbox_without_calling_model_loader(monkeypatch):
def fail_if_called(*args, **kwargs):
raise AssertionError("model loader must not be called")
monkeypatch.setattr(ModelManager, "load_model", fail_if_called)
response = client.get("/v1/models")
assert response.status_code == 200
assert response.json()["data"][0]["id"] == "chatterbox"
def test_speech_rejects_voice_path_traversal():
response = client.post("/v1/audio/speech", json=speech_payload(voice="../secret.wav"))
assert response.status_code == 400
def test_speech_rejects_absolute_voice_path():
response = client.post(
"/v1/audio/speech",
json=speech_payload(voice=str(Path(chatterbox_app.VOICES_DIR) / "reference.wav")),
)
assert response.status_code == 400
def test_speech_rejects_missing_voice_file():
response = client.post(
"/v1/audio/speech",
json=speech_payload(voice="definitely-not-present-reference.wav"),
)
assert response.status_code == 400
def test_speech_rejects_input_over_2000_characters():
response = client.post("/v1/audio/speech", json=speech_payload(input="x" * 2001))
assert response.status_code == 422
def test_speech_rejects_unsupported_response_format():
response = client.post(
"/v1/audio/speech", json=speech_payload(response_format="mp3")
)
assert response.status_code == 400
def test_speech_rejects_symlink_escaping_voices_directory(monkeypatch, tmp_path):
voices_dir = configure_voices(monkeypatch, tmp_path)
outside_voice = tmp_path / "outside.wav"
outside_voice.write_bytes(b"not really wav")
try:
(voices_dir / "escape.wav").symlink_to(outside_voice)
except OSError as error:
pytest.skip(f"symlinks are unavailable on this OS: {error}")
response = client.post("/v1/audio/speech", json=speech_payload(voice="escape.wav"))
assert response.status_code == 400
def test_speech_rejects_directory_as_voice_target(monkeypatch, tmp_path):
voices_dir = configure_voices(monkeypatch, tmp_path)
(voices_dir / "voice-dir").mkdir()
response = client.post("/v1/audio/speech", json=speech_payload(voice="voice-dir"))
assert response.status_code == 400
def test_speech_rejects_unreadable_voice_target(monkeypatch, tmp_path):
if os.geteuid() == 0:
pytest.skip("root can read files regardless of their permission bits")
voices_dir = configure_voices(monkeypatch, tmp_path)
voice = voices_dir / "unreadable.wav"
voice.write_bytes(b"not really wav")
voice.chmod(0)
try:
response = client.post("/v1/audio/speech", json=speech_payload(voice="unreadable.wav"))
finally:
voice.chmod(0o600)
assert response.status_code == 400
def test_speech_accepts_exactly_2000_characters(monkeypatch, tmp_path):
voices_dir = configure_voices(monkeypatch, tmp_path)
(voices_dir / "reference.wav").write_bytes(b"not really wav")
monkeypatch.setattr(chatterbox_app.model_manager, "generate", lambda *args: b"wav")
response = client.post("/v1/audio/speech", json=speech_payload(input="x" * 2000))
assert response.status_code == 200
assert response.headers["content-type"] == "audio/wav"
assert response.content == b"wav"
def test_generation_failure_still_records_idle_unload_time():
class FailingModel:
def generate(self, *args, **kwargs):
raise RuntimeError("synthesis failed")
manager = ModelManager()
manager._model = FailingModel()
before_request = time.monotonic()
with pytest.raises(RuntimeError, match="synthesis failed"):
manager.generate("hello", Path("reference.wav"), 1.0)
try:
assert manager.last_request_time >= before_request
assert manager._unload_timer is not None
finally:
manager._cancel_unload_timer()

51
tests/test_synthesis.py Normal file
View file

@ -0,0 +1,51 @@
"""CUDA smoke coverage for the real Chatterbox synthesis path."""
from pathlib import Path
import pytest
import torch
import torchaudio
import app as chatterbox_app
def test_runtime_requirements_pin_chatterbox_cuda_contract():
requirements = {
line.strip()
for line in (Path(__file__).parents[1] / "requirements.txt").read_text().splitlines()
if line.strip() and not line.startswith("#")
}
assert {
"chatterbox-tts==0.1.7",
"torch==2.6.0",
"torchaudio==2.6.0",
"pydantic",
"setuptools<81",
"pytest",
} <= requirements
def test_chatterbox_generates_cuda_audio_from_reference_voice(tmp_path: Path):
"""The app wrapper returns real, decodable CUDA-synthesized WAV audio."""
if not torch.cuda.is_available():
pytest.skip("CUDA is unavailable")
reference = chatterbox_app.VOICES_DIR / "reference.wav"
assert reference.is_file(), "voices/reference.wav must be available for synthesis"
manager = chatterbox_app.model_manager
try:
wav_bytes = manager.generate("Chatterbox CUDA smoke test.", reference, 1.0)
finally:
manager.unload()
output_path = tmp_path / "chatterbox-cuda-smoke.wav"
output_path.write_bytes(wav_bytes)
waveform, sample_rate = torchaudio.load(output_path)
assert isinstance(waveform, torch.Tensor)
assert waveform.ndim == 2
assert waveform.shape[0] == 1
assert waveform.numel() > 0
assert sample_rate > 0
assert output_path.stat().st_size > 44

0
voices/.gitkeep Normal file
View file