Skip to main content

forge_audio/
viz_buffer.rs

1//! AudioVizBuffer — lock-free shared memory between audio and render threads.
2//!
3//! Audio thread writes PCM samples, RMS, peak, phase. Render thread reads
4//! for waveform, VU meters, phase display. Zero mutex, zero allocation.
5//!
6
7
8use std::sync::atomic::{AtomicU32, AtomicU64, AtomicU8, Ordering};
9
10/// Atomic f32 wrapper (store/load via u32 bit pattern).
11pub struct AtomicF32(AtomicU32);
12
13impl AtomicF32 {
14    pub fn new(v: f32) -> Self { Self(AtomicU32::new(v.to_bits())) }
15    pub fn load(&self) -> f32 { f32::from_bits(self.0.load(Ordering::Relaxed)) }
16    pub fn store(&self, v: f32) { self.0.store(v.to_bits(), Ordering::Relaxed); }
17}
18
19/// Single-producer single-consumer lock-free ring buffer.
20/// Audio thread pushes, render thread reads latest. Lossy on overflow.
21pub struct AtomicRingBuffer {
22    buf: Box<[f32]>,
23    capacity: usize,
24    head: AtomicU64, // write position (audio thread)
25}
26
27impl AtomicRingBuffer {
28    pub fn new(capacity: usize) -> Self {
29        let cap = capacity.next_power_of_two().max(256);
30        Self {
31            buf: vec![0.0f32; cap].into_boxed_slice(),
32            capacity: cap,
33            head: AtomicU64::new(0),
34        }
35    }
36
37    /// Push samples. Audio thread only. No allocation.
38    pub fn push_slice(&self, data: &[f32]) {
39        let head = self.head.load(Ordering::Relaxed) as usize;
40        let mask = self.capacity - 1;
41        for (i, &sample) in data.iter().enumerate() {
42            let idx = (head + i) & mask;
43            // Safety: single producer, idx always in bounds
44            unsafe {
45                let ptr = self.buf.as_ptr() as *mut f32;
46                ptr.add(idx).write(sample);
47            }
48        }
49        self.head.store((head + data.len()) as u64, Ordering::Relaxed);
50    }
51
52    /// Read the most recent `count` samples. Render thread only.
53    pub fn read_latest(&self, count: usize, out: &mut [f32]) -> usize {
54        let head = self.head.load(Ordering::Relaxed) as usize;
55        let n = count.min(self.capacity).min(out.len());
56        let mask = self.capacity - 1;
57        let start = if head >= n { head - n } else { 0 };
58        let actual = head.saturating_sub(start);
59        for i in 0..actual {
60            let idx = (start + i) & mask;
61            out[i] = self.buf[idx];
62        }
63        actual
64    }
65
66    pub fn capacity(&self) -> usize { self.capacity }
67}
68
69/// Shared visualization buffer between audio and render threads.
70pub struct AudioVizBuffer {
71    /// PCM stereo interleaved samples.
72    pub samples: AtomicRingBuffer,
73    /// FFT magnitude bins.
74    pub fft_bins: AtomicRingBuffer,
75    /// RMS levels (300ms integration window).
76    pub rms_left: AtomicF32,
77    pub rms_right: AtomicF32,
78    /// Peak levels (1.7s hold, then decay).
79    pub peak_left: AtomicF32,
80    pub peak_right: AtomicF32,
81    /// Phase correlation coefficient (-1.0 to 1.0).
82    pub phase_correlation: AtomicF32,
83    /// Audio thread clock (microseconds since start).
84    pub audio_clock_us: AtomicU64,
85    /// Game tick clock (microseconds since start).
86    pub game_tick_us: AtomicU64,
87    /// Active lane indicator (0=game, 1=mixer, 2=file).
88    pub active_lane: AtomicU8,
89    /// Buffer underrun counter.
90    pub underrun_count: AtomicU32,
91}
92
93impl AudioVizBuffer {
94    pub fn new(sample_capacity: usize, fft_capacity: usize) -> Self {
95        Self {
96            samples: AtomicRingBuffer::new(sample_capacity),
97            fft_bins: AtomicRingBuffer::new(fft_capacity),
98            rms_left: AtomicF32::new(0.0),
99            rms_right: AtomicF32::new(0.0),
100            peak_left: AtomicF32::new(0.0),
101            peak_right: AtomicF32::new(0.0),
102            phase_correlation: AtomicF32::new(0.0),
103            audio_clock_us: AtomicU64::new(0),
104            game_tick_us: AtomicU64::new(0),
105            active_lane: AtomicU8::new(0),
106            underrun_count: AtomicU32::new(0),
107        }
108    }
109
110    /// Compute RMS from the last `window` stereo interleaved samples.
111    /// Called by audio thread after writing samples.
112    pub fn compute_and_store_rms(&self, window: usize) {
113        let n = window * 2; // stereo
114        let mut buf = vec![0.0f32; n];
115        let read = self.samples.read_latest(n, &mut buf);
116        if read < 2 { return; }
117        let (mut sum_l, mut sum_r) = (0.0f64, 0.0f64);
118        let mut count = 0usize;
119        for chunk in buf[..read].chunks_exact(2) {
120            sum_l += (chunk[0] as f64) * (chunk[0] as f64);
121            sum_r += (chunk[1] as f64) * (chunk[1] as f64);
122            count += 1;
123        }
124        if count > 0 {
125            self.rms_left.store((sum_l / count as f64).sqrt() as f32);
126            self.rms_right.store((sum_r / count as f64).sqrt() as f32);
127        }
128    }
129
130    /// Update peak hold with decay. Called by audio thread.
131    pub fn update_peak_hold(&self) {
132        const DECAY: f32 = 0.0003; // ~20dB/s at 200Hz callback
133        let rms_l = self.rms_left.load();
134        let rms_r = self.rms_right.load();
135        let cur_l = self.peak_left.load();
136        let cur_r = self.peak_right.load();
137        self.peak_left.store(if rms_l > cur_l { rms_l } else { (cur_l - DECAY).max(0.0) });
138        self.peak_right.store(if rms_r > cur_r { rms_r } else { (cur_r - DECAY).max(0.0) });
139    }
140}
141
142/// Sync health status from audio↔game clock drift.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum SyncStatus { Green, Yellow, Red }
145
146/// Compute sync status from viz buffer clocks.
147pub fn sync_health(viz: &AudioVizBuffer) -> SyncStatus {
148    let audio = viz.audio_clock_us.load(Ordering::Relaxed);
149    let game = viz.game_tick_us.load(Ordering::Relaxed);
150    let drift = audio.abs_diff(game);
151    if drift < 10_000 { SyncStatus::Green }
152    else if drift < 20_000 { SyncStatus::Yellow }
153    else { SyncStatus::Red }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn ring_buffer_push_read() {
162        let rb = AtomicRingBuffer::new(16);
163        rb.push_slice(&[1.0, 2.0, 3.0, 4.0]);
164        let mut out = [0.0f32; 4];
165        let n = rb.read_latest(4, &mut out);
166        assert_eq!(n, 4);
167        assert_eq!(out, [1.0, 2.0, 3.0, 4.0]);
168    }
169
170    #[test]
171    fn ring_buffer_wraps() {
172        let rb = AtomicRingBuffer::new(4); // capacity rounds to 4
173        rb.push_slice(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); // wraps
174        let mut out = [0.0f32; 4];
175        let n = rb.read_latest(4, &mut out);
176        assert_eq!(n, 4);
177        // Should have latest 4: 3.0, 4.0, 5.0, 6.0
178        assert_eq!(out[2], 5.0);
179        assert_eq!(out[3], 6.0);
180    }
181
182    #[test]
183    fn atomic_f32_roundtrip() {
184        let a = AtomicF32::new(0.42);
185        assert!((a.load() - 0.42).abs() < 1e-6);
186        a.store(0.99);
187        assert!((a.load() - 0.99).abs() < 1e-6);
188    }
189
190    #[test]
191    fn viz_buffer_rms() {
192        let viz = AudioVizBuffer::new(1024, 256);
193        // Push stereo silence
194        let silence = vec![0.0f32; 200];
195        viz.samples.push_slice(&silence);
196        viz.compute_and_store_rms(100);
197        assert_eq!(viz.rms_left.load(), 0.0);
198        assert_eq!(viz.rms_right.load(), 0.0);
199    }
200
201    #[test]
202    fn viz_buffer_peak_decay() {
203        let viz = AudioVizBuffer::new(1024, 256);
204        viz.peak_left.store(0.5);
205        viz.rms_left.store(0.1); // below peak
206        viz.update_peak_hold();
207        assert!(viz.peak_left.load() < 0.5); // decayed
208    }
209
210    #[test]
211    fn sync_health_green() {
212        let viz = AudioVizBuffer::new(256, 64);
213        viz.audio_clock_us.store(1_000_000, Ordering::Relaxed);
214        viz.game_tick_us.store(1_000_500, Ordering::Relaxed);
215        assert_eq!(sync_health(&viz), SyncStatus::Green);
216    }
217
218    #[test]
219    fn sync_health_red() {
220        let viz = AudioVizBuffer::new(256, 64);
221        viz.audio_clock_us.store(1_000_000, Ordering::Relaxed);
222        viz.game_tick_us.store(1_030_000, Ordering::Relaxed);
223        assert_eq!(sync_health(&viz), SyncStatus::Red);
224    }
225}