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