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