Performance plan: one engine that transcribes while you speak, flat with length - #23
Merged
Merged
Conversation
The architecture work it described is merged, so the plan and its review have served their purpose. What replaces them is a ranked list of changes to the release-to-paste path, each with the code to write, the test to add, and the measurement that decides whether it stays. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Steps 1 to 8 of docs/PERFORMANCE-PLAN.md. The metric is the time from the hotkey release to the paste; everything here either moves work off that path or removes it. - Step 1. The coordinator warms the Neural Engine at key-down with half a second of silence. Every utterance is padded to the model's fixed 15 s window, so the warm-up is the same encoder pass the real call makes. - Step 2. `AVAudioEngineCapture.stop()` returns the samples and pauses the engine afterwards. The samples are complete before the pause starts, and the pause blocks on the current hardware I/O cycle. - Step 3. The 10 ms sleep before Cmd+V is gone. The pasteboard write is synchronous with the pasteboard server, so it has landed when the call returns; the delay is now a parameter defaulting to zero. - Step 4. `TextOutput.prepare()` takes the clipboard snapshot at key-down instead of before the paste, and the snapshot's item cap rises from 4 MB to 64 MB now that reading it no longer bounds the path. - Step 6. `StreamingTranscriptionEngine`, and `FluidAudioStreamingEngine` behind it, transcribing through FluidAudio's sliding window while the user speaks. Flat in latency but it loses accuracy at window seams, so it is registered opt-in and is not the default. Step 8 supersedes it. - Step 7. Seam-gap repair off in the batch engine. - Step 8. `FluidAudioIncrementalEngine` runs the batch engine's own windows while the user is still speaking, so only the final window and the merge remain at release. The text is byte-identical to the batch engine on every fixture, and the word error rate matches it exactly, so the flat latency costs no accuracy. This needs `IncrementalChunkProcessor`, which a fork of FluidAudio adds; Package.swift points at that fork until the change is accepted upstream. Engine defaults are unchanged. The batch engine is still the default and both new engines are opt-in from the settings picker. The paced bench in pladder-cli feeds a fixture in one-second chunks at real time and times `endUtterance` alone, and runs the batch engine over the same samples to compare the raw text. Its batch column used to report FluidAudio's internal figure with no idle pause, which measured a warmer chip over a narrower span; it is now clock-timed after the same pause. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three findings from the first real dictations on the incremental engine. A 68 s dictation pasted in 0.306 s with 0.252 s of that in the engine. The engine figure is the cold one: on an M1 the same pass costs about 0.151 s back to back and 0.265 s after ten seconds of idle. The incremental engine runs a window roughly every 13 s, so the final window at release had been idle long enough to pay the cold price. The key-down warm pass therefore repeats every two seconds until release instead of running once. It is cancelled before the state flips to transcribing, so no pass is queued ahead of the real call. Cancellation cannot abort a CoreML call that has already started, so a release landing inside a pass still waits for it; that is bounded by one pass and shows in the log as `engine` exceeding `engine-time`. The interval is the dial between those two costs and is settable, which also keeps the new test well under a second. The four stage timings never actually appeared in the log. They are built into a Swift string and interpolated without `privacy: .public`, so the system redacted them and the line read `<private>` where the numbers should be. That made the measurement procedure in CLAUDE.md impossible to follow. One marker fixes it. It predates this branch. docs/PERFORMANCE.md gains the warm-pass behaviour and drops the claim that a periodic pass is still only a known fix, and notes that the benchmark table does not include it: the paced bench never warms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The incremental engine is now the default and the only one. The batch engine and the sliding-window streaming engine are deleted. The measurements no longer support keeping either. Transcribing while speaking produces byte-identical text to transcribing the whole recording at release, on all six fixtures, with the same word error rate, so there is nothing to trade off. It is faster from 30 s upward and eleven times faster at ten minutes. Below 15 s there is only one window either way and the same code runs, so the apparent difference on the 10 s row is measurement noise rather than a cost. Real dictations agree: 0.170 to 0.216 s in the engine across 8 s to 65 s of audio. The sliding-window engine was already superseded. It reached the same flat latency by carrying decoder state across windows and cutting on a fixed clock, which cost 1 to 6 points of word error rate at seams, growing with the length. Keeping a strictly worse engine in the picker only invites someone to pick it. An engine ID left behind by this removal resolves to the first registered engine, which `EngineRegistry.make` already did; `AppModel` now also rewrites the stored ID so the settings picker shows what is running. The CLI loses `--engine` and gains `--paced`, since there is one engine and two ways into it. The identity gate survives intact: the paced run is now compared against the same engine handed the whole buffer, which is the call it makes at release anyway, so `identical: yes` still means what it meant. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fork branch now carries upstream main. Two of those commits matter here. `fix(asr/tdt): default v3 long-form to the no-mel path (silence-aligned starts)` (#869) changes which chunk layout v3 uses by default, from the fixed grid to silence-aligned starts. That is the layout the incremental processor had never exercised, because it was unreachable before this merge, and where its start decision could diverge from a batch run. The fork fixes that in the same pass: the decision now waits until the audio its search reads has arrived. The seam work in #897 and its follow-ups improves accuracy outright. On the 60 s fixture the word error rate drops from 1.4% to 0.0%, identical between the paced and whole-buffer paths, with no change in time: 0.279 s paced against 0.552 s whole. Only the 10 s and 60 s fixtures were re-measured, so the six-fixture table in docs/PERFORMANCE.md and the pull request is stale on accuracy. It is stale in the right direction, and re-running it takes an hour of paced audio, so it is left for a quiet machine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things the notes no longer described. There is one engine and it transcribes while the key is held. The dependency is a fork, which the Decisions table should say outright rather than leaving to whoever opens Package.swift. And the coordinator warms the Neural Engine every two seconds, which is worth knowing when reading a slow log line, because a release that lands inside a warm pass waits for it. The long-recording risk is reworded rather than dropped: the windows moved off the release path but the layout and the merge are unchanged, so the seam risk is exactly what it was. What guards it now is the paced benchmark's identity check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every step in it landed, and what the code does now is described by PERFORMANCE.md and measured by BENCHMARKS.md. A plan kept past its execution reads as documentation and drifts: its step 6 already described a sliding-window engine that no longer exists, and its step 8 gate still spoke of the incremental engine as something to switch on. Where it differed from what shipped, this pull request's description is the record: the plan was written before the upstream merge that changed which chunk layout v3 uses, and before the decision to drop to one engine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Eight ranked changes to the release-to-paste path, worked through in order. They came from a written plan that was executed and then removed; this description is the record of what shipped and where it differed.
docs/ARCHITECTURE-PLAN.mdanddocs/ARCHITECTURE-REVIEW.mdgo too, anddocs/PERFORMANCE.mdarrives to describe the design that resulted.Steps 1 to 4 take fixed costs off the release-to-paste path for every dictation. Steps 6 to 8 attack the one cost that grew with the length of the recording: the engine's window passes, all of which used to run at release. The result is that the wait is now flat. A ten minute recording pastes in about the same time as a ten second one, and the text is byte-identical to what transcribing the whole recording at release would have produced.
Pladder now has one engine. The batch engine and an intermediate sliding-window engine are both deleted; see
## One enginebelow.Steps 1 to 4 — get everything else off the path
Step 1, warm the Neural Engine while the key is held.
DictationCoordinator.hotkeyPressedstarts a detached loop that transcribes half a second of silence immediately and then everywarmupInterval, two seconds by default, until release. Every utterance is padded to the model's fixed 15 s window, so each warm-up is exactly the encoder pass the real call will make, spent while the user is still speaking. It began as a single pass at key-down; real dictations showed that is not enough for a long recording, and## Real dictationshas the numbers. Three tests:theEngineKeepsWarmingWhileTheKeyIsHeld,keyDownWarmsTheEngine,releaseDuringWarmupStillInserts.Step 2,
AVAudioEngine.pause()off the path.AVAudioEngineCapture.stop()removes the tap, drains the samples and returns; thepause()that stops CoreAudio's hardware I/O cycle now runs afterwards viapauseIfIdle(), which skips it if a new recording started meanwhile. The samples are complete before the pause begins, so nothing is lost.Step 3, the 10 ms sleep before Cmd+V.
PasteboardOutput.propagationDelaybecomes an initialiser parameter defaulting to.zero, and the sleep is skipped entirely at zero. The pasteboard write is synchronous with the pasteboard server, so it has landed when the call returns, and the key event still has to cross the window server after that. The sleep guarded a race the ordering already prevents.Step 4, clipboard snapshot at key-down.
TextOutputgainsprepare(), with a no-op default so other conformers keep compiling.PasteboardOutput.prepare()captures the snapshot andNSPasteboard.general.changeCount;insertuses it only when the change count still matches, so anything copied during the recording still wins.maximumItemBytesgoes from 4 MB to 64 MB, since the cap existed only because the read sat on the path.Step 5 is diagnostic only, no code. It needs overlay A/B measurements from real dictations; see
## Not done.Steps 3 and 4 together are visible in the measurements below as a 2 ms paste.
Steps 6 to 8 — transcribe while the user speaks
Step 7 turns seam-gap repair off in the engine config.
Step 6 added
StreamingTranscriptionEngine(beginUtterance,feed,endUtterance,abandonUtterance,warmPass) andAudioCapture.drain(), which hands out everything captured since the last drain and keeps recording, sostop()returns only the tail. The coordinator runs a feed task that drains once a second, tracksfedSampleCountso the minimum-duration check sees the whole utterance, and cancels and abandons on cancel, suspend and short taps. That protocol and plumbing is what step 8 runs on.Step 8 is the engine. Windows in the batch layout do not depend on each other: each starts from a fresh decoder state, and a window's start is chosen from audio that ends before the previous window does. So every window but the last can run while the recording is still going, leaving one window and the merge at release. That is one pass at any length, and because it is the same windows in the same order through the same merge, the text is the batch engine's text rather than an approximation of it.
The window scheduling lives in a FluidAudio fork as a new
IncrementalChunkProcessor, becauseChunkProcessor,transcribeChunkandmergeChunksare allinternal.One engine
Step 6's sliding-window engine reached the same flat latency a different way, by carrying decoder state across windows and cutting on a fixed clock. It cost 1 to 6 points of word error rate at seams, growing with length. Step 8 gets the same latency at no accuracy cost, so it supersedes it.
With that settled, the measurements no longer justify keeping either of the other two engines, and both are deleted. Transcribing while speaking is now the only path and the default. Below 15 s there is only one window either way and the same code runs, so nothing is given up on short dictations.
An engine ID left behind by the removal resolves to the first registered engine, which
EngineRegistry.makealready did;AppModelnow also rewrites the stored ID so the settings picker shows what is running.The CLI loses
--engineand gains--paced, since there is one engine and two ways into it. The identity gate survives intact: a paced run is compared against the same engine handed the whole buffer, which is the call it makes at release anyway.The fork
Package.swiftpoints athttps://github.com/dinooo13/FluidAudio.git, branchincremental-chunks. The repository is public, so CI needs no credentials. This must go back to the upstream release line once the change is accepted there; a branch dependency is a temporary state.The branch carries
IncrementalChunkProcessor, a refactor that makesChunkProcessor's per-window plan and merge tail reusable instead of inlined, and upstreammainmerged in. Two of the merged commits matter here:fix(asr/tdt): default v3 long-form to the no-mel path (silence-aligned starts)(#869) changes which chunk layout v3 uses by default, from a fixed grid to silence-aligned starts. That is the layout the incremental path had never exercised, because it was unreachable before the merge, and where its start decision could diverge from a batch run: the silence search reads forward of the nominal start, and deciding as soon as audio passed that start searched a truncated signal. The fork fixes it in the same pass by waiting until the audio the search reads has arrived, which costs nothing because the window cannot run until far more audio exists.The change offered upstream is deliberately small: one new file plus a refactor that only moves existing code into reusable pieces, with identity tests proving the refactor changed no behaviour. It touches 172 lines of
ChunkProcessor.swift.Benchmark
Apple M1, macOS 26.6.2, one session, load average 2.12.
Each fixture is pushed in one-second chunks paced at real time, and only
endUtteranceis timed, which is what remains on the release-to-paste path. The other column is the same samples through the same engine handed the whole buffer, clock-timed, after the same idle pause.The identity column is the headline, not the times. Byte-identical text on all six fixtures, compared before any processor runs, with the word error rate matching on every one. The latency became flat without costing any accuracy, which is exactly what the sliding-window approach could not do.
The 10 s row is not a regression. Below one model window there are no windows to run early, so both columns execute the same code. The at-release column is one run per fixture, not a median, and an earlier run of the same comparison had the sign reversed.
Every number above is a cold release. Measured separately on the 10 s fixture, the identical path costs 0.151 s warm and 0.265 s cold. The paced bench never warms, so real dictations are faster than this table.
This table predates the upstream merge and is stale on accuracy, in the right direction. Re-measured afterwards, the 60 s fixture drops from 1.4 % to 0.0 % with no change in time (0.279 s paced, 0.552 s whole, identical). Only the 10 s and 60 s fixtures were re-run; a full six-fixture pass takes an hour of paced audio and is left for a quiet machine.
A bench fix, and a misleading first table
The paced bench's comparison column used to report FluidAudio's internal
processingTimeand ran with no idle pause. That was wrong twice over:processingTimestarts inside FluidAudio and misses the actor hop, the decoder-state allocation and the padding copy, and a call made straight after a paced run finds the Neural Engine warm when every release the bench models is cold. Either error alone is worth more than the difference between the two columns on short audio, where they run the same code. It is now clock-timed across the whole call and takes the same--pause.Real dictations, and the warm pass
The first dictations confirmed the cold-release problem in the app: 68.5 s of audio pasted in 0.306 s with 0.252 s of that in the engine, which is the cold figure. A window runs roughly every 13 s, so the final window at release had been idle long enough to pay for it. Hence the repeating warm pass. After it:
The comparable long dictation went from 0.252 s to 0.213 s in the engine and 0.306 s to 0.264 s overall. Flatness holds in the app and not just on fixtures: 37.8 s and 64.7 s cost the same, and the roughly 38 ms between the short pair and the long pair is the merge, which only multi-window recordings pay.
Two honest notes. This is one dictation per length. And the repeat cuts both ways: a CoreML call already running cannot be cancelled, so a release landing inside a warm pass waits for it, roughly one dictation in fourteen at a two second interval. None of the four hit it, but four samples cannot rule it out. The interval is settable and two seconds is a starting point, not a measured optimum.
These lines also needed a fix to the logging. The four stage timings were interpolated into the log without
privacy: .public, so the system redacted them and the line read<private>where the numbers should be. That predates this branch and made the measurement procedure inCLAUDE.mdimpossible to follow. The stage columns above are the first time those numbers have been visible.stopis now the second largest stage at 12 to 22 ms, almost all of it removing the audio tap, which blocks until the callback currently running finishes. Moving it off the path means draining before removing the tap, which risks the few milliseconds of audio in the in-flight callback. That is an accuracy trade rather than a free win and is not attempted here.Verification
swift buildandswift testpass: 98 tests in 10 suites. CI green on macos-26 including the ad-hoc-signed bundle step.IncrementalChunkProcessorin pieces and compare against a batch transcription of the same samples, at both long and short lengths.Not verified
enginesitting well aboveengine-time; if it shows up often, lengthen the interval.Not done
Out of scope
Package.swiftpoints at the branch.stopoff the path, for the accuracy reason above.🤖 Generated with Claude Code