Skip to main content

ff_preview/audio/
mod.rs

1//! Multi-track audio mixer for real-time preview.
2//!
3//! [`AudioMixer`] combines `N` mono tracks into a single interleaved stereo
4//! `f32` output at 48 kHz. Per-track volume and pan are controlled from any
5//! thread via the cloneable [`AudioTrackHandle`].
6
7use std::collections::VecDeque;
8use std::f32::consts;
9use std::sync::atomic::{AtomicU32, Ordering};
10use std::sync::{Arc, Mutex};
11
12// ── AudioTrack (private) ──────────────────────────────────────────────────────
13
14struct AudioTrack {
15    buf: Arc<Mutex<VecDeque<f32>>>,
16    volume: Arc<AtomicU32>,
17    pan: Arc<AtomicU32>,
18}
19
20// ── AudioTrackHandle ──────────────────────────────────────────────────────────
21
22/// Cloneable handle for filling a track and adjusting its gain from any thread.
23///
24/// Obtained by calling [`AudioMixer::add_track`]. All methods are lock-free
25/// on the hot path (volume/pan reads) and only lock for buffer access.
26#[derive(Clone)]
27pub struct AudioTrackHandle {
28    buf: Arc<Mutex<VecDeque<f32>>>,
29    volume: Arc<AtomicU32>,
30    pan: Arc<AtomicU32>,
31}
32
33impl AudioTrackHandle {
34    /// Set per-track volume. Clamped to `[0.0, 1.0]`.
35    pub fn set_volume(&self, v: f32) {
36        self.volume
37            .store(v.clamp(0.0, 1.0).to_bits(), Ordering::Relaxed);
38    }
39
40    /// Set stereo pan. Clamped to `[-1.0` (full left) `.. +1.0` (full right)`]`.
41    pub fn set_pan(&self, p: f32) {
42        self.pan
43            .store(p.clamp(-1.0, 1.0).to_bits(), Ordering::Relaxed);
44    }
45
46    /// Push decoded mono PCM samples into the track buffer.
47    ///
48    /// Called by the background audio-decode thread. The samples should be
49    /// `f32` mono at 48 kHz (i.e., one value per time step).
50    pub fn push_samples(&self, samples: &[f32]) {
51        self.buf
52            .lock()
53            .unwrap_or_else(std::sync::PoisonError::into_inner)
54            .extend(samples.iter().copied());
55    }
56
57    /// Number of samples currently buffered.
58    ///
59    /// Used by background audio threads to implement back-pressure.
60    #[cfg(feature = "timeline")]
61    pub(crate) fn buffered_samples(&self) -> usize {
62        self.buf
63            .lock()
64            .unwrap_or_else(std::sync::PoisonError::into_inner)
65            .len()
66    }
67
68    /// Drain all buffered samples.
69    ///
70    /// Called on seek to discard audio that is no longer relevant.
71    #[cfg(feature = "timeline")]
72    pub(crate) fn clear(&self) {
73        self.buf
74            .lock()
75            .unwrap_or_else(std::sync::PoisonError::into_inner)
76            .clear();
77    }
78}
79
80// ── AudioMixer ────────────────────────────────────────────────────────────────
81
82/// Multi-track, constant-power-panned stereo mixer.
83///
84/// Combines `N` mono tracks into a single interleaved stereo `f32` output at
85/// 48 kHz.  Per-track volume and pan adjustments take effect on the next call
86/// to [`mix`](Self::mix).
87///
88/// # Pan law
89///
90/// For a pan position `p ∈ [-1.0, +1.0]`:
91/// ```text
92/// p_norm = (p + 1.0) * π / 4
93/// l_gain = volume * cos(p_norm)
94/// r_gain = volume * sin(p_norm)
95/// ```
96/// At `p = 0` (center): `l_gain == r_gain ≈ 0.707 × volume` (constant-power
97/// law — equal loudness in both ears).
98///
99/// # Example
100///
101/// ```ignore
102/// let mut mixer = AudioMixer::new(48_000);
103/// let track = mixer.add_track();
104///
105/// // Background audio-decode thread:
106/// track.push_samples(&mono_pcm_chunk);
107///
108/// // Audio-device output callback:
109/// let stereo = mixer.mix(output_buf.len());
110/// output_buf[..stereo.len()].copy_from_slice(&stereo);
111/// ```
112pub struct AudioMixer {
113    tracks: Vec<AudioTrack>,
114    /// Output sample rate in Hz.
115    pub sample_rate: u32,
116    /// Number of output channels — always 2 (stereo).
117    pub channels: u16,
118}
119
120impl AudioMixer {
121    /// Create a new mixer with no tracks.
122    #[must_use]
123    pub fn new(sample_rate: u32) -> Self {
124        Self {
125            tracks: Vec::new(),
126            sample_rate,
127            channels: 2,
128        }
129    }
130
131    /// Add a new mono track and return a cloneable handle.
132    ///
133    /// The track starts with `volume = 1.0` and `pan = 0.0` (center).
134    pub fn add_track(&mut self) -> AudioTrackHandle {
135        let buf = Arc::new(Mutex::new(VecDeque::new()));
136        let volume = Arc::new(AtomicU32::new(1.0_f32.to_bits()));
137        let pan = Arc::new(AtomicU32::new(0.0_f32.to_bits()));
138        let handle = AudioTrackHandle {
139            buf: Arc::clone(&buf),
140            volume: Arc::clone(&volume),
141            pan: Arc::clone(&pan),
142        };
143        self.tracks.push(AudioTrack { buf, volume, pan });
144        handle
145    }
146
147    /// Mix `n_samples` interleaved stereo `f32` values from all tracks.
148    ///
149    /// `n_samples` is the total number of `f32` elements to produce (L + R
150    /// interleaved). Tracks with insufficient buffered data are zero-padded.
151    /// The output is clipped to `[-1.0, 1.0]`.
152    #[allow(clippy::cast_precision_loss)]
153    pub fn mix(&mut self, n_samples: usize) -> Vec<f32> {
154        let n_frames = n_samples / 2;
155        let mut out = vec![0.0_f32; n_frames * 2];
156
157        for track in &self.tracks {
158            let volume = f32::from_bits(track.volume.load(Ordering::Relaxed));
159            let pan = f32::from_bits(track.pan.load(Ordering::Relaxed));
160
161            // Constant-power pan law.
162            let p_norm = (pan + 1.0) * consts::FRAC_PI_4;
163            let l_gain = volume * p_norm.cos();
164            let r_gain = volume * p_norm.sin();
165
166            let mut guard = track
167                .buf
168                .lock()
169                .unwrap_or_else(std::sync::PoisonError::into_inner);
170            for i in 0..n_frames {
171                let s = guard.pop_front().unwrap_or(0.0);
172                out[i * 2] += s * l_gain;
173                out[i * 2 + 1] += s * r_gain;
174            }
175        }
176
177        // Clip to [-1.0, 1.0].
178        for sample in &mut out {
179            *sample = sample.clamp(-1.0, 1.0);
180        }
181
182        out
183    }
184
185    /// Drain all track buffers.
186    ///
187    /// Called on seek to discard stale audio across all tracks.
188    #[cfg(feature = "timeline")]
189    pub(crate) fn invalidate_all(&mut self) {
190        for track in &self.tracks {
191            track
192                .buf
193                .lock()
194                .unwrap_or_else(std::sync::PoisonError::into_inner)
195                .clear();
196        }
197    }
198}
199
200// ── Tests ─────────────────────────────────────────────────────────────────────
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn audio_mixer_mix_two_tracks_should_sum_and_clip_left_channel() {
208        // Two tracks, full-left pan (l_gain = volume = 1.0), amplitude 0.8.
209        // Without clipping: L = 0.8 + 0.8 = 1.6. After clip: 1.0.
210        let mut mixer = AudioMixer::new(48_000);
211        let t1 = mixer.add_track();
212        let t2 = mixer.add_track();
213        t1.set_pan(-1.0);
214        t2.set_pan(-1.0);
215        t1.push_samples(&[0.8, 0.8]);
216        t2.push_samples(&[0.8, 0.8]);
217
218        let out = mixer.mix(4); // 2 stereo frames
219        assert_eq!(out.len(), 4);
220        assert!(
221            (out[0] - 1.0).abs() < 1e-6,
222            "L must clip to 1.0; got {}",
223            out[0]
224        );
225        assert!(
226            out[1].abs() < 1e-6,
227            "R must be 0.0 for full-left pan; got {}",
228            out[1]
229        );
230    }
231
232    #[test]
233    fn audio_mixer_pan_full_left_should_produce_zero_right_channel() {
234        let mut mixer = AudioMixer::new(48_000);
235        let track = mixer.add_track();
236        track.set_pan(-1.0);
237        track.push_samples(&[0.5, 0.5, 0.5, 0.5]);
238
239        let out = mixer.mix(8); // 4 stereo frames
240        assert_eq!(out.len(), 8);
241        for i in (1..8usize).step_by(2) {
242            assert!(
243                out[i].abs() < 1e-6,
244                "R channel must be 0.0 for full-left pan; got {} at index {i}",
245                out[i]
246            );
247        }
248    }
249
250    #[test]
251    fn audio_mixer_pan_full_right_should_produce_zero_left_channel() {
252        let mut mixer = AudioMixer::new(48_000);
253        let track = mixer.add_track();
254        track.set_pan(1.0);
255        track.push_samples(&[0.5, 0.5, 0.5, 0.5]);
256
257        let out = mixer.mix(8);
258        for i in (0..8usize).step_by(2) {
259            assert!(
260                out[i].abs() < 1e-6,
261                "L channel must be 0.0 for full-right pan; got {} at index {i}",
262                out[i]
263            );
264        }
265    }
266
267    #[test]
268    fn audio_mixer_two_tracks_volume_sum_exceeding_one_should_be_clipped() {
269        // Two tracks, volume 0.7, full-left pan, amplitude 0.8.
270        // L = 0.8 * 0.7 + 0.8 * 0.7 = 1.12 > 1.0 → clipped to 1.0.
271        let mut mixer = AudioMixer::new(48_000);
272        let t1 = mixer.add_track();
273        let t2 = mixer.add_track();
274        t1.set_volume(0.7);
275        t2.set_volume(0.7);
276        t1.set_pan(-1.0);
277        t2.set_pan(-1.0);
278        t1.push_samples(&[0.8, 0.8]);
279        t2.push_samples(&[0.8, 0.8]);
280
281        let out = mixer.mix(4);
282        for &s in &out {
283            assert!(
284                s >= -1.0 && s <= 1.0,
285                "all output must be within [-1.0, 1.0]; got {s}"
286            );
287        }
288    }
289
290    #[test]
291    fn audio_mixer_center_pan_should_apply_constant_power_law() {
292        // At pan = 0.0: p_norm = π/4, cos = sin = 1/√2 ≈ 0.7071.
293        let mut mixer = AudioMixer::new(48_000);
294        let track = mixer.add_track();
295        // pan = 0 (center) by default, volume = 1.0 by default.
296        track.push_samples(&[1.0]);
297
298        let out = mixer.mix(2); // 1 stereo frame
299        let expected = (std::f32::consts::FRAC_PI_4).cos(); // ≈ 0.7071
300        assert!(
301            (out[0] - expected).abs() < 1e-5,
302            "L at center should be cos(π/4) ≈ {expected:.5}; got {}",
303            out[0]
304        );
305        assert!(
306            (out[1] - expected).abs() < 1e-5,
307            "R at center should be sin(π/4) ≈ {expected:.5}; got {}",
308            out[1]
309        );
310    }
311
312    #[test]
313    fn audio_mixer_underrun_should_zero_pad_remaining_frames() {
314        let mut mixer = AudioMixer::new(48_000);
315        let track = mixer.add_track();
316        track.set_pan(-1.0); // full left for determinism
317        track.push_samples(&[0.5]); // only one sample, but we request 4 frames
318
319        let out = mixer.mix(8);
320        assert_eq!(out.len(), 8);
321
322        // Frames 1-3 must be zero (underrun).
323        for i in 2..8 {
324            assert_eq!(out[i], 0.0, "underrun frame must be silent; got {}", out[i]);
325        }
326    }
327
328    #[test]
329    fn audio_mixer_empty_tracks_should_produce_silence() {
330        let mut mixer = AudioMixer::new(48_000);
331        let _track = mixer.add_track();
332        let out = mixer.mix(8);
333        assert_eq!(out.len(), 8);
334        assert!(
335            out.iter().all(|&s| s == 0.0),
336            "empty track must produce silence"
337        );
338    }
339
340    #[cfg(feature = "timeline")]
341    #[test]
342    fn audio_mixer_invalidate_all_should_clear_all_buffers() {
343        let mut mixer = AudioMixer::new(48_000);
344        let t1 = mixer.add_track();
345        let t2 = mixer.add_track();
346        t1.push_samples(&[0.5, 0.5]);
347        t2.push_samples(&[0.5, 0.5]);
348
349        mixer.invalidate_all();
350
351        let out = mixer.mix(4);
352        assert!(
353            out.iter().all(|&s| s == 0.0),
354            "after invalidate_all, mix must be silent"
355        );
356    }
357
358    #[test]
359    fn audio_track_handle_set_volume_should_clamp_to_zero_one() {
360        let mut mixer = AudioMixer::new(48_000);
361        let track = mixer.add_track();
362        track.set_volume(2.0); // should clamp to 1.0
363        track.push_samples(&[1.0]);
364        let out = mixer.mix(2);
365        // With volume clamped to 1.0 and center pan, L = cos(π/4) ≈ 0.707.
366        assert!(
367            out[0] <= 1.0,
368            "volume clamped to 1.0 must not exceed gain 1.0"
369        );
370    }
371
372    #[cfg(feature = "timeline")]
373    #[test]
374    fn audio_track_handle_clear_should_drain_buffered_samples() {
375        let mut mixer = AudioMixer::new(48_000);
376        let track = mixer.add_track();
377        track.push_samples(&[0.5, 0.5, 0.5, 0.5]);
378        assert_eq!(track.buffered_samples(), 4);
379        track.clear();
380        assert_eq!(
381            track.buffered_samples(),
382            0,
383            "clear() must drain all samples"
384        );
385    }
386}