Files
peerspeak/src/dsp/aec.rs
T
molluskandClaude Opus 4.8 d0a16cb8b9 style: apply cargo fmt across the crate (A20)
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>
2026-06-29 02:11:44 -04:00

408 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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.50.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.01.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"
);
}
}