Skip to main content

gpui_component/speech/
waveform.rs

1use gpui::{
2    App, Bounds, ContentMask, Entity, IntoElement, ParentElement as _, Path, PathBuilder, Pixels,
3    RenderOnce, StyleRefinement, Styled, Window, canvas, div, point, px, size,
4};
5
6use crate::{ActiveTheme as _, Sizable, Size, StyledExt as _};
7
8use instant::Instant;
9
10use super::SpeechState;
11
12/// The width of a waveform the caller does not size.
13const DEFAULT_WIDTH: Pixels = px(96.);
14
15/// The space between bars, relative to their width.
16const GAP_RATIO: f32 = 2.5;
17
18/// A live waveform of a [`SpeechState`]'s input levels.
19///
20/// Each level is a bar that grows up and down from the midline; silence is a
21/// dot. The newest level enters at the trailing edge and older ones scroll
22/// toward the leading edge at a steady pace, redrawn every frame while audio is
23/// captured, so the trail grows from the trailing edge until it fills the
24/// width. Before any audio, nothing is drawn. With reduced motion the bars
25/// still show each level but do not scroll between them.
26///
27/// Size the waveform like any element, e.g. `.w_full()` to span a row; it is
28/// 96 px wide by default, and its height follows [`Sizable`].
29#[derive(IntoElement)]
30pub struct SpeechWaveform {
31    state: Entity<SpeechState>,
32    size: Size,
33    style: StyleRefinement,
34}
35
36impl SpeechWaveform {
37    /// A waveform for `state`.
38    pub fn new(state: &Entity<SpeechState>) -> Self {
39        Self {
40            state: state.clone(),
41            size: Size::default(),
42            style: StyleRefinement::default(),
43        }
44    }
45
46    fn height(&self) -> Pixels {
47        match self.size {
48            Size::Size(height) => height,
49            Size::XSmall => px(12.),
50            Size::Small => px(16.),
51            Size::Medium => px(20.),
52            Size::Large => px(24.),
53        }
54    }
55}
56
57impl Sizable for SpeechWaveform {
58    fn with_size(mut self, size: impl Into<Size>) -> Self {
59        self.size = size.into();
60        self
61    }
62}
63
64impl Styled for SpeechWaveform {
65    fn style(&mut self) -> &mut StyleRefinement {
66        &mut self.style
67    }
68}
69
70impl RenderOnce for SpeechWaveform {
71    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
72        let height = self.height();
73        // Bars thicken with the waveform so a tall one does not read as hairlines,
74        // and stand apart so a run of silence reads as a row of dots.
75        let bar = (height * 0.125).round().clamp(px(2.), px(4.));
76        let gap = (bar * GAP_RATIO).round();
77        let state = self.state.read(cx);
78        let capturing = state.status().is_capturing();
79        let levels: Vec<f32> = state.levels().collect();
80        let animate = capturing && !cx.reduce_motion();
81        // The bars follow an even clock rather than the arrival of each level,
82        // which comes in uneven bursts: the newest bar slides in from just past
83        // the trailing edge, so the waveform scrolls without stepping or
84        // stalling. Without animation the newest bar sits at the edge.
85        let scroll = state
86            .level_lead_at(Instant::now())
87            .filter(|_| animate)
88            .map_or(0., |lead| -lead);
89        if animate {
90            window.request_animation_frame();
91        }
92        let color = if capturing {
93            cx.theme().primary
94        } else {
95            cx.theme().muted_foreground
96        };
97
98        div()
99            .h(height)
100            .w(DEFAULT_WIDTH)
101            .flex_shrink_0()
102            .refine_style(&self.style)
103            .child(
104                canvas(
105                    |_, _, _| {},
106                    move |bounds, _, window, _| {
107                        // A bar sliding in is cut at the trailing edge, not drawn past it.
108                        // Bars are anti-aliased paths, not quads: quads snap to whole
109                        // device pixels, so a bar moving a fraction of a pixel per
110                        // frame would step instead of glide.
111                        window.with_content_mask(Some(ContentMask { bounds }), |window| {
112                            for rect in bar_rects(bounds, &levels, scroll, bar, gap) {
113                                if let Some(path) = capsule(rect) {
114                                    window.paint_path(path, color);
115                                }
116                            }
117                        });
118                    },
119                )
120                .size_full(),
121            )
122    }
123}
124
125/// A capsule filling `rect`: round caps on its short ends, a dot when square.
126fn capsule(rect: Bounds<Pixels>) -> Option<Path<Pixels>> {
127    let radius = rect.size.width.min(rect.size.height) / 2.;
128    let radii = point(radius, radius);
129    let (left, right) = (rect.left(), rect.right());
130    let (top, bottom) = (rect.top() + radius, rect.bottom() - radius);
131    let mut path = PathBuilder::fill();
132    path.move_to(point(left, top));
133    path.arc_to(radii, px(0.), false, true, point(right, top));
134    path.line_to(point(right, bottom));
135    path.arc_to(radii, px(0.), false, true, point(left, bottom));
136    path.close();
137    path.build().ok()
138}
139
140/// The bars for `levels` (oldest first) in `bounds`: the newest at the trailing
141/// edge, `scroll` of a step further toward the leading edge (negative past the
142/// trailing edge, where a bar is cut off or left out), each centered on
143/// the midline and at least as tall as it is wide. Only levels that exist are
144/// drawn, so a short history leaves the leading part empty.
145fn bar_rects(
146    bounds: Bounds<Pixels>,
147    levels: &[f32],
148    scroll: f32,
149    bar: Pixels,
150    gap: Pixels,
151) -> Vec<Bounds<Pixels>> {
152    let pitch = bar + gap;
153    let height = bounds.size.height;
154    let middle = bounds.origin.y + height / 2.;
155    // Enough bars to reach the leading edge from wherever the newest one sits.
156    let count = ((bounds.size.width + gap) / pitch - scroll.min(0.))
157        .ceil()
158        .max(0.) as usize
159        + 1;
160    (0..count)
161        .filter_map(|from_end| {
162            let right = bounds.right() - pitch * (from_end as f32 + scroll);
163            let left = right - bar;
164            if left < bounds.left() - px(0.5) || left >= bounds.right() {
165                return None;
166            }
167            let level = levels[levels.len().checked_sub(from_end + 1)?];
168            let bar_height = (height * level.clamp(0., 1.)).max(bar);
169            Some(Bounds::new(
170                point(left, middle - bar_height / 2.),
171                size(bar, bar_height),
172            ))
173        })
174        .collect()
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    fn bounds(width: f32, height: f32) -> Bounds<Pixels> {
182        Bounds::new(point(px(10.), px(20.)), size(px(width), px(height)))
183    }
184
185    #[test]
186    fn a_short_history_grows_from_the_trailing_edge() {
187        let bounds = bounds(98., 20.);
188        let rects = bar_rects(bounds, &[0.2, 0.9], 0., px(2.), px(5.));
189        assert_eq!(rects.len(), 2);
190        assert_eq!(rects[0].right(), bounds.right());
191        assert_eq!(rects[0].size.height, px(18.));
192        assert_eq!(rects[1].right(), bounds.right() - px(7.));
193        assert_eq!(rects[1].size.height, px(4.));
194    }
195
196    #[test]
197    fn a_long_history_fills_the_width() {
198        let bounds = bounds(96., 20.);
199        let rects = bar_rects(bounds, &[0.; 64], 0., px(2.), px(5.));
200        // 96 px at a 7 px pitch fits 14 bars; the oldest sit off the leading edge.
201        assert_eq!(rects.len(), 14);
202        assert!(
203            rects
204                .iter()
205                .all(|rect| rect.left() >= bounds.left() - px(0.5))
206        );
207    }
208
209    #[test]
210    fn nothing_is_drawn_before_any_audio() {
211        assert!(bar_rects(bounds(96., 20.), &[], 0., px(2.), px(5.)).is_empty());
212    }
213
214    #[test]
215    fn bars_grow_from_the_midline_and_silence_is_a_dot() {
216        let bounds = bounds(40., 20.);
217        for rect in bar_rects(bounds, &[0.5, 0.], 0., px(2.), px(5.)) {
218            let middle = rect.origin.y + rect.size.height / 2.;
219            assert_eq!(middle, px(30.));
220            assert!(rect.size.height >= px(2.));
221        }
222    }
223
224    #[test]
225    fn scrolling_moves_bars_toward_the_leading_edge() {
226        let bounds = bounds(40., 20.);
227        let still = bar_rects(bounds, &[1.; 8], 0., px(2.), px(5.));
228        let moving = bar_rects(bounds, &[1.; 8], 0.5, px(2.), px(5.));
229        assert_eq!(still[0].left() - moving[0].left(), px(3.5));
230        assert!(
231            moving
232                .iter()
233                .all(|rect| rect.left() >= bounds.left() - px(0.5))
234        );
235    }
236
237    #[test]
238    fn a_bar_past_the_trailing_edge_is_left_out_until_it_slides_in() {
239        let bounds = bounds(40., 20.);
240        // The newest bar sits a whole pitch past the edge: not drawn yet.
241        let out = bar_rects(bounds, &[1., 0.5], -1., px(2.), px(5.));
242        assert_eq!(out.len(), 1);
243        assert_eq!(out[0].right(), bounds.right());
244        // A fifth of a pitch out (1.4 px): drawn across the edge, cut by the
245        // content mask.
246        let entering = bar_rects(bounds, &[1., 0.5], -0.2, px(2.), px(5.));
247        assert_eq!(entering.len(), 2);
248        assert!(entering[0].left() < bounds.right());
249    }
250}