Skip to main content

tauri_plugin_system_audio/
resampler.rs

1// Mono-or-mixdown linear resampler with an anti-alias LPF on downsample.
2//
3// Operates on Float32 samples in nominal [-1, 1] range — matches the
4// cpal F32 input format and the WebRTC APM's expected sample type. Keeping
5// the entire pipeline in f32 (input → resample → APM → final i16 conversion
6// only at the serialisation boundary) avoids two precision-eating
7// i16↔f32 round trips that the macOS / Windows-native paths don't pay.
8//
9// Quality is acceptable for STT (which itself does FE/MFCC and tolerates
10// moderate resampling artifacts). Swap for cubic / sinc if your workload
11// needs more.
12//
13// The pre-filter is a 21-tap windowed-sinc (Hamming) FIR low-pass with
14// cutoff at 0.4 × Nyquist of the **output** rate (the same spectral guard
15// Core Audio's `AudioConverterRef` applies). Without this filter, downsampling 48kHz → 16kHz
16// folds the 6-24kHz band back into the 0-6kHz speech band as alias noise
17// and audibly hurts STT WER on plosives + fricatives. 21 taps + Hamming
18// window gives ~50dB stop-band rejection at the cost of ~600µs/100ms frame
19// per channel.
20//
21// Stereo → mono: take channel 0 only. Many Windows USB mics expose a mono
22// element as "stereo" with L=R; equal-power sum 0.707×(L+R) on correlated
23// channels = 1.414×L which clamps, while arithmetic average 0.5×(L+R) is
24// fine but introduces no benefit over picking L. For real stereo content
25// (rare in STT input — desktop mics) we lose the R channel info, which is
26// an acceptable trade for STT accuracy on the common case. For >2 channels
27// we average.
28
29// 129-tap windowed-sinc — bumped from 21 because STT misrecognised
30// fricatives/sibilants (/s/ /sh/ → smeared) under the wider transition band
31// the shorter kernel produced on 48k→16k downsample. Group delay is still
32// only ~1.3ms (64 samples / 48kHz) — negligible for streaming STT.
33const FILTER_TAPS: usize = 129;
34
35pub struct Linear {
36    in_rate: u32,
37    out_rate: u32,
38    in_channels: u16,
39    out_channels: u8,
40    in_pos: f64,
41    last_sample: f32,
42    // FIR state used only when downsampling. Coefficients are precomputed
43    // in `new()`; `history` is a circular buffer with length FILTER_TAPS.
44    fir_coeffs: Option<[f32; FILTER_TAPS]>,
45    fir_history: Vec<f32>,
46    fir_write_idx: usize,
47}
48
49impl Linear {
50    pub fn new(in_rate: u32, out_rate: u32, in_channels: u16, out_channels: u8) -> Self {
51        let needs_lpf = in_rate > out_rate;
52        let fir_coeffs = if needs_lpf {
53            Some(design_lpf(in_rate, out_rate))
54        } else {
55            None
56        };
57        Self {
58            in_rate,
59            out_rate,
60            in_channels,
61            out_channels,
62            in_pos: 0.0,
63            last_sample: 0.0,
64            fir_coeffs,
65            fir_history: vec![0.0; FILTER_TAPS],
66            fir_write_idx: 0,
67        }
68    }
69
70    pub fn process(&mut self, input: &[f32]) -> Vec<f32> {
71        // 1. Mixdown to mono if needed.
72        //   - 1ch: pass-through
73        //   - 2ch: take L (most Windows USB mics expose mono-as-stereo with L=R)
74        //   - Nch (N>2): arithmetic average
75        let mono: Vec<f32> = match self.in_channels {
76            0 | 1 => input.to_vec(),
77            2 => input.chunks_exact(2).map(|frame| frame[0]).collect(),
78            n => {
79                let stride = n as usize;
80                let scale = 1.0 / stride as f32;
81                input
82                    .chunks_exact(stride)
83                    .map(|frame| frame.iter().sum::<f32>() * scale)
84                    .collect()
85            }
86        };
87
88        // 2. Anti-alias LPF (only on downsample). Filters the full
89        // pre-resample stream in place, preserving the original sample
90        // rate; the linear-interpolation step below then picks samples
91        // out at the lower output rate.
92        let filtered: Vec<f32> = if self.fir_coeffs.is_some() {
93            self.fir_filter(&mono)
94        } else {
95            mono
96        };
97
98        // 3. Linear resample.
99        if self.in_rate == self.out_rate {
100            self.last_sample = *filtered.last().unwrap_or(&self.last_sample);
101            return filtered;
102        }
103        let step = self.in_rate as f64 / self.out_rate as f64;
104        let mut out = Vec::with_capacity((filtered.len() as f64 / step).ceil() as usize);
105        let mut pos = self.in_pos;
106        while pos < filtered.len() as f64 {
107            let i = pos.floor() as usize;
108            let frac = (pos - i as f64) as f32;
109            let a = if i == 0 {
110                self.last_sample
111            } else {
112                filtered[i - 1]
113            };
114            let b = filtered.get(i).copied().unwrap_or(a);
115            out.push(a + (b - a) * frac);
116            pos += step;
117        }
118        self.in_pos = pos - filtered.len() as f64;
119        if let Some(&last) = filtered.last() {
120            self.last_sample = last;
121        }
122        let _ = self.out_channels; // reserved for future stereo upmix
123        out
124    }
125
126    /// In-place LPF using the precomputed FIR. `fir_history` is a circular
127    /// buffer of the last FILTER_TAPS samples seen; for each new input we
128    /// dot-product it with the (time-reversed) coefficients.
129    fn fir_filter(&mut self, samples: &[f32]) -> Vec<f32> {
130        let coeffs = match &self.fir_coeffs {
131            Some(c) => *c,
132            None => return samples.to_vec(),
133        };
134        let mut out = Vec::with_capacity(samples.len());
135        for &s in samples {
136            self.fir_history[self.fir_write_idx] = s;
137            self.fir_write_idx = (self.fir_write_idx + 1) % FILTER_TAPS;
138            let mut acc: f32 = 0.0;
139            let mut idx = self.fir_write_idx;
140            for &c in &coeffs {
141                acc += self.fir_history[idx] * c;
142                idx = (idx + 1) % FILTER_TAPS;
143            }
144            out.push(acc);
145        }
146        out
147    }
148}
149
150/// Designs a windowed-sinc low-pass FIR. `in_rate` is the sample rate the
151/// filter runs at; `out_rate` defines the cutoff (0.4 × out_rate / 2 = 0.2
152/// of in_rate when downsampling 2.5×, etc). Hamming window for ~50dB
153/// rejection at minimal compute.
154fn design_lpf(in_rate: u32, out_rate: u32) -> [f32; FILTER_TAPS] {
155    // Cutoff (relative to in_rate's Nyquist) — guard band sits at
156    // 0.4 × out_rate / in_rate so we filter everything that would alias
157    // into the output's [0, Nyquist] band.
158    let cutoff_ratio = 0.4 * (out_rate as f32 / in_rate as f32);
159    let mut coeffs = [0.0f32; FILTER_TAPS];
160    let half = (FILTER_TAPS as isize - 1) / 2;
161    let mut sum = 0.0f32;
162    for i in 0..FILTER_TAPS {
163        let n = i as isize - half;
164        // Ideal sinc — limit-handled at n=0.
165        let sinc = if n == 0 {
166            2.0 * cutoff_ratio
167        } else {
168            let x = std::f32::consts::PI * n as f32;
169            (2.0 * cutoff_ratio * x).sin() / x
170        };
171        // Hamming window — 0.54 - 0.46*cos(2πn/(N-1))
172        let w = 0.54
173            - 0.46 * (2.0 * std::f32::consts::PI * i as f32 / (FILTER_TAPS as f32 - 1.0)).cos();
174        coeffs[i] = sinc * w;
175        sum += coeffs[i];
176    }
177    // Normalize to unity DC gain so steady-state inputs aren't attenuated.
178    if sum.abs() > 1e-6 {
179        for c in &mut coeffs {
180            *c /= sum;
181        }
182    }
183    coeffs
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    /// Sanity: a 21-tap LPF with cutoff at 0.4 × Nyquist should pass DC
191    /// approximately unchanged.
192    #[test]
193    fn lpf_unity_dc_gain() {
194        let mut r = Linear::new(48_000, 16_000, 1, 1);
195        let samples = vec![0.25f32; 100];
196        let out = r.process(&samples);
197        let avg = out.iter().rev().take(10).copied().sum::<f32>() / 10.0;
198        assert!((avg - 0.25).abs() < 0.01, "dc gain off: {avg}");
199    }
200
201    /// 48k→16k is the dominant Win11 desktop case (default WASAPI render
202    /// sample rate). Verify no panic + output length is ~1/3 of input.
203    #[test]
204    fn downsample_48k_to_16k() {
205        let mut r = Linear::new(48_000, 16_000, 1, 1);
206        let samples = vec![0.0f32; 4800];
207        let out = r.process(&samples);
208        assert!(
209            out.len() >= 1500 && out.len() <= 1700,
210            "out len {} not ~1600",
211            out.len()
212        );
213    }
214
215    /// 44.1k → 16k path — second-most-common Win11 case. Feed silence and
216    /// verify no FIR ringing or DC bias.
217    #[test]
218    fn downsample_44100_to_16k_no_aliasing() {
219        let mut r = Linear::new(44_100, 16_000, 1, 1);
220        let samples = vec![0.0f32; 8820];
221        let out = r.process(&samples);
222        assert!(out.len() >= 3000 && out.len() <= 3400);
223        let max_abs = out.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
224        assert!(
225            max_abs <= 1e-4,
226            "silence in → silence out, got max={max_abs}"
227        );
228    }
229
230    /// Stereo → mono: take L. L=R=0.5 → 0.5.
231    #[test]
232    fn stereo_mixdown_takes_left() {
233        let mut r = Linear::new(16_000, 16_000, 2, 1);
234        let samples: Vec<f32> = (0..16).map(|_| 0.5f32).collect();
235        let out = r.process(&samples);
236        assert_eq!(out.len(), 8);
237        for &s in &out {
238            assert!((s - 0.5).abs() < 1e-3, "expected 0.5 (L), got {s}");
239        }
240    }
241
242    /// Stereo with L=0.5 R=-0.5 → output is just L = 0.5.
243    #[test]
244    fn stereo_mixdown_ignores_right() {
245        let mut r = Linear::new(16_000, 16_000, 2, 1);
246        let mut samples: Vec<f32> = Vec::with_capacity(16);
247        for _ in 0..8 {
248            samples.push(0.5);
249            samples.push(-0.5);
250        }
251        let out = r.process(&samples);
252        assert_eq!(out.len(), 8);
253        for &s in &out {
254            assert!((s - 0.5).abs() < 1e-6, "expected L=0.5, got {s}");
255        }
256    }
257
258    /// No resample, mono — pass-through with FIR bypass since in_rate == out_rate.
259    #[test]
260    fn passthrough_mono_no_resample() {
261        let mut r = Linear::new(16_000, 16_000, 1, 1);
262        let samples: Vec<f32> = (0..1600).map(|i| (i as f32 / 1000.0) - 0.8).collect();
263        let out = r.process(&samples);
264        assert_eq!(out, samples);
265    }
266}