CRITICAL from the 2026-04-22 code review: RmsVadChunker::flush() was
calling consume_frame() on a zero-padded final frame via `let _ = ...`,
discarding any VadChunk the call emitted. If the padded frame triggered
end-of-utterance (silent tail + padding zeros push past
silence_close_samples) or max_chunk_samples (buffered speech + padding
push past the cap), the emitted chunk was lost; the outer state check
then either returned None or an empty closing chunk.
Changes the VadChunker trait flush signature from Option<VadChunk> to
Vec<VadChunk> so both the mid-flush emission (from consume_frame) and
the closing emission (from emit_active_chunk_and_close) can be
surfaced. Updates RmsVadChunker::flush to collect from both sites
and skip a zero-length closing emit when the hit_max continue variant
already cleared active_chunk.
Two regression tests land alongside:
- flush_preserves_hit_max_chunk_from_padded_final_frame: tight
max_chunk, sub-frame speech tail; pre-fix dropped the chunk, post-fix
emitted samples cover the full active-speech region.
- flush_preserves_end_of_utterance_chunk_from_padded_final_frame:
silent tail near silence_close; padded zero frame closes the
utterance inside consume_frame; pre-fix returned None.
No production callers yet — the VadChunker wiring in live.rs is a
deferred item from A.3. API change is clean within the repo.
New streaming::buffer_trim module with two pure helpers:
- sample_index_for_seconds(end_secs, sample_rate) -> u64: converts
LocalAgreement::last_committed_end_secs() into an absolute sample
index. Defensive against negative end_secs (treats as 0) so a future
clock-skewed timestamp cannot wrap to a huge u64.
- trim_buffer_to_commit_point(buffer, buffer_start_sample,
commit_sample_index) -> new_buffer_start_sample: drains the prefix
of the capture buffer that falls below the commit point and returns
the new absolute-index origin.
Edge cases covered by tests:
- commit before buffer start → no drain
- commit equal to buffer start → no drain
- commit inside buffer → drain prefix, advance origin
- commit at buffer end → drain all, origin moves forward
- commit past buffer end → drain all, origin parks at commit (rare
edge after a committer reset)
- sample_index_for_seconds rounds nearest, negatives clamp to 0
- integration with LocalAgreement::last_committed_end_secs
trim_bounds_buffer_over_long_session is the acceptance fixture for
the ufal #120/#102 failure mode: 100 cycles of 16_000 captured samples
with a 200-sample tentative tail per cycle, and the buffer stays below
2× the tentative envelope instead of growing to 1.6M samples.
Integration into src-tauri/src/commands/live.rs deferred to the
dogfood session that wires VadChunker and LocalAgreement end-to-end —
the trim is a one-line replacement at the maybe_dispatch_chunk drain
site once the committer is feeding it commit points.
New streaming::commit_policy module implementing ufal's
LocalAgreement-n pattern: tokens emitted by the streaming ASR
pipeline stay tentative until N consecutive passes produce the same
prefix, at which point the agreed prefix commits.
Types:
- Token: text + absolute start/end seconds. PartialEq is text-only so
identical words from overlapping Whisper windows compare equal even
when their timestamps drift.
- CommitDecision { newly_committed, tentative }: the partition fed
back to the live-session worker after each pass.
- CommitPolicy enum with LocalAgreement { n }. Default is n=2 (ufal).
- LocalAgreement: stateful committer with push/flush/reset and a
last_committed_end_secs accessor. Brief item #25 uses the latter
to compute the sample-index drain target for aggressive buffer trim.
Invariants exercised by tests:
- first pass is all tentative (need 2 passes to commit under n=2)
- two matching passes commit their common prefix
- divergent second pass commits nothing
- extending agreement commits only the newly-agreed tokens
- tentative tail tracks latest pass only (no stale guesses)
- committed prefix never shrinks, even if later passes contradict
- flush emits any tentative-but-not-committed tail at session end
- flush on empty history is a no-op
- reset clears all commit state
- n=3 requires three matching passes before anything commits
- CommitPolicy::default() is LocalAgreement { n: 2 }
Integration into src-tauri/src/commands/live.rs deferred — the
tentative/committed split needs the B-side 'tentative: bool' field on
LiveResultMessage.segments (workstream-B #24 UI contract) and
validation against real streaming captures before going live.
New crates/transcription/src/streaming/ module with:
- VadChunker trait: Send-bound, object-safe, push/flush/reset/
next_sample_index. Same surface a future Silero backend will
present, so live.rs wiring does not change when Silero drops in.
- VadChunk type: (start_sample: u64, samples: Vec<f32>) for
commit-policy sample-offset bookkeeping in #24.
- RmsVadChunker: fallback backend the plan permits while the
ort 2.0.0-rc.10 vs rc.12 ecosystem conflict blocks silero-vad-rust
/ voice_activity_detector. Tuned to match the existing
evaluate_speech_gate behaviour (enter 0.003, exit 0.0014, 3-frame
onset, 500 ms silence close, 2 s max chunk).
Key behavioural properties, each backed by a test:
- pure silence emits nothing
- samples between exit and enter thresholds never trigger onset
- a single loud frame does not start a chunk (sustained speech only)
- sustained speech followed by silence emits exactly one chunk
- hysteresis: a dip between enter and exit does not split a chunk
- max_chunk_samples caps continuous speech (Whisper never fed > 2 s)
- flush surfaces in-flight speech
- flush on an idle chunker emits nothing
- reset restores a clean state
- emitted chunk start_sample includes the onset buffer (Whisper sees
the speech attack, not post-onset audio)
Open items tracked as follow-ups:
1. Silero backend via direct ort rc.12 bridge (Handy-style). Blocked
on either ecosystem ort alignment or dedicated bridge session.
2. Integration into src-tauri/src/commands/live.rs. Deferred so
threshold tuning can be validated against real microphone
captures rather than synthetic constant-signal fixtures.