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>
133 lines
4.9 KiB
Rust
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);
|
|
}
|
|
}
|