//! Voice Activity Detection (Silero VAD v5 via ONNX Runtime). //! //! The detector runs on the 16 kHz mono stream produced by the session layer //! (the same stream fed to the STT engine) and emits three kinds of events: //! //! - [`VadEvent::SpeechOnset`] — fast onset, used to interrupt the agent //! (barge-in) as soon as the user starts talking. //! - [`VadEvent::SpeechStart`] — confirmed utterance start. //! - [`VadEvent::SpeechEnd`] — utterance finished, carrying the captured audio //! (with a pre-roll pad) that is handed to the STT engine. //! //! Every behavioural knob is configured from the environment (see //! [`VadSettings`]), so the feature can be tuned or disabled without a rebuild. pub mod silero; pub use silero::{SileroVad, VadDetector, VadEvent}; use crate::config::{env_bool, env_f32, env_u32}; use std::path::PathBuf; /// Runtime configuration for the VAD subsystem, read from `.env`. #[derive(Debug, Clone)] pub struct VadSettings { /// Master switch (`VAD_ENABLED`). When false the legacy RMS segmentation /// inside the STT worker is used instead. pub enabled: bool, /// Path to `silero_vad.onnx` (`VAD_MODEL_PATH`). pub model_path: PathBuf, /// Speech probability above which a window counts as voice /// (`VAD_THRESHOLD`, default 0.5). pub threshold: f32, /// Probability below which a window counts as silence /// (`VAD_NEG_THRESHOLD`). Must be lower than `threshold`; the gap is the /// hysteresis that prevents rapid onset/offset flapping. pub neg_threshold: f32, /// Speech required to confirm an utterance (`VAD_MIN_SPEECH_MS`). pub min_speech_ms: u32, /// Silence required to close an utterance — the EOU window /// (`VAD_MIN_SILENCE_MS`). pub min_silence_ms: u32, /// Audio kept before the detected onset so the first phoneme is not /// clipped (`VAD_SPEECH_PAD_MS`). pub speech_pad_ms: u32, /// Speech required to fire the barge-in onset (`VAD_BARGE_IN_MIN_SPEECH_MS`). pub barge_in_min_speech_ms: u32, /// Drop an utterance shorter than this before transcription /// (`VAD_MIN_UTTERANCE_MS`, default 300). `0` disables the check. Filters /// the short VAD false-triggers (breaths, clicks, echo). pub min_utterance_ms: u32, /// Drop an utterance whose average speech probability is below this before /// transcription (`VAD_MIN_AVG_PROB`, default 0.25). `0.0` disables the /// check. Filters the soft/noisy segments that the STT would otherwise /// turn into hallucinated text. The average is taken over the **voiced /// windows only** (probability ≥ `neg_threshold`), so the trailing /// hangover silence (`min_silence_ms`) does not dilute short utterances /// like "uno" / "due". A genuine utterance still fluctuates around the /// speech threshold on its onset/release, so keep this well below /// `threshold` (default 0.5). pub min_avg_prob: f32, /// Feed the STT engine with VAD-segmented utterances instead of letting it /// run its own RMS segmentation (`VAD_SEGMENT_STT`). pub segment_stt: bool, /// Interrupt the agent when the user starts speaking over it /// (`VAD_BARGE_IN`). pub barge_in: bool, /// Log the speech probability of every analysis window (`VAD_DEBUG`). pub debug: bool, } impl Default for VadSettings { fn default() -> Self { Self { enabled: true, model_path: PathBuf::from("models/silero_vad.onnx"), threshold: 0.5, neg_threshold: 0.35, min_speech_ms: 250, min_silence_ms: 700, speech_pad_ms: 30, barge_in_min_speech_ms: 150, min_utterance_ms: 300, min_avg_prob: 0.25, segment_stt: true, barge_in: true, debug: false, } } } impl VadSettings { /// Build the settings from environment variables, falling back to defaults. pub fn from_env() -> Self { let mut s = Self::default(); if let Ok(p) = std::env::var("VAD_MODEL_PATH") { if !p.is_empty() { s.model_path = PathBuf::from(p); } } s.enabled = env_bool("VAD_ENABLED", s.enabled); s.segment_stt = env_bool("VAD_SEGMENT_STT", s.segment_stt); s.barge_in = env_bool("VAD_BARGE_IN", s.barge_in); s.debug = env_bool("VAD_DEBUG", s.debug); s.threshold = env_f32("VAD_THRESHOLD", s.threshold); s.neg_threshold = env_f32("VAD_NEG_THRESHOLD", s.neg_threshold); s.min_speech_ms = env_u32("VAD_MIN_SPEECH_MS", s.min_speech_ms); s.min_silence_ms = env_u32("VAD_MIN_SILENCE_MS", s.min_silence_ms); s.speech_pad_ms = env_u32("VAD_SPEECH_PAD_MS", s.speech_pad_ms); s.barge_in_min_speech_ms = env_u32("VAD_BARGE_IN_MIN_SPEECH_MS", s.barge_in_min_speech_ms); s.min_utterance_ms = env_u32("VAD_MIN_UTTERANCE_MS", s.min_utterance_ms); s.min_avg_prob = env_f32("VAD_MIN_AVG_PROB", s.min_avg_prob); // Hysteresis must stay below the speech threshold, and the fast // barge-in onset can never be slower than the utterance onset. s.neg_threshold = s.neg_threshold.min(s.threshold); s.barge_in_min_speech_ms = s.barge_in_min_speech_ms.min(s.min_speech_ms); s } } /// Samples retained ahead of a detected onset (pre-roll + triggering speech). pub(crate) fn preroll_samples(settings: &VadSettings) -> usize { let ms = settings .speech_pad_ms .saturating_add(settings.min_speech_ms) .saturating_add(silero::WINDOW_MS); (crate::config::STT_SAMPLE_RATE as u64 * ms as u64 / 1000) as usize }