mirror of
https://github.com/hanzoai/speech.git
synced 2026-10-04 07:37:19 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user