12 KiB
speech — CPU STT + TTS, one OpenAI-shaped surface
Five files are the service: main.py (routes), stt.py (faster-whisper),
tts.py (kokoro + ffmpeg for mp3), transcript.py (the growing transcript),
vad.py (the squelch in front of it).
Nothing here authenticates: the ai plane (hanzoai/ai, behind api.hanzo.ai)
fronts this service and meters callers — including the anon grant tier (~3 uses
per anon) which lives THERE, not here.
The streaming transcript (/v1/audio/transcript)
Batch stays at /v1/audio/transcriptions. The streaming sibling is a resource
that grows: POST to open, POST chunks to it, DELETE to close and flush.
- Half duplex over plain HTTP, and this is forced, not preferred.
aiis a child process of the cloud pod reached over ZAP, andzap-proto/httpbuilds a request as ONE frame — there isFrameResponseHeadfor streaming responses but no request-head frame, andzipcontains noHijack. So a WebSocket and a streamed request body BOTH cannot reach this service, no matter what the edge supports.hanzoai/ingressdoes pass upgrades, so testingwss://against ingress alone answers the wrong question — the wall is inside the pod. - Audio is raw little-endian int16, mono, 16 kHz, in the body. No base64 (that is a WebSocket text-frame tax we do not pay), no container to demux.
- Metering:
durationon each ack is the seconds THAT push put in front of a decoder, named as the batch route names it, soaireads it into the sameAudioDurationSecondsit already meters (stt/openai.go:133) with no new billing code. It is arithmetic on the bytes that got past the squelch, so it does not depend on the decoder returning anything, and summing the pushes yields the audio decoded — never more than the audio submitted. A push the squelch drops reports 0.0; the push that OPENS it reports more than its own length, because it carries the lead-in that earlier pushes withheld. - Silence is not decoded and not billed (
vad.py). Silero already ships inside faster-whisper and already runs inside every decode, so running it in FRONT of the decoder costs no new weight and no new bake step — 0.67 ms per 250 ms push, measured on the pod, against a decode of seconds. It is a squelch, not a per-window classifier: two thresholds to open and close,HANGof quiet before it closes, andLEADof audio held back and handed over by the push that opens. Measured on the pod: an idle room bills 0.0 up to about -45 dBFS RMS of room tone (it starts to open on tone above that); a keyboard click and full-scale white noise do not open it; a meeting bills its speech plus about 0.9 s per utterance and nothing for the lulls, so the share saved is 0% when someone is talking continuously and 86% at 30 s between turns. The lead-in is the part that is easy to get wrong and expensive to lose. Sliding a word across a push shows why: withLEAD = 0, 8 of 32 alignments clip up to 71.8 ms off the front of the word, because a push is admitted whole and a word beginning near the end of one has nothing in hand. At 0.5 s, none clip and the worst case still keeps 428 ms of margin. Hold the lead by slicing from the FRONT —[-int(LEAD * SECOND):]reads as the last N bytes and is the whole buffer when N is 0, which keeps every second of silence and hands the lot to the decoder on the next word. - Whisper decodes a span, not a sample. The window is re-decoded as it fills; segments ending a guard-length before the audio are committed and their bytes freed, which is what bounds the window and stops committed text being revised.
Sessions are pod-local, and speech.yaml sets replicas: 2. Pushes reach
speech.hanzo.svc, which balances, so a session opened on one pod is not found
on the other. Whoever routes must pin a session to the pod that owns it; that
belongs in ai (which already does provider routing), not here — this service
decodes, it does not route. Until that exists the endpoint is not usable through
the plane, and ai has no route for it yet either.
What it sustains, and where that number comes from
bench.py is the instrument: say once for a corpus of real speech carrying a
word-to-time map, then cost for what a decode pass costs and talk to drive N
sessions in real time. Everything below was measured with it on a four vCPU
DigitalOcean worker — the shape a speech pod runs in. That qualifier is the
whole point: the same code and the same run on four threads of a desktop Zen 5
retires 13.7 audio-seconds per wall-second where the pod retires 5.7. A capacity
number measured on the box is 2.4x the truth.
Ack latency cannot see any of this and none of these numbers is one. A push returns whatever text the session has, so it costs a memcpy whether the decoder is idle or minutes behind — 3.8 ms while the backlog grew fourfold. The metric is backlog, as lag: wall seconds elapsed minus seconds of audio already transcribed. The verdict is its SLOPE over the second half of a run, in seconds of lag added per minute of meeting. Runs must be MINUTES long: a stalling session looks fine for its first four minutes, which is how seconds-long runs came to predict eight concurrent meetings and were wrong by an order of magnitude.
A pass costs what it costs, almost regardless of what it covers. Whisper always encodes a thirty second mel window, so on the pod one pass takes 2.7 s over a one second window and 3.2 s over an eight second one — about 8.4 CPU-seconds either way. Capacity is a budget of PASSES, not of audio, and the way to buy it is to pass less often, never to pass over less.
Past the mel window that cost multiplies. 24 s -> 5.0 s per pass, 30 s -> 7.5, 45 s -> 23.5. It is a cliff, not a slope.
So the pod has a throughput PEAK, and the peak is the instability. In steady state a session gets one pass per interval and that pass covers the audio that arrived during it, so audio retired per wall second IS the number of real-time streams served, and lag is roughly the window. Measured on the pod:
window 8 s 12 s 16 s 24 s 30 s
streams served 3.1 4.4 5.0 3.9 2.8
Below the peak the loop is self-correcting: a longer window is cheaper per second, so lag comes back. Past it, a longer window is DEARER per second, so lag grows, which lengthens the window — runaway. That is why the failure is a cliff and not a gentle degradation, and why the working point has to sit on the rising branch rather than at the top of the curve.
Measured, in eight minute runs at 250 ms chunks, and the two agree to within a few percent — a session settles where its window buys exactly its own concurrency:
sessions lag slope verdict
3 7.7 s -2.36 s/min settles
4 12.7 s -0.45 s/min settles
So: four concurrent streams of continuous speech per four vCPU pod, with the transcript about thirteen seconds behind, or three at eight seconds. Two replicas is eight. The curve says five sits ON the peak and six has no fixed point at all.
The failure past that edge is not a gentle one. The same runs against the earlier
two-worker build, which peaked lower, settled at one and two sessions and at FOUR
ran away to 326 s of lag at +44 s/min — and closing those sessions then blocked for
400 s each, because DELETE decodes everything that piled up.
A session's ceiling is LIMIT = 600 s, so a 45 minute meeting is five sessions,
not one. Closing decodes the whole remaining window and blocks, and the transport
admits one outstanding chunk, so the roll costs the microphone that whole pause.
At three streams that is a couple of seconds every ten minutes; on a session that
has fallen behind it is minutes.
What more of it costs
Per pod, three streams. Per stream of continuous speech that is 1.33 vCPU. So:
concurrent streams pods (4 vCPU) vCPU
10 4 16
100 34 136
1000 334 1336
Per-participant streams are nearly free next to that, because the squelch means a quiet participant is never decoded: about 0.27% of a core for the Silero pass on 250 ms of quiet. A thousand five-person meetings is ~1000 voiced streams plus ~13 cores of squelch, not 5000 streams.
Buying 334 four-core nodes is the wrong answer, so state the alternatives with the number each has to beat.
Batching is not the lever, and that is measured, not assumed. The fixed thirty
second encode looks exactly like the shape that amortizes, and faster-whisper
carries BatchedInferencePipeline, but on CPU it does not amortize: eight 12 s
windows took 11.64 s sequentially and 6.79 s batched — 1.71x the wall clock for
the same CPU (24.1 vs 23.6 CPU-seconds). Batching overlaps the work, it does
not reduce it, so it helps a pod whose cores are idle and does nothing for one
that is already saturated. Where it would pay is a GPU, where the fixed cost is
underused hardware rather than arithmetic.
A GPU has to retire 1000 audio-seconds per wall second of distil-small.en over ~16 s windows to replace those 334 pods. No GPU figure here is measured, the cluster has no GPU node, and every large node is tainted for CI, so that is a procurement decision to be measured on the card and not believed before it is.
- Weights bake at image BUILD (deterministic image, boots offline). Adding a
model = extend
MODELSin stt.py/tts.py AND the bake step in the Dockerfile. - There is no fast tier to add:
whisperalready IS the fast one. Measured in the pod, both models on the same kokoro fixture, three runs each, median, no HTTP in the loop:whisper(distil-small.en) runs at 1.35–2.75× realtime andwhisper-small(small) at 1.03–1.73×, so the default is 1.25–1.64× the faster of the two, and both transcribe the fixture at WER 0.00 — clean, at 20 dB SNR and at 10 dB SNR alike. Splitting live captions from a final pass would mean a bigger model for the final one, andsmallat 1.03× is already the largest that keeps up with a meeting on four cores with no GPU in the cluster.whisper-smallearns its place on LANGUAGE, not on accuracy or speed: distil-small.en is English-only. whisper/kokoroare the ROUTED names the ai plane's provider rows send; 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.pytestgates the build and runs before the registry login, so a red suite cannot publish. - Two gates, and the second one exists because the first is blind.
test_speech.pyreplaces the MODEL (neverstt.segmentsorTranscript), so it needs no weights and the window arithmetic is checkable exactly — its stand-in names each segment after the samples inside it, so a wrong trim changes the words, not just a length. But a suite that only ever sees a stand-in cannot tell a working decoder from a deleted one: breakingstt.loadleaves it fully green.test_live.pycloses that — kokoro speaks a sentence and whisper has to say it back, against real weights, and it kills that mutation. It runs as thelivebuild stage, which sits abovefinalso an untargeteddocker buildstill produces an image without dev dependencies. Stub 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.yamlpins the semver; no ingress (in-cluster only). - Verifying a deploy: the deployment's image is not the served image. After a
pin,
deploy/speechreports the new tag immediately while the old pods still answer — the image is ~1.5 GB, so the pull alone runs about a minute and the ReplicaSets overlap.readyReplicascounts the OLD pods during that window, so it reads 2/2 and means nothing. Check for a READY pod on the new digest, then ask the running app what it serves (/openapi.json) or push real audio through it. There is no ingress, so probing iskubectl port-forward svc/speech— which attaches to ONE pod, so mid-rollout it may answer from either version. - Pushes to main have twice failed to create an Actions run (the run is simply
never created;
workflow_dispatchon the same ref works, and other repos on the forge still trigger on push). Check for a run after pushing rather than assuming one exists — the previous release was dispatched by hand too.