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
12const GROUP: &str = "speech-indicator";
14
15const BARS: usize = 4;
17
18const WEIGHTS: [f32; BARS] = [0.65, 1., 0.85, 0.55];
21
22const LAGS: [f32; BARS] = [1., 0., 0.5, 1.5];
25
26const STILL: [f32; BARS] = [0.4, 0.7, 0.55, 0.35];
28
29const BREATH: f32 = 0.12;
32
33const BREATH_RATE: f32 = 2.4;
35
36const BREATH_SPREAD: f32 = 0.9;
38
39const BAR_RATIO: f32 = 0.085;
42const GAP_RATIO: f32 = 0.075;
43const HEIGHT_RATIO: f32 = 0.46;
44
45#[derive(IntoElement)]
60pub struct SpeechIndicator {
61 state: Entity<SpeechState>,
62 size: Size,
63}
64
65impl SpeechIndicator {
66 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 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
135fn 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 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 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
166fn 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 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}