Skip to main content

gpui_component/speech/
waveform.rs

1use gpui::{
2    AnyElement, App, Bounds, ContentMask, Entity, Hsla, IntoElement, ParentElement as _, Path,
3    PathBuilder, Pixels, Point, RenderOnce, StyleRefinement, Styled, Window, canvas, div,
4    linear_color_stop, linear_gradient, point, px, size,
5};
6
7use crate::{ActiveTheme as _, Sizable, Size, StyledExt as _};
8
9use std::f32::consts::PI;
10
11use instant::Instant;
12
13use super::{
14    SpeechState,
15    surface::{edge_taper, still_heights},
16};
17
18/// The width of a waveform the caller does not size.
19const DEFAULT_WIDTH: Pixels = px(96.);
20
21/// The space between bars, relative to their width.
22const GAP_RATIO: f32 = 2.5;
23
24/// How far a liquid surface's crests reach from the midline, relative to the
25/// height, leaving room for the line's own width.
26const CREST_RATIO: f32 = 0.42;
27
28/// How steeply a liquid surface's heights approach its crest: higher waves
29/// flatten out instead of being cut off at the edge of the waveform.
30const CREST_SOFTNESS: f32 = 1.4;
31
32/// How wide a liquid surface's line is at its ends, relative to its middle.
33const LINE_TAPER: f32 = 0.25;
34
35/// How many points a liquid surface without heights is drawn with, so that
36/// even a flat line tapers smoothly toward its ends.
37const MIN_SURFACE_POINTS: usize = 32;
38
39/// How opaque the water under a liquid surface is at its crest; it fades to
40/// clear at the bottom.
41const WATER_OPACITY: f32 = 0.16;
42
43/// How a [`SpeechWaveform`] draws the input.
44#[derive(Clone, Copy, Default)]
45enum Appearance {
46    /// A bar per level, scrolling toward the leading edge.
47    #[default]
48    Bars,
49    /// A water surface that the voice sets in motion.
50    Liquid,
51}
52
53/// A live waveform of a [`SpeechState`]'s input levels.
54///
55/// Each level is a bar that grows up and down from the midline; silence is a
56/// dot. The newest level enters at the trailing edge and older ones scroll
57/// toward the leading edge at a steady pace, redrawn every frame while audio is
58/// captured, so the trail grows from the trailing edge until it fills the
59/// width. Before any audio, nothing is drawn. With reduced motion the bars
60/// still show each level but do not scroll between them.
61///
62/// [`Self::liquid`] draws a water surface instead.
63///
64/// Size the waveform like any element, e.g. `.w_full()` to span a row; it is
65/// 96 px wide by default, and its height follows [`Sizable`].
66#[derive(IntoElement)]
67pub struct SpeechWaveform {
68    state: Entity<SpeechState>,
69    size: Size,
70    appearance: Appearance,
71    style: StyleRefinement,
72}
73
74impl SpeechWaveform {
75    /// A waveform for `state`.
76    pub fn new(state: &Entity<SpeechState>) -> Self {
77        Self {
78            state: state.clone(),
79            size: Size::default(),
80            appearance: Appearance::default(),
81            style: StyleRefinement::default(),
82        }
83    }
84
85    /// Draw the input as a water surface rather than bars.
86    ///
87    /// While there is sound, the middle of the surface rises and falls with
88    /// the input level and sends waves toward both edges, and each syllable
89    /// drops onto the surface at a varying place; the waves travel, overlap
90    /// and die away, and in silence the surface settles to a flat line. The
91    /// line is shaded with the water beneath it. The motion is simulated in
92    /// the [`SpeechState`], so liquid waveforms of one state and width show
93    /// the same surface, and it starts from rest with each session. With reduced
94    /// motion the surface holds a ripple in place that grows with the level.
95    ///
96    /// Waves need room to travel: use it for a waveform that spans a row, not
97    /// a compact one beside a button.
98    pub fn liquid(mut self) -> Self {
99        self.appearance = Appearance::Liquid;
100        self
101    }
102
103    fn height(&self) -> Pixels {
104        match self.size {
105            Size::Size(height) => height,
106            Size::XSmall => px(12.),
107            Size::Small => px(16.),
108            Size::Medium => px(20.),
109            Size::Large => px(24.),
110        }
111    }
112}
113
114impl Sizable for SpeechWaveform {
115    fn with_size(mut self, size: impl Into<Size>) -> Self {
116        self.size = size.into();
117        self
118    }
119}
120
121impl Styled for SpeechWaveform {
122    fn style(&mut self) -> &mut StyleRefinement {
123        &mut self.style
124    }
125}
126
127impl RenderOnce for SpeechWaveform {
128    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
129        let height = self.height();
130        let state = self.state.read(cx);
131        let capturing = state.status().is_capturing();
132        let animate = capturing && !cx.reduce_motion();
133        if animate {
134            window.request_animation_frame();
135        }
136        let color = if capturing {
137            cx.theme().primary
138        } else {
139            cx.theme().muted_foreground
140        };
141        let content = match self.appearance {
142            Appearance::Bars => bars(state, height, animate, color),
143            Appearance::Liquid => liquid(&self.state, height, animate, color, cx),
144        };
145
146        div()
147            .h(height)
148            .w(DEFAULT_WIDTH)
149            .flex_shrink_0()
150            .refine_style(&self.style)
151            .child(content)
152    }
153}
154
155/// The bars of `state`'s levels, scrolling while `animate`.
156fn bars(state: &SpeechState, height: Pixels, animate: bool, color: Hsla) -> AnyElement {
157    // Bars thicken with the waveform so a tall one does not read as hairlines,
158    // and stand apart so a run of silence reads as a row of dots.
159    let bar = (height * 0.125).round().clamp(px(2.), px(4.));
160    let gap = (bar * GAP_RATIO).round();
161    let levels: Vec<f32> = state.levels().collect();
162    // The bars follow an even clock rather than the arrival of each level,
163    // which comes in uneven bursts: the newest bar slides in from just past
164    // the trailing edge, so the waveform scrolls without stepping or
165    // stalling. Without animation the newest bar sits at the edge.
166    let scroll = state
167        .level_lead_at(Instant::now())
168        .filter(|_| animate)
169        .map_or(0., |lead| -lead);
170
171    canvas(
172        |_, _, _| {},
173        move |bounds, _, window, _| {
174            // A bar sliding in is cut at the trailing edge, not drawn past it.
175            // Bars are anti-aliased paths, not quads: quads snap to whole
176            // device pixels, so a bar moving a fraction of a pixel per
177            // frame would step instead of glide.
178            window.with_content_mask(Some(ContentMask { bounds }), |window| {
179                for rect in bar_rects(bounds, &levels, scroll, bar, gap) {
180                    if let Some(path) = capsule(rect) {
181                        window.paint_path(path, color);
182                    }
183                }
184            });
185        },
186    )
187    .size_full()
188    .into_any_element()
189}
190
191/// The water surface of `state`, advanced to this frame while `animate`.
192fn liquid(
193    state: &Entity<SpeechState>,
194    height: Pixels,
195    animate: bool,
196    color: Hsla,
197    cx: &App,
198) -> AnyElement {
199    let still = cx
200        .reduce_motion()
201        .then(|| still_heights(state.read(cx).levels().last().unwrap_or(0.)));
202    let state = state.clone();
203    // The line at its thickest, in the middle.
204    let line = (height * (1. / 10.)).max(px(2.));
205
206    canvas(
207        // The surface is advanced here, where the width is known; it stays as
208        // it was while nothing is captured.
209        move |bounds, _, cx| {
210            still.unwrap_or_else(|| {
211                state.update(cx, |state, _| {
212                    if animate {
213                        state.advance_surface(Instant::now(), bounds.size.width);
214                    }
215                    state.surface_heights(bounds.size.width).to_vec()
216                })
217            })
218        },
219        move |bounds, heights, window, _| {
220            let points = surface_points(bounds, &heights);
221            window.with_content_mask(Some(ContentMask { bounds }), |window| {
222                let mut water = PathBuilder::fill();
223                trace(&mut water, &points);
224                water.line_to(bounds.bottom_right());
225                water.line_to(bounds.bottom_left());
226                water.close();
227                if let Ok(water) = water.build() {
228                    window.paint_path(
229                        water,
230                        linear_gradient(
231                            180.,
232                            linear_color_stop(color.opacity(WATER_OPACITY), 0.),
233                            linear_color_stop(color.opacity(0.), 1.),
234                        ),
235                    );
236                }
237                // A ribbon rather than a stroke, so the line can swell toward
238                // the middle and thin out at the edges.
239                let (top, bottom) = ribbon(&points, line);
240                let mut surface = PathBuilder::fill();
241                trace(&mut surface, &top);
242                if let Some(end) = bottom.last() {
243                    surface.line_to(*end);
244                }
245                follow(
246                    &mut surface,
247                    &bottom.iter().rev().copied().collect::<Vec<_>>(),
248                );
249                surface.close();
250                if let Ok(surface) = surface.build() {
251                    window.paint_path(surface, color);
252                }
253            });
254        },
255    )
256    .size_full()
257    .into_any_element()
258}
259
260/// The points of a surface with `heights` across `bounds`: evenly spaced from
261/// edge to edge, around the midline, flat when there are no heights.
262fn surface_points(bounds: Bounds<Pixels>, heights: &[f32]) -> Vec<Point<Pixels>> {
263    let middle = bounds.center().y;
264    let crest = bounds.size.height * CREST_RATIO;
265    let count = if heights.is_empty() {
266        MIN_SURFACE_POINTS
267    } else {
268        heights.len().max(2)
269    };
270    (0..count)
271        .map(|ix| {
272            let t = ix as f32 / (count - 1) as f32;
273            let height = heights.get(ix).copied().unwrap_or(0.);
274            let rise = (height * CREST_SOFTNESS).tanh() * edge_taper(t);
275            point(bounds.left() + bounds.size.width * t, middle + crest * rise)
276        })
277        .collect()
278}
279
280/// Trace a smooth curve through `points`, passing through each midpoint
281/// between them.
282fn trace(path: &mut PathBuilder, points: &[Point<Pixels>]) {
283    let Some(first) = points.first() else {
284        return;
285    };
286    path.move_to(*first);
287    follow(path, points);
288}
289
290/// Continue `path`, which is at the first of `points`, along a smooth curve
291/// through the rest, passing through each midpoint between them.
292fn follow(path: &mut PathBuilder, points: &[Point<Pixels>]) {
293    let Some(last) = points.last() else {
294        return;
295    };
296    for pair in points[1..].windows(2) {
297        let middle = point((pair[0].x + pair[1].x) / 2., (pair[0].y + pair[1].y) / 2.);
298        path.curve_to(middle, pair[0]);
299    }
300    path.line_to(*last);
301}
302
303/// The two edges of a line through `points` that is `width` wide in the
304/// middle and narrows smoothly to [`LINE_TAPER`] of that at the ends: each
305/// point moved half the local width to either side, across the line rather
306/// than straight up and down, so steep waves keep their weight.
307fn ribbon(points: &[Point<Pixels>], width: Pixels) -> (Vec<Point<Pixels>>, Vec<Point<Pixels>>) {
308    let last = points.len().saturating_sub(1).max(1);
309    points
310        .iter()
311        .enumerate()
312        .map(|(ix, at)| {
313            let before = points[ix.saturating_sub(1)];
314            let after = points[(ix + 1).min(points.len() - 1)];
315            let (dx, dy) = ((after.x - before.x).as_f32(), (after.y - before.y).as_f32());
316            let length = dx.hypot(dy).max(f32::EPSILON);
317            let swell = (PI * ix as f32 / last as f32).sin().max(0.);
318            let half = width * (LINE_TAPER + (1. - LINE_TAPER) * swell) / 2.;
319            let offset = point(half * (-dy / length), half * (dx / length));
320            (*at - offset, *at + offset)
321        })
322        .unzip()
323}
324
325/// A capsule filling `rect`: round caps on its short ends, a dot when square.
326pub(super) fn capsule(rect: Bounds<Pixels>) -> Option<Path<Pixels>> {
327    let radius = rect.size.width.min(rect.size.height) / 2.;
328    let radii = point(radius, radius);
329    let (left, right) = (rect.left(), rect.right());
330    let (top, bottom) = (rect.top() + radius, rect.bottom() - radius);
331    let mut path = PathBuilder::fill();
332    path.move_to(point(left, top));
333    path.arc_to(radii, px(0.), false, true, point(right, top));
334    path.line_to(point(right, bottom));
335    path.arc_to(radii, px(0.), false, true, point(left, bottom));
336    path.close();
337    path.build().ok()
338}
339
340/// The bars for `levels` (oldest first) in `bounds`: the newest at the trailing
341/// edge, `scroll` of a step further toward the leading edge (negative past the
342/// trailing edge, where a bar is cut off or left out), each centered on
343/// the midline and at least as tall as it is wide. Only levels that exist are
344/// drawn, so a short history leaves the leading part empty.
345fn bar_rects(
346    bounds: Bounds<Pixels>,
347    levels: &[f32],
348    scroll: f32,
349    bar: Pixels,
350    gap: Pixels,
351) -> Vec<Bounds<Pixels>> {
352    let pitch = bar + gap;
353    let height = bounds.size.height;
354    let middle = bounds.origin.y + height / 2.;
355    // Enough bars to reach the leading edge from wherever the newest one sits.
356    let count = ((bounds.size.width + gap) / pitch - scroll.min(0.))
357        .ceil()
358        .max(0.) as usize
359        + 1;
360    (0..count)
361        .filter_map(|from_end| {
362            let right = bounds.right() - pitch * (from_end as f32 + scroll);
363            let left = right - bar;
364            if left < bounds.left() - px(0.5) || left >= bounds.right() {
365                return None;
366            }
367            let level = levels[levels.len().checked_sub(from_end + 1)?];
368            let bar_height = (height * level.clamp(0., 1.)).max(bar);
369            Some(Bounds::new(
370                point(left, middle - bar_height / 2.),
371                size(bar, bar_height),
372            ))
373        })
374        .collect()
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    fn bounds(width: f32, height: f32) -> Bounds<Pixels> {
382        Bounds::new(point(px(10.), px(20.)), size(px(width), px(height)))
383    }
384
385    #[test]
386    fn a_short_history_grows_from_the_trailing_edge() {
387        let bounds = bounds(98., 20.);
388        let rects = bar_rects(bounds, &[0.2, 0.9], 0., px(2.), px(5.));
389        assert_eq!(rects.len(), 2);
390        assert_eq!(rects[0].right(), bounds.right());
391        assert_eq!(rects[0].size.height, px(18.));
392        assert_eq!(rects[1].right(), bounds.right() - px(7.));
393        assert_eq!(rects[1].size.height, px(4.));
394    }
395
396    #[test]
397    fn a_long_history_fills_the_width() {
398        let bounds = bounds(96., 20.);
399        let rects = bar_rects(bounds, &[0.; 64], 0., px(2.), px(5.));
400        // 96 px at a 7 px pitch fits 14 bars; the oldest sit off the leading edge.
401        assert_eq!(rects.len(), 14);
402        assert!(
403            rects
404                .iter()
405                .all(|rect| rect.left() >= bounds.left() - px(0.5))
406        );
407    }
408
409    #[test]
410    fn nothing_is_drawn_before_any_audio() {
411        assert!(bar_rects(bounds(96., 20.), &[], 0., px(2.), px(5.)).is_empty());
412    }
413
414    #[test]
415    fn a_surface_at_rest_is_a_flat_line_across_the_midline() {
416        let bounds = bounds(96., 20.);
417        let points = surface_points(bounds, &[]);
418        assert_eq!(points.len(), MIN_SURFACE_POINTS);
419        assert_eq!(points[0], point(bounds.left(), px(30.)));
420        assert_eq!(
421            points[MIN_SURFACE_POINTS - 1],
422            point(bounds.right(), px(30.))
423        );
424        assert!(points.iter().all(|point| point.y == px(30.)));
425    }
426
427    #[test]
428    fn a_surface_line_swells_in_the_middle_and_thins_at_the_ends() {
429        let bounds = bounds(96., 20.);
430        let points = surface_points(bounds, &[]);
431        let (top, bottom) = ribbon(&points, px(4.));
432        let width = |ix: usize| bottom[ix].y - top[ix].y;
433        let middle = points.len() / 2;
434        assert!((width(0) - px(1.)).abs() < px(0.01));
435        assert!((width(points.len() - 1) - px(1.)).abs() < px(0.01));
436        assert!(width(middle) > px(3.9));
437        assert!(width(4) > width(0) && width(middle) > width(4));
438    }
439
440    #[test]
441    fn a_surface_line_keeps_its_width_across_a_steep_wave() {
442        let points = [
443            point(px(0.), px(0.)),
444            point(px(1.), px(1.)),
445            point(px(2.), px(2.)),
446        ];
447        let (top, bottom) = ribbon(&points, px(4.));
448        let across = bottom[1] - top[1];
449        let length = across.x.as_f32().hypot(across.y.as_f32());
450        assert!((length - 4.).abs() < 0.01, "{length}");
451    }
452
453    #[test]
454    fn a_surface_meets_the_edges_at_rest_and_keeps_its_crests_inside() {
455        let bounds = bounds(96., 20.);
456        let points = surface_points(bounds, &[5., -5., 5., -5., 5.]);
457        assert_eq!(points.len(), 5);
458        assert_eq!(points[0].y, px(30.));
459        assert!((points[4].y - px(30.)).abs() < px(0.1));
460        for point in &points {
461            assert!((point.y - px(30.)).abs() <= px(20. * CREST_RATIO));
462        }
463        assert!(points[1].y < px(30.) && points[2].y > px(30.));
464    }
465
466    #[test]
467    fn bars_grow_from_the_midline_and_silence_is_a_dot() {
468        let bounds = bounds(40., 20.);
469        for rect in bar_rects(bounds, &[0.5, 0.], 0., px(2.), px(5.)) {
470            let middle = rect.origin.y + rect.size.height / 2.;
471            assert_eq!(middle, px(30.));
472            assert!(rect.size.height >= px(2.));
473        }
474    }
475
476    #[test]
477    fn scrolling_moves_bars_toward_the_leading_edge() {
478        let bounds = bounds(40., 20.);
479        let still = bar_rects(bounds, &[1.; 8], 0., px(2.), px(5.));
480        let moving = bar_rects(bounds, &[1.; 8], 0.5, px(2.), px(5.));
481        assert_eq!(still[0].left() - moving[0].left(), px(3.5));
482        assert!(
483            moving
484                .iter()
485                .all(|rect| rect.left() >= bounds.left() - px(0.5))
486        );
487    }
488
489    #[test]
490    fn a_bar_past_the_trailing_edge_is_left_out_until_it_slides_in() {
491        let bounds = bounds(40., 20.);
492        // The newest bar sits a whole pitch past the edge: not drawn yet.
493        let out = bar_rects(bounds, &[1., 0.5], -1., px(2.), px(5.));
494        assert_eq!(out.len(), 1);
495        assert_eq!(out[0].right(), bounds.right());
496        // A fifth of a pitch out (1.4 px): drawn across the edge, cut by the
497        // content mask.
498        let entering = bar_rects(bounds, &[1., 0.5], -0.2, px(2.), px(5.));
499        assert_eq!(entering.len(), 2);
500        assert!(entering[0].left() < bounds.right());
501    }
502}