Files
speech/test_speech.py
T
zeekayandHanzo Dev 6f516b97e5 speech: return the duration it measured, and the container it produced
Two values were computed here and thrown away, and both were the answer to a
question the caller had to ask.

DURATION. `segments, _ = load(model).transcribe(...)` discarded faster-whisper's
info, which carries the audio's duration. Transcription is billed per MINUTE by
everyone who sells it, so a response without a duration cannot be billed at all
— the ai plane metered every transcription at zero seconds no matter how much
audio was sent. transcribe returns it now, and verbose_json carries it exactly
as OpenAI's does (plain json stays {"text"}, so a client reading the standard
shape is untouched). It is `duration`, the audio SUBMITTED, never
`duration_after_vad`: a caller is charged for what they asked us to listen to,
not for how much of it turned out to be talking.

CONTAINER. `if response_format == "wav" ... else mp3` answered opus, aac, flac
and pcm with an MP3. The gateway then labelled the response from the REQUEST, so
a caller asking for opus received `Content-Type: audio/opus` wrapping an MP3 —
the API lying to whatever had to play it. ffmpeg in this image has libopus,
libmp3lame, aac, flac and pcm_s16le with the matching muxers, verified against
`ffmpeg -encoders` in the running pod, so every OpenAI format is really encoded
and anything else is a 400 that names what IS available. Substitution is gone,
not relabelled.

A suite arrives with them, and gates the build before the registry login so a
red run cannot publish. It stubs the two model calls, needing no weights and no
GPU, and answers in seconds.

The mutation that matters: stubbing `stt.transcribe` to test the ROUTE let the
duration fix be reverted with the suite still green. The tests that catch it
stub the MODEL and exercise the real function — testing the seam you fixed, not
the one above it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-07 16:00:10 -07:00

202 lines
7.3 KiB
Python

