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