Files
ratatoskr/tests/test_tts.py
T
vh 19b499ab50 feat(tts): migrate off Zonos to chatterbox-fast; drop affect, hold English
Repoint the TTS client from the Zonos gateway (:8890 /v1/audio/speech) to
chatterbox-fast (:8197 /tts — bespoke non-OpenAI {text,voice,format,stream}
schema, no auth, 24kHz, infra-ops-verified). tts.py stays the single swap seam.

Dropped, no backward-compat (pre-v1):
- Affect (DEC-7): the Turbo checkpoint has no emotion knob, so PadState,
  EmotionDials, pad_to_dials, the /api/tts p/a fields, and the browser pad
  argument are deleted. Voice is now flat.
- Client-side chunking (DEC-10): chatterbox has no per-synth cap and chunks
  internally, so chunk_text/tts_stream_long/_pcm_after_header are deleted; a
  single tts_stream call voices a whole turn, the mid-stream yielded_any degrade
  folded into it.
- Language pin (DEC-9): no language field; re-purposed to sampling curbs (below).

Fixed / added:
- Browser Web Audio sample rate 44100 -> 24000 (the chatterbox rate).
- Default voice Cora -> glados_25s; donut registered lowercase at /refs/donut.wav.
- English-drift curb: Turbo is multilingual-leaky and wanders off English on a
  long generation (the gateway scheduler ratchets chunk size unbounded). Tighten
  sampling in gateway_body: top_k 1000->80, top_p 0.95->0.85, temperature
  0.8->0.5. These reduce drift probability; the guaranteed fix is a server-side
  max-chunk cap (infra-ops, greenlit).
- OOM guard (DEC-9a): a long generation can OOM the shared 3090, returning 200
  with a 0-byte body; /api/tts surfaces an empty 200 as 503 rather than
  committing silent audio.

Contract donut_voiced_interview.contract.md amended: migration banner, DEC-1/3/8
amended, DEC-7/9/10 retired with historical notes, DEC-9a added.

Tests rewritten to the new wire; 520 green. Live-smoked against the gateway
(24kHz synth + endpoint proxy + web console). persistent-memory.md committed
alongside (commit-along).
2026-08-07 10:23:13 -07:00

132 lines
5.5 KiB
Python

