//! Voice activity detection backends and helpers. //! //! Apple capture feeds VoiceProcessingIO/CoreAudio-processed microphone //! frames into this module and prefers Apple CoreML Silero VAD when the //! Swift bridge is linked. WebRTC VAD remains the realtime-safe fallback; //! non-Apple platforms may use ONNX-backed Silero when available. #[cfg(any(target_os = "ios", target_os = "macos"))] pub mod apple_coreml; pub mod resampler; #[cfg(not(target_os = "ios"))] pub mod silero_onnx; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{OnceLock, RwLock}; use crate::frame::{f32_to_i16, i16_to_f32}; use crate::AudioError; use resampler::{Downsampler48to16, INPUT_FRAME_10MS}; #[cfg(not(target_os = "ios"))] pub use silero_onnx::SileroOnnxVad; /// Voice activity detector output for one 10 ms frame. #[derive(Debug, Clone, Copy)] pub struct VadOutput { /// Speech confidence in the inclusive range `[0.0, 1.0]`. pub probability: f32, /// Immediate detector speech decision before hangover/min-duration state. pub speech: bool, } /// Realtime-safe detector that consumes one 10 ms f32 mono frame. pub trait VoiceActivityDetector: Send { /// Process one 10 ms frame and return speech probability/state. fn process_10ms(&mut self, samples: &[f32]) -> VadOutput; } /// Realtime-safe WebRTC VAD used when a model runtime is unavailable. pub struct WebRtcFallbackVad { vad: webrtc_vad::Vad, frame_i16: [i16; INPUT_FRAME_10MS], } // `webrtc_vad::Vad` owns an FFI pointer and is only touched from the // capture thread after construction. Moving the wrapper between threads is // safe; sharing it concurrently is not required and not implemented. unsafe impl Send for WebRtcFallbackVad {} impl Default for WebRtcFallbackVad { fn default() -> Self { Self { vad: webrtc_vad::Vad::new_with_rate_and_mode( webrtc_vad::SampleRate::Rate48kHz, webrtc_vad::VadMode::Aggressive, ), frame_i16: [0; INPUT_FRAME_10MS], } } } impl VoiceActivityDetector for WebRtcFallbackVad { fn process_10ms(&mut self, samples: &[f32]) -> VadOutput { for (dst, src) in self.frame_i16.iter_mut().zip(samples.iter().copied()) { *dst = f32_to_i16(src); } let speech = self.vad.is_voice_segment(&self.frame_i16).unwrap_or(false); VadOutput { probability: if speech { 1.0 } else { 0.0 }, speech, } } } /// Wraps any `VoiceActivityDetector` that operates at 16 kHz and /// downsamples 48 kHz input before forwarding. pub struct Resampled16kHzVad { inner: D, downsampler: Downsampler48to16, } impl Resampled16kHzVad { /// Wrap a 16 kHz detector so it can consume 48 kHz frames. pub fn new(inner: D) -> Self { Self { inner, downsampler: Downsampler48to16::default(), } } } impl VoiceActivityDetector for Resampled16kHzVad { fn process_10ms(&mut self, samples: &[f32]) -> VadOutput { debug_assert_eq!(samples.len(), INPUT_FRAME_10MS); let mut input = [0.0_f32; INPUT_FRAME_10MS]; input.copy_from_slice(samples); let downsampled = self.downsampler.process_frame_10ms(&input); self.inner.process_10ms(&downsampled) } } /// Convert one 48 kHz i16 10 ms frame and run a detector over it. pub fn process_i16_10ms(detector: &mut dyn VoiceActivityDetector, samples: &[i16]) -> VadOutput { let mut frame = [0.0_f32; INPUT_FRAME_10MS]; for (dst, src) in frame.iter_mut().zip(samples.iter().copied()) { *dst = i16_to_f32(src); } detector.process_10ms(&frame) } static SILERO_MODEL_PATH_OVERRIDE: OnceLock>> = OnceLock::new(); static SILERO_MODEL_EPOCH: AtomicU64 = AtomicU64::new(0); fn silero_model_path_override() -> &'static RwLock> { SILERO_MODEL_PATH_OVERRIDE.get_or_init(|| RwLock::new(None)) } /// Configure the preferred Silero ONNX model path on supported platforms. /// /// The path is validated eagerly. A successful call increments the /// model epoch so running non-iOS audio backends can reload the model /// without an app restart. pub fn set_silero_model_path(path: &str) -> Result<(), AudioError> { let path = path.trim(); if path.is_empty() { return Err(AudioError::InvalidAudioProcessingConfig( "vad model path must not be empty".to_string(), )); } if !std::path::Path::new(path).is_file() { return Err(AudioError::InvalidAudioProcessingConfig(format!( "vad model path does not exist or is not a file: {path}" ))); } let mut guard = silero_model_path_override() .write() .map_err(|_| AudioError::Backend("vad model path lock poisoned".to_string()))?; *guard = Some(path.to_string()); SILERO_MODEL_EPOCH.fetch_add(1, Ordering::Relaxed); Ok(()) } /// Monotonic counter incremented whenever the configured model path changes. pub fn silero_model_epoch() -> u64 { SILERO_MODEL_EPOCH.load(Ordering::Relaxed) } /// Return the expected path of the Silero VAD v6 ONNX model on /// supported platforms. /// The model is shipped as a Flutter asset and copied to the app's /// data directory by the Dart-side asset loader. /// /// Android and macOS may use app data/Documents locations. Desktop /// callers can set the path explicitly via `set_silero_model_path`. pub fn silero_model_bundle_path() -> String { if let Ok(guard) = silero_model_path_override().read() { if let Some(path) = guard.as_ref() { return path.clone(); } } // macOS: Documents directory (written by Flutter asset loader). // iOS keeps this fallback only for API compatibility; the ONNX // detector is not compiled into iOS builds. #[cfg(any(target_os = "ios", target_os = "macos"))] { if let Ok(home) = std::env::var("HOME") { let docs = format!("{home}/Documents/silero_vad.onnx"); if std::path::Path::new(&docs).exists() { return docs; } let bundle = format!("{home}/../Library/silero_vad.onnx"); if std::path::Path::new(&bundle).exists() { return bundle; } } "silero_vad.onnx".to_string() } // Android: the model is in the app's files directory, same // Documents path pattern used by Flutter's path_provider. #[cfg(target_os = "android")] { // On Android, Flutter's getApplicationDocumentsDirectory // resolves to /data/data//app_flutter. // The Silero model path is set explicitly via // set_silero_model_path from Dart before voice starts, // so this fallback is rarely needed. "silero_vad.onnx".to_string() } // Desktop (Windows, Linux): rely on the override set by Dart. #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))] { if let Ok(cwd) = std::env::current_dir() { let local = cwd.join("silero_vad.onnx"); if local.exists() { return local.to_string_lossy().to_string(); } } "silero_vad.onnx".to_string() } } #[cfg(test)] mod tests { use super::*; #[test] fn fallback_opens_for_voiced_signal() { let mut vad = WebRtcFallbackVad::default(); let mut frame = [0_i16; INPUT_FRAME_10MS]; for (idx, sample) in frame.iter_mut().enumerate() { let phase = idx as f32 * 2.0 * std::f32::consts::PI * 220.0 / 48_000.0; *sample = (phase.sin() * 12_000.0) as i16; } let output = process_i16_10ms(&mut vad, &frame); assert!(output.speech); assert!(output.probability >= 0.5); } #[test] fn fallback_stays_closed_for_silence() { let mut vad = WebRtcFallbackVad::default(); let frame = [0_i16; INPUT_FRAME_10MS]; let output = process_i16_10ms(&mut vad, &frame); assert!(!output.speech); assert!(output.probability < 0.5); } #[test] fn set_silero_model_path_rejects_missing_file() { let result = set_silero_model_path("/definitely/not/a/silero_vad.onnx"); assert!(result.is_err()); } #[test] fn set_silero_model_path_updates_override_and_epoch() { let path = std::env::temp_dir().join(format!("chanora_test_silero_{}.onnx", std::process::id())); std::fs::write(&path, b"test").unwrap(); let before = silero_model_epoch(); set_silero_model_path(path.to_str().unwrap()).unwrap(); assert!(silero_model_epoch() > before); assert_eq!(silero_model_bundle_path(), path.to_string_lossy()); let _ = std::fs::remove_file(path); } }