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