gemini_adk_fluent_rs/voice/
denoise.rs

1//! A speech enhancer in the box *(feature `denoise`)*.
2//!
3//! [`Denoiser`] is an RNNoise-based noise suppressor
4//! ([`nnnoiseless`](https://crates.io/crates/nnnoiseless), pure Rust) packaged
5//! as an [`InputAudioProcessor`](super::InputAudioProcessor) — drop it into
6//! [`pump_processed`](super::pump_processed) and the session's VAD sees
7//! denoised audio.
8//!
9//! Why it earns its place: the client-side energy VAD has two noise
10//! pathologies, measured on TTS speech over synthesized noise. Continuous
11//! broadband noise (white, line hiss) triggers one false activation at call
12//! start and then latches the detector open for the rest of the call; pink
13//! (ambience-shaped) noise instead raises the adaptive floor until real
14//! speech at ≤10 dB SNR is *missed*. With this stage ahead of the VAD, both
15//! disappear in the same benchmark: zero false activations, zero stuck-open
16//! time, and every utterance detected down to 0 dB SNR — at ~0.007× realtime
17//! on one CPU core.
18//!
19//! Two honest limits, from the same measurements:
20//!
21//! - **Competing speech passes through.** A speech *enhancer* preserves
22//!   speech — babble noise and a second talker in the room still reach the
23//!   VAD. Level discrimination ([`NoiseGate`](super::NoiseGate) calibrated
24//!   between the near and far talkers' levels) is the mono-mic tool for
25//!   "the person closer to the microphone"; chain it *after* the denoiser
26//!   so it gates on clean levels.
27//! - **It buffers 10 ms.** RNNoise operates on 480-sample frames at 48 kHz;
28//!   input is resampled, processed per block, and resampled back, so output
29//!   trails input by up to one block (plus the first block, emitted as
30//!   silence while the network warms up).
31//!
32//! Heavier alternative: DeepFilterNet (also Rust, tract CPU inference at
33//! ~0.12× realtime) scores the same on these noise benchmarks and better on
34//! very low-SNR speech quality, but its inference crate is only published
35//! as a git dependency, which a crates.io release cannot carry — so it
36//! stays an application-side `impl InputAudioProcessor` (the eval harness in the
37//! repository history has a working one) rather than an SDK feature.
38
39use super::InputAudioProcessor;
40
41const DENOISE_HZ: u32 = 48_000;
42const FRAME: usize = nnnoiseless::DenoiseState::FRAME_SIZE; // 480 = 10 ms
43
44/// RNNoise noise suppression as a mic-chain stage. One instance per stream —
45/// the network is stateful across frames.
46pub struct Denoiser {
47    state: Box<nnnoiseless::DenoiseState<'static>>,
48    mic_hz: u32,
49    /// 48 kHz samples waiting to fill a 480-sample block.
50    pending: Vec<f32>,
51    /// The first processed block is warm-up noise; it is replaced by
52    /// silence so the session never hears it.
53    warmed_up: bool,
54    /// Speech probability from the network's VAD head, per 10 ms block.
55    last_vad: f32,
56}
57
58impl Denoiser {
59    /// A denoiser for a microphone stream at `mic_hz` (the same rate given
60    /// to [`pump_processed`](super::pump_processed)).
61    pub fn new(mic_hz: u32) -> Self {
62        Self {
63            state: nnnoiseless::DenoiseState::new(),
64            mic_hz,
65            pending: Vec::with_capacity(FRAME * 2),
66            warmed_up: false,
67            last_vad: 0.0,
68        }
69    }
70
71    /// Speech probability in `[0, 1]` from RNNoise's VAD head — the same
72    /// recurrent features that drive the suppression gains, read out as a
73    /// per-block classifier. Updated every 10 ms block the denoiser
74    /// processes (the maximum across the blocks consumed by the most recent
75    /// [`process_frame`](InputAudioProcessor::process_frame) call); `0.0` before the first
76    /// full block.
77    ///
78    /// This is a *learned* VAD: it responds to the statistical fingerprint
79    /// of speech (pitch movement, formants, syllabic modulation), not to
80    /// level. Measured on the module-docs benchmark it separates cleanly —
81    /// speech medians 0.76–0.96 against noise medians ≈ 0.00 for pink and
82    /// street-traffic scenes (horns, engines) even at 0 dB SNR. Use it as
83    /// the decision path for turn-taking (poll after each pumped frame, add
84    /// hysteresis: on above ≈ 0.6, off below ≈ 0.3 with a ~300 ms hangover)
85    /// where an energy-threshold VAD would false-trigger on loud non-speech.
86    ///
87    /// Two measured caveats: babble and competing talkers score as speech
88    /// (they are speech — see the module docs on level gating), and *loud
89    /// sustained broadband white noise from stream start* can hold the head
90    /// high until its noise estimate converges, so pair the probability
91    /// with hysteresis rather than acting on a single block.
92    pub fn vad_probability(&self) -> f32 {
93        self.last_vad
94    }
95}
96
97impl InputAudioProcessor for Denoiser {
98    fn process_frame(&mut self, frame: &mut Vec<i16>) {
99        if frame.is_empty() {
100            return;
101        }
102        // Up to the model's rate; RNNoise expects f32 samples in i16 range.
103        self.pending.extend(
104            super::resample(frame, self.mic_hz, DENOISE_HZ)
105                .iter()
106                .map(|&s| s as f32),
107        );
108
109        let mut out48: Vec<i16> = Vec::with_capacity(self.pending.len());
110        let mut input = [0.0f32; FRAME];
111        let mut output = [0.0f32; FRAME];
112        let mut call_vad: Option<f32> = None;
113        while self.pending.len() >= FRAME {
114            input.copy_from_slice(&self.pending[..FRAME]);
115            self.pending.drain(..FRAME);
116            let vad = self.state.process_frame(&mut output, &input);
117            call_vad = Some(call_vad.map_or(vad, |v: f32| v.max(vad)));
118            if self.warmed_up {
119                out48.extend(output.iter().map(|&s| s.clamp(-32768.0, 32767.0) as i16));
120            } else {
121                out48.extend(std::iter::repeat_n(0i16, FRAME));
122                self.warmed_up = true;
123            }
124        }
125
126        if let Some(vad) = call_vad {
127            self.last_vad = vad;
128        }
129
130        // Back to the mic rate. The frame the pump forwards may be shorter
131        // or longer than the one it handed us — it is a stream, not a
132        // sample-aligned transform.
133        *frame = super::resample(&out48, DENOISE_HZ, self.mic_hz);
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    /// Deterministic full-scale-ish white noise at the mic rate.
142    fn noise_frame(len: usize, seed: &mut u64) -> Vec<i16> {
143        (0..len)
144            .map(|_| {
145                *seed ^= *seed << 13;
146                *seed ^= *seed >> 7;
147                *seed ^= *seed << 17;
148                (seed.wrapping_mul(0x2545F4914F6CDD1D) >> 50) as i16
149            })
150            .collect()
151    }
152
153    fn rms(samples: &[i16]) -> f64 {
154        (samples.iter().map(|&s| (s as f64).powi(2)).sum::<f64>() / samples.len().max(1) as f64)
155            .sqrt()
156    }
157
158    #[test]
159    fn strongly_attenuates_stationary_noise() {
160        let mut denoiser = Denoiser::new(16_000);
161        let mut seed = 0xDECAF | 1;
162        let mut in_rms = 0.0;
163        let mut out_rms = 0.0;
164        // 20 ms frames; skip the first 10 (warm-up + floor estimation).
165        for i in 0..50 {
166            let mut frame = noise_frame(320, &mut seed);
167            let level_in = rms(&frame);
168            denoiser.process_frame(&mut frame);
169            if i >= 10 && !frame.is_empty() {
170                in_rms += level_in;
171                out_rms += rms(&frame);
172            }
173        }
174        // Sustained speech-free noise is attenuated (the full effect in a
175        // real stream is larger — RNNoise's own VAD gates harder between
176        // utterances; see the benchmark in the module docs). This guards
177        // "suppresses, doesn't corrupt", not the exact figure.
178        assert!(
179            out_rms < in_rms * 0.6,
180            "expected ≥4 dB suppression of white noise, got in={in_rms:.0} out={out_rms:.0}"
181        );
182    }
183
184    #[test]
185    fn preserves_stream_duration_within_one_block() {
186        let mut denoiser = Denoiser::new(8_000);
187        let mut seed = 3u64;
188        let mut fed = 0usize;
189        let mut got = 0usize;
190        for _ in 0..40 {
191            let mut frame = noise_frame(160, &mut seed); // 20 ms @ 8 kHz
192            fed += frame.len();
193            denoiser.process_frame(&mut frame);
194            got += frame.len();
195        }
196        // Output may trail input by up to one 10 ms block (80 samples @ 8 kHz).
197        assert!(fed - got <= 80, "fed {fed}, got {got}");
198    }
199
200    /// Pink noise via Voss-McCartney — ambience-shaped, the realistic
201    /// speech-free bed (the VAD head is known to run high on *loud white*
202    /// noise from a cold start; see the getter docs).
203    fn pink_frame(len: usize, rows: &mut [f64; 16], n: &mut usize, seed: &mut u64) -> Vec<i16> {
204        (0..len)
205            .map(|_| {
206                *n += 1;
207                let row = (n.trailing_zeros() as usize).min(15);
208                *seed ^= *seed << 13;
209                *seed ^= *seed >> 7;
210                *seed ^= *seed << 17;
211                rows[row] = (seed.wrapping_mul(0x2545F4914F6CDD1D) >> 11) as f64
212                    / (1u64 << 52) as f64
213                    * 2.0
214                    - 1.0;
215                (rows.iter().sum::<f64>() / 16.0 * 8000.0) as i16
216            })
217            .collect()
218    }
219
220    #[test]
221    fn vad_probability_stays_low_on_ambience_noise() {
222        let mut denoiser = Denoiser::new(16_000);
223        assert_eq!(denoiser.vad_probability(), 0.0, "no blocks processed yet");
224        let (mut rows, mut n, mut seed) = ([0.0; 16], 0usize, 0xFEED | 1);
225        for _ in 0..50 {
226            let mut frame = pink_frame(320, &mut rows, &mut n, &mut seed);
227            denoiser.process_frame(&mut frame);
228            let p = denoiser.vad_probability();
229            assert!((0.0..=1.0).contains(&p), "probability out of range: {p}");
230        }
231        // Sustained speech-free ambience must not read as speech.
232        assert!(
233            denoiser.vad_probability() < 0.5,
234            "pink noise scored {} on the VAD head",
235            denoiser.vad_probability()
236        );
237    }
238
239    #[test]
240    fn empty_frames_flow_through() {
241        let mut denoiser = Denoiser::new(16_000);
242        let mut frame = Vec::new();
243        denoiser.process_frame(&mut frame);
244        assert!(frame.is_empty());
245    }
246}