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.
84 lines
3.3 KiB
Rust
84 lines
3.3 KiB
Rust
//! Streaming primitives for live capture: VAD-gated chunking,
|
|
//! agreement-based commit policy, and bounded buffer management.
|
|
//!
|
|
//! These types are tested at the unit level. Integration into
|
|
//! `src-tauri/src/commands/live.rs` lands in follow-up commits so
|
|
//! threshold tuning can be validated against real microphone captures
|
|
//! rather than synthetic fixtures (brief items #21, #24, #25).
|
|
|
|
pub mod buffer_trim;
|
|
pub mod commit_policy;
|
|
pub mod rms_vad;
|
|
|
|
pub use buffer_trim::{sample_index_for_seconds, trim_buffer_to_commit_point};
|
|
pub use commit_policy::{CommitDecision, CommitPolicy, LocalAgreement, Token};
|
|
pub use rms_vad::RmsVadChunker;
|
|
|
|
/// A span of audio the VAD considers worth transcribing. `start_sample`
|
|
/// is an absolute index into the stream the `VadChunker` has been fed
|
|
/// since its last `reset`; `samples` is f32 PCM at the chunker's
|
|
/// configured sample rate.
|
|
#[derive(Debug, Clone)]
|
|
pub struct VadChunk {
|
|
pub start_sample: u64,
|
|
pub samples: Vec<f32>,
|
|
}
|
|
|
|
/// A streaming VAD-gated chunker.
|
|
///
|
|
/// Implementations accumulate incoming samples, decide whether the
|
|
/// current segment is speech using a score + hysteresis (brief item
|
|
/// #21), and emit `VadChunk`s when a speech region ends — or when an
|
|
/// in-progress speech region exceeds the configured max length so
|
|
/// Whisper is not fed a 30-second monolith.
|
|
///
|
|
/// `push` returns any chunks ready to dispatch; typical usage is
|
|
/// `for chunk in chunker.push(&samples) { dispatch(chunk); }` inside
|
|
/// the capture loop.
|
|
///
|
|
/// `flush` is called at end-of-session to emit any in-flight speech as
|
|
/// a final chunk (even if silence hasn't closed it).
|
|
///
|
|
/// `Send` because a chunker is owned by the live-session worker thread
|
|
/// and moved into `spawn_blocking`.
|
|
pub trait VadChunker: Send {
|
|
/// Feed new samples. Returns any chunks the chunker has decided to
|
|
/// 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 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
|
|
/// repetition detector — brief item #26).
|
|
fn reset(&mut self);
|
|
|
|
/// Absolute sample index of the next sample that will be fed via
|
|
/// `push`. Exposed so the commit policy (#24) can compute sample
|
|
/// offsets for its agreement window.
|
|
fn next_sample_index(&self) -> u64;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn vad_chunker_trait_is_object_safe() {
|
|
// Compile-time witness: keep the trait dyn-compatible so the
|
|
// live-session worker can hold `Box<dyn VadChunker>` and swap
|
|
// between RMS and Silero backends at runtime.
|
|
let _: Option<Box<dyn VadChunker>> = None;
|
|
}
|
|
}
|