Merge feat/vocabulary-hint: names-and-terms decoder prompt (spec 001 workstream 2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	docs/Architecture.md
#	docs/Decisions.md
#	docs/WorkBreakdown.md
#	server-py/sidecar.py
#	src/App.tsx
#	src/server/live.ts
#	src/server/transcribe.ts
#	tests/fixtures/fake_sidecar.py
#	tests/transcribe.test.ts
This commit is contained in:
Lucas Winther committed 2026-10-30 01:35:28 +01:00
commit 2c87471fdc
26 files changed
+1177 -21

No files matched your search

+15 -2
View File
@@ -67,7 +67,7 @@ src/
index.html # HTML entry, imports frontend.tsx
frontend.tsx # React root
App.tsx # top-level UI composition
components/ # ModelPicker, TranscriptionOptions, FileDropzone, LiveControls, TranscriptView, EngineToggle, AuthForms
components/ # ModelPicker, TranscriptionOptions, VocabularyHint, FileDropzone, LiveControls, TranscriptView, EngineToggle, AuthForms
lib/
audio.ts # decode + resample to 16 kHz mono Float32Array (Web Audio API)
live.ts # live-mode segment loop: RMS gate, LiveSegmenter, LiveTranscriber (pure, D-016)
@@ -76,9 +76,11 @@ src/
modelCache.ts # "downloaded?" per model via Cache Storage (D-005; reused by M3 offline indicator)
engine.ts # engine abstraction: BrowserEngine | ServerEngine
translation.ts # transcript translation planning: Opus-MT catalog, units, sentence pieces (pure, D-WS4b)
hint.ts # "Names and terms" hint: normalize, localStorage, prompt truncation rule (D-WS2a)
worker/
inference.worker.ts # Transformers.js pipeline, WebNN/WebGPU/WASM selection
protocol.ts # typed postMessage messages (shared with UI)
prompt.ts # decoder prompt (<|startofprev|> hint) via a generate() wrapper (D-WS2a)
translate.worker.ts # transcript translation (Opus-MT), its own worker + ort instance (D-WS4b)
translateProtocol.ts # typed messages for the translate worker
opusMt.ts # Opus-MT loader shared by the worker, bench script and real-model test
@@ -150,8 +152,15 @@ environment). See `WorkBreakdown.md` for the itemized checklist.)
transfers the `Float32Array` to the worker. For multilingual models the
`transcribe` message also carries `language` (or auto-detect) and `task`
(transcribe | translate — translate only for models with `canTranslate`, D-WS4a).
For every model it may carry `prompt`: the optional "Names and terms" hint
(`VocabularyHint`, stored in localStorage by `lib/hint.ts`), raw as typed (D-WS2a).
5. Worker runs the pipeline (chunked, with timestamps), streaming partial results
back via `{type:"partial"}` messages; final result as `{type:"result"}`.
A hint becomes `<|startofprev|> hint.` once per pass (`worker/prompt.ts`,
cut to a 120-token prompt budget at a whole word) and is prepended
to the decoder input of every 30 s window by a wrapper around the model's
`generate()`, which also strips it from the output again (D-WS2a).
Live passes (4.1b) carry the same `prompt`.
### 4.1b Live microphone transcription (anonymous, browser engine — D-016)
1. `LiveControls` starts a session: `getUserMedia` → `AudioContext` at the
@@ -230,7 +239,11 @@ environment). See `WorkBreakdown.md` for the itemized checklist.)
(`server-py/`) that Bun spawns and supervises (D-013; faster-whisper has no
browser/WASM build, so it is server-only — the browser stays on Transformers.js).
`src/server/transcribe.ts` bridges the sidecar's segment stream to the streamed
JSON-lines HTTP response (D-011). File requests (`mode: "file"`) decode with
JSON-lines HTTP response (D-011). `language`, `task` and the vocabulary hint
(`prompt`) travel as query params (also on the live WebSocket) and are
validated by `parseLanguageAndTask` (D-032, D-WS2a); the sidecar cuts the
hint with the same rule as the browser and passes it to faster-whisper as
`initial_prompt` (D-WS2a). File requests (`mode: "file"`) decode with
faster-whisper's batched pipeline over Silero-VAD speech chunks; server-live
passes send `mode: "live"` and stay sequential (D-WS5a). The shared catalog maps model ids to
`Systran/faster-whisper-*` CTranslate2 conversions. If the sidecar is absent,
+91
View File
@@ -1528,3 +1528,94 @@ WER here (0.144); SRT/VTT cues from the server engine are correspondingly
long. Segments arrive in bursts of 8 chunks (≈ 4 min of speech) and the first
burst can take ~15–25 s on a long file; the D-021 heartbeat covers it, and
cancel lands at the next burst boundary. No new env vars.
## D-WS2a — "Names and terms" vocabulary hint as a Whisper decoder prompt, cut to 120 tokens
**Status:** Accepted (2026-10-29) — spec 001, Goal 2. Placeholder id; the
integrator renumbers it.
**Context:** users want names and jargon spelled their way. Whisper takes a
"previous text" prompt (`<|startofprev|> … <|startoftranscript|>`).
Transformers.js 4.2.0 — and 4.3.2, checked in workstream 1's worktree —
declare `prompt_ids` in the Whisper generation config but never use it (the
parameter is commented out in `WhisperForConditionalGeneration.generate`),
and the pipeline's `_decode_asr` prints every non-special decoder token, so
a prompt passed as `decoder_input_ids` would appear in the transcript.
**Decision:**
- UI: an optional "Names and terms" textarea for every model
(`VocabularyHint`), raw text kept in localStorage (`whisper-vocabulary-hint`),
≤ 1000 characters, sent as `prompt` on the worker's transcribe message, on
the server engine's query string, and on the live WebSocket URL.
- `src/lib/hint.ts` normalizes (one term per line or comma-separated →
`"a, b, c"`, control characters dropped) and owns the **truncation rule**:
prompt text is `" " + hint` ending in a full stop (unless it ends in
`.?!`); over budget → the longest prefix of whole space-separated words
that fits (dangling `,`/`;` removed, full stop re-added) — the start of the
list is kept, the end dropped; if not even the first word fits, its first
`budget` tokens. Budget **120 tokens** (Whisper allows 223). The Python twin
`fit_prompt_ids` in `server-py/sidecar.py` is parity-tested.
- Browser: `src/worker/prompt.ts` builds `<|startofprev|> + ids` once per pass
and passes it through the ASR pipeline as a private generate kwarg; a
one-time wrapper around `model.generate` prepends it to the init tokens of
every 30 s window, caps `max_new_tokens` so prompt + init + new ≤ 448
decoder positions (D-021's 440 otherwise), and slices it off the output.
Live passes use the same path.
- Server: `parseLanguageAndTask` validates `prompt` (400 above 1000 chars) for
the HTTP and WebSocket routes; the sidecar passes the cut token ids as
faster-whisper's `initial_prompt`. **No `hotwords`.**
**Measurements** (en_alien, 888 s gaming livestream, whole file; WER vs
`tests/audio_files/en_alien.txt`, single runs; hinted terms counted as
maxwell / voices of the void / slenderman / runescape, reference 9/1/1/1;
hint "Maxwell, Voices of the Void, Slenderman, RuneScape, UFO" = 21 tokens):
- Browser path, **onnxruntime-node CPU**, catalog WASM dtypes (turbo q4,
base.en q8), the worker's prompt code:
| model | hint | prompt tokens | WER | terms |
|---|---|---|---|---|
| large-v3-turbo | none | 0 | 0.255 | 9/1/0/1 |
| large-v3-turbo | short list | 21 | **0.120** | 9/1/1/1 |
| large-v3-turbo | 54-name realistic list | 120 | 0.253 | 9/1/1/1 |
| large-v3-turbo | 54-name realistic list | 221 | 0.205 | 10/1/1/1 |
| large-v3-turbo | 300 synthetic "TermN" | 120 | 0.266 | 9/1/1/1 |
| base.en | none | 0 | 0.339 | 7/1/0/1 |
| base.en | short list, no final full stop | 21 | 0.566 | 4/1/1/0 |
| base.en | short list | 21 | 0.319 | 7/1/1/1 |
| base.en | realistic list | 32 / 64 / 96 / 120 | 0.286 / 0.306 / 0.305 / 0.284 | 9/1/1/1 at 32 and 120 |
| base.en | realistic list | 160 / 218 | 0.386 / **0.476** | 8/1/1/1, 6/1/1/0 |
| base.en | 300 synthetic "TermN" | 96 / 120 / 223 | 0.530 / 0.474 / **1.050** | |
Wall-clock per whole-file pass (turbo 141 s without a hint, 129–206 s
with) varied with machine load more than with the hint; the extra cost is
only the prompt's decoder positions per window.
- Without a final full stop the prompt made base.en drop punctuation and loop
(punctuation 239 → 183) — hence the full stop.
- Short windows (live): 10 cases × 2 models (silence 1/3 s, noise 1–3 s,
2 s speech snippets from en_aiproblem and da_festuge), with and without the
short hint: **0 / 20** outputs contained a hint word the no-hint output
lacked. One 2 s Danish snippet on turbo (language pinned to `da`) came out
as English words with the English hint ("I have been at an order June
August." vs "at den orde tøde i august.").
- Server path, faster-whisper 1.2.1 large-v3-turbo int8 CPU, short hint:
none 0.118 (9/1/0/1), `initial_prompt` 0.085, `hotwords` 0.077, both 0.112
— all with 9/1/1/1. Hotwords did not improve hinted-word recall over
`initial_prompt` and combining them lost WER, so the plan's bar ("only if it
measurably helps") is not met.
**Why 120 tokens:** with prompts near Whisper's 223-token cap the small
model degraded badly (base.en 0.339 → 0.476 realistic, 1.05 synthetic —
repetition and dropped text), while at ≤ 120 tokens a realistic list stayed
at or below the no-hint WER on both models. A junk list still hurts base.en
at 96–120 tokens (0.47–0.53); the UI says "put the most important names
first" once the text passes ~300 characters (≈ 120 tokens of names), and
short lists measured best everywhere.
**Rejected:** Transformers.js `prompt_ids` (unused in 4.2.0/4.3.2);
patching `_decode_asr` (a wrapper avoids touching the transformers patch);
the full 223-token budget (above); per-model budgets (one rule, one parity
test; revisit if a larger list is wanted on turbo).
**Consequence:** every window pays up to 121 extra decoder positions with a
long hint. WebGPU behaviour of the prompt path is unmeasured here (CPU
only) — needs the owner's browser check.
**Integration note (2026-10-30, merge with D-WS5a):** faster-whisper's
`BatchedInferencePipeline` (server file mode) re-encodes `initial_prompt`
itself and accepts only text, so the sidecar passes it the budget-cut ids
decoded back to text (re-encoding gives the same ids, checked with the real
tokenizer incl. "Århus, Ærø"); server-live passes keep the ids. Pinned by the
stubbed-sidecar test "the vocabulary hint reaches the batched pipeline as text".
+7
View File
@@ -355,6 +355,10 @@ cached.
for 4.3.2 and guarded by `tests/transformers-runtime.test.ts` (D-WS1a).
- [x] WASM sessions back at the default graph optimization level: ort-web
1.31.0-dev loads the q8 decoders at every level (D-WS1c supersedes D-014).
- [x] Vocabulary hint "Names and terms" (D-WS2a) — optional field for every
model, remembered in localStorage, sent as `prompt` on file and live
passes (browser and server engines); cut to 120 tokens at a whole word.
faster-whisper gets `initial_prompt` only (hotwords measured, no gain).
---
@@ -398,6 +402,9 @@ Found while bootstrapping agent docs (D-036); all fixed on 2026-10-29.
palette; `Inter` is listed first in the font stack but never loaded.
— Light theme via tokens + self-hosted Inter (D-037). Also fixed: the
Backend select overflowed its panel on 390 px-wide phones.
- [ ] An English "Names and terms" hint can pull a short Danish live window
toward English words (one 2 s da_festuge snippet on turbo, D-WS2a).
Not addressed: suggest same-language hints if it shows up in use.
Found while building transcript translation (D-WS4b), 2026-10-29:
- [ ] Transformers.js 4.2 batched Marian generation returns row 0's output for