feat: add OpenAI-compatible Chatterbox TTS service
This commit is contained in:
commit
38a2a48f28
9 changed files with 620 additions and 0 deletions
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue