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