Skip to main content

flowcat_core/audio/
vad.rs

1// SPDX-License-Identifier: Apache-2.0
2//
3//! Voice-activity-detector impls + the [`VadProcessor`].
4//!
5//! The [`VadAnalyzer`](crate::service::VadAnalyzer) trait is frozen in
6//! [`crate::service`]; this module fills the impls:
7//! - [`SileroVad`] — ONNX Silero VAD, compiled only under the `vad-ort` feature
8//!   (uses `ort`); the heavy ONNX runtime stays opt-in.
9//! - [`MockVad`] — a deterministic, scriptable VAD, always available for tests and
10//!   the cascaded-pipeline fixture (no `ort` dep).
11//!
12//! Both analyzers reuse [`VadStateMachine`], a faithful port of pipecat's
13//! `VADAnalyzer._run_analyzer` (`pipecat/audio/vad/vad_analyzer.py`): a per-window
14//! confidence + volume gate feeding a QUIET→STARTING→SPEAKING→STOPPING hysteresis
15//! with `start_secs`/`stop_secs` debounce. The analyzer only computes the raw
16//! voice **confidence** per window; the state machine owns the timing/hysteresis,
17//! exactly as in pipecat (the `voice_confidence` hook vs. the shared base loop).
18//!
19//! [`VadProcessor`] wraps a [`VadAnalyzer`] and emits the edge frames already in
20//! the [`Frame`] enum: [`Frame::VadUserStartedSpeaking`] /
21//! [`Frame::VadUserStoppedSpeaking`] (definitive VAD edges) plus the lifecycle
22//! [`Frame::UserStartedSpeaking`] / [`Frame::UserStoppedSpeaking`] /
23//! [`Frame::Interruption`] (barge-in when the bot is speaking).
24
25use async_trait::async_trait;
26
27use crate::error::Result;
28use crate::processor::frame::{AudioFrame, Frame, StartParams, VadParams};
29use crate::processor::{Envelope, FrameProcessor, Link, ProcessorSetup};
30use crate::service::{VadAnalyzer, VadState};
31
32/// Default VAD confidence threshold (pipecat `VAD_CONFIDENCE`).
33pub const VAD_CONFIDENCE: f32 = 0.7;
34/// Default voice-start debounce in seconds (pipecat `VAD_START_SECS`).
35pub const VAD_START_SECS: f32 = 0.2;
36/// Default voice-stop debounce in seconds (pipecat `VAD_STOP_SECS`).
37pub const VAD_STOP_SECS: f32 = 0.2;
38/// Default minimum normalized volume to count a window as speech
39/// (pipecat `VAD_MIN_VOLUME`).
40pub const VAD_MIN_VOLUME: f32 = 0.6;
41
42impl Default for VadParams {
43    fn default() -> Self {
44        Self {
45            confidence: VAD_CONFIDENCE,
46            start_secs: VAD_START_SECS,
47            stop_secs: VAD_STOP_SECS,
48            min_volume: VAD_MIN_VOLUME,
49        }
50    }
51}
52
53/// The QUIET→STARTING→SPEAKING→STOPPING hysteresis shared by every VAD impl.
54///
55/// A faithful port of pipecat `VADAnalyzer._run_analyzer`: the analyzer supplies
56/// the raw per-window voice **confidence** and a smoothed **volume**; this machine
57/// gates `speaking = confidence >= threshold && volume >= min_volume` and applies
58/// the debounce: STARTING must persist `start_frames` windows to become SPEAKING,
59/// STOPPING must persist `stop_frames` windows to become QUIET. `start_frames`/
60/// `stop_frames` are derived from `start_secs`/`stop_secs` and the window duration
61/// (`window_samples / sample_rate`), exactly as pipecat rounds them.
62#[derive(Debug, Clone)]
63pub struct VadStateMachine {
64    params: VadParams,
65    sample_rate: u32,
66    window_samples: usize,
67    start_frames: u32,
68    stop_frames: u32,
69    starting_count: u32,
70    stopping_count: u32,
71    state: VadState,
72}
73
74impl VadStateMachine {
75    /// Build a state machine for an analyzer producing `window_samples`-sample
76    /// windows at `sample_rate`, with the given debounce/threshold `params`.
77    pub fn new(sample_rate: u32, window_samples: usize, params: VadParams) -> Self {
78        let mut m = Self {
79            params,
80            sample_rate,
81            window_samples,
82            start_frames: 1,
83            stop_frames: 1,
84            starting_count: 0,
85            stopping_count: 0,
86            state: VadState::Quiet,
87        };
88        m.recompute_debounce();
89        m
90    }
91
92    fn recompute_debounce(&mut self) {
93        // pipecat: vad_frames_per_sec = window_samples / sample_rate;
94        //          start_frames = round(start_secs / vad_frames_per_sec)
95        let window_secs = self.window_samples as f32 / self.sample_rate.max(1) as f32;
96        let per = |secs: f32| -> u32 {
97            if window_secs <= 0.0 {
98                1
99            } else {
100                (secs / window_secs).round().max(1.0) as u32
101            }
102        };
103        self.start_frames = per(self.params.start_secs);
104        self.stop_frames = per(self.params.stop_secs);
105    }
106
107    /// Replace the params and recompute debounce, resetting the running state to
108    /// QUIET (mirrors pipecat `set_params`).
109    pub fn set_params(&mut self, params: VadParams) {
110        self.params = params;
111        self.starting_count = 0;
112        self.stopping_count = 0;
113        self.state = VadState::Quiet;
114        self.recompute_debounce();
115    }
116
117    /// The current state without advancing.
118    pub fn state(&self) -> VadState {
119        self.state
120    }
121
122    /// Advance one analysis window. `confidence` is the model's voice probability
123    /// for the window; `volume` is the window's normalized loudness in `[0, 1]`
124    /// already computed/smoothed by the analyzer (pipecat does the exponential
125    /// smoothing in the base analyzer before this gate). Returns the new state.
126    pub fn advance(&mut self, confidence: f32, volume: f32) -> VadState {
127        let speaking = confidence >= self.params.confidence && volume >= self.params.min_volume;
128
129        if speaking {
130            match self.state {
131                VadState::Quiet => {
132                    self.state = VadState::Starting;
133                    self.starting_count = 1;
134                }
135                VadState::Starting => self.starting_count += 1,
136                VadState::Stopping => {
137                    self.state = VadState::Speaking;
138                    self.stopping_count = 0;
139                }
140                VadState::Speaking => {}
141            }
142        } else {
143            match self.state {
144                VadState::Starting => {
145                    self.state = VadState::Quiet;
146                    self.starting_count = 0;
147                }
148                VadState::Speaking => {
149                    self.state = VadState::Stopping;
150                    self.stopping_count = 1;
151                }
152                VadState::Stopping => self.stopping_count += 1,
153                VadState::Quiet => {}
154            }
155        }
156
157        if self.state == VadState::Starting && self.starting_count >= self.start_frames {
158            self.state = VadState::Speaking;
159            self.starting_count = 0;
160        }
161        if self.state == VadState::Stopping && self.stopping_count >= self.stop_frames {
162            self.state = VadState::Quiet;
163            self.stopping_count = 0;
164        }
165
166        self.state
167    }
168}
169
170/// Normalized loudness of a 16-bit PCM window in `[0, 1]`. A pure-Rust analogue of
171/// pipecat's `calculate_audio_volume`/`normalize_value` (RMS in dBFS, mapped from
172/// roughly `[-60, 0]` dB to `[0, 1]`) — no `pyloudnorm`/EBU-R128 dependency. Used
173/// by both the mock and real VAD so the volume gate behaves consistently.
174pub fn window_volume(pcm: &[i16]) -> f32 {
175    if pcm.is_empty() {
176        return 0.0;
177    }
178    let sum_sq: f64 = pcm
179        .iter()
180        .map(|&s| {
181            let f = s as f64 / 32768.0;
182            f * f
183        })
184        .sum();
185    let rms = (sum_sq / pcm.len() as f64).sqrt();
186    if rms <= 0.0 {
187        return 0.0;
188    }
189    let db = 20.0 * rms.log10(); // dBFS, <= 0
190                                 // Map [-60, 0] dBFS -> [0, 1], clamped.
191    (((db + 60.0) / 60.0).clamp(0.0, 1.0)) as f32
192}
193
194/// Exponential volume smoother (pipecat `exp_smoothing`, factor 0.2). Used by the
195/// real analyzer to debounce the volume gate; the mock skips it for determinism.
196#[derive(Debug, Clone, Default)]
197pub struct VolumeSmoother {
198    prev: f32,
199}
200
201impl VolumeSmoother {
202    /// Smooth `volume` against the running average (factor 0.2) and return it.
203    pub fn smooth(&mut self, volume: f32) -> f32 {
204        self.prev += 0.2 * (volume - self.prev);
205        self.prev
206    }
207}
208
209/// A deterministic, scriptable VAD for tests + the cascaded fixture (no ONNX).
210///
211/// It owns a [`VadStateMachine`] so it exercises the **real** debounce/hysteresis,
212/// but instead of an ML model it reads the per-window voice confidence from a
213/// caller-controlled source:
214/// - by default, `confidence = 1.0` when the window's volume exceeds the
215///   `min_volume` gate and `0.0` otherwise (i.e. loud window ⇒ speech), so feeding
216///   loud PCM drives QUIET→SPEAKING and silence drives the reverse;
217/// - or a fixed scripted confidence sequence via [`MockVad::with_script`] for
218///   precise edge-timing tests.
219pub struct MockVad {
220    sm: VadStateMachine,
221    window_samples: usize,
222    script: Option<Vec<f32>>,
223    script_pos: usize,
224}
225
226impl MockVad {
227    /// A volume-driven mock at `sample_rate` with `window_samples`-sample windows
228    /// and default params. Loud windows are treated as speech.
229    pub fn new(sample_rate: u32, window_samples: usize) -> Self {
230        Self {
231            sm: VadStateMachine::new(sample_rate, window_samples, VadParams::default()),
232            window_samples,
233            script: None,
234            script_pos: 0,
235        }
236    }
237
238    /// Like [`MockVad::new`] with explicit params.
239    pub fn with_params(sample_rate: u32, window_samples: usize, params: VadParams) -> Self {
240        Self {
241            sm: VadStateMachine::new(sample_rate, window_samples, params),
242            window_samples,
243            script: None,
244            script_pos: 0,
245        }
246    }
247
248    /// A mock whose per-window confidences come from `script` (the last value is
249    /// repeated once exhausted), for precise debounce/edge tests. The volume gate
250    /// is bypassed (the scripted confidence is taken as the speaking signal). Uses
251    /// default [`VadParams`]; for a custom debounce use
252    /// [`MockVad::with_script_params`].
253    pub fn with_script(sample_rate: u32, window_samples: usize, script: Vec<f32>) -> Self {
254        Self::with_script_params(sample_rate, window_samples, script, VadParams::default())
255    }
256
257    /// Like [`MockVad::with_script`] with explicit `params` (debounce/threshold).
258    pub fn with_script_params(
259        sample_rate: u32,
260        window_samples: usize,
261        script: Vec<f32>,
262        params: VadParams,
263    ) -> Self {
264        Self {
265            sm: VadStateMachine::new(sample_rate, window_samples, params),
266            window_samples,
267            script: Some(script),
268            script_pos: 0,
269        }
270    }
271
272    /// The window size (samples) this mock consumes per [`VadAnalyzer::analyze`].
273    pub fn window_samples(&self) -> usize {
274        self.window_samples
275    }
276}
277
278impl VadAnalyzer for MockVad {
279    fn sample_rate(&self) -> u32 {
280        self.sm.sample_rate
281    }
282
283    fn analyze(&mut self, audio: &AudioFrame) -> VadState {
284        // The mock is deterministic: it does NOT apply the volume exp-smoothing the
285        // real analyzer does (that lag would make single-window fixture tests
286        // brittle). It exercises the real state-machine hysteresis, which is the
287        // fidelity point.
288        let (confidence, gate_volume) = match &self.script {
289            Some(script) => {
290                let c = script
291                    .get(self.script_pos)
292                    .copied()
293                    .or_else(|| script.last().copied())
294                    .unwrap_or(0.0);
295                self.script_pos += 1;
296                // Bypass the volume gate for scripted runs: drive purely by script
297                // (feed the threshold so the volume gate always passes).
298                (c, self.sm.params.min_volume)
299            }
300            None => {
301                // Loud window ⇒ confident speech; quiet window ⇒ no speech.
302                let volume = window_volume(&audio.pcm);
303                let c = if volume >= self.sm.params.min_volume {
304                    1.0
305                } else {
306                    0.0
307                };
308                (c, volume)
309            }
310        };
311        self.sm.advance(confidence, gate_volume)
312    }
313
314    fn set_params(&mut self, params: VadParams) {
315        self.sm.set_params(params);
316    }
317}
318
319/// A [`FrameProcessor`] that runs a [`VadAnalyzer`] over inbound audio and emits
320/// the VAD edge + lifecycle frames.
321///
322/// On each [`Frame::InputAudio`] it buffers PCM into analyzer-sized windows, runs
323/// the analyzer, and translates QUIET↔SPEAKING transitions into:
324/// - rising edge → [`Frame::VadUserStartedSpeaking`] + [`Frame::UserStartedSpeaking`],
325///   and (if the bot is speaking) a broadcast [`Frame::Interruption`] (barge-in);
326/// - falling edge → [`Frame::VadUserStoppedSpeaking`] + [`Frame::UserStoppedSpeaking`].
327///
328/// The original `InputAudio` frame is **always forwarded** downstream (STT/turn
329/// stages still need the raw audio). Tracks bot-speaking state from
330/// [`Frame::BotStartedSpeaking`]/[`Frame::BotStoppedSpeaking`] so interruption is
331/// only broadcast while the bot holds the floor (pipecat barge-in semantics).
332pub struct VadProcessor<V: VadAnalyzer> {
333    analyzer: V,
334    window_samples: usize,
335    buffer: Vec<i16>,
336    sample_rate: u32,
337    last_state: VadState,
338    bot_speaking: bool,
339    /// If true, emit `Interruption` on a rising edge while the bot speaks.
340    interrupt_on_barge_in: bool,
341}
342
343impl<V: VadAnalyzer> VadProcessor<V> {
344    /// Wrap `analyzer`, consuming `window_samples`-sample windows. Barge-in
345    /// interruption is enabled by default.
346    pub fn new(analyzer: V, window_samples: usize) -> Self {
347        let sample_rate = analyzer.sample_rate();
348        Self {
349            analyzer,
350            window_samples: window_samples.max(1),
351            buffer: Vec::new(),
352            sample_rate,
353            last_state: VadState::Quiet,
354            bot_speaking: false,
355            interrupt_on_barge_in: true,
356        }
357    }
358
359    /// Disable broadcasting `Interruption` on barge-in (the turn controller may own
360    /// interruption via an [`InterruptionStrategy`](crate::audio::strategy::InterruptionStrategy)).
361    pub fn without_barge_in_interruption(mut self) -> Self {
362        self.interrupt_on_barge_in = false;
363        self
364    }
365
366    /// Run the analyzer over every complete buffered window and push edge frames.
367    async fn drain_windows(&mut self, link: &Link) {
368        while self.buffer.len() >= self.window_samples {
369            let window: Vec<i16> = self.buffer.drain(..self.window_samples).collect();
370            let frame = AudioFrame::mono(window, self.sample_rate);
371            let new_state = self.analyzer.analyze(&frame);
372
373            let was_speaking = matches!(self.last_state, VadState::Speaking | VadState::Stopping);
374            let is_speaking = matches!(new_state, VadState::Speaking);
375
376            // Rising edge: Quiet/Starting/Stopping -> Speaking.
377            if is_speaking && !was_speaking {
378                let secs = self.window_samples as f32 / self.sample_rate.max(1) as f32;
379                link.push_down(Frame::VadUserStartedSpeaking { start_secs: secs })
380                    .await;
381                link.push_down(Frame::UserStartedSpeaking).await;
382                if self.bot_speaking && self.interrupt_on_barge_in {
383                    link.broadcast(Frame::Interruption).await;
384                }
385            }
386            // Falling edge: Speaking/Stopping -> Quiet.
387            else if was_speaking && matches!(new_state, VadState::Quiet) {
388                let secs = self.window_samples as f32 / self.sample_rate.max(1) as f32;
389                link.push_down(Frame::VadUserStoppedSpeaking { stop_secs: secs })
390                    .await;
391                link.push_down(Frame::UserStoppedSpeaking).await;
392            }
393
394            self.last_state = new_state;
395        }
396    }
397}
398
399#[async_trait]
400impl<V: VadAnalyzer + 'static> FrameProcessor for VadProcessor<V> {
401    fn name(&self) -> &str {
402        "VadProcessor"
403    }
404
405    async fn start(&mut self, _setup: &ProcessorSetup, params: &StartParams) -> Result<()> {
406        self.sample_rate = params.audio_in_sample_rate;
407        Ok(())
408    }
409
410    async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
411        match &env.frame {
412            Frame::BotStartedSpeaking => self.bot_speaking = true,
413            Frame::BotStoppedSpeaking => self.bot_speaking = false,
414            Frame::InputAudio(audio) => {
415                self.buffer.extend_from_slice(&audio.pcm);
416                self.drain_windows(link).await;
417            }
418            _ => {}
419        }
420        // Always forward the frame unchanged (STT/turn stages still need it).
421        link.push(env.meta, env.frame, env.direction).await;
422        Ok(())
423    }
424}
425
426// ---------------------------------------------------------------------------
427// Silero ONNX impl (behind `vad-ort`).
428// ---------------------------------------------------------------------------
429
430/// Silero VAD over ONNX Runtime (`ort`), behind the `vad-ort` feature.
431///
432/// Mirrors pipecat's `SileroVADAnalyzer`/`SileroOnnxModel`
433/// (`pipecat/audio/vad/silero.py`): it runs the Silero VAD ONNX graph per window
434/// (512 samples at 16 kHz, 256 at 8 kHz), carrying the recurrent `state` +
435/// `context` tensors between calls and periodically resetting them. The raw model
436/// confidence then feeds the shared [`VadStateMachine`] for the
437/// QUIET/STARTING/SPEAKING/STOPPING hysteresis — identical to the mock, so the
438/// behaviour is consistent.
439///
440/// Requires the `silero_vad.onnx` model file (not vendored); construct with
441/// [`SileroVad::from_model_path`]. Only 8 kHz and 16 kHz are supported.
442#[cfg(feature = "vad-ort")]
443pub struct SileroVad {
444    sm: VadStateMachine,
445    sample_rate: u32,
446    window_samples: usize,
447    session: ort::session::Session,
448    // Recurrent state tensor (Silero v5: shape [2,1,128]).
449    state: ndarray::Array3<f32>,
450    context: Vec<f32>,
451    context_size: usize,
452    windows_since_reset: u32,
453    reset_every_windows: u32,
454    smoother: VolumeSmoother,
455}
456
457#[cfg(feature = "vad-ort")]
458impl SileroVad {
459    /// Load the Silero VAD ONNX model at `model_path` for `sample_rate`
460    /// (must be 8000 or 16000) with default [`VadParams`].
461    pub fn from_model_path(
462        model_path: impl AsRef<std::path::Path>,
463        sample_rate: u32,
464    ) -> Result<Self> {
465        Self::with_params(model_path, sample_rate, VadParams::default())
466    }
467
468    /// Load the model with explicit `params`.
469    pub fn with_params(
470        model_path: impl AsRef<std::path::Path>,
471        sample_rate: u32,
472        params: VadParams,
473    ) -> Result<Self> {
474        use crate::error::FlowcatError;
475        if sample_rate != 8000 && sample_rate != 16000 {
476            return Err(FlowcatError::Codec(format!(
477                "Silero VAD sample rate must be 8000 or 16000 (got {sample_rate})"
478            )));
479        }
480        let (window_samples, context_size) = if sample_rate == 16000 {
481            (512usize, 64usize)
482        } else {
483            (256usize, 32usize)
484        };
485
486        let session = ort::session::Session::builder()
487            .and_then(|mut b| b.commit_from_file(model_path.as_ref()))
488            .map_err(|e| FlowcatError::Realtime(format!("silero load: {e}")))?;
489
490        // Reset roughly every ~5s of audio (pipecat `_MODEL_RESET_STATES_TIME`).
491        let window_secs = window_samples as f32 / sample_rate as f32;
492        let reset_every_windows = (5.0 / window_secs).round().max(1.0) as u32;
493
494        Ok(Self {
495            sm: VadStateMachine::new(sample_rate, window_samples, params),
496            sample_rate,
497            window_samples,
498            session,
499            state: ndarray::Array3::<f32>::zeros((2, 1, 128)),
500            context: vec![0.0; context_size],
501            context_size,
502            windows_since_reset: 0,
503            reset_every_windows,
504            smoother: VolumeSmoother::default(),
505        })
506    }
507
508    /// The window size (samples) this analyzer consumes per [`VadAnalyzer::analyze`].
509    pub fn window_samples(&self) -> usize {
510        self.window_samples
511    }
512
513    fn reset_states(&mut self) {
514        self.state = ndarray::Array3::<f32>::zeros((2, 1, 128));
515        self.context = vec![0.0; self.context_size];
516    }
517
518    /// Run the ONNX graph for one window, returning the voice probability `[0,1]`.
519    fn voice_confidence(&mut self, pcm: &[i16]) -> f32 {
520        use ndarray::{Array1, Array2};
521
522        // int16 -> float32 normalized, prepended with the carried context.
523        let mut x: Vec<f32> = Vec::with_capacity(self.context_size + pcm.len());
524        x.extend_from_slice(&self.context);
525        x.extend(pcm.iter().map(|&s| s as f32 / 32768.0));
526
527        let input_len = x.len();
528        let input = match Array2::from_shape_vec((1, input_len), x.clone()) {
529            Ok(a) => a,
530            Err(_) => return 0.0,
531        };
532        let sr = Array1::from_vec(vec![self.sample_rate as i64]);
533
534        let run = (|| -> std::result::Result<f32, Box<dyn std::error::Error>> {
535            let input_val = ort::value::Tensor::from_array(input)?;
536            let state_val = ort::value::Tensor::from_array(self.state.clone())?;
537            let sr_val = ort::value::Tensor::from_array(sr)?;
538            let outputs = self.session.run(ort::inputs![
539                "input" => input_val,
540                "state" => state_val,
541                "sr" => sr_val,
542            ])?;
543            // Output order: [output, state]. Update recurrent state.
544            let (_, prob) = outputs[0].try_extract_tensor::<f32>()?;
545            let confidence = prob.first().copied().unwrap_or(0.0);
546            let (_, new_state) = outputs[1].try_extract_tensor::<f32>()?;
547            if new_state.len() == self.state.len() {
548                self.state = ndarray::Array3::from_shape_vec((2, 1, 128), new_state.to_vec())?;
549            }
550            Ok(confidence)
551        })();
552
553        // Carry the last `context_size` samples for the next window.
554        if input_len >= self.context_size {
555            self.context = x[input_len - self.context_size..].to_vec();
556        }
557
558        match run {
559            Ok(c) => c,
560            Err(e) => {
561                tracing::warn!("Silero VAD inference failed: {e}");
562                0.0
563            }
564        }
565    }
566}
567
568#[cfg(feature = "vad-ort")]
569impl VadAnalyzer for SileroVad {
570    fn sample_rate(&self) -> u32 {
571        self.sample_rate
572    }
573
574    fn analyze(&mut self, audio: &AudioFrame) -> VadState {
575        // Periodically reset the recurrent state so memory/state doesn't drift
576        // (pipecat resets every ~5s).
577        self.windows_since_reset += 1;
578        if self.windows_since_reset >= self.reset_every_windows {
579            self.reset_states();
580            self.windows_since_reset = 0;
581        }
582        let confidence = self.voice_confidence(&audio.pcm);
583        let volume = self.smoother.smooth(window_volume(&audio.pcm));
584        self.sm.advance(confidence, volume)
585    }
586
587    fn set_params(&mut self, params: VadParams) {
588        self.sm.set_params(params);
589    }
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595    use crate::observer::{FrameEvent, FrameObserver};
596    use crate::pipeline::{Pipeline, PipelineTask, PipelineTaskParams};
597    use crate::processor::frame::Direction;
598    use crate::processor::Envelope;
599    use std::sync::atomic::{AtomicUsize, Ordering};
600    use std::sync::{Arc, Mutex};
601
602    // A loud 16-bit window (near full-scale) -> high volume -> speech.
603    fn loud(n: usize, rate: u32) -> AudioFrame {
604        AudioFrame::mono(vec![20000i16; n], rate)
605    }
606    fn quiet(n: usize, rate: u32) -> AudioFrame {
607        AudioFrame::mono(vec![0i16; n], rate)
608    }
609
610    #[test]
611    fn state_machine_debounce_start_and_stop() {
612        // 16kHz, 512-sample windows => 32ms/window. start_secs=0.064 => 2 windows;
613        // stop_secs=0.064 => 2 windows.
614        let params = VadParams {
615            confidence: 0.5,
616            start_secs: 0.064,
617            stop_secs: 0.064,
618            min_volume: 0.0,
619        };
620        let mut sm = VadStateMachine::new(16000, 512, params);
621        assert_eq!(
622            sm.advance(1.0, 1.0),
623            VadState::Starting,
624            "1st speech window"
625        );
626        assert_eq!(sm.advance(1.0, 1.0), VadState::Speaking, "2nd => debounced");
627        assert_eq!(sm.advance(1.0, 1.0), VadState::Speaking);
628        // Now silence: 1 window => Stopping, 2 => Quiet.
629        assert_eq!(sm.advance(0.0, 0.0), VadState::Stopping);
630        assert_eq!(sm.advance(0.0, 0.0), VadState::Quiet);
631    }
632
633    #[test]
634    fn state_machine_brief_blip_does_not_reach_speaking() {
635        let params = VadParams {
636            confidence: 0.5,
637            start_secs: 0.1, // ~3 windows @32ms
638            stop_secs: 0.064,
639            min_volume: 0.0,
640        };
641        let mut sm = VadStateMachine::new(16000, 512, params);
642        assert_eq!(sm.advance(1.0, 1.0), VadState::Starting);
643        // A single quiet window before the start debounce completes => back to Quiet.
644        assert_eq!(sm.advance(0.0, 0.0), VadState::Quiet);
645    }
646
647    #[test]
648    fn mock_vad_volume_driven_reaches_speaking_then_quiet() {
649        let mut vad = MockVad::with_params(
650            16000,
651            512,
652            VadParams {
653                confidence: 0.5,
654                start_secs: 0.032, // 1 window
655                stop_secs: 0.032,  // 1 window
656                min_volume: 0.3,
657            },
658        );
659        assert_eq!(vad.analyze(&loud(512, 16000)), VadState::Speaking);
660        assert_eq!(vad.analyze(&quiet(512, 16000)), VadState::Quiet);
661    }
662
663    #[test]
664    fn mock_vad_scripted_confidence() {
665        // start_secs/stop_secs = 1 window each (0.032s @ 8kHz/256 ≈ 1 window).
666        let mut vad = MockVad::with_script_params(
667            8000,
668            256,
669            vec![0.0, 1.0, 1.0, 0.0, 0.0],
670            VadParams {
671                confidence: 0.5,
672                start_secs: 0.032,
673                stop_secs: 0.032,
674                min_volume: 0.0,
675            },
676        );
677        let w = || AudioFrame::mono(vec![0i16; 256], 8000);
678        // window0: conf 0 -> Quiet
679        assert_eq!(vad.analyze(&w()), VadState::Quiet);
680        // window1: conf 1 -> Starting -> Speaking (start debounce = 1)
681        assert_eq!(vad.analyze(&w()), VadState::Speaking);
682        // window2 stays Speaking
683        vad.analyze(&w());
684        // window3: conf 0 -> Stopping -> Quiet (stop debounce = 1)
685        assert_eq!(vad.analyze(&w()), VadState::Quiet);
686    }
687
688    // An observer that counts the VAD edge frames it sees.
689    #[derive(Default)]
690    struct EdgeTap {
691        started: AtomicUsize,
692        stopped: AtomicUsize,
693        interruptions: AtomicUsize,
694        names: Mutex<Vec<&'static str>>,
695    }
696    #[async_trait]
697    impl FrameObserver for EdgeTap {
698        async fn on_process(&self, e: &FrameEvent<'_>) {
699            self.names.lock().unwrap().push(e.frame.name());
700            match e.frame {
701                Frame::VadUserStartedSpeaking { .. } => {
702                    self.started.fetch_add(1, Ordering::Relaxed);
703                }
704                Frame::VadUserStoppedSpeaking { .. } => {
705                    self.stopped.fetch_add(1, Ordering::Relaxed);
706                }
707                Frame::Interruption => {
708                    self.interruptions.fetch_add(1, Ordering::Relaxed);
709                }
710                _ => {}
711            }
712        }
713    }
714
715    #[tokio::test]
716    async fn vad_processor_emits_start_and_stop_edges() {
717        let vad = MockVad::with_params(
718            16000,
719            512,
720            VadParams {
721                confidence: 0.5,
722                start_secs: 0.032,
723                stop_secs: 0.032,
724                min_volume: 0.3,
725            },
726        );
727        let proc = VadProcessor::new(vad, 512);
728        let pipeline = Pipeline::new(vec![Box::new(proc)]);
729        let tap = Arc::new(EdgeTap::default());
730        let task = PipelineTask::new(
731            pipeline,
732            PipelineTaskParams::default(),
733            vec![tap.clone() as Arc<dyn FrameObserver>],
734        );
735
736        // Feed loud audio (speech) then quiet audio (silence).
737        task.queue_frame(Frame::InputAudio(Arc::new(loud(512, 16000))))
738            .await;
739        task.queue_frame(Frame::InputAudio(Arc::new(loud(512, 16000))))
740            .await;
741        task.queue_frame(Frame::InputAudio(Arc::new(quiet(512, 16000))))
742            .await;
743        task.queue_frame(Frame::InputAudio(Arc::new(quiet(512, 16000))))
744            .await;
745        task.stop_when_done().await;
746
747        tokio::time::timeout(std::time::Duration::from_secs(5), task.run())
748            .await
749            .expect("vad pipeline timed out")
750            .expect("run ok");
751
752        assert_eq!(
753            tap.started.load(Ordering::Relaxed),
754            1,
755            "exactly one start edge; saw {:?}",
756            tap.names.lock().unwrap()
757        );
758        assert_eq!(
759            tap.stopped.load(Ordering::Relaxed),
760            1,
761            "exactly one stop edge"
762        );
763    }
764
765    // Build a `Link` with capturing downstream + upstream channels so a processor's
766    // `process_frame` can be unit-tested directly (no PipelineTask lifecycle). This
767    // isolates the broadcast assertion from the framework's interruption-vs-End drain
768    // ordering (which is exercised by the pipeline-level edge tests above).
769    fn capture_link() -> (
770        Link,
771        crate::processor::runtime::ProcessorRx,
772        crate::processor::runtime::ProcessorRx,
773    ) {
774        use crate::processor::runtime::channel;
775        let (down_tx, down_rx) = channel(Arc::from("down"), 64);
776        let (up_tx, up_rx) = channel(Arc::from("up"), 64);
777        let link = Link {
778            next: Some(down_tx),
779            prev: Some(up_tx),
780            name: Arc::from("VadProcessor"),
781            clock: crate::processor::Clock::new(),
782            observer: None,
783            enable_metrics: false,
784            enable_usage_metrics: false,
785            ttfb_start: Arc::new(std::sync::atomic::AtomicI64::new(0)),
786            processing_start: Arc::new(std::sync::atomic::AtomicI64::new(0)),
787        };
788        (link, down_rx, up_rx)
789    }
790
791    // Drain everything currently queued on a receiver's system+normal channels.
792    fn drain(rx: &mut crate::processor::runtime::ProcessorRx) -> Vec<&'static str> {
793        let mut out = Vec::new();
794        while let Ok(e) = rx.system.try_recv() {
795            out.push(e.frame.name());
796        }
797        while let Ok(e) = rx.normal.try_recv() {
798            out.push(e.frame.name());
799        }
800        out
801    }
802
803    #[tokio::test]
804    async fn vad_processor_broadcasts_interruption_during_bot_speech() {
805        let vad = MockVad::with_params(
806            16000,
807            512,
808            VadParams {
809                confidence: 0.5,
810                start_secs: 0.032,
811                stop_secs: 0.032,
812                min_volume: 0.3,
813            },
814        );
815        let mut proc = VadProcessor::new(vad, 512);
816        let (link, mut down_rx, mut up_rx) = capture_link();
817
818        // Bot is speaking; user barges in (loud audio) -> rising edge -> broadcast.
819        proc.process_frame(
820            Envelope::new(Frame::BotStartedSpeaking, Direction::Downstream),
821            &link,
822        )
823        .await
824        .unwrap();
825        let _ = drain(&mut down_rx);
826        proc.process_frame(
827            Envelope::new(
828                Frame::InputAudio(Arc::new(loud(512, 16000))),
829                Direction::Downstream,
830            ),
831            &link,
832        )
833        .await
834        .unwrap();
835
836        let down = drain(&mut down_rx);
837        let up = drain(&mut up_rx);
838        assert!(
839            down.contains(&"VadUserStartedSpeaking") && down.contains(&"UserStartedSpeaking"),
840            "rising edge frames must be emitted downstream; saw {down:?}"
841        );
842        // broadcast fans the Interruption both directions (one copy each).
843        assert!(
844            down.iter().filter(|n| **n == "Interruption").count() == 1,
845            "exactly one downstream Interruption; saw {down:?}"
846        );
847        assert!(
848            up.iter().filter(|n| **n == "Interruption").count() == 1,
849            "exactly one upstream Interruption; saw {up:?}"
850        );
851    }
852
853    #[tokio::test]
854    async fn vad_processor_no_interruption_when_bot_silent() {
855        let vad = MockVad::with_params(
856            16000,
857            512,
858            VadParams {
859                confidence: 0.5,
860                start_secs: 0.032,
861                stop_secs: 0.032,
862                min_volume: 0.3,
863            },
864        );
865        let mut proc = VadProcessor::new(vad, 512);
866        let (link, mut down_rx, mut up_rx) = capture_link();
867
868        // No BotStartedSpeaking — a rising edge must NOT broadcast an interruption.
869        proc.process_frame(
870            Envelope::new(
871                Frame::InputAudio(Arc::new(loud(512, 16000))),
872                Direction::Downstream,
873            ),
874            &link,
875        )
876        .await
877        .unwrap();
878
879        let down = drain(&mut down_rx);
880        let up = drain(&mut up_rx);
881        assert!(
882            down.contains(&"UserStartedSpeaking"),
883            "rising edge still fires; saw {down:?}"
884        );
885        assert!(
886            !down.contains(&"Interruption") && !up.contains(&"Interruption"),
887            "no interruption when the bot is not speaking; down={down:?} up={up:?}"
888        );
889    }
890
891    /// Real-model smoke test — needs the Silero ONNX model. Ignored in CI
892    /// (no model/keys); run with `--features vad-ort -- --ignored` after placing
893    /// `silero_vad.onnx` at $SILERO_VAD_ONNX.
894    #[cfg(feature = "vad-ort")]
895    #[tokio::test]
896    #[ignore = "needs Silero ONNX model at $SILERO_VAD_ONNX"]
897    async fn silero_vad_smoke() {
898        let path = std::env::var("SILERO_VAD_ONNX")
899            .expect("set SILERO_VAD_ONNX to the silero_vad.onnx path");
900        let mut vad = SileroVad::from_model_path(&path, 16000).expect("load model");
901        // Feed a few windows of silence — must not panic and stays Quiet.
902        for _ in 0..5 {
903            let st = vad.analyze(&AudioFrame::mono(vec![0i16; 512], 16000));
904            assert_eq!(st, VadState::Quiet);
905        }
906    }
907}