-
Notifications
You must be signed in to change notification settings - Fork 2
perf(whisper): reuse the language-detect encoder pass (1.9x on auto) - #31
perf(whisper): reuse the language-detect encoder pass (1.9x on auto) #31sumerc wants to merge 15 commits into
Conversation
whisper_full encoded the same audio twice in auto mode: once inside whisper_lang_auto_detect_with_state, then again for the first decode window. Tag the encoder output with the (mel_offset, n_audio_ctx) it was computed from and skip the identical re-encode; assign exp_n_audio_ctx before detection so one call encodes at one window size. Auto-detect now costs the same as a forced language: 1.94x on dictation-length clips (530 -> 274 ms, M5 Pro, turbo-q5), with transcripts unchanged — the skipped work was bit-identical. The forced-language path never had the second encode and is untouched. Upstream as ggml-org/whisper.cpp#3954, unmerged, so it lives in patches/whisper.cpp and make whisper-lib applies it. Guarded twice, because an unpatched build is still correct and merely 2x slower on auto, which no test can distinguish from a fast one: WHISPER_BASE stops the build on a submodule bump, and TestWhisperPatchesApplied matches the checkout against the patch byte-for-byte (also catching hand-edits that ignore = dirty hides). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
What was measured while landing the encoder-reuse patch, so none of it gets re-derived: the before/after numbers, whisper's own time budget (the encoder is 94% of a dictation transcribe and flat in clip length), and the ggml 0.13 -> 0.18 bump measuring neutral on M5 Pro. Also corrects two entries the patch invalidates. The audio_ctx rejection is partly superseded: fault-matrix case H no longer garbles and whisper_init_state is ~10 ms, not the Metal setup it was assumed to be, so sizing is now a quality call rather than a correctness one. The silence-trimming entry gains a conditional, since "not a speed lever" holds only while the encoder window is fixed. Adds the alternative-engine survey: the search space collapses because the 30 s padding is a Whisper architecture property, not a whisper.cpp one. Notes that Parakeet's coverage excludes Turkish and every non-European language, so for those users Whisper is the only local engine and audio_ctx sizing is the only remaining lever; that upstream whisper.cpp now ships Parakeet but with an incompatible model format, so migrating costs a model release and buys build simplicity only; and that MLX is the one untested alternative that survives the coverage filter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note
Reviewed — No new blocking findings · 2 minor points
🔍 Full review · 6 files reviewed
🔵 Minor points
Not blocking, and no threads opened for these.
internal/whisper/patch_test.go:45— TestWhisperPatchesApplied builds its expected diff by concatenating each*.patchfile inos.ReadDir(filename-sorted) order and compares it byte-for-byte against a singlegit diffof the whole submodule.git diffemits one unified diff ordered by source path and merges all hunks touching the same file under a singlediff --githeader. This matches today's single patch, but a second patch — which the design notes explicitly anticipate ("if the patch set grows past two or three") — makes the two orderings diverge: two.patchfiles whose filename order differs from source-path order, or two patches touching the same source file, will not equal git's single combined diff. The test then fails even when the checkout is correctly patched.internal/whisper/whisper.go:153— The new audioCtxFor comment embeds machine-specific performance numbers ("measured ~10 ms", "worth a further ~1.7x") without naming the machine, whereas the repository convention keeps such numbers out of code comments and, when one must appear, requires naming the machine (e.g. "~10 ms on an M5 Pro"). The measurement is already recorded in docs/design-notes.md with the M5 Pro label, so the comment can cite it without the bare figure.
Review details
- Commit: c1bdb7e
- Model: claude-opus-4-8
- Panel: correctness · robustness · design
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Warning — Spurious build/test failure on a correct tree under a non-default global git config.
The comment above this call claims the fixed flags keep the comparison from being "at the mercy of the developer's diff config," but the command pins only --no-color/--no-ext-diff (and core.pager=cat, which cmd.Output() makes irrelevant). git diff still emits the index <old>..<new> line abbreviated per core.abbrev and the a//b/ path prefixes per diff.noprefix/diff.mnemonicPrefix. The stored patch uses 8-char hashes and a/,b/ prefixes; a developer or CI runner with a global core.abbrev = 12 (or diff.noprefix = true) produces a differently-formatted diff, so string(out) != want.String() fires and the test t.Fatalf's on a correctly-patched checkout. Pin these explicitly (e.g. -c core.abbrev=8 -c diff.noprefix=false -c diff.mnemonicPrefix=false). A related latent issue: because git diff merges all hunks for one file under a single diff --git header while the concatenated patch files each carry their own header, adding a second patch that also touches src/whisper.cpp (the PR anticipates growing to two or three) would make this comparison mismatch even when both patches apply correctly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note
Reviewed — No new blocking findings · 2 minor points · 1 still open
🔁 Incremental · 8 files reviewed
🔵 Minor points
Not blocking, and no threads opened for these.
internal/whisper/whisper.go:289— TheTranscribedoc comment just abovetranscribeAtstill states that""auto-detect "is the only mode that survives code-switching — a wrong forced language garbles the output rather than merely mislabelling it." This PR's own new comment in transcriber/whisper.go and the new design-notes section ("Why English is the default language for every model") retract both halves: auto is the mode that breaks on mixed audio, and a wrong forced language translates fluently rather than garbling. The two comments in the same change now contradict each other, so the next reader of internal/whisper/whisper.go gets the opposite of the rationale the rest of the PR documents.internal/whisper/whisper.go:53—zee_wsp_det_lang/zee_wsp_det_probare process-global statics written by the single process-widewhisper_log_setcallback and read byLastDetection(). The comment claims "only one call is ever in flight" under "the Ctx mutex", but that guarantee is per-Ctx: two differentCtxinstances transcribing concurrently (e.g. a warm-upNew()overlapping another transcribe, or tests creating multiple contexts) write/read these globals with no shared lock, racing on the diagnostic result. Production is single-model and serial so it does not hit this today, but the invariant the comment asserts is process-wide, not per-Ctx.
Review details
Outstanding from earlier reviews:
- #3735008296 —
internal/whisper/patch_test.go:55: Spurious build/test failure on a correct tree under a non-default global git config.
|
📋 PR Summary This PR speeds up local Whisper auto-detect transcription by reusing the encoder output already computed during language detection instead of encoding the audio a second time, delivering roughly a 1.9x win on short clips. It carries the change as an in-tree patch against a pinned whisper.cpp submodule, guarded by a Changes
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note
Reviewed — No new blocking findings · 2 minor points · 1 still open
🔁 Incremental · 5 files reviewed
🔵 Minor points
Not blocking, and no threads opened for these.
transcriber/local.go:226— Warm() ignores the error from eng.Transcribe (line 222) and then unconditionally sets p.lastUsed = time.Now(). When the warm pass fails — a transient engine error, or the engine being Closed between the ready-check and the call — the idle timer is reset even though no weight pages were faulted in, so no further warm is attempted for another warmIdleThreshold (10 min) and the next real dictation pays the full cold page-in with no diagnostic logged.transcriber/local.go:221— The comment justifies forcing lang "en" by claiming an auto warm pass "would log a bogus lang_detect line for silence." Warm calls eng.Transcribe directly rather than through localSession, and logDetectedLanguage — the only emitter of that log line — runs in localSession.Close(), which the warm path never reaches. No lang_detect line is logged regardless of the language passed; the real reason to force a language is skipping the wasted detection encode.
Review details
Outstanding from earlier reviews:
- #3735008296 —
internal/whisper/patch_test.go:55: Spurious build/test failure on a correct tree under a non-default global git config.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note
Reviewed — No new issues · 1 still open
🔁 Incremental · 1 file reviewed
Outstanding from earlier reviews:
- #3735008296 —
internal/whisper/patch_test.go:55: Spurious build/test failure on a correct tree under a non-default global git config.
Review 5 of 10 for this pull request · View the full run
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Warning
Changes suggested — 🟡 1 warning · 4 minor points · 1 still open
🔍 Full review · 19 files reviewed
🔵 Minor points
Not blocking, and no threads opened for these.
main.go:1518— TheapplyCorrectionfunction and its doc comment were inserted directly beneath the existing// runTranscribeFiles transcribes one or more files ...comment (lines 1515-1517) but abovefunc runTranscribeFiles(line 1533). As a result that comment now stacks on top ofapplyCorrection, andrunTranscribeFilesis left with no doc comment at all.config/hints.go:104—HintLines()openshints.txtread-only and returnsnilif it does not exist, unlikeGetHints()which auto-creates it from the default template. When the app is only ever run with-no-hints,config.SetHints("")pins hints soGetHints()returns early and never creates the file; on a config dir that was never otherwise initialized,hints.txttherefore never exists,HintLines()returns nil, andapplyCorrectionparses an empty dictionary. That silently disables the corrector — contradicting this function's own doc comment that-no-hints"must not disable post-transcription correction."correct/correct.go:147— Once the dictionary is non-empty,Applyrunsstrings.Fields(text)thenstrings.Join(result, " ")unconditionally, so it reflows whitespace on every English transcription even when no term is corrected: runs of multiple spaces collapse to one and newlines/leading/trailing whitespace are dropped.finishTranscriptionnow calls this on every English transcript with a non-empty hints.txt, so text like "Section 1.\n\nOverview" comes back as "Section 1. Overview" with no correction applied. Word-space normalization is documented onApply, but newline/blank-line stripping is not.main.go:22— The stdlib import"strings"was added inside thezee/*import group (between"zee/clipboard"and the blank line before"zee/config") and is out of alphabetical order within that group, sogofmt -l/goimportsreports main.go as unformatted.
Review details
- Commit: fe16254
- Model: claude-opus-4-8
- Panel: security · correctness · robustness · design
Outstanding from earlier reviews:
- #3735008296 —
internal/whisper/patch_test.go:55: Spurious build/test failure on a correct tree under a non-default global git config.
Review 6 of 10 for this pull request · View the full run
...nd update documentation and tests accordingly
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note
Reviewed — No new issues · 1 still open
🔁 Incremental · 4 files reviewed
Outstanding from earlier reviews:
- #3735008296 —
internal/whisper/patch_test.go:55: Spurious build/test failure on a correct tree under a non-default global git config.
Review 7 of 10 for this pull request · View the full run
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note
Reviewed — No new blocking findings · 1 minor point · 1 still open
🔁 Incremental · 9 files reviewed
🔵 Minor points
Not blocking, and no threads opened for these.
docs/design-notes.md:1553— This increment deletesSupportsHints(transcriber/local.go) and switches whisper/Groq to never send hints, but the earlier section "## Why hints reach Whisper but not Parakeet" (line 1171) still states the opposite: "Whisper takes the same comma-separated string ... asinitial_prompt" and "the gate is nowSupportsHints, a per-engine capability." Both are now false — the symbol no longer exists anywhere in the tree and whisper providers no longer send the prompt field. That section is not marked superseded, unlike the other reversed entries in this file (e.g. lines 47, 74, 205). The repo convention requires marking a superseded entry rather than leaving it to contradict the new note added here.
Outstanding from earlier reviews:
- #3735008296 —
internal/whisper/patch_test.go:55: Spurious build/test failure on a correct tree under a non-default global git config.
Review 8 of 10 for this pull request · View the full run
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Warning
Changes suggested — 🟡 1 warning · 1 still open
🔁 Incremental · 3 files reviewed
Outstanding from earlier reviews:
- #3735008296 —
internal/whisper/patch_test.go:55: Spurious build/test failure on a correct tree under a non-default global git config.
Review 9 of 10 for this pull request · View the full run
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Warning — Leaks fragments of user speech into an always-on log, bypassing the -debug-transcribe opt-in.
This log.Info call writes each correction as From→To into diagnostics_log.txt, which is always on. r.From is a verbatim span of the user's transcribed speech (the misheard words that fired a correction). The project keeps transcript text out of the always-on log and confines it to transcribe_log.txt, created only when -debug-transcribe is passed (log.TranscriptionText guards on transcribeFile != nil). Logging the spans here bypasses that gate: any clip that triggers a correction leaves fragments of what the user said in a log they never opted into for transcript capture. Log only the dictionary terms (r.To) here, or route From through the -debug-transcribe-gated channel.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Warning
Changes suggested — 🟡 1 warning · 1 minor point · 2 still open
🔁 Incremental (head + base moved) · 3 files reviewed
🟡 Warning (P1 — real issues) — outside diff range
docs/design-notes.md· merge-conflictThis PR does not merge cleanly onto the current base branch: docs/design-notes.md conflicts. The base moved (commit 2d4f0c4 appended an "install.sh resolves the release tag from a redirect" entry at the end of the file) while this PR also appends new entries to the same file, so the two edits collide. The PR must be rebased and the docs/design-notes.md conflict resolved before it can merge.
🔵 Minor points
Not blocking, and no threads opened for these.
docs/design-notes.md:1635— The new corrector-eval entries repeatedly describe "the shipped 28-term hints.txt" (also lines 1710 and 1737), but the committed default in config/hints.go lists 18 terms (Opus, Claude, Sonnet, Fable, Pi.dev, JSON, Codex, Harness, Gzip, OpenAI, Anthropic, App Router, Grafana, favicon, Mistral, ElevenLabs, Bun, Node.js). A reader who cross-checks the shipped file against the note finds the count does not match — the eval table (28) and the shipped file (18) are being conflated under the word "shipped".
Review details
Outstanding from earlier reviews:
- #3735008296 —
internal/whisper/patch_test.go:55: Spurious build/test failure on a correct tree under a non-default global git config. - #3790376160 —
main.go:1539: Leaks fragments of user speech into an always-on log, bypassing the -debug-transcribe opt-in.
Review 10 of 10 for this pull request · View the full run
What
In auto-detect mode
whisper_fullencoded the same audio twice: once insidewhisper_lang_auto_detect_with_state, then again for the first decode window.On dictation-length clips the encoder is the cost, so auto-detect simply
doubled it.
The patch tags the encoder output with the
(mel_offset, n_audio_ctx)it wascomputed from and skips a re-encode that would reproduce it, and assigns
exp_n_audio_ctxbefore detection so one call encodes at one window size.Numbers
M5 Pro,
internal/localbench, best of 5, whisper-turbo-q5:Transcripts are unchanged — the skipped work was bit-identical, and auto output
now equals forced-language output on every corpus clip. Long clips gain less
because one saved encode amortises over many windows.
Why it is guarded twice
Every failure mode here is silent: an unpatched build is still correct, just
2x slower on auto, and no test can tell a correct-but-slow build from a fast
one.
git applyalso only matches context lines, so after an upstream bump thepatch could apply cleanly onto a restructured encode path and quietly stop
working. So:
make whisper-librefuses to build unless the submodule HEAD is theWHISPER_BASEcommit the patch was benchmarked against.TestWhisperPatchesAppliedcompares the checkout's diff to the patchbyte-for-byte, catching a dropped patch, a bump that shifted hunks, and
hand-edits that
ignore = dirtyhides.Upstream
This is ggml-org/whisper.cpp#3954, opened from this project and still unmerged,
now implemented — and more generally than the issue proposed: keying on
(mel_offset, n_audio_ctx)holds for every window, not just the first, whichdrops the issue's caveats about nonzero offsets and custom
audio_ctx. When itmerges upstream, delete the patch file and bump the pin.
A fork carrying the patch as a commit was considered and rejected while it is a
single upstream-bound patch — rationale in
docs/design-notes.md.Side effects
Fixes fault-matrix case H (cold state, auto, sized window), which reopens
audio_ctxsizing as a quality decision rather than a correctness one.Sizing is worth a further ~1.7x but is not transcript-preserving, so it is
not taken here:
audioCtxForstill returns 0.Test plan
go test ./internal/whisper— includingTestTranscribeKnownClipsZEE_AC_DEBUG=1 ... -run FaultMatrix— case H now passes, D/F/G/I/J/L unchangedinternal/localbenchbefore/after on the full corpus (table above)git submodule updateresetting the checkoutWHISPER_BASEboth fail the build hardTestWhisperPatchesAppliedfails on a hand-edit to the submoduleof two) so it should transfer, but that is inference, not measurement
🤖 Generated with Claude Code