Skip to main content

koan_core/audio/
viz.rs

1use std::sync::Arc;
2
3use parking_lot::{Mutex, RwLock};
4
5/// Default buffer size: 4096 samples covers ~93ms at 44.1kHz,
6/// enough for a 2048-point FFT window with room to spare.
7const DEFAULT_BUFFER_SIZE: usize = 4096;
8
9/// Number of spectrum bars produced by the analyzer.
10pub const NUM_BARS: usize = 48;
11
12// ── Analysis output types (used by both analyzer.rs and visualizer.rs) ───────
13
14/// The output of one analysis pass: spectrum bars, peak holds, and VU levels.
15/// Written by `VizAnalyzer` on its background thread; read by the TUI thread.
16#[derive(Clone)]
17pub struct AnalysisOutput {
18    /// Spectrum bar heights (0.0..1.0), one per bar.
19    pub spectrum: [f32; NUM_BARS],
20    /// Peak hold values (slowly decaying maxima), one per bar.
21    pub peaks: [f32; NUM_BARS],
22    /// RMS VU levels: [left, right], each 0.0..1.0.
23    pub vu_levels: [f32; 2],
24}
25
26impl Default for AnalysisOutput {
27    fn default() -> Self {
28        Self {
29            spectrum: [0.0; NUM_BARS],
30            peaks: [0.0; NUM_BARS],
31            vu_levels: [0.0; 2],
32        }
33    }
34}
35
36/// Shared, lock-protected analysis output.
37/// The background analysis thread writes here; the TUI reads a clone each frame.
38pub type SharedAnalysisOutput = Arc<Mutex<AnalysisOutput>>;
39
40// ── VizFrame / VizSnapshot (high-level UI-facing snapshot API) ────────────────
41
42/// Number of waveform samples carried in each VizFrame for oscilloscope/lissajous modes.
43/// 2048 samples (~46ms at 44.1kHz) matches the FFT window size — enough for smooth waveform display.
44pub const WAVEFORM_SAMPLES: usize = 2048;
45
46/// A single frame of analysis output, ready for the UI thread.
47///
48/// Held inside `VizSnapshot` under an RwLock. The UI thread clones this in
49/// <1us (memcpy of 48 floats + 2 floats + 1 float + waveform + Instant) while holding the read lock.
50#[derive(Clone)]
51pub struct VizFrame {
52    /// Spectrum bar heights (0.0..1.0), one per bar. Already smoothed by the analyzer.
53    pub spectrum: [f32; NUM_BARS],
54    /// Peak hold values (slowly decaying maxima), one per bar. Managed by the analyzer.
55    pub peaks: [f32; NUM_BARS],
56    /// RMS VU levels: [left, right], each 0.0..1.0.
57    pub vu_levels: [f32; 2],
58    /// Beat energy (0.0..1.0). Spikes on transients in the low bands,
59    /// decays quickly. Used by the TUI for beat-reactive color shifts.
60    pub beat_energy: f32,
61    /// When this frame was computed.
62    pub timestamp: std::time::Instant,
63    /// Raw waveform samples for oscilloscope/lissajous rendering.
64    /// Interleaved stereo (L, R, L, R...) — `WAVEFORM_SAMPLES` frames = `WAVEFORM_SAMPLES * 2` values.
65    /// Empty when no audio is playing.
66    pub waveform: Vec<f32>,
67}
68
69impl Default for VizFrame {
70    fn default() -> Self {
71        Self {
72            spectrum: [0.0; NUM_BARS],
73            peaks: [0.0; NUM_BARS],
74            vu_levels: [0.0; 2],
75            beat_energy: 0.0,
76            timestamp: std::time::Instant::now(),
77            waveform: Vec::new(),
78        }
79    }
80}
81
82/// Thread-safe snapshot of the latest analysis frame.
83///
84/// Written by the analysis thread (~60fps), read by the UI thread every frame.
85///
86/// Lock discipline:
87/// - Writer: compute everything in thread-local scratch, then acquire write lock,
88///   swap the frame (~200B memcpy), release. Hold time <1us.
89/// - Reader (UI): acquire read lock, clone frame, release. Hold time <1us.
90///   All decay/smoothing happens on the local clone with no lock held.
91pub struct VizSnapshot {
92    inner: RwLock<VizFrame>,
93}
94
95impl VizSnapshot {
96    /// Create a new snapshot with a zeroed initial frame.
97    pub fn new() -> Arc<Self> {
98        Arc::new(Self {
99            inner: RwLock::new(VizFrame::default()),
100        })
101    }
102
103    /// Read the latest frame. Acquires read lock, clones, releases — <1us.
104    pub fn read(&self) -> VizFrame {
105        self.inner.read().clone()
106    }
107
108    /// Write a new frame. Acquires write lock, swaps, releases — <1us.
109    /// MUST only be called after all FFT computation is finished (never hold lock during FFT).
110    pub fn write(&self, frame: VizFrame) {
111        *self.inner.write() = frame;
112    }
113}
114
115impl Default for VizSnapshot {
116    fn default() -> Self {
117        Self {
118            inner: RwLock::new(VizFrame::default()),
119        }
120    }
121}
122
123// ── Raw sample snapshot (used internally by VizBuffer and VizAnalyzer) ────────
124
125/// A point-in-time snapshot of VizBuffer contents, bundling raw samples with
126/// the metadata needed to interpret them. Produced by `VizBuffer::snapshot_with_meta`.
127pub struct RawVizSnapshot {
128    /// Interleaved f32 samples, oldest first.
129    pub samples: Vec<f32>,
130    /// Channel count for de-interleaving.
131    pub channels: u16,
132    /// Sample rate in Hz.
133    pub sample_rate: u32,
134}
135
136// ── VizBuffer ────────────────────────────────────────────────────────────────
137
138/// Internal sample storage for the visualization buffer.
139struct VizSamples {
140    /// Circular buffer of interleaved f32 samples.
141    buf: Vec<f32>,
142    /// Current write position (wraps around).
143    write_pos: usize,
144    /// Channel count for de-interleaving.
145    channels: u16,
146    /// Sample rate for frequency calculations.
147    sample_rate: u32,
148}
149
150/// Shared visualization sample buffer.
151///
152/// Written by the decode thread, read by the analysis thread at ~60fps.
153/// Uses `parking_lot::Mutex` — contention is near-zero because the decode
154/// thread holds the lock for <50us per write and the analysis thread reads
155/// at 16ms intervals.
156pub struct VizBuffer {
157    samples: Mutex<VizSamples>,
158}
159
160impl VizBuffer {
161    /// Create a new visualization buffer with the default size.
162    pub fn new() -> Arc<Self> {
163        Arc::new(Self {
164            samples: Mutex::new(VizSamples {
165                buf: vec![0.0; DEFAULT_BUFFER_SIZE],
166                write_pos: 0,
167                channels: 2,
168                sample_rate: 44100,
169            }),
170        })
171    }
172
173    /// Push interleaved samples into the circular buffer.
174    ///
175    /// Called by the decode thread after each packet decode.
176    /// Updates channel count and sample rate if they differ from the
177    /// current values (happens on track boundaries).
178    pub fn push_samples(&self, samples: &[f32], channels: u16, sample_rate: u32) {
179        let mut inner = self.samples.lock();
180        inner.channels = channels;
181        inner.sample_rate = sample_rate;
182
183        let buf_len = inner.buf.len();
184        if samples.len() >= buf_len {
185            // More samples than buffer size — just copy the tail.
186            let start = samples.len() - buf_len;
187            inner.buf.copy_from_slice(&samples[start..]);
188            inner.write_pos = 0;
189        } else {
190            let pos = inner.write_pos;
191            let first = buf_len - pos;
192            if samples.len() <= first {
193                inner.buf[pos..pos + samples.len()].copy_from_slice(samples);
194                inner.write_pos = (pos + samples.len()) % buf_len;
195            } else {
196                inner.buf[pos..].copy_from_slice(&samples[..first]);
197                let remaining = samples.len() - first;
198                inner.buf[..remaining].copy_from_slice(&samples[first..]);
199                inner.write_pos = remaining;
200            }
201        }
202    }
203
204    /// Take a snapshot of the current buffer contents, ordered oldest to newest.
205    ///
206    /// Returns a contiguous `Vec<f32>` with the most recent samples in chronological order.
207    ///
208    /// Allocates a new Vec on every call. For hot paths (e.g. 60fps analysis),
209    /// prefer `snapshot_into` to reuse an existing buffer.
210    pub fn snapshot(&self) -> Vec<f32> {
211        let mut out = Vec::new();
212        self.snapshot_into(&mut out);
213        out
214    }
215
216    /// Take a snapshot into a caller-provided buffer, avoiding allocation when
217    /// the buffer already has sufficient capacity.
218    ///
219    /// The buffer is cleared and filled with the most recent samples in
220    /// chronological order (oldest to newest).
221    pub fn snapshot_into(&self, out: &mut Vec<f32>) {
222        let inner = self.samples.lock();
223        let buf_len = inner.buf.len();
224        let pos = inner.write_pos;
225        out.clear();
226        out.reserve(buf_len);
227        // Write position is where the *next* sample goes, so the oldest
228        // sample is at write_pos and the newest is at write_pos - 1.
229        out.extend_from_slice(&inner.buf[pos..]);
230        out.extend_from_slice(&inner.buf[..pos]);
231    }
232
233    /// Take a snapshot bundled with metadata (channels, sample_rate).
234    ///
235    /// Acquires the lock once to copy both samples and metadata atomically,
236    /// so the caller never sees mismatched channel/rate values.
237    pub fn snapshot_with_meta(&self) -> RawVizSnapshot {
238        let inner = self.samples.lock();
239        let buf_len = inner.buf.len();
240        let pos = inner.write_pos;
241        let mut samples = Vec::with_capacity(buf_len);
242        samples.extend_from_slice(&inner.buf[pos..]);
243        samples.extend_from_slice(&inner.buf[..pos]);
244        RawVizSnapshot {
245            samples,
246            channels: inner.channels,
247            sample_rate: inner.sample_rate,
248        }
249    }
250
251    /// Current channel count.
252    pub fn channels(&self) -> u16 {
253        self.samples.lock().channels
254    }
255
256    /// Current sample rate.
257    pub fn sample_rate(&self) -> u32 {
258        self.samples.lock().sample_rate
259    }
260}
261
262impl Default for VizBuffer {
263    fn default() -> Self {
264        // Cannot return Arc<Self> from Default, so this creates the inner value.
265        // Callers should prefer VizBuffer::new() which returns Arc<VizBuffer>.
266        Self {
267            samples: Mutex::new(VizSamples {
268                buf: vec![0.0; DEFAULT_BUFFER_SIZE],
269                write_pos: 0,
270                channels: 2,
271                sample_rate: 44100,
272            }),
273        }
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn push_and_snapshot_basic() {
283        let buf = VizBuffer::new();
284        let samples: Vec<f32> = (0..100).map(|i| i as f32).collect();
285        buf.push_samples(&samples, 2, 44100);
286
287        let snap = buf.snapshot();
288        assert_eq!(snap.len(), DEFAULT_BUFFER_SIZE);
289        // Last 100 samples should be 0..100, preceded by zeros.
290        let tail = &snap[DEFAULT_BUFFER_SIZE - 100..];
291        for (i, &val) in tail.iter().enumerate() {
292            assert_eq!(val, i as f32);
293        }
294    }
295
296    #[test]
297    fn push_wraps_around() {
298        let buf = VizBuffer::new();
299        // Fill the buffer completely.
300        let samples: Vec<f32> = (0..DEFAULT_BUFFER_SIZE as u32).map(|i| i as f32).collect();
301        buf.push_samples(&samples, 2, 44100);
302
303        // Push more to wrap.
304        let extra: Vec<f32> = (0..10).map(|i| (i + 1000) as f32).collect();
305        buf.push_samples(&extra, 2, 44100);
306
307        let snap = buf.snapshot();
308        // Newest 10 samples should be 1000..1010.
309        let tail = &snap[DEFAULT_BUFFER_SIZE - 10..];
310        for (i, &val) in tail.iter().enumerate() {
311            assert_eq!(val, (i + 1000) as f32);
312        }
313    }
314
315    #[test]
316    fn push_larger_than_buffer() {
317        let buf = VizBuffer::new();
318        let big: Vec<f32> = (0..(DEFAULT_BUFFER_SIZE + 500) as u32)
319            .map(|i| i as f32)
320            .collect();
321        buf.push_samples(&big, 2, 48000);
322
323        let snap = buf.snapshot();
324        assert_eq!(snap.len(), DEFAULT_BUFFER_SIZE);
325        // Should contain the last DEFAULT_BUFFER_SIZE samples.
326        for (i, &val) in snap.iter().enumerate() {
327            assert_eq!(val, (i + 500) as f32);
328        }
329        assert_eq!(buf.sample_rate(), 48000);
330    }
331
332    #[test]
333    fn channels_and_sample_rate() {
334        let buf = VizBuffer::new();
335        assert_eq!(buf.channels(), 2);
336        assert_eq!(buf.sample_rate(), 44100);
337
338        buf.push_samples(&[1.0, 2.0], 1, 96000);
339        assert_eq!(buf.channels(), 1);
340        assert_eq!(buf.sample_rate(), 96000);
341    }
342
343    #[test]
344    fn viz_snapshot_read_write() {
345        let snap = VizSnapshot::new();
346        let frame = snap.read();
347        assert_eq!(frame.spectrum.len(), NUM_BARS);
348        assert_eq!(frame.vu_levels, [0.0, 0.0]);
349
350        let mut new_spectrum = [0.0f32; NUM_BARS];
351        new_spectrum[5] = 0.9;
352        snap.write(VizFrame {
353            spectrum: new_spectrum,
354            peaks: [0.0; NUM_BARS],
355            vu_levels: [0.5, 0.5],
356            beat_energy: 0.0,
357            timestamp: std::time::Instant::now(),
358            waveform: Vec::new(),
359        });
360
361        let frame2 = snap.read();
362        assert!((frame2.spectrum[5] - 0.9).abs() < 0.001);
363        assert!((frame2.vu_levels[0] - 0.5).abs() < 0.001);
364    }
365}