fix(rms_vad): flush always leaves the chunker in clean state
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
This commit is contained in:
@@ -318,16 +318,24 @@ 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.
|
||||
}
|
||||
|
||||
// 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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user