Two issues in flush_is_idempotent_and_leaves_clean_state from
581a098:
1. silence_close_samples and max_chunk_samples were cast `as u64`
but with_thresholds takes usize — wouldn't compile.
2. enter_threshold was 0.005 and exit_threshold 0.01, which
violates the hysteresis invariant (enter must be >= exit) and
panics in debug_assert at runtime. Swap to 0.01 / 0.005 so the
test actually runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
736 lines
29 KiB
Rust
736 lines
29 KiB
Rust
//! RMS-energy-backed VAD chunker.
|
|
//!
|
|
//! This is the fallback backend the plan (`docs/whisper-ecosystem/
|
|
//! workstream-A.md`, Phase A.3 "Known unknowns") permits while the ort
|
|
//! 2.0.0-rc.10 vs rc.12 ecosystem conflict prevents a drop-in Silero
|
|
//! dep. The `VadChunker` trait surface is identical to what a Silero
|
|
//! backend will present, so the live-session path does not change when
|
|
//! Silero lands.
|
|
//!
|
|
//! The chunker emits a `VadChunk` when a sustained-speech region ends
|
|
//! (RMS drops below `exit_threshold` for `silence_close_samples`) or
|
|
//! when an in-progress region exceeds `max_chunk_samples` (so Whisper
|
|
//! is not fed a 30-second monolith). It applies hysteresis — an
|
|
//! `enter_threshold` higher than `exit_threshold` — so a VAD score
|
|
//! bouncing around the threshold does not toggle state every frame.
|
|
|
|
use super::{VadChunk, VadChunker};
|
|
|
|
/// Sample window used to compute a single RMS reading. 50 ms at 16
|
|
/// kHz. Shorter windows twitch on transients; longer windows blur the
|
|
/// speech-onset boundary.
|
|
const FRAME_SAMPLES: usize = 800;
|
|
|
|
/// Default thresholds tuned to match the existing `evaluate_speech_gate`
|
|
/// behaviour in `src-tauri/src/commands/live.rs`. The underlying
|
|
/// constants live in that file; this chunker exposes them as fields so
|
|
/// they can be tuned per-session without a recompile.
|
|
const DEFAULT_ENTER_RMS_THRESHOLD: f32 = 0.003;
|
|
const DEFAULT_EXIT_RMS_THRESHOLD: f32 = 0.0014;
|
|
/// Frames of sustained speech required before the chunker enters the
|
|
/// "in-speech" state. Filters out single-frame transients (keyboard
|
|
/// clicks, door closes).
|
|
const DEFAULT_SPEECH_ONSET_FRAMES: usize = 3;
|
|
/// Silence duration that closes an in-progress chunk, in samples.
|
|
/// 500 ms = 10 frames at 16 kHz / 50 ms-frames.
|
|
const DEFAULT_SILENCE_CLOSE_SAMPLES: usize = 8_000;
|
|
/// Hard cap on a single chunk. Matches the existing `CHUNK_SAMPLES`
|
|
/// (2 s) so the live-streaming experience is not delayed arbitrarily
|
|
/// by a user speaking continuously.
|
|
const DEFAULT_MAX_CHUNK_SAMPLES: usize = 32_000;
|
|
/// Sample rate the thresholds above assume. Exposed so future backends
|
|
/// (Parakeet, Moonshine) at different rates can construct a chunker
|
|
/// matching their native sample rate.
|
|
const DEFAULT_SAMPLE_RATE_HZ: u32 = 16_000;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
enum State {
|
|
/// Nothing buffered. Waiting for the first RMS excursion over
|
|
/// `enter_threshold`.
|
|
Idle,
|
|
/// In-progress speech. Samples accumulate; closes on
|
|
/// `silence_close_samples` of sub-threshold audio or on
|
|
/// `max_chunk_samples`.
|
|
InSpeech,
|
|
}
|
|
|
|
pub struct RmsVadChunker {
|
|
// Tunables
|
|
enter_threshold: f32,
|
|
exit_threshold: f32,
|
|
speech_onset_frames: usize,
|
|
silence_close_samples: usize,
|
|
max_chunk_samples: usize,
|
|
|
|
// Running state
|
|
state: State,
|
|
/// Frame-boundary reassembly: samples that did not complete a
|
|
/// frame on the previous `push`. Always shorter than `FRAME_SAMPLES`.
|
|
pending: Vec<f32>,
|
|
/// Samples belonging to the current in-progress chunk (State::InSpeech).
|
|
active_chunk: Vec<f32>,
|
|
/// Trailing silence sample count inside the current chunk. Resets
|
|
/// to zero whenever a speech frame is seen.
|
|
silent_tail_samples: usize,
|
|
/// Consecutive speech frames observed while `State::Idle`. When
|
|
/// this hits `speech_onset_frames`, state transitions to InSpeech.
|
|
pending_onset_frames: usize,
|
|
/// Samples buffered from the onset window that should be attached
|
|
/// to the front of the emitted chunk so Whisper sees the speech
|
|
/// onset itself, not just the post-onset audio.
|
|
onset_buffer: Vec<f32>,
|
|
/// Absolute sample index of the next sample `push` will consume.
|
|
next_sample_index: u64,
|
|
/// Absolute sample index where the current in-progress chunk
|
|
/// started. Valid only while `state == InSpeech`.
|
|
active_chunk_start: u64,
|
|
}
|
|
|
|
impl RmsVadChunker {
|
|
pub fn new() -> Self {
|
|
Self::with_thresholds(
|
|
DEFAULT_ENTER_RMS_THRESHOLD,
|
|
DEFAULT_EXIT_RMS_THRESHOLD,
|
|
DEFAULT_SPEECH_ONSET_FRAMES,
|
|
DEFAULT_SILENCE_CLOSE_SAMPLES,
|
|
DEFAULT_MAX_CHUNK_SAMPLES,
|
|
)
|
|
}
|
|
|
|
pub fn with_thresholds(
|
|
enter_threshold: f32,
|
|
exit_threshold: f32,
|
|
speech_onset_frames: usize,
|
|
silence_close_samples: usize,
|
|
max_chunk_samples: usize,
|
|
) -> Self {
|
|
debug_assert!(
|
|
exit_threshold <= enter_threshold,
|
|
"exit_threshold must not exceed enter_threshold (hysteresis requires enter >= exit)"
|
|
);
|
|
Self {
|
|
enter_threshold,
|
|
exit_threshold,
|
|
speech_onset_frames,
|
|
silence_close_samples,
|
|
max_chunk_samples,
|
|
state: State::Idle,
|
|
pending: Vec::new(),
|
|
active_chunk: Vec::new(),
|
|
silent_tail_samples: 0,
|
|
pending_onset_frames: 0,
|
|
onset_buffer: Vec::new(),
|
|
next_sample_index: 0,
|
|
active_chunk_start: 0,
|
|
}
|
|
}
|
|
|
|
pub fn sample_rate_hz(&self) -> u32 {
|
|
DEFAULT_SAMPLE_RATE_HZ
|
|
}
|
|
|
|
fn frame_rms(frame: &[f32]) -> f32 {
|
|
if frame.is_empty() {
|
|
return 0.0;
|
|
}
|
|
let sum_sq: f32 = frame.iter().map(|x| x * x).sum();
|
|
(sum_sq / frame.len() as f32).sqrt()
|
|
}
|
|
|
|
/// Consume one complete frame's worth of samples and update state.
|
|
/// `frame_start` is the absolute sample index of `frame[0]` in the
|
|
/// stream fed since `reset`. Returns a `VadChunk` if this frame
|
|
/// closed the in-progress chunk.
|
|
fn consume_frame(&mut self, frame: Vec<f32>, frame_start: u64) -> Option<VadChunk> {
|
|
let rms = Self::frame_rms(&frame);
|
|
match self.state {
|
|
State::Idle => self.consume_frame_idle(frame, frame_start, rms),
|
|
State::InSpeech => self.consume_frame_in_speech(frame, rms),
|
|
}
|
|
}
|
|
|
|
fn consume_frame_idle(
|
|
&mut self,
|
|
frame: Vec<f32>,
|
|
frame_start: u64,
|
|
rms: f32,
|
|
) -> Option<VadChunk> {
|
|
if rms >= self.enter_threshold {
|
|
self.pending_onset_frames += 1;
|
|
// Keep a rolling buffer of onset audio so once we confirm
|
|
// speech, the emitted chunk contains the speech attack
|
|
// rather than starting mid-syllable.
|
|
self.onset_buffer.extend_from_slice(&frame);
|
|
let onset_cap = self.speech_onset_frames * FRAME_SAMPLES;
|
|
if self.onset_buffer.len() > onset_cap {
|
|
let overflow = self.onset_buffer.len() - onset_cap;
|
|
self.onset_buffer.drain(..overflow);
|
|
}
|
|
|
|
if self.pending_onset_frames >= self.speech_onset_frames {
|
|
// Transition: flush the onset buffer into active_chunk
|
|
// and begin accumulating. The onset buffer includes
|
|
// the current frame, so its start index is
|
|
// `frame_start + FRAME_SAMPLES - onset_buffer.len()`.
|
|
self.state = State::InSpeech;
|
|
self.active_chunk_start = frame_start
|
|
.saturating_add(FRAME_SAMPLES as u64)
|
|
.saturating_sub(self.onset_buffer.len() as u64);
|
|
self.active_chunk.clear();
|
|
self.active_chunk.append(&mut self.onset_buffer);
|
|
self.silent_tail_samples = 0;
|
|
self.pending_onset_frames = 0;
|
|
}
|
|
} else {
|
|
// Sub-threshold frame while idle — reset the onset counter
|
|
// and drop any onset buffer. The gate demands *sustained*
|
|
// speech, not a single frame over threshold.
|
|
self.pending_onset_frames = 0;
|
|
self.onset_buffer.clear();
|
|
}
|
|
None
|
|
}
|
|
|
|
fn consume_frame_in_speech(&mut self, frame: Vec<f32>, rms: f32) -> Option<VadChunk> {
|
|
self.active_chunk.extend_from_slice(&frame);
|
|
if rms >= self.exit_threshold {
|
|
self.silent_tail_samples = 0;
|
|
} else {
|
|
self.silent_tail_samples += frame.len();
|
|
}
|
|
|
|
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 hit_max {
|
|
return Some(self.emit_active_chunk_continue());
|
|
}
|
|
None
|
|
}
|
|
|
|
/// 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;
|
|
samples.truncate(keep);
|
|
}
|
|
let start_sample = self.active_chunk_start;
|
|
|
|
self.state = State::Idle;
|
|
self.silent_tail_samples = 0;
|
|
self.pending_onset_frames = 0;
|
|
self.onset_buffer.clear();
|
|
|
|
VadChunk {
|
|
start_sample,
|
|
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 {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl VadChunker for RmsVadChunker {
|
|
fn push(&mut self, samples: &[f32]) -> Vec<VadChunk> {
|
|
if samples.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
self.pending.extend_from_slice(samples);
|
|
self.next_sample_index = self.next_sample_index.saturating_add(samples.len() as u64);
|
|
|
|
let mut emitted = Vec::new();
|
|
while self.pending.len() >= FRAME_SAMPLES {
|
|
// Absolute index of the first sample in the frame we are
|
|
// about to consume: total fed minus what is still pending.
|
|
let frame_start = self
|
|
.next_sample_index
|
|
.saturating_sub(self.pending.len() as u64);
|
|
let frame: Vec<f32> = self.pending.drain(..FRAME_SAMPLES).collect();
|
|
if let Some(chunk) = self.consume_frame(frame, frame_start) {
|
|
emitted.push(chunk);
|
|
}
|
|
}
|
|
emitted
|
|
}
|
|
|
|
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() {
|
|
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_n(0.0_f32, pad_len));
|
|
if let Some(chunk) = self.consume_frame(padded, frame_start) {
|
|
emitted.push(chunk);
|
|
}
|
|
}
|
|
|
|
// 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());
|
|
}
|
|
|
|
// Defence in depth: every flush exit-path must leave the chunker
|
|
// in the same clean state a freshly-constructed one is in,
|
|
// bar `next_sample_index` (the running total-samples counter,
|
|
// intentionally preserved across flush). Without this, a flush
|
|
// that emitted via `consume_frame`'s hit_max branch could leave
|
|
// `state == InSpeech` with stale `silent_tail_samples` or a
|
|
// populated `onset_buffer`, so the next feed() bleeds prior-
|
|
// session state into the first chunk of a fresh recording.
|
|
// The earlier branches already did most of this; the explicit
|
|
// clear here is a single source of truth.
|
|
self.state = State::Idle;
|
|
self.pending.clear();
|
|
self.active_chunk.clear();
|
|
self.silent_tail_samples = 0;
|
|
self.pending_onset_frames = 0;
|
|
self.onset_buffer.clear();
|
|
|
|
emitted
|
|
}
|
|
|
|
fn reset(&mut self) {
|
|
self.state = State::Idle;
|
|
self.pending.clear();
|
|
self.active_chunk.clear();
|
|
self.silent_tail_samples = 0;
|
|
self.pending_onset_frames = 0;
|
|
self.onset_buffer.clear();
|
|
self.next_sample_index = 0;
|
|
self.active_chunk_start = 0;
|
|
}
|
|
|
|
fn next_sample_index(&self) -> u64 {
|
|
self.next_sample_index
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Generate a vector of `len` samples at amplitude `amp`. The
|
|
/// signal is a constant DC offset, which gives a deterministic
|
|
/// RMS of exactly `amp.abs()` — simpler than a sinusoid for
|
|
/// threshold-crossing tests.
|
|
fn constant_signal(len: usize, amp: f32) -> Vec<f32> {
|
|
vec![amp; len]
|
|
}
|
|
|
|
#[test]
|
|
fn pure_silence_emits_nothing() {
|
|
let mut c = RmsVadChunker::new();
|
|
let silence = constant_signal(16_000, 0.0); // 1 s of zero
|
|
let chunks = c.push(&silence);
|
|
assert!(chunks.is_empty());
|
|
assert!(c.flush().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn below_enter_threshold_does_not_trigger() {
|
|
let mut c = RmsVadChunker::new();
|
|
// 0.002 is between the default exit (0.0014) and enter (0.003)
|
|
// thresholds — must NOT transition Idle → InSpeech.
|
|
let hum = constant_signal(16_000, 0.002);
|
|
let chunks = c.push(&hum);
|
|
assert!(
|
|
chunks.is_empty(),
|
|
"samples below enter_threshold must not trigger onset"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn single_loud_frame_does_not_trigger_onset() {
|
|
let mut c = RmsVadChunker::new();
|
|
// One frame above enter, surrounded by silence. With
|
|
// speech_onset_frames=3 this should NOT transition.
|
|
let mut signal = Vec::new();
|
|
signal.extend(constant_signal(FRAME_SAMPLES, 0.0));
|
|
signal.extend(constant_signal(FRAME_SAMPLES, 0.01)); // loud, one frame
|
|
signal.extend(constant_signal(FRAME_SAMPLES * 4, 0.0));
|
|
let chunks = c.push(&signal);
|
|
assert!(
|
|
chunks.is_empty(),
|
|
"single-frame transient must not cross sustained-speech onset"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sustained_speech_followed_by_silence_emits_one_chunk() {
|
|
let mut c = RmsVadChunker::new();
|
|
// 8 frames of speech (well over onset) followed by 12 frames of
|
|
// silence (well over silence_close). Must emit exactly one
|
|
// chunk.
|
|
let mut signal = Vec::new();
|
|
signal.extend(constant_signal(FRAME_SAMPLES * 8, 0.01));
|
|
signal.extend(constant_signal(FRAME_SAMPLES * 12, 0.0));
|
|
let chunks = c.push(&signal);
|
|
assert_eq!(chunks.len(), 1, "one speech region → one chunk");
|
|
let chunk = &chunks[0];
|
|
assert!(
|
|
!chunk.samples.is_empty(),
|
|
"emitted chunk must contain samples"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hysteresis_prevents_mid_utterance_close_on_brief_dip() {
|
|
let mut c = RmsVadChunker::new();
|
|
// Onset → loud → brief dip between enter and exit → loud again
|
|
// → silence. The dip is above exit_threshold so the chunk must
|
|
// NOT close across it.
|
|
let loud = constant_signal(FRAME_SAMPLES * 4, 0.01);
|
|
let dip = constant_signal(FRAME_SAMPLES, 0.002);
|
|
let more_loud = constant_signal(FRAME_SAMPLES * 4, 0.01);
|
|
let silence = constant_signal(FRAME_SAMPLES * 12, 0.0);
|
|
let mut signal = Vec::new();
|
|
signal.extend(loud);
|
|
signal.extend(dip);
|
|
signal.extend(more_loud);
|
|
signal.extend(silence);
|
|
let chunks = c.push(&signal);
|
|
assert_eq!(
|
|
chunks.len(),
|
|
1,
|
|
"hysteresis dip between enter and exit thresholds must not split a chunk"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn max_chunk_samples_caps_continuous_speech() {
|
|
let mut c = RmsVadChunker::with_thresholds(
|
|
DEFAULT_ENTER_RMS_THRESHOLD,
|
|
DEFAULT_EXIT_RMS_THRESHOLD,
|
|
DEFAULT_SPEECH_ONSET_FRAMES,
|
|
DEFAULT_SILENCE_CLOSE_SAMPLES,
|
|
FRAME_SAMPLES * 4, // tighter cap for the test
|
|
);
|
|
// Feed 12 frames of sustained speech with no silence break.
|
|
// The 4-frame cap must cause at least one emission mid-stream.
|
|
let signal = constant_signal(FRAME_SAMPLES * 12, 0.01);
|
|
let chunks = c.push(&signal);
|
|
assert!(
|
|
!chunks.is_empty(),
|
|
"continuous speech over the cap must emit at least one chunk"
|
|
);
|
|
for chunk in &chunks {
|
|
assert!(
|
|
chunk.samples.len() <= FRAME_SAMPLES * 4,
|
|
"emitted chunk exceeded max_chunk_samples"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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);
|
|
chunks.extend(c.flush());
|
|
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();
|
|
// Sustained speech with NO closing silence. Without flush this
|
|
// stays buffered; flush must surface it as a final chunk.
|
|
let signal = constant_signal(FRAME_SAMPLES * 5, 0.01);
|
|
let chunks = c.push(&signal);
|
|
assert!(
|
|
chunks.is_empty(),
|
|
"in-progress speech with no silence close stays buffered until flush"
|
|
);
|
|
let flushed = c.flush();
|
|
assert_eq!(
|
|
flushed.len(),
|
|
1,
|
|
"flush must emit exactly one in-flight chunk"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn flush_returns_empty_when_idle() {
|
|
let mut c = RmsVadChunker::new();
|
|
assert!(c.flush().is_empty());
|
|
let _ = c.push(&constant_signal(16_000, 0.0));
|
|
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]
|
|
fn reset_clears_state() {
|
|
let mut c = RmsVadChunker::new();
|
|
let signal = constant_signal(FRAME_SAMPLES * 5, 0.01);
|
|
let _ = c.push(&signal);
|
|
c.reset();
|
|
assert_eq!(c.next_sample_index(), 0);
|
|
// After reset, silence must not emit a chunk derived from pre-reset state.
|
|
let silence = constant_signal(FRAME_SAMPLES * 12, 0.0);
|
|
let chunks = c.push(&silence);
|
|
assert!(chunks.is_empty());
|
|
assert!(c.flush().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn start_sample_includes_onset_audio() {
|
|
let mut c = RmsVadChunker::new();
|
|
// First 2 frames silent (so next_sample_index is advanced but
|
|
// no onset). Then speech.
|
|
let silence = constant_signal(FRAME_SAMPLES * 2, 0.0);
|
|
let _ = c.push(&silence);
|
|
assert_eq!(c.next_sample_index(), (FRAME_SAMPLES * 2) as u64);
|
|
|
|
let speech = constant_signal(FRAME_SAMPLES * 5, 0.01);
|
|
let closing_silence = constant_signal(FRAME_SAMPLES * 12, 0.0);
|
|
let mut signal = Vec::new();
|
|
signal.extend(speech);
|
|
signal.extend(closing_silence);
|
|
let chunks = c.push(&signal);
|
|
assert_eq!(chunks.len(), 1);
|
|
let chunk = &chunks[0];
|
|
// The chunk's start_sample should reflect the absolute index
|
|
// of the first onset-buffered sample, NOT the post-onset index.
|
|
assert!(
|
|
chunk.start_sample >= (FRAME_SAMPLES * 2) as u64,
|
|
"start_sample must be at or after the pre-speech silence"
|
|
);
|
|
assert!(
|
|
chunk.start_sample
|
|
<= (FRAME_SAMPLES * 2 + FRAME_SAMPLES * DEFAULT_SPEECH_ONSET_FRAMES) as u64,
|
|
"start_sample must not skip past the onset frames"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn flush_is_idempotent_and_leaves_clean_state() {
|
|
// Drive the chunker through a full speech-then-silence cycle so
|
|
// most of the state-machine fields are exercised, flush once,
|
|
// then assert that flushing again is a no-op AND that feed-with-
|
|
// silence emits nothing (i.e. no stale onset / silent_tail
|
|
// bookkeeping leaks into the next feed).
|
|
let mut c = RmsVadChunker::with_thresholds(
|
|
0.01,
|
|
0.005,
|
|
DEFAULT_SPEECH_ONSET_FRAMES,
|
|
FRAME_SAMPLES * 4,
|
|
FRAME_SAMPLES * 50,
|
|
);
|
|
|
|
let speech = constant_signal(FRAME_SAMPLES * 6, 0.02);
|
|
let _ = c.push(&speech);
|
|
// Force a partial pending tail so flush exercises the padded-
|
|
// final-frame branch.
|
|
let partial = constant_signal(FRAME_SAMPLES / 3, 0.02);
|
|
let _ = c.push(&partial);
|
|
|
|
let _first = c.flush();
|
|
|
|
let second = c.flush();
|
|
assert!(
|
|
second.is_empty(),
|
|
"second flush must be a no-op; got {} chunk(s)",
|
|
second.len()
|
|
);
|
|
|
|
// A subsequent silent feed must emit nothing — proves nothing
|
|
// about prior speech leaked into the new session's bookkeeping.
|
|
let silence = constant_signal(FRAME_SAMPLES * 4, 0.0);
|
|
let chunks = c.push(&silence);
|
|
assert!(
|
|
chunks.is_empty(),
|
|
"post-flush silence must not emit any chunk; got {chunks:?}"
|
|
);
|
|
}
|
|
}
|