chore: symlink AGENTS.md + GEMINI.md + CLAUDE.md -> LLM.md

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-02 01:14:23 -07:00
co-authored by Hanzo Dev
parent abcd9b9faa
commit 71f43202b0
4 changed files with 203 additions and 201 deletions
Symlink
+1
View File
@@ -0,0 +1 @@
LLM.md
-200
View File
@@ -1,200 +0,0 @@
# 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.** `ai` is a
child process of the cloud pod reached over ZAP, and `zap-proto/http` builds a
request as ONE frame — there is `FrameResponseHead` for streaming responses but
no request-head frame, and `zip` contains no `Hijack`. So a WebSocket and a
streamed request body BOTH cannot reach this service, no matter what the edge
supports. `hanzoai/ingress` does pass upgrades, so testing `wss://` 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: `duration` on each ack is the seconds THAT push put in front of a
decoder**, named as the batch route names it, so `ai` reads it into the same
`AudioDurationSeconds` it 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, `HANG` of quiet
before it closes, and `LEAD` of 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: with `LEAD = 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 `MODELS` in stt.py/tts.py AND the bake step in the Dockerfile.
- **There is no fast tier to add: `whisper` already 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
and `whisper-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, and `small` at 1.03× is already
the largest that keeps up with a meeting on four cores with no GPU in the
cluster. `whisper-small` earns its place on LANGUAGE, not on accuracy or
speed: distil-small.en is English-only.
- `whisper` / `kokoro` are 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. `pytest` gates 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.py` replaces the MODEL (never `stt.segments` or `Transcript`), 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: breaking `stt.load`
leaves it fully green. `test_live.py` closes that — kokoro speaks a sentence
and whisper has to say it back, against real weights, and it kills that
mutation. It runs as the `live` build stage, which sits above `final` so an
untargeted `docker build` still 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.yaml` pins
the semver; no ingress (in-cluster only).
- **Verifying a deploy: the deployment's image is not the served image.** After a
pin, `deploy/speech` reports 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. `readyReplicas` counts 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 is `kubectl 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_dispatch` on 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.
Symlink
+1
View File
@@ -0,0 +1 @@
LLM.md
Symlink
+1
View File
@@ -0,0 +1 @@
LLM.md
-1
View File
@@ -1 +0,0 @@
CLAUDE.md
+200
View File
@@ -0,0 +1,200 @@
# 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.** `ai` is a
child process of the cloud pod reached over ZAP, and `zap-proto/http` builds a
request as ONE frame — there is `FrameResponseHead` for streaming responses but
no request-head frame, and `zip` contains no `Hijack`. So a WebSocket and a
streamed request body BOTH cannot reach this service, no matter what the edge
supports. `hanzoai/ingress` does pass upgrades, so testing `wss://` 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: `duration` on each ack is the seconds THAT push put in front of a
decoder**, named as the batch route names it, so `ai` reads it into the same
`AudioDurationSeconds` it 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, `HANG` of quiet
before it closes, and `LEAD` of 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: with `LEAD = 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 `MODELS` in stt.py/tts.py AND the bake step in the Dockerfile.
- **There is no fast tier to add: `whisper` already 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
and `whisper-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, and `small` at 1.03× is already
the largest that keeps up with a meeting on four cores with no GPU in the
cluster. `whisper-small` earns its place on LANGUAGE, not on accuracy or
speed: distil-small.en is English-only.
- `whisper` / `kokoro` are 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. `pytest` gates 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.py` replaces the MODEL (never `stt.segments` or `Transcript`), 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: breaking `stt.load`
leaves it fully green. `test_live.py` closes that — kokoro speaks a sentence
and whisper has to say it back, against real weights, and it kills that
mutation. It runs as the `live` build stage, which sits above `final` so an
untargeted `docker build` still 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.yaml` pins
the semver; no ingress (in-cluster only).
- **Verifying a deploy: the deployment's image is not the served image.** After a
pin, `deploy/speech` reports 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. `readyReplicas` counts 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 is `kubectl 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_dispatch` on 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.