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:
+25
-1
@@ -14,6 +14,7 @@ Foot-guns (verified live 2026-08-02):
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import struct
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
@@ -97,6 +98,27 @@ class EmotionDials:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _finalize_wav_header(data: bytes) -> bytes:
|
||||||
|
"""Rewrite the RIFF + data chunk sizes with the real byte counts.
|
||||||
|
|
||||||
|
The Zonos gateway emits 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 to decode it; a 0xFFFFFFFF length reads as raw/streaming PCM
|
||||||
|
and won't play (operator-reported). Now that the whole clip is buffered we know the
|
||||||
|
real sizes, so patch them in. Idempotent — writing an already-correct size is a
|
||||||
|
no-op; safe if the data chunk isn't found (leaves those bytes untouched).
|
||||||
|
"""
|
||||||
|
if len(data) < 44: # shorter than a canonical PCM header — nothing to patch
|
||||||
|
return data
|
||||||
|
buf = bytearray(data)
|
||||||
|
struct.pack_into("<I", buf, 4, len(buf) - 8) # RIFF chunk size = file len - 8
|
||||||
|
data_pos = buf.find(b"data", 12) # first "data" after the fmt chunk = the data chunk
|
||||||
|
if data_pos != -1 and data_pos + 8 <= len(buf):
|
||||||
|
struct.pack_into("<I", buf, data_pos + 4, len(buf) - (data_pos + 8))
|
||||||
|
return bytes(buf)
|
||||||
|
|
||||||
|
|
||||||
def _clamp(x: float, lo: float, hi: float) -> float:
|
def _clamp(x: float, lo: float, hi: float) -> float:
|
||||||
"""Clamp to [lo, hi], NaN-safe: a NaN axis (a dead/malformed signal) → 0.0
|
"""Clamp to [lo, hi], NaN-safe: a NaN axis (a dead/malformed signal) → 0.0
|
||||||
rather than propagating through max/min into the gateway."""
|
rather than propagating through max/min into the gateway."""
|
||||||
@@ -153,4 +175,6 @@ async def tts_synthesize(
|
|||||||
# WAVE form tag (8:12); a `b"RIFF..."` body that isn't WAVE would still fail decode.
|
# WAVE form tag (8:12); a `b"RIFF..."` body that isn't WAVE would still fail decode.
|
||||||
if data[:4] != b"RIFF" or data[8:12] != b"WAVE":
|
if data[:4] != b"RIFF" or data[8:12] != b"WAVE":
|
||||||
raise TtsUnavailable("gateway returned a non-wav body")
|
raise TtsUnavailable("gateway returned a non-wav body")
|
||||||
return data
|
# The gateway's streaming header carries 0xFFFFFFFF sizes; rewrite them with the
|
||||||
|
# real byte counts so a browser <audio> element can decode the finite clip.
|
||||||
|
return _finalize_wav_header(data)
|
||||||
|
|||||||
+40
-3
@@ -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).
|
INV-TTS-4: a gateway failure / non-wav body → TtsUnavailable (caller degrades).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
import math
|
import math
|
||||||
|
import struct
|
||||||
|
import wave
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
@@ -22,9 +25,20 @@ from ratatoskr.tts import (
|
|||||||
tts_synthesize,
|
tts_synthesize,
|
||||||
)
|
)
|
||||||
|
|
||||||
# A minimally-valid wav body: the DEC-3 guard checks the RIFF magic (0:4) + WAVE
|
|
||||||
# form tag (8:12).
|
def _wav(*, streaming: bool = False, nsamples: int = 8) -> bytes:
|
||||||
_WAV = b"RIFF\x00\x00\x00\x00WAVE" + b"\x00" * 32
|
"""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:
|
class TestPadState:
|
||||||
@@ -182,6 +196,29 @@ class TestTtsSynthesize:
|
|||||||
url="http://tts.example/v1/audio/speech",
|
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
|
@respx.mock
|
||||||
async def test_riff_but_not_wave_raises_tts_unavailable(self) -> None:
|
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
|
# A body with the RIFF magic but a non-WAVE form tag (e.g. RIFF/AVI, or a
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ AsyncClient.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import struct
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
@@ -1464,7 +1465,14 @@ class TestTtsEndpoint:
|
|||||||
(DEC-8); emotion dials map the browser-sent live PAD (DEC-7); a gateway failure
|
(DEC-8); emotion dials map the browser-sent live PAD (DEC-7); a gateway failure
|
||||||
degrades to 503 (INV-TTS-4)."""
|
degrades to 503 (INV-TTS-4)."""
|
||||||
|
|
||||||
_WAV = b"RIFF\x00\x00\x00\x00WAVE" + b"\x00" * 32
|
# A proper finite PCM WAV (correct RIFF + data sizes) so the header-finalize step is
|
||||||
|
# idempotent — the endpoint returns these bytes unchanged.
|
||||||
|
_WAV = (
|
||||||
|
b"RIFF" + (36 + 16).to_bytes(4, "little") + b"WAVE"
|
||||||
|
+ b"fmt " + (16).to_bytes(4, "little")
|
||||||
|
+ struct.pack("<HHIIHH", 1, 1, 44100, 88200, 2, 16)
|
||||||
|
+ b"data" + (16).to_bytes(4, "little") + b"\x11\x22" * 8
|
||||||
|
)
|
||||||
_TTS = "http://tts.example/v1/audio/speech"
|
_TTS = "http://tts.example/v1/audio/speech"
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
|
|||||||
Reference in New Issue
Block a user