From 581a098508e839e1cc9c432acddfaa40eb1c9afc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 09:37:39 +0000 Subject: [PATCH] fix(rms_vad): flush always leaves the chunker in clean state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier audit noted that `flush()` had three exit paths but only two of them explicitly cleared all state-machine fields: 1. InSpeech with non-empty active_chunk: emit_active_chunk_and_close() handled it. 2. InSpeech with empty active_chunk (hit_max-mid-flush): handled inline. 3. Idle (no padded frame, or padded frame closed cleanly): no explicit reset — silent_tail_samples / pending_onset_frames / onset_buffer could carry stale values from `consume_frame` calls inside the same flush. In the worst case, the first feed of a fresh recording could see leftover onset bookkeeping and produce a chunk start that doesn't match the new session's audio. Reusing the same `RmsVadChunker` across stop/start is the main path that would hit this. Add a single defence-in-depth reset block at the end of flush — every exit path lands the chunker in the same fields a fresh chunker has, except `next_sample_index` (the running total-samples counter, intent- ionally preserved). Test asserts: a second flush after a full speech → silence → partial-pending sequence emits zero chunks, and a subsequent silent feed also emits zero, proving no stale state leaked. (kon-transcription doesn't build in the audit sandbox because ort-sys's build script can't reach pyke's CDN; CI cross-platform compiles it.) https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb --- crates/transcription/src/streaming/rms_vad.rs | 67 ++++++++++++++++--- 1 file changed, 58 insertions(+), 9 deletions(-) diff --git a/crates/transcription/src/streaming/rms_vad.rs b/crates/transcription/src/streaming/rms_vad.rs index 2a3fb2e..daeb17a 100644 --- a/crates/transcription/src/streaming/rms_vad.rs +++ b/crates/transcription/src/streaming/rms_vad.rs @@ -318,17 +318,25 @@ impl VadChunker for RmsVadChunker { // 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(); } + // 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 } @@ -683,4 +691,45 @@ mod tests { "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.005, + 0.01, + DEFAULT_SPEECH_ONSET_FRAMES, + (FRAME_SAMPLES * 4) as u64, + (FRAME_SAMPLES * 50) as u64, + ); + + 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:?}" + ); + } }