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]>
This commit is contained in:
2026-08-07 16:00:10 -07:00
co-authored by Hanzo Dev
parent d519c1e460
commit 6f516b97e5
8 changed files with 957 additions and 614 deletions
+8
View File
@@ -19,6 +19,14 @@ jobs:
steps:
- uses: actions/checkout@v4
# The gate, and it runs BEFORE the registry login so a red suite cannot
# publish. It stubs the two model calls, so it needs no weights and no GPU
# and answers in seconds — which is the only reason a build lane can
# afford to depend on it. The image bakes 350MB of weights; discovering a
# contract break after that is discovering it too late.
- name: Test
run: uv run --group dev pytest -q
- uses: docker/login-action@v3
with:
registry: ghcr.io
+9 -1
View File
@@ -11,6 +11,14 @@ including the anon grant tier (~3 uses per anon) which lives THERE, not here.
renaming them is a cross-repo change, not a local one.
- CI is `.hanzo/workflows/deploy.yml` (git.hanzo.ai act_runner; GitHub Actions
is disabled account-wide). Version = pyproject's; a published tag is never
overwritten — bump patch (x.x.x+1) per release.
overwritten — bump patch (x.x.x+1) per release. `pytest` gates the build and
runs before the registry login, so a red suite cannot publish.
- `test_speech.py` stubs the two model calls, so it needs no weights: what is
tested is this service's CONTRACT — the duration it returns (the ai plane
meters transcription per minute) and the container it claims (a format is
encoded or refused, never substituted). Both are where its two shipped
defects lived. Test `stt.transcribe`/`tts.speak` by stubbing the MODEL, not
the function: stubbing the function tests the route and leaves the real one
free to regress.
- Deploy is declarative: universe `charts/app/values/hanzo/speech.yaml` pins
the semver; no ingress (in-cluster only).
+16 -1
View File
@@ -34,9 +34,14 @@ async def transcriptions(
):
if model not in stt.MODELS:
raise HTTPException(404, f"unknown model {model!r}")
text = stt.transcribe(model, await file.read(), language)
text, duration = stt.transcribe(model, await file.read(), language)
if response_format == "text":
return Response(text, media_type="text/plain")
# verbose_json carries `duration`, exactly as OpenAI's does — and the ai
# plane asks for it because that duration is what meters the call. Plain
# json stays {"text"} so a client reading the standard shape is unaffected.
if response_format == "verbose_json":
return JSONResponse({"text": text, "duration": duration})
return JSONResponse({"text": text})
@@ -46,5 +51,15 @@ def speech(ask: tts.Ask):
raise HTTPException(404, f"unknown model {ask.model!r}")
if ask.voice not in tts.VOICES:
raise HTTPException(400, f"unknown voice {ask.voice!r}")
# Refuse a format we cannot make, rather than answering in a different one:
# every format below is really encoded, and anything else is a 400 naming
# what IS available. Substituting silently is how a caller asking for opus
# received an MP3 labelled audio/opus.
if ask.response_format not in tts.FORMATS:
raise HTTPException(
400,
f"unsupported response_format {ask.response_format!r}; "
f"supported: {', '.join(sorted(tts.FORMATS))}",
)
audio, mime = tts.speak(ask)
return Response(audio, media_type=mime)
+10 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "speech"
version = "0.1.0"
version = "0.1.1"
description = "CPU speech: STT (faster-whisper) + TTS (kokoro) behind one OpenAI-shaped surface"
requires-python = ">=3.12"
dependencies = [
@@ -11,3 +11,12 @@ dependencies = [
"kokoro-onnx>=0.4",
"soundfile>=0.13",
]
# The suite stubs the two model calls, so it needs neither weights nor a GPU and
# runs in seconds — which is what lets CI gate on it. httpx is what FastAPI's
# TestClient drives the app through.
[dependency-groups]
dev = [
"pytest>=8.0",
"httpx>=0.28",
]
+16 -3
View File
@@ -22,6 +22,19 @@ def load(model: str) -> WhisperModel:
return WhisperModel(MODELS[model], device="cpu", compute_type="int8")
def transcribe(model: str, audio: bytes, language: str | None) -> str:
segments, _ = load(model).transcribe(io.BytesIO(audio), language=language, vad_filter=True)
return " ".join(segment.text.strip() for segment in segments).strip()
def transcribe(model: str, audio: bytes, language: str | None) -> tuple[str, float]:
"""Transcribe, returning the text AND the audio's duration in seconds.
The duration is the billable quantity: transcription is priced per minute of
audio by everyone who sells it, and the ai plane meters this call. It was
already computed here and thrown away — `segments, _ = ...` — so audio billed
zero no matter how much of it was sent. It is `info.duration`, the length of
the audio SUBMITTED, not `duration_after_vad`, which is 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.
"""
segments, info = load(model).transcribe(
io.BytesIO(audio), language=language, vad_filter=True
)
text = " ".join(segment.text.strip() for segment in segments).strip()
return text, info.duration
+201
View File
@@ -0,0 +1,201 @@
"""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
+37 -6
View File
@@ -1,4 +1,4 @@
"""kokoro-onnx (82M) — the TTS half. wav natively; mp3 through ffmpeg."""
"""kokoro-onnx (82M) — the TTS half. wav natively; everything else via ffmpeg."""
import io
import subprocess
@@ -11,6 +11,27 @@ from pydantic import BaseModel
MODELS = {"kokoro": ("kokoro-v1.0.onnx", "voices-v1.0.bin")}
VOICES = ("af_heart", "af_bella", "am_michael")
# The OpenAI response formats, each mapped to the media type it REALLY is and the
# ffmpeg encode that produces it. wav needs no encode — soundfile writes it.
#
# This table replaced `if response_format == "wav" ... else mp3`, which answered
# every other format with an MP3 while the caller (and the gateway labelling the
# response) believed it was opus, aac or flac. Claiming a container we did not
# produce is the API lying to whatever has to play it, and a player handed the
# wrong container is entitled to refuse it. Every codec below is verified present
# in the image's ffmpeg (libopus, libmp3lame, aac, flac, pcm_s16le), so the
# honest answer here is to ENCODE what was asked for rather than to refuse it.
#
# opus is Ogg-contained, so it is audio/ogg — that is what the bytes are.
FORMATS: dict[str, tuple[str, list[str] | None]] = {
"wav": ("audio/wav", None),
"mp3": ("audio/mpeg", ["-f", "mp3", "-c:a", "libmp3lame"]),
"opus": ("audio/ogg", ["-f", "ogg", "-c:a", "libopus"]),
"aac": ("audio/aac", ["-f", "adts", "-c:a", "aac"]),
"flac": ("audio/flac", ["-f", "flac"]),
"pcm": ("audio/pcm", ["-f", "s16le", "-c:a", "pcm_s16le"]),
}
class Ask(BaseModel):
model: str = "kokoro"
@@ -26,16 +47,26 @@ def load(model: str) -> Kokoro:
def speak(ask: Ask) -> tuple[bytes, str]:
"""Synthesize, returning the audio and the media type it actually is.
An unknown format raises: the caller validates against FORMATS first and
answers 400, so this never silently substitutes one container for another.
"""
if ask.response_format not in FORMATS:
raise ValueError(f"unsupported response_format {ask.response_format!r}")
samples, rate = load(ask.model).create(ask.input, voice=ask.voice)
wav = io.BytesIO()
soundfile.write(wav, samples, rate, format="WAV")
if ask.response_format == "wav":
return wav.getvalue(), "audio/wav"
# OpenAI's default is mp3; ffmpeg is in the image for exactly this hop.
mime, encode = FORMATS[ask.response_format]
if encode is None:
return wav.getvalue(), mime
made = subprocess.run(
["ffmpeg", "-loglevel", "error", "-i", "pipe:0", "-f", "mp3", "pipe:1"],
["ffmpeg", "-loglevel", "error", "-i", "pipe:0", *encode, "pipe:1"],
input=wav.getvalue(),
capture_output=True,
check=True,
)
return made.stdout, "audio/mpeg"
return made.stdout, mime
Generated
+660 -602
View File
File diff suppressed because it is too large Load Diff