Skip to main content

gpui_component/speech/
state.rs

1use std::{cell::OnceCell, rc::Rc, time::Duration};
2
3use gpui::{App, Context, EventEmitter, Pixels, SharedString, Subscription, Task, WeakEntity};
4
5use super::{
6    AudioInput, AudioSink, RecognitionSession, SpeechError, SpeechRecognizer, SpeechSink,
7    level::LevelMeter, surface::LiquidSurfaces,
8};
9
10/// Default time [`SpeechState::stop`] waits for the final result.
11const DEFAULT_STOP_TIMEOUT: Duration = Duration::from_secs(3);
12
13/// Where a [`SpeechState`] is in its session.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum SpeechStatus {
16    /// No session is running.
17    #[default]
18    Idle,
19    /// Audio is being captured while the recognizer connects.
20    Connecting,
21    /// Audio is being captured and recognized.
22    Recording,
23    /// Capture stopped; waiting for the recognizer's final result.
24    Stopping,
25}
26
27impl SpeechStatus {
28    /// Whether a session is running.
29    pub fn is_active(self) -> bool {
30        self != Self::Idle
31    }
32
33    /// Whether the microphone is capturing.
34    pub fn is_capturing(self) -> bool {
35        matches!(self, Self::Connecting | Self::Recording)
36    }
37}
38
39/// Events emitted by [`SpeechState`].
40#[derive(Debug, Clone)]
41pub enum SpeechEvent {
42    /// A session started and audio is being captured.
43    Started,
44    /// The transcript changed: every committed phrase followed by the current
45    /// hypothesis. Later events supersede earlier ones.
46    Partial(SharedString),
47    /// The session ended normally with this transcript, possibly empty.
48    Final(SharedString),
49    /// The session was cancelled and its transcript discarded.
50    Cancelled,
51    /// The session failed and ended.
52    Error(SpeechError),
53}
54
55struct Session {
56    id: usize,
57    /// Dropping this stops capture.
58    capture: Option<Subscription>,
59    recognition: Box<dyn RecognitionSession>,
60    _stop_timeout: Option<Task<()>>,
61}
62
63/// The state of a speech input: captures audio from an [`AudioInput`], feeds it
64/// to a [`SpeechRecognizer`] and tracks the transcript.
65///
66/// Render it with [`SpeechButton`](super::SpeechButton) and
67/// [`SpeechWaveform`](super::SpeechWaveform), and subscribe to [`SpeechEvent`]
68/// to receive the text.
69///
70/// The recognizer is, in order: the one passed to [`Self::recognizer`]; else
71/// the platform's `SystemRecognizer`, unless
72/// [`Self::system_fallback`] turned it off; else none, and the state is not
73/// available. The input defaults to the `Microphone`.
74/// Both defaults need the `speech` feature.
75pub struct SpeechState {
76    recognizer: Option<Rc<dyn SpeechRecognizer>>,
77    input: Option<Rc<dyn AudioInput>>,
78    system_fallback: bool,
79    system_recognizer: OnceCell<Option<Rc<dyn SpeechRecognizer>>>,
80    stop_timeout: Duration,
81    status: SpeechStatus,
82    session: Option<Session>,
83    next_session: usize,
84    committed: String,
85    hypothesis: SharedString,
86    meter: LevelMeter,
87    surfaces: LiquidSurfaces,
88}
89
90impl EventEmitter<SpeechEvent> for SpeechState {}
91
92impl SpeechState {
93    /// Create a speech state with the default recognizer and input.
94    pub fn new(_: &mut Context<Self>) -> Self {
95        Self {
96            recognizer: None,
97            input: super::default_input(),
98            system_fallback: true,
99            system_recognizer: OnceCell::new(),
100            stop_timeout: DEFAULT_STOP_TIMEOUT,
101            status: SpeechStatus::Idle,
102            session: None,
103            next_session: 0,
104            committed: String::new(),
105            hypothesis: SharedString::default(),
106            meter: LevelMeter::new(),
107            surfaces: LiquidSurfaces::default(),
108        }
109    }
110
111    /// Recognize speech with `recognizer` instead of the system's.
112    pub fn recognizer(mut self, recognizer: impl SpeechRecognizer) -> Self {
113        self.recognizer = Some(Rc::new(recognizer));
114        self
115    }
116
117    /// Capture audio from `input` instead of the microphone.
118    pub fn input(mut self, input: impl AudioInput) -> Self {
119        self.input = Some(Rc::new(input));
120        self
121    }
122
123    /// Whether to fall back to the platform's recognizer when no
124    /// [`Self::recognizer`] is set, default `true`.
125    ///
126    /// On Windows the system recognizer dictates through Microsoft's online
127    /// service; turn this off when audio must not leave the application.
128    pub fn system_fallback(mut self, system_fallback: bool) -> Self {
129        self.system_fallback = system_fallback;
130        self
131    }
132
133    /// Set how long [`Self::stop`] waits for the recognizer's final result
134    /// before ending the session with the transcript so far, default 3 seconds.
135    pub fn stop_timeout(mut self, timeout: Duration) -> Self {
136        self.stop_timeout = timeout;
137        self
138    }
139
140    /// Whether a recognizer and an input are configured, so that speech input
141    /// can work on this platform at all.
142    pub fn has_recognizer(&self) -> bool {
143        self.input.is_some() && self.active_recognizer().is_some()
144    }
145
146    /// Whether a session can start now: [`Self::has_recognizer`] and the
147    /// recognizer reports itself available.
148    pub fn is_available(&self, cx: &App) -> bool {
149        self.input.is_some()
150            && self
151                .active_recognizer()
152                .is_some_and(|recognizer| recognizer.is_available(cx))
153    }
154
155    /// Where the state is in its session.
156    pub fn status(&self) -> SpeechStatus {
157        self.status
158    }
159
160    /// The transcript of the current or last session: every committed phrase
161    /// followed by the current hypothesis.
162    pub fn transcript(&self) -> SharedString {
163        if self.hypothesis.is_empty() {
164            self.committed.clone().into()
165        } else {
166            format!("{}{}", self.committed, self.hypothesis).into()
167        }
168    }
169
170    /// Recent input levels in `0.0..=1.0`, oldest first, one per 80 ms of
171    /// audio. Peaks rise at once and fall back smoothly; background noise reads
172    /// as `0.0`.
173    pub fn levels(&self) -> impl ExactSizeIterator<Item = f32> + '_ {
174        self.meter.levels()
175    }
176
177    /// Levels recorded this session, including those dropped from
178    /// [`Self::levels`]: with [`Self::level_lead_at`], a clock that advances
179    /// with the audio.
180    pub(super) fn levels_recorded(&self) -> u64 {
181        self.meter.recorded()
182    }
183
184    /// How far past the waveform's trailing edge the newest level sits at
185    /// `now`, in levels; see `LevelMeter::lead_at`.
186    pub(super) fn level_lead_at(&self, now: instant::Instant) -> Option<f32> {
187        self.meter.lead_at(now)
188    }
189
190    /// Advance the water surface that a liquid waveform `width` wide draws to
191    /// `now`. The surface is simulated rather than derived from the levels, so
192    /// it lives here, with the levels that drive it, and the waveform advances
193    /// it once per frame; nothing else reads it, so this does not notify.
194    pub(super) fn advance_surface(&mut self, now: instant::Instant, width: Pixels) {
195        self.surfaces.advance(now, width.as_f32(), &self.meter);
196    }
197
198    /// The heights of the water surface `width` wide; see
199    /// `LiquidSurface::heights`.
200    pub(super) fn surface_heights(&self, width: Pixels) -> &[f32] {
201        self.surfaces.heights(width.as_f32())
202    }
203
204    /// Start a session. Does nothing while one is running.
205    ///
206    /// When the recognizer or the input fails to start, emits
207    /// [`SpeechEvent::Error`] and stays idle.
208    pub fn start(&mut self, cx: &mut Context<Self>) {
209        if self.status.is_active() {
210            return;
211        }
212
213        let (Some(recognizer), Some(input)) = (self.active_recognizer(), self.input.clone()) else {
214            cx.emit(SpeechEvent::Error(SpeechError::Unsupported));
215            return;
216        };
217
218        self.next_session += 1;
219        let id = self.next_session;
220        let state = cx.weak_entity();
221        let sink = SpeechSink {
222            state: state.clone(),
223            session: id,
224        };
225        let recognition = match recognizer.start(sink, cx) {
226            Ok(recognition) => recognition,
227            Err(error) => {
228                cx.emit(SpeechEvent::Error(error));
229                return;
230            }
231        };
232
233        let format = recognizer.audio_format();
234        let sink = AudioSink { state, session: id };
235        let capture = match input.start(format, sink, cx) {
236            Ok(capture) => capture,
237            Err(error) => {
238                cx.emit(SpeechEvent::Error(error));
239                return;
240            }
241        };
242
243        self.status = SpeechStatus::Connecting;
244        self.committed.clear();
245        self.hypothesis = SharedString::default();
246        self.meter.reset(format.sample_rate(), format.channels());
247        self.surfaces = LiquidSurfaces::default();
248        self.session = Some(Session {
249            id,
250            capture: Some(capture),
251            recognition,
252            _stop_timeout: None,
253        });
254        cx.emit(SpeechEvent::Started);
255        cx.notify();
256    }
257
258    /// Stop capturing and wait for the final result, which arrives as
259    /// [`SpeechEvent::Final`].
260    pub fn stop(&mut self, cx: &mut Context<Self>) {
261        if !self.status.is_capturing() {
262            return;
263        }
264        let Some(session) = self.session.as_mut() else {
265            return;
266        };
267
268        session.capture = None;
269        session.recognition.finish(cx);
270
271        let id = session.id;
272        let timeout = self.stop_timeout;
273        session._stop_timeout = Some(cx.spawn(async move |this, cx| {
274            cx.background_executor().timer(timeout).await;
275            _ = this.update(cx, |this, cx| {
276                if this.is_session(id) {
277                    this.end(SpeechEvent::Final(this.transcript()), cx);
278                }
279            });
280        }));
281
282        self.status = SpeechStatus::Stopping;
283        cx.notify();
284    }
285
286    /// End the session at once and discard its transcript.
287    pub fn cancel(&mut self, cx: &mut Context<Self>) {
288        if !self.status.is_active() {
289            return;
290        }
291        self.committed.clear();
292        self.hypothesis = SharedString::default();
293        self.end(SpeechEvent::Cancelled, cx);
294    }
295
296    /// Start a session when idle, otherwise stop the running one.
297    pub fn toggle(&mut self, cx: &mut Context<Self>) {
298        if self.status.is_active() {
299            self.stop(cx);
300        } else {
301            self.start(cx);
302        }
303    }
304
305    fn active_recognizer(&self) -> Option<Rc<dyn SpeechRecognizer>> {
306        if let Some(recognizer) = &self.recognizer {
307            return Some(recognizer.clone());
308        }
309        if !self.system_fallback {
310            return None;
311        }
312        self.system_recognizer
313            .get_or_init(super::system_recognizer)
314            .clone()
315    }
316
317    pub(super) fn is_session(&self, id: usize) -> bool {
318        self.session
319            .as_ref()
320            .is_some_and(|session| session.id == id)
321    }
322
323    pub(super) fn on_ready(&mut self, cx: &mut Context<Self>) {
324        if self.status == SpeechStatus::Connecting {
325            self.status = SpeechStatus::Recording;
326            cx.notify();
327        }
328    }
329
330    pub(super) fn on_audio(&mut self, samples: &[i16], cx: &mut Context<Self>) {
331        if !self.status.is_capturing() {
332            return;
333        }
334        if let Some(session) = self.session.as_mut() {
335            session.recognition.push_audio(samples, cx);
336        }
337        // Redraw once per new level, not per push: levels are what the
338        // waveform shows.
339        if self.meter.push(samples) {
340            cx.notify();
341        }
342    }
343
344    pub(super) fn on_hypothesis(&mut self, text: SharedString, cx: &mut Context<Self>) {
345        self.hypothesis = text;
346        cx.emit(SpeechEvent::Partial(self.transcript()));
347        cx.notify();
348    }
349
350    pub(super) fn on_phrase(&mut self, text: SharedString, cx: &mut Context<Self>) {
351        self.committed.push_str(&text);
352        self.hypothesis = SharedString::default();
353        cx.emit(SpeechEvent::Partial(self.transcript()));
354        cx.notify();
355    }
356
357    pub(super) fn on_finish(&mut self, cx: &mut Context<Self>) {
358        self.end(SpeechEvent::Final(self.transcript()), cx);
359    }
360
361    pub(super) fn on_error(&mut self, error: SpeechError, cx: &mut Context<Self>) {
362        self.end(SpeechEvent::Error(error), cx);
363    }
364
365    /// Tear the session down, capture first, and report how it ended.
366    fn end(&mut self, event: SpeechEvent, cx: &mut Context<Self>) {
367        if let Some(mut session) = self.session.take() {
368            session.capture = None;
369        }
370        self.status = SpeechStatus::Idle;
371        self.meter = LevelMeter::new();
372        self.surfaces = LiquidSurfaces::default();
373        cx.emit(event);
374        cx.notify();
375    }
376}
377
378/// Apply `f` to `state` after the current update, if `session` is still its
379/// running session. Sinks go through this so that a recognizer or an input may
380/// report from anywhere, including from inside a call the state made.
381pub(super) fn defer_session_update(
382    state: WeakEntity<SpeechState>,
383    session: usize,
384    cx: &mut App,
385    f: impl FnOnce(&mut SpeechState, &mut Context<SpeechState>) + 'static,
386) {
387    cx.defer(move |cx| {
388        _ = state.update(cx, |state, cx| {
389            if state.is_session(session) {
390                f(state, cx);
391            }
392        });
393    });
394}
395
396#[cfg(test)]
397mod tests {
398    use std::{cell::RefCell, rc::Rc, time::Duration};
399
400    use gpui::{App, AppContext as _, Entity, Subscription, TestAppContext};
401
402    use super::*;
403    use crate::speech::{AudioFormat, AudioInput, AudioSink, RecognitionSession, SpeechSink};
404
405    #[derive(Default)]
406    struct Recorded {
407        sink: Option<SpeechSink>,
408        samples: usize,
409        finished: bool,
410        dropped: bool,
411    }
412
413    #[derive(Clone, Default)]
414    struct FakeRecognizer {
415        recorded: Rc<RefCell<Recorded>>,
416        fail_to_start: bool,
417    }
418
419    struct FakeSession(Rc<RefCell<Recorded>>);
420
421    impl SpeechRecognizer for FakeRecognizer {
422        fn start(
423            &self,
424            sink: SpeechSink,
425            _: &mut App,
426        ) -> Result<Box<dyn RecognitionSession>, SpeechError> {
427            if self.fail_to_start {
428                return Err(SpeechError::recognizer(anyhow::anyhow!("offline")));
429            }
430            *self.recorded.borrow_mut() = Recorded {
431                sink: Some(sink),
432                ..Default::default()
433            };
434            Ok(Box::new(FakeSession(self.recorded.clone())))
435        }
436    }
437
438    impl RecognitionSession for FakeSession {
439        fn push_audio(&mut self, samples: &[i16], _: &mut App) {
440            self.0.borrow_mut().samples += samples.len();
441        }
442
443        fn finish(&mut self, _: &mut App) {
444            self.0.borrow_mut().finished = true;
445        }
446    }
447
448    impl Drop for FakeSession {
449        fn drop(&mut self) {
450            self.0.borrow_mut().dropped = true;
451        }
452    }
453
454    #[derive(Clone, Default)]
455    struct FakeInput {
456        sink: Rc<RefCell<Option<AudioSink>>>,
457        capturing: Rc<RefCell<bool>>,
458    }
459
460    impl AudioInput for FakeInput {
461        fn start(
462            &self,
463            _: AudioFormat,
464            sink: AudioSink,
465            _: &mut App,
466        ) -> Result<Subscription, SpeechError> {
467            *self.sink.borrow_mut() = Some(sink);
468            *self.capturing.borrow_mut() = true;
469            let capturing = self.capturing.clone();
470            Ok(Subscription::new(move || *capturing.borrow_mut() = false))
471        }
472    }
473
474    struct Fixture {
475        state: Entity<SpeechState>,
476        recognizer: FakeRecognizer,
477        input: FakeInput,
478        events: Rc<RefCell<Vec<SpeechEvent>>>,
479        _subscription: Subscription,
480    }
481
482    impl Fixture {
483        fn new(recognizer: FakeRecognizer, cx: &mut TestAppContext) -> Self {
484            let input = FakeInput::default();
485            let state = cx.update(|cx| {
486                cx.new(|cx| {
487                    SpeechState::new(cx)
488                        .recognizer(recognizer.clone())
489                        .input(input.clone())
490                        .stop_timeout(Duration::from_secs(1))
491                })
492            });
493            let events = Rc::new(RefCell::new(Vec::new()));
494            let _subscription = cx.update(|cx| {
495                let events = events.clone();
496                cx.subscribe(&state, move |_, event: &SpeechEvent, _| {
497                    events.borrow_mut().push(event.clone());
498                })
499            });
500            Self {
501                state,
502                recognizer,
503                input,
504                events,
505                _subscription,
506            }
507        }
508
509        fn sink(&self) -> SpeechSink {
510            self.recognizer.recorded.borrow().sink.clone().unwrap()
511        }
512
513        fn audio(&self) -> AudioSink {
514            self.input.sink.borrow().clone().unwrap()
515        }
516
517        fn status(&self, cx: &mut TestAppContext) -> SpeechStatus {
518            cx.read(|cx| self.state.read(cx).status())
519        }
520
521        /// Events so far, as short labels.
522        fn take_events(&self) -> Vec<String> {
523            self.events
524                .borrow_mut()
525                .drain(..)
526                .map(|event| match event {
527                    SpeechEvent::Started => "started".into(),
528                    SpeechEvent::Partial(text) => format!("partial:{text}"),
529                    SpeechEvent::Final(text) => format!("final:{text}"),
530                    SpeechEvent::Cancelled => "cancelled".into(),
531                    SpeechEvent::Error(error) => format!("error:{error}"),
532                })
533                .collect()
534        }
535    }
536
537    #[gpui::test]
538    fn session_runs_from_start_to_final(cx: &mut TestAppContext) {
539        let f = Fixture::new(FakeRecognizer::default(), cx);
540
541        f.state.update(cx, |state, cx| state.start(cx));
542        assert_eq!(f.status(cx), SpeechStatus::Connecting);
543        assert!(*f.input.capturing.borrow());
544
545        cx.update(|cx| {
546            f.sink().ready(cx);
547            // Two levels' worth: 80 ms is 1 280 samples at 16 kHz.
548            f.audio().push(vec![i16::MAX / 2; 2_560], cx);
549            f.sink().hypothesis("hello", cx);
550        });
551        cx.run_until_parked();
552        assert_eq!(f.status(cx), SpeechStatus::Recording);
553        assert_eq!(f.recognizer.recorded.borrow().samples, 2_560);
554        cx.read(|cx| {
555            let state = f.state.read(cx);
556            assert_eq!(state.levels().len(), 2);
557            assert!(state.levels().next().unwrap() > 0.5);
558        });
559
560        cx.update(|cx| {
561            f.sink().phrase("Hello.", cx);
562            f.sink().hypothesis(" How", cx);
563        });
564        cx.run_until_parked();
565        assert_eq!(
566            cx.read(|cx| f.state.read(cx).transcript()),
567            SharedString::from("Hello. How")
568        );
569
570        f.state.update(cx, |state, cx| state.stop(cx));
571        assert_eq!(f.status(cx), SpeechStatus::Stopping);
572        assert!(!*f.input.capturing.borrow(), "stop releases the microphone");
573        assert!(f.recognizer.recorded.borrow().finished);
574
575        cx.update(|cx| {
576            f.sink().phrase(" How are you?", cx);
577            f.sink().finish(cx);
578        });
579        cx.run_until_parked();
580        assert_eq!(f.status(cx), SpeechStatus::Idle);
581        assert!(f.recognizer.recorded.borrow().dropped);
582        assert_eq!(
583            f.take_events(),
584            [
585                "started",
586                "partial:hello",
587                "partial:Hello.",
588                "partial:Hello. How",
589                "partial:Hello. How are you?",
590                "final:Hello. How are you?",
591            ]
592        );
593    }
594
595    #[gpui::test]
596    fn stop_ends_with_the_transcript_so_far_after_the_timeout(cx: &mut TestAppContext) {
597        let f = Fixture::new(FakeRecognizer::default(), cx);
598        f.state.update(cx, |state, cx| state.start(cx));
599        cx.update(|cx| f.sink().hypothesis("half a sentence", cx));
600        cx.run_until_parked();
601
602        f.state.update(cx, |state, cx| state.stop(cx));
603        cx.executor().advance_clock(Duration::from_millis(900));
604        assert_eq!(f.status(cx), SpeechStatus::Stopping);
605        cx.executor().advance_clock(Duration::from_millis(200));
606        cx.run_until_parked();
607
608        assert_eq!(f.status(cx), SpeechStatus::Idle);
609        assert_eq!(f.take_events().last().unwrap(), "final:half a sentence");
610    }
611
612    #[gpui::test]
613    fn cancel_discards_the_session_and_ignores_late_results(cx: &mut TestAppContext) {
614        let f = Fixture::new(FakeRecognizer::default(), cx);
615        f.state.update(cx, |state, cx| state.start(cx));
616        let stale = f.sink();
617        cx.update(|cx| stale.phrase("draft", cx));
618        cx.run_until_parked();
619
620        f.state.update(cx, |state, cx| state.cancel(cx));
621        assert_eq!(f.status(cx), SpeechStatus::Idle);
622        assert!(!*f.input.capturing.borrow());
623        assert!(f.recognizer.recorded.borrow().dropped);
624
625        // A new session must not pick up the old session's results.
626        f.state.update(cx, |state, cx| state.start(cx));
627        cx.update(|cx| {
628            stale.phrase("late", cx);
629            stale.finish(cx);
630        });
631        cx.run_until_parked();
632
633        assert_eq!(f.status(cx), SpeechStatus::Connecting);
634        assert_eq!(
635            cx.read(|cx| f.state.read(cx).transcript()),
636            SharedString::default()
637        );
638        assert_eq!(
639            f.take_events(),
640            ["started", "partial:draft", "cancelled", "started"]
641        );
642    }
643
644    #[gpui::test]
645    fn input_error_ends_the_session(cx: &mut TestAppContext) {
646        let f = Fixture::new(FakeRecognizer::default(), cx);
647        f.state.update(cx, |state, cx| state.start(cx));
648        cx.update(|cx| f.audio().error(SpeechError::NoInputDevice, cx));
649        cx.run_until_parked();
650
651        assert_eq!(f.status(cx), SpeechStatus::Idle);
652        assert!(f.recognizer.recorded.borrow().dropped);
653        assert_eq!(
654            f.take_events(),
655            ["started", "error:no audio input device is available"]
656        );
657    }
658
659    #[gpui::test]
660    fn recognizer_that_fails_to_start_leaves_the_state_idle(cx: &mut TestAppContext) {
661        let f = Fixture::new(
662            FakeRecognizer {
663                fail_to_start: true,
664                ..Default::default()
665            },
666            cx,
667        );
668        f.state.update(cx, |state, cx| state.start(cx));
669
670        assert_eq!(f.status(cx), SpeechStatus::Idle);
671        assert!(!*f.input.capturing.borrow(), "the input never starts");
672        assert_eq!(
673            f.take_events(),
674            ["error:speech recognition failed: offline"]
675        );
676    }
677
678    #[gpui::test]
679    fn state_without_a_recognizer_is_unsupported(cx: &mut TestAppContext) {
680        let state = cx.update(|cx| {
681            cx.new(|cx| {
682                SpeechState::new(cx)
683                    .input(FakeInput::default())
684                    .system_fallback(false)
685            })
686        });
687        cx.read(|cx| {
688            assert!(!state.read(cx).has_recognizer());
689            assert!(!state.read(cx).is_available(cx));
690        });
691    }
692}