gemini_adk_fluent_rs/voice/
resampler.rs

1//! Streaming, anti-aliased sample-rate conversion for mono PCM16.
2//!
3//! Phone audio crosses three rates: 8 kHz on the line, 16 kHz into the Live
4//! API and 24 kHz out of it. Converting 24 kHz down to 8 kHz without a
5//! low-pass filter folds everything between 4 and 12 kHz (sibilants,
6//! breath, the top of the model's voice) back into the audible band as
7//! aliasing; converting chunk by chunk without carrying filter state across
8//! chunks adds a click at every chunk boundary.
9//!
10//! [`StreamResampler`] is a rational polyphase FIR resampler. Its low-pass
11//! prototype is a Blackman-windowed sinc with its cutoff just below the
12//! lower of the two Nyquist frequencies. It keeps its history between calls,
13//! so a stream resampled in 20 ms chunks is sample-for-sample the same as
14//! the whole stream resampled at once. It needs no dependencies and runs
15//! 16 multiply-adds per output sample.
16
17/// Filter taps per polyphase branch. 16 taps with a Blackman window give
18/// roughly 70 dB stop-band rejection with a transition band of about 10% of
19/// the lower Nyquist frequency, at under a millisecond of delay at 8 kHz.
20const TAPS_PER_PHASE: usize = 16;
21/// The pass-band edge, as a fraction of the lower Nyquist frequency.
22const CUTOFF: f64 = 0.9;
23
24/// A stateful resampler from `from_hz` to `to_hz`. See the module docs.
25#[derive(Debug, Clone)]
26pub struct StreamResampler {
27    /// Upsampling factor (L).
28    up: usize,
29    /// Downsampling factor (M).
30    down: usize,
31    /// `bank[phase][tap]`: the prototype filter split into `up` phases.
32    bank: Vec<[f32; TAPS_PER_PHASE]>,
33    /// The last `TAPS_PER_PHASE - 1` input samples, then the current input.
34    history: Vec<f32>,
35    /// Position of the next output sample, in upsampled samples, relative to
36    /// the first sample in `history`.
37    cursor: u64,
38}
39
40fn gcd(a: u32, b: u32) -> u32 {
41    if b == 0 { a } else { gcd(b, a % b) }
42}
43
44impl StreamResampler {
45    /// A resampler from `from_hz` to `to_hz`. Equal rates pass samples
46    /// through unchanged.
47    pub fn new(from_hz: u32, to_hz: u32) -> Self {
48        let from_hz = from_hz.max(1);
49        let to_hz = to_hz.max(1);
50        let g = gcd(from_hz, to_hz);
51        let up = (to_hz / g) as usize;
52        let down = (from_hz / g) as usize;
53        if up == down {
54            return Self {
55                up: 1,
56                down: 1,
57                bank: Vec::new(),
58                history: Vec::new(),
59                cursor: 0,
60            };
61        }
62        // Prototype at the upsampled rate (from_hz * up). The cutoff is the
63        // lower Nyquist frequency times CUTOFF, normalized to that rate.
64        let length = TAPS_PER_PHASE * up;
65        let fc = CUTOFF * 0.5 / up.max(down) as f64;
66        let centre = (length - 1) as f64 / 2.0;
67        let mut prototype = vec![0f64; length];
68        for (n, tap) in prototype.iter_mut().enumerate() {
69            let x = n as f64 - centre;
70            let sinc = if x == 0.0 {
71                2.0 * fc
72            } else {
73                (2.0 * std::f64::consts::PI * fc * x).sin() / (std::f64::consts::PI * x)
74            };
75            let w = n as f64 / (length - 1) as f64;
76            let blackman = 0.42 - 0.5 * (2.0 * std::f64::consts::PI * w).cos()
77                + 0.08 * (4.0 * std::f64::consts::PI * w).cos();
78            // Gain `up`: zero-stuffing divides the signal's energy by `up`.
79            *tap = sinc * blackman * up as f64;
80        }
81        let bank = (0..up)
82            .map(|phase| {
83                let mut taps = [0f32; TAPS_PER_PHASE];
84                for (j, tap) in taps.iter_mut().enumerate() {
85                    *tap = prototype[phase + j * up] as f32;
86                }
87                taps
88            })
89            .collect();
90        Self {
91            up,
92            down,
93            bank,
94            history: vec![0.0; TAPS_PER_PHASE - 1],
95            cursor: ((TAPS_PER_PHASE - 1) * up) as u64,
96        }
97    }
98
99    /// Resample the next chunk of the stream.
100    pub fn process(&mut self, input: &[i16]) -> Vec<i16> {
101        if self.up == self.down {
102            return input.to_vec();
103        }
104        self.history.extend(input.iter().map(|&s| f32::from(s)));
105        let available = self.history.len() as u64;
106        let mut out = Vec::with_capacity(input.len() * self.up / self.down + 1);
107        loop {
108            // The newest input sample this output depends on.
109            let newest = self.cursor / self.up as u64;
110            if newest >= available {
111                break;
112            }
113            let phase = (self.cursor % self.up as u64) as usize;
114            let taps = &self.bank[phase];
115            let newest = newest as usize;
116            let mut acc = 0f32;
117            for (j, &tap) in taps.iter().enumerate() {
118                acc += tap * self.history[newest - j];
119            }
120            out.push(acc.round().clamp(f32::from(i16::MIN), f32::from(i16::MAX)) as i16);
121            self.cursor += self.down as u64;
122        }
123        // Keep what the next outputs still need.
124        let keep_from = self.history.len() - (TAPS_PER_PHASE - 1);
125        self.history.drain(..keep_from);
126        self.cursor -= (keep_from * self.up) as u64;
127        out
128    }
129
130    /// Forget the stream so far, e.g. after playback was flushed.
131    pub fn reset(&mut self) {
132        if self.up != self.down {
133            self.history.clear();
134            self.history.resize(TAPS_PER_PHASE - 1, 0.0);
135            self.cursor = ((TAPS_PER_PHASE - 1) * self.up) as u64;
136        }
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    fn tone(hz: f64, rate: u32, samples: usize, amplitude: f64) -> Vec<i16> {
145        (0..samples)
146            .map(|n| {
147                (amplitude * (2.0 * std::f64::consts::PI * hz * n as f64 / f64::from(rate)).sin())
148                    as i16
149            })
150            .collect()
151    }
152
153    fn rms(samples: &[i16]) -> f64 {
154        // Skip the filter's start-up.
155        let s = &samples[samples.len() / 4..];
156        (s.iter().map(|&x| f64::from(x).powi(2)).sum::<f64>() / s.len() as f64).sqrt()
157    }
158
159    #[test]
160    fn durations_are_preserved() {
161        for (from, to) in [
162            (24_000, 8_000),
163            (8_000, 16_000),
164            (16_000, 24_000),
165            (48_000, 16_000),
166        ] {
167            let mut r = StreamResampler::new(from, to);
168            let out = r.process(&vec![0i16; from as usize]); // one second
169            let expected = to as usize;
170            assert!(
171                out.len().abs_diff(expected) <= 16,
172                "{from}->{to}: {}",
173                out.len()
174            );
175        }
176    }
177
178    #[test]
179    fn speech_band_passes_and_aliases_are_removed() {
180        // 1 kHz passes 24k -> 8k at unit gain.
181        let mut r = StreamResampler::new(24_000, 8_000);
182        let pass = r.process(&tone(1_000.0, 24_000, 24_000, 10_000.0));
183        let gain = rms(&pass) / (10_000.0 / 2f64.sqrt());
184        assert!((gain - 1.0).abs() < 0.05, "pass-band gain {gain}");
185
186        // 7 kHz is above the 4 kHz Nyquist of 8 kHz: without a filter it
187        // folds to 1 kHz at full level. It must be suppressed.
188        let mut r = StreamResampler::new(24_000, 8_000);
189        let alias = r.process(&tone(7_000.0, 24_000, 24_000, 10_000.0));
190        assert!(
191            rms(&alias) < 10_000.0 / 2f64.sqrt() * 0.01,
192            "alias rms {}",
193            rms(&alias)
194        );
195
196        // The linear interpolator this replaces lets it through.
197        let linear =
198            super::super::resample(&tone(7_000.0, 24_000, 24_000, 10_000.0), 24_000, 8_000);
199        assert!(rms(&linear) > 1_000.0);
200    }
201
202    #[test]
203    fn chunked_equals_whole() {
204        let input = tone(440.0, 8_000, 8_000, 8_000.0);
205        let mut whole = StreamResampler::new(8_000, 16_000);
206        let expected = whole.process(&input);
207        let mut chunked = StreamResampler::new(8_000, 16_000);
208        let mut got = Vec::new();
209        for chunk in input.chunks(160) {
210            got.extend(chunked.process(chunk));
211        }
212        assert_eq!(got, expected);
213    }
214
215    #[test]
216    fn equal_rates_pass_through() {
217        let mut r = StreamResampler::new(16_000, 16_000);
218        assert_eq!(r.process(&[1, 2, 3]), [1, 2, 3]);
219    }
220}