Files
peerspeak/src/bin/specview.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

423 lines
15 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.
//! `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 })
}