fix: finalize the Zonos streaming WAV header so the browser can play it

The Zonos gateway returns a STREAMING wav header — the RIFF chunk size (offset 4)
and the data chunk size are both 0xFFFFFFFF ("unknown length"), because it can
stream. A browser <audio> element playing a fully-downloaded blob needs a finite,
correctly-sized WAV; a 0xFFFFFFFF length reads as raw/streaming PCM and won't play
(operator-reported: "zonos sends pcm by default, but the browser wants wav").

tts_synthesize now rewrites both size fields with the real byte counts — the whole
clip is buffered server-side, so the sizes are known. Idempotent on an already-
correct header; no-op-safe if the data chunk isn't found. Live-verified: /api/tts
output now opens as a valid finite WAV (wave.open: 1ch/16bit/44.1kHz), RIFF + data
sizes correct where they were 0xFFFFFFFF before.

TDD: +1 test (streaming 0xFFFFFFFF header -> real sizes, wave-module-decodable); the
_WAV fixtures upgraded from bare RIFF stubs to proper finite WAVs. 525 green.
This commit is contained in:
vh
2026-08-01 23:25:32 -07:00
parent 09e425787b
commit 6c3c08b10f
3 changed files with 74 additions and 5 deletions
+40 -3
View File
@@ -8,7 +8,10 @@ DEC-3: response_format is ALWAYS "wav". DEC-7: live PAD → emotion dials.
INV-TTS-4: a gateway failure / non-wav body → TtsUnavailable (caller degrades).
"""
import io
import math
import struct
import wave
import httpx
import pytest
@@ -22,9 +25,20 @@ from ratatoskr.tts import (
tts_synthesize,
)
# A minimally-valid wav body: the DEC-3 guard checks the RIFF magic (0:4) + WAVE
# form tag (8:12).
_WAV = b"RIFF\x00\x00\x00\x00WAVE" + b"\x00" * 32
def _wav(*, streaming: bool = False, nsamples: int = 8) -> bytes:
"""A canonical PCM mono/44.1k/16-bit WAV. `streaming=True` mimics the Zonos
gateway's placeholder header (RIFF + data sizes = 0xFFFFFFFF)."""
samples = b"\x11\x22" * nsamples
fmt = struct.pack("<HHIIHH", 1, 1, 44100, 88200, 2, 16)
body = b"WAVE" + b"fmt " + struct.pack("<I", 16) + fmt + b"data"
data_size = 0xFFFFFFFF if streaming else len(samples)
riff_size = 0xFFFFFFFF if streaming else (len(body) + 4 + len(samples))
return b"RIFF" + struct.pack("<I", riff_size) + body + struct.pack("<I", data_size) + samples
# A proper finite WAV — the header-finalize step is idempotent on it (sizes already real).
_WAV = _wav()
class TestPadState:
@@ -182,6 +196,29 @@ class TestTtsSynthesize:
url="http://tts.example/v1/audio/speech",
)
@respx.mock
async def test_finalizes_streaming_wav_header_to_real_sizes(self) -> None:
# The Zonos gateway returns a streaming header (RIFF + data sizes = 0xFFFFFFFF);
# the browser <audio> element can't play that. tts_synthesize must rewrite both
# to the real byte counts so the result is a decodable finite WAV.
streaming = _wav(streaming=True)
assert struct.unpack("<I", streaming[4:8])[0] == 0xFFFFFFFF # precondition
respx.post("http://tts.example/v1/audio/speech").mock(
return_value=httpx.Response(200, content=streaming)
)
async with httpx.AsyncClient() as client:
out = await tts_synthesize(
"hi", voice="Cora", dials=pad_to_dials(None), client=client,
url="http://tts.example/v1/audio/speech",
)
# RIFF size + data size are now real, not 0xFFFFFFFF.
assert struct.unpack("<I", out[4:8])[0] == len(out) - 8
dpos = out.find(b"data", 12)
assert struct.unpack("<I", out[dpos + 4:dpos + 8])[0] == len(out) - (dpos + 8)
# …and Python's wave module (a real decoder) can now open it as a finite clip.
with wave.open(io.BytesIO(out)) as w:
assert w.getnchannels() == 1 and w.getsampwidth() == 2 and w.getframerate() == 44100
@respx.mock
async def test_riff_but_not_wave_raises_tts_unavailable(self) -> None:
# A body with the RIFF magic but a non-WAVE form tag (e.g. RIFF/AVI, or a