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>
This commit is contained in:
+171
@@ -0,0 +1,171 @@
|
||||
//! An in-process acoustic echo canceller built on a **Normalized Least Mean
|
||||
//! Squares (NLMS)** adaptive FIR filter.
|
||||
//!
|
||||
//! The idea: we know the far-end signal exactly (it's what we're about to play
|
||||
//! out), and the mic picks up an echo of it shaped by the unknown room. An
|
||||
//! adaptive filter continuously estimates that room response from the data:
|
||||
//! each sample it predicts the echo as a weighted sum of recent far-end samples,
|
||||
//! subtracts that prediction from the mic, and nudges its weights to shrink the
|
||||
//! leftover. With only far-end present, the leftover (the "error" signal) drives
|
||||
//! the weights toward the true echo path; once converged, the error *is* the
|
||||
//! cleaned near-end audio.
|
||||
//!
|
||||
//! NLMS over plain LMS divides the update by the reference's instantaneous energy,
|
||||
//! which makes the convergence speed independent of how loud the far-end is — the
|
||||
//! reason it's the workhorse of practical echo cancellation.
|
||||
//!
|
||||
//! This is the pure, sample-at-a-time core. Double-talk handling and a residual
|
||||
//! suppressor build on top of it; integration into the live audio path wraps it.
|
||||
|
||||
/// A single-channel NLMS echo canceller.
|
||||
pub struct Nlms {
|
||||
/// Adaptive filter coefficients (the estimated echo-path impulse response).
|
||||
/// `weights[k]` multiplies the far-end sample `k` steps in the past.
|
||||
weights: Vec<f32>,
|
||||
/// Ring buffer of recent far-end (reference) samples, same length as weights.
|
||||
history: Vec<f32>,
|
||||
/// Index in `history` where the newest sample currently lives.
|
||||
pos: usize,
|
||||
/// Step size (0 < mu < 2). Larger = faster adaptation but more misadjustment.
|
||||
mu: f32,
|
||||
/// Regularization added to the energy normalizer to avoid divide-by-zero and
|
||||
/// runaway updates when the far-end is near silent.
|
||||
eps: f32,
|
||||
/// When false, weights are held fixed (used during double-talk).
|
||||
adapting: bool,
|
||||
}
|
||||
|
||||
impl Nlms {
|
||||
/// Creates an NLMS canceller with `taps` filter coefficients (should cover the
|
||||
/// echo path's length: delay + reverb tail), step size `mu` in `(0, 2)`, and
|
||||
/// regularization `eps`. Sensible defaults: `mu = 0.5`, `eps = 1e-6`.
|
||||
pub fn new(taps: usize, mu: f32, eps: f32) -> Self {
|
||||
let taps = taps.max(1);
|
||||
Nlms {
|
||||
weights: vec![0.0; taps],
|
||||
history: vec![0.0; taps],
|
||||
pos: 0,
|
||||
mu,
|
||||
eps,
|
||||
adapting: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of filter taps.
|
||||
pub fn taps(&self) -> usize {
|
||||
self.weights.len()
|
||||
}
|
||||
|
||||
/// Enables or disables weight adaptation. Disable during double-talk so the
|
||||
/// near-end voice doesn't corrupt the learned echo path.
|
||||
pub fn set_adapting(&mut self, on: bool) {
|
||||
self.adapting = on;
|
||||
}
|
||||
|
||||
/// A copy of the current estimated impulse response (the filter weights),
|
||||
/// newest-tap-first. Useful for tests/inspection.
|
||||
pub fn weights(&self) -> &[f32] {
|
||||
&self.weights
|
||||
}
|
||||
|
||||
/// Processes one sample pair and returns the echo-cancelled output.
|
||||
///
|
||||
/// `reference` is the far-end sample about to be (or being) played out;
|
||||
/// `mic` is the simultaneously-captured near-end mic sample (near-end voice +
|
||||
/// echo of the far-end + noise). The returned value is `mic` minus the
|
||||
/// filter's echo estimate — i.e. the cleaned near-end.
|
||||
pub fn process_sample(&mut self, reference: f32, mic: f32) -> f32 {
|
||||
let l = self.weights.len();
|
||||
// Store newest reference at the current ring position.
|
||||
self.history[self.pos] = reference;
|
||||
|
||||
// Predict the echo: weights[k] pairs with the sample k steps back, which
|
||||
// sits at ring index (pos - k). Accumulate the reference energy too.
|
||||
let mut echo_est = 0.0f32;
|
||||
let mut energy = 0.0f32;
|
||||
for k in 0..l {
|
||||
let idx = (self.pos + l - k) % l;
|
||||
let s = self.history[idx];
|
||||
echo_est += self.weights[k] * s;
|
||||
energy += s * s;
|
||||
}
|
||||
|
||||
let error = mic - echo_est;
|
||||
|
||||
// NLMS weight update: w += mu * error * x / (||x||^2 + eps).
|
||||
if self.adapting {
|
||||
let g = self.mu * error / (energy + self.eps);
|
||||
for k in 0..l {
|
||||
let idx = (self.pos + l - k) % l;
|
||||
self.weights[k] += g * self.history[idx];
|
||||
}
|
||||
}
|
||||
|
||||
// Advance the ring so the next sample overwrites the oldest entry.
|
||||
self.pos = (self.pos + 1) % l;
|
||||
error
|
||||
}
|
||||
|
||||
/// Convenience: cancels a whole buffer, returning the cleaned output. `mic`
|
||||
/// and `reference` are processed pairwise over their shared length.
|
||||
pub fn process(&mut self, reference: &[f32], mic: &[f32]) -> Vec<f32> {
|
||||
let n = reference.len().min(mic.len());
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
out.push(self.process_sample(reference[i], mic[i]));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dsp::echo_path::EchoPath;
|
||||
use crate::dsp::generators::white_noise;
|
||||
use crate::dsp::metrics::erle;
|
||||
|
||||
/// With only far-end present (no near-end talk), the filter should identify
|
||||
/// the echo path and drive ERLE high — and the late part of the call should
|
||||
/// be far better cancelled than the early part (it learns over time).
|
||||
#[test]
|
||||
fn converges_on_a_known_echo_path() {
|
||||
let far = white_noise(0.5, 48_000, 1);
|
||||
let path = EchoPath::synthetic(64, 192, 0.5, 7); // 256-tap path
|
||||
let echo = path.apply(&far);
|
||||
// Mic is echo-only (single-talk): the ideal case for adaptation.
|
||||
let mut aec = Nlms::new(512, 0.5, 1e-6);
|
||||
let cleaned = aec.process(&far, &echo);
|
||||
|
||||
// Compare ERLE on the first vs last quarter.
|
||||
let q = echo.len() / 4;
|
||||
let early = erle(&echo[..q], &cleaned[..q]);
|
||||
let late = erle(&echo[3 * q..], &cleaned[3 * q..]);
|
||||
assert!(late > early + 10.0, "should improve markedly: early {early:.1} late {late:.1}");
|
||||
assert!(late > 20.0, "converged ERLE should exceed 20 dB, got {late:.1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_filter_does_not_adapt() {
|
||||
let far = white_noise(0.5, 4000, 2);
|
||||
let echo = EchoPath::synthetic(16, 48, 0.5, 3).apply(&far);
|
||||
let mut aec = Nlms::new(128, 0.5, 1e-6);
|
||||
aec.set_adapting(false);
|
||||
aec.process(&far, &echo);
|
||||
// Weights must remain exactly zero with adaptation off.
|
||||
assert!(aec.weights().iter().all(|&w| w == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passes_near_end_through_when_no_echo() {
|
||||
// No far-end (reference silent) -> filter predicts ~0 -> near-end passes
|
||||
// through essentially unchanged.
|
||||
let near = white_noise(0.3, 2000, 9);
|
||||
let silent_ref = vec![0.0f32; near.len()];
|
||||
let mut aec = Nlms::new(128, 0.5, 1e-6);
|
||||
let out = aec.process(&silent_ref, &near);
|
||||
for (a, b) in near.iter().zip(&out) {
|
||||
assert!((a - b).abs() < 1e-6, "near-end should pass through: {a} vs {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
//! `render`) have no I/O; `wav` is the only edge that touches the filesystem.
|
||||
//! Driven from the `specview` binary (`src/bin/specview.rs`).
|
||||
|
||||
pub mod aec;
|
||||
pub mod echo_path;
|
||||
pub mod fft;
|
||||
pub mod generators;
|
||||
pub mod metrics;
|
||||
|
||||
Reference in New Issue
Block a user