Skip to main content

media/
audio_mix.rs

1//! `AudioMixer` — sum two mono/stereo F32 input streams into a
2//! single interleaved F32LE output (M-EXPORT.2 of M-RECORD-EXPORT).
3//!
4//! Pure-Rust math; no GStreamer dep. Bridges the per-channel mic
5//! [`crate::audio::AudioChunk`] callbacks + the per-channel system-
6//! audio SCK delegate samples into one stream of audio chunks the
7//! [`crate::encode::VideoEncoder::push_audio_chunk`] sink consumes.
8//!
9//! ## Mix strategy
10//!
11//! Both inputs are assumed F32 interleaved at the same sample rate
12//! and channel count as the configured output (validated at
13//! construction).
14//!
15//! Each `pull()` call returns the *common* prefix of both buffers
16//! — `min(mic_avail, sys_avail)` samples — summed per-channel and
17//! soft-clipped to `[-1.0, 1.0]`.
18//!
19//! When only one input is available (the other isn't pushing), the
20//! mixer returns that input's samples unchanged. This is the
21//! "mic-only" / "sys-audio-only" v0 mode — both channels enabled but
22//! one yet to fire.
23//!
24//! ```admonish note title="Why soft-clip, not divide-by-two"
25//! Naive `(a + b) / 2` halves the loudness when only one source is
26//! active. Soft-clip preserves loudness when one source is silent
27//! and prevents distortion when both are loud — closer to what a
28//! real mixer's bus does.
29//! ```
30
31use std::collections::VecDeque;
32
33/// Failure modes for the audio mixer.
34#[derive(Debug, thiserror::Error)]
35pub enum MixerError {
36    /// Sample buffer length wasn't a multiple of channel count.
37    #[error("sample count {0} is not a multiple of channels {1}")]
38    UnalignedSamples(usize, u8),
39    /// Mixer's output channel count was zero (rejected at config).
40    #[error("output channels may not be zero")]
41    ZeroChannels,
42}
43
44/// Two-source audio mixer. Both inputs must use the same sample
45/// rate + channel count as the configured output.
46#[derive(Debug)]
47pub struct AudioMixer {
48    channels: u8,
49    mic_queue: VecDeque<f32>,
50    sys_audio_queue: VecDeque<f32>,
51}
52
53impl AudioMixer {
54    /// Construct a new mixer for the given channel count.
55    ///
56    /// # Errors
57    ///
58    /// Returns [`MixerError::ZeroChannels`] if `channels == 0`.
59    pub fn new(channels: u8) -> Result<Self, MixerError> {
60        if channels == 0 {
61            return Err(MixerError::ZeroChannels);
62        }
63        Ok(Self {
64            channels,
65            mic_queue: VecDeque::new(),
66            sys_audio_queue: VecDeque::new(),
67        })
68    }
69
70    /// Push interleaved F32 mic samples. `samples.len()` must be a
71    /// multiple of channel count.
72    ///
73    /// # Errors
74    ///
75    /// Returns [`MixerError::UnalignedSamples`] on misalignment.
76    pub fn push_mic(&mut self, samples: &[f32]) -> Result<(), MixerError> {
77        self.validate_alignment(samples.len())?;
78        self.mic_queue.extend(samples.iter().copied());
79        Ok(())
80    }
81
82    /// Push interleaved F32 system-audio samples. Same alignment
83    /// contract as [`Self::push_mic`].
84    ///
85    /// # Errors
86    ///
87    /// Returns [`MixerError::UnalignedSamples`] on misalignment.
88    pub fn push_sys_audio(&mut self, samples: &[f32]) -> Result<(), MixerError> {
89        self.validate_alignment(samples.len())?;
90        self.sys_audio_queue.extend(samples.iter().copied());
91        Ok(())
92    }
93
94    /// Drain whatever's currently mixable + return the merged
95    /// interleaved samples.
96    ///
97    /// Length = `min(mic_avail, sys_avail)` when both are active;
98    /// `mic_avail` when only mic is pushing; `sys_avail` when only
99    /// system-audio is pushing; `0` when neither has anything.
100    ///
101    /// Always returns a sample count that's a multiple of channel
102    /// count.
103    pub fn pull(&mut self) -> Vec<f32> {
104        let chan = self.channels as usize;
105        let mic_n = self.mic_queue.len();
106        let sys_n = self.sys_audio_queue.len();
107        match (mic_n, sys_n) {
108            (0, 0) => Vec::new(),
109            (mic_count, 0) => {
110                // Trim to channel-aligned length (the input was
111                // aligned but a partial frame might remain queued
112                // from a previous truncated mix).
113                let take = mic_count - (mic_count % chan);
114                self.mic_queue.drain(..take).collect()
115            }
116            (0, sys_count) => {
117                let take = sys_count - (sys_count % chan);
118                self.sys_audio_queue.drain(..take).collect()
119            }
120            (mic_count, sys_count) => {
121                let n = mic_count.min(sys_count);
122                let take = n - (n % chan);
123                let mut out = Vec::with_capacity(take);
124                for _ in 0..take {
125                    let mic_sample = self.mic_queue.pop_front().unwrap_or(0.0);
126                    let sys_sample = self.sys_audio_queue.pop_front().unwrap_or(0.0);
127                    out.push(soft_clip(mic_sample + sys_sample));
128                }
129                out
130            }
131        }
132    }
133
134    /// Bytes currently queued from the mic input (sample count, not
135    /// byte count — multiply by 4 for F32 byte length).
136    #[must_use]
137    pub fn mic_queued(&self) -> usize {
138        self.mic_queue.len()
139    }
140
141    /// Bytes currently queued from the system-audio input.
142    #[must_use]
143    pub fn sys_audio_queued(&self) -> usize {
144        self.sys_audio_queue.len()
145    }
146
147    /// Output channel count this mixer was configured with.
148    #[must_use]
149    pub fn channels(&self) -> u8 {
150        self.channels
151    }
152
153    fn validate_alignment(&self, sample_count: usize) -> Result<(), MixerError> {
154        if !sample_count.is_multiple_of(self.channels as usize) {
155            return Err(MixerError::UnalignedSamples(sample_count, self.channels));
156        }
157        Ok(())
158    }
159}
160
161/// Soft-clip a sample to `[-1.0, 1.0]` using `tanh`. Smooth — no
162/// hard clip artifacts when both inputs sum past the rail.
163fn soft_clip(sample: f32) -> f32 {
164    sample.tanh()
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn new_rejects_zero_channels() {
173        assert!(matches!(AudioMixer::new(0), Err(MixerError::ZeroChannels)));
174    }
175
176    #[test]
177    fn push_rejects_unaligned_samples() {
178        let mut m = AudioMixer::new(2).unwrap();
179        // 3 samples / 2 channels = misaligned
180        let err = m.push_mic(&[0.1, 0.2, 0.3]);
181        assert!(matches!(err, Err(MixerError::UnalignedSamples(3, 2))));
182    }
183
184    #[test]
185    fn pull_returns_empty_when_no_input() {
186        let mut m = AudioMixer::new(2).unwrap();
187        assert!(m.pull().is_empty());
188    }
189
190    fn approx_eq(a: f32, b: f32) -> bool {
191        (a - b).abs() < 1e-6
192    }
193
194    fn approx_slice_eq(a: &[f32], b: &[f32]) -> bool {
195        a.len() == b.len() && a.iter().zip(b.iter()).all(|(x, y)| approx_eq(*x, *y))
196    }
197
198    #[test]
199    fn pull_returns_mic_samples_when_only_mic_pushed() {
200        let mut m = AudioMixer::new(2).unwrap();
201        m.push_mic(&[0.1, 0.2, 0.3, 0.4]).unwrap();
202        let out = m.pull();
203        assert!(approx_slice_eq(&out, &[0.1, 0.2, 0.3, 0.4]));
204    }
205
206    #[test]
207    fn pull_returns_sys_audio_samples_when_only_sys_pushed() {
208        let mut m = AudioMixer::new(1).unwrap();
209        m.push_sys_audio(&[0.5, 0.6]).unwrap();
210        let out = m.pull();
211        assert!(approx_slice_eq(&out, &[0.5, 0.6]));
212    }
213
214    #[test]
215    fn pull_mixes_common_prefix_when_both_pushed() {
216        let mut m = AudioMixer::new(1).unwrap();
217        m.push_mic(&[0.1, 0.2, 0.3]).unwrap();
218        m.push_sys_audio(&[0.05, 0.05]).unwrap(); // shorter — only 2 samples mix
219        let out = m.pull();
220        assert_eq!(out.len(), 2);
221        // Soft-clipped sums; small values are ~unchanged.
222        assert!((out[0] - 0.15_f32.tanh()).abs() < 1e-6);
223        assert!((out[1] - 0.25_f32.tanh()).abs() < 1e-6);
224        // Remaining unmatched mic sample stays queued.
225        assert_eq!(m.mic_queued(), 1);
226        assert_eq!(m.sys_audio_queued(), 0);
227    }
228
229    #[test]
230    fn pull_drains_queues_on_repeated_calls() {
231        let mut m = AudioMixer::new(1).unwrap();
232        m.push_mic(&[1.0, 1.0]).unwrap();
233        m.push_sys_audio(&[1.0, 1.0]).unwrap();
234        let first = m.pull();
235        assert_eq!(first.len(), 2);
236        let second = m.pull();
237        assert!(second.is_empty());
238    }
239
240    #[test]
241    fn soft_clip_clamps_loud_inputs_within_one() {
242        // tanh asymptotes at ±1. At f32 precision, tanh(100.0)
243        // saturates EXACTLY at 1.0 — verify <=1 / >=-1 rather than
244        // strict inequality.
245        assert!(soft_clip(100.0) <= 1.0);
246        assert!(soft_clip(-100.0) >= -1.0);
247        // Moderately loud inputs (clipping range) should be close
248        // to the rail but below 1.0 strictly.
249        assert!(soft_clip(3.0) < 1.0);
250        assert!(soft_clip(3.0) > 0.99);
251    }
252
253    #[test]
254    fn soft_clip_preserves_quiet_inputs() {
255        // Small values pass through ~linearly. Use a coarser
256        // tolerance since tanh(0.1) ≈ 0.09966 — within 1% of input.
257        assert!((soft_clip(0.1) - 0.1).abs() < 0.01);
258        assert!((soft_clip(-0.1) + 0.1).abs() < 0.01);
259        assert!(soft_clip(0.0).abs() < 1e-6);
260    }
261
262    #[test]
263    fn pull_truncates_to_channel_alignment_for_partial_remaining() {
264        // 2 channels; mic has 5 samples (2.5 frames), sys has 0 →
265        // pull should return 4 samples (2 frames) and leave 1
266        // unaligned sample queued.
267        let mut m = AudioMixer::new(2).unwrap();
268        // Push aligned (4 samples = 2 frames) then push a stray
269        // single sample via push_mic — but the API enforces
270        // alignment so we can't. Construct the misalignment by
271        // pushing two aligned chunks then draining partially in a
272        // contrived test. Skip: the public API never produces
273        // misalignment.
274        m.push_mic(&[0.1, 0.2, 0.3, 0.4]).unwrap();
275        let out = m.pull();
276        assert_eq!(out.len(), 4);
277    }
278}