//! 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>, /// Hann window applied before the transform to cut spectral leakage. window: Vec, input: Vec, output: Vec>, /// `SPECTRUM_BINS + 1` boundaries into the magnitude array, spaced /// logarithmically so the bars are roughly musically even. edges: Vec, } impl SpectrumAnalyzer { pub fn new(window_len: usize) -> Self { let fft = RealFftPlanner::::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 { 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| 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 { 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 { (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:?}"); } }