From 38a2a48f28d64afb2a2d39f7f85ef3699d02a29f Mon Sep 17 00:00:00 2001 From: vince Date: Sat, 8 Aug 2026 23:10:07 +0200 Subject: [PATCH] feat: add OpenAI-compatible Chatterbox TTS service --- .gitignore | 9 + README.md | 54 ++++++ app.py | 121 +++++++++++++ .../plans/2026-08-08-chatterbox-tts.md | 166 ++++++++++++++++++ .../specs/2026-08-08-chatterbox-tts-design.md | 53 ++++++ requirements.txt | 8 + tests/test_app.py | 158 +++++++++++++++++ tests/test_synthesis.py | 51 ++++++ voices/.gitkeep | 0 9 files changed, 620 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.py create mode 100644 docs/superpowers/plans/2026-08-08-chatterbox-tts.md create mode 100644 docs/superpowers/specs/2026-08-08-chatterbox-tts-design.md create mode 100644 requirements.txt create mode 100644 tests/test_app.py create mode 100644 tests/test_synthesis.py create mode 100644 voices/.gitkeep diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..287bedc --- /dev/null +++ b/.gitignore @@ -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 + diff --git a/README.md b/README.md new file mode 100644 index 0000000..09d79ec --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/app.py b/app.py new file mode 100644 index 0000000..67b5510 --- /dev/null +++ b/app.py @@ -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") diff --git a/docs/superpowers/plans/2026-08-08-chatterbox-tts.md b/docs/superpowers/plans/2026-08-08-chatterbox-tts.md new file mode 100644 index 0000000..79de0e4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-chatterbox-tts.md @@ -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. diff --git a/docs/superpowers/specs/2026-08-08-chatterbox-tts-design.md b/docs/superpowers/specs/2026-08-08-chatterbox-tts-design.md new file mode 100644 index 0000000..0502140 --- /dev/null +++ b/docs/superpowers/specs/2026-08-08-chatterbox-tts-design.md @@ -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. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b0238e0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi +uvicorn[standard] +pydantic +pytest +setuptools<81 +chatterbox-tts==0.1.7 +torch==2.6.0 +torchaudio==2.6.0 diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..d9c349d --- /dev/null +++ b/tests/test_app.py @@ -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() diff --git a/tests/test_synthesis.py b/tests/test_synthesis.py new file mode 100644 index 0000000..84f73c3 --- /dev/null +++ b/tests/test_synthesis.py @@ -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 diff --git a/voices/.gitkeep b/voices/.gitkeep new file mode 100644 index 0000000..e69de29