Files
peerspeak/src/dsp/echo_path.rs
T
molluskandClaude Opus 4.8 eeb725f996 feat(dsp): in-process NLMS echo canceller + headless AEC test loop
Stage A+B of the in-process AEC: a Normalized LMS adaptive-filter echo
canceller and a synthetic echo-path simulator, driven end-to-end by a new
`specview aec` command so cancellation can be measured fully headless on
conjured signals (no speakers/mic needed).

- src/dsp/echo_path.rs: EchoPath — synthetic acoustic echo (bulk delay +
  exponentially-decaying diffuse RIR, energy-normalized to a target
  attenuation) convolved over a far-end signal. Gives ground-truth echo.
- src/dsp/aec.rs: Nlms — sample-at-a-time NLMS adaptive FIR (ring-buffered
  reference history, energy-normalized update, freezable for double-talk).
  Cleaned output = mic minus the learned echo estimate.
- specview aec: far -> sim echo -> (+ optional near-end) -> cancel -> measure.
  ERLE via oracle residual (cleaned - near), broadband + early/late
  (convergence) + per voice band, optional before/after spectrograms.
- 6 new tests (path delay/attenuation, NLMS convergence >20 dB on a known
  path, frozen-filter no-op, near-end passthrough). 33 dsp tests total.

Verified: single-talk pink-noise echo cancels +14.5 -> +32.0 dB ERLE as the
filter converges; double-talk (no DTD yet) drives ERLE negative as the filter
diverges onto the near-end tone — the motivating result for Stage C (DTD).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 02:39:01 -04:00

130 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);
}
}