This commit is contained in:
Brandon Thomas committed 2026-10-13 05:08:27 -04:00
commit c46d6efd4c
8 files changed
+647 -185

No files matched your search

+186 -110
View File
@@ -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<Vec<(f32, f32)>>,
levels: Vec<Vec<Block>>,
}
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<Block>> = 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<Item = Option<(f64, f64)>> {
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<Option<(f32, f32)>> {
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<Option<f32>> {
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::<f64>() / (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<f32> = (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))]);
}
}
+20
View File
@@ -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<Samples>, punch: Option<Range>, 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)]
+57 -6
View File
@@ -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<String>);
/// 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<Vec<(f32, f32)>>;
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<std::thread::JoinHandle<Takes>>,
/// The take writer, and what each track's take holds so far.
writer: Option<(std::thread::JoinHandle<Takes>, Vec<(TrackId, Arc<Mutex<LiveTake>>)>)>,
/// 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::<Result<Vec<_>, _>>()?;
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<MutexGuard<'_, LiveTake>> {
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<Trouble> {
@@ -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<String>,
live: Arc<Mutex<LiveTake>>,
}
impl TakeWriter {
fn create(id: TrackId, inputs: Vec<usize>, path: PathBuf, rate: u32) -> Result<TakeWriter, String> {
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<f32> = (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
+60 -58
View File
@@ -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<Pos2> = (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();
+1
View File
@@ -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;
+9 -11
View File
@@ -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 {
+209
View File
@@ -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<Item = Rect> {
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<Option<(f32, f32)>> = 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<Option<(f32, f32)>> = (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<Grid> {
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<Option<(f32, f32)>> = ys
.iter()
.enumerate()
.map(|(c, y)| {
let y = edge((*y)?);
let near = |i: Option<usize>| 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<u32>, 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<usize>| 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);
}
}
}
}
+105
View File
@@ -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<Shape> {
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<f32>, samples_per_px: f64) -> Vec<Shape> {
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<f32> = (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<f32> = 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);
}