diff --git a/crates/transcription/src/streaming/mod.rs b/crates/transcription/src/streaming/mod.rs index be1f0d9..8047e4a 100644 --- a/crates/transcription/src/streaming/mod.rs +++ b/crates/transcription/src/streaming/mod.rs @@ -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; - /// 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; + /// 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` rather than `Option` 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; /// Drop accumulated state. Used between sessions on the same /// chunker instance (or after a context-window reset from the diff --git a/crates/transcription/src/streaming/rms_vad.rs b/crates/transcription/src/streaming/rms_vad.rs index 59a9f54..e4d9e8e 100644 --- a/crates/transcription/src/streaming/rms_vad.rs +++ b/crates/transcription/src/streaming/rms_vad.rs @@ -291,27 +291,45 @@ impl VadChunker for RmsVadChunker { emitted } - fn flush(&mut self) -> Option { + fn flush(&mut self) -> Vec { + 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]