Files
Ely-GPUI-Components/src/media/audio.rs
T

257 lines
9.3 KiB
Rust

use std::{rc::Rc, time::Duration};
use gpui::{
App, Bounds, Corners, DragMoveEvent, ElementId, EmptyView, EntityId, HoverListenerMode,
InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, RenderOnce,
StatefulInteractiveElement, Styled, Window, canvas, div, fill, point, prelude::*, relative,
size, transparent_black,
};
use super::scrubber::{OnTime, along, clock, time_at, time_tip};
use crate::{
forms::keyed,
layout::fit,
primitives::{FocusRing, tab_stop},
theme::{ActiveTheme, Radius},
};
/// Seconds an arrow key moves the time.
const STEP: f64 = 5.0;
/// `peaks` gathered into `count` bars, each the loudest peak it covers.
pub(crate) fn resample(peaks: &[f32], count: usize) -> Vec<f32> {
assert!(
!peaks.is_empty() && count > 0,
"{} peaks into {count}",
peaks.len()
);
// In u64: bar by length outgrows a 32-bit usize on the web.
let edge = |bar: usize| (bar as u64 * peaks.len() as u64 / count as u64) as usize;
(0..count)
.map(|bar| {
let from = edge(bar);
let to = edge(bar + 1).max(from + 1);
peaks[from..to].iter().copied().fold(0.0, f32::max)
})
.collect()
}
/// A seeking drag on a waveform, marked with its waveform.
struct Seek(EntityId);
/// The waveform's bounds and the share under the pointer.
#[derive(Default)]
struct Wave {
bounds: Bounds<Pixels>,
hover: Option<f32>,
}
/// A sound's waveform, its peaks from 0 to 1 in time order, dark as far as it has played; the time under the pointer shows above it. A press or a drag seeks; with focus the arrows step five seconds, the Page keys fifty, Home and End go to the ends. The host plays it.
#[derive(IntoElement)]
pub struct AudioWaveform {
id: ElementId,
peaks: Rc<Vec<f32>>,
length: Duration,
at: Duration,
on_seek: Option<OnTime>,
}
impl AudioWaveform {
pub fn new(
id: impl Into<ElementId>,
peaks: impl Into<Vec<f32>>,
length: Duration,
at: Duration,
) -> Self {
let peaks: Vec<f32> = peaks.into();
assert!(
!peaks.is_empty() && peaks.iter().all(|peak| (0.0..=1.0).contains(peak)),
"peaks lie within 0 to 1"
);
assert!(!length.is_zero(), "{at:?} of no length");
let at = super::played(at, length, "audio waveform");
Self {
id: id.into(),
peaks: Rc::new(peaks),
length,
at,
on_seek: None,
}
}
/// Gets the time to seek to.
pub fn on_seek(mut self, handler: impl Fn(Duration, &mut Window, &mut App) + 'static) -> Self {
self.on_seek = Some(Rc::new(handler));
self
}
}
impl RenderOnce for AudioWaveform {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let wave = window.use_keyed_state((self.id.clone(), "wave"), cx, |_, _| Wave::default());
let focus = tab_stop(
(self.id.clone(), "focus").into(),
self.on_seek.is_some(),
window,
cx,
);
let owner = wave.entity_id();
let (length, at, peaks) = (self.length, self.at, self.peaks);
let played = at.as_secs_f32() / length.as_secs_f32();
let hover = wave.read(cx).hover;
let theme = cx.theme();
let sizes = theme.media();
let rem = window.rem_size();
let (bar, gap) = (sizes.wave_bar.to_pixels(rem), sizes.wave_gap.to_pixels(rem));
let (dark, light) = (theme.colors.fg, theme.colors.fg_subtle);
let tip = hover.map(|share| time_tip(share, clock(time_at(share, length)), cx));
let measured = wave.clone();
let drawn = canvas(
move |bounds, window, cx| {
if measured.read(cx).bounds != bounds {
measured.update(cx, |wave, cx| {
wave.bounds = bounds;
cx.notify();
});
window.request_animation_frame();
}
},
move |bounds, _, window, _| {
let width = f32::from(bounds.size.width);
let pitch = f32::from(bar + gap);
let (count, inset) = fit(bounds.size.width, bar, gap);
let inset = f32::from(inset);
for (ix, peak) in resample(&peaks, count).into_iter().enumerate() {
let left = inset + pitch * ix as f32;
let tall = (bounds.size.height * peak).max(bar);
let top = bounds.top() + (bounds.size.height - tall) / 2.0;
let origin = point(bounds.left() + Pixels::from(left), top);
let lit = (left + f32::from(bar) / 2.0) / width <= played;
window.paint_quad(
fill(
Bounds::new(origin, size(bar, tall)),
if lit { dark } else { light },
)
.corner_radii(Corners::all(bar / 2.0)),
);
}
},
)
.absolute()
.top_0()
.left_0()
.size_full();
let (moved, left) = (wave.clone(), wave.clone());
div()
.id(self.id.clone())
.debug_selector(|| "audio-waveform".into())
.relative()
.h(sizes.waveform)
.border_1()
.border_color(transparent_black())
.rounded(theme.radius(Radius::Sm))
.child(drawn)
.children(tip)
.on_mouse_move(move |event, _, cx| {
moved.update(cx, |wave, cx| {
wave.hover = along(wave.bounds, event.position);
cx.notify();
})
})
.hover_listener_mode(HoverListenerMode::InputModalityIndependent)
.on_hover(move |inside, _, cx| {
if !inside {
left.update(cx, |wave, cx| {
wave.hover = None;
cx.notify();
})
}
})
.when_some(self.on_seek, |shown, seek| {
let (pressed, dragged, keys) = (seek.clone(), seek.clone(), seek);
let (pressed_wave, dragged_wave) = (wave.clone(), wave.clone());
shown
.track_focus(&focus)
.focus_ring(cx)
.cursor_pointer()
.on_mouse_down(MouseButton::Left, move |event, window, cx| {
let Some(share) = along(pressed_wave.read(cx).bounds, event.position)
else {
return;
};
let time = time_at(share, length);
log::info!("audio waveform: seek to {}", clock(time));
pressed(time, window, cx);
})
.on_drag(Seek(owner), |_, _, _, cx| cx.new(|_| EmptyView))
.on_drag_move(move |event: &DragMoveEvent<Seek>, window, cx| {
if event.drag(cx).0 != owner {
return;
}
let bounds = dragged_wave.read(cx).bounds;
let Some(share) = along(bounds, event.event.position) else {
return;
};
dragged(time_at(share, length), window, cx);
})
.on_key_down(move |event, window, cx| {
let (now, end) = (at.as_secs_f64(), length.as_secs_f64());
let Some(next) = keyed(&event.keystroke.key, now, 0.0, end, STEP) else {
return;
};
cx.stop_propagation();
keys(Duration::from_secs_f64(next), window, cx);
})
})
}
}
/// Sound by pitch, low to high: bars rising from a line, each level from 0 to 1. The host sends new levels as it plays.
#[derive(IntoElement)]
pub struct AudioSpectrum {
levels: Vec<f32>,
}
impl AudioSpectrum {
pub fn new(levels: impl Into<Vec<f32>>) -> Self {
let levels: Vec<f32> = levels.into();
assert!(
!levels.is_empty() && levels.iter().all(|level| (0.0..=1.0).contains(level)),
"levels lie within 0 to 1"
);
Self { levels }
}
}
impl RenderOnce for AudioSpectrum {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let (color, round) = (theme.colors.fg_muted, theme.radius(Radius::Sm));
div()
.debug_selector(|| "audio-spectrum".into())
.h(theme.media().waveform)
.flex()
.items_end()
.gap_0p5()
.children(self.levels.into_iter().map(move |level| {
div()
.flex_1()
.h(relative(level.max(0.02)))
.rounded_t(round)
.bg(color)
}))
}
}
#[cfg(test)]
mod tests {
use super::resample;
#[test]
fn peaks_gather_into_bars_by_their_loudest() {
assert_eq!(resample(&[0.1, 0.9, 0.3, 0.2], 2), [0.9, 0.3]);
assert_eq!(resample(&[0.4, 0.8], 4), [0.4, 0.4, 0.8, 0.8]);
assert_eq!(resample(&[0.5], 3), [0.5, 0.5, 0.5]);
}
}