"""The service's contract, tested without loading a single weight.
stt.transcribe and tts.speak are replaced per test: the models are baked into
the image and cost seconds to load, so exercising them here would test
faster-whisper and kokoro rather than this service. What IS this service's own
is the shape of what it returns — which is exactly where both defects lived.
"""
import pytest
from fastapi.testclient import TestClient
import main
import stt
import tts
client = TestClient(main.app)
@pytest.fixture
def heard(monkeypatch):
"""Stub the transcriber; record the call, return a known text + duration."""
calls = {}
def fake(model, audio, language):
calls.update(model=model, audio=audio, language=language)
return "the quick brown fox", 12.5
monkeypatch.setattr(stt, "transcribe", fake)
return calls
@pytest.fixture
def spoken(monkeypatch):
"""Stub the synthesizer; echo back the format it was asked for."""
def fake(ask):
mime, _ = tts.FORMATS[ask.response_format]
return b"AUDIOBYTES", mime
monkeypatch.setattr(tts, "speak", fake)
# ── STT: the duration is the billable quantity ──────────────────────────────
def test_verbose_json_carries_duration(heard):
"""The defect: `segments, _ = ...transcribe(...)` discarded info.duration, so
the ai plane metered every transcription at zero seconds no matter how much
audio was sent. Transcription is billed per MINUTE, so a response with no
duration cannot be billed at all."""
r = client.post(
"/v1/audio/transcriptions",
files={"file": ("a.wav", b"RIFFxxxxWAVE", "audio/wav")},
data={"model": "whisper", "response_format": "verbose_json"},
)
assert r.status_code == 200, r.text
assert r.json() == {"text": "the quick brown fox", "duration": 12.5}
def test_plain_json_is_unchanged(heard):
"""OpenAI's `json` body is {"text"}; a client reading the standard shape must
not start receiving extra fields because we needed one internally."""
r = client.post(
"/v1/audio/transcriptions",
files={"file": ("a.wav", b"x", "audio/wav")},
data={"model": "whisper", "response_format": "json"},
)
assert r.json() == {"text": "the quick brown fox"}
def test_text_format_returns_plain_text(heard):
r = client.post(
"/v1/audio/transcriptions",
files={"file": ("a.wav", b"x", "audio/wav")},
data={"model": "whisper", "response_format": "text"},
)
assert r.text == "the quick brown fox"
assert r.headers["content-type"].startswith("text/plain")
def test_unknown_model_is_404(heard):
r = client.post(
"/v1/audio/transcriptions",
files={"file": ("a.wav", b"x", "audio/wav")},
data={"model": "not-a-model"},
)
assert r.status_code == 404
# ── TTS: the container is what we say it is ─────────────────────────────────
@pytest.mark.parametrize(
"fmt,mime",
[
("wav", "audio/wav"),
("mp3", "audio/mpeg"),
("opus", "audio/ogg"),
("aac", "audio/aac"),
("flac", "audio/flac"),
("pcm", "audio/pcm"),
],
)
def test_every_format_is_labelled_as_itself(spoken, fmt, mime):
"""The defect: `if response_format == "wav" ... else mp3` answered opus, aac,
flac and pcm with an MP3, and the gateway labelled it from the REQUEST — so
a caller asking for opus got `Content-Type: audio/opus` wrapping an MP3."""
r = client.post("/v1/audio/speech", json={"model": "kokoro", "input": "hi", "response_format": fmt})
assert r.status_code == 200, r.text
assert r.headers["content-type"].startswith(mime)
def test_unsupported_format_is_refused_by_name(spoken):
"""Refuse, never substitute. The message names what IS available so a caller
can act on it."""
r = client.post(
"/v1/audio/speech",
json={"model": "kokoro", "input": "hi", "response_format": "midi"},
)
assert r.status_code == 400
assert "midi" in r.text
for fmt in tts.FORMATS:
assert fmt in r.text
def test_default_format_is_mp3(spoken):
"""mp3 is OpenAI's default and the one every browser plays; hanzo.chat's
playback path depends on it."""
r = client.post("/v1/audio/speech", json={"model": "kokoro", "input": "hi"})
assert r.headers["content-type"].startswith("audio/mpeg")
def test_unknown_voice_is_400(spoken):
r = client.post(
"/v1/audio/speech",
json={"model": "kokoro", "input": "hi", "voice": "nobody"},
)
assert r.status_code == 400
def test_speak_refuses_unknown_format_even_when_called_directly():
"""The guard lives in tts.speak too, not only in the route: a second caller
reaching the synthesizer directly cannot get a substituted container."""
with pytest.raises(ValueError):
tts.speak(tts.Ask(input="hi", response_format="midi"))
def test_formats_cover_the_openai_set():
"""Every format the OpenAI audio API defines is encodable in this image —
verified against `ffmpeg -encoders` (libopus, libmp3lame, aac, flac,
pcm_s16le). If one is dropped, callers silently lose it."""
assert set(tts.FORMATS) == {"wav", "mp3", "opus", "aac", "flac", "pcm"}
# ── stt.transcribe itself: the duration must come from the model, not a stub ──
class _Segment:
def __init__(self, text): self.text = text
class _Info:
"""faster-whisper's TranscriptionInfo, reduced to what billing reads."""
duration = 7.25 # the audio SUBMITTED
duration_after_vad = 3.10 # speech only — deliberately different
class _Model:
def __init__(self): self.called_with = {}
def transcribe(self, audio, language=None, vad_filter=None):
self.called_with = dict(language=language, vad_filter=vad_filter)
return iter([_Segment(" the quick "), _Segment(" brown fox ")]), _Info()
def test_transcribe_returns_the_models_duration(monkeypatch):
"""The defect lived HERE, not in the route: `segments, _ = ...` dropped the
info that carries the duration. Stubbing the route's transcriber cannot see
that, so this stubs the MODEL and exercises the real function — the test the
absence of which let the first mutation of this fix pass unnoticed."""
model = _Model()
monkeypatch.setattr(stt, "load", lambda m: model)
text, duration = stt.transcribe("whisper", b"RIFFxxxx", None)
assert duration == 7.25, "the audio duration must reach the caller; 0 means unbillable"
assert text == "the quick brown fox", "segments are joined and stripped"
def test_transcribe_bills_submitted_audio_not_speech_only(monkeypatch):
"""`duration` (submitted), never `duration_after_vad` (speech only). A caller
is charged for what they asked us to listen to, not for how much of it turned
out to be talking — and VAD is on, so the two always differ."""
monkeypatch.setattr(stt, "load", lambda m: _Model())
_, duration = stt.transcribe("whisper", b"x", None)
assert duration == _Info.duration
assert duration != _Info.duration_after_vad
def test_transcribe_forwards_the_language_hint(monkeypatch):
model = _Model()
monkeypatch.setattr(stt, "load", lambda m: model)
stt.transcribe("whisper", b"x", "de")
assert model.called_with["language"] == "de"
assert model.called_with["vad_filter"] is True