feat(dsp): double-talk detection for the NLMS echo canceller (Stage C)

Add Geigel double-talk detection so the adaptive filter stops diverging when
the near-end talks over the far-end (the failure the Stage B demo exposed:
ERLE went negative as the filter mistook near-end voice for echo).

- src/dsp/aec.rs: DoubleTalkDetector — Geigel test (mic level vs a sliding-
  window peak of the far-end, via an O(1) monotonic-deque max) with a hangover
  latch. EchoCanceller — wraps Nlms + the detector, freezing adaptation while
  double-talk is declared; reports the double-talk rate; DTD can be toggled off
  for A/B. 5 aec tests (detector fires on near-end not echo; DTD protects a
  converged filter through double-talk, +15 dB over raw NLMS).
- specview aec: now uses EchoCanceller with tunable --dtd-threshold / --hangover
  / --near-onset / --no-dtd, and prints the double-talk %.

Tuning found with the harness (white far-end, -12 dB echo, near-end onset
mid-call): DTD threshold 0.5 is the sweet spot — late-call ERLE +29.6 dB with
DTD vs -4.0 dB without (a 33 dB swing); 0.35 over-triggers (never learns), 0.7
under-triggers (drifts). Default set to 0.5. Also documents the false-positive
/miss tradeoff and that correlated (pink/speech) far-ends converge slower.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 02:45:40 -04:00
co-authored by Claude Opus 4.8
parent eeb725f996
commit 9e5fd63291
2 changed files with 243 additions and 6 deletions
+29 -6
View File
@@ -55,8 +55,9 @@ const USAGE: &str = "specview — spectrogram + AEC measurement tool\n\n\
specview gen <kind> -o out.wav [--secs S] [--amp A] [--freq F] [--f0 F] [--f1 F] [--seed N]\n\
kinds: sine sweep white pink impulse silence\n\
specview erle <before.wav> <after.wav> [--show]\n\
specview aec --far far.wav [--near near.wav] [--delay N] [--tail N] [--atten A]\n\
[--taps N] [--mu M] [--out cleaned.wav] [--show]";
specview aec --far far.wav [--near near.wav] [--near-onset S] [--delay N] [--tail N]\n\
[--atten A] [--taps N] [--mu M] [--dtd-threshold T] [--hangover N]\n\
[--no-dtd] [--out cleaned.wav] [--show]";
/// Renders a WAV as a spectrogram plus a numeric summary.
fn cmd_show(args: &[String]) -> Result<(), String> {
@@ -183,12 +184,19 @@ fn cmd_aec(args: &[String]) -> Result<(), String> {
let far = wav::read(Path::new(far_path))?;
let sr = far.sample_rate;
// Optional near-end (the local voice the canceller must preserve).
// Optional near-end (the local voice the canceller must preserve). An onset
// can be set so the near-end starts partway through, exercising the classic
// "far-end converges first, then double-talk" case.
let onset = (flags.f32_or("near-onset", 0.0) * sr as f32) as usize;
let near = match flags.get("near") {
Some(p) => {
let d = wav::read(Path::new(p))?;
let mut v = d.samples;
v.resize(far.samples.len(), 0.0); // align length to far-end
let cut = onset.min(v.len());
for s in v.iter_mut().take(cut) {
*s = 0.0; // silence before onset
}
v
}
None => vec![0.0f32; far.samples.len()],
@@ -197,15 +205,22 @@ fn cmd_aec(args: &[String]) -> Result<(), String> {
// Build the echo and the mic signal (echo + near-end).
let delay = flags.usize_or("delay", 480); // ~10 ms at 48 kHz
let tail = flags.usize_or("tail", 960); // ~20 ms reverb tail
let atten = flags.f32_or("atten", 0.5); // ~ -6 dB echo
let atten = flags.f32_or("atten", 0.3); // ~ -10 dB echo (realistic acoustic coupling)
let path = echo_path::EchoPath::synthetic(delay, tail, atten, flags.u64_or("seed", 1));
let echo = path.apply(&far.samples);
let mic: Vec<f32> = echo.iter().zip(&near).map(|(&e, &n)| e + n).collect();
// Cancel. Default taps comfortably cover the synthetic path length.
// Cancel with the full echo canceller (NLMS + double-talk detection).
let taps = flags.usize_or("taps", (delay + tail).next_power_of_two());
let mu = flags.f32_or("mu", 0.5);
let mut canceller = aec::Nlms::new(taps, mu, 1e-6);
// 0.5 is a good operating point for ~-10 to -20 dB acoustic coupling: high
// enough that echo-only rarely trips it, low enough to catch near-end talk.
let dtd_threshold = flags.f32_or("dtd-threshold", 0.5);
let hangover = flags.usize_or("hangover", sr as usize / 50); // ~20 ms
let mut canceller = aec::EchoCanceller::new(taps, mu, 1e-6, dtd_threshold, hangover);
if flags.present("no-dtd") {
canceller.set_dtd_enabled(false);
}
let cleaned = canceller.process(&far.samples, &mic);
// Residual echo (oracle): whatever's left after removing the known near-end.
@@ -225,6 +240,14 @@ fn cmd_aec(args: &[String]) -> Result<(), String> {
metrics::dbfs(atten),
);
println!(" filter: {taps} taps, mu {mu}{}", if has_near { " (with near-end / double-talk)" } else { "" });
if has_near {
let dtd = if flags.present("no-dtd") { "off" } else { "on" };
println!(
" double-talk: detector {dtd}, threshold {dtd_threshold}, flagged {:.0}% of samples{}",
100.0 * canceller.double_talk_rate(),
if onset > 0 { format!(", near-end onset {:.1}s", onset as f32 / sr as f32) } else { String::new() },
);
}
println!(" mic before: {:.1} dBFS rms", metrics::dbfs(metrics::rms(&mic)));
println!(" residual echo after: {:.1} dBFS rms", metrics::dbfs(metrics::rms(&residual)));
println!(" ERLE broadband: {broadband:+.1} dB");
+214
View File
@@ -17,6 +17,8 @@
//! 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).
@@ -118,6 +120,146 @@ impl Nlms {
}
}
/// 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::*;
@@ -168,4 +310,76 @@ mod tests {
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");
}
}