//! Phase 11.1 smoke test for Silero V5. Runs the model on the //! synthetic 50/50 silence/speech WAV (created by `make_silence_test`) //! and verifies that speech windows give high probability, silence //! windows give low. //! //! Usage: //! ```bash //! # First create the test WAV (silence + speech + silence + silence): //! cargo run -p rtx-csm --release --example make_silence_test //! //! # Then run the VAD smoke: //! cargo run -p rtx-csm --release --features metal --example silero_vad_smoke //! ``` use anyhow::{Context, Result}; use candle_core::Device; use rtx_csm::{audio_io, silero_vad::SileroVad}; use std::time::Instant; fn main() -> Result<()> { let device = if candle_core::utils::metal_is_available() { Device::new_metal(0)? } else { Device::Cpu }; eprintln!("device: {device:?}"); let load_t = Instant::now(); let vad = SileroVad::load_default(&device).context("load Silero VAD")?; eprintln!("model loaded in {} ms", load_t.elapsed().as_millis()); // Test path: prefer the silence-heavy synthetic WAV (50% silence) // if available; fall back to the LibriSpeech sample (mostly speech). let path = if std::path::Path::new("/tmp/asr_silence_heavy.wav").exists() { std::path::Path::new("/tmp/asr_silence_heavy.wav") } else { std::path::Path::new("/tmp/asr_test.flac") }; eprintln!("test audio: {}", path.display()); let samples = audio_io::load_mono_at_rate(path, 16_000) .context("load + resample to 16 kHz")?; eprintln!( " {} samples ({:.2}s @ 16 kHz)", samples.len(), samples.len() as f32 / 16_000.0 ); let inf_t = Instant::now(); let probs = vad.forward_audio(&samples, &device)?; let inf_ms = inf_t.elapsed().as_millis(); let audio_secs = samples.len() as f32 / 16_000.0; eprintln!( "VAD sweep: {inf_ms} ms over {} chunks ({:.4}× realtime)", probs.len(), inf_ms as f32 / (audio_secs * 1000.0) ); // Each chunk = 512 samples = 32 ms. Print probabilities every 250 ms // (~8 chunks). println!(); println!("=== speech probability over time ==="); println!("(each chunk is 32 ms; printed every 8 chunks ~= every 256 ms)"); let mut speech_count = 0; let mut silence_count = 0; for (i, &p) in probs.iter().enumerate() { if p > 0.5 { speech_count += 1; } else { silence_count += 1; } if i % 8 == 0 { let ms = i * 32; let bar = (p * 40.0) as usize; let bar_str: String = std::iter::repeat('█').take(bar).collect(); println!(" [{ms:>5} ms] p={p:.3} {bar_str}"); } } println!(); println!("=== summary ==="); println!( "speech chunks: {speech_count} ({:.1}%)", 100.0 * speech_count as f32 / probs.len() as f32 ); println!( "silence chunks: {silence_count} ({:.1}%)", 100.0 * silence_count as f32 / probs.len() as f32 ); println!("total: {} chunks ({:.2} s of audio)", probs.len(), audio_secs); if path.ends_with("asr_silence_heavy.wav") { // Synthetic layout: 1 s silence + 4 s speech + 5 s silence. // Expect ~40 % speech. let speech_pct = speech_count as f32 / probs.len() as f32; if (0.30..0.55).contains(&speech_pct) { println!("PASS: discrimination matches 50/50 synthetic layout"); } else { println!( "WARN: expected ~40% speech, got {:.1}% (model may be miscalibrated)", speech_pct * 100.0 ); } } else if path.ends_with("asr_test.flac") { // LibriSpeech is mostly speech. Expect ≥ 80 % speech. let speech_pct = speech_count as f32 / probs.len() as f32; if speech_pct > 0.80 { println!("PASS: LibriSpeech sample classified mostly as speech"); } else { println!( "WARN: expected > 80% speech, got {:.1}%", speech_pct * 100.0 ); } } Ok(()) }