From ed90de3c93c14a51b5605fee43bc316b390542c6 Mon Sep 17 00:00:00 2001 From: Jake Date: Wed, 22 Apr 2026 08:01:14 +0100 Subject: [PATCH] fix(A.3 #21): preserve audio contiguity across max_chunk splits in RmsVadChunker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback (CRITICAL): when a chunk hit max_chunk_samples during continuous speech, emit_active_chunk reset state to Idle. The next 1-2 loud frames of post-split continued speech went into onset_buffer and were silently cleared if silence arrived before the 3-frame onset threshold — 50-100ms of user audio lost at every max-chunk boundary in long-continuous-speech scenarios. Splits emit_active_chunk into two variants: - emit_active_chunk_and_close: the existing behaviour. Used for end-of-utterance closes and end-of-session flush. Truncates trailing silence, resets state to Idle. - emit_active_chunk_continue: mid-utterance split on max_chunk. Stays in State::InSpeech, clears active_chunk for continued accumulation, advances active_chunk_start by the emitted length so the next chunk's start_sample is contiguous with this one's end. No silence-trim (by definition still in speech — end-of-utterance takes priority). Adds max_chunk_split_preserves_audio_contiguity test: feeds 17 frames of continuous speech into a chunker with a 4-frame cap, asserts (a) chunk[i+1].start_sample == chunk[i].start_sample + chunk[i].samples.len() across every pair, and (b) the final emitted region reaches the end of the fed speech with no sample loss. --- crates/transcription/src/streaming/rms_vad.rs | 114 ++++++++++++++++-- 1 file changed, 107 insertions(+), 7 deletions(-) diff --git a/crates/transcription/src/streaming/rms_vad.rs b/crates/transcription/src/streaming/rms_vad.rs index 87fa951..59a9f54 100644 --- a/crates/transcription/src/streaming/rms_vad.rs +++ b/crates/transcription/src/streaming/rms_vad.rs @@ -200,17 +200,21 @@ impl RmsVadChunker { } let end_of_utterance = self.silent_tail_samples >= self.silence_close_samples; + if end_of_utterance { + return Some(self.emit_active_chunk_and_close()); + } let hit_max = self.active_chunk.len() >= self.max_chunk_samples; - if end_of_utterance || hit_max { - return Some(self.emit_active_chunk()); + if hit_max { + return Some(self.emit_active_chunk_continue()); } None } - fn emit_active_chunk(&mut self) -> VadChunk { - // Trim the trailing silence from the chunk so Whisper isn't - // fed dead air at the end. Matches the "tighten chunk on - // commit" property #25 will later tie to commit points. + /// Emit the active chunk as an end-of-utterance close: trailing + /// silence is trimmed off (Whisper does not need dead air) and + /// state returns to Idle. Next speech onset must re-cross the + /// sustained-speech threshold before a new chunk begins. + fn emit_active_chunk_and_close(&mut self) -> VadChunk { let mut samples = std::mem::take(&mut self.active_chunk); if self.silent_tail_samples > 0 && samples.len() > self.silent_tail_samples { let keep = samples.len() - self.silent_tail_samples; @@ -228,6 +232,34 @@ impl RmsVadChunker { samples, } } + + /// Emit the active chunk as a mid-utterance split because we hit + /// `max_chunk_samples`. State stays `InSpeech` and `active_chunk` + /// resets to empty — the very next frame in this still-ongoing + /// speech region accumulates into the new chunk, so no audio is + /// dropped across the split. `active_chunk_start` advances by the + /// emitted length so the next chunk's `start_sample` is contiguous + /// with this one's end. + /// + /// No trailing-silence truncation: we are by definition still in + /// speech when this fires (end-of-utterance takes priority in the + /// caller), so any brief silent stretch is legitimately part of + /// the continuing utterance and belongs to one of the chunks. + fn emit_active_chunk_continue(&mut self) -> VadChunk { + let samples = std::mem::take(&mut self.active_chunk); + let chunk_len = samples.len() as u64; + let start_sample = self.active_chunk_start; + self.active_chunk_start = start_sample.saturating_add(chunk_len); + // Reset silent_tail so any silence accumulated just before + // the split does not carry over into the next chunk's + // end-of-utterance detector. onset_buffer stays empty + // (we never leave InSpeech). + self.silent_tail_samples = 0; + VadChunk { + start_sample, + samples, + } + } } impl Default for RmsVadChunker { @@ -274,7 +306,9 @@ impl VadChunker for RmsVadChunker { let _ = self.consume_frame(padded, frame_start); } if self.state == State::InSpeech { - Some(self.emit_active_chunk()) + // End of session: close the chunk cleanly, trimming any + // trailing silence as usual. + Some(self.emit_active_chunk_and_close()) } else { None } @@ -412,6 +446,72 @@ mod tests { } } + #[test] + fn max_chunk_split_preserves_audio_contiguity() { + // Regression: a max_chunk emission in the middle of continuous + // speech used to reset state to Idle, which dropped 1-2 frames + // of post-split speech into the onset buffer where they were + // cleared if silence arrived before the onset threshold. + // + // Property under test: across a multi-chunk continuous-speech + // session, (a) chunk starts are contiguous with previous chunk + // ends, and (b) the total emitted+flushed sample count equals + // the input speech sample count (sans the pre-onset frames + // that are correctly dropped as silence). + let max_chunk = FRAME_SAMPLES * 4; + let mut c = RmsVadChunker::with_thresholds( + DEFAULT_ENTER_RMS_THRESHOLD, + DEFAULT_EXIT_RMS_THRESHOLD, + DEFAULT_SPEECH_ONSET_FRAMES, + DEFAULT_SILENCE_CLOSE_SAMPLES, + max_chunk, + ); + // 17 frames of continuous speech. 3 onset + 14 post-onset. + // With a 4-frame max cap, we expect multiple chunks. + let total_frames = 17; + let signal = constant_signal(FRAME_SAMPLES * total_frames, 0.01); + let mut chunks = c.push(&signal); + if let Some(final_chunk) = c.flush() { + chunks.push(final_chunk); + } + assert!( + chunks.len() >= 2, + "continuous speech past the cap must produce at least 2 chunks" + ); + // Contiguity: chunk[i+1].start == chunk[i].start + chunk[i].samples.len() + for pair in chunks.windows(2) { + let prev = &pair[0]; + let next = &pair[1]; + assert_eq!( + next.start_sample, + prev.start_sample + prev.samples.len() as u64, + "chunk starts must be contiguous across the max-chunk split \ + (prev start={}, prev len={}, next start={})", + prev.start_sample, + prev.samples.len(), + next.start_sample, + ); + } + // Every chunk honours the cap. + for chunk in &chunks { + assert!( + chunk.samples.len() <= max_chunk, + "chunk exceeded max_chunk_samples cap" + ); + } + // No audio loss: total emitted samples covers the full speech + // region (from the onset start — samples before onset are + // legitimately dropped). + let first_start = chunks.first().unwrap().start_sample; + let total_emitted: u64 = chunks.iter().map(|c| c.samples.len() as u64).sum(); + let end = first_start + total_emitted; + assert_eq!( + end, + (FRAME_SAMPLES * total_frames) as u64, + "emitted sample region must reach the end of the fed speech" + ); + } + #[test] fn flush_emits_in_flight_speech() { let mut c = RmsVadChunker::new();