diff --git a/crates/audio-io/src/peaks.rs b/crates/audio-io/src/peaks.rs index d13c1ea9..9553665c 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/engine/src/io.rs b/crates/engine/src/io.rs index a278e0b7..e423655a 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -1114,6 +1114,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::*; @@ -1176,6 +1185,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 0f75aa53..76279b5d 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, AtomicU8, AtomicU32, AtomicU64, AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex, PoisonError}; +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; /// How long a recording input may go without a callback before it counts as gone. const STALL: Duration = Duration::from_secs(2); @@ -122,7 +131,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>)>)>, /// The callback count last seen, and since when. seen: (u64, Instant), } @@ -191,13 +201,14 @@ 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.failed.lock().unwrap_or_else(PoisonError::into_inner) = None; 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()); @@ -206,6 +217,12 @@ 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)) + } + /// Why the input can't go on, if it can't: the device went away or, while recording, stopped /// sending audio; its callback hit a bug; or writing a take failed (reported once). pub fn trouble(&mut self) -> Option { @@ -233,7 +250,7 @@ impl Recorder { 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)); @@ -292,13 +309,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) } @@ -314,11 +333,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, @@ -452,6 +490,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 f8c4e728..6008c068 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -782,6 +782,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 s.edit.grid_lines { grid_lines(&painter, &s, tl, lane); @@ -823,9 +824,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() @@ -1400,6 +1399,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); + } +} + /// The translucent white or black that turns `bg` into `line`, so grid lines also show over clips. fn grid_veil(line: Color32, bg: Color32) -> Color32 { let level = |c: Color32| (f32::from(c.r()) + f32::from(c.g()) + f32::from(c.b())) / 3.0; @@ -1444,58 +1461,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 { @@ -1519,7 +1493,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, @@ -1528,6 +1506,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 { @@ -1565,12 +1558,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))); @@ -1630,6 +1623,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 1d4f2211..74132bdf 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -25,6 +25,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 8b91c82a..e88e4fa2 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -419,20 +419,18 @@ pub fn track_backgrounds(color: [u8; 3], selected: bool, tint: f32) -> (Color32, /// 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 00000000..0a2ccd46 --- /dev/null +++ b/crates/ui-egui/src/waveform.rs @@ -0,0 +1,209 @@ +//! 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) { + 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 }; + 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); +} + +/// 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 } +} + +/// 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 00000000..2724ec05 --- /dev/null +++ b/crates/ui-egui/tests/waveform.rs @@ -0,0 +1,105 @@ +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); + +/// 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 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| 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(); + 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); +}