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
18const DEFAULT_WIDTH: Pixels = px(96.);
20
21const GAP_RATIO: f32 = 2.5;
23
24const CREST_RATIO: f32 = 0.42;
27
28const CREST_SOFTNESS: f32 = 1.4;
31
32const LINE_TAPER: f32 = 0.25;
34
35const MIN_SURFACE_POINTS: usize = 32;
38
39const WATER_OPACITY: f32 = 0.16;
42
43#[derive(Clone, Copy, Default)]
45enum Appearance {
46 #[default]
48 Bars,
49 Liquid,
51}
52
53#[derive(IntoElement)]
67pub struct SpeechWaveform {
68 state: Entity<SpeechState>,
69 size: Size,
70 appearance: Appearance,
71 style: StyleRefinement,
72}
73
74impl SpeechWaveform {
75 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 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
155fn bars(state: &SpeechState, height: Pixels, animate: bool, color: Hsla) -> AnyElement {
157 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 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 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
191fn 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 let line = (height * (1. / 10.)).max(px(2.));
205
206 canvas(
207 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 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
260fn 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
280fn 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
290fn 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
303fn 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
325pub(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
340fn 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 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 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 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 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}