makepad/libs/voice/tests/silero_vad.rs
Admin 0727415daa voice: pure-rust silero v5 vad + learned speech gate in window voice input
Ports the silero v5 16k branch to pure rust (minimal onnx-protobuf weight
extraction, hardcoded graph: stft-conv, 4x conv+relu, lstm cell, sigmoid
head), 512-sample chunks with 64-sample carried context. Validated against
onnxruntime to 2.3e-6 max diff (fixtures committed), ~425us/chunk release.
Model loads from repo-root silero_vad.onnx or MAKEPAD_VAD_MODEL. vad-test
bin for wav files. window_voice_input now gates packets on vad probability
(0.5 enter / 0.35 exit) and falls back to the rms gate when the model file
is missing — the log line says which gate is active.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:47:05 +02:00

126 lines
4.8 KiB
Rust

//! Numeric validation of the pure-Rust Silero VAD against onnxruntime.
//!
//! The reference probabilities in `tests/fixtures/` were produced by
//! onnxruntime 1.28 running the official `silero_vad.onnx` (see the oracle
//! script noted in `src/vad.rs`). Both tests need the model file, resolved
//! from `MAKEPAD_VAD_MODEL` or the repo root; they skip quietly if it is
//! missing so the suite still passes on machines without the weights.
use makepad_voice::{SileroVad, VAD_CHUNK_SAMPLES};
use std::path::{Path, PathBuf};
fn repo_root_file(name: &str) -> Option<PathBuf> {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").join(name);
path.is_file().then_some(path)
}
fn load_vad() -> Option<SileroVad> {
let path = std::env::var("MAKEPAD_VAD_MODEL")
.ok()
.map(PathBuf::from)
.filter(|path| path.is_file())
.or_else(|| repo_root_file("silero_vad.onnx"));
let Some(path) = path else {
eprintln!("skipping: silero_vad.onnx not found (repo root or MAKEPAD_VAD_MODEL)");
return None;
};
Some(SileroVad::load(path.to_str().unwrap()).expect("model should load"))
}
fn load_fixture(name: &str) -> Vec<f32> {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name);
std::fs::read_to_string(&path)
.unwrap_or_else(|err| panic!("{}: {err}", path.display()))
.lines()
.map(|line| line.trim().parse().expect("fixture float"))
.collect()
}
fn compare(vad: &mut SileroVad, signal: &[f32], expected: &[f32], what: &str) {
vad.reset();
let mut max_diff = 0.0f32;
for (index, expect) in expected.iter().enumerate() {
let chunk = &signal[index * VAD_CHUNK_SAMPLES..(index + 1) * VAD_CHUNK_SAMPLES];
let prob = vad.process_chunk(chunk);
let diff = (prob - expect).abs();
if diff > max_diff {
max_diff = diff;
}
assert!(
diff < 1.5e-3,
"{what} chunk {index}: rust {prob} vs onnxruntime {expect}"
);
}
eprintln!("{what}: {} chunks, max diff {max_diff:.2e}", expected.len());
}
/// The deterministic signal must match the oracle script exactly: silence,
/// then LCG noise at 0.05, a vibrato tone, and LCG noise at 0.3 (the LCG
/// stream continues across the two noise segments).
fn synth_signal() -> Vec<f32> {
use std::f64::consts::PI;
let mut sig = vec![0.0f32; 16000 * 4];
let mut lcg_state: u32 = 12345;
let mut lcg = move || -> f64 {
lcg_state = lcg_state.wrapping_mul(1664525).wrapping_add(1013904223);
(lcg_state as f64 / 4294967296.0) * 2.0 - 1.0
};
for sample in &mut sig[16000..32000] {
*sample = (0.05 * lcg()) as f32;
}
for (index, sample) in sig[32000..48000].iter_mut().enumerate() {
let t = index as f64 / 16000.0;
let f = 220.0 + 40.0 * (2.0 * PI * 3.0 * t).sin();
*sample = (0.2 * (2.0 * PI * f * t).sin() + 0.05 * (2.0 * PI * 2.0 * f * t).sin()) as f32;
}
for sample in &mut sig[48000..64000] {
*sample = (0.3 * lcg()) as f32;
}
sig
}
#[test]
fn silero_matches_onnxruntime_on_synthetic_signal() {
let Some(mut vad) = load_vad() else { return };
let expected = load_fixture("silero_ref_synth.txt");
compare(&mut vad, &synth_signal(), &expected, "synth");
}
#[test]
fn silero_matches_onnxruntime_on_speech() {
let Some(mut vad) = load_vad() else { return };
let Some(wav_path) = repo_root_file("kokoro_ref_16k.wav") else {
eprintln!("skipping: kokoro_ref_16k.wav not found at repo root");
return;
};
let signal = read_wav_16k_mono(&wav_path);
let expected = load_fixture("silero_ref_speech.txt");
compare(&mut vad, &signal, &expected, "speech");
}
fn read_wav_16k_mono(path: &Path) -> Vec<f32> {
let bytes = std::fs::read(path).expect("wav read");
assert_eq!(&bytes[0..4], b"RIFF");
assert_eq!(&bytes[8..12], b"WAVE");
let mut pos = 12;
let mut samples = Vec::new();
while pos + 8 <= bytes.len() {
let id = &bytes[pos..pos + 4];
let size = u32::from_le_bytes(bytes[pos + 4..pos + 8].try_into().unwrap()) as usize;
let body = &bytes[pos + 8..(pos + 8 + size).min(bytes.len())];
if id == b"fmt " {
let channels = u16::from_le_bytes(body[2..4].try_into().unwrap());
let rate = u32::from_le_bytes(body[4..8].try_into().unwrap());
let bits = u16::from_le_bytes(body[14..16].try_into().unwrap());
assert_eq!((channels, rate, bits), (1, 16000, 16), "expect 16k mono s16");
} else if id == b"data" {
for chunk in body.chunks_exact(2) {
samples.push(i16::from_le_bytes(chunk.try_into().unwrap()) as f32 / 32768.0);
}
}
pos += 8 + size + (size & 1);
}
samples
}