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

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.