Skip to content

Commit fb8f85c

Browse files
committed
Document the speech-to-text architecture in its README
The STT README named only the purpose of the directory. It now gives the four crates and their dependency direction, the path of one take from audio append to publication, and the transcript states with their owners and invariants. - The transcript-state table covers finalized, decoded, agreed, and tentative text, accepted hypotheses, pending forced text, the anchored suffix, and held closed ranges. Each row names its type and file. - Further sections compare the interim and final passes, list the worker queue limits and the overload and shutdown behavior, and say what the replay, hour, native, and CI tests check. - The README states that the latency metrics are regression proxies and that CI does not load the recommended `base.en` and `small.en` pair.
1 parent be7ae6e commit fb8f85c

1 file changed

Lines changed: 74 additions & 0 deletions

File tree

‎crates/gateway/stt/README.md‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,77 @@
11
# Speech-to-text
22

33
This directory holds the gateway's speech-to-text subsystem, which turns recorded speech into text for gateway clients. The subsystem is private to the gateway family, and the rest of the family reaches it only through the transcription service.
4+
5+
## Crates
6+
7+
| Directory | Package | Owns |
8+
|---|---|---|
9+
| `api/` | `gateway-stt` | The batch endpoint, the realtime WebSocket session, audio buffering and resampling, segmentation, each take's transcript state, and publication |
10+
| `engine/` | `gateway-stt-engine` | Backend-neutral decoder and detector contracts, the worker threads, and the decode policy |
11+
| `backend-whisper/` | `gateway-stt-backend-whisper` | Whisper decoders, the Silero detector, per-role decode profiles, prompts, and the interim guard |
12+
| `whisper-ffi/` | `gateway-whisper-ffi` | The runtime-loaded whisper.cpp C ABI, and the only `unsafe` code in the subsystem |
13+
14+
`api` depends on `engine` and `backend-whisper`, and `backend-whisper` depends on `engine` and `whisper-ffi`. `engine` depends on no whisper crate, so its worker and queue logic runs under Miri.
15+
16+
## One take
17+
18+
```mermaid
19+
flowchart LR
20+
append[Append PCM16] --> resample[Resample 16 kHz]
21+
resample --> detect[Silero speech]
22+
detect --> segment[Segmenter]
23+
segment -->|"every interval"| interim[Interim decode]
24+
interim --> agree[Agreement]
25+
segment -->|"closed range"| finalDecode[Final decode]
26+
agree --> publish[Publish events]
27+
finalDecode --> publish
28+
```
29+
30+
1. The client appends 24 kHz PCM16 as Base64 to `AudioBuffer` (`api/src/audio.rs`). `Resampler24To16` low-pass filters it to 16 kHz with a polyphase FIR. Each `n` input samples yield exactly `ceil(2n / 3)` output samples, and the audio runs 1 ms behind its timeline.
31+
2. `Segmenter` (`api/src/segment.rs`) classifies every 32 ms chunk with the take's `SpeechDetector`, which is Silero in production (`backend-whisper/src/silero.rs`). The endpoint rules (`api/src/segment/endpoint.rs`) close a segment after 2 s of silence, or after 0.2 s when the latest accepted interim text ends a sentence or the speech run was shorter than 1 s. Continuous speech is cut into forced strides of 10.016 s, at the longest late pause when there is one, and each forced window overlaps its predecessor by 8 s.
32+
3. Every interval tick, 500 ms by default, the session decodes the window that ends at the speech tail with the interim model, and skips a window that holds no new speech. `WholeWindowState` (`api/src/take/window.rs`) folds each hypothesis into agreed and tentative text.
33+
4. Closed ranges go to the take's `FinalPipeline` (`api/src/take/finalization.rs`) and are decoded with the final model, conditioned on the take's decoded history. `TakeState` (`api/src/take/state.rs`) applies the outcomes in audio order.
34+
5. The session publishes hypothesis snapshots or standard deltas (`api/src/realtime/session/route.rs`), and a commit produces the completed transcript.
35+
36+
## Transcript states
37+
38+
A hypothesis snapshot shows `finalized + agreed + tentative`. These states feed it:
39+
40+
| State | Lives in | Meaning and invariant |
41+
|---|---|---|
42+
| Finalized text | `FinalizedState.text`, `api/src/take/state.rs` | The settled transcript through `samples`. It only grows, by final outcomes in audio order or by accepted interim text that stands in for a range the final pass skipped. |
43+
| Decoded text | `FinalizedState.decoded_text` | The finalized text that final decodes produced, without the accepted interim text. It is the history the next final decode is conditioned on, because whisper copies its prompt's style and interim text often lacks punctuation. |
44+
| Agreed text | `Agreement`, `api/src/take/window/evidence.rs` | A word is agreed once it holds at the same position in at least 2 hypotheses whose audio ends are at least 0.5 s apart. Agreed text never shrinks: a hypothesis that disputes it replaces only the tentative text after it. A last word that ends in punctuation waits for a later hypothesis to continue past it. |
45+
| Tentative text | `InterimSnapshot.tentative`, `api/src/take/interim.rs` | The rest of the active hypothesis after its agreed text. It can change on every tick. |
46+
| Accepted hypotheses | `WholeWindowState.pending`, `api/src/take/window.rs` | Earlier hypotheses the window accepted and still renders, shown agreed, ahead of the active one. At most 2048 are retained. |
47+
| Pending forced text | `FinalizedState.pending_forced` | A forced window's final text, shown but not yet authoritative. When the successor window arrives, the pending text before the overlap becomes finalized and the successor becomes pending. Pending text never enters a final decode's prompt. |
48+
| Anchored suffix | `FinalizedState.anchored`, `api/src/take/live_prefix.rs` | Up to 5 displayed words after a natural final's last word, kept live when the 2 displayed tokens before them equal the final's last tokens, and dropped when they only repeat the final. It lives until the next final outcome and seeds the next window's agreement once (`seeded_through`). |
49+
| Held closed ranges | `TakeState.held`, `api/src/take/finalization/retry.rs` | Closed ranges that wait in audio order, with their PCM resident, for a final queue slot. When new audio would exceed the retained PCM cap, the oldest one is released and its accepted interim text becomes final. |
50+
51+
## Interim and final passes
52+
53+
| | Interim | Final |
54+
|---|---|---|
55+
| Recommended model | `base.en` | `small.en` |
56+
| Runs | Every interval tick | When a segment closes or a stride is forced, and at commit |
57+
| Profile (`backend-whisper/src/profile.rs`) | One segment, no temperature fallback, an encoder context fit to the window, 4 tokens per window second, token timestamps | Whisper's defaults with multiple segments |
58+
| Prompt (`backend-whisper/src/prompt.rs`) | The glossary alone | The glossary and the decoded history |
59+
| Guard (`backend-whisper/src/guard.rs`) | Drops a pass that is likely silent and low in confidence, and collapses 4 or more copies of a word or phrase | None |
60+
61+
Both passes decode English only (`backend-whisper/src/model.rs`), and the batch endpoint rejects any other `language`.
62+
63+
## Workers and backpressure
64+
65+
- `SttEngine` (`engine/src/engine.rs`) runs one OS thread per role, `stt-interim` and an optional `stt-final` (`engine/src/worker.rs`).
66+
- Each worker's queue holds 8 jobs. A full queue fails the request with `TranscribeError::Overloaded` instead of blocking, and the session skips that interim tick and sends the same window again later.
67+
- Each take keeps up to 4 closed segments in its final pipeline. Further closed ranges wait in the held queue.
68+
- Dropping a request's reply skips its decode, and a request's cancellation flag aborts whisper during a pass. Shutdown closes the queue and joins the thread.
69+
- A take retains at most 30 s of PCM (`api/src/take/pcm.rs`), and the budget counts allocated capacity.
70+
71+
## Testing
72+
73+
- Replay: `api/tests/it/replay.rs` replays the scripted and natively captured takes in `api/tests/fixtures/replay/` against golden snapshots, `metrics.json`, and the thresholds in `baseline.json`. `PROMPTFORGE_REPLAY_UPDATE=1` rewrites the snapshots and metrics, and adds baseline sections only for new fixtures. A native fixture is never edited by hand; a new capture gets a new name.
74+
- Metrics: UPWR counts displayed words that a later update revises, per completed word. UPSR is the share of updates that revise anything. Partial latency and commit lag are regression proxies, not measured recognition latency: a word's audio end is estimated by spreading a final's words evenly, and a word counts only once it shows correctly at its position.
75+
- Hour simulation: `realtime_forced_windows::hour` runs one take through an hour of fixture audio whose levels encode time, and checks every decoded window bit for bit against the production resampler's output.
76+
- Native tests are `#[ignore]`d. They read `PROMPTFORGE_WHISPER_LIBRARY`, `PROMPTFORGE_WHISPER_MODEL`, the optional `PROMPTFORGE_WHISPER_FINAL_MODEL`, `PROMPTFORGE_WHISPER_AUDIO` with its `<stem>-lines.json` beside it, `PROMPTFORGE_SILERO_MODEL`, `PROMPTFORGE_NOISE_CLIPS`, and, on the gateway path, `PROMPTFORGE_WHISPER_BACKEND`. Run them one at a time, with `--run-ignored only --test-threads 1` under nextest or `-- --ignored --test-threads=1` under `cargo test`.
77+
- CI: `.github/workflows/stt-miri.yml` runs the `miri_` tests of `gateway-stt-engine` and `gateway-stt` under Miri. On the self-hosted CUDA runner it runs the native tests with `tiny.en` and `jfk.wav`: `native_whisper`, `native_silero` without its noise test, `native_interim_timing`, `native_warm_up`, the prompt budget tests, and the ignored tests of `gateway-whisper-ffi` and `gateway-stt`. CI does not load the recommended `base.en` and `small.en` pair, so it checks behavior, not accuracy.

0 commit comments

Comments
 (0)