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>
56 lines
1.7 KiB
Rust
56 lines
1.7 KiB
Rust
//! 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]);
|
|
}
|
|
}
|