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>
153 lines
4.6 KiB
Rust
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}");
|
|
}
|
|
}
|