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,17 +318,25 @@ impl VadChunker for RmsVadChunker {
|
|||||||
// whatever is still open as the closing chunk.
|
// whatever is still open as the closing chunk.
|
||||||
if self.state == State::InSpeech && !self.active_chunk.is_empty() {
|
if self.state == State::InSpeech && !self.active_chunk.is_empty() {
|
||||||
emitted.push(self.emit_active_chunk_and_close());
|
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
|
emitted
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,4 +691,45 @@ mod tests {
|
|||||||
"start_sample must not skip past the onset frames"
|
"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