Place cuts with Silero rather than frame energy - #13
Merged
Conversation
Silero has been loaded and run on every recording since it replaced the noise-floor heuristic, but only ever as a yes/no gate on whether a chunk contains speech. Where to cut was still decided by frame energy against a 2nd-percentile floor, which fragments a long pause the moment a breath or a keyboard tap crosses it: 226 pauses of two seconds or more across 147 real recordings, against 451 that Silero finds in the same audio. Taking the gaps between finalised speech runs instead, measured on the 60 retained recordings past the splitting threshold: the median pause a cut lands in goes from 0.76 s to 2.14 s and cuts landing in a pause of a second or more from 40% to 77%. The Swift implementation reproduces the offline prediction exactly — median 2.14 s on 54 cuts across the corpus. The cost is a longer final chunk, p50 17.9 s to 27.7 s. On the offline path that is close to free, because request latency barely tracks chunk length: 60 s costs 2.43 s and 120 s costs 2.98 s. The reason this was not done when Silero arrived is that the live segmenter asks for a boundary every 200 ms once a minute of audio is pending, and re-running the model over the pending buffer costs about 0.19 s of CPU per call at that size — roughly a whole core, sustained, for as long as no qualifying pause appears. Silero is recurrent and designed to stream, so the state and the 64-sample context are now carried across calls and only new samples are analysed: 300 s of audio in 1.62 s, fed in the 85 ms pieces the recorder actually delivers, and a test holds that bound. Two edges are load-bearing. A run still open when the boundary is wanted counts, because during capture the newest gap is always followed by one — only its start is used, and Silero does not report a start until 250 ms of speech confirms it, which is exactly the evidence the pause has ended. Excluding it made the newest pause invisible and the segmenter never cut. And fewer than two runs hands the decision back to the energy finder, which needs no model and cannot fail, so audio Silero cannot parse still gets split rather than growing without bound. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015XKX6zEAGiZFE5wAxJgYbV
…daries # Conflicts: # Sources/DoNotTypeCore/AudioChunker.swift
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.
Answering "why did you not ship this?" — the blocker was real but solvable, and this solves it.
Why energy boundaries were worth replacing
Silero has been loaded and run on every recording since it replaced the noise-floor heuristic, but only ever as a yes/no gate. Where to cut was still frame energy against a 2nd-percentile floor, which fragments a long pause the moment a breath or keyboard tap crosses it:
Measured on the 60 retained recordings past the splitting threshold:
The Swift implementation reproduces the offline prediction exactly — median 2.14 s across 54 cuts on the real corpus.
The longer final chunk is near-free on the offline path: request latency barely tracks chunk length (60 s → 2.43 s, 120 s → 2.98 s).
Why it wasn't done before
The live segmenter asks for a boundary every 200 ms once a minute is pending. Re-running the model over the pending buffer costs ~0.19 s of CPU per call at that size — roughly a whole core, sustained, for as long as no qualifying pause appears.
Silero is recurrent and built to stream, so the state and 64-sample context are now carried across calls and only new samples are analysed:
fed in the 85 ms pieces the recorder actually delivers. A test holds that bound, to catch a regression back to re-scanning.
Two edges that are load-bearing
A still-open speech run counts when finding gaps. During capture the newest gap — the one worth cutting at — is always followed by a run that has not ended. Only its start is used, and Silero does not report a start until 250 ms of speech confirms it, which is exactly the evidence that the pause is over. I had this wrong first: excluding the open run made the newest pause invisible and the segmenter never cut. The live-session test caught it.
Fewer than two finalised runs hands the decision back to the energy finder, which needs no model and cannot fail. Audio Silero cannot parse still gets split rather than growing without bound — which matters more now, since an unbounded chunk is exactly the long-request case that truncates (#10).
Tests
SpeechStreamTestscovers the risky part, the recurrent carry, on real speech:530 Swift tests, zero failures.
Scope
Swift only, so macOS and iOS. Android keeps energy boundaries — unchanged behaviour, no regression — and porting it needs the same streaming treatment. Windows has no live session. Worth a follow-up rather than bundling here.
Supersedes the "measured, not yet shipped" note in #12; that line needs updating once both land.
🤖 Generated with Claude Code
https://claude.ai/code/session_015XKX6zEAGiZFE5wAxJgYbV