Files
chanora/crates/chanora_audio/src/voice_activity.rs
T

244 lines
8.5 KiB
Rust

//! 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());
}
}