155 lines
5.2 KiB
Rust
155 lines
5.2 KiB
Rust
//! Turns a window of audio samples into a handful of normalized
|
|
//! frequency bars (architecture/spectrum.md). Pure DSP — the tap
|
|
//! (`audio-player`) supplies samples, the stream carries the result;
|
|
//! this only does the maths.
|
|
|
|
use realfft::num_complex::Complex;
|
|
use realfft::{RealFftPlanner, RealToComplex};
|
|
use std::sync::Arc;
|
|
|
|
/// Number of bars the visualizer shows.
|
|
pub const SPECTRUM_BINS: usize = 24;
|
|
|
|
/// Magnitudes below this (dBFS) map to an empty bar; 0 dB maps to full.
|
|
const MIN_DB: f32 = -60.0;
|
|
|
|
/// A reusable forward-FFT + log-bin folder for a fixed window length.
|
|
pub struct SpectrumAnalyzer {
|
|
fft: Arc<dyn RealToComplex<f32>>,
|
|
/// Hann window applied before the transform to cut spectral leakage.
|
|
window: Vec<f32>,
|
|
input: Vec<f32>,
|
|
output: Vec<Complex<f32>>,
|
|
/// `SPECTRUM_BINS + 1` boundaries into the magnitude array, spaced
|
|
/// logarithmically so the bars are roughly musically even.
|
|
edges: Vec<usize>,
|
|
}
|
|
|
|
impl SpectrumAnalyzer {
|
|
pub fn new(window_len: usize) -> Self {
|
|
let fft = RealFftPlanner::<f32>::new().plan_fft_forward(window_len);
|
|
let input = fft.make_input_vec();
|
|
let output = fft.make_output_vec();
|
|
let window = (0..window_len).map(|i| hann(i, window_len)).collect();
|
|
let edges = log_bin_edges(output.len(), SPECTRUM_BINS);
|
|
Self {
|
|
fft,
|
|
window,
|
|
input,
|
|
output,
|
|
edges,
|
|
}
|
|
}
|
|
|
|
/// Folds `samples` (exactly the window length) into `SPECTRUM_BINS`
|
|
/// normalized `[0, 1]` magnitudes, low frequency first. A processing
|
|
/// error or a wrong-length input yields all-zero bars rather than a
|
|
/// panic.
|
|
pub fn analyze(&mut self, samples: &[f32]) -> Vec<f32> {
|
|
if samples.len() != self.input.len() {
|
|
return vec![0.0; SPECTRUM_BINS];
|
|
}
|
|
for (dst, (sample, w)) in self
|
|
.input
|
|
.iter_mut()
|
|
.zip(samples.iter().zip(self.window.iter()))
|
|
{
|
|
*dst = sample * w;
|
|
}
|
|
if self.fft.process(&mut self.input, &mut self.output).is_err() {
|
|
return vec![0.0; SPECTRUM_BINS];
|
|
}
|
|
let n = self.window.len() as f32;
|
|
let magnitude = |c: &Complex<f32>| c.norm() / n;
|
|
(0..SPECTRUM_BINS)
|
|
.map(|bar| {
|
|
let lo = self.edges[bar];
|
|
let hi = self.edges[bar + 1].max(lo + 1).min(self.output.len());
|
|
let avg = self.output[lo..hi].iter().map(magnitude).sum::<f32>() / (hi - lo) as f32;
|
|
// Log-compress: dBFS mapped onto [0, 1].
|
|
let db = 20.0 * (avg + 1e-9).log10();
|
|
((db - MIN_DB) / -MIN_DB).clamp(0.0, 1.0)
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
/// A Hann window coefficient for sample `i` of `len`.
|
|
fn hann(i: usize, len: usize) -> f32 {
|
|
let x = std::f32::consts::PI * i as f32 / (len - 1) as f32;
|
|
x.sin().powi(2)
|
|
}
|
|
|
|
/// Geometrically-spaced boundaries into a magnitude array of `mag_len`
|
|
/// (skipping the DC bin at 0), giving `bins` groups. Monotonic
|
|
/// non-decreasing; callers widen any empty group to at least one bin.
|
|
fn log_bin_edges(mag_len: usize, bins: usize) -> Vec<usize> {
|
|
let lo = 1.0_f32;
|
|
let hi = (mag_len - 1).max(2) as f32;
|
|
(0..=bins)
|
|
.map(|b| {
|
|
let t = b as f32 / bins as f32;
|
|
(lo * (hi / lo).powf(t)).round() as usize
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const WINDOW: usize = 2048;
|
|
|
|
fn sine(freq: f32, sample_rate: f32, len: usize) -> Vec<f32> {
|
|
(0..len)
|
|
.map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / sample_rate).sin())
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn silence_is_all_zero_bars() {
|
|
let mut a = SpectrumAnalyzer::new(WINDOW);
|
|
let bars = a.analyze(&vec![0.0; WINDOW]);
|
|
assert_eq!(bars.len(), SPECTRUM_BINS);
|
|
assert!(bars.iter().all(|&b| b == 0.0), "{bars:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_tone_lights_one_region_and_stays_normalized() {
|
|
let mut a = SpectrumAnalyzer::new(WINDOW);
|
|
// ~1 kHz at 44.1 kHz lands in the upper-middle of the log bars.
|
|
let bars = a.analyze(&sine(1000.0, 44_100.0, WINDOW));
|
|
assert_eq!(bars.len(), SPECTRUM_BINS);
|
|
assert!(bars.iter().all(|&b| (0.0..=1.0).contains(&b)), "{bars:?}");
|
|
let peak = bars
|
|
.iter()
|
|
.cloned()
|
|
.enumerate()
|
|
.max_by(|a, b| a.1.total_cmp(&b.1))
|
|
.unwrap();
|
|
assert!(
|
|
peak.1 > 0.5,
|
|
"a pure tone should drive its bar high: {bars:?}"
|
|
);
|
|
// Energy is concentrated: most bars stay well below the peak.
|
|
let loud = bars.iter().filter(|&&b| b > peak.1 * 0.5).count();
|
|
assert!(
|
|
loud <= 4,
|
|
"a tone should not light the whole spectrum: {bars:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wrong_length_input_is_zero_not_a_panic() {
|
|
let mut a = SpectrumAnalyzer::new(WINDOW);
|
|
assert!(a.analyze(&[0.1, 0.2, 0.3]).iter().all(|&b| b == 0.0));
|
|
}
|
|
|
|
#[test]
|
|
fn bin_edges_are_monotonic() {
|
|
let edges = log_bin_edges(WINDOW / 2 + 1, SPECTRUM_BINS);
|
|
assert_eq!(edges.len(), SPECTRUM_BINS + 1);
|
|
assert!(edges.windows(2).all(|w| w[1] >= w[0]), "{edges:?}");
|
|
}
|
|
}
|