use super::{VadBackend, VadStream};
const FRAME_SIZE: usize = 160;
const FLOOR_DB: f32 = -100.0;
#[derive(Debug, Clone)]
pub struct EnergyVad {
margin_db: f32,
ramp_db: f32,
speech_floor_db: f32,
}
impl EnergyVad {
pub fn new() -> Self {
Self {
margin_db: 10.0,
ramp_db: 6.0,
speech_floor_db: -38.0,
}
}
pub fn with_margin_db(mut self, margin_db: f32) -> Self {
self.margin_db = margin_db;
self
}
pub fn with_ramp_db(mut self, ramp_db: f32) -> Self {
self.ramp_db = ramp_db.max(f32::EPSILON);
self
}
pub fn with_speech_floor_db(mut self, speech_floor_db: f32) -> Self {
self.speech_floor_db = speech_floor_db;
self
}
}
impl Default for EnergyVad {
fn default() -> Self {
Self::new()
}
}
impl VadBackend for EnergyVad {
fn frame_size(&self) -> usize {
FRAME_SIZE
}
fn required_sample_rate(&self) -> Option<u32> {
None
}
fn start(&self) -> Box<dyn VadStream> {
Box::new(EnergyStream {
margin_db: self.margin_db,
ramp_db: self.ramp_db,
speech_floor_db: self.speech_floor_db,
noise_floor_db: -60.0,
})
}
}
struct EnergyStream {
margin_db: f32,
ramp_db: f32,
speech_floor_db: f32,
noise_floor_db: f32,
}
fn ramp(excess_db: f32, ramp_db: f32) -> f32 {
(0.5 + 0.5 * excess_db / ramp_db).clamp(0.0, 1.0)
}
impl VadStream for EnergyStream {
fn speech_probability(&mut self, frame: &[f32]) -> f32 {
let db = rms_db(frame);
let relative = ramp(db - (self.noise_floor_db + self.margin_db), self.ramp_db);
let absolute = ramp(db - self.speech_floor_db, self.ramp_db);
if db < self.noise_floor_db {
self.noise_floor_db += (db - self.noise_floor_db) * 0.5;
} else {
self.noise_floor_db += (db - self.noise_floor_db) * 0.005;
}
relative.max(absolute)
}
}
fn rms_db(frame: &[f32]) -> f32 {
if frame.is_empty() {
return FLOOR_DB;
}
let sum_sq: f32 = frame.iter().map(|s| s * s).sum();
let rms = (sum_sq / frame.len() as f32).sqrt();
if rms <= 0.0 {
return FLOOR_DB;
}
(20.0 * rms.log10()).max(FLOOR_DB)
}
#[cfg(test)]
mod tests {
use super::*;
fn tone(n: usize, amplitude: f32) -> Vec<f32> {
(0..n).map(|i| (i as f32 * 0.3).sin() * amplitude).collect()
}
fn drive(backend: &EnergyVad, frames: &[Vec<f32>]) -> Vec<f32> {
let mut stream = backend.start();
frames.iter().map(|f| stream.speech_probability(f)).collect()
}
#[test]
fn digital_silence_never_scores_as_speech() {
let frames = vec![vec![0.0f32; FRAME_SIZE]; 200];
let probs = drive(&EnergyVad::new(), &frames);
assert!(
probs.iter().all(|p| *p < 0.5),
"silence scored as speech: max {:?}",
probs.iter().cloned().fold(0.0f32, f32::max)
);
}
#[test]
fn a_loud_tone_scores_as_speech() {
let frames = vec![tone(FRAME_SIZE, 0.4); 50];
let probs = drive(&EnergyVad::new(), &frames);
assert!(
probs.iter().all(|p| *p >= 0.5),
"a tone 90 dB over digital silence should score as speech, got {probs:?}"
);
}
#[test]
fn steady_background_noise_stops_scoring_as_speech() {
let frames = vec![tone(FRAME_SIZE, 0.005); 1000];
let probs = drive(&EnergyVad::new(), &frames);
assert!(
probs.last().copied().unwrap() < 0.5,
"the floor should have risen to meet steady noise, ended at {:?}",
probs.last()
);
}
#[test]
fn speech_is_detected_over_adapted_background_noise() {
let mut frames = vec![tone(FRAME_SIZE, 0.005); 1000];
frames.extend(vec![tone(FRAME_SIZE, 0.4); 20]);
let probs = drive(&EnergyVad::new(), &frames);
assert!(
probs[1000..].iter().all(|p| *p >= 0.5),
"speech 38 dB over the settled noise floor must score as speech, got {:?}",
&probs[1000..]
);
}
#[test]
fn sustained_speech_does_not_mute_itself() {
let frames = vec![tone(FRAME_SIZE, 0.4); 3000]; let probs = drive(&EnergyVad::new(), &frames);
assert!(
probs.last().copied().unwrap() >= 0.5,
"30 s of continuous speech ended at {:?}",
probs.last()
);
}
#[test]
fn speech_below_the_absolute_floor_eventually_fades() {
let frames = vec![tone(FRAME_SIZE, 0.005); 2000];
let probs = drive(&EnergyVad::new().with_speech_floor_db(-20.0), &frames);
assert!(
probs[0] >= 0.5 && probs.last().copied().unwrap() < 0.5,
"expected a quiet steady signal to start as speech and fade; \
got {:?} → {:?}",
probs[0],
probs.last()
);
}
#[test]
fn a_wider_margin_demands_a_louder_frame() {
let frames = vec![tone(FRAME_SIZE, 0.005); 200];
let strict = drive(&EnergyVad::new().with_margin_db(40.0), &frames);
let lenient = drive(&EnergyVad::new().with_margin_db(3.0), &frames);
assert!(
strict.iter().sum::<f32>() < lenient.iter().sum::<f32>(),
"a 40 dB margin should score fewer frames as speech than a 3 dB one"
);
}
#[test]
fn rms_db_handles_empty_and_zero_frames() {
assert_eq!(rms_db(&[]), FLOOR_DB);
assert_eq!(rms_db(&[0.0; 8]), FLOOR_DB);
assert!(rms_db(&[1.0; 8]) > -0.001, "full scale should read ~0 dBFS");
}
}