diff --git a/apps/desktop-gpui/src/editor_timeline.rs b/apps/desktop-gpui/src/editor_timeline.rs index c9eb39230e7..6a7b568a824 100644 --- a/apps/desktop-gpui/src/editor_timeline.rs +++ b/apps/desktop-gpui/src/editor_timeline.rs @@ -712,6 +712,96 @@ impl PartialEq for WaveformGeometry { } } +/// The span of a segment (segment-local output seconds) its cached waveform +/// path covers. The visible slice is grown to a window anchored on a grid of +/// a quarter of the viewport, so a timeline that scrolls -- playback following +/// the playhead while zoomed in -- reuses one path for a quarter-viewport of +/// travel instead of rebuilding it every frame. A segment that is entirely +/// visible covers exactly that, as before. +pub fn waveform_window(visible: (f64, f64), zoom: f64, duration: f64) -> (f64, f64) { + if !zoom.is_finite() || zoom <= 0. || !duration.is_finite() || duration <= 0. { + return visible; + } + let grid = zoom / 4.; + let step = (visible.0 / grid).floor() - 1.; + let start = (step * grid).max(0.).min(visible.0); + let end = (start + zoom * 1.5).max(visible.1).min(duration); + (start, end) +} + +/// Where a segment's waveform path sits inside its canvas. Everything is +/// canvas-local, so a timeline that only scrolls keeps hitting the cache; the +/// path is moved into place when painted. +#[derive(Debug, Clone, Copy, PartialEq)] +struct WaveformLayout { + range: (f64, f64), + origin: gpui::Point, + size: gpui::Size, + samples: usize, + clip_bounds: gpui::Bounds, +} + +/// `view` is the timeline's (position, zoom), `segment` its (start, output +/// duration) and `canvas` the (canvas height, lane width, lane height, wave +/// height). `None` when the segment is off screen. +fn waveform_layout( + view: (f64, f64), + segment: (f64, f64), + full_width: f64, + canvas: (Pixels, Pixels, Pixels, Pixels), +) -> Option { + let (view_start, zoom) = view; + let (timeline_start, output_duration) = segment; + let (canvas_height, lane_width, lane_height, wave_height) = canvas; + // The visible slice, in segment-local output seconds + // (`TL/ClipTrack.tsx:202-245`). + let view_end = view_start + zoom; + let visible_start = view_start.max(timeline_start) - timeline_start; + let visible_end = view_end.min(timeline_start + output_duration) - timeline_start; + if visible_end <= visible_start { + return None; + } + let px_per_sec = full_width / output_duration; + let range = waveform_window((visible_start, visible_end), zoom, output_duration); + let window_width = (range.1 - range.0) * px_per_sec; + Some(WaveformLayout { + range, + origin: gpui::point(px((range.0 * px_per_sec) as f32), px(0.)), + size: gpui::size(px(window_width as f32), wave_height), + samples: waveform_window_sample_count(window_width, zoom * px_per_sec), + clip_bounds: gpui::Bounds { + origin: gpui::point(px(0.), canvas_height - lane_height), + size: gpui::size(lane_width, lane_height), + }, + }) +} + +/// `waveform_sample_count` for a window wider than the viewport: the sample +/// cap grows with the window, so the slice on screen keeps the density it had +/// when only the visible slice was built. +fn waveform_window_sample_count(window_width: f64, viewport_width: f64) -> usize { + let scale = if viewport_width.is_finite() && viewport_width > 0. { + (window_width / viewport_width).max(1.) + } else { + 1. + }; + let cap = (MAX_WAVEFORM_SAMPLES as f64 * scale).ceil() as usize; + ((window_width * WAVEFORM_SAMPLES_PER_PIXEL).ceil() as usize).min(cap) +} + +/// `path` moved by `offset`: cached waveform paths are built relative to their +/// segment and placed where the segment is drawn this frame. +fn translated_path( + mut path: gpui::Path, + offset: gpui::Point, +) -> gpui::Path { + for vertex in &mut path.vertices { + vertex.xy_position = vertex.xy_position + offset; + } + path.bounds.origin = path.bounds.origin + offset; + path +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum WaveformTrack { Mic, @@ -3217,27 +3307,13 @@ fn render_waveform( gpui::canvas( |bounds, _window, _cx| bounds, move |_, bounds, window, _cx| { - // The visible slice, in segment-local output seconds - // (`TL/ClipTrack.tsx:202-245`). Off screen entirely: nothing. - let view_start = transform.position; - let view_end = view_start + transform.zoom; - let visible_start = view_start.max(timeline_start) - timeline_start; - let visible_end = view_end.min(timeline_start + output_duration) - timeline_start; - if visible_end <= visible_start { + let Some(layout) = waveform_layout( + (transform.position, transform.zoom), + (timeline_start, output_duration), + full_width, + (bounds.size.height, px(width), px(height), px(wave_height)), + ) else { return; - } - let px_per_sec = full_width / output_duration; - let origin = gpui::point( - bounds.origin.x + px((visible_start * px_per_sec) as f32), - bounds.origin.y, - ); - let slice_width = ((visible_end - visible_start) * px_per_sec) as f32; - let size = gpui::size(px(slice_width), px(wave_height)); - let samples = waveform_sample_count(slice_width as f64); - - let clip_bounds = gpui::Bounds { - origin: gpui::point(bounds.origin.x, bounds.bottom() - px(height)), - size: gpui::size(px(width), px(height)), }; for (track, peaks, scale) in [ @@ -3246,16 +3322,17 @@ fn render_waveform( ] { let geometry = WaveformGeometry { peaks: peaks.clone(), - range: (visible_start, visible_end), - target_samples: samples, + range: layout.range, + target_samples: layout.samples, holds: holds.clone(), source_start: segment_start, - origin, - size, + origin: layout.origin, + size: layout.size, scale, - clip_bounds, + clip_bounds: layout.clip_bounds, }; if let Some(path) = paths.get_or_build((index, track), geometry) { + let path = translated_path(path, bounds.origin); window.with_content_mask(Some(gpui::ContentMask { bounds }), |window| { window.paint_path(path, wave_color) }); @@ -4659,6 +4736,125 @@ mod tests { } } + #[test] + fn the_waveform_window_always_covers_the_visible_slice() { + let duration = 120.; + let zoom = 20.; + let mut start = 0.; + while start + zoom <= duration { + let visible = (start, start + zoom); + let window = waveform_window(visible, zoom, duration); + assert!( + window.0 <= visible.0 && window.1 >= visible.1, + "{window:?} {visible:?}" + ); + assert!(window.1 - window.0 <= zoom * 1.5 + 1e-9); + start += 0.37; + } + } + + #[test] + fn a_fully_visible_segment_covers_exactly_its_visible_slice() { + assert_eq!(waveform_window((0., 8.), 30., 8.), (0., 8.)); + assert_eq!(waveform_window((0., 30.), 30., 30.), (0., 30.)); + } + + #[test] + fn scrolling_reuses_the_window_for_a_quarter_viewport() { + let zoom = 20.; + let duration = 600.; + let mut windows = std::collections::HashSet::new(); + // 60 s of follow-mode playback at 60 fps. + for frame in 0..3600 { + let start = 100. + frame as f64 / 60.; + let window = waveform_window((start, start + zoom), zoom, duration); + windows.insert((window.0.to_bits(), window.1.to_bits())); + } + assert!(windows.len() <= 13, "{} distinct windows", windows.len()); + } + + #[test] + fn a_translated_relative_path_matches_one_built_in_place() { + let peaks = speech_like_peaks(400, 7.); + let offset = gpui::point(px(250.5), px(312.25)); + let relative = waveform_geometry(peaks.clone()); + let path = waveform_path(&relative).unwrap(); + let moved = translated_path(path.clone(), offset); + for (before, after) in path.vertices.iter().zip(&moved.vertices) { + assert_eq!(after.xy_position, before.xy_position + offset); + } + assert_eq!(moved.bounds.origin, path.bounds.origin + offset); + assert_eq!(moved.bounds.size, path.bounds.size); + + // Building at the absolute position gives the same shape; the + // tessellator's triangle order can differ with float rounding, so the + // comparison is on the vertex count and the bounds. + let absolute = WaveformGeometry { + origin: relative.origin + offset, + clip_bounds: gpui::Bounds { + origin: relative.clip_bounds.origin + offset, + size: relative.clip_bounds.size, + }, + ..relative.clone() + }; + let built = waveform_path(&absolute).unwrap(); + assert_eq!(built.vertices.len(), moved.vertices.len()); + let close = |a: Pixels, b: Pixels| (f32::from(a) - f32::from(b)).abs() < 0.01; + assert!(close(built.bounds.origin.x, moved.bounds.origin.x)); + assert!(close(built.bounds.origin.y, moved.bounds.origin.y)); + assert!(close(built.bounds.size.width, moved.bounds.size.width)); + assert!(close(built.bounds.size.height, moved.bounds.size.height)); + } + + #[test] + fn follow_mode_paths_are_built_once_per_window() { + let paths = WaveformPaths::default(); + let slot = (0, WaveformTrack::Mic); + let peaks = speech_like_peaks(2000, 9.); + let zoom = 20.; + let segment = (12., 190.); + let full_width = 190. * 60.; + let canvas = (px(44.), px(full_width as f32), px(44.), px(18.)); + for frame in 0..600 { + let position = 40. + frame as f64 / 60.; + let layout = waveform_layout((position, zoom), segment, full_width, canvas).unwrap(); + let geometry = WaveformGeometry { + range: layout.range, + target_samples: layout.samples, + origin: layout.origin, + size: layout.size, + clip_bounds: layout.clip_bounds, + ..waveform_geometry(peaks.clone()) + }; + paths.get_or_build(slot, geometry).unwrap(); + } + let stats = paths.stats(); + assert!(stats.builds <= 3, "{stats:?}"); + assert_eq!(stats.builds + stats.hits, 600); + } + + #[test] + fn an_off_screen_segment_has_no_waveform_layout() { + let canvas = (px(44.), px(600.), px(44.), px(18.)); + assert!(waveform_layout((0., 20.), (30., 10.), 600., canvas).is_none()); + assert!(waveform_layout((50., 20.), (30., 10.), 600., canvas).is_none()); + } + + #[test] + fn the_wider_window_keeps_the_on_screen_sample_density_at_the_cap() { + for (window, viewport) in [(15_000., 10_000.), (4_500., 3_000.), (900., 600.)] { + let samples = waveform_window_sample_count(window, viewport); + let window_density = samples as f64 / window; + let visible_density = waveform_sample_count(viewport) as f64 / viewport; + assert!( + (window_density - visible_density).abs() < 1e-3, + "{window} {viewport}: {window_density} vs {visible_density}" + ); + } + assert_eq!(waveform_window_sample_count(300., 600.), 600); + assert_eq!(waveform_window_sample_count(300., 0.), 600); + } + #[test] fn cached_waveform_matches_an_uncached_build_after_every_input_change() { let paths = WaveformPaths::default();