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    Resampler, SincFixedIn, SincInterpolationParameters, SincInterpolationType, WindowFunction,
20};
21
22use super::{AudioBus, DspStage};
23
24/// High-quality streaming resampler stage.
25pub struct SincResampler {
26    inner: SincFixedIn<f32>,
27    from_hz: u32,
28    to_hz: u32,
29    chunk: usize,
30    input_fifo: VecDeque<f32>,
31    in_scratch: Vec<Vec<f32>>,
32    out_scratch: Vec<Vec<f32>>,
33    output_fifo: VecDeque<f32>,
34    latency_out: usize,
35    /// Cumulative input samples accepted (for exact long-term rate).
36    in_total: u64,
37    /// Cumulative output samples emitted.
38    out_total: u64,
39}
40
41impl SincResampler {
42    /// A mono resampler from `from_hz` to `to_hz` with speech-grade
43    /// quality (128-tap sinc, Blackman-Harris, cutoff 0.95).
44    pub fn new(from_hz: u32, to_hz: u32) -> Self {
45        let chunk = (from_hz / 100).max(1) as usize; // 10 ms of input
46        let params = SincInterpolationParameters {
47            sinc_len: 128,
48            f_cutoff: 0.95,
49            interpolation: SincInterpolationType::Linear,
50            oversampling_factor: 128,
51            window: WindowFunction::BlackmanHarris2,
52        };
53        let inner =
54            SincFixedIn::<f32>::new(f64::from(to_hz) / f64::from(from_hz), 1.0, params, chunk, 1)
55                .expect("valid resampler parameters");
56        let latency_out = inner.output_delay();
57        let in_scratch = vec![vec![0.0f32; chunk]];
58        let out_scratch = inner.output_buffer_allocate(true);
59        Self {
60            inner,
61            from_hz,
62            to_hz,
63            chunk,
64            input_fifo: VecDeque::with_capacity(chunk * 4),
65            in_scratch,
66            out_scratch,
67            output_fifo: VecDeque::with_capacity(chunk * 4),
68            latency_out,
69            in_total: 0,
70            out_total: 0,
71        }
72    }
73}
74
75impl DspStage for SincResampler {
76    fn name(&self) -> &'static str {
77        "resample"
78    }
79
80    fn process(&mut self, bus: &mut AudioBus) {
81        debug_assert_eq!(
82            bus.sample_rate, self.from_hz,
83            "SincResampler built for {} Hz fed {} Hz",
84            self.from_hz, bus.sample_rate
85        );
86        let in_len = bus.samples.len();
87        self.input_fifo.extend(bus.samples.iter().copied());
88
89        while self.input_fifo.len() >= self.chunk {
90            for slot in self.in_scratch[0].iter_mut() {
91                *slot = self.input_fifo.pop_front().unwrap_or(0.0);
92            }
93            let (_, out_len) = self
94                .inner
95                .process_into_buffer(&self.in_scratch, &mut self.out_scratch, None)
96                .expect("fixed-size chunk");
97            self.output_fifo
98                .extend(self.out_scratch[0][..out_len].iter().copied());
99        }
100
101        // Emit the rate-converted share of the CUMULATIVE input, not of
102        // this call alone: per-call truncation would permanently drop the
103        // fractional remainder (one sample per call at 48k -> 16k never
104        // emits anything), drifting the long-term rate and stranding audio
105        // in the FIFO. A startup deficit (FIFO shorter than the share)
106        // carries forward and is recovered as the resampler fills.
107        self.in_total += in_len as u64;
108        let due = self.in_total * u64::from(self.to_hz) / u64::from(self.from_hz);
109        let want = due.saturating_sub(self.out_total) as usize;
110        bus.samples.clear();
111        let take = want.min(self.output_fifo.len());
112        for _ in 0..take {
113            bus.samples
114                .push(self.output_fifo.pop_front().unwrap_or(0.0));
115        }
116        self.out_total += take as u64;
117        bus.sample_rate = self.to_hz;
118    }
119
120    fn latency_samples(&self) -> usize {
121        // Declared at the OUTPUT rate: sinc group delay plus one input chunk
122        // of buffering, converted.
123        self.latency_out
124            + (self.chunk as u64 * u64::from(self.to_hz) / u64::from(self.from_hz)) as usize
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    fn sine(hz: f32, rate: u32, seconds: f32, amp: f32) -> Vec<f32> {
133        let n = (rate as f32 * seconds) as usize;
134        (0..n)
135            .map(|i| amp * (2.0 * std::f32::consts::PI * hz * i as f32 / rate as f32).sin())
136            .collect()
137    }
138
139    fn run_in_chunks(stage: &mut SincResampler, input: &[f32], from_hz: u32) -> Vec<f32> {
140        let mut out = Vec::new();
141        let mut buf = Vec::new();
142        for chunk in input.chunks(173) {
143            buf.clear();
144            buf.extend_from_slice(chunk);
145            let mut bus = AudioBus {
146                samples: &mut buf,
147                sample_rate: from_hz,
148            };
149            stage.process(&mut bus);
150            out.extend_from_slice(&buf);
151        }
152        out
153    }
154
155    #[test]
156    fn preserves_tone_across_48k_to_16k() {
157        let mut stage = SincResampler::new(48_000, 16_000);
158        let input = sine(1_000.0, 48_000, 1.0, 0.5);
159        let out = run_in_chunks(&mut stage, &input, 48_000);
160        // Steady state: skip the declared latency, measure RMS.
161        let skip = stage.latency_samples() * 2;
162        let tail = &out[skip..];
163        let rms = (tail.iter().map(|s| s * s).sum::<f32>() / tail.len() as f32).sqrt();
164        let expected = 0.5 / std::f32::consts::SQRT_2;
165        assert!(
166            (rms - expected).abs() / expected < 0.05,
167            "rms {rms} vs {expected}"
168        );
169    }
170
171    #[test]
172    fn rejects_content_above_target_nyquist() {
173        // 12 kHz tone at 48 kHz input is above the 8 kHz Nyquist of a
174        // 16 kHz output: a linear interpolator aliases it in loudly; the
175        // sinc filter must crush it.
176        let mut stage = SincResampler::new(48_000, 16_000);
177        let input = sine(12_000.0, 48_000, 1.0, 0.5);
178        let out = run_in_chunks(&mut stage, &input, 48_000);
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        assert!(rms < 0.01, "alias energy leaked: rms {rms}");
183    }
184
185    #[test]
186    fn tiny_blocks_still_emit_the_full_rate_share() {
187        // One sample per call at 48k -> 16k: per-call truncation computes
188        // floor(1/3) = 0 forever and strands every output sample in the
189        // FIFO. The cumulative accounting must emit ~1/3 of the input.
190        let mut stage = SincResampler::new(48_000, 16_000);
191        let mut emitted = 0usize;
192        let mut buf = Vec::with_capacity(1);
193        for _ in 0..48_000 {
194            buf.clear();
195            buf.push(0.25f32);
196            let mut bus = AudioBus {
197                samples: &mut buf,
198                sample_rate: 48_000,
199            };
200            stage.process(&mut bus);
201            emitted += buf.len();
202        }
203        let expected = 16_000usize;
204        assert!(
205            (emitted as i64 - expected as i64).unsigned_abs() as usize
206                <= stage.latency_samples() + 320,
207            "emitted {emitted} of ~{expected}"
208        );
209    }
210
211    #[test]
212    fn output_rate_and_length_track_ratio() {
213        let mut stage = SincResampler::new(48_000, 16_000);
214        let input = vec![0.0f32; 48_000];
215        let out = run_in_chunks(&mut stage, &input, 48_000);
216        let expected = 16_000usize;
217        assert!(
218            (out.len() as i64 - expected as i64).unsigned_abs() as usize
219                <= stage.latency_samples() + 320,
220            "len {} vs ~{expected}",
221            out.len()
222        );
223    }
224}