Skip to main content

gemini_adk_fluent_rs/voice/dsp/
resample.rs

1//! Windowed-sinc resampling as a chain stage (feature `dsp`).
2//!
3//! The free function [`resample`](crate::voice::resample) is a linear
4//! interpolator — deliberately simple, and fine for conversational speech.
5//! But linear interpolation is a first-order filter: its stopband barely
6//! attenuates, so content above the target Nyquist aliases back into the
7//! speech band. A DSP chain deserves the real thing: [`rubato`]'s
8//! polyphase windowed-sinc resampler (128-tap sinc, Blackman-Harris
9//! window, 0.95 cutoff), with the filter's group delay *declared* through
10//! [`DspStage::latency_samples`] instead of ignored.
11//!
12//! The stage buffers arbitrary input block sizes into fixed 10 ms chunks
13//! (rubato wants fixed input), and rewrites `bus.sample_rate` to the
14//! target rate — stages after it operate at the new rate.
15
16use std::collections::VecDeque;
17
18use rubato::{
19    Async, FixedAsync, Resampler, SincInterpolationParameters, SincInterpolationType,
20    WindowFunction, audioadapter_buffers::direct::SequentialSliceOfVecs,
21};
22
23use super::{AudioBus, DspStage};
24
25/// High-quality streaming resampler stage.
26pub struct SincResampler {
27    inner: Async<f32>,
28    from_hz: u32,
29    to_hz: u32,
30    chunk: usize,
31    input_fifo: VecDeque<f32>,
32    in_scratch: Vec<Vec<f32>>,
33    out_scratch: Vec<Vec<f32>>,
34    output_fifo: VecDeque<f32>,
35    latency_out: usize,
36    /// Cumulative input samples accepted (for exact long-term rate).
37    in_total: u64,
38    /// Cumulative output samples emitted.
39    out_total: u64,
40}
41
42impl SincResampler {
43    /// A mono resampler from `from_hz` to `to_hz` with speech-grade
44    /// quality (128-tap sinc, Blackman-Harris, cutoff 0.95).
45    pub fn new(from_hz: u32, to_hz: u32) -> Self {
46        let chunk = (from_hz / 100).max(1) as usize; // 10 ms of input
47        let params = SincInterpolationParameters {
48            sinc_len: 128,
49            f_cutoff: Some(0.95),
50            interpolation: SincInterpolationType::Linear,
51            oversampling_factor: 128,
52            window: WindowFunction::BlackmanHarris2,
53        };
54        // Fixed input preserves the 10 ms streaming contract. Keep the cutoff
55        // explicit: None asks Rubato to choose a different filter cutoff.
56        let inner = Async::<f32>::new_sinc(
57            f64::from(to_hz) / f64::from(from_hz),
58            1.0,
59            &params,
60            chunk,
61            1,
62            FixedAsync::Input,
63        )
64        .expect("valid resampler parameters");
65        let latency_out = inner.output_delay();
66        let in_scratch = vec![vec![0.0f32; chunk]];
67        let out_scratch = vec![vec![0.0f32; inner.output_frames_max()]];
68        Self {
69            inner,
70            from_hz,
71            to_hz,
72            chunk,
73            input_fifo: VecDeque::with_capacity(chunk * 4),
74            in_scratch,
75            out_scratch,
76            output_fifo: VecDeque::with_capacity(chunk * 4),
77            latency_out,
78            in_total: 0,
79            out_total: 0,
80        }
81    }
82}
83
84impl DspStage for SincResampler {
85    fn name(&self) -> &'static str {
86        "resample"
87    }
88
89    fn process(&mut self, bus: &mut AudioBus) {
90        debug_assert_eq!(
91            bus.sample_rate, self.from_hz,
92            "SincResampler built for {} Hz fed {} Hz",
93            self.from_hz, bus.sample_rate
94        );
95        let in_len = bus.samples.len();
96        self.input_fifo.extend(bus.samples.iter().copied());
97
98        while self.input_fifo.len() >= self.chunk {
99            for slot in self.in_scratch[0].iter_mut() {
100                *slot = self.input_fifo.pop_front().unwrap_or(0.0);
101            }
102            // Borrow the preallocated planar buffers. Only the returned frame
103            // count is valid output; the unused capacity may contain old audio.
104            let input = SequentialSliceOfVecs::new(&self.in_scratch, 1, self.chunk)
105                .expect("mono input scratch");
106            let output_capacity = self.out_scratch[0].len();
107            let mut output =
108                SequentialSliceOfVecs::new_mut(&mut self.out_scratch, 1, output_capacity)
109                    .expect("mono output scratch");
110            let (consumed, out_len) = self
111                .inner
112                .process_into_buffer(&input, &mut output, None)
113                .expect("fixed-size chunk");
114            debug_assert_eq!(consumed, self.chunk);
115            self.output_fifo
116                .extend(self.out_scratch[0][..out_len].iter().copied());
117        }
118
119        // Emit the rate-converted share of the CUMULATIVE input, not of
120        // this call alone: per-call truncation would permanently drop the
121        // fractional remainder (one sample per call at 48k -> 16k never
122        // emits anything), drifting the long-term rate and stranding audio
123        // in the FIFO. A startup deficit (FIFO shorter than the share)
124        // carries forward and is recovered as the resampler fills.
125        self.in_total += in_len as u64;
126        let due = self.in_total * u64::from(self.to_hz) / u64::from(self.from_hz);
127        let want = due.saturating_sub(self.out_total) as usize;
128        bus.samples.clear();
129        let take = want.min(self.output_fifo.len());
130        for _ in 0..take {
131            bus.samples
132                .push(self.output_fifo.pop_front().unwrap_or(0.0));
133        }
134        self.out_total += take as u64;
135        bus.sample_rate = self.to_hz;
136    }
137
138    fn latency_samples(&self) -> usize {
139        // Declared at the OUTPUT rate: sinc group delay plus one input chunk
140        // of buffering, converted.
141        self.latency_out
142            + (self.chunk as u64 * u64::from(self.to_hz) / u64::from(self.from_hz)) as usize
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    fn sine(hz: f32, rate: u32, seconds: f32, amp: f32) -> Vec<f32> {
151        let n = (rate as f32 * seconds) as usize;
152        (0..n)
153            .map(|i| amp * (2.0 * std::f32::consts::PI * hz * i as f32 / rate as f32).sin())
154            .collect()
155    }
156
157    fn run_in_chunks(stage: &mut SincResampler, input: &[f32], from_hz: u32) -> Vec<f32> {
158        let mut out = Vec::new();
159        let mut buf = Vec::new();
160        for chunk in input.chunks(173) {
161            buf.clear();
162            buf.extend_from_slice(chunk);
163            let mut bus = AudioBus {
164                samples: &mut buf,
165                sample_rate: from_hz,
166            };
167            stage.process(&mut bus);
168            out.extend_from_slice(&buf);
169        }
170        out
171    }
172
173    #[test]
174    fn preserves_tone_across_48k_to_16k() {
175        let mut stage = SincResampler::new(48_000, 16_000);
176        let input = sine(1_000.0, 48_000, 1.0, 0.5);
177        let out = run_in_chunks(&mut stage, &input, 48_000);
178        // Steady state: skip the declared latency, measure RMS.
179        let skip = stage.latency_samples() * 2;
180        let tail = &out[skip..];
181        let rms = (tail.iter().map(|s| s * s).sum::<f32>() / tail.len() as f32).sqrt();
182        let expected = 0.5 / std::f32::consts::SQRT_2;
183        assert!(
184            (rms - expected).abs() / expected < 0.05,
185            "rms {rms} vs {expected}"
186        );
187    }
188
189    #[test]
190    fn rejects_content_above_target_nyquist() {
191        // 12 kHz tone at 48 kHz input is above the 8 kHz Nyquist of a
192        // 16 kHz output: a linear interpolator aliases it in loudly; the
193        // sinc filter must crush it.
194        let mut stage = SincResampler::new(48_000, 16_000);
195        let input = sine(12_000.0, 48_000, 1.0, 0.5);
196        let out = run_in_chunks(&mut stage, &input, 48_000);
197        let skip = stage.latency_samples() * 2;
198        let tail = &out[skip..];
199        let rms = (tail.iter().map(|s| s * s).sum::<f32>() / tail.len() as f32).sqrt();
200        assert!(rms < 0.01, "alias energy leaked: rms {rms}");
201    }
202
203    #[test]
204    fn tiny_blocks_still_emit_the_full_rate_share() {
205        // One sample per call at 48k -> 16k: per-call truncation computes
206        // floor(1/3) = 0 forever and strands every output sample in the
207        // FIFO. The cumulative accounting must emit ~1/3 of the input.
208        let mut stage = SincResampler::new(48_000, 16_000);
209        let mut emitted = 0usize;
210        let mut buf = Vec::with_capacity(1);
211        for _ in 0..48_000 {
212            buf.clear();
213            buf.push(0.25f32);
214            let mut bus = AudioBus {
215                samples: &mut buf,
216                sample_rate: 48_000,
217            };
218            stage.process(&mut bus);
219            emitted += buf.len();
220        }
221        let expected = 16_000usize;
222        assert!(
223            (emitted as i64 - expected as i64).unsigned_abs() as usize
224                <= stage.latency_samples() + 320,
225            "emitted {emitted} of ~{expected}"
226        );
227    }
228
229    #[test]
230    fn output_rate_and_length_track_ratio() {
231        let mut stage = SincResampler::new(48_000, 16_000);
232        let input = vec![0.0f32; 48_000];
233        let out = run_in_chunks(&mut stage, &input, 48_000);
234        let expected = 16_000usize;
235        assert!(
236            (out.len() as i64 - expected as i64).unsigned_abs() as usize
237                <= stage.latency_samples() + 320,
238            "len {} vs ~{expected}",
239            out.len()
240        );
241    }
242
243    fn run_partitioned(from: u32, to: u32, input: &[f32], sizes: &[usize]) -> Vec<f32> {
244        let mut stage = SincResampler::new(from, to);
245        let mut out = Vec::new();
246        let mut offset = 0;
247        let mut call = 0;
248        while offset < input.len() {
249            let end = (offset + sizes[call % sizes.len()]).min(input.len());
250            let mut samples = input[offset..end].to_vec();
251            let mut bus = AudioBus {
252                samples: &mut samples,
253                sample_rate: from,
254            };
255            stage.process(&mut bus);
256            assert_eq!(bus.sample_rate, to);
257            out.extend_from_slice(&samples);
258            offset = end;
259            call += 1;
260            assert_eq!(stage.in_total, offset as u64);
261            assert_eq!(stage.out_total, out.len() as u64);
262            assert!(stage.out_total <= stage.in_total * u64::from(to) / u64::from(from));
263            assert!(stage.input_fifo.len() < stage.chunk);
264            assert!(stage.output_fifo.len() <= stage.inner.output_frames_max());
265
266            // Empty calls must neither pad a partial chunk nor flush filter history.
267            let before = (stage.in_total, stage.out_total, stage.input_fifo.len());
268            let mut empty = Vec::new();
269            stage.process(&mut AudioBus {
270                samples: &mut empty,
271                sample_rate: from,
272            });
273            assert!(empty.is_empty());
274            assert_eq!(
275                before,
276                (stage.in_total, stage.out_total, stage.input_fifo.len())
277            );
278        }
279        let due = input.len() as u64 * u64::from(to) / u64::from(from);
280        assert!(due - stage.out_total <= stage.latency_samples() as u64);
281        out
282    }
283
284    #[test]
285    fn streaming_is_invariant_to_input_partitioning() {
286        // Cover integer decimation, interpolation, fractional ratios and unity.
287        // A final partial chunk is deliberately left pending, never zero-padded.
288        for (from, to) in [
289            (48_000, 16_000),
290            (16_000, 48_000),
291            (44_100, 16_000),
292            (48_000, 44_100),
293            (16_000, 16_000),
294        ] {
295            let mut input = sine(997.0, from, 1.0, 0.5);
296            input.extend([0.2; 37]);
297            let reference = run_partitioned(from, to, &input, &[input.len()]);
298            for sizes in [&[1][..], &[173][..], &[7, 0, 479, 1, 2049, 13][..]] {
299                let actual = run_partitioned(from, to, &input, sizes);
300                assert_eq!(actual, reference, "partition {sizes:?}, {from} -> {to}");
301            }
302        }
303    }
304
305    #[test]
306    fn impulse_delay_and_filter_configuration_are_preserved() {
307        for (from, to) in [(48_000, 16_000), (16_000, 48_000), (44_100, 16_000)] {
308            let mut stage = SincResampler::new(from, to);
309            assert_eq!(stage.inner.input_frames_next(), stage.chunk);
310            let cutoff = 0.95 * (f64::from(to) / f64::from(from)).min(1.0) as f32;
311            assert!((stage.inner.cutoff().unwrap() - cutoff).abs() < 1e-6);
312            let delay = (64.0 * f64::from(to) / f64::from(from)) as usize;
313            assert_eq!(stage.latency_out, delay);
314            assert_eq!(
315                stage.latency_samples(),
316                delay + stage.chunk * to as usize / from as usize
317            );
318            let mut input = vec![0.0; from as usize / 10];
319            // Place the impulse away from the startup boundary.
320            let impulse_at = stage.chunk * 2;
321            input[impulse_at] = 1.0;
322            let out = run_in_chunks(&mut stage, &input, from);
323            let peak = out
324                .iter()
325                .enumerate()
326                .max_by(|a, b| a.1.abs().total_cmp(&b.1.abs()))
327                .unwrap()
328                .0;
329            let expected = impulse_at * to as usize / from as usize + delay;
330            assert!(
331                peak.abs_diff(expected) <= 1,
332                "peak {peak}, expected {expected}"
333            );
334        }
335    }
336}