"""Tests for ratatoskr.tts — the STREAMING chatterbox-fast gateway client.
tts_stream proxies the gateway's chunked response verbatim (no buffering, no header
rewrite — the placeholder-size streaming WAV is meant to be played progressively). It
is the sole synthesis primitive: chatterbox chunks arbitrary-length text internally, so
there is no client-side chunk-and-concatenate (retired with the Zonos migration), and no
affect dials (Turbo has no emotion knob). The mid-stream degrade policy is folded in.
"""
import httpx
import pytest
import respx
from ratatoskr.tts import (
_TTS_TEMPERATURE,
_TTS_TOP_K,
_TTS_TOP_P,
CHATTERBOX_TTS_URL,
TtsUnavailable,
gateway_body,
tts_stream,
)
_URL = "http://tts.example/tts"
# The gateway's streaming WAV bytes (placeholder 0xFFFFFFFF sizes). We pass them through
# untouched, so the content only has to round-trip.
_WAV = (
b"RIFF\xff\xff\xff\xffWAVEfmt \x10\x00\x00\x00" + b"\x00" * 20
+ b"data\xff\xff\xff\xff" + b"\x11\x22" * 64
)
async def _drain(gen) -> bytes:
out = b""
async for chunk in gen:
out += chunk
return out
class _RaisingByteStream(httpx.AsyncByteStream):
"""A 200-body stream that yields `head` then drops mid-stream (an httpx.ReadError, a
RequestError subclass) — models a gateway connection drop AFTER the response committed."""
def __init__(self, head: bytes) -> None:
self._head = head
async def __aiter__(self):
yield self._head
raise httpx.ReadError("mid-stream drop")
async def aclose(self) -> None:
pass
class TestGatewayBody:
def test_bespoke_chatterbox_schema(self) -> None:
b = gateway_body("hi", "donut")
assert b["text"] == "hi" # "text", not "input"
assert b["voice"] == "donut"
assert b["format"] == "wav" # DEC-3 — "format", not "response_format"
assert b["stream"] is True # DEC-2 — play-as-it-arrives
def test_sampling_curbs_below_gateway_defaults_hold_english(self) -> None:
# DEC-9: the model has no `language` pin and Turbo's multilingual capacity leaks under
# high-entropy sampling on long turns. gateway_body tightens temperature/top_p/top_k
# below the gateway defaults (0.8 / 0.95 / 1000) to hold English — top_k the highest-
# leverage. Pin presence + the below-default relationship (infra-ops-authoritative).
b = gateway_body("a long turn", "donut")
assert b["temperature"] == _TTS_TEMPERATURE and _TTS_TEMPERATURE < 0.8
assert b["top_p"] == _TTS_TOP_P and _TTS_TOP_P < 0.95
assert b["top_k"] == _TTS_TOP_K and _TTS_TOP_K < 1000
def test_no_zonos_era_fields(self) -> None:
# The Zonos body fields are gone: no OpenAI `input`/`response_format`, no
# `language` pin (DEC-9 retired), no affect dials (DEC-7 retired).
b = gateway_body("hi", "Cora")
for dead in ("input", "response_format", "language", "emotion_valence",
"emotion_arousal", "emotion_enabled", "emotion_strength"):
assert dead not in b
class TestTtsStream:
@respx.mock
async def test_streams_chunks_and_posts_bespoke_body(self) -> None:
route = respx.post(_URL).mock(return_value=httpx.Response(200, content=_WAV))
async with httpx.AsyncClient() as client:
out = await _drain(tts_stream("hello there", voice="donut", client=client, url=_URL))
assert out == _WAV # passed through verbatim — no header rewrite
import json as _json
body = _json.loads(route.calls.last.request.content)
assert body["text"] == "hello there"
assert body["voice"] == "donut"
assert body["format"] == "wav"
assert body["stream"] is True
@respx.mock
async def test_default_url_is_chatterbox(self) -> None:
route = respx.post(CHATTERBOX_TTS_URL).mock(
return_value=httpx.Response(200, content=_WAV)
)
async with httpx.AsyncClient() as client:
await _drain(tts_stream("hi", voice="donut", client=client))
assert route.called # the module default points at the chatterbox gateway
@respx.mock
async def test_non_200_open_raises_before_any_chunk(self) -> None:
respx.post(_URL).mock(return_value=httpx.Response(500, content=b"boom"))
async with httpx.AsyncClient() as client:
with pytest.raises(TtsUnavailable) as exc:
await _drain(tts_stream("hi", voice="Cora", client=client, url=_URL))
assert exc.value.status == 500
@respx.mock
async def test_transport_error_on_open_raises(self) -> None:
respx.post(_URL).mock(side_effect=httpx.ConnectError("refused"))
async with httpx.AsyncClient() as client:
with pytest.raises(TtsUnavailable):
await _drain(tts_stream("hi", voice="Cora", client=client, url=_URL))
@respx.mock
async def test_mid_stream_drop_after_first_byte_degrades_not_raises(self) -> None:
# The 200 is committed once bytes flow; a later transport drop must DEGRADE
# (return what streamed), never raise — the pivot is yielded_any, folded in from
# the retired tts_stream_long. Keeps a committed StreamingResponse from an ASGI trace.
respx.post(_URL).mock(
return_value=httpx.Response(200, stream=_RaisingByteStream(_WAV))
)
async with httpx.AsyncClient() as client:
out = await _drain(tts_stream("hi", voice="donut", client=client, url=_URL))
assert out == _WAV # head kept, no raise