use gpui::{
App, Bounds, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Pixels,
RenderOnce, Styled as _, Window, canvas, div, point, size,
};
use crate::{Icon, IconName, Sizable, Size};
use instant::Instant;
use super::{SpeechState, level::LEVEL_INTERVAL, waveform::capsule};
const GROUP: &str = "speech-indicator";
const BARS: usize = 4;
const WEIGHTS: [f32; BARS] = [0.65, 1., 0.85, 0.55];
const LAGS: [f32; BARS] = [1., 0., 0.5, 1.5];
const STILL: [f32; BARS] = [0.4, 0.7, 0.55, 0.35];
const BREATH: f32 = 0.12;
const BREATH_RATE: f32 = 2.4;
const BREATH_SPREAD: f32 = 0.9;
const BAR_RATIO: f32 = 0.085;
const GAP_RATIO: f32 = 0.075;
const HEIGHT_RATIO: f32 = 0.46;
#[derive(IntoElement)]
pub struct SpeechIndicator {
state: Entity<SpeechState>,
size: Size,
}
impl SpeechIndicator {
pub fn new(state: &Entity<SpeechState>) -> Self {
Self {
state: state.clone(),
size: Size::default(),
}
}
}
impl Sizable for SpeechIndicator {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl RenderOnce for SpeechIndicator {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = self.state.read(cx);
let heights = if cx.reduce_motion() {
STILL
} else {
let lead = state.level_lead_at(Instant::now());
let levels: Vec<f32> = state.levels().collect();
bar_heights(&levels, state.levels_recorded(), lead)
};
if state.status().is_capturing() && !cx.reduce_motion() {
window.request_animation_frame();
}
div()
.size_full()
.relative()
.group(GROUP)
.child(
div()
.absolute()
.inset_0()
.group_hover(GROUP, |this| this.opacity(0.))
.child(
canvas(
|_, _, _| {},
move |bounds, _, window, _| {
let color = window.text_style().color;
for rect in bar_rects(bounds, &heights) {
if let Some(path) = capsule(rect) {
window.paint_path(path, color);
}
}
},
)
.size_full(),
),
)
.child(
div()
.absolute()
.inset_0()
.flex()
.items_center()
.justify_center()
.opacity(0.)
.group_hover(GROUP, |this| this.opacity(1.))
.child(Icon::new(IconName::Square).with_size(self.size)),
)
}
}
fn bar_heights(levels: &[f32], recorded: u64, lead: Option<f32>) -> [f32; BARS] {
let (Some(lead), Some(last)) = (lead, levels.len().checked_sub(1)) else {
return [0.; BARS];
};
let playhead = last as f32 - lead;
let at = |position: f32| {
let position = position.clamp(0., last as f32);
let below = position.floor() as usize;
let above = (below + 1).min(last);
let fraction = position - below as f32;
levels[below] + (levels[above] - levels[below]) * fraction
};
let seconds = (recorded as f32 - lead) * LEVEL_INTERVAL.as_secs_f32();
let quiet = 1. - (at(playhead) * 4.).min(1.);
std::array::from_fn(|ix| {
let voice = at(playhead - LAGS[ix]) * WEIGHTS[ix];
let phase = seconds * BREATH_RATE + ix as f32 * BREATH_SPREAD;
let breath = BREATH * (0.5 + 0.5 * phase.sin()) * quiet;
voice.max(breath).clamp(0., 1.)
})
}
fn bar_rects(bounds: Bounds<Pixels>, heights: &[f32; BARS]) -> Vec<Bounds<Pixels>> {
let side = bounds.size.width.min(bounds.size.height);
let bar = side * BAR_RATIO;
let gap = side * GAP_RATIO;
let full = side * HEIGHT_RATIO;
let total = bar * BARS as f32 + gap * (BARS - 1) as f32;
let left = bounds.center().x - total / 2.;
let middle = bounds.center().y;
heights
.iter()
.enumerate()
.map(|(ix, height)| {
let height = bar + (full - bar) * *height;
Bounds::new(
point(left + (bar + gap) * ix as f32, middle - height / 2.),
size(bar, height),
)
})
.collect()
}
#[cfg(test)]
mod tests {
use gpui::px;
use super::*;
#[test]
fn the_bars_are_dots_before_any_audio() {
assert_eq!(bar_heights(&[], 0, None), [0.; BARS]);
assert_eq!(bar_heights(&[0.5], 1, None), [0.; BARS]);
}
#[test]
fn the_bars_follow_a_loud_voice_fullest_in_the_middle() {
let heights = bar_heights(&[1.; 8], 8, Some(0.));
assert_eq!(heights, WEIGHTS);
}
#[test]
fn the_bars_rise_one_after_another() {
let heights = bar_heights(&[0., 0., 0., 1.], 4, Some(0.));
assert!(heights[1] > 0.9);
assert!(heights[0] < heights[1]);
assert!(heights[3] < heights[2]);
}
#[test]
fn the_bars_glide_between_levels() {
let levels = [0., 1.];
let at_edge = bar_heights(&levels, 2, Some(0.))[1];
let between = bar_heights(&levels, 2, Some(0.5))[1];
let behind = bar_heights(&levels, 2, Some(1.))[1];
assert!(
behind < between && between < at_edge,
"{behind} {between} {at_edge}"
);
}
#[test]
fn the_bars_breathe_in_silence_but_stay_low() {
let silence = [0.; 8];
let early = bar_heights(&silence, 8, Some(0.));
let later = bar_heights(&silence, 12, Some(0.));
assert_ne!(early, later);
assert!(early.iter().chain(&later).all(|height| *height <= BREATH));
}
#[test]
fn bars_sit_side_by_side_around_the_center_and_grow_from_the_midline() {
let bounds = Bounds::new(point(px(0.), px(0.)), size(px(24.), px(24.)));
let rects = bar_rects(bounds, &[0., 1., 0.5, 0.]);
let first = rects.first().unwrap();
let last = rects.last().unwrap();
assert!((first.left() + last.right() - px(24.)).abs() < px(0.01));
for rect in &rects {
assert!((rect.center().y - px(12.)).abs() < px(0.01));
assert!(rect.size.height >= rect.size.width);
}
assert!(rects[1].size.height > rects[2].size.height);
assert!(rects[2].size.height > rects[0].size.height);
}
}