Edit window: draw large edited sessions without per-clip work every frame

On a 63,424-clip drum edit the UI thread spent ~187 ms per frame, so with a plugin editor open
(a frame every 30 ms) it never went idle and the editor windows, which Windows only paints
when the thread's queue is empty, stopped redrawing. Now ~11 ms of CPU per frame:

- Overlap shadows: each drawn clip checked every clip of its track; on a sorted track the
  clips starting inside it are found by binary search (sortedness is checked once per track
  and frame, and an unsorted track is still scanned).
- Fade curves: a fade took at least 65 points, even under a pixel wide. A narrow one takes
  ~4 * its width in physical pixels; the chord error stays under a quarter pixel (tested).
- Clip List: rows are listed and filtered once per document revision and filter text, and
  only the rows in view are laid out (show_rows).
- Universe: each track's clips are merged into same-colour runs covering exactly the same
  pixels, cached per revision and area (5,000 clips become a handful of rects).
- Session::content_end, which walks every clip, is worked out once per revision.
- Alternate playlist lanes skip clips outside the view, like the main lane, and clip names
  are not laid out where no glyph would show.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrrobbiecandClaude Opus 5.5 committed 2026-10-20 16:01:25 +10:00
1 parent 25d52c646a
commit 09c2e71141
2 files changed
+274 -42

No files matched your search

