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}