fix(A.3 #21): preserve audio contiguity across max_chunk splits in RmsVadChunker

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.
This commit is contained in:
2026-04-22 08:01:14 +01:00
parent 4455e4d1b1
commit ed90de3c93

View File

@@ -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();