+185 -25
View File
@@ -115,26 +115,81 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
crate::docking::show(app, ui, crate::MainWindow::Edit);
}
/// Universe: the whole session in miniature; the box is the visible area (drag or click to move).
fn universe(app: &mut SoundApp, ui: &mut Ui) {
let r = ui.max_rect().shrink2(vec2(8.0, 4.0));
let s = app.engine.session();
/// The Universe's clip blocks for one document revision and area: each track's clips merged into
/// runs of one colour. A drum edit holds tens of thousands of clips a pixel or less wide here;
/// painting one rect per clip every frame would cost more than all the rest of the window.
#[derive(Clone)]
struct UniverseRuns {
revision: u64,
area: Rect,
runs: std::sync::Arc<Vec<(Rect, Color32)>>,
}
/// Each clip covers `[x0, max(x1, x0 + 1)]`; neighbours of one colour that touch or overlap are
/// painted as one rect, which covers exactly the same pixels in the same order.
fn universe_runs(s: &Session, r: Rect) -> Vec<(Rect, Color32)> {
let total = (s.content_end() + s.sample_rate.samples(10.0)).max(1) as f32;
let tracks: Vec<&Track> = s.tracks.iter().filter(|t| !t.hidden).collect();
let n = tracks.len().max(1) as f32;
let rh = (r.height() / n).max(1.0);
let mut runs = Vec::new();
for (i, t) in tracks.iter().enumerate() {
let y = r.min.y + i as f32 * rh;
let block = |x0: f32, x1: f32, color| (Rect::from_min_max(pos2(x0, y + 0.5), pos2(x1, y + rh - 0.5)), color);
let mut run: Option<(f32, f32, Color32)> = None;
for c in t.clips() {
let x0 = r.min.x + c.start as f32 / total * r.width();
let x1 = r.min.x + c.end() as f32 / total * r.width();
ui.painter().rect_filled(
Rect::from_min_max(pos2(x0, y + 0.5), pos2(x1.max(x0 + 1.0), y + rh - 0.5)),
0.0,
rgb(c.color.unwrap_or(t.color)),
);
let x1 = (r.min.x + c.end() as f32 / total * r.width()).max(x0 + 1.0);
let color = rgb(c.color.unwrap_or(t.color));
run = match run {
Some((a, b, col)) if col == color && x0 >= a && x0 <= b => Some((a, b.max(x1), col)),
Some((a, b, col)) => {
runs.push(block(a, b, col));
Some((x0, x1, color))
}
None => Some((x0, x1, color)),
};
}
if let Some((a, b, col)) = run {
runs.push(block(a, b, col));
}
}
runs
}
/// [`Session::content_end`], worked out once per document revision: it walks every clip.
fn content_end(app: &SoundApp, ctx: &egui::Context) -> Samples {
let key = egui::Id::new("content_end");
let revision = app.engine.revision;
if let Some((at, end)) = ctx.memory(|m| m.data.get_temp::<(u64, Samples)>(key))
&& at == revision
{
return end;
}
let end = app.engine.session().content_end();
ctx.memory_mut(|m| m.data.insert_temp(key, (revision, end)));
end
}
/// Universe: the whole session in miniature; the box is the visible area (drag or click to move).
fn universe(app: &mut SoundApp, ui: &mut Ui) {
let r = ui.max_rect().shrink2(vec2(8.0, 4.0));
let key = egui::Id::new("universe_runs");
let revision = app.engine.revision;
let runs = match ui.ctx().memory(|m| m.data.get_temp::<UniverseRuns>(key)).filter(|u| u.revision == revision && u.area == r) {
Some(u) => u.runs,
None => {
let runs = std::sync::Arc::new(universe_runs(app.engine.session(), r));
ui.ctx().memory_mut(|m| m.data.insert_temp(key, UniverseRuns { revision, area: r, runs: runs.clone() }));
runs
}
};
for &(rect, color) in runs.iter() {
ui.painter().rect_filled(rect, 0.0, color);
}
let end = content_end(app, ui.ctx());
let s = app.engine.session();
let total = (end + s.sample_rate.samples(10.0)).max(1) as f32;
let vis0 = s.edit.zoom.scroll as f32 / total;
let tlw = app.edit_layout.timeline[2] - app.edit_layout.timeline[0];
let vis1 = vis0 + (f64::from(tlw.max(100.0)) * s.edit.zoom.samples_per_px) as f32 / total;
@@ -373,9 +428,10 @@ fn pan(app: &mut SoundApp, px: f64) {
fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) {
let t = Tokens::current();
ui.painter().rect_filled(sb, 0.0, t.panel_bg);
let end = content_end(app, ui.ctx());
let s = app.engine.session();
let vis = f64::from(tl.width()) * s.edit.zoom.samples_per_px;
let total = (s.content_end().saturating_add(s.sample_rate.samples(30.0)).max(1) as f64).max(s.edit.zoom.scroll as f64 + vis);
let total = (end.saturating_add(s.sample_rate.samples(30.0)).max(1) as f64).max(s.edit.zoom.scroll as f64 + vis);
let frac = (vis / total).clamp(0.02, 1.0) as f32;
let off = (s.edit.zoom.scroll as f64 / total).clamp(0.0, 1.0) as f32;
let thumb = Rect::from_min_size(pos2(sb.min.x + sb.width() * off, sb.min.y + 3.0), vec2((sb.width() * frac).max(24.0), sb.height() - 6.0));
@@ -838,11 +894,12 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect,
}
let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x));
let auto_view = AutoParam::parse(&track.view);
let sorted = track.clips().is_sorted_by_key(|c| c.start);
for clip in track.clips() {
if !clip.range().overlaps(&view) {
continue;
}
draw_clip(app, &painter, &s, track, clip, tl, lane, auto_view.is_some());
draw_clip(app, &painter, &s, track, sorted, clip, tl, lane, auto_view.is_some());
}
draw_live_take(app, &painter, &s, track, tl, lane);
crate::video_track::draw_lane(app, &painter, &s, track, tl, lane);
@@ -885,8 +942,10 @@ fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, mai
ui.painter().rect_filled(lane, 0.0, t.playlist_lane);
ui.painter().text(pos2(head.min.x + 4.0, head.center().y), Align2::LEFT_CENTER, &pl.name, regular(10.5), t.text_dim);
let painter = ui.painter().with_clip_rect(lane.intersect(ui.clip_rect()));
for c in &pl.clips {
draw_clip(app, &painter, &s, track, c, tl, lane, false);
let sorted = track.clips().is_sorted_by_key(|c| c.start);
let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x));
for c in pl.clips.iter().filter(|c| c.range().overlaps(&view)) {
draw_clip(app, &painter, &s, track, sorted, c, tl, lane, false);
}
let resp = ui.interact(lane, ui.id().with(("pl_lane", track.id.0, i)), Sense::click());
if resp.clicked()
@@ -1511,9 +1570,13 @@ fn fade_curve_points(fade: soundcraft_model::Fade, fade_in: bool, a: f32, b: f32
let scale = if pixels_per_point.is_finite() { pixels_per_point.clamp(0.5, 4.0) } else { 1.0 };
let extent = (b - a).abs().max(body.height().abs()) * scale;
let extent = if extent.is_finite() { extent.clamp(64.0, 4096.0) } else { 64.0 };
// A narrow fade needs fewer: the curve between two samples stays inside a box `width / segments`
// wide, so it is never further than that from its chord. Zoomed out on an edited drum take,
// thousands of fades are a pixel or two wide.
let narrow = ((b - a).abs() * scale * 4.0).ceil() + 1.0;
// Physical-pixel sampling avoids visible corners, even for steep bends.
// Keep an exact midpoint and cap allocations for extreme zoom/clip sizes.
let segments = (extent.ceil() as usize).div_ceil(2) * 2;
let segments = (extent.min(narrow).max(2.0).ceil() as usize).div_ceil(2) * 2;
(0..=segments)
.map(|i| {
let k = i as f32 / segments as f32;
@@ -1582,7 +1645,8 @@ fn grid_veil(line: Color32, bg: Color32) -> Color32 {
}
}
fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track, clip: &Clip, tl: Rect, lane: Rect, dim: bool) {
/// `sorted`: whether `track.clips()` is sorted by start (worked out once per track and frame).
fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track, sorted: bool, clip: &Clip, tl: Rect, lane: Rect, dim: bool) {
let x0 = x_of(s, tl, clip.start);
let x1 = x_of(s, tl, clip.end()).max(x0 + 1.0);
let r = Rect::from_min_max(pos2(x0, lane.min.y + 1.0), pos2(x1, lane.max.y - 1.0));
@@ -1604,13 +1668,16 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track
let nb = Rect::from_min_max(r.min, pos2(r.max.x, r.min.y + name_h));
painter.rect_filled(nb, CornerRadius { nw: 2, ne: 2, sw: 0, se: 0 }, if clip.muted { Color32::from_rgb(70, 70, 70) } else { bar });
let name_clip = nb.intersect(lane);
painter.with_clip_rect(name_clip).text(
pos2(nb.min.x.max(lane.min.x) + 4.0, nb.center().y),
Align2::LEFT_CENTER,
&clip.name,
bold(10.5),
Color32::from_rgb(12, 12, 12),
);
// Narrower than this, no glyph would show: the name starts 4 points in.
if name_clip.width() > 6.0 {
painter.with_clip_rect(name_clip).text(
pos2(nb.min.x.max(lane.min.x) + 4.0, nb.center().y),
Align2::LEFT_CENTER,
&clip.name,
bold(10.5),
Color32::from_rgb(12, 12, 12),
);
}
}
let body_r = Rect::from_min_max(pos2(r.min.x, r.min.y + name_h), r.max);
let vis = body_r.intersect(lane);
@@ -1772,8 +1839,16 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track
painter.text(pos2(r.max.x - 6.0, r.max.y - 7.0), Align2::RIGHT_CENTER, "FX", bold(9.0), Color32::from_rgb(250, 230, 120));
}
if s.edit.flag("view.clip.overlap_shadows") {
// Shade where a later clip covers this one (crossfades and layered edits).
for o in track.clips() {
// Shade where a later clip covers this one (crossfades and layered edits). Only clips that
// start inside this one can: on a sorted track, find them instead of checking every clip.
let clips = track.clips();
let later = if sorted {
let from = clips.partition_point(|o| o.start <= clip.start);
clips.get(from..clips.partition_point(|o| o.start < clip.end()).max(from)).unwrap_or_default()
} else {
clips
};
for o in later {
if o.id != clip.id && o.start > clip.start && o.start < clip.end() {
let x0 = x_of(s, tl, o.start);
let x1 = x_of(s, tl, clip.end().min(o.end()));
@@ -2776,6 +2851,60 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) {
}
}
#[cfg(test)]
mod universe_tests {
use super::*;
use soundcraft_model::{ChannelFormat, Clip, ClipId, SourceId, TrackKind};
/// The pixel columns each block covers, per colour, as the old one-rect-per-clip painting did.
fn covered(blocks: &[(Rect, Color32)]) -> Vec<(i32, Color32)> {
let mut px: Vec<(i32, Color32)> = Vec::new();
for (r, c) in blocks {
for x in (r.min.x * 4.0).round() as i32..(r.max.x * 4.0).round() as i32 {
px.retain(|p| p.0 != x);
px.push((x, *c));
}
}
px.sort_by_key(|p| p.0);
px
}
fn per_clip(s: &Session, r: Rect) -> Vec<(Rect, Color32)> {
let total = (s.content_end() + s.sample_rate.samples(10.0)).max(1) as f32;
let t = &s.tracks[0];
t.clips()
.iter()
.map(|c| {
let x0 = r.min.x + c.start as f32 / total * r.width();
let x1 = (r.min.x + c.end() as f32 / total * r.width()).max(x0 + 1.0);
(Rect::from_min_max(pos2(x0, r.min.y + 0.5), pos2(x1, r.max.y - 0.5)), rgb(c.color.unwrap_or(t.color)))
})
.collect()
}
#[test]
fn a_sliced_track_is_a_few_runs_covering_the_same_pixels() {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let mut clips = Vec::new();
let mut at = 0;
for i in 0..5000u64 {
let mut c = Clip::audio(ClipId(i + 1), "hit", SourceId(1), 0, at, 200 + (i % 7) as i64 * 50);
// A recoloured stretch, and a gap, in the middle.
if (2000..2100).contains(&i) {
c.color = Some([200, 40, 40]);
}
at = c.end() + if i == 3000 { 2_000_000 } else { 0 };
clips.push(c);
}
s.track_mut(t).unwrap().playlist_mut().unwrap().clips = clips;
let r = Rect::from_min_size(pos2(8.0, 4.0), vec2(900.0, 56.0));
let runs = universe_runs(&s, r);
assert!(runs.len() <= 6, "{} rects for 5000 clips", runs.len());
assert_eq!(covered(&runs), covered(&per_clip(&s, r)));
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -3444,6 +3573,37 @@ mod tests {
}
}
#[test]
fn narrow_fades_take_few_points_and_stay_within_a_quarter_pixel() {
for width in [0.2f32, 1.0, 3.0, 12.0] {
for height in [40.0f32, 320.0] {
let body = Rect::from_min_size(Pos2::ZERO, vec2(width, height));
for scale in [1.0, 2.0] {
for shape in soundcraft_model::FadeShape::ALL {
for curvature in [-1.0, 0.0, 1.0] {
let fade = soundcraft_model::Fade { len: 1000, shape, curvature };
let points = fade_curve_points(fade, true, 0.0, width, body, scale);
assert!(points.len() <= ((width * scale * 4.0).ceil() as usize + 4), "{width} wide: {} points", points.len());
assert_eq!((points.len() - 1) % 2, 0, "an exact midpoint");
let segments = (points.len() - 1) as f32;
for (i, pair) in points.windows(2).enumerate() {
let line = pair[1] - pair[0];
for fraction in [0.1, 0.25, 0.5, 0.75, 0.9] {
let t = (i as f32 + fraction) / segments;
let actual = pos2(width * t, height - height * fade.gain(t));
let along =
if line.length_sq() > 0.0 { ((actual - pair[0]).dot(line) / line.length_sq()).clamp(0.0, 1.0) } else { 0.0 };
let distance = actual.distance(pair[0] + line * along) * scale;
assert!(distance < 0.25, "{width}x{height}, scale={scale}, {shape:?}, curve={curvature}, error={distance}");
}
}
}
}
}
}
}
}
fn painted_fade_control(h: &Harness<SoundApp>, clip_rect: Rect) -> Pos2 {
let controls: Vec<_> = h
.output()
+89 -17
View File
@@ -184,32 +184,75 @@ fn gutter_scroll<R>(ui: &Ui, gutter: Rect, out: &egui::scroll_area::ScrollAreaOu
}
}
/// One row of the clip list: a whole file, or a clip on a track (positions in the session).
#[derive(Clone, Copy)]
enum ClipRow {
Source(usize),
Clip { track: usize, clip: usize },
}
/// The clip list's rows for one document revision and filter. A drum edit holds tens of thousands
/// of clips: the rows are listed (and filtered) when either changes, not every frame.
#[derive(Clone)]
struct ClipRows {
revision: u64,
filter: String,
rows: std::sync::Arc<Vec<ClipRow>>,
}
fn clip_rows(s: &soundcraft_model::Session, needle: &str) -> Vec<ClipRow> {
let shown = |name: &str| needle.is_empty() || name.to_lowercase().contains(needle);
// Whole files first (bold), then clips on tracks.
let sources =
s.sources.iter().enumerate().filter(|(_, src)| shown(&format!("{} ({}ch)", src.name, src.channels))).map(|(i, _)| ClipRow::Source(i));
let clips =
s.tracks.iter().enumerate().flat_map(|(track, tr)| {
tr.clips().iter().enumerate().filter(|(_, c)| shown(&c.name)).map(move |(clip, _)| ClipRow::Clip { track, clip })
});
sources.chain(clips).collect()
}
pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::current();
panel_header(ui, "CLIPS");
let items: Vec<(u64, String, bool, [u8; 3], bool)> = {
let s = app.engine.session();
let mut v = Vec::new();
// Whole files first (bold), then clips on tracks.
for src in &s.sources {
v.push((src.id.0, format!("{} ({}ch)", src.name, src.channels), true, [150, 150, 150], s.edit.selected_sources.contains(&src.id)));
}
for tr in &s.tracks {
for c in tr.clips() {
v.push((c.id.0, c.name.clone(), false, c.color.unwrap_or(tr.color), s.edit.selected_clips.contains(&c.id)));
}
}
v
};
let fkey = egui::Id::new("clip_filter");
let mut filter: String = ui.ctx().memory(|m| m.data.get_temp(fkey)).unwrap_or_default();
ui.add(egui::TextEdit::singleline(&mut filter).hint_text("🔍 Find clips").desired_width(f32::INFINITY));
ui.ctx().memory_mut(|m| m.data.insert_temp(fkey, filter.clone()));
let needle = filter.to_lowercase();
let items: Vec<_> = items.into_iter().filter(|(_, n, _, _, _)| needle.is_empty() || n.to_lowercase().contains(&needle)).collect();
egui::ScrollArea::vertical().id_salt("clips_scroll").auto_shrink([false, false]).show(ui, |ui| {
let rkey = egui::Id::new("clip_rows");
let revision = app.engine.revision;
let cached = ui.ctx().memory(|m| m.data.get_temp::<ClipRows>(rkey)).filter(|c| c.revision == revision && c.filter == needle);
let rows = match cached {
Some(c) => c.rows,
None => {
let rows = std::sync::Arc::new(clip_rows(app.engine.session(), &needle));
ui.ctx().memory_mut(|m| m.data.insert_temp(rkey, ClipRows { revision, filter: needle, rows: rows.clone() }));
rows
}
};
let row_h = 17.0;
egui::ScrollArea::vertical().id_salt("clips_scroll").auto_shrink([false, false]).show_rows(ui, row_h, rows.len(), |ui, shown| {
// Only the rows in view: what they show is read now, before any of them acts on the session.
let items: Vec<(u64, String, bool, [u8; 3], bool)> = {
let s = app.engine.session();
rows.get(shown)
.unwrap_or_default()
.iter()
.filter_map(|row| match *row {
ClipRow::Source(i) => s.sources.get(i).map(|src| {
(src.id.0, format!("{} ({}ch)", src.name, src.channels), true, [150, 150, 150], s.edit.selected_sources.contains(&src.id))
}),
ClipRow::Clip { track, clip } => {
let tr = s.tracks.get(track)?;
let c = tr.clips().get(clip)?;
Some((c.id.0, c.name.clone(), false, c.color.unwrap_or(tr.color), s.edit.selected_clips.contains(&c.id)))
}
})
.collect()
};
for (id, name, whole, color, selected) in items {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), if whole { Sense::click_and_drag() } else { Sense::click() });
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), if whole { Sense::click_and_drag() } else { Sense::click() });
resp.context_menu(|ui| {
if whole {
if ui.button("Place on New Track").clicked() {
@@ -708,3 +751,32 @@ fn preset_menu(
});
ui.ctx().memory_mut(|m| m.data.insert_temp(key, name));
}
#[cfg(test)]
mod tests {
use super::*;
use soundcraft_model::{ChannelFormat, Clip, Session, TrackKind};
#[test]
fn clip_rows_list_every_clip_in_track_order_and_filter_by_name() {
let mut s = Session::default();
for names in [["Kick 1", "Snare 1"], ["Kick 2", "Tom"]] {
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
for (i, n) in names.into_iter().enumerate() {
let c = Clip::audio(s.new_clip_id(), n, SourceId(1), 0, i as i64 * 100, 100);
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(c);
}
}
let names = |rows: Vec<ClipRow>| -> Vec<String> {
rows.into_iter()
.filter_map(|r| match r {
ClipRow::Clip { track, clip } => Some(s.tracks[track].clips()[clip].name.clone()),
ClipRow::Source(_) => None,
})
.collect()
};
assert_eq!(names(clip_rows(&s, "")), ["Kick 1", "Snare 1", "Kick 2", "Tom"]);
assert_eq!(names(clip_rows(&s, "kick")), ["Kick 1", "Kick 2"]);
assert!(clip_rows(&s, "cowbell").is_empty());
}
}