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}