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>
408 lines
16 KiB
Rust
408 lines
16 KiB
Rust
//! 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<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
|
||
}
|
||
}
|
||
|
||
/// 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<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}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 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<f32> {
|
||
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<f32> = 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<f32> = 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<f32> = 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<f32> = 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"
|
||
);
|
||
}
|
||
}
|