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}