tauri_plugin_system_audio/mixer.rs
1// Sample-and-hold mixer for time-aligning mic + loopback streams.
2//
3// Background:
4// On Windows we capture from two independent WASAPI streams (the user's
5// mic + the default render device put into loopback mode). Each stream
6// has its own period (CPAL `BufferSize::Default`), so even when both are
7// nominally 16kHz mono post-resample, the rate they hand frames to our
8// drain loop drifts. The 100ms aligning step in `capture.rs:run` can
9// dequeue a 100ms mic frame paired with a "loopback frame that's actually
10// 90ms of old samples + 10ms of empty waiting". Adding them together
11// produces a phasey mix and bleeds mic-on-loopback into the AEC reference
12// — AEC3 then over-cancels real mic content because it sees a delayed
13// echo of itself in the reference.
14//
15// On macOS, ScreenCaptureKit-based pipelines don't need this:
16// SCStream buffers + timestamps every CMSampleBuffer, and AVAudioEngine's
17// tap shares the same audio HAL clock. CPAL has no such cross-stream sync, so we have to fake it.
18//
19// Fix: a small ring buffer holds the most recent ~10ms of each input.
20// When `mix()` is called and one side is shorter than the other (because
21// its WASAPI period hasn't elapsed yet), we pad with the most-recent
22// captured sample (sample-and-hold) instead of zero. Removes the
23// alias-by-zero-crossing artifact while staying simple.
24//
25// Output gains:
26// * mic_gain / loopback_gain default 0.7 each — leaves ~6dB of summed
27// headroom even with both streams near full-scale. Tunable per session.
28// * The summed `i16` sample is clamped to [-32768, 32767]; we do NOT
29// soft-clip with `tanh` because the downstream STT models train on
30// hard-clipped data anyway and tanh adds harmonic distortion.
31//
32// The output buffer is owned by the caller via `mix_into` (writes into a
33// reusable `Vec<i16>` to avoid per-frame heap churn in the 10Hz capture
34// loop). The convenience `mix(...) -> Vec<i16>` form exists for tests.
35
36/// One 10ms APM frame at 16kHz. Drift past this threshold counts as
37/// "macro-misalignment" (one stream skipped a beat); below it is normal
38/// jitter between WASAPI periods.
39const HOLD_SAMPLES: usize = 160;
40
41pub struct Mixer {
42 pub mic_gain: f32,
43 pub loopback_gain: f32,
44 last_mic: Option<i16>,
45 last_loopback: Option<i16>,
46 /// Counts iterations where mic vs loopback length difference exceeded
47 /// `HOLD_SAMPLES`. Callers can read this to decide whether to nudge
48 /// the APM stream delay (drift > 250ms beyond seed risks AEC3
49 /// divergence — see `APM_PLAYBACK_DELAY_MS`).
50 pub drift_frames: u64,
51}
52
53impl Default for Mixer {
54 fn default() -> Self {
55 Self {
56 mic_gain: 0.7,
57 loopback_gain: 0.7,
58 last_mic: None,
59 last_loopback: None,
60 drift_frames: 0,
61 }
62 }
63}
64
65impl Mixer {
66 /// Allocate a fresh output buffer. Convenience for tests; the hot
67 /// capture loop should use `mix_into` to recycle a `Vec<i16>`.
68 /// `#[allow(dead_code)]` because production callers always use
69 /// `mix_into` — keeping the alloc'ing helper for test ergonomics.
70 #[allow(dead_code)]
71 pub fn mix(&mut self, mic: &[i16], loopback: &[i16]) -> Vec<i16> {
72 let mut out = Vec::with_capacity(mic.len().max(loopback.len()));
73 self.mix_into(mic, loopback, &mut out);
74 out
75 }
76
77 /// Mix mic + loopback into the caller-supplied `out` buffer. Resizes
78 /// `out` to `max(mic.len(), loopback.len())` and writes per-sample
79 /// `(mic[i]*g_mic + loopback[i]*g_lp)` with sample-and-hold padding
80 /// on the shorter side. Reusing `out` between calls is the entire
81 /// point — the audio thread runs at 10Hz and cannot afford a 6.4KB
82 /// alloc per tick.
83 pub fn mix_into(&mut self, mic: &[i16], loopback: &[i16], out: &mut Vec<i16>) {
84 let mic_gain = self.mic_gain;
85 let lp_gain = self.loopback_gain;
86 let len = mic.len().max(loopback.len());
87
88 // Track drift for the next mix call's sample-and-hold tail.
89 if let Some(&last) = mic.last() {
90 self.last_mic = Some(last);
91 }
92 if let Some(&last) = loopback.last() {
93 self.last_loopback = Some(last);
94 }
95 let mic_short = len.saturating_sub(mic.len());
96 let lp_short = len.saturating_sub(loopback.len());
97 if mic_short > HOLD_SAMPLES || lp_short > HOLD_SAMPLES {
98 self.drift_frames = self.drift_frames.wrapping_add(1);
99 }
100
101 let mic_hold = self.last_mic.unwrap_or(0);
102 let lp_hold = self.last_loopback.unwrap_or(0);
103
104 out.clear();
105 out.reserve(len);
106 for i in 0..len {
107 let a = mic.get(i).copied().unwrap_or(mic_hold) as f32 * mic_gain;
108 let b = loopback.get(i).copied().unwrap_or(lp_hold) as f32 * lp_gain;
109 let sum = (a + b).clamp(i16::MIN as f32, i16::MAX as f32);
110 out.push(sum as i16);
111 }
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 #[test]
120 fn sample_and_hold_when_loopback_short() {
121 let mut mixer = Mixer::default();
122 // First mix establishes the "last value" for both streams.
123 let _ = mixer.mix(&vec![100i16; 160], &vec![500i16; 160]);
124 // Now mic delivers a full frame but loopback only 80 samples.
125 let mic = vec![100i16; 160];
126 let lp = vec![500i16; 80];
127 let out = mixer.mix(&mic, &lp);
128 assert_eq!(out.len(), 160);
129 // The 80 trailing samples should use the held loopback value (500),
130 // not zero — so the additive mix on tail samples is (100+500)*.7.
131 let expected = ((100.0f32 + 500.0) * 0.7).round() as i16;
132 assert_eq!(out[100], expected);
133 assert_eq!(out[159], expected);
134 }
135
136 #[test]
137 fn drift_counter_increments_on_large_misalignment() {
138 let mut mixer = Mixer::default();
139 // 100ms mic vs only 32 samples of loopback — short by 1568, way
140 // over HOLD_SAMPLES (160).
141 let out = mixer.mix(&vec![0i16; 1600], &vec![0i16; 32]);
142 assert_eq!(out.len(), 1600);
143 assert_eq!(mixer.drift_frames, 1);
144 }
145
146 /// Below-threshold drift (≤ HOLD_SAMPLES) should NOT count, because
147 /// it's normal WASAPI period jitter, not a real desync.
148 #[test]
149 fn drift_counter_silent_on_jitter() {
150 let mut mixer = Mixer::default();
151 // 1600 vs 1500 — short by 100, well under HOLD_SAMPLES (160).
152 let _ = mixer.mix(&vec![0i16; 1600], &vec![0i16; 1500]);
153 assert_eq!(mixer.drift_frames, 0);
154 // Repeat — still no drift.
155 let _ = mixer.mix(&vec![0i16; 1600], &vec![0i16; 1550]);
156 assert_eq!(mixer.drift_frames, 0);
157 }
158
159 /// Empty loopback (Mock mode, mic-only) — mix should still produce
160 /// the mic frame, hold falls back to 0, additive becomes mic*gain.
161 #[test]
162 fn empty_loopback_yields_mic_only() {
163 let mut mixer = Mixer::default();
164 let mic = vec![1000i16; 1600];
165 let out = mixer.mix(&mic, &[]);
166 assert_eq!(out.len(), 1600);
167 let expected = (1000.0 * 0.7) as i16;
168 assert!(
169 out.iter().all(|&s| (s - expected).abs() <= 1),
170 "all samples should be ~700"
171 );
172 }
173
174 /// Clip protection — gains of 0.7 + full-scale i16 = ~22937 each ×2 =
175 /// 45874 → clamp to 32767. No wrap.
176 #[test]
177 fn additive_mix_clamps_at_i16_max() {
178 let mut mixer = Mixer::default();
179 let mic = vec![i16::MAX; 100];
180 let lp = vec![i16::MAX; 100];
181 let out = mixer.mix(&mic, &lp);
182 // 0.7 * 32767 + 0.7 * 32767 = 45874 → clamp to 32767.
183 for &s in &out {
184 assert_eq!(s, i16::MAX);
185 }
186 }
187
188 /// `mix_into` reuses the output buffer — verify it's resized in-place
189 /// and old contents are overwritten (not appended).
190 #[test]
191 fn mix_into_reuses_buffer() {
192 let mut mixer = Mixer::default();
193 let mut out = vec![999i16; 100]; // pre-existing garbage
194 let mic = vec![10i16; 1600];
195 let lp = vec![20i16; 1600];
196 mixer.mix_into(&mic, &lp, &mut out);
197 assert_eq!(out.len(), 1600);
198 // First sample should be ~21 (10*.7 + 20*.7 = 21), NOT 999.
199 let expected = ((10.0 + 20.0) * 0.7) as i16;
200 assert_eq!(out[0], expected);
201 // Capacity should be preserved/grown — no shrink.
202 assert!(out.capacity() >= 1600);
203 }
204
205 /// Drift counter should wrap rather than panic on overflow. Set it
206 /// near u64::MAX and confirm one more drift event doesn't crash.
207 #[test]
208 fn drift_counter_wraps_safely() {
209 let mut mixer = Mixer::default();
210 mixer.drift_frames = u64::MAX;
211 let _ = mixer.mix(&vec![0i16; 1600], &vec![0i16; 0]);
212 assert_eq!(mixer.drift_frames, 0); // wrapping_add(1) on MAX = 0
213 }
214}