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// ── Tests ─────────────────────────────────────────────────────────────────────
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn audio_mixer_mix_two_tracks_should_sum_and_clip_left_channel() {
212        // Two tracks, full-left pan (l_gain = volume = 1.0), amplitude 0.8.
213        // Without clipping: L = 0.8 + 0.8 = 1.6. After clip: 1.0.
214        let mut mixer = AudioMixer::new(48_000);
215        let t1 = mixer.add_track();
216        let t2 = mixer.add_track();
217        t1.set_pan(-1.0);
218        t2.set_pan(-1.0);
219        t1.push_samples(&[0.8, 0.8]);
220        t2.push_samples(&[0.8, 0.8]);
221
222        let out = mixer.mix(4); // 2 stereo frames
223        assert_eq!(out.len(), 4);
224        assert!(
225            (out[0] - 1.0).abs() < 1e-6,
226            "L must clip to 1.0; got {}",
227            out[0]
228        );
229        assert!(
230            out[1].abs() < 1e-6,
231            "R must be 0.0 for full-left pan; got {}",
232            out[1]
233        );
234    }
235
236    #[test]
237    fn audio_mixer_pan_full_left_should_produce_zero_right_channel() {
238        let mut mixer = AudioMixer::new(48_000);
239        let track = mixer.add_track();
240        track.set_pan(-1.0);
241        track.push_samples(&[0.5, 0.5, 0.5, 0.5]);
242
243        let out = mixer.mix(8); // 4 stereo frames
244        assert_eq!(out.len(), 8);
245        for i in (1..8usize).step_by(2) {
246            assert!(
247                out[i].abs() < 1e-6,
248                "R channel must be 0.0 for full-left pan; got {} at index {i}",
249                out[i]
250            );
251        }
252    }
253
254    #[test]
255    fn audio_mixer_pan_full_right_should_produce_zero_left_channel() {
256        let mut mixer = AudioMixer::new(48_000);
257        let track = mixer.add_track();
258        track.set_pan(1.0);
259        track.push_samples(&[0.5, 0.5, 0.5, 0.5]);
260
261        let out = mixer.mix(8);
262        for i in (0..8usize).step_by(2) {
263            assert!(
264                out[i].abs() < 1e-6,
265                "L channel must be 0.0 for full-right pan; got {} at index {i}",
266                out[i]
267            );
268        }
269    }
270
271    #[test]
272    fn audio_mixer_two_tracks_volume_sum_exceeding_one_should_be_clipped() {
273        // Two tracks, volume 0.7, full-left pan, amplitude 0.8.
274        // L = 0.8 * 0.7 + 0.8 * 0.7 = 1.12 > 1.0 → clipped to 1.0.
275        let mut mixer = AudioMixer::new(48_000);
276        let t1 = mixer.add_track();
277        let t2 = mixer.add_track();
278        t1.set_volume(0.7);
279        t2.set_volume(0.7);
280        t1.set_pan(-1.0);
281        t2.set_pan(-1.0);
282        t1.push_samples(&[0.8, 0.8]);
283        t2.push_samples(&[0.8, 0.8]);
284
285        let out = mixer.mix(4);
286        for &s in &out {
287            assert!(
288                s >= -1.0 && s <= 1.0,
289                "all output must be within [-1.0, 1.0]; got {s}"
290            );
291        }
292    }
293
294    #[test]
295    fn audio_mixer_center_pan_should_apply_constant_power_law() {
296        // At pan = 0.0: p_norm = π/4, cos = sin = 1/√2 ≈ 0.7071.
297        let mut mixer = AudioMixer::new(48_000);
298        let track = mixer.add_track();
299        // pan = 0 (center) by default, volume = 1.0 by default.
300        track.push_samples(&[1.0]);
301
302        let out = mixer.mix(2); // 1 stereo frame
303        let expected = (std::f32::consts::FRAC_PI_4).cos(); // ≈ 0.7071
304        assert!(
305            (out[0] - expected).abs() < 1e-5,
306            "L at center should be cos(π/4) ≈ {expected:.5}; got {}",
307            out[0]
308        );
309        assert!(
310            (out[1] - expected).abs() < 1e-5,
311            "R at center should be sin(π/4) ≈ {expected:.5}; got {}",
312            out[1]
313        );
314    }
315
316    #[test]
317    fn audio_mixer_underrun_should_zero_pad_remaining_frames() {
318        let mut mixer = AudioMixer::new(48_000);
319        let track = mixer.add_track();
320        track.set_pan(-1.0); // full left for determinism
321        track.push_samples(&[0.5]); // only one sample, but we request 4 frames
322
323        let out = mixer.mix(8);
324        assert_eq!(out.len(), 8);
325
326        // Frames 1-3 must be zero (underrun).
327        for i in 2..8 {
328            assert_eq!(out[i], 0.0, "underrun frame must be silent; got {}", out[i]);
329        }
330    }
331
332    #[test]
333    fn audio_mixer_empty_tracks_should_produce_silence() {
334        let mut mixer = AudioMixer::new(48_000);
335        let _track = mixer.add_track();
336        let out = mixer.mix(8);
337        assert_eq!(out.len(), 8);
338        assert!(
339            out.iter().all(|&s| s == 0.0),
340            "empty track must produce silence"
341        );
342    }
343
344    #[cfg(feature = "timeline")]
345    #[test]
346    fn audio_mixer_invalidate_all_should_clear_all_buffers() {
347        let mut mixer = AudioMixer::new(48_000);
348        let t1 = mixer.add_track();
349        let t2 = mixer.add_track();
350        t1.push_samples(&[0.5, 0.5]);
351        t2.push_samples(&[0.5, 0.5]);
352
353        mixer.invalidate_all();
354
355        let out = mixer.mix(4);
356        assert!(
357            out.iter().all(|&s| s == 0.0),
358            "after invalidate_all, mix must be silent"
359        );
360    }
361
362    #[test]
363    fn audio_track_handle_set_volume_above_one_should_amplify() {
364        let mut mixer = AudioMixer::new(48_000);
365        let track = mixer.add_track();
366        track.set_volume(2.0);
367        track.set_pan(-1.0); // full left for determinism
368        track.push_samples(&[0.4]);
369        let out = mixer.mix(2); // 1 stereo frame
370        // L = 0.4 * 2.0 = 0.8 (below clip threshold).
371        assert!(
372            (out[0] - 0.8).abs() < 1e-5,
373            "volume 2.0 should amplify to 0.8; got {}",
374            out[0]
375        );
376    }
377
378    #[test]
379    fn audio_track_handle_set_negative_volume_should_be_silent() {
380        let mut mixer = AudioMixer::new(48_000);
381        let track = mixer.add_track();
382        track.set_volume(-1.0); // clamped to 0.0
383        track.push_samples(&[1.0]);
384        let out = mixer.mix(2);
385        assert!(
386            out.iter().all(|&s| s.abs() < 1e-6),
387            "negative volume must be silent"
388        );
389    }
390
391    #[cfg(feature = "timeline")]
392    #[test]
393    fn audio_track_handle_clear_should_drain_buffered_samples() {
394        let mut mixer = AudioMixer::new(48_000);
395        let track = mixer.add_track();
396        track.push_samples(&[0.5, 0.5, 0.5, 0.5]);
397        assert_eq!(track.buffered_samples(), 4);
398        track.clear();
399        assert_eq!(
400            track.buffered_samples(),
401            0,
402            "clear() must drain all samples"
403        );
404    }
405}