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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user