feat: add OpenAI-compatible Chatterbox TTS service
This commit is contained in:
commit
38a2a48f28
9 changed files with 620 additions and 0 deletions
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal 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
54
README.md
Normal 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
121
app.py
Normal 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")
|
||||||
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.
|
||||||
8
requirements.txt
Normal file
8
requirements.txt
Normal 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
158
tests/test_app.py
Normal 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
51
tests/test_synthesis.py
Normal 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
0
voices/.gitkeep
Normal file
Loading…
Add table
Add a link
Reference in a new issue