fix(cr-2026-04-22 C2): VadChunker::flush returns Vec<VadChunk> and preserves mid-flush emissions
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.
This commit is contained in:
@@ -46,10 +46,17 @@ pub trait VadChunker: Send {
|
||||
/// emit as a result. An empty Vec means "still gathering".
|
||||
fn push(&mut self, samples: &[f32]) -> Vec<VadChunk>;
|
||||
|
||||
/// End-of-session: emit any in-progress speech as a chunk even
|
||||
/// though silence has not closed it. Returns `None` if there is
|
||||
/// nothing buffered (or only sub-threshold samples).
|
||||
fn flush(&mut self) -> Option<VadChunk>;
|
||||
/// End-of-session: emit any in-progress speech as chunks even
|
||||
/// though silence has not closed them. Returns an empty Vec if
|
||||
/// there is nothing buffered (or only sub-threshold samples).
|
||||
///
|
||||
/// Returns `Vec<VadChunk>` rather than `Option<VadChunk>` because
|
||||
/// the zero-padded final frame can legitimately trigger both a
|
||||
/// mid-flush emission (end-of-utterance or `max_chunk_samples`)
|
||||
/// AND a closing emission if the backend stays in-speech after
|
||||
/// the mid-flush cut. The previous `Option` signature silently
|
||||
/// dropped the mid-flush chunk.
|
||||
fn flush(&mut self) -> Vec<VadChunk>;
|
||||
|
||||
/// Drop accumulated state. Used between sessions on the same
|
||||
/// chunker instance (or after a context-window reset from the
|
||||
|
||||
@@ -291,27 +291,45 @@ impl VadChunker for RmsVadChunker {
|
||||
emitted
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Option<VadChunk> {
|
||||
fn flush(&mut self) -> Vec<VadChunk> {
|
||||
let mut emitted = Vec::new();
|
||||
|
||||
// Consume any tail of fewer-than-frame samples so the last
|
||||
// utterance is not lost when a user stops recording mid-word.
|
||||
// The padded frame can legitimately trigger a chunk emission
|
||||
// (end-of-utterance if the zeros close a near-expired silent
|
||||
// tail, or `max_chunk_samples` if the speech pushes past the
|
||||
// cap). Both must be surfaced — dropping them loses audio.
|
||||
if !self.pending.is_empty() {
|
||||
// The tail's absolute start index is captured BEFORE we
|
||||
// consume — same formula as in `push`.
|
||||
let frame_start = self
|
||||
.next_sample_index
|
||||
.saturating_sub(self.pending.len() as u64);
|
||||
let pad_len = FRAME_SAMPLES - self.pending.len();
|
||||
let mut padded = std::mem::take(&mut self.pending);
|
||||
padded.extend(std::iter::repeat(0.0_f32).take(pad_len));
|
||||
let _ = self.consume_frame(padded, frame_start);
|
||||
if let Some(chunk) = self.consume_frame(padded, frame_start) {
|
||||
emitted.push(chunk);
|
||||
}
|
||||
}
|
||||
if self.state == State::InSpeech {
|
||||
// End of session: close the chunk cleanly, trimming any
|
||||
// trailing silence as usual.
|
||||
Some(self.emit_active_chunk_and_close())
|
||||
} else {
|
||||
None
|
||||
|
||||
// If the backend is still mid-speech after the padded frame
|
||||
// (no end-of-utterance, or it was a hit_max continue that
|
||||
// left state in InSpeech with an empty active_chunk), emit
|
||||
// whatever is still open as the closing chunk.
|
||||
if self.state == State::InSpeech && !self.active_chunk.is_empty() {
|
||||
emitted.push(self.emit_active_chunk_and_close());
|
||||
} else if self.state == State::InSpeech {
|
||||
// hit_max emitted mid-flush and left state in InSpeech
|
||||
// with active_chunk empty. Reset cleanly without emitting
|
||||
// a zero-length closing chunk — the hit_max chunk already
|
||||
// carried all the audio.
|
||||
self.state = State::Idle;
|
||||
self.silent_tail_samples = 0;
|
||||
self.pending_onset_frames = 0;
|
||||
self.onset_buffer.clear();
|
||||
}
|
||||
|
||||
emitted
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
@@ -348,7 +366,7 @@ mod tests {
|
||||
let silence = constant_signal(16_000, 0.0); // 1 s of zero
|
||||
let chunks = c.push(&silence);
|
||||
assert!(chunks.is_empty());
|
||||
assert_eq!(c.flush().is_none(), true);
|
||||
assert!(c.flush().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -471,9 +489,7 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
chunks.extend(c.flush());
|
||||
assert!(
|
||||
chunks.len() >= 2,
|
||||
"continuous speech past the cap must produce at least 2 chunks"
|
||||
@@ -523,16 +539,108 @@ mod tests {
|
||||
chunks.is_empty(),
|
||||
"in-progress speech with no silence close stays buffered until flush"
|
||||
);
|
||||
let final_chunk = c.flush();
|
||||
assert!(final_chunk.is_some(), "flush must emit the in-flight chunk");
|
||||
let flushed = c.flush();
|
||||
assert_eq!(
|
||||
flushed.len(),
|
||||
1,
|
||||
"flush must emit exactly one in-flight chunk"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_returns_none_when_idle() {
|
||||
fn flush_returns_empty_when_idle() {
|
||||
let mut c = RmsVadChunker::new();
|
||||
assert!(c.flush().is_none());
|
||||
assert!(c.flush().is_empty());
|
||||
let _ = c.push(&constant_signal(16_000, 0.0));
|
||||
assert!(c.flush().is_none(), "flushing pure silence emits nothing");
|
||||
assert!(
|
||||
c.flush().is_empty(),
|
||||
"flushing pure silence emits nothing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_preserves_hit_max_chunk_from_padded_final_frame() {
|
||||
// Regression for CRITICAL C2 (2026-04-22 audit): if the zero-
|
||||
// padded final frame in flush() triggers `max_chunk_samples`,
|
||||
// the continue-variant emission was previously discarded by
|
||||
// `let _ = consume_frame(...)`. Must now surface in the
|
||||
// returned Vec.
|
||||
//
|
||||
// Setup: tight max_chunk so 4 frames of accumulated speech
|
||||
// (3 onset + 1) plus the padded tail exceeds the cap during
|
||||
// consume_frame, triggering a hit_max continue emission.
|
||||
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,
|
||||
);
|
||||
// 3 onset frames — transitions to InSpeech, active_chunk = 3 frames.
|
||||
let onset = constant_signal(FRAME_SAMPLES * 3, 0.01);
|
||||
let mid = c.push(&onset);
|
||||
assert!(mid.is_empty());
|
||||
// Sub-frame tail of speech that padding will push to 4 full
|
||||
// frames in active_chunk = max_chunk, triggering hit_max.
|
||||
let half_frame = constant_signal(FRAME_SAMPLES / 2, 0.01);
|
||||
let mid2 = c.push(&half_frame);
|
||||
assert!(mid2.is_empty());
|
||||
|
||||
let flushed = c.flush();
|
||||
assert!(
|
||||
!flushed.is_empty(),
|
||||
"flush must surface the hit_max chunk triggered by the padded frame"
|
||||
);
|
||||
// Coverage of the onset + half-frame speech is the property
|
||||
// under test. Emitted samples across all chunks must add up
|
||||
// to at least the active-speech duration (some trailing
|
||||
// zero-pad may be included in the final chunk — that is
|
||||
// acceptable, dropping live speech is not).
|
||||
let total: usize = flushed.iter().map(|c| c.samples.len()).sum();
|
||||
let speech_samples = FRAME_SAMPLES * 3 + FRAME_SAMPLES / 2;
|
||||
assert!(
|
||||
total >= speech_samples,
|
||||
"flush lost audio: emitted {total} samples, expected at least {speech_samples}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_preserves_end_of_utterance_chunk_from_padded_final_frame() {
|
||||
// Second regression for CRITICAL C2: if the padded final
|
||||
// frame's zeros close a near-expired silent tail (triggering
|
||||
// end_of_utterance → emit_active_chunk_and_close inside
|
||||
// consume_frame), state flips to Idle and the outer check
|
||||
// previously returned None. Must now surface.
|
||||
//
|
||||
// Setup: speak long enough to enter InSpeech, then trail with
|
||||
// near-silence so the silent_tail is just below the close
|
||||
// threshold. A padded zero frame during flush pushes it over.
|
||||
let silence_close = FRAME_SAMPLES * 2;
|
||||
let mut c = RmsVadChunker::with_thresholds(
|
||||
DEFAULT_ENTER_RMS_THRESHOLD,
|
||||
DEFAULT_EXIT_RMS_THRESHOLD,
|
||||
DEFAULT_SPEECH_ONSET_FRAMES,
|
||||
silence_close,
|
||||
DEFAULT_MAX_CHUNK_SAMPLES,
|
||||
);
|
||||
// 3 onset frames → InSpeech.
|
||||
let _ = c.push(&constant_signal(FRAME_SAMPLES * 3, 0.01));
|
||||
// 1 frame of near-silence: pushes silent_tail to 1 frame.
|
||||
// Needs to stay below silence_close so no emit happens during push.
|
||||
let _ = c.push(&constant_signal(FRAME_SAMPLES, 0.0));
|
||||
// Push a sub-frame tail of silence — after padding this
|
||||
// produces a full zero frame, pushing silent_tail from 1 to 2
|
||||
// frames = silence_close, triggering end_of_utterance inside
|
||||
// consume_frame.
|
||||
let _ = c.push(&constant_signal(FRAME_SAMPLES / 4, 0.0));
|
||||
|
||||
let flushed = c.flush();
|
||||
assert_eq!(
|
||||
flushed.len(),
|
||||
1,
|
||||
"flush must surface the end-of-utterance chunk triggered by the padded frame"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -546,7 +654,7 @@ mod tests {
|
||||
let silence = constant_signal(FRAME_SAMPLES * 12, 0.0);
|
||||
let chunks = c.push(&silence);
|
||||
assert!(chunks.is_empty());
|
||||
assert!(c.flush().is_none());
|
||||
assert!(c.flush().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user