Files
peerspeak/src/dsp/metrics.rs
T
molluskandClaude Opus 4.8 d0a16cb8b9 style: apply cargo fmt across the crate (A20)
The repo never enforced rustfmt, so formatting had drifted broadly. This is a
single mechanical `cargo fmt` pass over the whole crate (no behavioral change;
lib suite green, 493 passed). Going forward fmt should be enforced (planned CI
fmt --check step). Part of the 0.6.1 hygiene pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 02:11:44 -04:00

153 lines
4.6 KiB
Rust

//! 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::<f64>() / 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}");
}
}