//! 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, } impl EchoPath { /// Builds the path directly from a caller-supplied impulse response. pub fn from_ir(ir: Vec) -> 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::().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 { 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); } }