//! 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. use std::collections::VecDeque; /// 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, /// Ring buffer of recent far-end (reference) samples, same length as weights. history: Vec, /// 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 { 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 } } /// Geigel double-talk detector. /// /// The danger with an adaptive echo canceller is *double-talk*: when the /// near-end person speaks at the same time as the far-end, the mic contains /// near-end voice the filter has no reference for. If it keeps adapting it will /// (wrongly) try to model that voice as echo and diverge, wrecking the painstakingly /// learned echo path. The fix is to detect double-talk and freeze adaptation /// while it lasts (the filter coasts on its last good estimate). /// /// Geigel's classic test: declare near-end speech present when the mic level /// rises above a fraction `threshold` of the recent peak far-end level. The echo /// is always an attenuated, smeared copy of the far-end, so a mic louder than the /// far-end's recent peak (scaled) can only be near-end energy. A `hangover` keeps /// the freeze latched for a while after the trigger so brief dips mid-utterance /// don't immediately re-enable adaptation. pub struct DoubleTalkDetector { /// Sliding window (samples) over which the peak far-end level is tracked. window: usize, /// Detection threshold: `|mic| ≥ threshold · max|far|` over the window ⇒ talk. threshold: f32, /// Samples to keep adaptation frozen after the last trigger. hangover: usize, /// Monotonic-decreasing deque of `(sample_index, |far|)` for O(1) sliding max. mono: VecDeque<(usize, f32)>, /// Running sample counter. i: usize, /// Samples of freeze remaining. hangover_left: usize, } impl DoubleTalkDetector { /// `window` should roughly cover the echo path length (e.g. the filter taps), /// `threshold` is the Geigel fraction (≈0.5–0.7 for ~6 dB echo coupling), and /// `hangover` is how long to stay frozen after a trigger (e.g. ~50 ms). pub fn new(window: usize, threshold: f32, hangover: usize) -> Self { DoubleTalkDetector { window: window.max(1), threshold, hangover, mono: VecDeque::new(), i: 0, hangover_left: 0, } } /// Feeds one sample pair; returns `true` if double-talk is currently declared /// (i.e. adaptation should be frozen this sample). pub fn update(&mut self, reference: f32, mic: f32) -> bool { let ax = reference.abs(); // Maintain the monotonic-decreasing deque: drop smaller tail entries. while matches!(self.mono.back(), Some(&(_, v)) if v <= ax) { self.mono.pop_back(); } self.mono.push_back((self.i, ax)); // Evict entries that have fallen out of the sliding window. let window_start = (self.i + 1).saturating_sub(self.window); while matches!(self.mono.front(), Some(&(idx, _)) if idx < window_start) { self.mono.pop_front(); } let far_max = self.mono.front().map(|&(_, v)| v).unwrap_or(0.0); self.i += 1; // Geigel decision (only meaningful when the far-end is actually active). if far_max > 1e-6 && mic.abs() >= self.threshold * far_max { self.hangover_left = self.hangover; } if self.hangover_left > 0 { self.hangover_left -= 1; true } else { false } } } /// A complete in-process echo canceller: an [`Nlms`] adaptive filter guarded by a /// [`DoubleTalkDetector`]. This is the unit the live audio path will wrap — feed /// it the far-end reference and the mic, get back cleaned near-end audio, with /// adaptation automatically frozen during double-talk. pub struct EchoCanceller { nlms: Nlms, dtd: DoubleTalkDetector, /// Whether double-talk protection is active (off = raw NLMS, for A/B testing). dtd_enabled: bool, /// Count of samples the most recent run flagged as double-talk. double_talk_samples: usize, /// Total samples processed (for the double-talk rate). total_samples: usize, } impl EchoCanceller { /// Builds a canceller with `taps` filter length, NLMS `mu`/`eps`, and DTD /// `threshold`/`hangover`. The DTD window is the filter length. pub fn new(taps: usize, mu: f32, eps: f32, dtd_threshold: f32, hangover: usize) -> Self { EchoCanceller { nlms: Nlms::new(taps, mu, eps), dtd: DoubleTalkDetector::new(taps, dtd_threshold, hangover), dtd_enabled: true, double_talk_samples: 0, total_samples: 0, } } /// Enables/disables the double-talk freeze (disable to compare against raw NLMS). pub fn set_dtd_enabled(&mut self, on: bool) { self.dtd_enabled = on; } /// Fraction of processed samples that were flagged as double-talk (0.0–1.0). pub fn double_talk_rate(&self) -> f32 { if self.total_samples == 0 { 0.0 } else { self.double_talk_samples as f32 / self.total_samples as f32 } } /// Processes one sample pair: runs the detector, freezes adaptation if it /// fires, and returns the cleaned output. pub fn process_sample(&mut self, reference: f32, mic: f32) -> f32 { let double_talk = self.dtd_enabled && self.dtd.update(reference, mic); self.nlms.set_adapting(!double_talk); self.total_samples += 1; if double_talk { self.double_talk_samples += 1; } self.nlms.process_sample(reference, mic) } /// Cancels a whole buffer pairwise over the shared length. pub fn process(&mut self, reference: &[f32], mic: &[f32]) -> Vec { 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}" ); } } /// Builds a near-end signal that is silent until `onset`, then a loud tone — /// the canonical "far-end converges, then both talk" double-talk scenario. fn near_end_with_onset(len: usize, onset: usize) -> Vec { use std::f64::consts::PI; (0..len) .map(|i| { if i < onset { 0.0 } else { 0.5 * (2.0 * PI * 300.0 * i as f64 / 48_000.0).sin() as f32 } }) .collect() } #[test] fn detector_fires_on_near_end_not_on_echo() { // Realistic ~-12 dB echo coupling (atten 0.25); louder coupling makes // Geigel false-positive on white-noise echo peaks (a real limitation). let far = white_noise(0.5, 20_000, 4); let echo = EchoPath::synthetic(64, 192, 0.25, 5).apply(&far); let onset = 10_000; let near = near_end_with_onset(far.len(), onset); let mic: Vec = echo.iter().zip(&near).map(|(&e, &n)| e + n).collect(); let mut dtd = DoubleTalkDetector::new(256, 0.7, 200); let mut early_hits = 0; let mut late_hits = 0; for i in 0..far.len() { if dtd.update(far[i], mic[i]) { if i < onset { early_hits += 1 } else { late_hits += 1 } } } // Echo-only stretch should rarely trip; near-end stretch should trip a lot. let early_rate = early_hits as f32 / onset as f32; let late_rate = late_hits as f32 / (far.len() - onset) as f32; assert!( early_rate < 0.10, "false-positive rate {early_rate:.2} too high" ); assert!( late_rate > 0.50, "missed double-talk, rate only {late_rate:.2}" ); } #[test] fn dtd_protects_convergence_under_double_talk() { // Far-end runs the whole time; near-end starts at 60%. The filter should // converge during the single-talk opening, then the DTD freezes it so the // late (double-talk) echo stays cancelled. Without DTD it diverges. let far = white_noise(0.5, 60_000, 6); let path = EchoPath::synthetic(64, 192, 0.25, 7); let echo = path.apply(&far); let onset = (far.len() as f32 * 0.6) as usize; let near = near_end_with_onset(far.len(), onset); let mic: Vec = echo.iter().zip(&near).map(|(&e, &n)| e + n).collect(); let late = onset..far.len(); let mut with_dtd = EchoCanceller::new(512, 0.5, 1e-6, 0.7, 1200); let cleaned_d = with_dtd.process(&far, &mic); let res_d: Vec = cleaned_d.iter().zip(&near).map(|(&c, &n)| c - n).collect(); let erle_dtd = erle(&echo[late.clone()], &res_d[late.clone()]); let mut no_dtd = EchoCanceller::new(512, 0.5, 1e-6, 0.7, 1200); no_dtd.set_dtd_enabled(false); let cleaned_n = no_dtd.process(&far, &mic); let res_n: Vec = cleaned_n.iter().zip(&near).map(|(&c, &n)| c - n).collect(); let erle_no = erle(&echo[late.clone()], &res_n[late]); assert!( erle_dtd > erle_no + 15.0, "DTD should hold the echo path: with {erle_dtd:.1} dB vs without {erle_no:.1} dB" ); assert!( erle_dtd > 15.0, "held filter should still cancel echo: {erle_dtd:.1} dB" ); } }