feat: add OpenAI-compatible Chatterbox TTS service
This commit is contained in:
commit
38a2a48f28
9 changed files with 620 additions and 0 deletions
166
docs/superpowers/plans/2026-08-08-chatterbox-tts.md
Normal file
166
docs/superpowers/plans/2026-08-08-chatterbox-tts.md
Normal 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.
|
||||
53
docs/superpowers/specs/2026-08-08-chatterbox-tts-design.md
Normal file
53
docs/superpowers/specs/2026-08-08-chatterbox-tts-design.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue