Skip to main content

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;
42mod resampler;
43pub use resampler::StreamResampler;
44
45#[cfg(feature = "denoise")]
46pub use denoise::Denoiser;
47
48/// The mic-chain stage trait — one `process_frame` over a PCM16 frame in
49/// place. Defined by the L1 runtime (`Live::mic_processor` takes the same
50/// trait), re-exported here so a voice application has one name for it.
51pub use gemini_adk_rs::live::InputAudioProcessor;
52use gemini_adk_rs::live::{LiveEvent, LiveHandle};
53use gemini_genai_rs::prelude::{bytes_to_i16, i16_to_bytes};
54use tokio::sync::mpsc;
55use tokio::task::JoinHandle;
56
57/// The sample rate the Live API expects on its input stream.
58pub const SESSION_INPUT_HZ: u32 = 16_000;
59/// The sample rate the Live API produces on its output stream.
60pub const SESSION_OUTPUT_HZ: u32 = 24_000;
61
62/// One playback instruction to the speaker side.
63#[derive(Debug, Clone, PartialEq)]
64pub enum Playback {
65    /// PCM16 mono samples at the sink rate requested from [`pump`].
66    Chunk(Vec<i16>),
67    /// Barge-in: the model was interrupted — drop every buffered sample
68    /// immediately. Playing on is the one unforgivable sin of a voice UI.
69    Flush,
70}
71
72/// The two halves of a running duplex pump. Ends on its own when the session
73/// closes or either channel hangs up; [`abort`](VoicePump::abort) ends it early.
74pub struct VoicePump {
75    uplink: JoinHandle<()>,
76    downlink: JoinHandle<()>,
77}
78
79impl VoicePump {
80    /// Wait for both directions to finish (session closed or channels dropped).
81    pub async fn join(self) {
82        let _ = self.uplink.await;
83        let _ = self.downlink.await;
84    }
85
86    /// Stop both directions immediately.
87    pub fn abort(&self) {
88        self.uplink.abort();
89        self.downlink.abort();
90    }
91}
92
93/// Run the device-independent duplex loop between audio channels and a
94/// session.
95///
96/// - `mic`: mono PCM16 frames at `mic_hz` — resampled to
97///   [`SESSION_INPUT_HZ`] and written to the session.
98/// - `speaker`: receives [`Playback`] instructions; chunks are mono PCM16 at
99///   `speaker_hz`, resampled from [`SESSION_OUTPUT_HZ`]. An interruption
100///   arrives as [`Playback::Flush`].
101///
102/// The pump reports what it plays to the session's
103/// [`PlaybackClock`](gemini_adk_rs::live::PlaybackClock), so an interruption
104/// cuts the model's transcript to what the listener heard.
105///
106/// The pump owns no devices: pair it with `cpal` streams
107/// (`Talk::talk` does exactly that), a WebSocket bridge, a test harness —
108/// anything that can fill and drain a channel.
109pub fn pump(
110    handle: &LiveHandle,
111    mic: mpsc::Receiver<Vec<i16>>,
112    mic_hz: u32,
113    speaker: mpsc::Sender<Playback>,
114    speaker_hz: u32,
115) -> VoicePump {
116    pump_processed(handle, mic, mic_hz, Vec::new(), speaker, speaker_hz)
117}
118
119/// [`pump`], with a chain of [`InputAudioProcessor`]s applied to each
120/// microphone frame before resampling — the insertion point for denoisers
121/// and client-side voice-activity gates. Processors run in order at the
122/// mic's native rate; an emptied frame (all-zero) still flows, so the
123/// session's own VAD sees continuous audio.
124///
125/// This is the seam third-party audio front-ends plug into — a
126/// DeepFilterNet-style denoiser, a Silero-style VAD gate, a proprietary
127/// vendor SDK — each as one `impl InputAudioProcessor` with no changes to
128/// the pump. Evaluate candidates with the same recorded call set on both
129/// transcription accuracy *and* added latency: a stage that cleans the audio
130/// but spends 200 ms per frame defeats the point.
131pub fn pump_processed(
132    handle: &LiveHandle,
133    mut mic: mpsc::Receiver<Vec<i16>>,
134    mic_hz: u32,
135    mut processors: Vec<Box<dyn InputAudioProcessor>>,
136    speaker: mpsc::Sender<Playback>,
137    speaker_hz: u32,
138) -> VoicePump {
139    let uplink_handle = handle.clone();
140    let uplink = tokio::spawn(async move {
141        // Stateful and anti-aliased: see `StreamResampler`.
142        let mut to_session = StreamResampler::new(mic_hz, SESSION_INPUT_HZ);
143        while let Some(mut frame) = mic.recv().await {
144            for processor in &mut processors {
145                processor.process_frame(&mut frame);
146            }
147            let samples = to_session.process(&frame);
148            if uplink_handle
149                .send_audio(i16_to_bytes(&samples).to_vec())
150                .await
151                .is_err()
152            {
153                break;
154            }
155        }
156    });
157
158    let mut events = handle.events();
159    let clock = handle.playback().clone();
160    let downlink = tokio::spawn(async move {
161        let mut to_speaker = StreamResampler::new(SESSION_OUTPUT_HZ, speaker_hz);
162        loop {
163            match events.recv().await {
164                Ok(event) => match playback_of(&event, &mut to_speaker) {
165                    Some(playback) => {
166                        // Report playback, so an interruption cuts the
167                        // model's transcript to what the listener heard.
168                        match &playback {
169                            Playback::Chunk(samples) => {
170                                clock.queued(std::time::Duration::from_secs_f64(
171                                    samples.len() as f64 / f64::from(speaker_hz.max(1)),
172                                ));
173                            }
174                            Playback::Flush => clock.flushed(),
175                        }
176                        if speaker.send(playback).await.is_err() {
177                            break;
178                        }
179                    }
180                    None => continue,
181                },
182                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
183                Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
184            }
185        }
186    });
187
188    VoicePump { uplink, downlink }
189}
190
191/// A reference [`InputAudioProcessor`]: an energy gate that silences frames
192/// whose RMS falls below a threshold, with a hold so word tails are not
193/// chopped. A floor, not a denoiser — it removes constant low-level room
194/// noise between utterances and nothing more.
195pub struct NoiseGate {
196    threshold_rms: f64,
197    hold_frames: u32,
198    open_for: u32,
199}
200
201impl NoiseGate {
202    /// `threshold_rms` in sample units (i16 full scale is 32767; telephone
203    /// speech typically sits well above 1000 RMS). `hold_frames` keeps the
204    /// gate open that many quiet frames after the last loud one (the same
205    /// knob as `Live::mic_noise_gate`).
206    pub fn new(threshold_rms: f64, hold_frames: u32) -> Self {
207        Self {
208            threshold_rms,
209            hold_frames,
210            open_for: 0,
211        }
212    }
213}
214
215impl InputAudioProcessor for NoiseGate {
216    fn process_frame(&mut self, frame: &mut Vec<i16>) {
217        if frame.is_empty() {
218            return;
219        }
220        let energy: f64 = frame.iter().map(|&s| f64::from(s) * f64::from(s)).sum();
221        let rms = (energy / frame.len() as f64).sqrt();
222        if rms >= self.threshold_rms {
223            self.open_for = self.hold_frames + 1;
224        }
225        if self.open_for > 0 {
226            self.open_for -= 1;
227        } else {
228            frame.fill(0);
229        }
230    }
231}
232
233/// Map one session event to a playback instruction, if it carries any.
234/// Pure — this is the whole downlink policy, testable without a session.
235pub(crate) fn playback_of(event: &LiveEvent, to_speaker: &mut StreamResampler) -> Option<Playback> {
236    match event {
237        LiveEvent::Audio(bytes) => {
238            let samples = bytes_to_i16(bytes)?;
239            Some(Playback::Chunk(to_speaker.process(samples)))
240        }
241        LiveEvent::Interrupted => {
242            // The flushed audio's tail must not bleed into the next turn.
243            to_speaker.reset();
244            Some(Playback::Flush)
245        }
246        _ => None,
247    }
248}
249
250/// Linear-interpolation resampling for mono PCM16, one buffer at a time.
251///
252/// Cheap, with no filter and no state: fine between rates that are both
253/// well above the speech band, but it aliases when going down to telephone
254/// rates and clicks at chunk boundaries. Streams use [`StreamResampler`],
255/// which the voice pump and telephony bridges do. Same-rate input is
256/// returned unchanged.
257pub fn resample(input: &[i16], from_hz: u32, to_hz: u32) -> Vec<i16> {
258    if from_hz == to_hz || input.is_empty() {
259        return input.to_vec();
260    }
261    let ratio = from_hz as f64 / to_hz as f64;
262    let out_len = ((input.len() as f64) / ratio).floor() as usize;
263    let mut out = Vec::with_capacity(out_len);
264    for i in 0..out_len {
265        let pos = i as f64 * ratio;
266        let idx = pos as usize;
267        let frac = pos - idx as f64;
268        let a = input[idx] as f64;
269        let b = input[(idx + 1).min(input.len() - 1)] as f64;
270        out.push((a + (b - a) * frac).round() as i16);
271    }
272    out
273}
274
275/// Down-mix interleaved multi-channel PCM16 to mono by averaging.
276pub fn downmix(interleaved: &[i16], channels: u16) -> Vec<i16> {
277    if channels <= 1 {
278        return interleaved.to_vec();
279    }
280    let channels = channels as usize;
281    interleaved
282        .chunks_exact(channels)
283        .map(|frame| (frame.iter().map(|&s| s as i32).sum::<i32>() / channels as i32) as i16)
284        .collect()
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use bytes::Bytes;
291
292    #[test]
293    fn resample_preserves_duration() {
294        // 480 samples at 48k = 10ms → 160 samples at 16k.
295        let input = vec![1000i16; 480];
296        assert_eq!(resample(&input, 48_000, 16_000).len(), 160);
297        // 240 samples at 24k = 10ms → 480 samples at 48k.
298        let output = vec![1000i16; 240];
299        assert_eq!(resample(&output, 24_000, 48_000).len(), 480);
300    }
301
302    #[test]
303    fn resample_same_rate_is_identity() {
304        let input = vec![1, -2, 3, -4];
305        assert_eq!(resample(&input, 16_000, 16_000), input);
306    }
307
308    #[test]
309    fn resample_interpolates_between_samples() {
310        // Doubling the rate of [0, 100] lands a midpoint near 50.
311        let out = resample(&[0, 100], 1, 2);
312        assert_eq!(out.len(), 4);
313        assert_eq!(out[0], 0);
314        assert_eq!(out[1], 50);
315    }
316
317    #[test]
318    fn downmix_averages_channels() {
319        // Stereo frames (L, R): (100, 300) → 200; (-50, 50) → 0.
320        assert_eq!(downmix(&[100, 300, -50, 50], 2), vec![200, 0]);
321        // Mono passes through.
322        assert_eq!(downmix(&[7, 8], 1), vec![7, 8]);
323    }
324
325    #[test]
326    fn audio_events_become_resampled_chunks() {
327        // 240 samples at the session's 24k output = 10ms → 480 at 48k.
328        let samples = vec![500i16; 240];
329        let event = LiveEvent::Audio(Bytes::copy_from_slice(i16_to_bytes(&samples)));
330        match playback_of(&event, &mut StreamResampler::new(SESSION_OUTPUT_HZ, 48_000)) {
331            Some(Playback::Chunk(chunk)) => assert_eq!(chunk.len(), 480),
332            other => panic!("expected a chunk, got {other:?}"),
333        }
334    }
335
336    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
337    async fn the_pump_reports_what_it_plays() {
338        use base64::Engine as _;
339        use std::time::Duration;
340        // One second of model audio, twice, then the caller barges in.
341        let second = serde_json::json!({ "serverContent": { "modelTurn": { "parts": [{
342            "inlineData": {
343                "mimeType": "audio/pcm;rate=24000",
344                "data": base64::engine::general_purpose::STANDARD.encode(vec![0u8; 48_000]),
345            }
346        }] } } });
347        let (transport, control) = crate::live::scripted::ScriptedServer::new()
348            .frame(second.clone())
349            .frame(second)
350            .interrupts()
351            .into_transport();
352        let handle = crate::live::Live::builder()
353            .connect_with_transport(transport)
354            .await
355            .unwrap();
356        let (_mic_tx, mic_rx) = mpsc::channel(4);
357        let (speaker_tx, mut speaker_rx) = mpsc::channel(16);
358        let _pump = pump(&handle, mic_rx, 16_000, speaker_tx, 8_000);
359        assert_eq!(handle.playback().heard(), None);
360        control.release();
361
362        let mut got = Vec::new();
363        while got.len() < 3 {
364            match tokio::time::timeout(Duration::from_secs(3), speaker_rx.recv()).await {
365                Ok(Some(Playback::Chunk(c))) if c.is_empty() => {}
366                Ok(Some(p)) => got.push(p),
367                other => panic!("pump stopped early: {other:?}"),
368            }
369        }
370        assert!(matches!(got[2], Playback::Flush));
371        // Two seconds were queued; the flush came almost at once, so little
372        // was heard, and nothing more is heard after it.
373        let heard = handle.playback().heard().expect("the pump reports");
374        assert!(heard < Duration::from_millis(500), "{heard:?}");
375        tokio::time::sleep(Duration::from_millis(100)).await;
376        assert_eq!(handle.playback().heard(), Some(heard));
377        handle.disconnect().await.ok();
378    }
379
380    #[test]
381    fn interruption_becomes_flush() {
382        assert_eq!(
383            playback_of(
384                &LiveEvent::Interrupted,
385                &mut StreamResampler::new(SESSION_OUTPUT_HZ, 48_000)
386            ),
387            Some(Playback::Flush)
388        );
389    }
390
391    #[test]
392    fn unrelated_events_produce_no_playback() {
393        assert_eq!(
394            playback_of(
395                &LiveEvent::TurnComplete,
396                &mut StreamResampler::new(SESSION_OUTPUT_HZ, 48_000)
397            ),
398            None
399        );
400    }
401
402    #[test]
403    fn noise_gate_silences_quiet_frames_with_hangover() {
404        let mut gate = NoiseGate::new(1_000.0, 1);
405        let loud = vec![8_000i16; 160];
406        let quiet = vec![50i16; 160];
407
408        let mut frame = loud.clone();
409        gate.process_frame(&mut frame);
410        assert_eq!(frame, loud, "speech passes untouched");
411
412        let mut frame = quiet.clone();
413        gate.process_frame(&mut frame);
414        assert_eq!(frame, quiet, "hangover keeps the tail of an utterance");
415
416        let mut frame = quiet.clone();
417        gate.process_frame(&mut frame);
418        assert_eq!(frame, vec![0i16; 160], "sustained quiet is gated");
419
420        let mut frame = loud.clone();
421        gate.process_frame(&mut frame);
422        assert_eq!(frame, loud, "the gate reopens on speech");
423    }
424
425    #[test]
426    fn resample_single_sample_upsampling() {
427        // Single sample upsampled should not panic and should produce clamped output
428        let result = resample(&[100i16], 8_000, 24_000);
429        assert!(
430            !result.is_empty(),
431            "upsampling single sample should produce output"
432        );
433        assert_eq!(
434            result[0], 100,
435            "single sample upsampled should preserve value"
436        );
437    }
438
439    #[test]
440    fn resample_single_sample_downsampling() {
441        // Downsampling single sample to lower rate should work
442        let result = resample(&[100i16], 24_000, 8_000);
443        // A single 24kHz sample downsampled to 8kHz may produce 0-1 samples depending on ratio
444        // floor(1 / (24000/8000)) = floor(1 / 3) = 0, but let's verify the function handles it
445        assert!(
446            result.len() <= 1,
447            "downsampling single sample should produce at most 1 sample"
448        );
449    }
450
451    #[test]
452    fn resample_extreme_upsampling_ratio() {
453        // 1 sample at 100 Hz to 48000 Hz (480x ratio)
454        let result = resample(&[1000i16], 100, 48_000);
455        assert!(
456            !result.is_empty(),
457            "extreme upsampling should produce output"
458        );
459        // With 1 sample input at low rate upsampled to high rate, we should get many samples
460        assert!(
461            result.len() >= 400,
462            "extreme upsampling should produce proportional output"
463        );
464    }
465
466    #[test]
467    fn resample_maintains_edge_indices_without_panic() {
468        // Verify that resampling with various edge lengths doesn't panic on index access
469        for len in &[1, 2, 3, 159, 160, 161, 479, 480, 481] {
470            let input = vec![100i16; *len];
471            let down = resample(&input, 48_000, 16_000);
472            let up = resample(&input, 16_000, 48_000);
473            assert!(
474                !down.is_empty() || *len <= 4,
475                "downsampling should produce some output or be very short"
476            );
477            // Up should always produce non-empty unless input is empty
478            assert!(
479                !up.is_empty(),
480                "upsampling non-empty input should produce output"
481            );
482        }
483    }
484
485    #[test]
486    fn resample_boundary_value_clamping() {
487        // Extreme values should not overflow or produce NaN
488        let extreme = vec![i16::MIN, 0, i16::MAX];
489        let result = resample(&extreme, 16_000, 24_000);
490        for &sample in &result {
491            assert!(
492                (i16::MIN..=i16::MAX).contains(&sample),
493                "resampled sample in valid i16 range"
494            );
495        }
496    }
497
498    #[test]
499    fn noise_gate_accumulates_open_duration() {
500        // Test that open_for counter works correctly over multiple frames
501        let mut gate = NoiseGate::new(1_000.0, 3);
502        let loud = vec![8_000i16; 160];
503        let quiet = vec![100i16; 160];
504
505        // Loud frame: open_for = 4 (hold_frames + 1), then -= 1 → 3
506        let mut frame = loud.clone();
507        gate.process_frame(&mut frame);
508        assert_eq!(frame, loud);
509
510        // Quiet frame 1: open_for = 3, -= 1 → 2, gate open
511        let mut frame = quiet.clone();
512        gate.process_frame(&mut frame);
513        assert_eq!(frame, quiet);
514
515        // Quiet frame 2: open_for = 2, -= 1 → 1, gate open
516        let mut frame = quiet.clone();
517        gate.process_frame(&mut frame);
518        assert_eq!(frame, quiet);
519
520        // Quiet frame 3: open_for = 1, -= 1 → 0, gate open
521        let mut frame = quiet.clone();
522        gate.process_frame(&mut frame);
523        assert_eq!(frame, quiet);
524
525        // Quiet frame 4: open_for = 0, gate closed
526        let mut frame = quiet.clone();
527        gate.process_frame(&mut frame);
528        assert_eq!(frame, vec![0i16; 160]);
529    }
530}