Skip to main content

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}