Skip to main content

koan_core/audio/
analyzer.rs

1//! Background FFT analysis thread for the visualizer.
2//!
3//! `VizAnalyzer` owns the FFT state and runs on a dedicated thread, decoupling
4//! heavy computation from both the audio decode thread and the TUI render thread.
5//!
6//! # Lock discipline
7//!
8//! The analysis loop follows a strict two-phase discipline to minimise lock
9//! contention:
10//!
11//! 1. **Input phase** — lock `VizBuffer` briefly, memcpy samples + metadata,
12//!    release immediately.  The decode thread is never blocked for longer than
13//!    a single copy.
14//! 2. **Compute phase** — run windowing, FFT, bin→bar accumulation *without*
15//!    holding any lock.
16//! 3. **Output phase** — take the `VizSnapshot` write lock briefly, swap in the
17//!    finished frame, release.  The TUI thread is blocked for at most one
18//!    ~200-byte memcpy.
19
20use std::sync::Arc;
21use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
22use std::thread;
23use std::time::{Duration, Instant};
24
25use realfft::RealFftPlanner;
26
27use super::viz::{NUM_BARS, RawVizSnapshot, VizBuffer, VizFrame, VizSnapshot, WAVEFORM_SAMPLES};
28use crate::config::VisualizerConfig;
29
30// ── FFT constants ────────────────────────────────────────────────────────────
31
32/// FFT window size: 2048 samples (~46ms at 44.1kHz).
33const FFT_SIZE: usize = 2048;
34
35/// Minimum frequency (Hz) included in spectrum bars.
36const MIN_FREQ: f32 = 20.0;
37
38/// Maximum frequency (Hz) included in spectrum bars.
39const MAX_FREQ: f32 = 18_000.0;
40
41/// dB floor: magnitudes below this map to 0.0.
42const DB_FLOOR: f32 = -80.0;
43
44/// dB ceiling: magnitudes at or above this map to 1.0.
45const DB_CEIL: f32 = 0.0;
46
47// ── Frequency scale ──────────────────────────────────────────────────────────
48
49/// Frequency scale used to map FFT bins to spectrum bars.
50#[derive(Debug, Clone, Copy, Default)]
51pub enum FrequencyScale {
52    /// Bark psychoacoustic scale — 24 critical bands, best for perceiving music.
53    #[default]
54    Bark,
55    /// Mel perceptual pitch scale.
56    Mel,
57    /// Logarithmic — equal spacing per octave.
58    Log,
59    /// Linear — equal Hz per bar.
60    Linear,
61}
62
63impl FrequencyScale {
64    pub fn parse(s: &str) -> Self {
65        match s.to_lowercase().as_str() {
66            "bark" => Self::Bark,
67            "mel" => Self::Mel,
68            "log" | "logarithmic" => Self::Log,
69            "linear" => Self::Linear,
70            _ => Self::default(),
71        }
72    }
73
74    /// Map a frequency in Hz to a normalised 0.0..1.0 position on this scale.
75    fn normalize(&self, freq: f32) -> f32 {
76        match self {
77            Self::Bark => {
78                let bark = |f: f32| 26.81 / (1.0 + 1960.0 / f) - 0.53;
79                let b = bark(freq);
80                let b_min = bark(MIN_FREQ);
81                let b_max = bark(MAX_FREQ);
82                (b - b_min) / (b_max - b_min)
83            }
84            Self::Mel => {
85                let mel = |f: f32| 2595.0 * (1.0 + f / 700.0).log10();
86                let m = mel(freq);
87                let m_min = mel(MIN_FREQ);
88                let m_max = mel(MAX_FREQ);
89                (m - m_min) / (m_max - m_min)
90            }
91            Self::Log => {
92                let log_min = MIN_FREQ.ln();
93                let log_max = MAX_FREQ.ln();
94                (freq.ln() - log_min) / (log_max - log_min)
95            }
96            Self::Linear => (freq - MIN_FREQ) / (MAX_FREQ - MIN_FREQ),
97        }
98    }
99}
100
101// ── Amplitude scale ─────────────────────────────────────────────────────────
102
103/// Amplitude scale applied to FFT magnitudes before display.
104#[derive(Debug, Clone, Copy, Default)]
105pub enum AmplitudeScale {
106    /// A-weighted + gentle gamma — bars reflect perceived loudness with quiet boost.
107    Perceptual,
108    /// Pure A-weighting (IEC 61672), linear mapping after.
109    #[default]
110    AWeight,
111    /// Square root — gentle boost to quiet bands.
112    Sqrt,
113    /// Linear — raw dB-normalized magnitude, no correction.
114    Linear,
115}
116
117impl AmplitudeScale {
118    pub fn parse(s: &str) -> Self {
119        match s.to_lowercase().as_str() {
120            "perceptual" => Self::Perceptual,
121            "aweight" | "a-weight" | "a_weight" => Self::AWeight,
122            "sqrt" => Self::Sqrt,
123            "linear" => Self::Linear,
124            _ => Self::default(),
125        }
126    }
127
128    /// Apply the amplitude curve to a 0.0..1.0 normalized level.
129    fn apply(self, level: f32) -> f32 {
130        match self {
131            Self::Perceptual => level.powf(0.4),
132            Self::AWeight => level,
133            Self::Sqrt => level.sqrt(),
134            Self::Linear => level,
135        }
136    }
137}
138
139/// A-weighting correction in dB for a given frequency (IEC 61672-1).
140///
141/// Returns the dB offset to add to a magnitude before normalization.
142/// At 1kHz the correction is 0dB; bass and extreme treble are attenuated.
143fn a_weight_db(freq: f32) -> f32 {
144    let f2 = freq * freq;
145    let f4 = f2 * f2;
146
147    let num = 12194.0_f32.powi(2) * f4;
148    let denom = (f2 + 20.6_f32.powi(2))
149        * ((f2 + 107.7_f32.powi(2)) * (f2 + 737.9_f32.powi(2))).sqrt()
150        * (f2 + 12194.0_f32.powi(2));
151
152    if denom == 0.0 {
153        return DB_FLOOR;
154    }
155
156    // R_A(f) relative to 1kHz reference
157    let ra = num / denom;
158    // A-weighting: 20*log10(R_A) + 2.00 dB offset (IEC 61672 normalization)
159    20.0 * ra.log10() + 2.0
160}
161
162/// Pre-compute A-weighting corrections for each FFT bin.
163fn build_a_weight_table(sample_rate: f32) -> Vec<f32> {
164    let bin_hz = sample_rate / FFT_SIZE as f32;
165    let num_bins = FFT_SIZE / 2 + 1;
166    (0..num_bins)
167        .map(|bin_idx| {
168            let freq = bin_idx as f32 * bin_hz;
169            if freq < 1.0 {
170                DB_FLOOR // DC bin — silence
171            } else {
172                a_weight_db(freq)
173            }
174        })
175        .collect()
176}
177
178// ── Helpers ──────────────────────────────────────────────────────────────────
179
180/// Precomputed Hann window coefficients.
181fn hann_window() -> Vec<f32> {
182    (0..FFT_SIZE)
183        .map(|i| {
184            let t = std::f32::consts::PI * 2.0 * i as f32 / FFT_SIZE as f32;
185            0.5 * (1.0 - t.cos())
186        })
187        .collect()
188}
189
190/// Build the bin→bar lookup table for a given sample rate and scale.
191/// Returns `None` for bins outside [MIN_FREQ, MAX_FREQ].
192fn build_bin_to_bar(sample_rate: f32, scale: FrequencyScale) -> Vec<Option<usize>> {
193    let bin_hz = sample_rate / FFT_SIZE as f32;
194    let num_bins = FFT_SIZE / 2 + 1;
195    (0..num_bins)
196        .map(|bin_idx| {
197            let freq = bin_idx as f32 * bin_hz;
198            if !(MIN_FREQ..=MAX_FREQ).contains(&freq) {
199                return None;
200            }
201            let normalized = scale.normalize(freq);
202            Some(((normalized * NUM_BARS as f32) as usize).min(NUM_BARS - 1))
203        })
204        .collect()
205}
206
207// ── Internal analysis state ──────────────────────────────────────────────────
208
209/// All mutable state owned by the analysis thread — not shared.
210struct AnalysisState {
211    /// Precomputed Hann window.
212    window: Vec<f32>,
213    /// Magnitude scale that maps a windowed bin back to signal amplitude.
214    /// Derived from the window's coherent gain, so it stays correct if the
215    /// window function changes.
216    fft_norm: f32,
217    /// FFT scratch: time-domain input (windowed mono).
218    fft_input: Vec<f32>,
219    /// FFT scratch: frequency-domain output.
220    fft_output: Vec<realfft::num_complex::Complex<f32>>,
221    /// Cached FFT plan.
222    fft: Arc<dyn realfft::RealToComplex<f32>>,
223    /// Bin→bar lookup (rebuilt on sample-rate change).
224    bin_to_bar: Vec<Option<usize>>,
225    /// Last seen sample rate — detects changes.
226    last_sample_rate: f32,
227    /// Reusable counts per bar (how many bins mapped to each bar).
228    bar_counts: [u32; NUM_BARS],
229    /// Smoothed spectrum from previous frame (for decay).
230    prev_spectrum: [f32; NUM_BARS],
231    /// Current spectrum (written each pass, then moved to output).
232    spectrum: [f32; NUM_BARS],
233    /// Peak hold values.
234    peaks: [f32; NUM_BARS],
235    /// VU levels [left, right].
236    vu_levels: [f32; 2],
237    /// Timestamp of the previous analysis pass (for decay timing).
238    last_update: Instant,
239    /// Frequency scale for bin→bar mapping.
240    scale: FrequencyScale,
241    /// Bar decay half-life in seconds.
242    bar_half_life: f32,
243    /// Peak decay half-life in seconds.
244    peak_half_life: f32,
245    /// Amplitude scale for magnitude mapping.
246    amplitude_scale: AmplitudeScale,
247    /// Pre-computed A-weighting correction per FFT bin (dB).
248    a_weight_table: Vec<f32>,
249    /// Rolling average of low-band energy for beat detection.
250    /// Tracks the mean of the bottom ~4 bars over recent frames.
251    beat_avg: f32,
252    /// Current beat energy output (0.0..1.0), decays each frame.
253    beat_energy: f32,
254}
255
256impl AnalysisState {
257    fn new(
258        scale: FrequencyScale,
259        bar_half_life: f32,
260        peak_half_life: f32,
261        amplitude_scale: AmplitudeScale,
262    ) -> Self {
263        let mut planner = RealFftPlanner::<f32>::new();
264        let fft = planner.plan_fft_forward(FFT_SIZE);
265        let fft_input = fft.make_input_vec();
266        let fft_output = fft.make_output_vec();
267        let window = hann_window();
268        let fft_norm = 2.0 / window.iter().sum::<f32>();
269        Self {
270            window,
271            fft_norm,
272            fft_input,
273            fft_output,
274            fft,
275            bin_to_bar: Vec::new(),
276            last_sample_rate: 0.0,
277            bar_counts: [0u32; NUM_BARS],
278            prev_spectrum: [0.0; NUM_BARS],
279            spectrum: [0.0; NUM_BARS],
280            peaks: [0.0; NUM_BARS],
281            vu_levels: [0.0; 2],
282            last_update: Instant::now(),
283            scale,
284            bar_half_life,
285            peak_half_life,
286            amplitude_scale,
287            a_weight_table: Vec::new(),
288            beat_avg: 0.0,
289            beat_energy: 0.0,
290        }
291    }
292
293    /// Compute time-based decay factors from elapsed time since last pass.
294    fn decay_factors(&mut self) -> (f32, f32) {
295        let now = Instant::now();
296        let dt = now.duration_since(self.last_update).as_secs_f32();
297        self.last_update = now;
298        let bar_decay = 0.5f32.powf(dt / self.bar_half_life);
299        let peak_decay = 0.5f32.powf(dt / self.peak_half_life);
300        (bar_decay, peak_decay)
301    }
302
303    /// Run a full analysis pass on the given snapshot.
304    ///
305    /// No lock is held during this call.
306    fn analyze(&mut self, samples: &[f32], channels: usize, sample_rate: f32) {
307        if samples.is_empty() || sample_rate <= 0.0 || channels == 0 {
308            self.decay_silence();
309            return;
310        }
311
312        // ── VU (RMS per channel) ────────────────────────────────────────────
313        self.compute_vu(samples, channels);
314
315        // ── Mix to mono + apply Hann window ────────────────────────────────
316        let total_frames = samples.len() / channels;
317        let frames_to_use = total_frames.min(FFT_SIZE);
318        let frame_start = total_frames - frames_to_use;
319
320        for i in 0..FFT_SIZE {
321            if i < frames_to_use {
322                let frame_idx = frame_start + i;
323                let sample_start = frame_idx * channels;
324                let mut sum = 0.0f32;
325                for ch in 0..channels {
326                    if sample_start + ch < samples.len() {
327                        sum += samples[sample_start + ch];
328                    }
329                }
330                self.fft_input[i] = (sum / channels as f32) * self.window[i];
331            } else {
332                self.fft_input[i] = 0.0;
333            }
334        }
335
336        // ── FFT ─────────────────────────────────────────────────────────────
337        if self
338            .fft
339            .process(&mut self.fft_input, &mut self.fft_output)
340            .is_err()
341        {
342            self.decay_silence();
343            return;
344        }
345
346        // ── Rebuild bin→bar + A-weight table on sample-rate change ──────────
347        if (sample_rate - self.last_sample_rate).abs() > 0.5 {
348            self.bin_to_bar = build_bin_to_bar(sample_rate, self.scale);
349            self.a_weight_table = build_a_weight_table(sample_rate);
350            self.last_sample_rate = sample_rate;
351        }
352
353        // ── Accumulate bins into bars ────────────────────────────────────────
354        std::mem::swap(&mut self.spectrum, &mut self.prev_spectrum);
355        for bar in self.spectrum.iter_mut() {
356            *bar = 0.0;
357        }
358        for c in self.bar_counts.iter_mut() {
359            *c = 0;
360        }
361
362        let norm = self.fft_norm;
363        let db_range_inv = 1.0 / (DB_CEIL - DB_FLOOR);
364        let num_bins = self.fft_output.len().min(self.bin_to_bar.len());
365
366        for bin_idx in 0..num_bins {
367            let bar_idx = match self.bin_to_bar[bin_idx] {
368                Some(b) => b,
369                None => continue,
370            };
371            let c = self.fft_output[bin_idx];
372            let magnitude = (c.re * c.re + c.im * c.im).sqrt() * norm;
373            let mut db = if magnitude > 0.0 {
374                20.0 * magnitude.log10()
375            } else {
376                DB_FLOOR
377            };
378            // Apply A-weighting if using perceptual or aweight scale.
379            if matches!(
380                self.amplitude_scale,
381                AmplitudeScale::Perceptual | AmplitudeScale::AWeight
382            ) && let Some(&aw) = self.a_weight_table.get(bin_idx)
383            {
384                db += aw;
385            }
386            let level = ((db - DB_FLOOR) * db_range_inv).clamp(0.0, 1.0);
387            let level = self.amplitude_scale.apply(level);
388            if level > self.spectrum[bar_idx] {
389                self.spectrum[bar_idx] = level;
390            }
391            self.bar_counts[bar_idx] += 1;
392        }
393
394        self.fill_empty_bars();
395
396        // ── Time-based smoothing + peak hold ────────────────────────────────
397        let (bar_decay, peak_decay) = self.decay_factors();
398        for i in 0..NUM_BARS {
399            let decayed = self.prev_spectrum[i] * bar_decay;
400            self.spectrum[i] = self.spectrum[i].max(decayed);
401
402            if self.spectrum[i] > self.peaks[i] {
403                self.peaks[i] = self.spectrum[i];
404            } else {
405                self.peaks[i] *= peak_decay;
406            }
407        }
408
409        // ── Beat detection (low-band transient) ─────────────────────────────
410        // Sum the bottom ~6 bars (sub-bass through upper bass) as the beat signal.
411        let beat_bands = NUM_BARS.min(6);
412        let low_energy: f32 = self.spectrum[..beat_bands].iter().sum::<f32>() / beat_bands as f32;
413
414        // Slow EMA — alpha 0.02 gives ~50 frame memory at 60fps (~0.8s).
415        // This tracks the ambient bass level, not individual beats.
416        const BEAT_AVG_ALPHA: f32 = 0.02;
417        self.beat_avg = self.beat_avg * (1.0 - BEAT_AVG_ALPHA) + low_energy * BEAT_AVG_ALPHA;
418
419        // Beat = how far current energy exceeds the rolling average, normalized.
420        // The spike is scaled so that a 2x surge = 1.0 output.
421        let beat_spike = if self.beat_avg > 0.005 {
422            let excess = (low_energy - self.beat_avg).max(0.0);
423            (excess / self.beat_avg.max(0.05)).clamp(0.0, 1.0)
424        } else {
425            // No meaningful baseline yet — use raw energy as bootstrap.
426            (low_energy * 3.0).clamp(0.0, 1.0)
427        };
428
429        // Beat energy: rise instantly, decay slower than bars for a visible pulse.
430        // Using sqrt of bar_decay gives roughly double the half-life.
431        self.beat_energy = beat_spike.max(self.beat_energy * bar_decay.sqrt());
432    }
433
434    /// Fill bars that no FFT bin landed in.
435    ///
436    /// At high sample rates a 2048-point FFT spaces bins ~94 Hz apart, leaving
437    /// whole runs of the bottom Bark bars with no bin at all. Each run is
438    /// interpolated across its two *measured* neighbours in one pass, so a
439    /// synthesised bar is never used as an endpoint for the next one.
440    fn fill_empty_bars(&mut self) {
441        let mut i = 0;
442        while i < NUM_BARS {
443            if self.bar_counts[i] != 0 {
444                i += 1;
445                continue;
446            }
447            let mut end = i;
448            while end < NUM_BARS && self.bar_counts[end] == 0 {
449                end += 1;
450            }
451
452            match (i.checked_sub(1), (end < NUM_BARS).then_some(end)) {
453                (Some(left), Some(right)) => {
454                    let (lo, hi) = (self.spectrum[left], self.spectrum[right]);
455                    let span = (right - left) as f32;
456                    for (n, bar) in (i..end).enumerate() {
457                        let t = (n + 1) as f32 / span;
458                        self.spectrum[bar] = lo + (hi - lo) * t;
459                    }
460                }
461                // A run at either edge has one measured neighbour; extend it
462                // rather than fading the outermost bar toward an imaginary zero.
463                (Some(left), None) => {
464                    let value = self.spectrum[left];
465                    self.spectrum[i..end].fill(value);
466                }
467                (None, Some(right)) => {
468                    let value = self.spectrum[right];
469                    self.spectrum[i..end].fill(value);
470                }
471                (None, None) => self.spectrum.fill(0.0),
472            }
473
474            i = end;
475        }
476    }
477
478    /// Apply decay-to-silence (called when paused or no audio).
479    fn decay_silence(&mut self) {
480        let (bar_decay, peak_decay) = self.decay_factors();
481        for i in 0..NUM_BARS {
482            self.spectrum[i] *= bar_decay;
483            self.peaks[i] *= peak_decay;
484        }
485        for v in self.vu_levels.iter_mut() {
486            *v *= bar_decay;
487        }
488        self.beat_energy *= bar_decay;
489    }
490
491    /// Compute RMS VU levels per channel from the snapshot.
492    fn compute_vu(&mut self, samples: &[f32], channels: usize) {
493        let total_frames = samples.len() / channels;
494        let frames_to_use = total_frames.min(2048);
495        let frame_start = total_frames - frames_to_use;
496        let vu_channels = channels.min(2);
497        let mut sum_sq = [0.0f64; 2];
498
499        for frame in 0..frames_to_use {
500            let idx = (frame_start + frame) * channels;
501            for ch in 0..vu_channels {
502                if idx + ch < samples.len() {
503                    let s = samples[idx + ch] as f64;
504                    sum_sq[ch] += s * s;
505                }
506            }
507        }
508
509        let db_range = DB_CEIL - DB_FLOOR;
510        for (ch, &sq) in sum_sq.iter().enumerate().take(vu_channels) {
511            let rms = (sq / frames_to_use as f64).sqrt() as f32;
512            let db = if rms > 0.0 {
513                20.0 * rms.log10()
514            } else {
515                DB_FLOOR
516            };
517            self.vu_levels[ch] = ((db - DB_FLOOR) / db_range).clamp(0.0, 1.0);
518        }
519
520        if vu_channels == 1 {
521            self.vu_levels[1] = self.vu_levels[0];
522        }
523    }
524}
525
526// ── VizAnalyzer (public API) ─────────────────────────────────────────────────
527
528/// Background FFT analysis engine.
529///
530/// Call `VizAnalyzer::spawn_with_snapshot` to start the analysis thread. Drop
531/// the returned handle (or let it go out of scope) to request graceful
532/// shutdown; the thread exits within one analysis interval.
533pub struct VizAnalyzer {
534    running: Arc<AtomicBool>,
535    handle: Option<thread::JoinHandle<()>>,
536}
537
538impl VizAnalyzer {
539    /// Spawn the background analysis thread, writing each pass to `snapshot`.
540    ///
541    /// * `viz_buffer`     — the delay line written by the decode thread.
542    /// * `cfg`            — visualizer configuration (scale, decay times, fps).
543    /// * `snapshot`       — where each finished `VizFrame` is published.
544    /// * `samples_played` — the engine's played counter, used to read the delay
545    ///   line at the position currently reaching the DAC.
546    pub fn spawn_with_snapshot(
547        viz_buffer: Arc<VizBuffer>,
548        cfg: &VisualizerConfig,
549        snapshot: Arc<VizSnapshot>,
550        samples_played: Arc<AtomicU64>,
551    ) -> Self {
552        let running = Arc::new(AtomicBool::new(true));
553
554        let scale = FrequencyScale::parse(&cfg.scale);
555        let amplitude_scale = AmplitudeScale::parse(&cfg.amplitude_scale);
556        let bar_half_life = cfg.bar_decay_ms as f32 / 1000.0;
557        let peak_half_life = cfg.peak_decay_ms as f32 / 1000.0;
558        let interval = Duration::from_millis(1000 / cfg.fps.max(1) as u64);
559
560        let running_clone = Arc::clone(&running);
561
562        let handle = thread::Builder::new()
563            .name("viz-analyzer".into())
564            .spawn(move || {
565                analysis_loop(
566                    viz_buffer,
567                    snapshot,
568                    samples_played,
569                    running_clone,
570                    scale,
571                    amplitude_scale,
572                    bar_half_life,
573                    peak_half_life,
574                    interval,
575                );
576            })
577            .expect("failed to spawn viz-analyzer thread");
578
579        Self {
580            running,
581            handle: Some(handle),
582        }
583    }
584
585    /// Signal the background thread to stop and wait for it to exit.
586    pub fn shutdown(&mut self) {
587        self.running.store(false, Ordering::Relaxed);
588        if let Some(h) = self.handle.take() {
589            let _ = h.join();
590        }
591    }
592}
593
594impl Drop for VizAnalyzer {
595    fn drop(&mut self) {
596        self.shutdown();
597    }
598}
599
600// ── Analysis thread loop ─────────────────────────────────────────────────────
601
602/// Frames read from the delay line each pass: enough for both the FFT window
603/// and the widest waveform the UI draws.
604const WINDOW_FRAMES: usize = if FFT_SIZE > WAVEFORM_SAMPLES {
605    FFT_SIZE
606} else {
607    WAVEFORM_SAMPLES
608};
609
610#[allow(clippy::too_many_arguments)]
611fn analysis_loop(
612    viz_buffer: Arc<VizBuffer>,
613    snapshot: Arc<VizSnapshot>,
614    samples_played: Arc<AtomicU64>,
615    running: Arc<AtomicBool>,
616    scale: FrequencyScale,
617    amplitude_scale: AmplitudeScale,
618    bar_half_life: f32,
619    peak_half_life: f32,
620    interval: Duration,
621) {
622    let mut state = AnalysisState::new(scale, bar_half_life, peak_half_life, amplitude_scale);
623    let mut snap = RawVizSnapshot::default();
624
625    while running.load(Ordering::Relaxed) {
626        let start = Instant::now();
627
628        // ── Phase 1: read the delay line at the play head (lock held briefly) ─
629        let played = samples_played.load(Ordering::Relaxed);
630        viz_buffer.snapshot_at(played, WINDOW_FRAMES, &mut snap);
631
632        // ── Phase 2: compute (no lock held) ──────────────────────────────────
633        state.analyze(
634            &snap.samples,
635            snap.channels.max(1) as usize,
636            snap.sample_rate as f32,
637        );
638
639        // ── Phase 3: publish to VizSnapshot (RwLock write, <1us) ─────────────
640        // The tail of the window is the newest audible audio, which is what the
641        // oscilloscope and lissajous modes draw.
642        let interleaved_len = WAVEFORM_SAMPLES * snap.channels.max(1) as usize;
643        let waveform_start = snap.samples.len().saturating_sub(interleaved_len);
644        snapshot.write(VizFrame {
645            spectrum: state.spectrum,
646            peaks: state.peaks,
647            vu_levels: state.vu_levels,
648            beat_energy: state.beat_energy,
649            timestamp: Instant::now(),
650            waveform: snap.samples[waveform_start..].to_vec(),
651        });
652
653        // ── Sleep for the remainder of the interval ───────────────────────────
654        let elapsed = start.elapsed();
655        if elapsed < interval {
656            thread::sleep(interval - elapsed);
657        }
658    }
659}
660
661// ── Tests ────────────────────────────────────────────────────────────────────
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666    use crate::audio::viz::VizBuffer;
667    use crate::config::VisualizerConfig;
668
669    fn make_cfg() -> VisualizerConfig {
670        VisualizerConfig::default()
671    }
672
673    /// Interleaved stereo sine, `frames` long, at `freq` Hz.
674    fn sine(frames: usize, freq: f32, amplitude: f32, sample_rate: u32) -> Vec<f32> {
675        let mut samples = Vec::with_capacity(frames * 2);
676        for i in 0..frames {
677            let t = i as f32 / sample_rate as f32;
678            let val = (2.0 * std::f32::consts::PI * freq * t).sin() * amplitude;
679            samples.push(val);
680            samples.push(val);
681        }
682        samples
683    }
684
685    fn spawn_analyzer(
686        buf: Arc<VizBuffer>,
687        cfg: &VisualizerConfig,
688        played: u64,
689    ) -> (VizAnalyzer, Arc<VizSnapshot>) {
690        let snapshot = VizSnapshot::new();
691        let analyzer = VizAnalyzer::spawn_with_snapshot(
692            buf,
693            cfg,
694            Arc::clone(&snapshot),
695            Arc::new(AtomicU64::new(played)),
696        );
697        (analyzer, snapshot)
698    }
699
700    #[test]
701    fn analyzer_spawns_and_shuts_down() {
702        let buf = VizBuffer::new();
703        let cfg = make_cfg();
704        let (mut analyzer, snapshot) = spawn_analyzer(buf, &cfg, 0);
705        // Let it run for one cycle.
706        std::thread::sleep(Duration::from_millis(100));
707        analyzer.shutdown();
708        // The snapshot must still be readable after shutdown.
709        let frame = snapshot.read();
710        assert_eq!(frame.spectrum.len(), NUM_BARS);
711        assert_eq!(frame.peaks.len(), NUM_BARS);
712    }
713
714    #[test]
715    fn analyzer_produces_nonzero_output_for_sine() {
716        let buf = VizBuffer::new();
717        let sample_rate = 44100u32;
718        let samples = sine(4096, 440.0, 0.5, sample_rate);
719        buf.push_samples(&samples, 2, sample_rate);
720
721        let cfg = make_cfg();
722        // Everything pushed has been played, so the window sits at the head.
723        let (mut analyzer, snapshot) = spawn_analyzer(Arc::clone(&buf), &cfg, samples.len() as u64);
724        // Wait for at least two analysis passes.
725        std::thread::sleep(Duration::from_millis(150));
726
727        let frame = snapshot.read();
728        analyzer.shutdown();
729
730        let max_bar = frame.spectrum.iter().cloned().fold(0.0f32, f32::max);
731        assert!(
732            max_bar > 0.05,
733            "expected nonzero spectrum for 440 Hz sine, max = {}",
734            max_bar
735        );
736    }
737
738    #[test]
739    fn analyzer_reads_the_delay_line_at_the_play_head() {
740        let sample_rate = 44100u32;
741        let buf = VizBuffer::new();
742        // A second of silence is heard first; a tone is decoded far ahead of it.
743        buf.push_samples(&vec![0.0; sample_rate as usize * 2], 2, sample_rate);
744        buf.push_samples(&sine(4096, 440.0, 0.8, sample_rate), 2, sample_rate);
745
746        let cfg = make_cfg();
747        // The DAC is still inside the silence.
748        let (mut analyzer, snapshot) = spawn_analyzer(Arc::clone(&buf), &cfg, sample_rate as u64);
749        std::thread::sleep(Duration::from_millis(150));
750        let frame = snapshot.read();
751        analyzer.shutdown();
752
753        let max_bar = frame.spectrum.iter().cloned().fold(0.0f32, f32::max);
754        assert!(
755            max_bar < 0.05,
756            "visualizer showed audio the DAC has not reached yet, max = {}",
757            max_bar
758        );
759    }
760
761    #[test]
762    fn full_scale_sine_reads_zero_db() {
763        // Linear amplitude scale so no A-weighting shifts the level, and a
764        // bin-centred frequency so there is no scalloping loss to hide a
765        // wrong window gain.
766        let mut state =
767            AnalysisState::new(FrequencyScale::Bark, 0.08, 0.35, AmplitudeScale::Linear);
768        let sample_rate = 44100.0;
769        let freq = 46.0 * sample_rate / FFT_SIZE as f32;
770        let samples = sine(FFT_SIZE, freq, 1.0, sample_rate as u32);
771
772        state.analyze(&samples, 2, sample_rate);
773
774        // 0 dBFS maps to the top of the DB_FLOOR..DB_CEIL range.
775        let max_bar = state.spectrum.iter().cloned().fold(0.0f32, f32::max);
776        assert!(
777            max_bar > 0.98,
778            "full-scale sine should reach the top of the widget, got {}",
779            max_bar
780        );
781    }
782
783    #[test]
784    fn bark_bars_go_unmapped_at_high_sample_rates() {
785        // 192 kHz over a 2048-point FFT is 93.75 Hz per bin — too coarse for
786        // the bottom of the Bark scale, which is what makes interpolation
787        // load-bearing rather than cosmetic.
788        let mapping = build_bin_to_bar(192_000.0, FrequencyScale::Bark);
789        let mut counts = [0u32; NUM_BARS];
790        for bar in mapping.iter().flatten() {
791            counts[*bar] += 1;
792        }
793        assert_eq!(
794            counts[0], 0,
795            "no bin reaches the lowest Bark bar at 192 kHz"
796        );
797        assert!(
798            counts.iter().filter(|&&c| c == 0).count() > 3,
799            "expected several unmapped bass bars, got {:?}",
800            counts
801        );
802    }
803
804    #[test]
805    fn empty_bars_interpolate_without_sawtooth() {
806        let mut state =
807            AnalysisState::new(FrequencyScale::Bark, 0.08, 0.35, AmplitudeScale::Linear);
808        // A rising bass ramp measured only on the bars a 192 kHz FFT reaches.
809        for (n, &bar) in [1usize, 4, 6, 8].iter().enumerate() {
810            state.bar_counts[bar] = 1;
811            state.spectrum[bar] = 0.2 + 0.1 * n as f32;
812        }
813        for bar in 9..NUM_BARS {
814            state.bar_counts[bar] = 1;
815            state.spectrum[bar] = 0.5;
816        }
817
818        state.fill_empty_bars();
819
820        // Bar 0 has no measured neighbour below it, so it takes bar 1's level
821        // rather than half of it.
822        assert!((state.spectrum[0] - state.spectrum[1]).abs() < 1e-6);
823        for i in 0..8 {
824            assert!(
825                state.spectrum[i + 1] >= state.spectrum[i] - 1e-6,
826                "sawtooth across interpolated bass: {:?}",
827                &state.spectrum[..9]
828            );
829        }
830    }
831
832    #[test]
833    fn analysis_state_decays_to_zero_on_silence() {
834        // Use Linear amplitude scale — A-weighting can produce small residual
835        // levels from FFT numerical noise at boosted frequencies.
836        let mut state =
837            AnalysisState::new(FrequencyScale::Bark, 0.08, 0.35, AmplitudeScale::Linear);
838
839        // Seed some nonzero spectrum.
840        for v in state.spectrum.iter_mut() {
841            *v = 1.0;
842        }
843        for v in state.peaks.iter_mut() {
844            *v = 1.0;
845        }
846
847        // Simulate 100 frames of silence with 100ms gaps (10s total).
848        // peak_half_life = 350ms → need ~3.4 half-lives to reach < 0.1.
849        // Use 100ms offsets so decay is guaranteed even on fast machines where
850        // the real elapsed time between last_update and decay_factors() is tiny.
851        let silence: Vec<f32> = vec![0.0; FFT_SIZE * 2];
852        for _ in 0..100 {
853            state.last_update = Instant::now() - Duration::from_millis(100);
854            state.analyze(&silence, 2, 44100.0);
855        }
856
857        let max_spec = state.spectrum.iter().cloned().fold(0.0f32, f32::max);
858        let max_peak = state.peaks.iter().cloned().fold(0.0f32, f32::max);
859        assert!(
860            max_spec < 0.1,
861            "spectrum should decay near zero, got {}",
862            max_spec
863        );
864        assert!(
865            max_peak < 0.1,
866            "peaks should decay near zero, got {}",
867            max_peak
868        );
869    }
870
871    #[test]
872    fn bin_to_bar_covers_audible_range() {
873        let mapping = build_bin_to_bar(44100.0, FrequencyScale::Bark);
874        let active_bins: Vec<usize> = mapping.iter().filter_map(|x| *x).collect();
875        assert!(
876            !active_bins.is_empty(),
877            "at least some bins should map to bars"
878        );
879        let max_bar = *active_bins.iter().max().unwrap();
880        assert!(max_bar < NUM_BARS, "bar index must be in range");
881    }
882
883    #[test]
884    fn frequency_scale_bark_normalize_monotonic() {
885        let scale = FrequencyScale::Bark;
886        let freqs: Vec<f32> = vec![100.0, 500.0, 1000.0, 4000.0, 10000.0];
887        let normed: Vec<f32> = freqs.iter().map(|&f| scale.normalize(f)).collect();
888        for w in normed.windows(2) {
889            assert!(w[1] > w[0], "Bark scale must be monotonically increasing");
890        }
891    }
892}