Files
peerspeak/src/dsp/echo_path.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

133 lines
4.9 KiB
Rust

//! A synthetic acoustic echo path — the model the AEC test harness uses in place
//! of real speakers + a room + a microphone.
//!
//! When you're on a speakerphone call, the far-end voice comes out of your
//! speaker, bounces around the room, and re-enters your mic delayed, coloured,
//! and attenuated. That transformation is a linear filter: an **impulse
//! response** (IR). Convolving the far-end signal with an IR reproduces the echo
//! a mic would capture — fully deterministically, so the canceller can be
//! measured headless against ground truth.
//!
//! The IR here is `delay` samples of silence (bulk acoustic propagation /
//! buffering latency) followed by an exponentially-decaying diffuse tail (the
//! room reverb), scaled so the whole echo sits `~attenuation` below the far-end.
use super::generators;
/// A linear echo path expressed as an impulse response.
pub struct EchoPath {
/// The impulse response; `echo[n] = Σ_k ir[k]·far[n-k]`.
pub ir: Vec<f32>,
}
impl EchoPath {
/// Builds the path directly from a caller-supplied impulse response.
pub fn from_ir(ir: Vec<f32>) -> Self {
EchoPath { ir }
}
/// A synthetic room echo: `delay` samples of pure delay, then a `tail`-sample
/// exponentially-decaying diffuse reflection cluster. `attenuation` is the
/// peak linear gain of the path (e.g. `0.5` ≈ -6 dB echo). `seed` makes the
/// diffuse tail reproducible.
///
/// A strong early reflection is placed at the delay (the dominant first bounce)
/// followed by a decaying noisy tail, then the whole IR is normalized so its
/// energy gain matches `attenuation` — a realistic, non-trivial path for the
/// adaptive filter to identify.
pub fn synthetic(delay: usize, tail: usize, attenuation: f32, seed: u64) -> Self {
let tail = tail.max(1);
let mut ir = vec![0.0f32; delay + tail];
let noise = generators::white_noise(1.0, tail, seed);
// Time constant: decay to ~e^-4 (≈1.8%) by the end of the tail.
let tau = tail as f32 / 4.0;
for i in 0..tail {
let env = (-(i as f32) / tau).exp();
// Dominant direct reflection at i=0, diffuse decaying noise after.
let direct = if i == 0 { 1.0 } else { 0.4 * noise[i] };
ir[delay + i] = env * direct;
}
// Normalize so the path's RMS gain equals `attenuation` (energy-based, so
// ERLE targets are predictable regardless of delay/tail choices).
let energy: f32 = ir.iter().map(|&x| x * x).sum::<f32>().sqrt();
if energy > 1e-9 {
let scale = attenuation / energy;
for x in &mut ir {
*x *= scale;
}
}
EchoPath { ir }
}
/// Number of taps in the impulse response (its total length).
pub fn len(&self) -> usize {
self.ir.len()
}
pub fn is_empty(&self) -> bool {
self.ir.is_empty()
}
/// Convolves `far` with the impulse response, returning the echo signal the
/// mic would capture. Output length matches `far` (the convolution tail past
/// the input end is truncated — the far-end keeps going in a real call).
pub fn apply(&self, far: &[f32]) -> Vec<f32> {
let n = far.len();
let m = self.ir.len();
let mut echo = vec![0.0f32; n];
for (k, &h) in self.ir.iter().enumerate() {
if h == 0.0 {
continue;
}
// echo[i] += h * far[i-k] for all valid i.
for i in k..n {
echo[i] += h * far[i - k];
}
}
let _ = m;
echo
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dsp::metrics::rms;
#[test]
fn pure_delay_shifts_the_signal() {
// IR = single unit tap at index 3 -> echo is far delayed by 3 samples.
let path = EchoPath::from_ir({
let mut ir = vec![0.0; 4];
ir[3] = 1.0;
ir
});
let far = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let echo = path.apply(&far);
assert_eq!(echo, vec![0.0, 0.0, 0.0, 1.0, 2.0]);
}
#[test]
fn synthetic_path_attenuates_to_target() {
// A white-noise far-end through a path normalized to 0.5 RMS gain should
// produce an echo roughly 0.5x the far-end RMS (≈ -6 dB).
let far = generators::white_noise(0.5, 48_000, 1);
let path = EchoPath::synthetic(480, 480, 0.5, 99);
let echo = path.apply(&far);
let ratio = rms(&echo) / rms(&far);
assert!(
(0.3..0.7).contains(&ratio),
"echo/far rms ratio {ratio} off target"
);
}
#[test]
fn synthetic_path_has_leading_delay() {
let path = EchoPath::synthetic(100, 200, 0.5, 5);
// The first `delay` IR taps are silent (pure propagation delay).
assert!(path.ir[..100].iter().all(|&x| x == 0.0));
assert!(path.ir[100..].iter().any(|&x| x != 0.0));
assert_eq!(path.len(), 300);
}
}