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}