//! Voice-activity state machine for P1 transmit gating. //! //! Implements the full P1 VAD gate policy: //! * backend/model speech decisions — no custom probability thresholds. //! * `open_after_ms` — speech must be detected for this long before //! the gate opens (prevents false opens on transients). Default 40 ms. //! * `hangover_ms` — gate stays open for this long after speech drops //! out of the backend decision (prevents choppy transmit close). Default 500 ms. //! * `min_tx_ms` — minimum transmit duration after gate opens. Default 200 ms. //! //! Pre-roll (first-syllable preservation) is handled in the capture //! pipeline, not here. The state machine only decides whether the gate //! is open or closed. /// Shared VAD timing constants and gate state machine. /// /// Exposing these values here keeps the audio config and platform /// capture paths aligned without repeating the same magic numbers in /// multiple modules. /// Default confirmation window before the gate opens, in milliseconds. pub const VAD_OPEN_AFTER_MS: u32 = 40; /// Default hangover duration in milliseconds. pub const VAD_HANGOVER_MS: u32 = 500; /// Default minimum transmit duration in milliseconds. pub const VAD_MIN_TX_MS: u32 = 200; /// Hangover/open-after/minimum-transmit state machine. #[derive(Debug, Clone)] pub struct VoiceActivityStateMachine { /// Frames of continuous speech required before gate opens. open_after_frames: u32, hangover_frames: u32, min_tx_frames: u32, active: bool, hangover_remaining: u32, min_tx_remaining: u32, /// Frames of continuous speech seen since last open attempt. open_confirm_frames: u32, /// Frames spent open without a strong speech score. This keeps /// stale or borderline VAD output from holding the mic open forever. weak_hold_frames: u32, } impl VoiceActivityStateMachine { /// Create a state machine. Frame duration is 10 ms. pub fn new(open_after_ms: u32, hangover_ms: u32, min_tx_ms: u32) -> Self { Self { open_after_frames: open_after_ms / 10, hangover_frames: hangover_ms / 10, min_tx_frames: min_tx_ms / 10, active: false, hangover_remaining: 0, min_tx_remaining: 0, open_confirm_frames: 0, weak_hold_frames: 0, } } /// Update timers without forcing a close. Used by /// live settings changes while audio is already running. pub fn configure(&mut self, open_after_ms: u32, hangover_ms: u32, min_tx_ms: u32) { self.open_after_frames = open_after_ms / 10; self.hangover_frames = hangover_ms / 10; self.min_tx_frames = min_tx_ms / 10; self.hangover_remaining = self.hangover_remaining.min(self.hangover_frames); self.min_tx_remaining = self.min_tx_remaining.min(self.min_tx_frames); } /// Advance by one 10 ms backend speech decision and return whether /// transmit should be open for VoiceActivity mode. pub fn update(&mut self, speech: bool) -> bool { if self.active { if self.min_tx_remaining > 0 { self.min_tx_remaining -= 1; } if speech { self.weak_hold_frames = 0; } else { self.weak_hold_frames = self.weak_hold_frames.saturating_add(1); } if self.weak_hold_frames >= self.weak_hold_limit_frames() && self.min_tx_remaining == 0 { self.close(); return false; } if speech { // Speech still present — reset hangover. self.hangover_remaining = self.hangover_frames; } else if self.hangover_remaining > 0 { self.hangover_remaining -= 1; } else if self.min_tx_remaining == 0 { // Hangover expired and min-tx elapsed — close gate. self.close(); } } else { // Gate is closed. Accumulate confirmation frames. if speech { self.open_confirm_frames += 1; if self.open_confirm_frames >= self.open_after_frames.max(1) { // Confirmed speech — open gate. self.active = true; self.hangover_remaining = self.hangover_frames; self.min_tx_remaining = self.min_tx_frames; self.open_confirm_frames = 0; self.weak_hold_frames = 0; } } else { // Speech is no longer detected — reset confirmation. self.open_confirm_frames = 0; } } self.active } /// Current active state. pub fn active(&self) -> bool { self.active } fn weak_hold_limit_frames(&self) -> u32 { (self.hangover_frames + self.min_tx_frames + self.open_after_frames).clamp(30, 100) } fn close(&mut self) { self.active = false; self.hangover_remaining = 0; self.min_tx_remaining = 0; self.open_confirm_frames = 0; self.weak_hold_frames = 0; } /// Reset all state (call on session restart / voice_leave). pub fn reset(&mut self) { self.close(); } } impl Default for VoiceActivityStateMachine { fn default() -> Self { Self::new(VAD_OPEN_AFTER_MS, VAD_HANGOVER_MS, VAD_MIN_TX_MS) } } #[cfg(test)] mod tests { use super::*; #[test] fn hangover_keeps_gate_open_after_close() { // open_after_ms=0 so gate opens immediately on first frame. let mut sm = VoiceActivityStateMachine::new(0, 30, 0); assert!(sm.update(true)); assert!(sm.update(false)); assert!(sm.update(false)); assert!(sm.update(false)); assert!(!sm.update(false)); } #[test] fn open_after_requires_confirmation_frames() { // open_after_ms=20 → 2 frames required. let mut sm = VoiceActivityStateMachine::new(20, 0, 0); // First frame: not yet open. assert!(!sm.update(true)); // Second frame: now open. assert!(sm.update(true)); } #[test] fn open_after_resets_on_silence() { // open_after_ms=20 → 2 frames required. let mut sm = VoiceActivityStateMachine::new(20, 0, 0); assert!(!sm.update(true)); // 1 frame assert!(!sm.update(false)); // silence resets counter assert!(!sm.update(true)); // 1 frame again assert!(sm.update(true)); // 2nd frame → open } #[test] fn min_tx_keeps_gate_open_briefly() { // open_after_ms=0, hangover=0, min_tx=20ms (2 frames). // After opening: min_tx_remaining decrements each frame. // Gate closes on the frame where it reaches 0. let mut sm = VoiceActivityStateMachine::new(0, 0, 20); assert!(sm.update(true)); // opens; min_tx_remaining=2 assert!(sm.update(false)); // min_tx_remaining=1; still open assert!(!sm.update(false)); // min_tx_remaining=0; gate closes } #[test] fn reset_clears_all_state() { let mut sm = VoiceActivityStateMachine::new(0, 100, 0); assert!(sm.update(true)); // open sm.reset(); assert!(!sm.active()); // After reset, gate should not be open even with hangover pending. assert!(!sm.update(false)); } #[test] fn default_uses_p1_spec_values() { let sm = VoiceActivityStateMachine::default(); assert_eq!(sm.open_after_frames, VAD_OPEN_AFTER_MS / 10); assert_eq!(sm.hangover_frames, VAD_HANGOVER_MS / 10); assert_eq!(sm.min_tx_frames, VAD_MIN_TX_MS / 10); } #[test] fn live_config_update_shortens_existing_hangover() { let mut sm = VoiceActivityStateMachine::new(0, 1000, 0); assert!(sm.update(true)); assert!(sm.update(false)); sm.configure(0, 100, 0); for _ in 0..10 { assert!(sm.update(false)); } assert!(!sm.update(false)); } #[test] fn stale_closed_decisions_cannot_hold_gate_forever() { let mut sm = VoiceActivityStateMachine::new(0, 500, 0); assert!(sm.update(true)); for _ in 0..49 { assert!(sm.update(false)); } assert!(!sm.update(false)); } #[test] fn speech_decision_resets_weak_hold_limit() { let mut sm = VoiceActivityStateMachine::new(0, 500, 0); assert!(sm.update(true)); for _ in 0..40 { assert!(sm.update(false)); } assert!(sm.update(true)); for _ in 0..40 { assert!(sm.update(false)); } assert!(sm.active()); } }