diff --git a/src/bin/specview.rs b/src/bin/specview.rs index bad4007..a8cdb76 100644 --- a/src/bin/specview.rs +++ b/src/bin/specview.rs @@ -55,8 +55,9 @@ const USAGE: &str = "specview — spectrogram + AEC measurement tool\n\n\ specview gen -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 [--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 = 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"); diff --git a/src/dsp/aec.rs b/src/dsp/aec.rs index a54454a..18306f4 100644 --- a/src/dsp/aec.rs +++ b/src/dsp/aec.rs @@ -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.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::*; @@ -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 { + 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"); + } }