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
10const DEFAULT_STOP_TIMEOUT: Duration = Duration::from_secs(3);
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum SpeechStatus {
16 #[default]
18 Idle,
19 Connecting,
21 Recording,
23 Stopping,
25}
26
27impl SpeechStatus {
28 pub fn is_active(self) -> bool {
30 self != Self::Idle
31 }
32
33 pub fn is_capturing(self) -> bool {
35 matches!(self, Self::Connecting | Self::Recording)
36 }
37}
38
39#[derive(Debug, Clone)]
41pub enum SpeechEvent {
42 Started,
44 Partial(SharedString),
47 Final(SharedString),
49 Cancelled,
51 Error(SpeechError),
53}
54
55struct Session {
56 id: usize,
57 capture: Option<Subscription>,
59 recognition: Box<dyn RecognitionSession>,
60 _stop_timeout: Option<Task<()>>,
61}
62
63pub 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 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 pub fn recognizer(mut self, recognizer: impl SpeechRecognizer) -> Self {
113 self.recognizer = Some(Rc::new(recognizer));
114 self
115 }
116
117 pub fn input(mut self, input: impl AudioInput) -> Self {
119 self.input = Some(Rc::new(input));
120 self
121 }
122
123 pub fn system_fallback(mut self, system_fallback: bool) -> Self {
129 self.system_fallback = system_fallback;
130 self
131 }
132
133 pub fn stop_timeout(mut self, timeout: Duration) -> Self {
136 self.stop_timeout = timeout;
137 self
138 }
139
140 pub fn has_recognizer(&self) -> bool {
143 self.input.is_some() && self.active_recognizer().is_some()
144 }
145
146 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 pub fn status(&self) -> SpeechStatus {
157 self.status
158 }
159
160 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 pub fn levels(&self) -> impl ExactSizeIterator<Item = f32> + '_ {
174 self.meter.levels()
175 }
176
177 pub(super) fn levels_recorded(&self) -> u64 {
181 self.meter.recorded()
182 }
183
184 pub(super) fn level_lead_at(&self, now: instant::Instant) -> Option<f32> {
187 self.meter.lead_at(now)
188 }
189
190 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 pub(super) fn surface_heights(&self, width: Pixels) -> &[f32] {
201 self.surfaces.heights(width.as_f32())
202 }
203
204 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 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 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 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 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 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
378pub(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 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 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 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}