mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-13 23:08:25 +00:00
Merge #369
This commit is contained in:
commit
c46d6efd4c
8 files changed
+647
-185
No files matched your search
+186
-110
@@ -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))]);
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user