From a47ce1cee5d3ac05a05fa0c5c4b70680b4ab48bf Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:18:25 +0100 Subject: [PATCH 1/2] Waveforms like Pro Tools': exact per-pixel peaks and working view options Waveforms looked blocky and jagged next to Pro Tools': each column was a point wide (two pixels on Retina screens), and the overview only knew 32-sample blocks, so zooming in showed stair steps and, at the sample level, wide flat bars instead of the signal. Several View > Waveforms options did nothing or drew something else. - Peaks keeps one tree of blocks (min, max and sum of squares). Its queries read the partial blocks at a range's edges from the samples, so every answer is exact and still O(log n). `Peaks::spans` gives each column the (min, max) of the line through the samples over its span; neighbouring spans share their boundary, so the waveform stays connected at any zoom. - The Edit window draws a column per physical pixel, each over a 1-point window anchored to absolute sample positions (no shimmer while scrolling), with anti-aliased ends, in one triangle strip where runs of equal columns such as silence share vertices. At the sample level (a sample per pixel or less) the waveform is a 1-point line through the samples, without Rectified, Power or Outlines, as in Pro Tools. - Outlines draws an edge around a lighter fill, as Pro Tools does; it used to replace the fill with a line of dots along the tops. - Power shows the RMS, which is how Pro Tools calculates it (`Peaks::rms_spans`). Ours squashed the peak heights with a square root, which drew loud material bigger than Peak view. Power draws +/-RMS around the centre, or 0..RMS above the base line when Rectified. - Dark theme clips have a dark body tinted with the clip colour and a light waveform in that colour, as in Pro Tools, instead of black waveforms on a mid-tone body; a selected clip inverts to a bright body with a dark waveform, as before. MIDI notes on unselected clips take the waveform colour, with velocity as opacity. The Light theme is unchanged. - Blocks view draws clips as empty blocks with their names, and in Playlists view the alternate playlists' lanes draw their clips like the main lane, waveforms included, instead of empty boxes. - Overlapped Crossfades draws the outgoing clip's waveform, translucent, over the crossfade, so both clips' audio shows inside it. The outgoing clip is found by binary search in the clip's own playlist. A crossfade also shows both fade curves: the outgoing clip's fade-out used to be hidden under the incoming clip. Tests: the peaks unit tests check every range, span and RMS against the samples; tests/waveform.rs checks the sample-level line, a one-sample spike zoomed out, Blocks and playlist lanes, and Overlapped Crossfades. --- crates/audio-io/src/peaks.rs | 296 +++++++++++++++++++----------- crates/ui-egui/src/edit_window.rs | 99 +++++----- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/theme.rs | 20 +- crates/ui-egui/src/waveform.rs | 186 +++++++++++++++++++ crates/ui-egui/tests/waveform.rs | 91 +++++++++ 6 files changed, 514 insertions(+), 179 deletions(-) create mode 100644 crates/ui-egui/src/waveform.rs create mode 100644 crates/ui-egui/tests/waveform.rs diff --git a/crates/audio-io/src/peaks.rs b/crates/audio-io/src/peaks.rs index d13c1ea..9553665 100644 --- a/crates/audio-io/src/peaks.rs +++ b/crates/audio-io/src/peaks.rs @@ -1,48 +1,62 @@ -//! Multi-resolution min/max overview for waveform drawing. +//! Multi-resolution overview for waveform drawing. //! -//! Level 0 holds the (min, max) of each block of [`BLOCK`] samples; each following level merges -//! [`FANOUT`] blocks of the level below, until a level has a single block. Range queries combine -//! the coarsest whole blocks that fit inside the range, so a query costs O(log n) regardless of -//! zoom. +//! Level 0 sums up each block of [`BLOCK`] samples (min, max and sum of squares); each following +//! level merges [`FANOUT`] blocks of the level below, until a level has a single block. Range +//! queries combine the coarsest whole blocks that fit inside the range and read the partial blocks +//! at its edges from the samples, so a query is exact and costs O(log n) regardless of zoom. /// Samples per level-0 block. pub const BLOCK: usize = 32; /// Blocks of level `n` merged into one block of level `n + 1`. pub const FANOUT: usize = 4; -/// Upper bound on the number of columns [`Peaks::columns`] will produce. +/// Upper bound on the number of spans [`Peaks::spans`] will produce. pub const MAX_COLUMNS: usize = 1 << 20; -/// Multi-resolution min/max overview for waveform drawing. Level 0 = blocks of 32 samples; each next level merges 4. +/// Multi-resolution overview of one channel for waveform drawing. #[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Peaks { len: usize, - levels: Vec>, + levels: Vec>, } -const EMPTY: (f32, f32) = (f32::INFINITY, f32::NEG_INFINITY); - -fn merge(a: (f32, f32), b: (f32, f32)) -> (f32, f32) { - (a.0.min(b.0), a.1.max(b.1)) +/// What a run of samples sums up to; NaN samples count for nothing. +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +struct Block { + min: f32, + max: f32, + squares: f32, } -fn finish(acc: (f32, f32)) -> (f32, f32) { - if acc.0 > acc.1 { (0.0, 0.0) } else { acc } +impl Block { + const EMPTY: Block = Block { min: f32::INFINITY, max: f32::NEG_INFINITY, squares: 0.0 }; + + fn point(s: f32) -> Block { + Block { min: s, max: s, squares: if s.is_finite() { s * s } else { 0.0 } } + } + + fn of(samples: &[f32]) -> Block { + samples.iter().fold(Block::EMPTY, |b, &s| b.merge(Block::point(s))) + } + + fn merge(self, o: Block) -> Block { + Block { min: self.min.min(o.min), max: self.max.max(o.max), squares: self.squares + o.squares } + } + + /// (min, max), or (0, 0) when nothing was summed up. + fn range(self) -> (f32, f32) { + if self.min > self.max { (0.0, 0.0) } else { (self.min, self.max) } + } } impl Peaks { /// Build the overview of `samples` (one channel). NaN samples are ignored. pub fn build(samples: &[f32]) -> Peaks { - let mut levels = Vec::new(); - let level0: Vec<(f32, f32)> = samples.chunks(BLOCK).map(|c| finish(c.iter().fold(EMPTY, |acc, &s| merge(acc, (s, s))))).collect(); - if level0.is_empty() { - return Peaks { len: 0, levels }; + if samples.is_empty() { + return Peaks::default(); } - levels.push(level0); - while let Some(last) = levels.last() { - if last.len() <= 1 { - break; - } - let next: Vec<(f32, f32)> = last.chunks(FANOUT).map(|c| c.iter().fold(EMPTY, |acc, &b| merge(acc, b))).collect(); + let mut levels: Vec> = vec![samples.chunks(BLOCK).map(Block::of).collect()]; + while let Some(last) = levels.last().filter(|l| l.len() > 1) { + let next = last.chunks(FANOUT).map(|c| c.iter().fold(Block::EMPTY, |a, &b| a.merge(b))).collect(); levels.push(next); } Peaks { len: samples.len(), levels } @@ -58,65 +72,84 @@ impl Peaks { self.levels.len() } - /// (min, max) over the sample range `[start, end)`, clamped to the data. Exact when both ends - /// are multiples of 32 (or `end` is the data length); otherwise the partial blocks at the edges - /// contribute their whole block's extremes, so the result always contains the true range. - /// Returns `(0.0, 0.0)` for an empty range. - pub fn minmax(&self, start: usize, end: usize) -> (f32, f32) { - let end = end.min(self.len); - if start >= end { - return (0.0, 0.0); + /// (min, max) over the sample range `[start, end)`, clamped to the data. `samples` must be the + /// data the overview was built from. Returns `(0.0, 0.0)` for an empty range. + pub fn minmax(&self, samples: &[f32], start: usize, end: usize) -> (f32, f32) { + self.sum(samples, start, end).range() + } + + fn sum(&self, samples: &[f32], start: usize, end: usize) -> Block { + let end = end.min(self.len).min(samples.len()); + let (first, last) = (start.div_ceil(BLOCK), end / BLOCK); + let raw = |a: usize, b: usize| Block::of(samples.get(a..b).unwrap_or_default()); + if first >= last { + return raw(start, end); } - let mut lo = start / BLOCK; - let mut hi = end.div_ceil(BLOCK); - let mut acc = EMPTY; - let mut lvl = 0usize; - while lo < hi { - let Some(level) = self.levels.get(lvl) else { break }; - let has_parent = lvl + 1 < self.levels.len(); - if !has_parent || hi - lo < 2 * FANOUT { - for b in level.get(lo..hi.min(level.len())).unwrap_or_default() { - acc = merge(acc, *b); - } + raw(start, first * BLOCK).merge(raw(last * BLOCK, end)).merge(self.blocks(first, last)) + } + + /// The level-0 blocks `[lo, hi)`, summed from the coarsest blocks that fit. + fn blocks(&self, mut lo: usize, mut hi: usize) -> Block { + let mut acc = Block::EMPTY; + for (lvl, level) in self.levels.iter().enumerate() { + let mut add = |i: usize| acc = acc.merge(level.get(i).copied().unwrap_or(Block::EMPTY)); + if lvl + 1 == self.levels.len() || hi.saturating_sub(lo) < 2 * FANOUT { + (lo..hi).for_each(add); break; } - while !lo.is_multiple_of(FANOUT) && lo < hi { - acc = merge(acc, level.get(lo).copied().unwrap_or(EMPTY)); + while !lo.is_multiple_of(FANOUT) { + add(lo); lo += 1; } - while !hi.is_multiple_of(FANOUT) && hi > lo { + while !hi.is_multiple_of(FANOUT) { hi -= 1; - acc = merge(acc, level.get(hi).copied().unwrap_or(EMPTY)); + add(hi); } lo /= FANOUT; hi /= FANOUT; - lvl += 1; } - finish(acc) + acc } - /// Per-column (min, max) for drawing `cols` pixel columns spanning the sample range - /// `[start, end)`. Columns that fall outside the data are `(0.0, 0.0)`. Each column covers at - /// least one sample, so zoomed-in views repeat the containing sample's block. - pub fn columns(&self, start: f64, end: f64, cols: usize) -> Vec<(f32, f32)> { - let cols = cols.min(MAX_COLUMNS); - let mut out = vec![(0.0, 0.0); cols]; - if cols == 0 || !start.is_finite() || !end.is_finite() || end <= start || self.len == 0 { - return out; - } - let span = (end - start) / cols as f64; - let len = self.len as f64; - for (c, slot) in out.iter_mut().enumerate() { - let s = start + span * c as f64; - let e = start + span * (c + 1) as f64; - if e <= 0.0 || s >= len { - continue; - } - let si = s.max(0.0).floor(); - let ei = e.min(len).ceil().max(si + 1.0); - *slot = self.minmax(si as usize, ei as usize); - } - out + /// The spans of sample positions `[start + c * step, start + (c + 1) * step + overlap]` that + /// reach the data, clamped to it. + fn windows(&self, samples: &[f32], start: f64, step: f64, overlap: f64, cols: usize) -> impl Iterator> { + let n = self.len.min(samples.len()); + let valid = n > 0 && start.is_finite() && step.is_finite() && step > 0.0 && overlap.is_finite() && overlap >= 0.0; + let last = n.saturating_sub(1) as f64; + (0..cols.min(MAX_COLUMNS)).map(move |c| { + let (a, b) = (start + step * c as f64, start + step * (c + 1) as f64 + overlap); + (valid && b >= 0.0 && a <= last).then(|| (a.max(0.0), b.min(last))) + }) + } + + /// (min, max) of the line through `samples` over each of `cols` spans of sample positions + /// `[start + c * step, start + (c + 1) * step + overlap]`; fractional positions interpolate + /// between samples. Neighbouring spans share at least their boundary, so a waveform drawn from + /// them stays connected at any zoom. `None` for spans outside the data. + pub fn spans(&self, samples: &[f32], start: f64, step: f64, overlap: f64, cols: usize) -> Vec> { + let at = |t: f64| { + let i = t.floor(); + let a = samples.get(i as usize).copied().unwrap_or(0.0); + let b = samples.get(i as usize + 1).copied().unwrap_or(a); + Block::point(a + (b - a) * (t - i) as f32) + }; + self.windows(samples, start, step, overlap, cols) + .map(|w| w.map(|(a, b)| self.sum(samples, a.ceil() as usize, b.floor() as usize + 1).merge(at(a)).merge(at(b)).range())) + .collect() + } + + /// The RMS of `samples` over each span [`Peaks::spans`] gives, including the samples just + /// outside a span's ends. + pub fn rms_spans(&self, samples: &[f32], start: f64, step: f64, overlap: f64, cols: usize) -> Vec> { + self.windows(samples, start, step, overlap, cols) + .map(|w| { + w.map(|(a, b)| { + let (s, e) = (a.floor() as usize, (b.ceil() as usize).saturating_add(1).min(self.len).min(samples.len())); + (self.sum(samples, s, e).squares / e.saturating_sub(s).max(1) as f32).sqrt() + }) + }) + .collect() } } @@ -134,29 +167,19 @@ mod tests { } #[test] - fn aligned_ranges_are_exact() { + fn every_range_is_exact() { let x = signal(100_003); let p = Peaks::build(&x); - for &(a, b) in &[(0, 32), (0, 100_003), (32, 64_000), (3200, 3232), (96, 99_968), (64, 100_003)] { - assert_eq!(p.minmax(a, b), brute(&x, a, b), "range {a}..{b}"); + for &(a, b) in &[(0, 32), (0, 100_003), (32, 64_000), (3200, 3232), (96, 99_968), (64, 100_003), (5, 6), (31, 33), (1000, 1031)] { + assert_eq!(p.minmax(&x, a, b), brute(&x, a, b), "range {a}..{b}"); } - } - - #[test] - fn unaligned_ranges_contain_truth_and_match_block_cover() { - let x = signal(50_000); - let p = Peaks::build(&x); let mut seed = 12345u64; for _ in 0..2000 { seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); - let a = (seed >> 33) as usize % 50_000; + let a = (seed >> 33) as usize % 100_003; seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); - let b = a + 1 + (seed >> 33) as usize % (50_000 - a); - let got = p.minmax(a, b); - let truth = brute(&x, a, b); - assert!(got.0 <= truth.0 && got.1 >= truth.1, "{a}..{b}: {got:?} vs {truth:?}"); - let cover = brute(&x, a / BLOCK * BLOCK, (b.div_ceil(BLOCK) * BLOCK).min(x.len())); - assert_eq!(got, cover, "{a}..{b}"); + let b = a + 1 + (seed >> 33) as usize % (100_003 - a); + assert_eq!(p.minmax(&x, a, b), brute(&x, a, b), "range {a}..{b}"); } } @@ -164,34 +187,87 @@ mod tests { fn degenerate_inputs() { let p = Peaks::build(&[]); assert_eq!(p.len_samples(), 0); - assert_eq!(p.minmax(0, 10), (0.0, 0.0)); - assert_eq!(p.columns(0.0, 10.0, 4), vec![(0.0, 0.0); 4]); - let p = Peaks::build(&[0.5, -0.25]); - assert_eq!(p.minmax(0, 2), (-0.25, 0.5)); - assert_eq!(p.minmax(5, 3), (0.0, 0.0)); - assert_eq!(p.minmax(0, usize::MAX), (-0.25, 0.5)); - assert!(p.columns(f64::NAN, 1.0, 3).iter().all(|&c| c == (0.0, 0.0))); - assert_eq!(p.columns(0.0, 1.0, 0).len(), 0); - let p = Peaks::build(&[f32::NAN, 1.0]); - assert_eq!(p.minmax(0, 2), (1.0, 1.0)); + assert_eq!(p.minmax(&[], 0, 10), (0.0, 0.0)); + assert_eq!(p.spans(&[], 0.0, 1.0, 0.0, 4), vec![None; 4]); + let x = [0.5, -0.25]; + let p = Peaks::build(&x); + assert_eq!(p.minmax(&x, 0, 2), (-0.25, 0.5)); + assert_eq!(p.minmax(&x, 5, 3), (0.0, 0.0)); + assert_eq!(p.minmax(&x, 0, usize::MAX), (-0.25, 0.5)); + assert_eq!(p.minmax(&x[..1], 0, 2), (0.5, 0.5)); + for (start, step, overlap) in [ + (f64::NAN, 1.0, 0.0), + (0.0, 0.0, 0.0), + (0.0, -1.0, 0.0), + (0.0, f64::INFINITY, 0.0), + (f64::NEG_INFINITY, 1.0, 0.0), + (0.0, 1.0, -0.5), + (0.0, 1.0, f64::NAN), + ] { + assert_eq!(p.spans(&x, start, step, overlap, 3), vec![None; 3]); + } + assert_eq!(p.spans(&x, 0.0, 1.0, 0.0, 0).len(), 0); + assert_eq!(p.spans(&x, 0.0, 1.0, 0.0, usize::MAX).len(), MAX_COLUMNS); + let x = [f32::NAN, 1.0]; + let p = Peaks::build(&x); + assert_eq!(p.minmax(&x, 0, 2), (1.0, 1.0)); + assert_eq!(p.spans(&x, 0.0, 1.0, 0.0, 1), vec![Some((1.0, 1.0))]); + assert!(p.rms_spans(&x, 0.0, 1.0, 0.0, 1)[0].is_some_and(f32::is_finite)); } #[test] - fn columns_cover_range() { + fn rms_spans_match_the_samples() { + let x = signal(20_000); + let p = Peaks::build(&x); + let rms = |s: usize, e: usize| (x[s..e].iter().map(|&v| f64::from(v) * f64::from(v)).sum::() / (e - s) as f64).sqrt() as f32; + for (step, overlap) in [(1.5, 0.0), (37.0, 37.0), (1000.0, 0.0), (4096.0, 512.0)] { + let cols = ((19_999.0 - overlap) / step) as usize; + for (c, got) in p.rms_spans(&x, 0.0, step, overlap, cols).into_iter().enumerate() { + let (a, b) = (c as f64 * step, ((c + 1) as f64 * step + overlap).min(19_999.0)); + let (got, want) = (got.unwrap(), rms(a.floor() as usize, (b.ceil() as usize + 1).min(20_000))); + assert!((got - want).abs() <= want * 1e-4 + 1e-6, "span {c} at step {step}: {got} vs {want}"); + } + } + let sine: Vec = (0..48_000).map(|i| (i as f32 * 0.01).sin()).collect(); + let p = Peaks::build(&sine); + let r = p.rms_spans(&sine, 0.0, 47_000.0, 0.0, 1)[0].unwrap(); + assert!((r - std::f32::consts::FRAC_1_SQRT_2).abs() < 1e-3, "{r}"); + assert_eq!(p.rms_spans(&sine, f64::NAN, 1.0, 0.0, 2), vec![None, None]); + assert_eq!(p.rms_spans(&sine, 50_000.0, 1.0, 0.0, 1), vec![None]); + } + + #[test] + fn spans_cover_their_samples_and_touch_their_neighbours() { let x = signal(10_000); let p = Peaks::build(&x); - let cols = p.columns(0.0, 10_000.0, 100); - assert_eq!(cols.len(), 100); - for (c, &(lo, hi)) in cols.iter().enumerate() { - let truth = brute(&x, c * 100, c * 100 + 100); - assert!(lo <= truth.0 && hi >= truth.1); + for step in [0.3, 1.0, 7.5, 100.0, 333.3] { + let cols = (9_999.0 / step) as usize; + let spans = p.spans(&x, 0.0, step, 0.0, cols); + for (c, s) in spans.iter().enumerate() { + let (lo, hi) = s.unwrap_or_else(|| panic!("span {c} at step {step}")); + let (a, b) = ((c as f64 * step).ceil() as usize, ((c + 1) as f64 * step).floor() as usize); + if a <= b { + let truth = brute(&x, a, b + 1); + assert!(lo <= truth.0 && hi >= truth.1, "span {c} at step {step}"); + } + if let Some(Some((nlo, nhi))) = spans.get(c + 1) { + assert!(*nlo <= hi && *nhi >= lo, "spans {c} and {} at step {step} don't touch", c + 1); + } + } + let all = spans.iter().flatten().fold((f32::INFINITY, f32::NEG_INFINITY), |a, s| (a.0.min(s.0), a.1.max(s.1))); + assert_eq!(all, brute(&x, 0, ((cols as f64 * step).floor() as usize + 1).min(10_000)), "step {step}"); } - let all = cols.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |a, &c| (a.0.min(c.0), a.1.max(c.1))); - assert_eq!(all, brute(&x, 0, 10_000)); - // Past the end of the data. - let cols = p.columns(9_000.0, 11_000.0, 2); - assert_eq!(cols[1], (0.0, 0.0)); - // Zoomed in further than one sample per column. - assert_eq!(p.columns(0.0, 4.0, 8).len(), 8); + } + + #[test] + fn spans_follow_the_line_between_samples() { + let x = [0.0, 1.0, -1.0, 0.5]; + let p = Peaks::build(&x); + assert_eq!(p.spans(&x, 0.0, 0.25, 0.0, 4), vec![Some((0.0, 0.25)), Some((0.25, 0.5)), Some((0.5, 0.75)), Some((0.75, 1.0))]); + assert_eq!(p.spans(&x, 0.5, 1.0, 0.0, 1), vec![Some((0.0, 1.0))]); + let spans = vec![None, Some((0.0, 0.0)), Some((0.0, 1.0)), Some((-1.0, 1.0)), Some((-1.0, 0.5)), Some((0.5, 0.5)), None]; + assert_eq!(p.spans(&x, -2.0, 1.0, 0.0, 7), spans); + assert_eq!(p.spans(&x, 0.0, 1.0, 1.0, 3), vec![Some((-1.0, 1.0)), Some((-1.0, 1.0)), Some((-1.0, 0.5))]); + assert_eq!(p.spans(&x, 0.0, 1.0, 0.5, 3), vec![Some((0.0, 1.0)), Some((-1.0, 1.0)), Some((-1.0, 0.5))]); } } diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index f13bc78..bbd5dc6 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -744,9 +744,7 @@ fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, mai 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 { - let r = Rect::from_min_max(pos2(x_of(&s, tl, c.start), lane.min.y + 3.0), pos2(x_of(&s, tl, c.end()), lane.max.y - 3.0)); - painter.rect(r, 2.0, rgb(track.color).gamma_multiply(0.45), Stroke::new(1.0, Color32::BLACK), StrokeKind::Inside); - painter.text(pos2(r.min.x + 4.0, r.min.y + 7.0), Align2::LEFT_CENTER, &c.name, regular(9.5), t.text); + draw_clip(app, &painter, &s, track, c, tl, lane, false); } let resp = ui.interact(lane, ui.id().with(("pl_lane", track.id.0, i)), Sense::click()); if resp.clicked() @@ -1179,58 +1177,15 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track let body_r = Rect::from_min_max(pos2(r.min.x, r.min.y + name_h), r.max); let vis = body_r.intersect(lane); match &clip.content { - ClipContent::Audio { source, offset } => { + _ if track.view == "blocks" => {} + ClipContent::Audio { source, .. } => { if let Some(audio) = s.pool.get(*source) { - let nch = track.channels().min(audio.peaks.len().max(1)).max(1); - let ch_h = body_r.height() / nch as f32; - let zoom = s.edit.zoom.waveform_zoom; - let rectified = s.edit.flag("waveform.rectified"); - let power = s.edit.flag("waveform.power"); - let outlines = s.edit.flag("waveform.outlines"); - let cols = vis.width().max(0.0) as usize; - if cols > 0 && vis.height() > 2.0 { - let a = (offset + sample_at(s, tl, vis.min.x) - clip.start) as f64; - let b = (offset + sample_at(s, tl, vis.max.x) - clip.start) as f64; - for ch in 0..nch { - let Some(pk) = audio.peaks.get(ch % audio.peaks.len().max(1)) else { continue }; - let cy = body_r.min.y + ch_h * (ch as f32 + 0.5); - let half = ch_h * 0.46; - let colsv = pk.columns(a, b, cols); - let mut mesh = egui::Mesh::default(); - for (i, (mn, mx)) in colsv.iter().enumerate() { - let x = vis.min.x + i as f32; - let rel = sample_at(s, tl, x) - clip.start; - let g = clip.gain_at(rel.clamp(0, clip.length)); - let (mut top, mut bot) = if rectified { - let a = (mx.abs().max(mn.abs()) * g * zoom).clamp(0.0, 1.0); - let b = cy + half; - (b - a * half * 2.0, b) - } else { - (cy - (mx * g * zoom).clamp(-1.0, 1.0) * half, cy - (mn * g * zoom).clamp(-1.0, 1.0) * half) - }; - if power { - // Power view: compress peaks logarithmically around the centre. - let k = |y: f32| cy + (y - cy).signum() * ((y - cy).abs() / half).sqrt() * half; - top = k(top); - bot = k(bot); - } - if outlines { - bot = top + 1.0; - } - let bot = if (bot - top) < 1.0 { top + 1.0 } else { bot }; - let idx = mesh.vertices.len() as u32; - mesh.colored_vertex(pos2(x, top), wave); - mesh.colored_vertex(pos2(x + 1.0, top), wave); - mesh.colored_vertex(pos2(x + 1.0, bot), wave); - mesh.colored_vertex(pos2(x, bot), wave); - mesh.add_triangle(idx, idx + 1, idx + 2); - mesh.add_triangle(idx, idx + 2, idx + 3); - } - painter.with_clip_rect(vis).add(Shape::mesh(mesh)); - if nch > 1 && ch + 1 < nch { - let y = body_r.min.y + ch_h * (ch as f32 + 1.0); - painter.line_segment([pos2(vis.min.x, y), pos2(vis.max.x, y)], Stroke::new(1.0, body.gamma_multiply(0.7))); - } + if vis.width() > 0.0 && vis.height() > 2.0 { + crate::waveform::draw(&painter.with_clip_rect(vis), s, tl, clip, audio, track.channels(), body_r, wave); + let n = track.channels().min(audio.peaks.len()).max(1); + for k in 1..n { + let y = body_r.min.y + body_r.height() / n as f32 * k as f32; + painter.line_segment([pos2(vis.min.x, y), pos2(vis.max.x, y)], Stroke::new(1.0, body.gamma_multiply(0.7))); } } } else { @@ -1254,7 +1209,11 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track } let y = body_r.max.y - 3.0 - (f32::from(n.pitch.saturating_sub(lo)) / span) * (body_r.height() - 6.0); let nh = ((body_r.height() - 6.0) / span).clamp(1.5, 6.0); - let vcol = Color32::from_rgb(20, 20 + n.velocity, 40); + let vcol = if crate::theme::is_light() { + Color32::from_rgb(20, 20 + n.velocity, 40) + } else { + wave.gamma_multiply(0.5 + f32::from(n.velocity) / 254.0) + }; p.rect_filled( Rect::from_min_max(pos2(nx0, y - nh * 0.5), pos2(nx1, y + nh * 0.5)), 1.0, @@ -1263,6 +1222,21 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track } } } + let prev = crossfade_partner(track, clip); + if s.edit.flag("waveform.overlapped_crossfades") + && track.view != "blocks" + && let Some(prev) = prev + && let ClipContent::Audio { source, .. } = &prev.content + && let Some(audio) = s.pool.get(*source) + { + let xf = Rect::from_min_max(pos2(x0, body_r.min.y), pos2(x_of(s, tl, prev.end()).min(x1), body_r.max.y)).intersect(vis); + let tail = if prev.muted || dim { + Color32::from_rgb(70, 70, 70) + } else { + clip_colors(prev.color.unwrap_or(track.color), false).2.gamma_multiply(0.6) + }; + crate::waveform::draw(&painter.with_clip_rect(xf), s, tl, prev, audio, track.channels(), body_r, tail); + } // Clip gain line. let show_gain = s.edit.flag("view.clip.gain_line") && (!clip.gain_env.is_empty() || clip.gain_db.abs() > 0.01 || track.view == "clip_gain"); if show_gain && body_r.height() > 12.0 { @@ -1300,12 +1274,12 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track painter.add(Shape::line(pts, Stroke::new(1.2, fade_col))); painter.line_segment([pos2(x0, body_r.min.y), pos2(fx, body_r.min.y)], Stroke::new(1.0, fade_col)); } - if clip.fade_out.len > 0 { - let fx = x_of(s, tl, clip.end() - clip.fade_out.len); + for c in std::iter::once(clip).chain(prev).filter(|c| c.fade_out.len > 0) { + let (fx, ex) = (x_of(s, tl, c.end() - c.fade_out.len), x_of(s, tl, c.end())); let pts: Vec = (0..=16) .map(|i| { let k = i as f32 / 16.0; - pos2(fx + (x1 - fx) * k, body_r.max.y - body_r.height() * clip.fade_out.shape.gain(1.0 - k)) + pos2(fx + (ex - fx) * k, body_r.max.y - body_r.height() * c.fade_out.shape.gain(1.0 - k)) }) .collect(); painter.add(Shape::line(pts, Stroke::new(1.2, fade_col))); @@ -1364,6 +1338,15 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track let _ = app; } +/// The earlier clip of a crossfade pair: the one before `clip` in its playlist, overlapping its start. +fn crossfade_partner<'a>(track: &'a Track, clip: &Clip) -> Option<&'a Clip> { + let (p, i) = track.playlists.iter().find_map(|p| { + let i = p.clips.partition_point(|c| c.start < clip.start); + p.clips.get(i).is_some_and(|c| c.id == clip.id).then_some((p, i)) + })?; + p.clips.get(i.checked_sub(1)?).filter(|c| c.end() > clip.start) +} + fn draw_automation(painter: &egui::Painter, s: &Session, track: &Track, p: &AutoParam, tl: Rect, lane: Rect) { let t = Tokens::current(); let (lo, hi, _) = p.range(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c4a751..4336cf6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -24,6 +24,7 @@ pub mod theme; pub mod toolbar; pub mod video_track; pub mod video_window; +pub mod waveform; pub mod widgets; pub mod windows; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index c667e6f..37aae95 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -347,20 +347,18 @@ pub fn wants_light(ctx: &egui::Context, mode: ThemeMode) -> bool { /// Track colours tinted for clip bodies. pub fn clip_colors(rgb: [u8; 3], selected: bool) -> (Color32, Color32, Color32) { let [r, g, b] = rgb; - let k = if selected { 0.95 } else { 0.62 }; - let body = Color32::from_rgb((f32::from(r) * k) as u8, (f32::from(g) * k) as u8, (f32::from(b) * k) as u8); - let bar = Color32::from_rgb((f32::from(r) * 0.9) as u8, (f32::from(g) * 0.9) as u8, (f32::from(b) * 0.9) as u8); + let shade = |k: f32| Color32::from_rgb((f32::from(r) * k) as u8, (f32::from(g) * k) as u8, (f32::from(b) * k) as u8); + let tint = |k: f32| { + let t = |c: u8| (f32::from(c) + (255.0 - f32::from(c)) * k) as u8; + Color32::from_rgb(t(r), t(g), t(b)) + }; + let bar = shade(0.9); if is_light() { // A paler body and a waveform in a deep shade of the clip's own colour. - let tint = |c: u8, k: f32| (f32::from(c) + (255.0 - f32::from(c)) * k) as u8; - let k = if selected { 0.2 } else { 0.45 }; - let body = Color32::from_rgb(tint(r, k), tint(g, k), tint(b, k)); - let d = if selected { 0.22 } else { 0.32 }; - let wave = Color32::from_rgb((f32::from(r) * d) as u8, (f32::from(g) * d) as u8, (f32::from(b) * d) as u8); - return (body, bar, wave); + return if selected { (tint(0.2), bar, shade(0.22)) } else { (tint(0.45), bar, shade(0.32)) }; } - let wave = if selected { Color32::from_rgb(20, 20, 24) } else { Color32::from_rgb(12, 12, 14) }; - (body, bar, wave) + // A dark body and a light waveform in the clip's own colour; selection inverts them. + if selected { (shade(0.7), bar, Color32::from_rgb(16, 16, 18)) } else { (shade(0.34), bar, tint(0.35)) } } pub fn rgb(c: [u8; 3]) -> Color32 { diff --git a/crates/ui-egui/src/waveform.rs b/crates/ui-egui/src/waveform.rs new file mode 100644 index 0000000..2378f07 --- /dev/null +++ b/crates/ui-egui/src/waveform.rs @@ -0,0 +1,186 @@ +//! Clip waveforms, drawn per physical pixel, or as a line through the samples at the sample level. + +use egui::{Color32, Mesh, Painter, Rect, Shape, Stroke, pos2, vec2}; +use soundcraft_model::{Clip, Session, SourceAudio}; +use soundcraft_time::Samples; + +/// Draw a clip's audio across `body`, one band per channel, `channels` of them at most. +pub fn draw(painter: &Painter, s: &Session, tl: Rect, clip: &Clip, audio: &SourceAudio, channels: usize, body: Rect, wave: Color32) { + let n = channels.min(audio.peaks.len()).max(1); + let h = body.height() / n as f32; + for ch in 0..n { + channel(painter, s, tl, clip, audio, ch, Rect::from_min_size(pos2(body.min.x, body.min.y + h * ch as f32), vec2(body.width(), h)), wave); + } +} + +fn channel(painter: &Painter, s: &Session, tl: Rect, clip: &Clip, audio: &SourceAudio, ch: usize, band: Rect, wave: Color32) { + let (Some(samples), Some(peaks)) = (audio.channel(ch), audio.peaks.get(ch)) else { return }; + let Some(g) = Grid::new(painter, s, tl, band) else { return }; + let (rectified, power, outlines) = (s.edit.flag("waveform.rectified"), s.edit.flag("waveform.power"), s.edit.flag("waveform.outlines")); + let origin = clip.start.saturating_sub(clip.source_offset()) as f64; + let gain = |pos: f64| clip.gain_at((pos as Samples).saturating_sub(clip.start).min(clip.length).max(0)) * s.edit.zoom.waveform_zoom; + if g.step <= 1.0 { + let first = (g.pos_at(g.vis.min.x) - origin).floor().max(0.0) as usize; + let last = (g.pos_at(g.vis.max.x) - origin).ceil() as usize; + let from = first.saturating_sub(1); + let points = samples.get(from..samples.len().min(last.saturating_add(2))).unwrap_or_default().iter().zip(from..).map(|(&v, i)| { + let pos = origin + i as f64; + pos2(g.x_at(pos), g.cy - level(v * gain(pos)) * g.half) + }); + painter.add(Shape::line(points.collect(), Stroke::new(g.weight, wave))); + return; + } + let (start, overlap) = (g.start() - origin, g.overlap()); + let spans = if power { + peaks.rms_spans(samples, start, g.step, overlap, g.cols).into_iter().map(|r| r.map(|r| (-r, r))).collect() + } else { + peaks.spans(samples, start, g.step, overlap, g.cols) + }; + let base = g.cy + g.half; + let ys: Vec> = spans + .into_iter() + .enumerate() + .map(|(c, span)| { + let (lo, hi) = span?; + let k = gain((g.k0 + c as f64 + 0.5) * g.step); + Some(if rectified { + (base - level(lo.abs().max(hi.abs()) * k) * g.half * 2.0, base) + } else { + (g.cy - level(hi * k) * g.half, g.cy - level(lo * k) * g.half) + }) + }) + .collect(); + g.paint(painter, &ys, wave, outlines, rectified); +} + +fn level(v: f32) -> f32 { + if v.is_finite() { v.clamp(-1.0, 1.0) } else { 0.0 } +} + +/// One column per physical pixel across the visible part of a channel's band, each summing up a +/// one-point window of timeline samples anchored to absolute positions, so scrolling doesn't +/// change what a column shows. +struct Grid { + vis: Rect, + tl_x: f32, + scroll: f64, + /// Timeline samples per point, and per column. + spp: f64, + step: f64, + px: f32, + /// A one-point line, in whole physical pixels. + weight: f32, + cy: f32, + half: f32, + /// The first column's physical pixel and absolute window index, and the column count. + p0: f32, + k0: f64, + cols: usize, +} + +impl Grid { + fn new(painter: &Painter, s: &Session, tl: Rect, band: Rect) -> Option { + let vis = painter.clip_rect().intersect(band); + if !vis.is_positive() { + return None; + } + let ppp = painter.pixels_per_point(); + let px = 1.0 / ppp; + let weight = ppp.round().max(1.0) * px; + let spp = s.edit.zoom.samples_per_px.max(0.01); + let step = spp * f64::from(px); + let scroll = s.edit.zoom.scroll as f64; + let p0 = (vis.min.x * ppp).floor(); + let k0 = ((scroll + f64::from((p0 + 0.5) * px - tl.min.x) * spp) / step).floor(); + let cy = ((band.center().y - weight * 0.5) * ppp).round() * px + weight * 0.5; + let cols = ((vis.max.x * ppp).ceil() - p0).max(0.0) as usize; + Some(Grid { vis, tl_x: tl.min.x, scroll, spp, step, px, weight, cy, half: band.height() * 0.46, p0, k0, cols }) + } + + fn pos_at(&self, x: f32) -> f64 { + self.scroll + f64::from(x - self.tl_x) * self.spp + } + + fn x_at(&self, pos: f64) -> f32 { + self.tl_x + ((pos - self.scroll) / self.spp) as f32 + } + + /// How far each column's window reaches into its neighbours': a column is `step` apart but sums + /// up a whole point. + fn overlap(&self) -> f64 { + (self.spp - self.step).max(0.0) + } + + /// The timeline position where the first column's window starts. + fn start(&self) -> f64 { + self.k0 * self.step - self.overlap() * 0.5 + } + + /// Fill the columns' (top, bottom) spans; with `outlines`, edge them in `wave` around a lighter + /// fill, along the top only when `rectified`. + fn paint(&self, painter: &Painter, ys: &[Option<(f32, f32)>], wave: Color32, outlines: bool, rectified: bool) { + let mut mesh = Mesh::default(); + self.strip(&mut mesh, ys, if outlines { wave.gamma_multiply(0.55) } else { wave }); + let edges: &[fn((f32, f32)) -> f32] = match (outlines, rectified) { + (false, _) => &[], + (true, true) => &[|y| y.0], + (true, false) => &[|y| y.0, |y| y.1], + }; + for edge in edges { + let line: Vec> = ys + .iter() + .enumerate() + .map(|(c, y)| { + let y = edge((*y)?); + let near = |i: Option| i.and_then(|i| ys.get(i).copied().flatten()).map_or(y, |n| (edge(n) + y) * 0.5); + let (a, b) = (near(c.checked_sub(1)), near(Some(c + 1))); + Some((y.min(a).min(b) - self.weight * 0.5, y.max(a).max(b) + self.weight * 0.5)) + }) + .collect(); + self.strip(&mut mesh, &line, wave); + } + painter.add(Shape::mesh(mesh)); + } + + /// Fill each column's (top, bottom) span, at least `weight` tall, joined to its neighbours in + /// one triangle strip and anti-aliased at both ends. Runs of equal spans share vertices. + fn strip(&self, mesh: &mut Mesh, spans: &[Option<(f32, f32)>], color: Color32) { + let half = self.px * 0.5; + let thick = |(top, bot): (f32, f32)| { + if bot - top < self.weight { ((top + bot - self.weight) * 0.5, (top + bot + self.weight) * 0.5) } else { (top, bot) } + }; + let push = |mesh: &mut Mesh, prev: &mut Option, x: f32, (top, bot): (f32, f32)| { + let i = mesh.vertices.len() as u32; + mesh.colored_vertex(pos2(x, top - half), Color32::TRANSPARENT); + mesh.colored_vertex(pos2(x, top + half), color); + mesh.colored_vertex(pos2(x, bot - half), color); + mesh.colored_vertex(pos2(x, bot + half), Color32::TRANSPARENT); + if let Some(j) = *prev { + for k in 0..3 { + mesh.add_triangle(j + k, j + k + 1, i + k); + mesh.add_triangle(j + k + 1, i + k + 1, i + k); + } + } + *prev = Some(i); + }; + let at = |c: Option| c.and_then(|c| spans.get(c).copied().flatten()).map(thick); + let mut prev = None; + for c in 0..spans.len() { + let Some(span) = at(Some(c)) else { + prev = None; + continue; + }; + let x = (self.p0 + c as f32 + 0.5) * self.px; + let (before, after) = (at(c.checked_sub(1)), at(Some(c + 1))); + if before.is_none() { + push(mesh, &mut prev, x - half, span); + } + if before != Some(span) || after != Some(span) { + push(mesh, &mut prev, x, span); + } + if after.is_none() { + push(mesh, &mut prev, x + half, span); + } + } + } +} diff --git a/crates/ui-egui/tests/waveform.rs b/crates/ui-egui/tests/waveform.rs new file mode 100644 index 0000000..4726f23 --- /dev/null +++ b/crates/ui-egui/tests/waveform.rs @@ -0,0 +1,91 @@ +use egui::{Color32, Pos2, RawInput, Rect, Shape, vec2}; +use serde_json::json; +use soundcraft_audio_io::AudioBuffer; +use soundcraft_model::{Clip, ClipId, Session, SourceAudio, SourceId}; +use soundcraft_time::SampleRate; +use soundcraft_ui_egui::{Services, SoundApp, waveform}; + +const WAVE: Color32 = Color32::from_rgb(12, 12, 14); + +fn draw(samples: Vec, samples_per_px: f64) -> Vec { + let mut s = Session::new("t", SampleRate::HZ_48000); + s.edit.zoom.samples_per_px = samples_per_px; + let clip = Clip::audio(ClipId(1), "c", SourceId(1), 0, 0, samples.len() as i64); + let audio = SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![samples] }); + let band = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 100.0)); + let ctx = egui::Context::default(); + ctx.set_pixels_per_point(2.0); + let mut out = ctx.run_ui(RawInput { screen_rect: Some(band), ..Default::default() }, |ui| { + waveform::draw(ui.painter(), &s, band, &clip, &audio, 1, band, WAVE); + }); + out.textures_delta.clear(); + out.shapes.into_iter().map(|c| c.shape).filter(|s| matches!(s, Shape::Path(_) | Shape::Mesh(_))).collect() +} + +#[test] +fn at_the_sample_level_the_waveform_is_a_line_through_the_samples() { + let x: Vec = (0..200).map(|i| (i as f32 * 0.3).sin() * 0.8).collect(); + let shapes = draw(x.clone(), 0.25); + let [Shape::Path(line)] = shapes.as_slice() else { panic!("{shapes:?}") }; + assert!(line.points.len() > 100); + for p in &line.points { + let i = (p.x / 4.0).round() as usize; + assert!((p.x - i as f32 * 4.0).abs() < 1e-3 && (p.y - (50.0 - x[i] * 46.0)).abs() < 1e-3, "{p:?}"); + } +} + +#[test] +fn zoomed_out_a_one_sample_spike_stays_a_point_wide() { + let mut x = vec![0.0f32; 4800]; + x[1600] = 1.0; + let shapes = draw(x, 8.0); + let [Shape::Mesh(mesh)] = shapes.as_slice() else { panic!("{shapes:?}") }; + let tall: Vec = mesh.vertices.iter().filter(|v| v.pos.y < 40.0).map(|v| v.pos.x).collect(); + let (left, right) = tall.iter().fold((f32::MAX, f32::MIN), |(a, b), &x| (a.min(x), b.max(x))); + assert!(right - left <= 1.0 && (199.0..=201.0).contains(&left), "spike drawn from {left} to {right}"); + assert!(mesh.vertices.iter().any(|v| v.pos.y < 5.0 && v.color == WAVE)); +} + +/// Waveform meshes drawn in a track's row, from `below` its top, once the app has settled. +fn waveforms(ctx: &egui::Context, app: &mut SoundApp, name: &str, below: f32) -> usize { + let mut shapes = Vec::new(); + for _ in 0..2 { + let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + shapes = out.shapes; + } + let id = app.engine.session().track_by_name(name).unwrap().id.0; + let row = app.edit_layout.rows.iter().find(|(i, _)| *i == id).unwrap().1; + let [x0, _, x1, _] = app.edit_layout.timeline; + let lane = Rect::from_min_max(egui::pos2(x0, row[1] + below), egui::pos2(x1, row[3])); + shapes.iter().filter(|c| matches!(&c.shape, Shape::Mesh(m) if m.vertices.iter().any(|v| lane.contains(v.pos)))).count() +} + +#[test] +fn blocks_hide_waveforms_and_playlist_lanes_show_them() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.engine.execute("track.playlist_duplicate", &json!({"track": "Bass"})).unwrap(); + app.engine.execute("track.view", &json!({"tracks": ["Bass"], "view": "playlists"})).unwrap(); + app.engine.execute("track.view", &json!({"tracks": ["Snare"], "view": "blocks"})).unwrap(); + let main = app.engine.session().track_by_name("Bass").unwrap().height.points(); + let ctx = egui::Context::default(); + assert!(waveforms(&ctx, &mut app, "Kick", 0.0) > 0); + assert_eq!(waveforms(&ctx, &mut app, "Snare", 0.0), 0); + assert!(waveforms(&ctx, &mut app, "Bass", main) > 0, "the alternate playlist's lane"); +} + +#[test] +fn overlapped_crossfades_show_the_outgoing_clip_too() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.engine.execute("edit.fades_create", &json!({"tracks": ["Bass"], "start": {"seconds": 18.2}, "end": {"seconds": 18.7}})).unwrap(); + app.engine.execute("view.zoom_set", &json!({"samples_per_px": 64})).unwrap(); + app.engine.execute("view.scroll", &json!({"to": {"seconds": 18.0}})).unwrap(); + let ctx = egui::Context::default(); + let plain = waveforms(&ctx, &mut app, "Bass", 0.0); + app.engine.execute("view.waveform_overlapped_crossfades", &json!({"value": true})).unwrap(); + assert_eq!(waveforms(&ctx, &mut app, "Bass", 0.0), plain + 1); +} From 7f9b629964e123bdf48edf5809c69bbdaac4b028 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:43:58 +0100 Subject: [PATCH 2/2] Draw each take in the Edit window while it records Pro Tools draws a take's waveform as it records; ours showed nothing until Stop. Each record-enabled track now shows its take growing as a red clip with its waveform, drawn from the take's live summary through the same column grid and channel bands as clips (Peak view, as Pro Tools always shows while recording). - The take writer keeps, per take channel, the (min, max) of every 128 frames it writes (at most 2^22 blocks, three hours at 48 kHz; the file records on regardless). Recorder::live_take hands the Edit window what a track's take holds so far, while the writer runs. The blocks sit behind a mutex the writer thread and the UI lock briefly; the input callback is untouched. - io::live_span places the take as Stop will: from the record start, only the pass under way when loop recording, and within the punch range. Tests: the take writer's summary (record.rs), live_span with loop and punch (io.rs) and draw_live (tests/waveform.rs). --- crates/engine/src/io.rs | 20 ++++++++++ crates/playback/src/record.rs | 63 ++++++++++++++++++++++++++++--- crates/ui-egui/src/edit_window.rs | 19 ++++++++++ crates/ui-egui/src/waveform.rs | 31 +++++++++++++-- crates/ui-egui/tests/waveform.rs | 26 ++++++++++--- 5 files changed, 143 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 108330e..4f14495 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -967,6 +967,15 @@ pub fn add_recording(e: &mut Engine, start: Samples, takes: Vec<(TrackId, PathBu out } +/// Where a take recording from `start` shows while it holds `len` samples, as it will be placed: +/// just the pass under way when loop recording in passes of `pass`, within the `punch` range. +/// Returns the timeline position of its first sample and the span it covers. +pub fn live_span(start: Samples, pass: Option, punch: Option, len: Samples) -> Option<(Samples, Range)> { + let origin = start.saturating_sub(pass.filter(|p| *p > 0).map_or(0, |p| len / p * p)); + let span = Range::new(start, origin.saturating_add(len)); + Some((origin, span.intersect(&punch.unwrap_or(span))?)) +} + #[cfg(test)] mod record_tests { use super::*; @@ -1029,6 +1038,17 @@ mod record_tests { assert_eq!(e.session().track(vox).unwrap().playlists.len(), 1); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn a_take_shows_the_pass_under_way_within_the_punch_while_it_records() { + assert_eq!(live_span(1_000, None, None, 0), None); + assert_eq!(live_span(1_000, None, None, 500), Some((1_000, Range::new(1_000, 1_500)))); + assert_eq!(live_span(1_000, Some(400), None, 900), Some((200, Range::new(1_000, 1_100)))); + assert_eq!(live_span(1_000, Some(400), None, 800), None); + let punch = Some(Range::new(1_200, 1_400)); + assert_eq!(live_span(1_000, None, punch, 150), None); + assert_eq!(live_span(1_000, None, punch, 500), Some((1_000, Range::new(1_200, 1_400)))); + } } #[cfg(test)] diff --git a/crates/playback/src/record.rs b/crates/playback/src/record.rs index ec784ac..04ee521 100644 --- a/crates/playback/src/record.rs +++ b/crates/playback/src/record.rs @@ -9,7 +9,7 @@ use std::fs::File; use std::io::{Seek, SeekFrom, Write}; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use std::time::{Duration, Instant}; use soundcraft_model::TrackId; @@ -17,6 +17,15 @@ use soundcraft_model::TrackId; /// What [`Recorder::finish`] returns: the takes that hold audio and the first problem met. pub type Takes = (Vec<(TrackId, PathBuf)>, Option); +/// Frames each block of a [`LiveTake`] sums up. +pub const LIVE_BLOCK: usize = 128; +/// Blocks a live take keeps per channel at most (three hours at 48 kHz); its file records on. +const LIVE_BLOCKS: usize = 1 << 22; + +/// A take while it records, summed up for drawing: per channel, the (min, max) of each +/// [`LIVE_BLOCK`] frames so far. +pub type LiveTake = Vec>; + const RING_SECONDS: usize = 4; /// Single-producer single-consumer FIFO of samples (input callback → writer thread). The slots @@ -84,7 +93,8 @@ pub struct Recorder { pub device_name: String, pub sample_rate: u32, pub channels: usize, - writer: Option>, + /// The take writer, and what each track's take holds so far. + writer: Option<(std::thread::JoinHandle, Vec<(TrackId, Arc>)>)>, } impl Recorder { @@ -145,12 +155,13 @@ impl Recorder { } let takes = takes.into_iter().map(|(id, inputs, path)| TakeWriter::create(id, inputs, path, self.sample_rate)).collect::, _>>()?; + let live = takes.iter().map(|t| (t.id, Arc::clone(&t.live))).collect(); let ring = &self.shared.ring; ring.tail.store(ring.head.load(Ordering::Acquire), Ordering::Release); self.shared.armed.store(true, Ordering::Release); let (shared, channels) = (Arc::clone(&self.shared), self.channels); match std::thread::Builder::new().name("take writer".into()).spawn(move || write_takes(&shared, takes, channels)) { - Ok(w) => self.writer = Some(w), + Ok(w) => self.writer = Some((w, live)), Err(e) => { self.shared.armed.store(false, Ordering::Release); return Err(e.to_string()); @@ -159,9 +170,15 @@ impl Recorder { Ok(()) } + /// What the take recording onto `track` holds so far. + pub fn live_take(&self, track: TrackId) -> Option> { + let (_, take) = self.writer.as_ref()?.1.iter().find(|(id, _)| *id == track)?; + Some(take.lock().unwrap_or_else(PoisonError::into_inner)) + } + pub fn finish(&mut self) -> Takes { self.shared.armed.store(false, Ordering::Release); - let (takes, problem) = self.writer.take().and_then(|w| w.join().ok()).unwrap_or_default(); + let (takes, problem) = self.writer.take().and_then(|(w, _)| w.join().ok()).unwrap_or_default(); let lost = self.shared.dropped.swap(0, Ordering::Relaxed) / self.channels.max(1); let slow = (lost > 0).then(|| format!("{} ms of input lost: the disk was too slow", lost.saturating_mul(1000) / self.sample_rate.max(1) as usize)); @@ -216,13 +233,15 @@ struct TakeWriter { rate: u32, frames: u64, error: Option, + live: Arc>, } impl TakeWriter { fn create(id: TrackId, inputs: Vec, path: PathBuf, rate: u32) -> Result { let file = path.parent().map_or(Ok(()), std::fs::create_dir_all).and_then(|()| File::create_new(&path)); let file = file.map_err(|e| format!("{}: {e}", path.display()))?; - let mut take = TakeWriter { id, inputs, path, file, rate, frames: 0, error: None }; + let live = Arc::new(Mutex::new(vec![Vec::new(); inputs.len()])); + let mut take = TakeWriter { id, inputs, path, file, rate, frames: 0, error: None, live }; take.write_header(); take.error.take().map_or(Ok(take), Err) } @@ -238,11 +257,30 @@ impl TakeWriter { .flat_map(|f| self.inputs.iter().flat_map(move |&i| f.get(i).copied().unwrap_or(0.0).to_le_bytes())) .collect(); match self.file.write_all(&bytes) { - Ok(()) => self.frames += (block.len() / channels) as u64, + Ok(()) => { + self.summarize(block, channels); + self.frames += (block.len() / channels) as u64; + } Err(e) => self.fail(e), } } + /// Add this take's channels of `block` to its live take. + fn summarize(&self, block: &[f32], channels: usize) { + let mut live = self.live.lock().unwrap_or_else(PoisonError::into_inner); + for (f, n) in block.chunks_exact(channels).zip(self.frames..) { + let at = (n / LIVE_BLOCK as u64) as usize; + for (blocks, &i) in live.iter_mut().zip(&self.inputs) { + let v = f.get(i).copied().unwrap_or(0.0); + match blocks.get_mut(at) { + Some(b) => *b = (b.0.min(v), b.1.max(v)), + None if at < LIVE_BLOCKS => blocks.push((v, v)), + None => {} + } + } + } + } + fn write_header(&mut self) { let header = match soundcraft_audio_io::wav_float_header(self.inputs.len(), self.rate, self.frames) { Ok(h) => h, @@ -306,6 +344,19 @@ mod tests { assert_eq!(decode(&done[1].1), vec![[vec![0.5; 100], vec![0.25; 50], channel(0)].concat()]); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn a_take_sums_itself_up_for_drawing_while_it_records() { + let dir = std::env::temp_dir().join(format!("soundcraft-live-take-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut take = TakeWriter::create(TrackId(4), vec![2, 0], dir.join("Gtr_01.wav"), 48_000).unwrap(); + let input: Vec = (0..300).flat_map(|f| [f as f32, -1.0, -(f as f32)]).collect(); + take.write(&input[..3 * 100], 3); + take.write(&input[3 * 100..], 3); + let live = take.live.lock().unwrap().clone(); + assert_eq!(live, [[(-127.0, 0.0), (-255.0, -128.0), (-299.0, -256.0)], [(0.0, 127.0), (128.0, 255.0), (256.0, 299.0)]]); + let _ = std::fs::remove_dir_all(&dir); + } } /// Ring state behind the lock: interleaved input frames at the input device's rate, plus the diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index bbd5dc6..e346c3c 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -706,6 +706,7 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) } draw_clip(app, &painter, &s, &track, 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); if let Some(p) = &auto_view { draw_automation(&painter, &s, &track, p, tl, lane); @@ -1144,6 +1145,24 @@ fn grid_lines(painter: &egui::Painter, s: &Session, tl: Rect, lane: Rect) { } } +/// The take `track` is recording, drawn in red as it grows, as in Pro Tools. +fn draw_live_take(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track, tl: Rect, lane: Rect) { + let Some(rec) = &app.recorder else { return }; + let Some(take) = rec.live_take(track.id) else { return }; + let block_len = soundcraft_playback::record::LIVE_BLOCK as f64 * s.sample_rate.as_f64() / f64::from(rec.sample_rate.max(1)); + let len = soundcraft_time::to_samples(take.first().map_or(0, Vec::len) as f64 * block_len); + let sel = s.edit.selection; + let (start, pass) = if s.edit.loop_record && !sel.is_empty() { (sel.start, Some(sel.len())) } else { (app.record_start, None) }; + let Some((origin, span)) = soundcraft_engine::io::live_span(start, pass, app.punch, len) else { return }; + let r = Rect::from_min_max(pos2(x_of(s, tl, span.start), lane.min.y + 1.0), pos2(x_of(s, tl, span.end), lane.max.y - 1.0)); + let t = Tokens::current(); + let (body, _, wave) = clip_colors([t.rec.r(), t.rec.g(), t.rec.b()], false); + painter.rect(r, CornerRadius::same(2), body, Stroke::new(1.0, Color32::from_rgb(10, 10, 10)), StrokeKind::Inside); + if track.view != "blocks" { + crate::waveform::draw_live(&painter.with_clip_rect(r.intersect(lane)), s, tl, origin as f64, block_len, &take, r, wave); + } +} + fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track, 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); diff --git a/crates/ui-egui/src/waveform.rs b/crates/ui-egui/src/waveform.rs index 2378f07..0a2ccd4 100644 --- a/crates/ui-egui/src/waveform.rs +++ b/crates/ui-egui/src/waveform.rs @@ -6,13 +6,16 @@ use soundcraft_time::Samples; /// Draw a clip's audio across `body`, one band per channel, `channels` of them at most. pub fn draw(painter: &Painter, s: &Session, tl: Rect, clip: &Clip, audio: &SourceAudio, channels: usize, body: Rect, wave: Color32) { - let n = channels.min(audio.peaks.len()).max(1); - let h = body.height() / n as f32; - for ch in 0..n { - channel(painter, s, tl, clip, audio, ch, Rect::from_min_size(pos2(body.min.x, body.min.y + h * ch as f32), vec2(body.width(), h)), wave); + for (ch, band) in bands(body, channels.min(audio.peaks.len()).max(1)).enumerate() { + channel(painter, s, tl, clip, audio, ch, band, wave); } } +fn bands(body: Rect, n: usize) -> impl Iterator { + let h = body.height() / n as f32; + (0..n).map(move |k| Rect::from_min_size(pos2(body.min.x, body.min.y + h * k as f32), vec2(body.width(), h))) +} + fn channel(painter: &Painter, s: &Session, tl: Rect, clip: &Clip, audio: &SourceAudio, ch: usize, band: Rect, wave: Color32) { let (Some(samples), Some(peaks)) = (audio.channel(ch), audio.peaks.get(ch)) else { return }; let Some(g) = Grid::new(painter, s, tl, band) else { return }; @@ -53,6 +56,26 @@ fn channel(painter: &Painter, s: &Session, tl: Rect, clip: &Clip, audio: &Source g.paint(painter, &ys, wave, outlines, rectified); } +/// Draw a take while it records across `body`, one band per channel: `blocks` holds each channel's +/// (min, max) per block of input frames, the blocks `block_len` timeline samples long from +/// timeline position `origin`. +pub fn draw_live(painter: &Painter, s: &Session, tl: Rect, origin: f64, block_len: f64, blocks: &[Vec<(f32, f32)>], body: Rect, wave: Color32) { + let zoom = s.edit.zoom.waveform_zoom; + for (blocks, band) in blocks.iter().zip(bands(body, blocks.len())) { + let Some(g) = Grid::new(painter, s, tl, band) else { continue }; + let ys: Vec> = (0..g.cols) + .map(|c| { + let a = g.start() + c as f64 * g.step - origin; + let (i, j) = ((a / block_len).floor().max(0.0) as usize, ((a + g.step + g.overlap()) / block_len).ceil().max(0.0) as usize); + let (lo, hi) = + blocks.get(i..j.min(blocks.len()))?.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), b| (lo.min(b.0), hi.max(b.1))); + (lo <= hi).then(|| (g.cy - level(hi * zoom) * g.half, g.cy - level(lo * zoom) * g.half)) + }) + .collect(); + g.paint(painter, &ys, wave, false, false); + } +} + fn level(v: f32) -> f32 { if v.is_finite() { v.clamp(-1.0, 1.0) } else { 0.0 } } diff --git a/crates/ui-egui/tests/waveform.rs b/crates/ui-egui/tests/waveform.rs index 4726f23..2724ec0 100644 --- a/crates/ui-egui/tests/waveform.rs +++ b/crates/ui-egui/tests/waveform.rs @@ -7,21 +7,35 @@ use soundcraft_ui_egui::{Services, SoundApp, waveform}; const WAVE: Color32 = Color32::from_rgb(12, 12, 14); -fn draw(samples: Vec, samples_per_px: f64) -> Vec { +/// The lines and meshes `f` paints across a 400×100 band on a 2x screen. +fn paint(samples_per_px: f64, f: impl Fn(&egui::Painter, &Session, Rect)) -> Vec { let mut s = Session::new("t", SampleRate::HZ_48000); s.edit.zoom.samples_per_px = samples_per_px; - let clip = Clip::audio(ClipId(1), "c", SourceId(1), 0, 0, samples.len() as i64); - let audio = SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![samples] }); let band = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 100.0)); let ctx = egui::Context::default(); ctx.set_pixels_per_point(2.0); - let mut out = ctx.run_ui(RawInput { screen_rect: Some(band), ..Default::default() }, |ui| { - waveform::draw(ui.painter(), &s, band, &clip, &audio, 1, band, WAVE); - }); + let mut out = ctx.run_ui(RawInput { screen_rect: Some(band), ..Default::default() }, |ui| f(ui.painter(), &s, band)); out.textures_delta.clear(); out.shapes.into_iter().map(|c| c.shape).filter(|s| matches!(s, Shape::Path(_) | Shape::Mesh(_))).collect() } +fn draw(samples: Vec, samples_per_px: f64) -> Vec { + let clip = Clip::audio(ClipId(1), "c", SourceId(1), 0, 0, samples.len() as i64); + let audio = SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![samples] }); + paint(samples_per_px, |p, s, band| waveform::draw(p, s, band, &clip, &audio, 1, band, WAVE)) +} + +#[test] +fn a_take_draws_its_overview_while_it_records() { + let blocks = vec![vec![(-0.5, 0.5); 100]]; + let shapes = paint(128.0, |p, s, band| waveform::draw_live(p, s, band, 1280.0, 128.0, &blocks, band, WAVE)); + let [Shape::Mesh(mesh)] = shapes.as_slice() else { panic!("{shapes:?}") }; + let span = |f: fn(&Pos2) -> f32| mesh.vertices.iter().map(|v| f(&v.pos)).fold((f32::MAX, f32::MIN), |(a, b), v| (a.min(v), b.max(v))); + let ((left, right), (top, bottom)) = (span(|p| p.x), span(|p| p.y)); + assert!((left - 10.0).abs() <= 1.0 && (right - 110.0).abs() <= 1.0, "drawn from {left} to {right}"); + assert!((top - 27.0).abs() <= 1.0 && (bottom - 73.0).abs() <= 1.0, "drawn from {top} to {bottom}"); +} + #[test] fn at_the_sample_level_the_waveform_is_a_line_through_the_samples() { let x: Vec = (0..200).map(|i| (i as f32 * 0.3).sin() * 0.8).collect();