263 lines
9.0 KiB
Rust
263 lines
9.0 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;
|
|
// Single-sided amplitude (2/N): a full-scale tone reads near 1.0
|
|
// at its bin, so the bars fill for ordinary listening levels.
|
|
let magnitude = |c: &Complex<f32>| c.norm() * 2.0 / n;
|
|
(0..SPECTRUM_BINS)
|
|
.map(|bar| {
|
|
let lo = self.edges[bar];
|
|
let hi = self.edges[bar + 1].max(lo + 1).min(self.output.len());
|
|
// Peak within the band, not the mean: a strong component
|
|
// must light its bar instead of being diluted by the
|
|
// quiet bins around it (which averaging does).
|
|
let peak = self.output[lo..hi]
|
|
.iter()
|
|
.map(magnitude)
|
|
.fold(0.0_f32, f32::max);
|
|
// Log-compress: dBFS mapped onto [0, 1].
|
|
let db = 20.0 * (peak + 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()
|
|
}
|
|
|
|
/// How many consecutive ticks may see no new samples before the bars are
|
|
/// declared idle and dropped to zero.
|
|
///
|
|
/// One is too few. The tap counts *frames*, so during playback every tick
|
|
/// normally sees thousands more — but a tick that arrives early (or two that
|
|
/// arrive together) sees none, and calling that silence made the bars flick to
|
|
/// the floor about once a second and wrote two log lines each time. Two ticks
|
|
/// is 100 ms at 20 fps: still immediate to the eye when playback really stops.
|
|
const IDLE_TICKS_BEFORE_ZERO: u8 = 2;
|
|
|
|
/// Decides, tick by tick, whether audio is flowing — the only state the
|
|
/// spectrum task keeps.
|
|
///
|
|
/// Split out from the task so the flapping that motivated it is testable
|
|
/// without a runtime or an audio device (architecture/spectrum.md D2).
|
|
pub struct FlowDetector {
|
|
last_count: u64,
|
|
idle_ticks: u8,
|
|
active: bool,
|
|
}
|
|
|
|
/// What the spectrum task should send after one tick.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum Tick {
|
|
/// Audio is flowing: analyze the window and broadcast bars.
|
|
Bars,
|
|
/// Playback just went idle: broadcast a single zeroed frame so the bars
|
|
/// fall instead of freezing.
|
|
Silence,
|
|
/// Nothing to say.
|
|
Nothing,
|
|
}
|
|
|
|
impl FlowDetector {
|
|
pub fn new(count: u64) -> Self {
|
|
Self {
|
|
last_count: count,
|
|
idle_ticks: 0,
|
|
active: false,
|
|
}
|
|
}
|
|
|
|
/// Folds this tick's frame count into the decision.
|
|
pub fn observe(&mut self, count: u64) -> Tick {
|
|
if count != self.last_count {
|
|
self.last_count = count;
|
|
self.idle_ticks = 0;
|
|
self.active = true;
|
|
return Tick::Bars;
|
|
}
|
|
// No new frames. Only after enough of them in a row is this silence
|
|
// rather than a tick that merely landed between two callbacks.
|
|
self.idle_ticks = self.idle_ticks.saturating_add(1);
|
|
if self.active && self.idle_ticks >= IDLE_TICKS_BEFORE_ZERO {
|
|
self.active = false;
|
|
return Tick::Silence;
|
|
}
|
|
Tick::Nothing
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const WINDOW: usize = 2048;
|
|
|
|
/// The flapping this exists to stop: a single tick with no new frames is
|
|
/// not silence, so it must not zero the bars (which is what the log showed
|
|
/// happening ~1/s during playback).
|
|
#[test]
|
|
fn one_empty_tick_does_not_zero_the_bars() {
|
|
let mut flow = FlowDetector::new(0);
|
|
assert_eq!(flow.observe(1000), Tick::Bars);
|
|
assert_eq!(
|
|
flow.observe(1000),
|
|
Tick::Nothing,
|
|
"one empty tick is not silence"
|
|
);
|
|
assert_eq!(flow.observe(2000), Tick::Bars);
|
|
}
|
|
|
|
#[test]
|
|
fn sustained_silence_zeroes_the_bars_exactly_once() {
|
|
let mut flow = FlowDetector::new(0);
|
|
assert_eq!(flow.observe(1000), Tick::Bars);
|
|
assert_eq!(flow.observe(1000), Tick::Nothing);
|
|
assert_eq!(flow.observe(1000), Tick::Silence);
|
|
// And then stays quiet rather than re-sending zero frames forever.
|
|
for _ in 0..5 {
|
|
assert_eq!(flow.observe(1000), Tick::Nothing);
|
|
}
|
|
// Resuming reports flowing again.
|
|
assert_eq!(flow.observe(1001), Tick::Bars);
|
|
}
|
|
|
|
/// A player that never started must not emit a zero frame just because
|
|
/// nothing is happening.
|
|
#[test]
|
|
fn an_idle_player_says_nothing() {
|
|
let mut flow = FlowDetector::new(0);
|
|
for _ in 0..10 {
|
|
assert_eq!(flow.observe(0), Tick::Nothing);
|
|
}
|
|
}
|
|
|
|
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:?}");
|
|
}
|
|
}
|