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>
423 lines
15 KiB
Rust
423 lines
15 KiB
Rust
//! `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 <file.wav> [--width N] [--height N] [--max-hz HZ] [--floor DB] [--ascii]
|
||
//! specview gen <kind> -o out.wav [--secs S] [--amp A] [--freq F] [--f0 F] [--f1 F] [--seed N]
|
||
//! kinds: sine | sweep | white | pink | impulse | silence
|
||
//! specview erle <before.wav> <after.wav> [--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<String> = 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 <file.wav> [--width N] [--height N] [--max-hz HZ] [--floor DB] [--ascii]\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] [--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 <file.wav>")?;
|
||
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 <kind>")?;
|
||
let out = flags
|
||
.get("o")
|
||
.or_else(|| flags.get("out"))
|
||
.ok_or("gen needs -o <out.wav>")?;
|
||
|
||
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 <before.wav> <after.wav>")?;
|
||
let after = positional
|
||
.get(1)
|
||
.ok_or("erle needs <before.wav> <after.wav>")?;
|
||
|
||
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 <far.wav>")?;
|
||
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<f32> = 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<f32> = 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::<f32>().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<f32> {
|
||
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<String, String>,
|
||
bools: Vec<String>,
|
||
}
|
||
|
||
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<String>, 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 })
|
||
}
|