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