//! Objective signal metrics — the numbers that turn "sounds better" into a //! measurement. The headline one for echo cancellation is **ERLE** (Echo Return //! Loss Enhancement): how much echo energy the canceller removed, in dB. /// Root-mean-square level of a signal (linear amplitude). `0.0` for empty input. pub fn rms(samples: &[f32]) -> f32 { if samples.is_empty() { return 0.0; } let sum_sq: f64 = samples.iter().map(|&s| s as f64 * s as f64).sum(); (sum_sq / samples.len() as f64).sqrt() as f32 } /// Peak absolute amplitude. `0.0` for empty input. pub fn peak(samples: &[f32]) -> f32 { samples.iter().fold(0.0, |m, &s| m.max(s.abs())) } /// Converts a linear amplitude (e.g. an RMS or peak value, relative to full-scale /// `1.0`) to decibels below full scale. A floor of -120 dBFS is returned for /// silence so the result is always finite. pub fn dbfs(linear: f32) -> f32 { if linear <= 1e-6 { return -120.0; } 20.0 * linear.log10() } /// **Echo Return Loss Enhancement**, in dB: `10·log10(E[echo²] / E[residual²])`. /// /// `echo` is the signal *before* cancellation (the echo the mic picked up); /// `residual` is what's *left after* the canceller ran. A larger number is /// better — e.g. +30 dB means the canceller removed 99.9% of the echo energy. /// Returns a +120 dB ceiling if the residual is effectively silent (perfect /// cancellation) and 0.0 if there was no echo energy to begin with. pub fn erle(echo: &[f32], residual: &[f32]) -> f32 { let e_echo = mean_square(echo); let e_res = mean_square(residual); if e_echo <= 1e-12 { return 0.0; } if e_res <= 1e-12 { return 120.0; } 10.0 * (e_echo / e_res).log10() as f32 } /// Mean square (average energy per sample) of a signal. `0.0` for empty input. fn mean_square(samples: &[f32]) -> f64 { if samples.is_empty() { return 0.0; } samples.iter().map(|&s| s as f64 * s as f64).sum::() / samples.len() as f64 } /// A frequency band for per-band energy analysis, in Hz. pub struct Band { pub label: &'static str, pub low_hz: f32, pub high_hz: f32, } /// Voice-relevant bands for spotting *where* residual echo or noise lives. pub const VOICE_BANDS: &[Band] = &[ Band { label: "low (80-300)", low_hz: 80.0, high_hz: 300.0, }, Band { label: "low-mid (300-1k)", low_hz: 300.0, high_hz: 1000.0, }, Band { label: "mid (1k-3k)", low_hz: 1000.0, high_hz: 3000.0, }, Band { label: "high-mid (3k-6k)", low_hz: 3000.0, high_hz: 6000.0, }, Band { label: "high (6k-12k)", low_hz: 6000.0, high_hz: 12000.0, }, ]; /// Sums the linear magnitude energy within `[low_hz, high_hz)` across a single /// STFT magnitude frame. `bin_hz` maps a bin index to its centre frequency. pub fn band_energy(frame: &[f32], bin_hz: impl Fn(usize) -> f32, low_hz: f32, high_hz: f32) -> f32 { frame .iter() .enumerate() .filter(|&(bin, _)| { let hz = bin_hz(bin); hz >= low_hz && hz < high_hz }) .map(|(_, &m)| m * m) .sum() } #[cfg(test)] mod tests { use super::*; #[test] fn rms_of_constant_is_that_constant() { assert!((rms(&[0.5; 100]) - 0.5).abs() < 1e-6); assert_eq!(rms(&[]), 0.0); } #[test] fn peak_finds_max_magnitude() { assert_eq!(peak(&[0.1, -0.9, 0.3]), 0.9); } #[test] fn dbfs_landmarks() { assert!((dbfs(1.0) - 0.0).abs() < 1e-4, "full scale = 0 dBFS"); assert!((dbfs(0.5) - -6.0206).abs() < 1e-3, "half = ~-6 dB"); assert_eq!(dbfs(0.0), -120.0, "silence floors"); } #[test] fn erle_halving_energy_is_about_3db() { // residual amplitude = echo/sqrt(2) -> half the energy -> ~3.01 dB. let echo = vec![1.0f32; 1000]; let residual = vec![std::f32::consts::FRAC_1_SQRT_2; 1000]; let e = erle(&echo, &residual); assert!((e - 3.0103).abs() < 0.01, "expected ~3 dB, got {e}"); } #[test] fn erle_perfect_cancellation_ceils() { assert_eq!(erle(&[1.0; 10], &[0.0; 10]), 120.0); assert_eq!(erle(&[0.0; 10], &[0.0; 10]), 0.0, "no echo -> 0"); } #[test] fn band_energy_selects_the_right_bins() { // 5-bin frame, 100 Hz per bin: bins at 0,100,200,300,400 Hz. let frame = [1.0, 2.0, 3.0, 4.0, 5.0]; let bin_hz = |b: usize| b as f32 * 100.0; // [150,350) -> bins 200,300 Hz -> 3^2 + 4^2 = 25. let e = band_energy(&frame, bin_hz, 150.0, 350.0); assert!((e - 25.0).abs() < 1e-4, "got {e}"); } }