gemini_adk_rs/live/
playback.rs

1//! What the listener heard of the model's speech.
2//!
3//! The model streams its audio faster than it plays, and its transcript runs
4//! further ahead still. Measured on the Live API, output transcription
5//! arrives up to twice as far into the answer as the audio delivered with
6//! it. When the listener barges in, the model's transcript therefore holds
7//! words the listener never heard: a confirmation cut off halfway, or a
8//! verbatim disclosure that was never finished.
9//!
10//! Whatever plays the audio reports to the session's [`PlaybackClock`]
11//! ([`LiveHandle::playback`](super::LiveHandle::playback)). It reports the
12//! audio it queues and the moment it flushes on barge-in. The voice pump,
13//! and so every telephony bridge built on it, reports automatically. When
14//! the model is interrupted, the runtime reads the clock and cuts the
15//! model's side of the turn to what was heard. That cut applies to the
16//! transcript buffer, the final `on_output_transcript` callback and the
17//! verbatim check. Heard audio becomes text through the session's speaking
18//! rate, which is calibrated on its uninterrupted turns (16 characters a
19//! second until then, which matches English speech on current Live models).
20//! The cut ends at the last whole word.
21//!
22//! A session with no playback reporter cuts to the audio it received, which
23//! is an upper bound on what could have been heard. A turn with no audio,
24//! as in a text session, is not cut.
25//!
26//! The model's own context is not changed. The Live API keeps what it sent,
27//! so the model may still believe it finished its sentence.
28
29use std::sync::Arc;
30use std::time::{Duration, Instant};
31
32use parking_lot::Mutex;
33
34use crate::clock::SharedClock;
35
36/// English speech on current Live models, in transcript characters per
37/// second of audio, used until a session has calibrated its own.
38const DEFAULT_CHARS_PER_SEC: f64 = 16.0;
39/// Uninterrupted speech a session needs before its own rate is trusted.
40const CALIBRATION_MIN: Duration = Duration::from_secs(2);
41/// Live API output: 24 kHz mono PCM16.
42const OUTPUT_BYTES_PER_SEC: f64 = 48_000.0;
43
44/// Playback progress of the model's audio, reported by whatever plays it.
45/// See the [module docs](self).
46///
47/// It assumes queued audio plays in real time after audio queued before it,
48/// as a phone line or a sound card does. Cloning shares the clock.
49#[derive(Clone)]
50pub struct PlaybackClock {
51    inner: Arc<Mutex<Queue>>,
52    clock: SharedClock,
53}
54
55impl std::fmt::Debug for PlaybackClock {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        let q = self.inner.lock();
58        f.debug_struct("PlaybackClock")
59            .field("reporting", &q.reporting)
60            .field("scheduled", &q.scheduled)
61            .finish()
62    }
63}
64
65#[derive(Debug, Default)]
66struct Queue {
67    reporting: bool,
68    /// Audio that has played or will play: everything queued, less what a
69    /// flush dropped.
70    scheduled: Duration,
71    /// When the queued audio finishes playing.
72    drains_at: Option<Instant>,
73}
74
75impl Queue {
76    fn remaining(&self, now: Instant) -> Duration {
77        self.drains_at
78            .map_or(Duration::ZERO, |end| end.saturating_duration_since(now))
79    }
80}
81
82impl PlaybackClock {
83    /// A clock on `clock`'s time (the session's, so replays stay
84    /// deterministic).
85    pub fn new(clock: SharedClock) -> Self {
86        Self {
87            inner: Arc::default(),
88            clock,
89        }
90    }
91
92    /// `audio` of the model's speech was handed to the speaker just now. It
93    /// plays after the audio already queued.
94    pub fn queued(&self, audio: Duration) {
95        self.queued_at(self.clock.now(), audio);
96    }
97
98    /// Playback was cut (barge-in): the queued audio not yet played is
99    /// dropped.
100    pub fn flushed(&self) {
101        self.flushed_at(self.clock.now());
102    }
103
104    /// All of the model's audio the listener has heard this session, or
105    /// `None` if no playback has been reported.
106    pub fn heard(&self) -> Option<Duration> {
107        self.heard_at(self.clock.now())
108    }
109
110    pub(crate) fn queued_at(&self, now: Instant, audio: Duration) {
111        let mut q = self.inner.lock();
112        q.reporting = true;
113        let start = q.drains_at.filter(|&end| end > now).unwrap_or(now);
114        q.drains_at = Some(start + audio);
115        q.scheduled += audio;
116    }
117
118    pub(crate) fn flushed_at(&self, now: Instant) {
119        let mut q = self.inner.lock();
120        let dropped = q.remaining(now);
121        q.scheduled = q.scheduled.saturating_sub(dropped);
122        q.drains_at = Some(now);
123    }
124
125    pub(crate) fn heard_at(&self, now: Instant) -> Option<Duration> {
126        let q = self.inner.lock();
127        q.reporting
128            .then(|| q.scheduled.saturating_sub(q.remaining(now)))
129    }
130
131    /// Everything queued so far, played or not: where the next audio starts.
132    fn scheduled(&self) -> Duration {
133        self.inner.lock().scheduled
134    }
135}
136
137/// The router's account of the model's current turn, for cutting its
138/// transcript to what was heard.
139#[derive(Debug, Default)]
140pub(crate) struct TurnSpeech {
141    /// Model audio received this turn.
142    audio: Duration,
143    /// Output transcript characters received this turn.
144    chars: usize,
145    /// Where this turn's audio starts on the playback clock.
146    starts_at: Option<Duration>,
147    /// The turn was interrupted; what follows it is not counted.
148    cut: bool,
149    /// Calibration over uninterrupted turns.
150    calibrated_chars: usize,
151    calibrated_audio: Duration,
152}
153
154impl TurnSpeech {
155    pub(crate) fn on_audio(&mut self, bytes: usize, playback: &PlaybackClock) {
156        if self.cut {
157            return;
158        }
159        if self.starts_at.is_none() {
160            self.starts_at = Some(playback.scheduled());
161        }
162        self.audio += Duration::from_secs_f64(bytes as f64 / OUTPUT_BYTES_PER_SEC);
163    }
164
165    pub(crate) fn on_text(&mut self, text: &str) {
166        if !self.cut {
167            self.chars += text.chars().count();
168        }
169    }
170
171    /// The model was interrupted: how many of this turn's transcript
172    /// characters were heard, or `None` to leave the transcript whole.
173    pub(crate) fn on_interrupted(&mut self, playback: &PlaybackClock) -> Option<usize> {
174        self.on_interrupted_at(playback, playback.clock.now())
175    }
176
177    pub(crate) fn on_interrupted_at(
178        &mut self,
179        playback: &PlaybackClock,
180        now: Instant,
181    ) -> Option<usize> {
182        if self.cut {
183            return None;
184        }
185        self.cut = true;
186        if self.audio.is_zero() || self.chars == 0 {
187            return None;
188        }
189        let heard = match (playback.heard_at(now), self.starts_at) {
190            (Some(total), Some(start)) => total.saturating_sub(start).min(self.audio),
191            _ => self.audio,
192        };
193        let chars = (heard.as_secs_f64() * self.chars_per_sec()).floor() as usize;
194        Some(chars.min(self.chars))
195    }
196
197    pub(crate) fn on_turn_complete(&mut self) {
198        if !self.cut && self.chars > 0 && self.audio >= Duration::from_millis(500) {
199            self.calibrated_chars += self.chars;
200            self.calibrated_audio += self.audio;
201        }
202        self.audio = Duration::ZERO;
203        self.chars = 0;
204        self.starts_at = None;
205        self.cut = false;
206    }
207
208    fn chars_per_sec(&self) -> f64 {
209        if self.calibrated_audio >= CALIBRATION_MIN {
210            self.calibrated_chars as f64 / self.calibrated_audio.as_secs_f64()
211        } else {
212            DEFAULT_CHARS_PER_SEC
213        }
214    }
215}
216
217/// The byte length of the heard prefix of `text`: at most `chars`
218/// characters, ending at the last whole word, without trailing space.
219pub(crate) fn heard_prefix(text: &str, chars: usize) -> usize {
220    let Some((cut, next)) = text.char_indices().nth(chars) else {
221        return text.len();
222    };
223    let head = &text[..cut];
224    // Mid-word: the partly heard word goes.
225    let head = if next.is_whitespace() || head.ends_with(char::is_whitespace) {
226        head
227    } else {
228        head.rfind(char::is_whitespace).map_or("", |i| &head[..i])
229    };
230    head.trim_end().len()
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::clock::system_clock;
237
238    fn ms(n: u64) -> Duration {
239        Duration::from_millis(n)
240    }
241
242    /// Bytes of 24 kHz PCM16 for `n` milliseconds.
243    fn audio_bytes(n: u64) -> usize {
244        (n * 48) as usize
245    }
246
247    #[test]
248    fn heard_follows_real_time_and_flush_drops_the_rest() {
249        let clock = PlaybackClock::new(system_clock());
250        let t0 = Instant::now();
251        assert_eq!(clock.heard_at(t0), None);
252        // 3 s of audio arrives at once: it plays until t0 + 3 s.
253        clock.queued_at(t0, ms(3_000));
254        assert_eq!(clock.heard_at(t0 + ms(1_200)), Some(ms(1_200)));
255        // More audio queues behind it.
256        clock.queued_at(t0 + ms(1_500), ms(1_000));
257        assert_eq!(clock.heard_at(t0 + ms(3_500)), Some(ms(3_500)));
258        // Barge-in at 3.6 s: the last 400 ms never play.
259        clock.flushed_at(t0 + ms(3_600));
260        assert_eq!(clock.heard_at(t0 + ms(9_000)), Some(ms(3_600)));
261        // After an idle gap, new audio starts when it arrives.
262        clock.queued_at(t0 + ms(10_000), ms(500));
263        assert_eq!(clock.heard_at(t0 + ms(10_200)), Some(ms(3_800)));
264    }
265
266    #[test]
267    fn an_interrupted_turn_is_cut_to_the_audio_heard() {
268        let playback = PlaybackClock::new(system_clock());
269        let mut speech = TurnSpeech::default();
270        let t0 = Instant::now();
271        // A first, uninterrupted turn calibrates the rate: 40 chars in 2 s.
272        speech.on_audio(audio_bytes(2_000), &playback);
273        playback.queued_at(t0, ms(2_000));
274        speech.on_text(&"x".repeat(40));
275        speech.on_turn_complete();
276        assert_eq!(speech.chars_per_sec(), 20.0);
277
278        // The next turn starts at 10 s. The model streams 5 s of audio and
279        // 150 characters within a second; the caller cuts in 1.5 s into
280        // playback.
281        let start = t0 + ms(10_000);
282        speech.on_audio(audio_bytes(5_000), &playback);
283        playback.queued_at(start, ms(5_000));
284        speech.on_text(&"y".repeat(150));
285        let heard = speech.on_interrupted_at(&playback, start + ms(1_500));
286        assert_eq!(heard, Some(30)); // 1.5 s at 20 chars/s
287        // What follows the interruption in the same turn is not counted,
288        // and an interrupted turn does not calibrate.
289        speech.on_text("zzz");
290        speech.on_turn_complete();
291        assert_eq!(speech.chars_per_sec(), 20.0);
292    }
293
294    #[test]
295    fn without_a_reporter_the_cut_is_the_audio_received() {
296        let playback = PlaybackClock::new(system_clock());
297        let mut speech = TurnSpeech::default();
298        speech.on_audio(audio_bytes(2_000), &playback);
299        speech.on_text(&"w ".repeat(100));
300        // 2 s at the default rate.
301        assert_eq!(
302            speech.on_interrupted_at(&playback, Instant::now()),
303            Some(32)
304        );
305    }
306
307    #[test]
308    fn a_turn_without_audio_or_text_is_left_whole() {
309        let playback = PlaybackClock::new(system_clock());
310        let mut text_only = TurnSpeech::default();
311        text_only.on_text("a text session's reply");
312        assert_eq!(text_only.on_interrupted(&playback), None);
313
314        let mut untranscribed = TurnSpeech::default();
315        untranscribed.on_audio(audio_bytes(1_000), &playback);
316        assert_eq!(untranscribed.on_interrupted(&playback), None);
317    }
318
319    #[test]
320    fn the_heard_prefix_ends_at_a_whole_word() {
321        let text = "Your table is booked for Friday at eight.";
322        assert_eq!(&text[..heard_prefix(text, 17)], "Your table is"); // mid-word
323        assert_eq!(&text[..heard_prefix(text, 13)], "Your table is"); // at a space
324        assert_eq!(&text[..heard_prefix(text, 14)], "Your table is"); // after one
325        assert_eq!(&text[..heard_prefix(text, 3)], ""); // not one whole word
326        assert_eq!(heard_prefix(text, 400), text.len());
327        // Multi-byte text is cut on character boundaries.
328        let accented = "Réservation confirmée à huit heures";
329        assert_eq!(
330            &accented[..heard_prefix(accented, 24)],
331            "Réservation confirmée à"
332        );
333    }
334}