Skip to main content

gpui_ai/
voice.rs

1//! Voice controls: dictation and speech as typed intent.
2//!
3//! gpui-ai does not capture audio or synthesize speech. [`VoiceControls`]
4//! renders the dictate and speak controls for a [`VoiceState`] the
5//! application owns, shows the live input level and interim transcript it
6//! is handed, and reports every press as a [`VoiceEvent`] so the
7//! application starts, stops, or cancels the real audio work.
8
9use crate::{
10    control::{outlined_control_with_icon, outlined_control_with_label},
11    handlers::SharedHandler,
12    motion::{MotionTokens, VisibleAnimationExt as _},
13    surface::icon_button,
14    theme::SemanticStyledExt as _,
15};
16use gpui::{
17    AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement,
18    ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
19    StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _, rems,
20};
21use gpui_component::{
22    ActiveTheme as _, IconName, Sizable as _, StyledExt as _, h_flex, spinner::Spinner, v_flex,
23};
24use std::rc::Rc;
25
26/// Where the application's audio pipeline is.
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub enum VoiceState {
29    /// Nothing is recording or playing.
30    Idle,
31    /// The microphone is open; `level` is the current input level (0–1).
32    Listening {
33        /// Normalized input level used for the meter.
34        level: f32,
35    },
36    /// Audio stopped; speech-to-text is still finishing.
37    Transcribing,
38    /// Text-to-speech is playing.
39    Speaking,
40}
41
42impl VoiceState {
43    /// The short status word read to assistive technology.
44    pub fn label(self) -> &'static str {
45        match self {
46            Self::Idle => "Idle",
47            Self::Listening { .. } => "Listening",
48            Self::Transcribing => "Transcribing",
49            Self::Speaking => "Speaking",
50        }
51    }
52
53    /// Whether the microphone is open.
54    pub fn is_listening(self) -> bool {
55        matches!(self, Self::Listening { .. })
56    }
57}
58
59/// An interaction emitted by [`VoiceControls`].
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum VoiceEvent {
62    /// The user asked to start dictating.
63    DictationStarted,
64    /// The user asked to stop and transcribe what was heard.
65    DictationStopped,
66    /// The user discarded the dictation in progress.
67    DictationCancelled,
68    /// The user asked for the latest response to be read aloud.
69    SpeakRequested,
70    /// The user stopped playback.
71    SpeakStopped,
72}
73
74/// Dictate and speak controls with a live level meter and interim transcript.
75///
76/// # Example
77///
78/// ```
79/// # use gpui_ai::prelude::*;
80/// VoiceControls::new("voice", VoiceState::Listening { level: 0.6 })
81///     .transcript("compare the three suppl…")
82///     .speakable(true)
83///     .on_event(|event, _, _| { /* VoiceEvent::DictationStopped … */ });
84/// ```
85#[derive(IntoElement)]
86pub struct VoiceControls {
87    id: SharedString,
88    style: StyleRefinement,
89    state: VoiceState,
90    transcript: Option<SharedString>,
91    speakable: bool,
92    on_event: Option<SharedHandler<VoiceEvent>>,
93}
94
95impl VoiceControls {
96    /// Creates the controls for the given state.
97    pub fn new(id: impl Into<SharedString>, state: VoiceState) -> Self {
98        Self {
99            id: id.into(),
100            style: StyleRefinement::default(),
101            state,
102            transcript: None,
103            speakable: false,
104            on_event: None,
105        }
106    }
107
108    /// Shows the interim transcript under the controls.
109    pub fn transcript(mut self, transcript: impl Into<SharedString>) -> Self {
110        let transcript = transcript.into();
111        self.transcript = (!transcript.is_empty()).then_some(transcript);
112        self
113    }
114
115    /// Offers a Speak / Stop control for reading responses aloud.
116    pub fn speakable(mut self, speakable: bool) -> Self {
117        self.speakable = speakable;
118        self
119    }
120
121    /// Handles typed interactions. Without a handler the controls are inert.
122    pub fn on_event(
123        mut self,
124        handler: impl Fn(&VoiceEvent, &mut Window, &mut App) + 'static,
125    ) -> Self {
126        self.on_event = Some(Rc::new(handler));
127        self
128    }
129}
130
131impl Styled for VoiceControls {
132    fn style(&mut self) -> &mut StyleRefinement {
133        &mut self.style
134    }
135}
136
137impl RenderOnce for VoiceControls {
138    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
139        let tokens = cx.theme().semantic_tokens();
140        let root_id = ElementId::from(self.id.clone());
141        let debug_id = self.id.to_string();
142        let handler = self.on_event;
143        let state = self.state;
144
145        let (dictate_name, dictate_label, dictate_event): (&str, &str, Option<VoiceEvent>) =
146            match state {
147                VoiceState::Idle | VoiceState::Speaking => (
148                    "Start dictation",
149                    "Dictate",
150                    Some(VoiceEvent::DictationStarted),
151                ),
152                VoiceState::Listening { .. } => (
153                    "Stop dictation",
154                    "Listening",
155                    Some(VoiceEvent::DictationStopped),
156                ),
157                VoiceState::Transcribing => ("Transcribing", "Transcribing", None),
158            };
159        let indicator: AnyElement = match state {
160            VoiceState::Listening { level } => {
161                let field_debug = debug_id.clone();
162                div()
163                    .debug_selector(move || format!("voice-signal-{field_debug}"))
164                    .child(signal_field(
165                        ElementId::from((root_id.clone(), "signal-field")),
166                        level,
167                        cx,
168                    ))
169                    .into_any_element()
170            }
171            VoiceState::Transcribing => Spinner::new().xsmall().into_any_element(),
172            VoiceState::Idle | VoiceState::Speaking => div()
173                .flex_none()
174                .size_2()
175                .rounded(tokens.radius.full)
176                .bg(cx.theme().muted_foreground.opacity(0.6))
177                .into_any_element(),
178        };
179        let dictate_debug = debug_id.clone();
180        let dictate = {
181            let handler = handler.clone();
182            let event = dictate_event.clone();
183            outlined_control_with_label(
184                (root_id.clone(), "dictate"),
185                dictate_name,
186                dictate_label,
187                window,
188                cx,
189            )
190            .debug_selector(move || format!("voice-dictate-{dictate_debug}"))
191            .disabled(dictate_event.is_none() || handler.is_none())
192            .when(state.is_listening(), |this| {
193                this.border_color(cx.theme().primary)
194                    .text_color(cx.theme().primary)
195            })
196            .gap(tokens.spacing.xs)
197            .child(indicator)
198            .on_click(move |_: &ClickEvent, window, cx| {
199                if let (Some(handler), Some(event)) = (&handler, &event) {
200                    handler(event, window, cx)
201                }
202            })
203        };
204        // The visible label is the last child of the pill; put the indicator
205        // before it by rebuilding the children order.
206        let cancel = (state.is_listening() && handler.is_some()).then(|| {
207            let handler = handler.clone();
208            let cancel_debug = debug_id.clone();
209            icon_button(
210                (root_id.clone(), "cancel"),
211                IconName::Close,
212                "Cancel dictation",
213                window,
214                cx,
215            )
216            .debug_selector(move || format!("voice-cancel-{cancel_debug}"))
217            .on_click(move |_: &ClickEvent, window, cx| {
218                if let Some(handler) = &handler {
219                    handler(&VoiceEvent::DictationCancelled, window, cx)
220                }
221            })
222        });
223        let speak = self.speakable.then(|| {
224            let handler = handler.clone();
225            let speak_debug = debug_id.clone();
226            let speaking = state == VoiceState::Speaking;
227            let (name, icon, event) = if speaking {
228                ("Stop speaking", IconName::Pause, VoiceEvent::SpeakStopped)
229            } else {
230                (
231                    "Read the latest response aloud",
232                    IconName::Play,
233                    VoiceEvent::SpeakRequested,
234                )
235            };
236            // The library's own icon-and-label control, tinted while
237            // speaking. It used to be hand-built with a padding-derived
238            // height, so it was the one control in the crate that did not
239            // move when an application replaced the size policy — and the
240            // one without the shared press decay.
241            outlined_control_with_icon(
242                (root_id.clone(), "speak"),
243                name,
244                icon,
245                if speaking { "Stop" } else { "Speak" },
246                window,
247                cx,
248            )
249            .debug_selector(move || format!("voice-speak-{speak_debug}"))
250            .when(speaking, |button| {
251                button
252                    .border_color(cx.theme().primary)
253                    .text_color(cx.theme().primary)
254            })
255            .disabled(handler.is_none())
256            .on_click(move |_: &ClickEvent, window, cx| {
257                if let Some(handler) = &handler {
258                    handler(&event, window, cx)
259                }
260            })
261        });
262        let transcript_debug = debug_id.clone();
263        let transcript = self.transcript.map(|text| {
264            div()
265                .id((root_id.clone(), "transcript"))
266                .role(Role::Status)
267                .aria_label(format!("Heard: {text}"))
268                .debug_selector(move || format!("voice-transcript-{transcript_debug}"))
269                .min_w_0()
270                .truncate()
271                .text_token(tokens.typography.sm)
272                .italic()
273                .text_color(cx.theme().muted_foreground)
274                .child(text)
275        });
276
277        v_flex()
278            .id(self.id)
279            .role(Role::Group)
280            .aria_label(format!(
281                "Voice controls, {}",
282                state.label().to_ascii_lowercase()
283            ))
284            .debug_selector(move || format!("voice-{debug_id}"))
285            .min_w_0()
286            .gap(tokens.spacing.xs)
287            .child(
288                h_flex()
289                    .items_center()
290                    .gap(tokens.spacing.xs)
291                    .child(dictate)
292                    .children(cancel)
293                    .children(speak),
294            )
295            .children(transcript)
296            .refine_style(&self.style)
297    }
298}
299
300/// Four bars that rise with the input level; heights stay in rems so the
301/// meter scales with the UI.
302/// The listening signal field: six lobes shaped by the controlled level,
303/// pulsing on one shared clock.
304///
305/// Spike C's shipped outcome: plain layout primitives produce a clean
306/// field with no shader, no overdraw pass, and no background job. Lobe
307/// heights follow the application-owned level — sampled from the
308/// controlled snapshot, never smoothed here — and a single phase-locked
309/// animation drives a wave of opacity across the lobes, so the whole
310/// field costs one scheduled clock. Reduced motion holds the phase at
311/// zero, leaving a static graded envelope at the level's heights as the
312/// meaningful mark; the status text remains the semantic carrier.
313fn signal_field(id: ElementId, level: f32, cx: &App) -> impl IntoElement {
314    let tokens = cx.theme().semantic_tokens();
315    let color = cx.theme().primary;
316    let level = level.clamp(0.0, 1.0);
317    let gap = tokens.spacing.xxs;
318    let radius = tokens.radius.full;
319    let full = crate::motion::motion_is_full(cx);
320    const WEIGHTS: [f32; 6] = [0.35, 0.65, 1.0, 0.85, 0.55, 0.3];
321    h_flex()
322        .flex_none()
323        .items_end()
324        .h(rems(0.9))
325        .gap(gap)
326        .with_visible_animation(
327            id,
328            // Frame demand: active while the state stays Listening — the
329            // field is that state's indicator and settles by the state
330            // changing, which unmounts it. Phase-locked to the shared
331            // epoch, so a field remounted mid-session rejoins the beat.
332            MotionTokens::read(cx).breathing().looping_synced(),
333            move |field, delta| {
334                let delta = if full { delta } else { 0.0 };
335                field.children(WEIGHTS.iter().enumerate().map(|(index, weight)| {
336                    let phase = index as f32 / WEIGHTS.len() as f32;
337                    let wave = ((delta - phase).rem_euclid(1.0) * 2.0 - 1.0).abs();
338                    div()
339                        .w(rems(0.14))
340                        .h(rems(0.25 + 0.65 * level * weight))
341                        .rounded(radius)
342                        .bg(color)
343                        .opacity(0.35 + 0.65 * wave)
344                }))
345            },
346        )
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use gpui::{Context, Render, TestAppContext, VisualTestContext, px};
353
354    struct VoiceProbe {
355        state: VoiceState,
356    }
357
358    impl Render for VoiceProbe {
359        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
360            div()
361                .w(px(420.))
362                .h(px(160.))
363                .child(VoiceControls::new("probe-voice", self.state).speakable(true))
364        }
365    }
366
367    fn draw(cx: &mut VisualTestContext) {
368        cx.update(|window, cx| window.draw(cx).clear(cx));
369    }
370
371    #[gpui::test]
372    fn the_signal_field_exists_exactly_while_listening(cx: &mut TestAppContext) {
373        cx.update(crate::init);
374        let (probe, cx) = cx.add_window_view(|_, _| VoiceProbe {
375            state: VoiceState::Listening { level: 0.6 },
376        });
377        let cx: &mut VisualTestContext = cx;
378        draw(cx);
379        assert!(
380            cx.debug_bounds("voice-signal-probe-voice").is_some(),
381            "listening must show the signal field"
382        );
383
384        probe.update(cx, |probe, cx| {
385            probe.state = VoiceState::Idle;
386            cx.notify();
387        });
388        draw(cx);
389        assert!(
390            cx.debug_bounds("voice-signal-probe-voice").is_none(),
391            "leaving Listening must unmount the field, and its clock with it"
392        );
393    }
394
395    #[gpui::test]
396    fn reduced_motion_keeps_a_static_field(cx: &mut TestAppContext) {
397        cx.update(crate::init);
398        cx.update(|cx| cx.set_reduce_motion(true));
399        let (_, cx) = cx.add_window_view(|_, _| VoiceProbe {
400            state: VoiceState::Listening { level: 0.6 },
401        });
402        let cx: &mut VisualTestContext = cx;
403        draw(cx);
404        assert!(
405            cx.debug_bounds("voice-signal-probe-voice").is_some(),
406            "reduced motion keeps the static envelope as the meaningful mark"
407        );
408    }
409
410    #[test]
411    fn states_describe_themselves() {
412        assert_eq!(VoiceState::Listening { level: 0.2 }.label(), "Listening");
413        assert!(VoiceState::Listening { level: 0.2 }.is_listening());
414        assert!(!VoiceState::Transcribing.is_listening());
415    }
416}