gemini_adk_fluent_rs/voice/
mod.rs

1//! # Voice I/O — a talking application in five lines
2//!
3//! The Live API speaks PCM16: 16 kHz in, 24 kHz out. Everything between a
4//! microphone and that contract — resampling, channel down-mix, playback
5//! buffering, and *barge-in* (the user speaks over the model; buffered speech
6//! must vanish now) — is plumbing every voice application needs and none
7//! should write. This module is that plumbing, engineered as two primitives:
8//!
9//! - [`pump`] — the device-independent duplex core. Feed it microphone frames
10//!   on a channel at any sample rate; receive playback frames on another at
11//!   any sample rate. It resamples both directions, forwards
12//!   [`LiveEvent::Audio`](gemini_adk_rs::live::LiveEvent) to your speaker
13//!   channel, and turns an interruption into an explicit [`Playback::Flush`]
14//!   so stale audio is dropped, not played. Works with any audio backend —
15//!   or none (tests drive it with plain channels).
16//! - `Talk::talk` *(feature `voice-io`)* — the whole loop on the system's
17//!   default microphone and speakers via `cpal`, with drain signaling wired
18//!   back into the session's voice reactor. Ctrl-C or session end stops it.
19//!   Without the feature there is no `talk()` method on the handle: the
20//!   `Talk` extension trait is only compiled when `voice-io` is enabled
21//!   (Linux also needs `libasound2-dev`).
22//!
23//! ```ignore
24//! // `voice-io` feature: the doctest cannot compile without it.
25//! let session = Live::builder()
26//!     .instruction("You are a helpful concierge.")
27//!     .greeting("Greet the caller.")
28//!     .connect_from_env().await?;
29//! session.talk().await?;
30//! ```
31
32#[cfg(feature = "voice-io")]
33mod devices;
34
35#[cfg(feature = "voice-io")]
36pub use devices::{Talk, VoiceIoError};
37pub mod dsp;
38pub use dsp::{AudioBus, ChainMetrics, ChainSnapshot, DspChain, DspStage, IntStage, StageSnapshot};
39
40#[cfg(feature = "denoise")]
41mod denoise;
42
43#[cfg(feature = "denoise")]
44pub use denoise::Denoiser;
45
46/// The mic-chain stage trait — one `process_frame` over a PCM16 frame in
47/// place. Defined by the L1 runtime (`Live::mic_processor` takes the same
48/// trait), re-exported here so a voice application has one name for it.
49pub use gemini_adk_rs::live::InputAudioProcessor;
50use gemini_adk_rs::live::{LiveEvent, LiveHandle};
51use gemini_genai_rs::prelude::{bytes_to_i16, i16_to_bytes};
52use tokio::sync::mpsc;
53use tokio::task::JoinHandle;
54
55/// The sample rate the Live API expects on its input stream.
56pub const SESSION_INPUT_HZ: u32 = 16_000;
57/// The sample rate the Live API produces on its output stream.
58pub const SESSION_OUTPUT_HZ: u32 = 24_000;
59
60/// One playback instruction to the speaker side.
61#[derive(Debug, Clone, PartialEq)]
62pub enum Playback {
63    /// PCM16 mono samples at the sink rate requested from [`pump`].
64    Chunk(Vec<i16>),
65    /// Barge-in: the model was interrupted — drop every buffered sample
66    /// immediately. Playing on is the one unforgivable sin of a voice UI.
67    Flush,
68}
69
70/// The two halves of a running duplex pump. Ends on its own when the session
71/// closes or either channel hangs up; [`abort`](VoicePump::abort) ends it early.
72pub struct VoicePump {
73    uplink: JoinHandle<()>,
74    downlink: JoinHandle<()>,
75}
76
77impl VoicePump {
78    /// Wait for both directions to finish (session closed or channels dropped).
79    pub async fn join(self) {
80        let _ = self.uplink.await;
81        let _ = self.downlink.await;
82    }
83
84    /// Stop both directions immediately.
85    pub fn abort(&self) {
86        self.uplink.abort();
87        self.downlink.abort();
88    }
89}
90
91/// Run the device-independent duplex loop between audio channels and a
92/// session.
93///
94/// - `mic`: mono PCM16 frames at `mic_hz` — resampled to
95///   [`SESSION_INPUT_HZ`] and written to the session.
96/// - `speaker`: receives [`Playback`] instructions; chunks are mono PCM16 at
97///   `speaker_hz`, resampled from [`SESSION_OUTPUT_HZ`]. An interruption
98///   arrives as [`Playback::Flush`].
99///
100/// The pump owns no devices: pair it with `cpal` streams
101/// (`Talk::talk` does exactly that), a WebSocket bridge, a test harness —
102/// anything that can fill and drain a channel.
103pub fn pump(
104    handle: &LiveHandle,
105    mic: mpsc::Receiver<Vec<i16>>,
106    mic_hz: u32,
107    speaker: mpsc::Sender<Playback>,
108    speaker_hz: u32,
109) -> VoicePump {
110    pump_processed(handle, mic, mic_hz, Vec::new(), speaker, speaker_hz)
111}
112
113/// [`pump`], with a chain of [`InputAudioProcessor`]s applied to each
114/// microphone frame before resampling — the insertion point for denoisers
115/// and client-side voice-activity gates. Processors run in order at the
116/// mic's native rate; an emptied frame (all-zero) still flows, so the
117/// session's own VAD sees continuous audio.
118///
119/// This is the seam third-party audio front-ends plug into — a
120/// DeepFilterNet-style denoiser, a Silero-style VAD gate, a proprietary
121/// vendor SDK — each as one `impl InputAudioProcessor` with no changes to
122/// the pump. Evaluate candidates with the same recorded call set on both
123/// transcription accuracy *and* added latency: a stage that cleans the audio
124/// but spends 200 ms per frame defeats the point.
125pub fn pump_processed(
126    handle: &LiveHandle,
127    mut mic: mpsc::Receiver<Vec<i16>>,
128    mic_hz: u32,
129    mut processors: Vec<Box<dyn InputAudioProcessor>>,
130    speaker: mpsc::Sender<Playback>,
131    speaker_hz: u32,
132) -> VoicePump {
133    let uplink_handle = handle.clone();
134    let uplink = tokio::spawn(async move {
135        while let Some(mut frame) = mic.recv().await {
136            for processor in &mut processors {
137                processor.process_frame(&mut frame);
138            }
139            let samples = resample(&frame, mic_hz, SESSION_INPUT_HZ);
140            if uplink_handle
141                .send_audio(i16_to_bytes(&samples).to_vec())
142                .await
143                .is_err()
144            {
145                break;
146            }
147        }
148    });
149
150    let mut events = handle.events();
151    let downlink = tokio::spawn(async move {
152        loop {
153            match events.recv().await {
154                Ok(event) => match playback_of(&event, speaker_hz) {
155                    Some(playback) => {
156                        if speaker.send(playback).await.is_err() {
157                            break;
158                        }
159                    }
160                    None => continue,
161                },
162                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
163                Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
164            }
165        }
166    });
167
168    VoicePump { uplink, downlink }
169}
170
171/// A reference [`InputAudioProcessor`]: an energy gate that silences frames
172/// whose RMS falls below a threshold, with a hold so word tails are not
173/// chopped. A floor, not a denoiser — it removes constant low-level room
174/// noise between utterances and nothing more.
175pub struct NoiseGate {
176    threshold_rms: f64,
177    hold_frames: u32,
178    open_for: u32,
179}
180
181impl NoiseGate {
182    /// `threshold_rms` in sample units (i16 full scale is 32767; telephone
183    /// speech typically sits well above 1000 RMS). `hold_frames` keeps the
184    /// gate open that many quiet frames after the last loud one (the same
185    /// knob as `Live::mic_noise_gate`).
186    pub fn new(threshold_rms: f64, hold_frames: u32) -> Self {
187        Self {
188            threshold_rms,
189            hold_frames,
190            open_for: 0,
191        }
192    }
193}
194
195impl InputAudioProcessor for NoiseGate {
196    fn process_frame(&mut self, frame: &mut Vec<i16>) {
197        if frame.is_empty() {
198            return;
199        }
200        let energy: f64 = frame.iter().map(|&s| f64::from(s) * f64::from(s)).sum();
201        let rms = (energy / frame.len() as f64).sqrt();
202        if rms >= self.threshold_rms {
203            self.open_for = self.hold_frames + 1;
204        }
205        if self.open_for > 0 {
206            self.open_for -= 1;
207        } else {
208            frame.fill(0);
209        }
210    }
211}
212
213/// Map one session event to a playback instruction, if it carries any.
214/// Pure — this is the whole downlink policy, testable without a session.
215pub(crate) fn playback_of(event: &LiveEvent, speaker_hz: u32) -> Option<Playback> {
216    match event {
217        LiveEvent::Audio(bytes) => {
218            let samples = bytes_to_i16(bytes)?;
219            Some(Playback::Chunk(resample(
220                samples,
221                SESSION_OUTPUT_HZ,
222                speaker_hz,
223            )))
224        }
225        LiveEvent::Interrupted => Some(Playback::Flush),
226        _ => None,
227    }
228}
229
230/// Linear-interpolation resampling for mono PCM16.
231///
232/// Deliberately simple: conversational speech through a linear resampler is
233/// transparent for this use, and zero dependencies keep the core buildable
234/// everywhere. Same-rate input is returned unchanged.
235pub fn resample(input: &[i16], from_hz: u32, to_hz: u32) -> Vec<i16> {
236    if from_hz == to_hz || input.is_empty() {
237        return input.to_vec();
238    }
239    let ratio = from_hz as f64 / to_hz as f64;
240    let out_len = ((input.len() as f64) / ratio).floor() as usize;
241    let mut out = Vec::with_capacity(out_len);
242    for i in 0..out_len {
243        let pos = i as f64 * ratio;
244        let idx = pos as usize;
245        let frac = pos - idx as f64;
246        let a = input[idx] as f64;
247        let b = input[(idx + 1).min(input.len() - 1)] as f64;
248        out.push((a + (b - a) * frac).round() as i16);
249    }
250    out
251}
252
253/// Down-mix interleaved multi-channel PCM16 to mono by averaging.
254pub fn downmix(interleaved: &[i16], channels: u16) -> Vec<i16> {
255    if channels <= 1 {
256        return interleaved.to_vec();
257    }
258    let channels = channels as usize;
259    interleaved
260        .chunks_exact(channels)
261        .map(|frame| (frame.iter().map(|&s| s as i32).sum::<i32>() / channels as i32) as i16)
262        .collect()
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use bytes::Bytes;
269
270    #[test]
271    fn resample_preserves_duration() {
272        // 480 samples at 48k = 10ms → 160 samples at 16k.
273        let input = vec![1000i16; 480];
274        assert_eq!(resample(&input, 48_000, 16_000).len(), 160);
275        // 240 samples at 24k = 10ms → 480 samples at 48k.
276        let output = vec![1000i16; 240];
277        assert_eq!(resample(&output, 24_000, 48_000).len(), 480);
278    }
279
280    #[test]
281    fn resample_same_rate_is_identity() {
282        let input = vec![1, -2, 3, -4];
283        assert_eq!(resample(&input, 16_000, 16_000), input);
284    }
285
286    #[test]
287    fn resample_interpolates_between_samples() {
288        // Doubling the rate of [0, 100] lands a midpoint near 50.
289        let out = resample(&[0, 100], 1, 2);
290        assert_eq!(out.len(), 4);
291        assert_eq!(out[0], 0);
292        assert_eq!(out[1], 50);
293    }
294
295    #[test]
296    fn downmix_averages_channels() {
297        // Stereo frames (L, R): (100, 300) → 200; (-50, 50) → 0.
298        assert_eq!(downmix(&[100, 300, -50, 50], 2), vec![200, 0]);
299        // Mono passes through.
300        assert_eq!(downmix(&[7, 8], 1), vec![7, 8]);
301    }
302
303    #[test]
304    fn audio_events_become_resampled_chunks() {
305        // 240 samples at the session's 24k output = 10ms → 480 at 48k.
306        let samples = vec![500i16; 240];
307        let event = LiveEvent::Audio(Bytes::copy_from_slice(i16_to_bytes(&samples)));
308        match playback_of(&event, 48_000) {
309            Some(Playback::Chunk(chunk)) => assert_eq!(chunk.len(), 480),
310            other => panic!("expected a chunk, got {other:?}"),
311        }
312    }
313
314    #[test]
315    fn interruption_becomes_flush() {
316        assert_eq!(
317            playback_of(&LiveEvent::Interrupted, 48_000),
318            Some(Playback::Flush)
319        );
320    }
321
322    #[test]
323    fn unrelated_events_produce_no_playback() {
324        assert_eq!(playback_of(&LiveEvent::TurnComplete, 48_000), None);
325    }
326
327    #[test]
328    fn noise_gate_silences_quiet_frames_with_hangover() {
329        let mut gate = NoiseGate::new(1_000.0, 1);
330        let loud = vec![8_000i16; 160];
331        let quiet = vec![50i16; 160];
332
333        let mut frame = loud.clone();
334        gate.process_frame(&mut frame);
335        assert_eq!(frame, loud, "speech passes untouched");
336
337        let mut frame = quiet.clone();
338        gate.process_frame(&mut frame);
339        assert_eq!(frame, quiet, "hangover keeps the tail of an utterance");
340
341        let mut frame = quiet.clone();
342        gate.process_frame(&mut frame);
343        assert_eq!(frame, vec![0i16; 160], "sustained quiet is gated");
344
345        let mut frame = loud.clone();
346        gate.process_frame(&mut frame);
347        assert_eq!(frame, loud, "the gate reopens on speech");
348    }
349
350    #[test]
351    fn resample_single_sample_upsampling() {
352        // Single sample upsampled should not panic and should produce clamped output
353        let result = resample(&[100i16], 8_000, 24_000);
354        assert!(
355            !result.is_empty(),
356            "upsampling single sample should produce output"
357        );
358        assert_eq!(
359            result[0], 100,
360            "single sample upsampled should preserve value"
361        );
362    }
363
364    #[test]
365    fn resample_single_sample_downsampling() {
366        // Downsampling single sample to lower rate should work
367        let result = resample(&[100i16], 24_000, 8_000);
368        // A single 24kHz sample downsampled to 8kHz may produce 0-1 samples depending on ratio
369        // floor(1 / (24000/8000)) = floor(1 / 3) = 0, but let's verify the function handles it
370        assert!(
371            result.len() <= 1,
372            "downsampling single sample should produce at most 1 sample"
373        );
374    }
375
376    #[test]
377    fn resample_extreme_upsampling_ratio() {
378        // 1 sample at 100 Hz to 48000 Hz (480x ratio)
379        let result = resample(&[1000i16], 100, 48_000);
380        assert!(
381            !result.is_empty(),
382            "extreme upsampling should produce output"
383        );
384        // With 1 sample input at low rate upsampled to high rate, we should get many samples
385        assert!(
386            result.len() >= 400,
387            "extreme upsampling should produce proportional output"
388        );
389    }
390
391    #[test]
392    fn resample_maintains_edge_indices_without_panic() {
393        // Verify that resampling with various edge lengths doesn't panic on index access
394        for len in &[1, 2, 3, 159, 160, 161, 479, 480, 481] {
395            let input = vec![100i16; *len];
396            let down = resample(&input, 48_000, 16_000);
397            let up = resample(&input, 16_000, 48_000);
398            assert!(
399                !down.is_empty() || *len <= 4,
400                "downsampling should produce some output or be very short"
401            );
402            // Up should always produce non-empty unless input is empty
403            assert!(
404                !up.is_empty(),
405                "upsampling non-empty input should produce output"
406            );
407        }
408    }
409
410    #[test]
411    fn resample_boundary_value_clamping() {
412        // Extreme values should not overflow or produce NaN
413        let extreme = vec![i16::MIN, 0, i16::MAX];
414        let result = resample(&extreme, 16_000, 24_000);
415        for &sample in &result {
416            assert!(
417                (i16::MIN..=i16::MAX).contains(&sample),
418                "resampled sample in valid i16 range"
419            );
420        }
421    }
422
423    #[test]
424    fn noise_gate_accumulates_open_duration() {
425        // Test that open_for counter works correctly over multiple frames
426        let mut gate = NoiseGate::new(1_000.0, 3);
427        let loud = vec![8_000i16; 160];
428        let quiet = vec![100i16; 160];
429
430        // Loud frame: open_for = 4 (hold_frames + 1), then -= 1 → 3
431        let mut frame = loud.clone();
432        gate.process_frame(&mut frame);
433        assert_eq!(frame, loud);
434
435        // Quiet frame 1: open_for = 3, -= 1 → 2, gate open
436        let mut frame = quiet.clone();
437        gate.process_frame(&mut frame);
438        assert_eq!(frame, quiet);
439
440        // Quiet frame 2: open_for = 2, -= 1 → 1, gate open
441        let mut frame = quiet.clone();
442        gate.process_frame(&mut frame);
443        assert_eq!(frame, quiet);
444
445        // Quiet frame 3: open_for = 1, -= 1 → 0, gate open
446        let mut frame = quiet.clone();
447        gate.process_frame(&mut frame);
448        assert_eq!(frame, quiet);
449
450        // Quiet frame 4: open_for = 0, gate closed
451        let mut frame = quiet.clone();
452        gate.process_frame(&mut frame);
453        assert_eq!(frame, vec![0i16; 160]);
454    }
455}