Skip to main content

gpui_component/speech/
indicator.rs

1use gpui::{
2    App, Bounds, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Pixels,
3    RenderOnce, Styled as _, Window, canvas, div, point, size,
4};
5
6use crate::{Icon, IconName, Sizable, Size};
7
8use instant::Instant;
9
10use super::{SpeechState, level::LEVEL_INTERVAL, waveform::capsule};
11
12/// The group the indicator's layers watch for the pointer.
13const GROUP: &str = "speech-indicator";
14
15/// How many bars move with the voice.
16const BARS: usize = 4;
17
18/// How strongly each bar follows the voice: fuller in the middle, so the bars
19/// read as one shape rather than four equal meters.
20const WEIGHTS: [f32; BARS] = [0.65, 1., 0.85, 0.55];
21
22/// How far behind the newest level each bar reads, in levels, so the bars rise
23/// and fall one after another instead of in step.
24const LAGS: [f32; BARS] = [1., 0., 0.5, 1.5];
25
26/// The bars' heights with reduced motion: a still glyph of a voice.
27const STILL: [f32; BARS] = [0.4, 0.7, 0.55, 0.35];
28
29/// How high the bars breathe in silence, so a quiet microphone still reads as
30/// listening.
31const BREATH: f32 = 0.12;
32
33/// How fast the bars breathe, in radians per second.
34const BREATH_RATE: f32 = 2.4;
35
36/// How far apart the bars breathe, in radians.
37const BREATH_SPREAD: f32 = 0.9;
38
39/// A bar's width, the gap between bars and a full bar's height, relative to
40/// the indicator's shorter side.
41const BAR_RATIO: f32 = 0.085;
42const GAP_RATIO: f32 = 0.075;
43const HEIGHT_RATIO: f32 = 0.46;
44
45/// What a stop button shows while a [`SpeechState`] captures: bars that rise
46/// and fall with the voice, so the button reads as listening rather than as a
47/// media control, turning into a stop glyph while the pointer is over it.
48///
49/// In silence the bars shrink to dots that breathe gently; with reduced motion
50/// they hold still. They take the text color of the button they sit in.
51///
52/// [`SpeechButton`](super::SpeechButton) shows it while capturing. A caller
53/// with its own stop button makes it the button's only child and removes the
54/// button's padding, so the indicator fills it; the button stays responsible
55/// for its accessible label, which should say that it stops. Keyboard focus
56/// has no pointer to reveal the stop glyph, so while the button has it, show
57/// the glyph in place of the indicator, as `SpeechButton` does. Size it like
58/// the icon it replaces: the stop glyph takes that size.
59#[derive(IntoElement)]
60pub struct SpeechIndicator {
61    state: Entity<SpeechState>,
62    size: Size,
63}
64
65impl SpeechIndicator {
66    /// An indicator for `state`.
67    pub fn new(state: &Entity<SpeechState>) -> Self {
68        Self {
69            state: state.clone(),
70            size: Size::default(),
71        }
72    }
73}
74
75impl Sizable for SpeechIndicator {
76    fn with_size(mut self, size: impl Into<Size>) -> Self {
77        self.size = size.into();
78        self
79    }
80}
81
82impl RenderOnce for SpeechIndicator {
83    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
84        let state = self.state.read(cx);
85        let heights = if cx.reduce_motion() {
86            STILL
87        } else {
88            let lead = state.level_lead_at(Instant::now());
89            let levels: Vec<f32> = state.levels().collect();
90            bar_heights(&levels, state.levels_recorded(), lead)
91        };
92        if state.status().is_capturing() && !cx.reduce_motion() {
93            window.request_animation_frame();
94        }
95
96        div()
97            .size_full()
98            .relative()
99            .group(GROUP)
100            .child(
101                div()
102                    .absolute()
103                    .inset_0()
104                    .group_hover(GROUP, |this| this.opacity(0.))
105                    .child(
106                        canvas(
107                            |_, _, _| {},
108                            move |bounds, _, window, _| {
109                                // The button's foreground, whatever its variant.
110                                let color = window.text_style().color;
111                                for rect in bar_rects(bounds, &heights) {
112                                    if let Some(path) = capsule(rect) {
113                                        window.paint_path(path, color);
114                                    }
115                                }
116                            },
117                        )
118                        .size_full(),
119                    ),
120            )
121            .child(
122                div()
123                    .absolute()
124                    .inset_0()
125                    .flex()
126                    .items_center()
127                    .justify_center()
128                    .opacity(0.)
129                    .group_hover(GROUP, |this| this.opacity(1.))
130                    .child(Icon::new(IconName::Square).with_size(self.size)),
131            )
132    }
133}
134
135/// The height of each bar in `0.0..=1.0` for `levels` (oldest first), `recorded`
136/// of them so far this session, with the newest `lead` levels past the
137/// playhead; see `LevelMeter::lead_at`. Without a lead, before any audio, the
138/// bars are dots.
139fn bar_heights(levels: &[f32], recorded: u64, lead: Option<f32>) -> [f32; BARS] {
140    let (Some(lead), Some(last)) = (lead, levels.len().checked_sub(1)) else {
141        return [0.; BARS];
142    };
143    // The playhead moves evenly between levels, so reading at it rather than
144    // at the newest level makes the bars glide instead of jump every 80 ms.
145    let playhead = last as f32 - lead;
146    let at = |position: f32| {
147        let position = position.clamp(0., last as f32);
148        let below = position.floor() as usize;
149        let above = (below + 1).min(last);
150        let fraction = position - below as f32;
151        levels[below] + (levels[above] - levels[below]) * fraction
152    };
153    // The bars breathe only while the voice is quiet, on a clock that runs
154    // with the audio.
155    let seconds = (recorded as f32 - lead) * LEVEL_INTERVAL.as_secs_f32();
156    let quiet = 1. - (at(playhead) * 4.).min(1.);
157
158    std::array::from_fn(|ix| {
159        let voice = at(playhead - LAGS[ix]) * WEIGHTS[ix];
160        let phase = seconds * BREATH_RATE + ix as f32 * BREATH_SPREAD;
161        let breath = BREATH * (0.5 + 0.5 * phase.sin()) * quiet;
162        voice.max(breath).clamp(0., 1.)
163    })
164}
165
166/// The bars for `heights` in `bounds`: side by side around the center, each
167/// centered on the midline and at least as tall as it is wide.
168fn bar_rects(bounds: Bounds<Pixels>, heights: &[f32; BARS]) -> Vec<Bounds<Pixels>> {
169    let side = bounds.size.width.min(bounds.size.height);
170    let bar = side * BAR_RATIO;
171    let gap = side * GAP_RATIO;
172    let full = side * HEIGHT_RATIO;
173    let total = bar * BARS as f32 + gap * (BARS - 1) as f32;
174    let left = bounds.center().x - total / 2.;
175    let middle = bounds.center().y;
176    heights
177        .iter()
178        .enumerate()
179        .map(|(ix, height)| {
180            let height = bar + (full - bar) * *height;
181            Bounds::new(
182                point(left + (bar + gap) * ix as f32, middle - height / 2.),
183                size(bar, height),
184            )
185        })
186        .collect()
187}
188
189#[cfg(test)]
190mod tests {
191    use gpui::px;
192
193    use super::*;
194
195    #[test]
196    fn the_bars_are_dots_before_any_audio() {
197        assert_eq!(bar_heights(&[], 0, None), [0.; BARS]);
198        assert_eq!(bar_heights(&[0.5], 1, None), [0.; BARS]);
199    }
200
201    #[test]
202    fn the_bars_follow_a_loud_voice_fullest_in_the_middle() {
203        let heights = bar_heights(&[1.; 8], 8, Some(0.));
204        assert_eq!(heights, WEIGHTS);
205    }
206
207    #[test]
208    fn the_bars_rise_one_after_another() {
209        // A voice that has only just started reaches the bar without lag
210        // first and the most lagging bar last.
211        let heights = bar_heights(&[0., 0., 0., 1.], 4, Some(0.));
212        assert!(heights[1] > 0.9);
213        assert!(heights[0] < heights[1]);
214        assert!(heights[3] < heights[2]);
215    }
216
217    #[test]
218    fn the_bars_glide_between_levels() {
219        let levels = [0., 1.];
220        let at_edge = bar_heights(&levels, 2, Some(0.))[1];
221        let between = bar_heights(&levels, 2, Some(0.5))[1];
222        let behind = bar_heights(&levels, 2, Some(1.))[1];
223        assert!(
224            behind < between && between < at_edge,
225            "{behind} {between} {at_edge}"
226        );
227    }
228
229    #[test]
230    fn the_bars_breathe_in_silence_but_stay_low() {
231        let silence = [0.; 8];
232        let early = bar_heights(&silence, 8, Some(0.));
233        let later = bar_heights(&silence, 12, Some(0.));
234        assert_ne!(early, later);
235        assert!(early.iter().chain(&later).all(|height| *height <= BREATH));
236    }
237
238    #[test]
239    fn bars_sit_side_by_side_around_the_center_and_grow_from_the_midline() {
240        let bounds = Bounds::new(point(px(0.), px(0.)), size(px(24.), px(24.)));
241        let rects = bar_rects(bounds, &[0., 1., 0.5, 0.]);
242        let first = rects.first().unwrap();
243        let last = rects.last().unwrap();
244        assert!((first.left() + last.right() - px(24.)).abs() < px(0.01));
245        for rect in &rects {
246            assert!((rect.center().y - px(12.)).abs() < px(0.01));
247            assert!(rect.size.height >= rect.size.width);
248        }
249        assert!(rects[1].size.height > rects[2].size.height);
250        assert!(rects[2].size.height > rects[0].size.height);
251    }
252}