//! 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, /// Samples belonging to the current in-progress chunk (State::InSpeech). active_chunk: Vec, /// 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, /// 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, frame_start: u64) -> Option { 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, frame_start: u64, rms: f32, ) -> Option { 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, rms: f32) -> Option { 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 { 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 = 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 { 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 { 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:?}" ); } }