//! `specview` — a developer CLI for the `dsp` toolkit: conjure test signals, //! draw spectrograms in the terminal, and measure echo-cancellation quality //! (ERLE) on WAV files. Not shipped in the GUI; a measurement aid. //! //! Usage: //! specview show [--width N] [--height N] [--max-hz HZ] [--floor DB] [--ascii] //! specview gen -o out.wav [--secs S] [--amp A] [--freq F] [--f0 F] [--f1 F] [--seed N] //! kinds: sine | sweep | white | pink | impulse | silence //! specview erle [--show] //! //! Examples: //! specview gen sweep -o sweep.wav --secs 3 --f0 100 --f1 12000 //! specview show ~/peerspeak-recordings/2026-06-14/peerspeak-*.wav //! specview erle echo_only.wav after_aec.wav --show use std::collections::HashMap; use std::path::Path; use std::process::ExitCode; use peerspeak::dsp::{aec, echo_path, generators, metrics, render, stft, wav}; const SAMPLE_RATE: u32 = 48_000; fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); let Some(cmd) = args.first() else { eprintln!("{}", USAGE); return ExitCode::FAILURE; }; let rest = &args[1..]; let result = match cmd.as_str() { "show" => cmd_show(rest), "gen" => cmd_gen(rest), "erle" => cmd_erle(rest), "aec" => cmd_aec(rest), "-h" | "--help" | "help" => { println!("{}", USAGE); Ok(()) } other => Err(format!("unknown command {other:?}\n\n{USAGE}")), }; match result { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("error: {e}"); ExitCode::FAILURE } } } const USAGE: &str = "specview — spectrogram + AEC measurement tool\n\n\ specview show [--width N] [--height N] [--max-hz HZ] [--floor DB] [--ascii]\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] [--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> { let (positional, flags) = parse_args(args); let path = positional.first().ok_or("show needs a ")?; let data = wav::read(Path::new(path))?; let opts = render::RenderOpts { width: flags.usize_or("width", 100), height: flags.usize_or("height", 30), max_hz: flags.f32_or("max-hz", 12_000.0), floor_db: flags.f32_or("floor", -80.0), ascii: flags.present("ascii"), }; let spec = stft::analyze(&data.samples, data.sample_rate, 2048, 512); print!("{}", render::render(&spec, &opts)); print_summary(path, &data.samples, &spec); Ok(()) } /// Generates a test signal and writes it to a WAV file. fn cmd_gen(args: &[String]) -> Result<(), String> { let (positional, flags) = parse_args(args); let kind = positional.first().ok_or("gen needs a ")?; let out = flags .get("o") .or_else(|| flags.get("out")) .ok_or("gen needs -o ")?; let secs = flags.f32_or("secs", 2.0); let amp = flags.f32_or("amp", 0.8); let len = (secs * SAMPLE_RATE as f32) as usize; let seed = flags.u64_or("seed", 1); let samples = match kind.as_str() { "sine" => generators::sine(flags.f32_or("freq", 1000.0), amp, len, SAMPLE_RATE), "sweep" => generators::log_sweep( flags.f32_or("f0", 100.0), flags.f32_or("f1", 12_000.0), amp, len, SAMPLE_RATE, ), "white" => generators::white_noise(amp, len, seed), "pink" => generators::pink_noise(amp, len, seed), "impulse" => generators::impulse(amp, len), "silence" => generators::silence(len), other => { return Err(format!( "unknown kind {other:?} (sine sweep white pink impulse silence)" )); } }; wav::write(Path::new(out), &samples, SAMPLE_RATE)?; println!( "wrote {} — {} ({:.2}s, {} samples, rms {:.1} dBFS)", out, kind, secs, samples.len(), metrics::dbfs(metrics::rms(&samples)), ); Ok(()) } /// Measures ERLE between a "before" (echo present) and "after" (post-AEC) file — /// broadband and per voice band — so you can see how much echo was removed and /// in which frequency range any residual lives. fn cmd_erle(args: &[String]) -> Result<(), String> { let (positional, flags) = parse_args(args); let before = positional .first() .ok_or("erle needs ")?; let after = positional .get(1) .ok_or("erle needs ")?; let b = wav::read(Path::new(before))?; let a = wav::read(Path::new(after))?; // Compare over the overlapping length. let n = b.samples.len().min(a.samples.len()); let bs = &b.samples[..n]; let as_ = &a.samples[..n]; let broadband = metrics::erle(bs, as_); println!("ERLE (broadband): {broadband:+.1} dB (higher = more echo removed)"); println!( " before: {:.1} dBFS rms after: {:.1} dBFS rms", metrics::dbfs(metrics::rms(bs)), metrics::dbfs(metrics::rms(as_)), ); // Per-band ERLE via averaged STFT magnitude energy. let sb = stft::analyze(bs, b.sample_rate, 2048, 512); let sa = stft::analyze(as_, a.sample_rate, 2048, 512); let avg_b = average_frame(&sb); let avg_a = average_frame(&sa); println!(" per band:"); for band in metrics::VOICE_BANDS { let eb = metrics::band_energy(&avg_b, |bin| sb.bin_hz(bin), band.low_hz, band.high_hz); let ea = metrics::band_energy(&avg_a, |bin| sa.bin_hz(bin), band.low_hz, band.high_hz); let db = if eb > 1e-12 && ea > 1e-12 { 10.0 * (eb / ea).log10() } else if eb > 1e-12 { 120.0 } else { 0.0 }; println!(" {:<18} {:+6.1} dB", band.label, db); } if flags.present("show") { println!("\n--- before ---"); print!("{}", render::render(&sb, &render::RenderOpts::default())); println!("\n--- after ---"); print!("{}", render::render(&sa, &render::RenderOpts::default())); } Ok(()) } /// The full headless AEC loop: take a far-end signal, run it through a synthetic /// echo path to make the echo a mic would hear, optionally add near-end speech, /// then cancel with the NLMS filter and measure how much echo was removed. /// /// Because the simulator hands us the true echo and near-end separately, ERLE is /// measured against ground truth (residual echo = cleaned − near) — exact even /// when near-end talk is present. fn cmd_aec(args: &[String]) -> Result<(), String> { let (_positional, flags) = parse_args(args); let far_path = flags.get("far").ok_or("aec needs --far ")?; let far = wav::read(Path::new(far_path))?; let sr = far.sample_rate; // 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()], }; // 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.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 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); // 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. let residual: Vec = cleaned.iter().zip(&near).map(|(&c, &n)| c - n).collect(); let broadband = metrics::erle(&echo, &residual); let q = echo.len() / 4; let early = metrics::erle(&echo[..q], &residual[..q]); let late = metrics::erle(&echo[3 * q..], &residual[3 * q..]); let has_near = flags.present("near"); println!("AEC simulation"); println!( " echo path: delay {delay} ({:.0} ms), tail {tail} ({:.0} ms), atten {atten} ({:.1} dB)", 1000.0 * delay as f32 / sr as f32, 1000.0 * tail as f32 / sr as f32, 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"); println!(" ERLE early/late: {early:+.1} -> {late:+.1} dB (rise = filter converging)"); // Per-band residual echo, to see where any leak lives. let se = stft::analyze(&echo, sr, 2048, 512); let sr_res = stft::analyze(&residual, sr, 2048, 512); let avg_e = average_frame(&se); let avg_r = average_frame(&sr_res); println!(" ERLE by band:"); for band in metrics::VOICE_BANDS { let ee = metrics::band_energy(&avg_e, |b| se.bin_hz(b), band.low_hz, band.high_hz); let er = metrics::band_energy(&avg_r, |b| sr_res.bin_hz(b), band.low_hz, band.high_hz); let db = if ee > 1e-12 && er > 1e-12 { 10.0 * (ee / er).log10() } else if ee > 1e-12 { 120.0 } else { 0.0 }; println!(" {:<18} {:+6.1} dB", band.label, db); } if let Some(out) = flags.get("out") { wav::write(Path::new(out), &cleaned, sr)?; println!(" wrote cleaned output -> {out}"); } if flags.present("show") { println!("\n--- mic (echo present) ---"); print!( "{}", render::render( &stft::analyze(&mic, sr, 2048, 512), &render::RenderOpts::default() ) ); println!("\n--- cleaned (post-AEC) ---"); print!( "{}", render::render( &stft::analyze(&cleaned, sr, 2048, 512), &render::RenderOpts::default() ) ); } Ok(()) } /// Prints RMS/peak/dBFS plus the per-band energy distribution of a signal. fn print_summary(path: &str, samples: &[f32], spec: &stft::Spectrogram) { println!( "\n{}\n duration {:.2}s rms {:.1} dBFS peak {:.1} dBFS", path, samples.len() as f32 / spec.sample_rate as f32, metrics::dbfs(metrics::rms(samples)), metrics::dbfs(metrics::peak(samples)), ); let avg = average_frame(spec); let total: f32 = avg.iter().map(|m| m * m).sum::().max(1e-12); println!(" energy by band:"); for band in metrics::VOICE_BANDS { let e = metrics::band_energy(&avg, |bin| spec.bin_hz(bin), band.low_hz, band.high_hz); let pct = 100.0 * e / total; let bar = "█".repeat((pct / 2.5).round() as usize); println!(" {:<18} {:5.1}% {}", band.label, pct, bar); } } /// Mean magnitude spectrum across all STFT frames (the time-averaged spectrum). fn average_frame(spec: &stft::Spectrogram) -> Vec { let bins = spec.bins(); let mut avg = vec![0.0f32; bins]; if spec.frames.is_empty() { return avg; } for frame in &spec.frames { for (bin, a) in avg.iter_mut().enumerate() { *a += frame[bin]; } } let n = spec.frames.len() as f32; for a in &mut avg { *a /= n; } avg } /// A trivial `--flag value` / `--flag` (boolean) parser. Positional args are /// everything not consumed as a flag value. Keeps the binary dependency-free. struct Flags { map: HashMap, bools: Vec, } impl Flags { fn get(&self, key: &str) -> Option<&String> { self.map.get(key) } fn present(&self, key: &str) -> bool { self.bools.iter().any(|b| b == key) || self.map.contains_key(key) } fn f32_or(&self, key: &str, default: f32) -> f32 { self.map .get(key) .and_then(|v| v.parse().ok()) .unwrap_or(default) } fn usize_or(&self, key: &str, default: usize) -> usize { self.map .get(key) .and_then(|v| v.parse().ok()) .unwrap_or(default) } fn u64_or(&self, key: &str, default: u64) -> u64 { self.map .get(key) .and_then(|v| v.parse().ok()) .unwrap_or(default) } } /// Splits args into positionals and flags. A token like `--width`/`-o` followed /// by a non-flag token captures that token as its value; otherwise it's a bool. fn parse_args(args: &[String]) -> (Vec, Flags) { let mut positional = Vec::new(); let mut map = HashMap::new(); let mut bools = Vec::new(); let mut i = 0; while i < args.len() { let tok = &args[i]; if let Some(key) = tok.strip_prefix("--").or_else(|| tok.strip_prefix('-')) { let next_is_value = args.get(i + 1).is_some_and(|n| !n.starts_with('-')); if next_is_value { map.insert(key.to_string(), args[i + 1].clone()); i += 2; } else { bools.push(key.to_string()); i += 1; } } else { positional.push(tok.clone()); i += 1; } } (positional, Flags { map, bools }) }