feat(dsp): spectrogram + AEC measurement toolkit (specview)
Add a dependency-free signal-analysis toolkit and a `specview` dev CLI to evaluate audio (especially echo cancellation) with objective numbers and a terminal spectrogram instead of ear alone. - src/dsp/: hand-written radix-2 FFT, Hann window, STFT, seeded test-signal generators (sine/log-sweep/white/pink/impulse), metrics (RMS/dBFS/peak/ERLE/ per-band energy), minimal WAV read+write, and a 24-bit-ANSI half-block spectrogram renderer (magma colormap, freq/time axes, dB legend, ASCII fallback). Pure layers have no I/O; only `wav` touches the filesystem. - src/bin/specview.rs: `gen` (conjure a test signal -> WAV), `show` (spectrogram + per-band energy summary), `erle` (broadband + per-band echo-return-loss between a before/after pair). - 27 unit tests (FFT correctness, ERLE landmarks, WAV round-trip, render shape). Verified end-to-end: log sweep renders as the expected exponential curve; a 20 dB-quieter copy reads +20.0 dB ERLE broadband and per band. Measurement substrate for upcoming AEC refinement (no shipped-path changes). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,10 @@ path = "src/main.rs"
|
|||||||
name = "test_net"
|
name = "test_net"
|
||||||
path = "src/bin/test_net.rs"
|
path = "src/bin/test_net.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "specview"
|
||||||
|
path = "src/bin/specview.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.102"
|
anyhow = "1.0.102"
|
||||||
async-trait = "0.1.89"
|
async-trait = "0.1.89"
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
//! `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::{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),
|
||||||
|
"-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]";
|
||||||
|
|
||||||
|
/// 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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 })
|
||||||
|
}
|
||||||
+186
@@ -0,0 +1,186 @@
|
|||||||
|
//! A small, dependency-free radix-2 Cooley-Tukey FFT.
|
||||||
|
//!
|
||||||
|
//! We hand-roll this rather than pull in `rustfft`/`realfft` because (a) the
|
||||||
|
//! whole DSP toolkit is a developer/measurement aid, not a hot real-time path,
|
||||||
|
//! and (b) it keeps the supply-chain surface at zero new crates. The transform
|
||||||
|
//! is the textbook iterative in-place algorithm (bit-reversal permutation +
|
||||||
|
//! log2(N) butterfly stages), computed in `f64` for headroom even though the
|
||||||
|
//! audio it analyses is `f32`.
|
||||||
|
//!
|
||||||
|
//! Only power-of-two lengths are supported; the STFT layer always pads frames
|
||||||
|
//! up to a power of two before calling in.
|
||||||
|
|
||||||
|
use std::f64::consts::PI;
|
||||||
|
|
||||||
|
/// A minimal complex number for the transform. Kept local (rather than pulling a
|
||||||
|
/// `num-complex` dependency) since the FFT is the only thing that needs it.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub struct Complex {
|
||||||
|
pub re: f64,
|
||||||
|
pub im: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Complex {
|
||||||
|
pub const ZERO: Complex = Complex { re: 0.0, im: 0.0 };
|
||||||
|
|
||||||
|
pub fn new(re: f64, im: f64) -> Self {
|
||||||
|
Complex { re, im }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Magnitude `sqrt(re^2 + im^2)`.
|
||||||
|
pub fn magnitude(self) -> f64 {
|
||||||
|
self.re.hypot(self.im)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add(self, o: Complex) -> Complex {
|
||||||
|
Complex::new(self.re + o.re, self.im + o.im)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sub(self, o: Complex) -> Complex {
|
||||||
|
Complex::new(self.re - o.re, self.im - o.im)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mul(self, o: Complex) -> Complex {
|
||||||
|
Complex::new(
|
||||||
|
self.re * o.re - self.im * o.im,
|
||||||
|
self.re * o.im + self.im * o.re,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-place forward FFT. `buf.len()` must be a power of two.
|
||||||
|
///
|
||||||
|
/// Uses the standard sign convention `X[k] = sum_n x[n] * exp(-2πi·kn/N)`.
|
||||||
|
pub fn fft(buf: &mut [Complex]) {
|
||||||
|
transform(buf, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-place inverse FFT (normalized by `1/N`), the exact inverse of [`fft`].
|
||||||
|
pub fn ifft(buf: &mut [Complex]) {
|
||||||
|
transform(buf, true);
|
||||||
|
let n = buf.len() as f64;
|
||||||
|
for c in buf.iter_mut() {
|
||||||
|
c.re /= n;
|
||||||
|
c.im /= n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transform(buf: &mut [Complex], inverse: bool) {
|
||||||
|
let n = buf.len();
|
||||||
|
assert!(n.is_power_of_two(), "FFT length {n} must be a power of two");
|
||||||
|
if n <= 1 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bit-reversal permutation: reorder so the iterative butterflies can run
|
||||||
|
// bottom-up in place.
|
||||||
|
let mut j = 0usize;
|
||||||
|
for i in 1..n {
|
||||||
|
let mut bit = n >> 1;
|
||||||
|
while j & bit != 0 {
|
||||||
|
j ^= bit;
|
||||||
|
bit >>= 1;
|
||||||
|
}
|
||||||
|
j ^= bit;
|
||||||
|
if i < j {
|
||||||
|
buf.swap(i, j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Butterfly stages: combine length-`len` DFTs from length-`len/2` halves,
|
||||||
|
// doubling `len` each pass.
|
||||||
|
let sign = if inverse { 1.0 } else { -1.0 };
|
||||||
|
let mut len = 2;
|
||||||
|
while len <= n {
|
||||||
|
let ang = sign * 2.0 * PI / len as f64;
|
||||||
|
let wlen = Complex::new(ang.cos(), ang.sin());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < n {
|
||||||
|
let mut w = Complex::new(1.0, 0.0);
|
||||||
|
for k in 0..len / 2 {
|
||||||
|
let u = buf[i + k];
|
||||||
|
let v = buf[i + k + len / 2].mul(w);
|
||||||
|
buf[i + k] = u.add(v);
|
||||||
|
buf[i + k + len / 2] = u.sub(v);
|
||||||
|
w = w.mul(wlen);
|
||||||
|
}
|
||||||
|
i += len;
|
||||||
|
}
|
||||||
|
len <<= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forward FFT of a real signal, returning the **one-sided** magnitude spectrum:
|
||||||
|
/// bins `0..=N/2` (DC through Nyquist), where `N` is the next power of two ≥
|
||||||
|
/// `samples.len()`. The input is zero-padded up to `N`.
|
||||||
|
///
|
||||||
|
/// Magnitudes are raw (un-normalized) linear amplitudes; callers convert to dB
|
||||||
|
/// or normalize as needed.
|
||||||
|
pub fn real_magnitude_spectrum(samples: &[f32]) -> Vec<f32> {
|
||||||
|
let n = samples.len().next_power_of_two().max(2);
|
||||||
|
let mut buf = vec![Complex::ZERO; n];
|
||||||
|
for (i, &s) in samples.iter().enumerate() {
|
||||||
|
buf[i].re = s as f64;
|
||||||
|
}
|
||||||
|
fft(&mut buf);
|
||||||
|
buf[..=n / 2].iter().map(|c| c.magnitude() as f32).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn approx(a: f64, b: f64, eps: f64) -> bool {
|
||||||
|
(a - b).abs() <= eps
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn impulse_transforms_to_flat_spectrum() {
|
||||||
|
// FFT of a unit impulse at n=0 is all-ones (flat spectrum).
|
||||||
|
let mut buf = vec![Complex::ZERO; 8];
|
||||||
|
buf[0] = Complex::new(1.0, 0.0);
|
||||||
|
fft(&mut buf);
|
||||||
|
for c in &buf {
|
||||||
|
assert!(approx(c.magnitude(), 1.0, 1e-9), "expected flat 1.0, got {c:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_bin_sine_peaks_in_that_bin() {
|
||||||
|
// A cosine at exactly bin k=2 over N=16 should put all energy in bin 2
|
||||||
|
// (and its mirror N-2). Check the one-sided spectrum peaks at bin 2.
|
||||||
|
let n = 16;
|
||||||
|
let k = 2;
|
||||||
|
let samples: Vec<f32> = (0..n)
|
||||||
|
.map(|i| (2.0 * PI * k as f64 * i as f64 / n as f64).cos() as f32)
|
||||||
|
.collect();
|
||||||
|
let mag = real_magnitude_spectrum(&samples);
|
||||||
|
let peak_bin = mag
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.0;
|
||||||
|
assert_eq!(peak_bin, k, "energy should land in bin {k}, got {peak_bin}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ifft_inverts_fft() {
|
||||||
|
let original: Vec<Complex> = (0..32)
|
||||||
|
.map(|i| Complex::new((i as f64 * 0.3).sin(), (i as f64 * 0.1).cos()))
|
||||||
|
.collect();
|
||||||
|
let mut buf = original.clone();
|
||||||
|
fft(&mut buf);
|
||||||
|
ifft(&mut buf);
|
||||||
|
for (a, b) in original.iter().zip(&buf) {
|
||||||
|
assert!(approx(a.re, b.re, 1e-9) && approx(a.im, b.im, 1e-9));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "power of two")]
|
||||||
|
fn non_power_of_two_panics() {
|
||||||
|
let mut buf = vec![Complex::ZERO; 6];
|
||||||
|
fft(&mut buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
//! Reproducible test-signal generators — the "conjured noises" the measurement
|
||||||
|
//! harness drives AEC/analysis with. Everything is deterministic: the noise
|
||||||
|
//! sources take an explicit seed so a run is byte-for-byte repeatable, which is
|
||||||
|
//! what makes A/B comparisons (old build vs new, tuning X vs Y) meaningful.
|
||||||
|
//!
|
||||||
|
//! All generators emit `f32` samples nominally in `[-amp, amp]` at the given
|
||||||
|
//! sample rate. Convert to `i16` at the I/O edge.
|
||||||
|
|
||||||
|
use std::f64::consts::PI;
|
||||||
|
|
||||||
|
/// A tiny deterministic PRNG (xorshift64*) so the noise generators need no
|
||||||
|
/// external crate and produce identical output for a given seed across runs.
|
||||||
|
struct XorShift64 {
|
||||||
|
state: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl XorShift64 {
|
||||||
|
fn new(seed: u64) -> Self {
|
||||||
|
// Avoid the all-zero fixed point.
|
||||||
|
XorShift64 {
|
||||||
|
state: seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_u64(&mut self) -> u64 {
|
||||||
|
let mut x = self.state;
|
||||||
|
x ^= x >> 12;
|
||||||
|
x ^= x << 25;
|
||||||
|
x ^= x >> 27;
|
||||||
|
self.state = x;
|
||||||
|
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uniform `f32` in `[-1.0, 1.0)`.
|
||||||
|
fn next_bipolar(&mut self) -> f32 {
|
||||||
|
// Top 24 bits -> [0,1), then map to [-1,1).
|
||||||
|
let u = (self.next_u64() >> 40) as f32 / (1u32 << 24) as f32;
|
||||||
|
u * 2.0 - 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `len` samples of silence.
|
||||||
|
pub fn silence(len: usize) -> Vec<f32> {
|
||||||
|
vec![0.0; len]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pure sine tone at `freq` Hz.
|
||||||
|
pub fn sine(freq: f32, amp: f32, len: usize, sample_rate: u32) -> Vec<f32> {
|
||||||
|
(0..len)
|
||||||
|
.map(|i| amp * (2.0 * PI * freq as f64 * i as f64 / sample_rate as f64).sin() as f32)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A unit impulse: `amp` at sample 0, silence after. Its flat spectrum makes it
|
||||||
|
/// the natural probe for an echo path's impulse response.
|
||||||
|
pub fn impulse(amp: f32, len: usize) -> Vec<f32> {
|
||||||
|
let mut v = vec![0.0; len];
|
||||||
|
if len > 0 {
|
||||||
|
v[0] = amp;
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A logarithmic (exponential) sine sweep from `f0` to `f1` Hz over the buffer —
|
||||||
|
/// the standard excitation for measuring a system's frequency response, since it
|
||||||
|
/// spends equal time per octave.
|
||||||
|
pub fn log_sweep(f0: f32, f1: f32, amp: f32, len: usize, sample_rate: u32) -> Vec<f32> {
|
||||||
|
if len == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let t_total = len as f64 / sample_rate as f64;
|
||||||
|
let (f0, f1) = (f0 as f64, f1 as f64);
|
||||||
|
let k = (f1 / f0).ln();
|
||||||
|
(0..len)
|
||||||
|
.map(|i| {
|
||||||
|
let t = i as f64 / sample_rate as f64;
|
||||||
|
// Instantaneous-phase integral of an exponential chirp.
|
||||||
|
let phase = 2.0 * PI * f0 * t_total / k * ((k * t / t_total).exp() - 1.0);
|
||||||
|
amp * phase.sin() as f32
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uniform white noise in `[-amp, amp]`, seeded for reproducibility.
|
||||||
|
pub fn white_noise(amp: f32, len: usize, seed: u64) -> Vec<f32> {
|
||||||
|
let mut rng = XorShift64::new(seed);
|
||||||
|
(0..len).map(|_| amp * rng.next_bipolar()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pink (1/f) noise via the Voss-McCartney algorithm — perceptually flatter than
|
||||||
|
/// white noise and a closer stand-in for room/voice spectra. Seeded.
|
||||||
|
pub fn pink_noise(amp: f32, len: usize, seed: u64) -> Vec<f32> {
|
||||||
|
let mut rng = XorShift64::new(seed);
|
||||||
|
const ROWS: usize = 16;
|
||||||
|
let mut rows = [0.0f32; ROWS];
|
||||||
|
let mut running = 0.0f32;
|
||||||
|
let mut out = Vec::with_capacity(len);
|
||||||
|
for i in 0..len {
|
||||||
|
// Each row updates half as often as the previous: the row to refresh is
|
||||||
|
// the index of the lowest set bit of the sample counter.
|
||||||
|
let n = i + 1;
|
||||||
|
let row = (n & n.wrapping_neg()).trailing_zeros() as usize;
|
||||||
|
if row < ROWS {
|
||||||
|
running -= rows[row];
|
||||||
|
rows[row] = rng.next_bipolar();
|
||||||
|
running += rows[row];
|
||||||
|
}
|
||||||
|
// Normalize: sum of ROWS unit-bipolar rows spans roughly [-ROWS, ROWS].
|
||||||
|
out.push(amp * running / ROWS as f32);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::dsp::metrics::rms;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sine_has_expected_length_and_range() {
|
||||||
|
let s = sine(1000.0, 0.5, 480, 48_000);
|
||||||
|
assert_eq!(s.len(), 480);
|
||||||
|
assert!(s.iter().all(|&x| x.abs() <= 0.5001));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn impulse_is_single_spike() {
|
||||||
|
let s = impulse(1.0, 10);
|
||||||
|
assert_eq!(s[0], 1.0);
|
||||||
|
assert!(s[1..].iter().all(|&x| x == 0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn white_noise_is_deterministic_for_a_seed() {
|
||||||
|
let a = white_noise(1.0, 256, 42);
|
||||||
|
let b = white_noise(1.0, 256, 42);
|
||||||
|
let c = white_noise(1.0, 256, 43);
|
||||||
|
assert_eq!(a, b, "same seed -> identical output");
|
||||||
|
assert_ne!(a, c, "different seed -> different output");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn noise_energy_is_nonzero_and_bounded() {
|
||||||
|
let w = white_noise(0.5, 4096, 7);
|
||||||
|
let r = rms(&w);
|
||||||
|
assert!(r > 0.0 && r < 0.5, "rms {r} should be in (0, amp)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn log_sweep_spans_the_buffer() {
|
||||||
|
let s = log_sweep(100.0, 8000.0, 1.0, 4800, 48_000);
|
||||||
|
assert_eq!(s.len(), 4800);
|
||||||
|
// Non-trivial energy present.
|
||||||
|
assert!(rms(&s) > 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
//! Objective signal metrics — the numbers that turn "sounds better" into a
|
||||||
|
//! measurement. The headline one for echo cancellation is **ERLE** (Echo Return
|
||||||
|
//! Loss Enhancement): how much echo energy the canceller removed, in dB.
|
||||||
|
|
||||||
|
/// Root-mean-square level of a signal (linear amplitude). `0.0` for empty input.
|
||||||
|
pub fn rms(samples: &[f32]) -> f32 {
|
||||||
|
if samples.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let sum_sq: f64 = samples.iter().map(|&s| s as f64 * s as f64).sum();
|
||||||
|
(sum_sq / samples.len() as f64).sqrt() as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Peak absolute amplitude. `0.0` for empty input.
|
||||||
|
pub fn peak(samples: &[f32]) -> f32 {
|
||||||
|
samples.iter().fold(0.0, |m, &s| m.max(s.abs()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a linear amplitude (e.g. an RMS or peak value, relative to full-scale
|
||||||
|
/// `1.0`) to decibels below full scale. A floor of -120 dBFS is returned for
|
||||||
|
/// silence so the result is always finite.
|
||||||
|
pub fn dbfs(linear: f32) -> f32 {
|
||||||
|
if linear <= 1e-6 {
|
||||||
|
return -120.0;
|
||||||
|
}
|
||||||
|
20.0 * linear.log10()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Echo Return Loss Enhancement**, in dB: `10·log10(E[echo²] / E[residual²])`.
|
||||||
|
///
|
||||||
|
/// `echo` is the signal *before* cancellation (the echo the mic picked up);
|
||||||
|
/// `residual` is what's *left after* the canceller ran. A larger number is
|
||||||
|
/// better — e.g. +30 dB means the canceller removed 99.9% of the echo energy.
|
||||||
|
/// Returns a +120 dB ceiling if the residual is effectively silent (perfect
|
||||||
|
/// cancellation) and 0.0 if there was no echo energy to begin with.
|
||||||
|
pub fn erle(echo: &[f32], residual: &[f32]) -> f32 {
|
||||||
|
let e_echo = mean_square(echo);
|
||||||
|
let e_res = mean_square(residual);
|
||||||
|
if e_echo <= 1e-12 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
if e_res <= 1e-12 {
|
||||||
|
return 120.0;
|
||||||
|
}
|
||||||
|
10.0 * (e_echo / e_res).log10() as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mean square (average energy per sample) of a signal. `0.0` for empty input.
|
||||||
|
fn mean_square(samples: &[f32]) -> f64 {
|
||||||
|
if samples.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
samples.iter().map(|&s| s as f64 * s as f64).sum::<f64>() / samples.len() as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A frequency band for per-band energy analysis, in Hz.
|
||||||
|
pub struct Band {
|
||||||
|
pub label: &'static str,
|
||||||
|
pub low_hz: f32,
|
||||||
|
pub high_hz: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Voice-relevant bands for spotting *where* residual echo or noise lives.
|
||||||
|
pub const VOICE_BANDS: &[Band] = &[
|
||||||
|
Band { label: "low (80-300)", low_hz: 80.0, high_hz: 300.0 },
|
||||||
|
Band { label: "low-mid (300-1k)", low_hz: 300.0, high_hz: 1000.0 },
|
||||||
|
Band { label: "mid (1k-3k)", low_hz: 1000.0, high_hz: 3000.0 },
|
||||||
|
Band { label: "high-mid (3k-6k)", low_hz: 3000.0, high_hz: 6000.0 },
|
||||||
|
Band { label: "high (6k-12k)", low_hz: 6000.0, high_hz: 12000.0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Sums the linear magnitude energy within `[low_hz, high_hz)` across a single
|
||||||
|
/// STFT magnitude frame. `bin_hz` maps a bin index to its centre frequency.
|
||||||
|
pub fn band_energy(frame: &[f32], bin_hz: impl Fn(usize) -> f32, low_hz: f32, high_hz: f32) -> f32 {
|
||||||
|
frame
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|&(bin, _)| {
|
||||||
|
let hz = bin_hz(bin);
|
||||||
|
hz >= low_hz && hz < high_hz
|
||||||
|
})
|
||||||
|
.map(|(_, &m)| m * m)
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rms_of_constant_is_that_constant() {
|
||||||
|
assert!((rms(&[0.5; 100]) - 0.5).abs() < 1e-6);
|
||||||
|
assert_eq!(rms(&[]), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn peak_finds_max_magnitude() {
|
||||||
|
assert_eq!(peak(&[0.1, -0.9, 0.3]), 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dbfs_landmarks() {
|
||||||
|
assert!((dbfs(1.0) - 0.0).abs() < 1e-4, "full scale = 0 dBFS");
|
||||||
|
assert!((dbfs(0.5) - -6.0206).abs() < 1e-3, "half = ~-6 dB");
|
||||||
|
assert_eq!(dbfs(0.0), -120.0, "silence floors");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn erle_halving_energy_is_about_3db() {
|
||||||
|
// residual amplitude = echo/sqrt(2) -> half the energy -> ~3.01 dB.
|
||||||
|
let echo = vec![1.0f32; 1000];
|
||||||
|
let residual = vec![std::f32::consts::FRAC_1_SQRT_2; 1000];
|
||||||
|
let e = erle(&echo, &residual);
|
||||||
|
assert!((e - 3.0103).abs() < 0.01, "expected ~3 dB, got {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn erle_perfect_cancellation_ceils() {
|
||||||
|
assert_eq!(erle(&[1.0; 10], &[0.0; 10]), 120.0);
|
||||||
|
assert_eq!(erle(&[0.0; 10], &[0.0; 10]), 0.0, "no echo -> 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn band_energy_selects_the_right_bins() {
|
||||||
|
// 5-bin frame, 100 Hz per bin: bins at 0,100,200,300,400 Hz.
|
||||||
|
let frame = [1.0, 2.0, 3.0, 4.0, 5.0];
|
||||||
|
let bin_hz = |b: usize| b as f32 * 100.0;
|
||||||
|
// [150,350) -> bins 200,300 Hz -> 3^2 + 4^2 = 25.
|
||||||
|
let e = band_energy(&frame, bin_hz, 150.0, 350.0);
|
||||||
|
assert!((e - 25.0).abs() < 1e-4, "got {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//! Signal-analysis toolkit: FFT, STFT spectrograms, reproducible test-signal
|
||||||
|
//! generators, objective metrics (RMS/dBFS/ERLE/per-band energy), a minimal WAV
|
||||||
|
//! reader/writer, and a 24-bit-colour terminal spectrogram renderer.
|
||||||
|
//!
|
||||||
|
//! This is a **measurement/developer aid**, not part of the real-time audio
|
||||||
|
//! path. It exists so audio work — especially echo-cancellation tuning — can be
|
||||||
|
//! evaluated with reproducible numbers and a visible spectrogram instead of
|
||||||
|
//! ear alone. The pure layers (`fft`, `window`, `stft`, `generators`, `metrics`,
|
||||||
|
//! `render`) have no I/O; `wav` is the only edge that touches the filesystem.
|
||||||
|
//! Driven from the `specview` binary (`src/bin/specview.rs`).
|
||||||
|
|
||||||
|
pub mod fft;
|
||||||
|
pub mod generators;
|
||||||
|
pub mod metrics;
|
||||||
|
pub mod render;
|
||||||
|
pub mod stft;
|
||||||
|
pub mod wav;
|
||||||
|
pub mod window;
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
//! Renders a [`Spectrogram`] to a string for the terminal.
|
||||||
|
//!
|
||||||
|
//! The trick that gives real resolution in a text grid: each character cell is
|
||||||
|
//! the Unicode upper-half block `▀` with a 24-bit-colour **foreground** (top
|
||||||
|
//! pixel) and **background** (bottom pixel), so one row of text shows two
|
||||||
|
//! frequency bins. Magnitudes are mapped to dB against the loudest cell and
|
||||||
|
//! coloured with a magma-style ramp (dark = quiet, bright = loud). An ASCII
|
||||||
|
//! fallback ramp is available for non-truecolor / piped output.
|
||||||
|
|
||||||
|
use super::stft::Spectrogram;
|
||||||
|
|
||||||
|
/// Rendering options for [`render`].
|
||||||
|
pub struct RenderOpts {
|
||||||
|
/// Output width in character columns (time axis).
|
||||||
|
pub width: usize,
|
||||||
|
/// Output height in character rows; vertical resolution is `2×height` bins.
|
||||||
|
pub height: usize,
|
||||||
|
/// Dynamic-range floor in dB below the loudest cell (e.g. `-80.0`).
|
||||||
|
pub floor_db: f32,
|
||||||
|
/// Top of the frequency axis in Hz (clamped to Nyquist).
|
||||||
|
pub max_hz: f32,
|
||||||
|
/// Use a plain-ASCII brightness ramp instead of 24-bit colour blocks.
|
||||||
|
pub ascii: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RenderOpts {
|
||||||
|
fn default() -> Self {
|
||||||
|
RenderOpts {
|
||||||
|
width: 100,
|
||||||
|
height: 30,
|
||||||
|
floor_db: -80.0,
|
||||||
|
max_hz: 12_000.0,
|
||||||
|
ascii: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESET: &str = "\x1b[0m";
|
||||||
|
/// Brightness ramp (dark→bright) for the ASCII fallback.
|
||||||
|
const ASCII_RAMP: &[u8] = b" .:-=+*#%@";
|
||||||
|
|
||||||
|
/// Renders `spec` to a multi-line string: a header, the frequency-labelled
|
||||||
|
/// spectrogram body, a time axis, and a dB colour legend.
|
||||||
|
pub fn render(spec: &Spectrogram, opts: &RenderOpts) -> String {
|
||||||
|
if spec.frames.is_empty() {
|
||||||
|
return "(empty signal — nothing to plot)\n".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let nyquist = spec.sample_rate as f32 / 2.0;
|
||||||
|
let max_hz = opts.max_hz.min(nyquist).max(spec.bin_hz(1));
|
||||||
|
// Number of bins from DC up to max_hz (at least 2 so we can aggregate).
|
||||||
|
let bins_used = (0..spec.bins())
|
||||||
|
.take_while(|&b| spec.bin_hz(b) <= max_hz)
|
||||||
|
.count()
|
||||||
|
.max(2);
|
||||||
|
|
||||||
|
let n_frames = spec.frames.len();
|
||||||
|
let width = opts.width.clamp(1, n_frames);
|
||||||
|
let rows = opts.height.max(1) * 2; // 2 frequency pixels per character row
|
||||||
|
|
||||||
|
// Stage 1: aggregate frames into `width` time columns (mean magnitude/bin).
|
||||||
|
let mut columns: Vec<Vec<f32>> = Vec::with_capacity(width);
|
||||||
|
for c in 0..width {
|
||||||
|
let f_lo = c * n_frames / width;
|
||||||
|
let f_hi = ((c + 1) * n_frames / width).max(f_lo + 1).min(n_frames);
|
||||||
|
let span = (f_hi - f_lo) as f32;
|
||||||
|
let mut acc = vec![0.0f32; bins_used];
|
||||||
|
for f in f_lo..f_hi {
|
||||||
|
let frame = &spec.frames[f];
|
||||||
|
for (bin, a) in acc.iter_mut().enumerate() {
|
||||||
|
*a += frame[bin];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for a in &mut acc {
|
||||||
|
*a /= span;
|
||||||
|
}
|
||||||
|
columns.push(acc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 2: aggregate each column's bins into `rows` vertical pixels.
|
||||||
|
// grid[col][pixel], pixel 0 = lowest freq.
|
||||||
|
let mut grid: Vec<Vec<f32>> = Vec::with_capacity(width);
|
||||||
|
let mut max_mag = 1e-9f32;
|
||||||
|
for col in &columns {
|
||||||
|
let mut pixels = vec![0.0f32; rows];
|
||||||
|
for (p, px) in pixels.iter_mut().enumerate() {
|
||||||
|
let b_lo = p * bins_used / rows;
|
||||||
|
let b_hi = ((p + 1) * bins_used / rows).max(b_lo + 1).min(bins_used);
|
||||||
|
let mean = col[b_lo..b_hi].iter().copied().sum::<f32>() / (b_hi - b_lo) as f32;
|
||||||
|
*px = mean;
|
||||||
|
max_mag = max_mag.max(mean);
|
||||||
|
}
|
||||||
|
grid.push(pixels);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 3: draw. Char row r (0 = top) shows pixels (top=high freq, bottom).
|
||||||
|
let mut out = String::new();
|
||||||
|
out.push_str(&format!(
|
||||||
|
"Spectrogram {:.0} Hz–{:.0} Hz · {:.2}s · {} frames, fft {}, hop {}\n",
|
||||||
|
0.0,
|
||||||
|
max_hz,
|
||||||
|
spec.frame_time(n_frames),
|
||||||
|
n_frames,
|
||||||
|
spec.fft_size,
|
||||||
|
spec.hop,
|
||||||
|
));
|
||||||
|
|
||||||
|
let label_every = (opts.height.max(1) / 6).max(1);
|
||||||
|
for r in 0..opts.height {
|
||||||
|
let top_pixel = rows - 1 - 2 * r;
|
||||||
|
let bot_pixel = rows.saturating_sub(2 + 2 * r);
|
||||||
|
// Frequency label for this char row (use the top pixel's centre freq).
|
||||||
|
let label = if r % label_every == 0 {
|
||||||
|
let frac = top_pixel as f32 / (rows - 1) as f32;
|
||||||
|
format!("{:>6.0}", frac * max_hz)
|
||||||
|
} else {
|
||||||
|
" ".to_string()
|
||||||
|
};
|
||||||
|
out.push_str(&label);
|
||||||
|
out.push_str(" \u{2502}"); // " │"
|
||||||
|
for col in &grid {
|
||||||
|
let top = norm_db(col[top_pixel], max_mag, opts.floor_db);
|
||||||
|
let bot = norm_db(col[bot_pixel], max_mag, opts.floor_db);
|
||||||
|
push_cell(&mut out, top, bot, opts.ascii);
|
||||||
|
}
|
||||||
|
if !opts.ascii {
|
||||||
|
out.push_str(RESET);
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time axis.
|
||||||
|
out.push_str(" \u{2514}"); // " └"
|
||||||
|
out.push_str(&"\u{2500}".repeat(width));
|
||||||
|
out.push('\n');
|
||||||
|
out.push_str(&format!(
|
||||||
|
" 0.00s{:>width$.2}s\n",
|
||||||
|
spec.frame_time(n_frames),
|
||||||
|
width = width.saturating_sub(5),
|
||||||
|
));
|
||||||
|
|
||||||
|
out.push_str(&legend(opts));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps a linear magnitude to `[0,1]` over `[floor_db, 0]` relative to `max_mag`.
|
||||||
|
fn norm_db(mag: f32, max_mag: f32, floor_db: f32) -> f32 {
|
||||||
|
if mag <= 1e-9 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let db = 20.0 * (mag / max_mag).log10();
|
||||||
|
((db - floor_db) / -floor_db).clamp(0.0, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends one character cell (two stacked pixels) to `out`.
|
||||||
|
fn push_cell(out: &mut String, top: f32, bot: f32, ascii: bool) {
|
||||||
|
if ascii {
|
||||||
|
// One pixel per cell in ASCII mode (use the brighter of the pair).
|
||||||
|
let v = top.max(bot);
|
||||||
|
let idx = ((v * (ASCII_RAMP.len() - 1) as f32).round() as usize).min(ASCII_RAMP.len() - 1);
|
||||||
|
out.push(ASCII_RAMP[idx] as char);
|
||||||
|
} else {
|
||||||
|
let (tr, tg, tb) = magma(top);
|
||||||
|
let (br, bg, bb) = magma(bot);
|
||||||
|
// fg = top pixel, bg = bottom pixel, glyph = upper half block.
|
||||||
|
out.push_str(&format!(
|
||||||
|
"\x1b[38;2;{tr};{tg};{tb}m\x1b[48;2;{br};{bg};{bb}m\u{2580}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A magma-style colormap: `t` in `[0,1]` → `(r,g,b)`. Piecewise-linear through
|
||||||
|
/// control colours sampled from matplotlib's `magma`.
|
||||||
|
fn magma(t: f32) -> (u8, u8, u8) {
|
||||||
|
const STOPS: &[(f32, (f32, f32, f32))] = &[
|
||||||
|
(0.0, (0.0, 0.0, 4.0)),
|
||||||
|
(0.25, (80.0, 18.0, 123.0)),
|
||||||
|
(0.5, (182.0, 54.0, 121.0)),
|
||||||
|
(0.75, (252.0, 136.0, 97.0)),
|
||||||
|
(1.0, (252.0, 253.0, 191.0)),
|
||||||
|
];
|
||||||
|
let t = t.clamp(0.0, 1.0);
|
||||||
|
for win in STOPS.windows(2) {
|
||||||
|
let (t0, c0) = win[0];
|
||||||
|
let (t1, c1) = win[1];
|
||||||
|
if t <= t1 {
|
||||||
|
let f = if t1 > t0 { (t - t0) / (t1 - t0) } else { 0.0 };
|
||||||
|
return (
|
||||||
|
(c0.0 + (c1.0 - c0.0) * f) as u8,
|
||||||
|
(c0.1 + (c1.1 - c0.1) * f) as u8,
|
||||||
|
(c0.2 + (c1.2 - c0.2) * f) as u8,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(252, 253, 191)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A horizontal dB colour legend strip.
|
||||||
|
fn legend(opts: &RenderOpts) -> String {
|
||||||
|
let mut s = format!(" dB: {:.0}", opts.floor_db);
|
||||||
|
let steps = 32;
|
||||||
|
if opts.ascii {
|
||||||
|
for i in 0..steps {
|
||||||
|
let v = i as f32 / (steps - 1) as f32;
|
||||||
|
let idx = ((v * (ASCII_RAMP.len() - 1) as f32).round() as usize).min(ASCII_RAMP.len() - 1);
|
||||||
|
s.push(ASCII_RAMP[idx] as char);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.push(' ');
|
||||||
|
for i in 0..steps {
|
||||||
|
let v = i as f32 / (steps - 1) as f32;
|
||||||
|
let (r, g, b) = magma(v);
|
||||||
|
s.push_str(&format!("\x1b[48;2;{r};{g};{b}m "));
|
||||||
|
}
|
||||||
|
s.push_str(RESET);
|
||||||
|
}
|
||||||
|
s.push_str(" 0\n");
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::dsp::{generators, stft};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn norm_db_maps_floor_and_peak() {
|
||||||
|
// At max magnitude -> 0 dB -> top of range (1.0).
|
||||||
|
assert!((norm_db(1.0, 1.0, -80.0) - 1.0).abs() < 1e-5);
|
||||||
|
// Silence -> 0.0.
|
||||||
|
assert_eq!(norm_db(0.0, 1.0, -80.0), 0.0);
|
||||||
|
// -80 dB (mag 1e-4 of max) -> ~0.0 bottom of range.
|
||||||
|
assert!(norm_db(1e-4, 1.0, -80.0) < 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn magma_endpoints() {
|
||||||
|
assert_eq!(magma(0.0), (0, 0, 4));
|
||||||
|
assert_eq!(magma(1.0), (252, 253, 191));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_produces_grid_of_expected_height() {
|
||||||
|
let sig = generators::sine(2000.0, 0.8, 48_000, 48_000);
|
||||||
|
let spec = stft::analyze(&sig, 48_000, 1024, 512);
|
||||||
|
let opts = RenderOpts { width: 40, height: 10, ..Default::default() };
|
||||||
|
let out = render(&spec, &opts);
|
||||||
|
// Header + 10 body rows + time axis (2) + legend = non-trivial.
|
||||||
|
let lines = out.lines().count();
|
||||||
|
assert!(lines >= 13, "expected >=13 lines, got {lines}");
|
||||||
|
assert!(out.contains("Spectrogram"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_spectrogram_renders_message() {
|
||||||
|
let spec = stft::analyze(&[], 48_000, 512, 256);
|
||||||
|
let out = render(&spec, &RenderOpts::default());
|
||||||
|
assert!(out.contains("empty"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
//! Short-Time Fourier Transform: slice a signal into overlapping windowed
|
||||||
|
//! frames and take each frame's magnitude spectrum. The result is the
|
||||||
|
//! time×frequency matrix a spectrogram draws.
|
||||||
|
|
||||||
|
use super::fft::real_magnitude_spectrum;
|
||||||
|
use super::window::{apply, hann};
|
||||||
|
|
||||||
|
/// One STFT analysis: a sequence of magnitude frames plus the geometry needed to
|
||||||
|
/// label axes (sample rate, fft size, hop).
|
||||||
|
pub struct Spectrogram {
|
||||||
|
/// `frames[t][bin]` = linear magnitude of frequency `bin` at time-step `t`.
|
||||||
|
/// Each inner vec has `fft_size/2 + 1` bins (DC..Nyquist).
|
||||||
|
pub frames: Vec<Vec<f32>>,
|
||||||
|
pub sample_rate: u32,
|
||||||
|
pub fft_size: usize,
|
||||||
|
pub hop: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Spectrogram {
|
||||||
|
/// Number of one-sided frequency bins per frame (`fft_size/2 + 1`).
|
||||||
|
pub fn bins(&self) -> usize {
|
||||||
|
self.fft_size / 2 + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Centre frequency (Hz) of bin index `bin`.
|
||||||
|
pub fn bin_hz(&self, bin: usize) -> f32 {
|
||||||
|
bin as f32 * self.sample_rate as f32 / self.fft_size as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Time (seconds) at the start of frame `t`.
|
||||||
|
pub fn frame_time(&self, t: usize) -> f32 {
|
||||||
|
(t * self.hop) as f32 / self.sample_rate as f32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the STFT of `samples` with the given `fft_size` (rounded up to a
|
||||||
|
/// power of two) and `hop` (frame advance in samples). A Hann window is applied
|
||||||
|
/// to each frame. The final partial frame is zero-padded so trailing audio is
|
||||||
|
/// not dropped.
|
||||||
|
pub fn analyze(samples: &[f32], sample_rate: u32, fft_size: usize, hop: usize) -> Spectrogram {
|
||||||
|
let fft_size = fft_size.next_power_of_two().max(2);
|
||||||
|
let hop = hop.max(1);
|
||||||
|
let window = hann(fft_size);
|
||||||
|
|
||||||
|
let mut frames = Vec::new();
|
||||||
|
if !samples.is_empty() {
|
||||||
|
let mut start = 0;
|
||||||
|
while start < samples.len() {
|
||||||
|
let end = (start + fft_size).min(samples.len());
|
||||||
|
let mut frame = vec![0.0f32; fft_size];
|
||||||
|
frame[..end - start].copy_from_slice(&samples[start..end]);
|
||||||
|
let windowed = apply(&frame, &window);
|
||||||
|
frames.push(real_magnitude_spectrum(&windowed));
|
||||||
|
start += hop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spectrogram {
|
||||||
|
frames,
|
||||||
|
sample_rate,
|
||||||
|
fft_size,
|
||||||
|
hop,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::f64::consts::PI;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frame_count_follows_hop() {
|
||||||
|
// 1000 samples, hop 250 -> frames start at 0,250,500,750 = 4 frames.
|
||||||
|
let sig = vec![0.0f32; 1000];
|
||||||
|
let s = analyze(&sig, 48_000, 512, 250);
|
||||||
|
assert_eq!(s.frames.len(), 4);
|
||||||
|
assert_eq!(s.bins(), 512 / 2 + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tone_lands_in_expected_bin() {
|
||||||
|
// A 3 kHz tone at 48 kHz with a 1024-pt FFT -> bin ≈ 3000/(48000/1024) = 64.
|
||||||
|
let sr = 48_000;
|
||||||
|
let freq = 3000.0;
|
||||||
|
let sig: Vec<f32> = (0..4096)
|
||||||
|
.map(|i| (2.0 * PI * freq * i as f64 / sr as f64).sin() as f32)
|
||||||
|
.collect();
|
||||||
|
let s = analyze(&sig, sr, 1024, 512);
|
||||||
|
let mid = &s.frames[s.frames.len() / 2];
|
||||||
|
let peak_bin = mid
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.0;
|
||||||
|
let peak_hz = s.bin_hz(peak_bin);
|
||||||
|
assert!((peak_hz - freq as f32).abs() < 100.0, "peak at {peak_hz} Hz, want {freq}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_input_yields_no_frames() {
|
||||||
|
let s = analyze(&[], 48_000, 512, 256);
|
||||||
|
assert!(s.frames.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
//! A minimal canonical-PCM WAV reader (the analysis-side counterpart to
|
||||||
|
//! `audio::recorder::WavWriter`). Handles 16-bit integer PCM — the only format
|
||||||
|
//! PeerSpeak writes — in mono or interleaved multi-channel, downmixing to mono
|
||||||
|
//! `f32` in `[-1, 1]` for analysis. I/O lives here, at the edge; the rest of the
|
||||||
|
//! `dsp` module is pure.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// A decoded WAV: mono `f32` samples plus the original sample rate.
|
||||||
|
pub struct WavData {
|
||||||
|
pub samples: Vec<f32>,
|
||||||
|
pub sample_rate: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a 16-bit PCM WAV file, downmixing any channels to mono `f32`.
|
||||||
|
///
|
||||||
|
/// Returns `Err` with a human-readable reason if the file is missing, truncated,
|
||||||
|
/// not RIFF/WAVE, or not 16-bit PCM. The parser walks the chunk list rather than
|
||||||
|
/// assuming a fixed 44-byte header, so files with extra chunks (`LIST`, `fact`,
|
||||||
|
/// …) still read.
|
||||||
|
pub fn read(path: &Path) -> Result<WavData, String> {
|
||||||
|
let bytes = fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
|
||||||
|
if bytes.len() < 12 || &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
|
||||||
|
return Err("not a RIFF/WAVE file".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut channels = 0u16;
|
||||||
|
let mut sample_rate = 0u32;
|
||||||
|
let mut bits = 0u16;
|
||||||
|
let mut data: Option<&[u8]> = None;
|
||||||
|
|
||||||
|
// Walk chunks starting after the 12-byte RIFF/WAVE header.
|
||||||
|
let mut pos = 12usize;
|
||||||
|
while pos + 8 <= bytes.len() {
|
||||||
|
let id = &bytes[pos..pos + 4];
|
||||||
|
let size = u32::from_le_bytes([bytes[pos + 4], bytes[pos + 5], bytes[pos + 6], bytes[pos + 7]]) as usize;
|
||||||
|
let body_start = pos + 8;
|
||||||
|
let body_end = (body_start + size).min(bytes.len());
|
||||||
|
match id {
|
||||||
|
b"fmt " if size >= 16 => {
|
||||||
|
let fmt = &bytes[body_start..body_end];
|
||||||
|
let audio_format = u16::from_le_bytes([fmt[0], fmt[1]]);
|
||||||
|
channels = u16::from_le_bytes([fmt[2], fmt[3]]);
|
||||||
|
sample_rate = u32::from_le_bytes([fmt[4], fmt[5], fmt[6], fmt[7]]);
|
||||||
|
bits = u16::from_le_bytes([fmt[14], fmt[15]]);
|
||||||
|
if audio_format != 1 {
|
||||||
|
return Err(format!("unsupported WAV format tag {audio_format} (need PCM=1)"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b"data" => {
|
||||||
|
data = Some(&bytes[body_start..body_end]);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
// Chunks are word-aligned: an odd size is padded with one byte.
|
||||||
|
pos = body_start + size + (size & 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if bits != 16 {
|
||||||
|
return Err(format!("only 16-bit PCM supported, got {bits}-bit"));
|
||||||
|
}
|
||||||
|
let channels = channels.max(1);
|
||||||
|
let data = data.ok_or("no data chunk")?;
|
||||||
|
|
||||||
|
// Interleaved S16LE -> per-frame channel average -> mono f32.
|
||||||
|
let frame_bytes = 2 * channels as usize;
|
||||||
|
let mut samples = Vec::with_capacity(data.len() / frame_bytes.max(1));
|
||||||
|
for frame in data.chunks_exact(frame_bytes) {
|
||||||
|
let mut acc = 0i32;
|
||||||
|
for ch in frame.chunks_exact(2) {
|
||||||
|
acc += i16::from_le_bytes([ch[0], ch[1]]) as i32;
|
||||||
|
}
|
||||||
|
let avg = acc as f32 / channels as f32;
|
||||||
|
samples.push(avg / 32768.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(WavData { samples, sample_rate })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes mono `f32` samples (clamped to `[-1, 1]`) as a 16-bit PCM WAV. Used by
|
||||||
|
/// the `specview gen` command to materialize conjured test signals.
|
||||||
|
pub fn write(path: &Path, samples: &[f32], sample_rate: u32) -> Result<(), String> {
|
||||||
|
let pcm: Vec<i16> = samples
|
||||||
|
.iter()
|
||||||
|
.map(|&s| (s.clamp(-1.0, 1.0) * 32767.0).round() as i16)
|
||||||
|
.collect();
|
||||||
|
let data_bytes = (pcm.len() * 2) as u32;
|
||||||
|
let byte_rate = sample_rate * 2; // mono, 2 bytes/sample
|
||||||
|
let mut out = Vec::with_capacity(44 + pcm.len() * 2);
|
||||||
|
out.extend_from_slice(b"RIFF");
|
||||||
|
out.extend_from_slice(&(36 + data_bytes).to_le_bytes());
|
||||||
|
out.extend_from_slice(b"WAVE");
|
||||||
|
out.extend_from_slice(b"fmt ");
|
||||||
|
out.extend_from_slice(&16u32.to_le_bytes());
|
||||||
|
out.extend_from_slice(&1u16.to_le_bytes()); // PCM
|
||||||
|
out.extend_from_slice(&1u16.to_le_bytes()); // mono
|
||||||
|
out.extend_from_slice(&sample_rate.to_le_bytes());
|
||||||
|
out.extend_from_slice(&byte_rate.to_le_bytes());
|
||||||
|
out.extend_from_slice(&2u16.to_le_bytes()); // block align
|
||||||
|
out.extend_from_slice(&16u16.to_le_bytes()); // bits
|
||||||
|
out.extend_from_slice(b"data");
|
||||||
|
out.extend_from_slice(&data_bytes.to_le_bytes());
|
||||||
|
for s in pcm {
|
||||||
|
out.extend_from_slice(&s.to_le_bytes());
|
||||||
|
}
|
||||||
|
fs::write(path, out).map_err(|e| format!("cannot write {}: {e}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_then_read_round_trips() {
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join(format!("dsp-wav-rt-{}.wav", std::process::id()));
|
||||||
|
let sig: Vec<f32> = (0..1000).map(|i| (i as f32 * 0.01).sin() * 0.5).collect();
|
||||||
|
write(&path, &sig, 48_000).unwrap();
|
||||||
|
|
||||||
|
let back = read(&path).unwrap();
|
||||||
|
assert_eq!(back.sample_rate, 48_000);
|
||||||
|
assert_eq!(back.samples.len(), sig.len());
|
||||||
|
// 16-bit quantization tolerance.
|
||||||
|
for (a, b) in sig.iter().zip(&back.samples) {
|
||||||
|
assert!((a - b).abs() < 1e-3, "{a} vs {b}");
|
||||||
|
}
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_non_wave() {
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join(format!("dsp-wav-bad-{}.bin", std::process::id()));
|
||||||
|
fs::write(&path, b"not a wav at all").unwrap();
|
||||||
|
assert!(read(&path).is_err());
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
//! Analysis windows for the STFT.
|
||||||
|
//!
|
||||||
|
//! A raw rectangular frame leaks spectral energy across bins (the abrupt edges
|
||||||
|
//! look like discontinuities to the FFT). A Hann window tapers each frame to
|
||||||
|
//! zero at its edges, trading a little main-lobe width for much lower side-lobe
|
||||||
|
//! leakage — the standard choice for a spectrogram.
|
||||||
|
|
||||||
|
use std::f64::consts::PI;
|
||||||
|
|
||||||
|
/// Returns a length-`n` periodic Hann window, `w[i] = 0.5·(1 - cos(2πi/n))`.
|
||||||
|
///
|
||||||
|
/// The *periodic* form (denominator `n`, not `n-1`) is used because STFT frames
|
||||||
|
/// tile the signal; it gives perfect overlap-add reconstruction at 50% hop.
|
||||||
|
pub fn hann(n: usize) -> Vec<f32> {
|
||||||
|
if n <= 1 {
|
||||||
|
return vec![1.0; n];
|
||||||
|
}
|
||||||
|
(0..n)
|
||||||
|
.map(|i| (0.5 - 0.5 * (2.0 * PI * i as f64 / n as f64).cos()) as f32)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies `window` to `frame` element-wise into a new buffer. Lengths must match.
|
||||||
|
pub fn apply(frame: &[f32], window: &[f32]) -> Vec<f32> {
|
||||||
|
debug_assert_eq!(frame.len(), window.len());
|
||||||
|
frame.iter().zip(window).map(|(&s, &w)| s * w).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hann_endpoints_are_zero_and_center_is_one() {
|
||||||
|
let w = hann(8);
|
||||||
|
assert!(w[0].abs() < 1e-6, "first sample tapers to ~0, got {}", w[0]);
|
||||||
|
// Periodic Hann peaks at the midpoint n/2.
|
||||||
|
assert!((w[4] - 1.0).abs() < 1e-6, "center peaks at 1, got {}", w[4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hann_is_symmetric_about_center() {
|
||||||
|
let w = hann(16);
|
||||||
|
// Periodic window is symmetric across indices 1..n-1.
|
||||||
|
for i in 1..8 {
|
||||||
|
assert!((w[i] - w[16 - i]).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_scales_samples() {
|
||||||
|
let out = apply(&[2.0, 2.0], &[0.5, 0.25]);
|
||||||
|
assert_eq!(out, vec![1.0, 0.5]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod audio;
|
pub mod audio;
|
||||||
pub mod codec;
|
pub mod codec;
|
||||||
|
pub mod dsp;
|
||||||
pub mod network;
|
pub mod network;
|
||||||
pub mod core;
|
pub mod core;
|
||||||
pub mod app;
|
pub mod app;
|
||||||
|
|||||||
Reference in New Issue
Block a user