1use 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
32pub const VAD_CONFIDENCE: f32 = 0.7;
34pub const VAD_START_SECS: f32 = 0.2;
36pub const VAD_STOP_SECS: f32 = 0.2;
38pub 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#[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 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 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 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 pub fn state(&self) -> VadState {
119 self.state
120 }
121
122 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
170pub 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(); (((db + 60.0) / 60.0).clamp(0.0, 1.0)) as f32
192}
193
194#[derive(Debug, Clone, Default)]
197pub struct VolumeSmoother {
198 prev: f32,
199}
200
201impl VolumeSmoother {
202 pub fn smooth(&mut self, volume: f32) -> f32 {
204 self.prev += 0.2 * (volume - self.prev);
205 self.prev
206 }
207}
208
209pub struct MockVad {
220 sm: VadStateMachine,
221 window_samples: usize,
222 script: Option<Vec<f32>>,
223 script_pos: usize,
224}
225
226impl MockVad {
227 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 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 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 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 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 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 (c, self.sm.params.min_volume)
299 }
300 None => {
301 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
319pub 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 interrupt_on_barge_in: bool,
341}
342
343impl<V: VadAnalyzer> VadProcessor<V> {
344 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 pub fn without_barge_in_interruption(mut self) -> Self {
362 self.interrupt_on_barge_in = false;
363 self
364 }
365
366 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 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 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 link.push(env.meta, env.frame, env.direction).await;
422 Ok(())
423 }
424}
425
426#[cfg(feature = "vad-ort")]
443pub struct SileroVad {
444 sm: VadStateMachine,
445 sample_rate: u32,
446 window_samples: usize,
447 session: ort::session::Session,
448 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 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 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 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 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 fn voice_confidence(&mut self, pcm: &[i16]) -> f32 {
520 use ndarray::{Array1, Array2};
521
522 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 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 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 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 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 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 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, 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 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, stop_secs: 0.032, 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 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 assert_eq!(vad.analyze(&w()), VadState::Quiet);
680 assert_eq!(vad.analyze(&w()), VadState::Speaking);
682 vad.analyze(&w());
684 assert_eq!(vad.analyze(&w()), VadState::Quiet);
686 }
687
688 #[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 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 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 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 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 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 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 #[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 for _ in 0..5 {
903 let st = vad.analyze(&AudioFrame::mono(vec![0i16; 512], 16000));
904 assert_eq!(st, VadState::Quiet);
905 }
906 }
907}