mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-03 08:50:21 -06:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa250cc1d7 | ||
|
|
351b9d3a55 | ||
|
|
da02c19fcc | ||
|
|
64540f109b | ||
|
|
0af1ad909e | ||
|
|
17d3f00700 | ||
|
|
1c350101bc |
@@ -598,6 +598,7 @@ fn write_current(e: &mut Engine, p: &Value, all: bool) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Clip-gain envelope value in dB. An empty envelope contributes nothing, and the last point holds.
|
||||
#[cfg(test)]
|
||||
fn env_db(env: &[(i64, f32)], at: i64) -> f32 {
|
||||
let mut prev: Option<(i64, f32)> = None;
|
||||
for &(t, v) in env {
|
||||
@@ -612,6 +613,38 @@ fn env_db(env: &[(i64, f32)], at: i64) -> f32 {
|
||||
prev.map_or(0.0, |(_, v)| v)
|
||||
}
|
||||
|
||||
/// Repeated lookups in one clip-gain envelope, each O(log n) instead of a walk from its start.
|
||||
/// Gives exactly what walking the points in stored order gives, sorted or not: the first point
|
||||
/// at or after `at` is the first whose running maximum position reaches `at`.
|
||||
struct EnvLookup<'a> {
|
||||
env: &'a [(i64, f32)],
|
||||
/// Largest position among `env[..=i]`.
|
||||
reach: Vec<i64>,
|
||||
}
|
||||
|
||||
impl<'a> EnvLookup<'a> {
|
||||
fn new(env: &'a [(i64, f32)]) -> Self {
|
||||
let reach = env
|
||||
.iter()
|
||||
.scan(i64::MIN, |m, &(t, _)| {
|
||||
*m = (*m).max(t);
|
||||
Some(*m)
|
||||
})
|
||||
.collect();
|
||||
EnvLookup { env, reach }
|
||||
}
|
||||
|
||||
/// Value in dB at `at` (see `env_db`).
|
||||
fn db(&self, at: i64) -> f32 {
|
||||
let i = self.reach.partition_point(|&m| m < at);
|
||||
let Some(&(t, v)) = self.env.get(i) else { return self.env.last().map_or(0.0, |&(_, v)| v) };
|
||||
match i.checked_sub(1).and_then(|p| self.env.get(p)) {
|
||||
Some(&(pt, pv)) if t > pt => pv + (v - pv) * ((at - pt) as f32 / (t - pt) as f32),
|
||||
_ => v,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold the volume `lane` into the gain envelope of clip `c` over the selection `r`.
|
||||
fn bake_volume_into_clip(c: &mut soundcraft_model::Clip, lane: &soundcraft_model::AutomationLane, base: f32, r: soundcraft_time::Range) {
|
||||
let delta = |at: i64| lane.value_at(at, base) - base;
|
||||
@@ -621,39 +654,41 @@ fn bake_volume_into_clip(c: &mut soundcraft_model::Clip, lane: &soundcraft_model
|
||||
return;
|
||||
}
|
||||
let rel = |at: i64| at - c.start;
|
||||
let old_env = std::mem::take(&mut c.gain_env);
|
||||
let env = EnvLookup::new(&old_env);
|
||||
let env_db = |at: i64| env.db(at);
|
||||
let rel0 = rel(sel0);
|
||||
let rel1 = rel(sel1);
|
||||
// Later writes win, so the selection anchors replace any point they land on.
|
||||
let mut pts: Vec<(i64, f32)> =
|
||||
c.gain_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect();
|
||||
let mut pts: Vec<(i64, f32)> = old_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect();
|
||||
if rel0 > 0 {
|
||||
pts.push((rel0 - 1, env_db(&c.gain_env, rel0 - 1)));
|
||||
pts.push((rel0 - 1, env_db(rel0 - 1)));
|
||||
}
|
||||
pts.push((rel0, env_db(&c.gain_env, rel0) + delta(sel0)));
|
||||
pts.push((rel0, env_db(rel0) + delta(sel0)));
|
||||
for pt in lane.points.iter().filter(|pt| pt.at > sel0 && pt.at < sel1) {
|
||||
let at = rel(pt.at);
|
||||
pts.push((at, env_db(&c.gain_env, at) + delta(pt.at)));
|
||||
pts.push((at, env_db(at) + delta(pt.at)));
|
||||
}
|
||||
// The clip's own gain points inside the selection keep their shape, offset by the lane.
|
||||
for &(at, _) in c.gain_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) {
|
||||
pts.push((at, env_db(&c.gain_env, at) + delta(c.start.saturating_add(at))));
|
||||
for &(at, _) in old_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) {
|
||||
pts.push((at, env_db(at) + delta(c.start.saturating_add(at))));
|
||||
}
|
||||
if sel1 - 1 > sel0 {
|
||||
let at = rel(sel1 - 1);
|
||||
pts.push((at, env_db(&c.gain_env, at) + delta(sel1 - 1)));
|
||||
pts.push((at, env_db(at) + delta(sel1 - 1)));
|
||||
}
|
||||
if sel1 < c.end() {
|
||||
pts.push((rel1, env_db(&c.gain_env, rel1)));
|
||||
pts.push((rel1, env_db(rel1)));
|
||||
}
|
||||
pts.sort_by_key(|p| p.0);
|
||||
let mut env = Vec::new();
|
||||
let mut baked = Vec::new();
|
||||
for p in pts {
|
||||
if env.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) {
|
||||
env.pop();
|
||||
if baked.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) {
|
||||
baked.pop();
|
||||
}
|
||||
env.push(p);
|
||||
baked.push(p);
|
||||
}
|
||||
c.gain_env = env;
|
||||
c.gain_env = baked;
|
||||
}
|
||||
|
||||
/// Clear the volume lane over `r` when the clips all sit inside it, else hold the fader there.
|
||||
@@ -726,8 +761,9 @@ fn clip_gain_to_vol(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
if let Some(l) = &old {
|
||||
ats.extend(l.points.iter().map(|pt| pt.at).filter(|&at| at > c.start && at < c.end() - 1));
|
||||
}
|
||||
let env = EnvLookup::new(&c.gain_env);
|
||||
for at in ats {
|
||||
pts.push((at, level(at) + c.gain_db + env_db(&c.gain_env, at - c.start)));
|
||||
pts.push((at, level(at) + c.gain_db + env.db(at - c.start)));
|
||||
}
|
||||
c.gain_db = 0.0;
|
||||
c.gain_env.clear();
|
||||
|
||||
@@ -345,3 +345,36 @@ fn insert_and_send_reject_invalid_slot() {
|
||||
let r = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Verb"})).unwrap();
|
||||
assert_eq!(r["slot"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_envelope_lookup_matches_the_walk() {
|
||||
let envs: [Vec<(i64, f32)>; 5] = [
|
||||
Vec::new(),
|
||||
vec![(100, -3.0)],
|
||||
vec![(0, 0.0), (100, -6.0), (100, 3.0), (250, -12.0), (900, 1.0)],
|
||||
// Out of order, as a hand-edited session file may hold.
|
||||
vec![(500, -1.0), (100, -6.0), (700, 4.0), (300, 2.0), (700, -9.0), (50, 0.5)],
|
||||
(0..200).map(|i| ((i * 37) % 1000, (i as f32 * 0.7).sin() * 12.0)).collect(),
|
||||
];
|
||||
for env in &envs {
|
||||
let lookup = EnvLookup::new(env);
|
||||
for at in -20..1100 {
|
||||
assert_eq!(lookup.db(at).to_bits(), env_db(env, at).to_bits(), "{env:?} at {at}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_gain_to_volume_with_a_large_envelope_stays_fast() {
|
||||
let (mut e, t) = tone_session(&[]);
|
||||
let clip = e.session().track(t).unwrap().clips()[0].id;
|
||||
let n = 200_000i64;
|
||||
let c = e.session_mut().find_clip_mut(clip).unwrap();
|
||||
c.length = n * 5 + 10;
|
||||
c.gain_env = (0..n).map(|i| (i * 5, if i % 2 == 0 { -6.0 } else { 0.0 })).collect();
|
||||
let start = std::time::Instant::now();
|
||||
e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 0, "end": n * 5 + 10})).unwrap();
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
|
||||
let lane = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap();
|
||||
assert!(lane.points.len() as i64 >= n);
|
||||
}
|
||||
|
||||
@@ -734,6 +734,12 @@ fn read_bundle(bytes: &[u8], limits: BundleLimits) -> Result<(String, std::colle
|
||||
if z.len() > limits.entries {
|
||||
return Err(EngineError::Io("bundle: too many files".into()));
|
||||
}
|
||||
// Entries are stored, so the files we keep can only outgrow the bundle when several entries
|
||||
// point at the same bytes: a small bundle naming one payload thousands of times would be
|
||||
// copied out once per name.
|
||||
if z.has_overlapping_files().map_err(zerr)? {
|
||||
return Err(EngineError::Io("bundle: files share data".into()));
|
||||
}
|
||||
let (mut text, mut files, mut total) = (None, std::collections::HashMap::new(), 0u64);
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).map_err(zerr)?;
|
||||
|
||||
@@ -194,6 +194,23 @@ mod bundle_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_sharing_one_payload_are_refused() {
|
||||
use std::io::Write as _;
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
w.start_file("Song.scraft", opts).unwrap();
|
||||
w.write_all(Session::default().to_json().unwrap().as_bytes()).unwrap();
|
||||
w.start_file("Audio Files/a.wav", opts).unwrap();
|
||||
w.write_all(&[7u8; 4096]).unwrap();
|
||||
for i in 0..100 {
|
||||
w.shallow_copy_file("Audio Files/a.wav", &format!("Audio Files/copy{i}.wav")).unwrap();
|
||||
}
|
||||
let bundle = w.finish().unwrap().into_inner();
|
||||
let err = read_bundle(&bundle, BUNDLE_LIMITS).unwrap_err();
|
||||
assert!(err.to_string().contains("share data"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bundle_is_saved_only_if_it_opens_again() {
|
||||
// #433: the writer keeps to the reader's limits.
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::Entry;
|
||||
|
||||
/// A MIDI note. Times are in model ticks ([`crate::PPQ`] per quarter note).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
@@ -108,14 +110,28 @@ impl Sequence {
|
||||
|
||||
self.ctrls.sort_by_key(|c| c.tick);
|
||||
let mut kept: Vec<CtrlEvent> = Vec::with_capacity(self.ctrls.len());
|
||||
// The latest tick and `kept` index of each channel+kind, so a same-tick repeat is found
|
||||
// without rescanning the tick's events (a tick can hold many thousands of distinct keys).
|
||||
// An entry from an earlier tick counts as absent; keeping the tick instead of clearing the
|
||||
// map per tick keeps the work per event constant after a dense tick.
|
||||
let mut last_at: HashMap<(u8, (u8, u8)), (i64, usize)> = HashMap::new();
|
||||
for c in self.ctrls.drain(..) {
|
||||
// Within a tick, replace an earlier event of the same channel+kind.
|
||||
// Only events at the same tick (the tail of `kept`) can match.
|
||||
let slot =
|
||||
kept.iter_mut().rev().take_while(|k| k.tick == c.tick).find(|k| k.channel == c.channel && kind_key(k.kind) == kind_key(c.kind));
|
||||
match slot {
|
||||
Some(slot) => *slot = c,
|
||||
None => kept.push(c),
|
||||
match last_at.entry((c.channel, kind_key(c.kind))) {
|
||||
Entry::Occupied(mut e) => {
|
||||
let (tick, idx) = *e.get();
|
||||
match kept.get_mut(idx) {
|
||||
Some(slot) if tick == c.tick => *slot = c,
|
||||
_ => {
|
||||
e.insert((c.tick, kept.len()));
|
||||
kept.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
Entry::Vacant(e) => {
|
||||
e.insert((c.tick, kept.len()));
|
||||
kept.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.ctrls = kept;
|
||||
|
||||
@@ -222,3 +222,72 @@ fn sequence_serde_round_trip() {
|
||||
let json = serde_json::to_string(&seq).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Sequence>(&json).unwrap(), seq);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_duplicates_handles_a_large_same_tick_controller_group() {
|
||||
// Every channel x controller number at one tick, twice: the second copy of each wins. A
|
||||
// rescan of the tick's kept events per incoming event would make this quadratic.
|
||||
let keys: Vec<(u8, u8)> = (0..=255u8).flat_map(|ch| (0..=255u8).map(move |cc| (ch, cc))).collect();
|
||||
let ctrls = [0, 1]
|
||||
.iter()
|
||||
.flat_map(|&pass| keys.iter().map(move |&(channel, cc)| CtrlEvent { tick: 7, channel, kind: CtrlKind::Cc(cc), value: pass }))
|
||||
.collect();
|
||||
let mut seq = Sequence { notes: Vec::new(), ctrls };
|
||||
let start = std::time::Instant::now();
|
||||
assert_eq!(seq.remove_duplicates(), keys.len());
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
|
||||
assert_eq!(seq.ctrls.len(), keys.len());
|
||||
assert!(seq.ctrls.iter().all(|c| c.value == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_duplicates_matches_a_per_tick_rescan_on_mixed_ticks() {
|
||||
// The straightforward per-tick rescan the indexed version replaced, kept as an oracle.
|
||||
fn reference(mut ctrls: Vec<CtrlEvent>) -> Vec<CtrlEvent> {
|
||||
ctrls.sort_by_key(|c| c.tick);
|
||||
let key = |k: CtrlKind| match k {
|
||||
CtrlKind::Cc(n) => (0, n),
|
||||
CtrlKind::PitchBend => (1, 0),
|
||||
CtrlKind::ChannelPressure => (2, 0),
|
||||
CtrlKind::PolyPressure(p) => (3, p),
|
||||
CtrlKind::Program => (4, 0),
|
||||
};
|
||||
let mut kept: Vec<CtrlEvent> = Vec::new();
|
||||
for c in ctrls {
|
||||
match kept.iter_mut().rev().take_while(|k| k.tick == c.tick).find(|k| k.channel == c.channel && key(k.kind) == key(c.kind)) {
|
||||
Some(slot) => *slot = c,
|
||||
None => kept.push(c),
|
||||
}
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
// Few ticks, channels and kinds, so the same key recurs both within a tick (interleaved with
|
||||
// other keys) and across ticks; unsorted input from a fixed xorshift stream.
|
||||
let mut state = 0x9E37_79B9_7F4A_7C15u64;
|
||||
let mut next = move || {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state
|
||||
};
|
||||
let kinds = |r: u64| match r % 5 {
|
||||
0 => CtrlKind::Cc((r >> 8) as u8 % 4),
|
||||
1 => CtrlKind::PitchBend,
|
||||
2 => CtrlKind::ChannelPressure,
|
||||
3 => CtrlKind::PolyPressure((r >> 8) as u8 % 4),
|
||||
_ => CtrlKind::Program,
|
||||
};
|
||||
for round in 0..200 {
|
||||
let mut ctrls = Vec::new();
|
||||
for i in 0..(next() % 64) {
|
||||
let r = next();
|
||||
ctrls.push(CtrlEvent { tick: (r % 6) as i64 - 2, channel: (r >> 16) as u8 % 3, kind: kinds(r >> 24), value: (round * 100 + i) as i32 });
|
||||
}
|
||||
let mut seq = Sequence { notes: Vec::new(), ctrls: ctrls.clone() };
|
||||
let removed = seq.remove_duplicates();
|
||||
let expected = reference(ctrls.clone());
|
||||
assert_eq!(seq.ctrls, expected, "round {round}");
|
||||
assert_eq!(removed, ctrls.len() - expected.len(), "round {round}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,12 +46,20 @@ pub(crate) fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize
|
||||
rtp("delay", 0.0).clamp(-1e7, 1e7) as i64,
|
||||
)
|
||||
};
|
||||
// The block in clip ticks, one tick wider on each side so rounding never drops an event.
|
||||
let near = s.tempo.samples_to_ticks(pos, sr).saturating_sub(base).saturating_sub(1)
|
||||
..=s.tempo.samples_to_ticks(end, sr).saturating_sub(base).saturating_add(1);
|
||||
for n0 in &sequence.notes {
|
||||
let mut n = *n0;
|
||||
n.velocity = (i64::from(n.velocity) + dv).clamp(1, 127) as u8;
|
||||
n.pitch = (i64::from(n.pitch) + dp).clamp(0, 127) as u8;
|
||||
n.length = ((n.length as f64 * dur) as i64).max(1);
|
||||
n.start = n.start.saturating_add(delay).max(0);
|
||||
// A note whose on and off both fall outside the block sends nothing: skip it before
|
||||
// converting its positions, so a long sequence costs a comparison per note per block.
|
||||
if n.start > *near.end() || n.start.saturating_add(n.length) < *near.start() {
|
||||
continue;
|
||||
}
|
||||
let on = s.tempo.tick_to_samples(base.saturating_add(n.start), sr);
|
||||
let off = s.tempo.tick_to_samples(base.saturating_add(n.start).saturating_add(n.length), sr).min(clip.end());
|
||||
if on >= clip.end() {
|
||||
@@ -68,8 +76,6 @@ pub(crate) fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize
|
||||
held.retain(|p| *p != n.pitch);
|
||||
}
|
||||
}
|
||||
let near = s.tempo.samples_to_ticks(pos, sr).saturating_sub(base).saturating_sub(1)
|
||||
..=s.tempo.samples_to_ticks(end, sr).saturating_sub(base).saturating_add(1);
|
||||
// Events before tick 0 are what a start trim hid: they don't play.
|
||||
for c in sequence.ctrls.iter().filter(|c| c.tick >= 0) {
|
||||
let tick = c.tick.saturating_add(delay).max(0);
|
||||
|
||||
+108
-24
@@ -46,7 +46,8 @@ pub(crate) fn processing_order(s: &Session) -> Vec<usize> {
|
||||
} else {
|
||||
// A cycle keeps the existing bounded ordering for the entire graph, including any
|
||||
// independent acyclic branches. Feedback does not have a finite longest-path depth.
|
||||
auxes.sort_by_key(|&i| bounded_aux_depth(s, i));
|
||||
let depths = bounded_aux_depths(s);
|
||||
auxes.sort_by_key(|&i| depths.get(i).copied().unwrap_or(0));
|
||||
}
|
||||
order.extend(auxes);
|
||||
order
|
||||
@@ -115,32 +116,38 @@ fn aux_output_busses(t: &Track) -> impl Iterator<Item = BusId> + '_ {
|
||||
.filter_map(|route| if let Route::Bus(bus) = route { Some(*bus) } else { None })
|
||||
}
|
||||
|
||||
/// The previous bounded depth calculation, retained unchanged for any cyclic graph.
|
||||
fn bounded_aux_depth(s: &Session, i: usize) -> usize {
|
||||
let mut d = 0;
|
||||
let mut cur = vec![i];
|
||||
for _ in 0..8 {
|
||||
let inputs: Vec<&Route> = cur.iter().filter_map(|&k| s.tracks.get(k)).map(|t| &t.mixer.input).collect();
|
||||
let feeders: Vec<usize> = auxes_feeding(s, &inputs);
|
||||
if feeders.is_empty() {
|
||||
/// Hop cap for the bounded depth used when the aux graph has a cycle.
|
||||
const MAX_BOUNDED_DEPTH: usize = 8;
|
||||
|
||||
/// For each track, the bounded depth used for a cyclic graph: the length of the longest chain of
|
||||
/// Aux/Folder feeders into it (through a bus its input reads and the feeder's output or a send
|
||||
/// writes), capped at [`MAX_BOUNDED_DEPTH`] hops. One pass per hop over the tracks and their bus
|
||||
/// writes, so the cost is linear in the routing, not a fresh feeder search per track per
|
||||
/// sort comparison.
|
||||
fn bounded_aux_depths(s: &Session) -> Vec<usize> {
|
||||
let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder);
|
||||
// Tracks with a feeder chain of the current length ending at them (length 0: every aux).
|
||||
let mut reached: Vec<bool> = s.tracks.iter().map(is_aux).collect();
|
||||
let mut depths = vec![0usize; s.tracks.len()];
|
||||
let mut fed = std::collections::HashSet::<BusId>::new();
|
||||
for hop in 1..=MAX_BOUNDED_DEPTH {
|
||||
fed.clear();
|
||||
for (t, _) in s.tracks.iter().zip(&reached).filter(|(t, r)| **r && is_aux(t)) {
|
||||
fed.extend(aux_output_busses(t));
|
||||
}
|
||||
let mut any = false;
|
||||
for ((t, r), d) in s.tracks.iter().zip(reached.iter_mut()).zip(depths.iter_mut()) {
|
||||
*r = matches!(&t.mixer.input, Route::Bus(b) if fed.contains(b));
|
||||
if *r {
|
||||
*d = hop;
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
break;
|
||||
}
|
||||
d += 1;
|
||||
cur = feeders;
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn auxes_feeding(s: &Session, inputs: &[&Route]) -> Vec<usize> {
|
||||
s.tracks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, t)| matches!(t.kind, TrackKind::Aux | TrackKind::Folder))
|
||||
.filter(|(_, t)| {
|
||||
inputs.iter().any(|r| matches!(r, Route::Bus(_)) && (&&t.mixer.output == r || t.mixer.sends.iter().flatten().any(|sn| &&sn.target == r)))
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
depths
|
||||
}
|
||||
|
||||
/// Implicit solo: an aux is not muted when a soloed track feeds its input bus.
|
||||
@@ -204,4 +211,81 @@ mod processing_order_tests {
|
||||
// Both deepest independent returns tie with the cycle at the legacy eight-hop cap.
|
||||
assert_eq!(processing_order(&s), vec![9, 8, 7, 6, 5, 4, 3, 2, 0, 1, 10]);
|
||||
}
|
||||
|
||||
/// The per-track feeder search `bounded_aux_depths` replaced, kept as its reference.
|
||||
fn reference_depth(s: &Session, i: usize) -> usize {
|
||||
let mut d = 0;
|
||||
let mut cur = vec![i];
|
||||
for _ in 0..MAX_BOUNDED_DEPTH {
|
||||
let inputs: Vec<&Route> = cur.iter().filter_map(|&k| s.tracks.get(k)).map(|t| &t.mixer.input).collect();
|
||||
let feeders: Vec<usize> = s
|
||||
.tracks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, t)| matches!(t.kind, TrackKind::Aux | TrackKind::Folder))
|
||||
.filter(|(_, t)| {
|
||||
inputs
|
||||
.iter()
|
||||
.any(|r| matches!(r, Route::Bus(_)) && (&&t.mixer.output == r || t.mixer.sends.iter().flatten().any(|sn| &&sn.target == r)))
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
if feeders.is_empty() {
|
||||
break;
|
||||
}
|
||||
d += 1;
|
||||
cur = feeders;
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_depths_match_the_per_track_feeder_search() {
|
||||
// A small pseudo-random graph of auxes, folders and audio tracks with cycles, joins,
|
||||
// sends and unrouted inputs.
|
||||
let mut s = Session::default();
|
||||
let busses: Vec<_> = (0..6).map(|i| s.add_bus(&format!("B{i}"), ChannelFormat::Stereo)).collect();
|
||||
let mut x = 12345u32;
|
||||
let mut next = |m: usize| {
|
||||
x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345);
|
||||
(x >> 16) as usize % m
|
||||
};
|
||||
for i in 0..30 {
|
||||
let kind = [TrackKind::Aux, TrackKind::Folder, TrackKind::Audio][next(3)];
|
||||
let id = s.add_track(kind, ChannelFormat::Stereo, None);
|
||||
let pick = |n: usize| if n < busses.len() { Route::Bus(busses[n]) } else { Route::Main };
|
||||
let (input, output, send) = (pick(next(8)), pick(next(8)), pick(next(8)));
|
||||
let t = s.track_mut(id).unwrap();
|
||||
t.mixer.input = input;
|
||||
t.mixer.output = output;
|
||||
if i % 2 == 0 {
|
||||
t.mixer.sends[0] = Some(SendSlot::new(send));
|
||||
}
|
||||
}
|
||||
let depths = bounded_aux_depths(&s);
|
||||
for (i, (t, depth)) in s.tracks.iter().zip(&depths).enumerate() {
|
||||
if matches!(t.kind, TrackKind::Aux | TrackKind::Folder) {
|
||||
assert_eq!(*depth, reference_depth(&s, i), "track {i}");
|
||||
}
|
||||
}
|
||||
assert!(depths.contains(&MAX_BOUNDED_DEPTH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_large_cyclic_graph_orders_quickly() {
|
||||
// Thousands of auxes all reading and writing one bus: every aux feeds every other.
|
||||
let mut s = Session::default();
|
||||
let bus = s.add_bus("Loop", ChannelFormat::Stereo);
|
||||
for _ in 0..3000 {
|
||||
let id = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, None);
|
||||
let t = s.track_mut(id).unwrap();
|
||||
t.mixer.input = Route::Bus(bus);
|
||||
t.mixer.output = Route::Bus(bus);
|
||||
t.mixer.sends[0] = Some(SendSlot::new(Route::Bus(bus)));
|
||||
}
|
||||
let start = std::time::Instant::now();
|
||||
let order = processing_order(&s);
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed());
|
||||
assert_eq!(order, (0..3000).collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
|
||||
+124
-6
@@ -24,6 +24,9 @@ pub(crate) struct PluginSlot {
|
||||
pub(crate) plugin: Box<dyn Plugin>,
|
||||
/// Last values pushed to the plugin, in the insert's parameter-map order.
|
||||
applied: Vec<f32>,
|
||||
/// Scratch for `apply_params`: indexes of the track's automation lanes for this slot, kept
|
||||
/// so the audio thread reuses its allocation.
|
||||
lanes: Vec<usize>,
|
||||
pub(crate) failed: bool,
|
||||
}
|
||||
|
||||
@@ -191,7 +194,7 @@ impl SlotCtx<'_> {
|
||||
let state = if third { ins.state_bytes() } else { None };
|
||||
create_instance(&ins.plugin, state.as_deref(), self.sr, self.mb, ch).map(|n| n.plugin)
|
||||
}?;
|
||||
Some(PluginSlot { id: ins.plugin.clone(), ch, plugin, applied: Vec::new(), failed: false })
|
||||
Some(PluginSlot { id: ins.plugin.clone(), ch, plugin, applied: Vec::new(), lanes: Vec::new(), failed: false })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,21 +494,40 @@ pub(crate) fn apply_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert
|
||||
let Ok(slot_u8) = u8::try_from(i) else {
|
||||
return apply_values(slot, ins, |_, v| v);
|
||||
};
|
||||
let automated = t.mixer.automation_mode.reads()
|
||||
&& t.automation.iter().any(|l| matches!(&l.param, AutoParam::Plugin { slot, .. } if *slot == slot_u8) && !l.points.is_empty());
|
||||
let lanes = &t.automation;
|
||||
let name = |j: usize| match lanes.get(j).map(|l| &l.param) {
|
||||
Some(AutoParam::Plugin { param, .. }) => param.as_str(),
|
||||
_ => "",
|
||||
};
|
||||
// This slot's lanes, ordered by parameter name (the parameter map's order) and keeping only
|
||||
// the first lane per name (the one a lookup by name finds). Matching them to the parameters
|
||||
// in one merge costs O(lanes log lanes + params) per block, not O(lanes x params).
|
||||
let mut order = std::mem::take(&mut slot.lanes);
|
||||
order.clear();
|
||||
if t.mixer.automation_mode.reads() {
|
||||
order
|
||||
.extend(lanes.iter().enumerate().filter(|(_, l)| matches!(&l.param, AutoParam::Plugin { slot, .. } if *slot == slot_u8)).map(|(j, _)| j));
|
||||
if !order.iter().any(|&j| lanes.get(j).is_some_and(|l| !l.points.is_empty())) {
|
||||
order.clear();
|
||||
}
|
||||
order.sort_unstable_by(|&a, &b| name(a).cmp(name(b)).then(a.cmp(&b)));
|
||||
order.dedup_by(|later, kept| name(*later) == name(*kept));
|
||||
}
|
||||
let mut next = order.iter().copied().peekable();
|
||||
apply_values(slot, ins, |k, val| {
|
||||
if automated
|
||||
&& let Some(l) = t.automation.iter().find(|l| matches!(&l.param, AutoParam::Plugin { slot, param } if *slot == slot_u8 && param == k))
|
||||
while next.next_if(|&j| name(j) < k).is_some() {}
|
||||
if let Some(l) = next.peek().filter(|&&j| name(j) == k).and_then(|&j| lanes.get(j))
|
||||
&& !l.points.is_empty()
|
||||
{
|
||||
return l.value_at(pos, val);
|
||||
}
|
||||
val
|
||||
});
|
||||
slot.lanes = order;
|
||||
}
|
||||
|
||||
/// Sends each stored param (as mapped by `value`) to the plugin when it changed since last time.
|
||||
fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, value: impl Fn(&str, f32) -> f32) {
|
||||
fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, mut value: impl FnMut(&str, f32) -> f32) {
|
||||
if slot.applied.len() != ins.params.len() {
|
||||
slot.applied = vec![f32::NAN; ins.params.len()];
|
||||
}
|
||||
@@ -517,3 +539,99 @@ fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, value: im
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod apply_params_tests {
|
||||
use super::*;
|
||||
use soundcraft_model::{AutomationLane, Insert};
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// A built-in gain that logs every parameter set.
|
||||
struct Recorder {
|
||||
inner: Box<dyn Plugin>,
|
||||
sets: Arc<Mutex<Vec<(String, f32)>>>,
|
||||
}
|
||||
|
||||
impl Plugin for Recorder {
|
||||
fn info(&self) -> &'static soundcraft_dsp::PluginInfo {
|
||||
self.inner.info()
|
||||
}
|
||||
fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {
|
||||
self.inner.prepare(sr, mb, ch);
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn set_param(&mut self, id: &str, v: f32) -> bool {
|
||||
self.sets.lock().unwrap().push((id.to_string(), v));
|
||||
true
|
||||
}
|
||||
fn param(&self, id: &str) -> Option<f32> {
|
||||
self.inner.param(id)
|
||||
}
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
self.inner.process(io, frames);
|
||||
}
|
||||
}
|
||||
|
||||
fn recorder() -> (PluginSlot, Arc<Mutex<Vec<(String, f32)>>>) {
|
||||
let sets = Arc::new(Mutex::new(Vec::new()));
|
||||
let plugin = Box::new(Recorder { inner: soundcraft_dsp::create("gain").unwrap(), sets: Arc::clone(&sets) });
|
||||
(PluginSlot { id: "gain".into(), ch: 2, plugin, applied: Vec::new(), lanes: Vec::new(), failed: false }, sets)
|
||||
}
|
||||
|
||||
fn lane(slot: u8, param: &str, point: Option<f32>) -> AutomationLane {
|
||||
let mut l = AutomationLane::new(AutoParam::Plugin { slot, param: param.into() });
|
||||
if let Some(v) = point {
|
||||
l.set_point(0, v);
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_lane_per_param_of_this_slot_drives_it() {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None);
|
||||
let tr = s.track_mut(t).unwrap();
|
||||
tr.automation = vec![
|
||||
lane(0, "c", None),
|
||||
lane(1, "a", Some(5.0)),
|
||||
lane(0, "b", Some(9.0)),
|
||||
lane(0, "c", Some(7.0)),
|
||||
lane(0, "zz", Some(1.0)),
|
||||
lane(0, "b", Some(8.0)),
|
||||
];
|
||||
let mut ins = Insert::new("gain");
|
||||
for (k, v) in [("a", 1.0), ("b", 2.0), ("c", 3.0), ("d", 4.0)] {
|
||||
ins.params.insert(k.into(), v);
|
||||
}
|
||||
let (mut slot, sets) = recorder();
|
||||
apply_params(&mut slot, &ins, tr, 0, 0);
|
||||
let got = sets.lock().unwrap().clone();
|
||||
let want: Vec<(String, f32)> = [("a", 1.0), ("b", 9.0), ("c", 3.0), ("d", 4.0)].iter().map(|(k, v)| (k.to_string(), *v)).collect();
|
||||
assert_eq!(got, want);
|
||||
// Unchanged values are not sent again.
|
||||
apply_params(&mut slot, &ins, tr, 0, 0);
|
||||
assert_eq!(sets.lock().unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_params_and_lanes_apply_without_a_quadratic_search() {
|
||||
let n = 40_000;
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None);
|
||||
let tr = s.track_mut(t).unwrap();
|
||||
tr.automation = (0..n).rev().map(|i| lane(0, &format!("p{i:06}"), Some(i as f32))).collect();
|
||||
let mut ins = Insert::new("gain");
|
||||
for i in 0..n {
|
||||
ins.params.insert(format!("p{i:06}"), -1.0);
|
||||
}
|
||||
let (mut slot, sets) = recorder();
|
||||
let start = std::time::Instant::now();
|
||||
apply_params(&mut slot, &ins, tr, 0, 0);
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed());
|
||||
let got = sets.lock().unwrap();
|
||||
assert_eq!(got.len(), n);
|
||||
assert!(got.iter().all(|(k, v)| k == &format!("p{:06}", *v as usize)));
|
||||
}
|
||||
}
|
||||
|
||||
+144
-35
@@ -40,14 +40,34 @@ pub struct BarBeat {
|
||||
|
||||
/// Tempo and meter changes for a session. Always has at least one of each, at tick 0.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(from = "TempoMapData")]
|
||||
pub struct TempoMap {
|
||||
tempos: Vec<TempoEvent>,
|
||||
meters: Vec<MeterEvent>,
|
||||
/// For each tempo event, the elapsed time at its tick in quarter-note minutes (ticks / bpm,
|
||||
/// summed over the earlier segments). Lets tick/sample conversions binary-search the segment
|
||||
/// instead of walking every earlier tempo change, so converting many positions against a large
|
||||
/// tempo map stays O(log n) each. Rebuilt whenever `tempos` changes.
|
||||
#[serde(skip)]
|
||||
elapsed: Vec<f64>,
|
||||
}
|
||||
|
||||
/// The serialized form of [`TempoMap`], without the derived lookup table.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct TempoMapData {
|
||||
tempos: Vec<TempoEvent>,
|
||||
meters: Vec<MeterEvent>,
|
||||
}
|
||||
|
||||
impl From<TempoMapData> for TempoMap {
|
||||
fn from(d: TempoMapData) -> Self {
|
||||
TempoMap::from_parts(d.tempos, d.meters)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TempoMap {
|
||||
fn default() -> Self {
|
||||
TempoMap { tempos: vec![TempoEvent { tick: 0, bpm: 120.0 }], meters: vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }] }
|
||||
TempoMap::from_parts(vec![TempoEvent { tick: 0, bpm: 120.0 }], vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +80,43 @@ fn valid_meter(numerator: u32, denominator: u32) -> bool {
|
||||
}
|
||||
|
||||
impl TempoMap {
|
||||
fn from_parts(tempos: Vec<TempoEvent>, meters: Vec<MeterEvent>) -> Self {
|
||||
let mut m = TempoMap { tempos, meters, elapsed: Vec::new() };
|
||||
m.reindex();
|
||||
m
|
||||
}
|
||||
|
||||
/// Rebuild `elapsed` from `tempos`. The first segment runs from tick 0 at the first tempo.
|
||||
fn reindex(&mut self) {
|
||||
let mut elapsed = Vec::with_capacity(self.tempos.len());
|
||||
let mut acc = 0.0;
|
||||
let mut seg_tick = 0i64;
|
||||
let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm);
|
||||
for (i, e) in self.tempos.iter().enumerate() {
|
||||
if i > 0 {
|
||||
// Saturating: a file's ticks are indexed before `sanitize` orders them.
|
||||
acc += e.tick.saturating_sub(seg_tick) as f64 / bpm;
|
||||
seg_tick = e.tick;
|
||||
bpm = e.bpm;
|
||||
}
|
||||
elapsed.push(acc);
|
||||
}
|
||||
self.elapsed = elapsed;
|
||||
}
|
||||
|
||||
/// Samples per quarter-note minute (the unit of `elapsed`) at `sr`.
|
||||
fn samples_per_elapsed(sr: SampleRate) -> f64 {
|
||||
sr.as_f64() * 60.0 / TICKS_PER_QUARTER as f64
|
||||
}
|
||||
|
||||
/// The tempo segment `i` (an index into `tempos`, 0 when it is empty): its start tick (0 for
|
||||
/// the first), bpm and elapsed time at its start.
|
||||
fn segment(&self, i: usize) -> (i64, f64, f64) {
|
||||
let bpm = self.tempos.get(i).or(self.tempos.first()).map_or(120.0, |e| e.bpm);
|
||||
let tick = if i == 0 { 0 } else { self.tempos.get(i).map_or(0, |e| e.tick) };
|
||||
(tick, bpm, self.elapsed.get(i).copied().unwrap_or(0.0))
|
||||
}
|
||||
|
||||
pub fn new(bpm: f64, numerator: u32, denominator: u32) -> Result<Self, TimeError> {
|
||||
let mut m = TempoMap::default();
|
||||
m.set_tempo(0, bpm)?;
|
||||
@@ -91,6 +148,7 @@ impl TempoMap {
|
||||
let (numerator, denominator) = self.meters.first().map_or((4, 4), |m| (m.numerator, m.denominator));
|
||||
self.meters.insert(0, MeterEvent { tick: 0, numerator, denominator });
|
||||
}
|
||||
self.reindex();
|
||||
}
|
||||
|
||||
/// Insert or replace the tempo at `tick`.
|
||||
@@ -105,6 +163,7 @@ impl TempoMap {
|
||||
}
|
||||
Err(i) => self.tempos.insert(i, TempoEvent { tick, bpm }),
|
||||
}
|
||||
self.reindex();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -115,6 +174,7 @@ impl TempoMap {
|
||||
}
|
||||
let before = self.tempos.len();
|
||||
self.tempos.retain(|e| e.tick != tick);
|
||||
self.reindex();
|
||||
before != self.tempos.len()
|
||||
}
|
||||
|
||||
@@ -204,36 +264,20 @@ impl TempoMap {
|
||||
}
|
||||
|
||||
fn tick_to_samples_f(&self, tick: i64, sr: SampleRate) -> f64 {
|
||||
let mut acc = 0.0;
|
||||
let mut seg_tick = 0i64;
|
||||
let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm);
|
||||
for e in self.tempos.iter().skip(1) {
|
||||
if e.tick >= tick {
|
||||
break;
|
||||
}
|
||||
acc += (e.tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr);
|
||||
seg_tick = e.tick;
|
||||
bpm = e.bpm;
|
||||
}
|
||||
acc + (tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr)
|
||||
// Tempo events after the first that start before `tick` (the segments it has passed).
|
||||
let passed = self.tempos.get(1..).map_or(0, |rest| rest.partition_point(|e| e.tick < tick));
|
||||
let (seg_tick, bpm, elapsed) = self.segment(passed);
|
||||
elapsed * Self::samples_per_elapsed(sr) + (tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr)
|
||||
}
|
||||
|
||||
/// Samples → ticks (fractional).
|
||||
pub fn samples_to_ticks_f(&self, s: Samples, sr: SampleRate) -> f64 {
|
||||
let s = s as f64;
|
||||
let mut acc = 0.0;
|
||||
let mut seg_tick = 0i64;
|
||||
let mut bpm = self.tempos.first().map_or(120.0, |e| e.bpm);
|
||||
for e in self.tempos.iter().skip(1) {
|
||||
let seg = (e.tick - seg_tick) as f64 * Self::samples_per_tick(bpm, sr);
|
||||
if acc + seg > s {
|
||||
break;
|
||||
}
|
||||
acc += seg;
|
||||
seg_tick = e.tick;
|
||||
bpm = e.bpm;
|
||||
}
|
||||
seg_tick as f64 + (s - acc) / Self::samples_per_tick(bpm, sr)
|
||||
let scale = Self::samples_per_elapsed(sr);
|
||||
// Tempo events after the first that start at or before `s`.
|
||||
let passed = self.elapsed.get(1..).map_or(0, |rest| rest.partition_point(|e| e * scale <= s));
|
||||
let (seg_tick, bpm, elapsed) = self.segment(passed);
|
||||
seg_tick as f64 + (s - elapsed * scale) / Self::samples_per_tick(bpm, sr)
|
||||
}
|
||||
|
||||
pub fn samples_to_ticks(&self, s: Samples, sr: SampleRate) -> i64 {
|
||||
@@ -319,6 +363,7 @@ impl TempoMap {
|
||||
e.bpm = valid_bpm(e.bpm * factor)?;
|
||||
}
|
||||
self.tempos = out;
|
||||
self.reindex();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -407,24 +452,24 @@ mod tests {
|
||||
fn sanitize_keeps_what_the_setters_accept() {
|
||||
let tempo = |tick, bpm| TempoEvent { tick, bpm };
|
||||
let meter = |tick, numerator, denominator| MeterEvent { tick, numerator, denominator };
|
||||
let mut m = TempoMap {
|
||||
tempos: vec![tempo(960, 4e9), tempo(3840, 90.0), tempo(-5, 100.0), tempo(1920, 60.0), tempo(1920, 70.0)],
|
||||
meters: vec![meter(0, 0, 4), meter(3840, 3, 4)],
|
||||
};
|
||||
let mut m = TempoMap::from_parts(
|
||||
vec![tempo(960, 4e9), tempo(3840, 90.0), tempo(-5, 100.0), tempo(1920, 60.0), tempo(1920, 70.0)],
|
||||
vec![meter(0, 0, 4), meter(3840, 3, 4)],
|
||||
);
|
||||
m.sanitize();
|
||||
assert_eq!(m.tempos(), [tempo(0, 60.0), tempo(1920, 60.0), tempo(3840, 90.0)]);
|
||||
assert_eq!(m.meters(), [meter(0, 3, 4), meter(3840, 3, 4)]);
|
||||
let mut empty = TempoMap { tempos: Vec::new(), meters: Vec::new() };
|
||||
let mut empty = TempoMap::from_parts(Vec::new(), Vec::new());
|
||||
empty.sanitize();
|
||||
assert_eq!(empty, TempoMap::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_drops_events_beyond_the_position_range() {
|
||||
let mut m = TempoMap {
|
||||
tempos: vec![TempoEvent { tick: 0, bpm: 120.0 }, TempoEvent { tick: i64::MAX, bpm: 90.0 }],
|
||||
meters: vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }, MeterEvent { tick: i64::MAX, numerator: 3, denominator: 4 }],
|
||||
};
|
||||
let mut m = TempoMap::from_parts(
|
||||
vec![TempoEvent { tick: 0, bpm: 120.0 }, TempoEvent { tick: i64::MAX, bpm: 90.0 }],
|
||||
vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }, MeterEvent { tick: i64::MAX, numerator: 3, denominator: 4 }],
|
||||
);
|
||||
m.sanitize();
|
||||
assert_eq!(m, TempoMap::default());
|
||||
assert_eq!(m.bar_start_tick(2), TICKS_PER_QUARTER * 4);
|
||||
@@ -446,4 +491,68 @@ mod tests {
|
||||
assert_eq!(m.bar_beat_at_tick(-TICKS_PER_QUARTER * 4), BarBeat { bar: 0, beat: 1, tick: 0 });
|
||||
assert_eq!(m.bar_beat_at_tick(-TICKS_PER_QUARTER), BarBeat { bar: 0, beat: 4, tick: 0 });
|
||||
}
|
||||
|
||||
/// The segment walk the indexed conversions replaced, kept as the reference they must match.
|
||||
fn linear_tick_to_samples(m: &TempoMap, tick: i64, sr: SampleRate) -> i64 {
|
||||
let mut acc = 0.0;
|
||||
let mut seg_tick = 0i64;
|
||||
let mut bpm = m.tempos[0].bpm;
|
||||
for e in m.tempos.iter().skip(1) {
|
||||
if e.tick >= tick {
|
||||
break;
|
||||
}
|
||||
acc += (e.tick - seg_tick) as f64 * TempoMap::samples_per_tick(bpm, sr);
|
||||
seg_tick = e.tick;
|
||||
bpm = e.bpm;
|
||||
}
|
||||
to_samples(acc + (tick - seg_tick) as f64 * TempoMap::samples_per_tick(bpm, sr))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_conversions_match_the_segment_walk() {
|
||||
let sr = SampleRate::HZ_48000;
|
||||
let mut m = TempoMap::default();
|
||||
for i in 1..200i64 {
|
||||
m.set_tempo(i * 487, 40.0 + ((i * 37) % 300) as f64).unwrap();
|
||||
}
|
||||
assert!(m.remove_tempo(487 * 50));
|
||||
for tick in (-1000..200 * 487 + 5000).step_by(113) {
|
||||
let s = m.tick_to_samples(tick, sr);
|
||||
assert_eq!(s, linear_tick_to_samples(&m, tick, sr), "tick {tick}");
|
||||
assert!((m.samples_to_ticks(s, sr) - tick).abs() <= 1, "tick {tick}");
|
||||
}
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
assert!(!json.contains("elapsed"));
|
||||
let back: TempoMap = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, m);
|
||||
assert_eq!(back.tick_to_samples(150 * 487 + 3, sr), m.tick_to_samples(150 * 487 + 3, sr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_wild_ticks_is_indexed_without_overflow() {
|
||||
let json = r#"{"tempos":[{"tick":0,"bpm":120.0},{"tick":-9223372036854775808,"bpm":100.0},{"tick":9223372036854775807,"bpm":90.0}],"meters":[{"tick":0,"numerator":4,"denominator":4}]}"#;
|
||||
let mut m: TempoMap = serde_json::from_str(json).unwrap();
|
||||
m.sanitize();
|
||||
assert_eq!(m.tempos().first().map(|e| e.tick), Some(0));
|
||||
assert_eq!(m.elapsed.len(), m.tempos().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversions_against_a_large_tempo_map_stay_fast() {
|
||||
// 200k tempo changes, then 200k conversions each way: quadratic if every conversion walks
|
||||
// the earlier segments, well under a second when it binary-searches them.
|
||||
let sr = SampleRate::HZ_48000;
|
||||
let n = 200_000i64;
|
||||
let tempos = (0..n).map(|i| TempoEvent { tick: i * 10, bpm: 60.0 + (i % 7) as f64 }).collect();
|
||||
let mut m = TempoMap::from_parts(tempos, vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }]);
|
||||
m.sanitize();
|
||||
let start = std::time::Instant::now();
|
||||
let mut sum = 0i64;
|
||||
for i in 0..n {
|
||||
let s = m.tick_to_samples(i * 10 + 5, sr);
|
||||
sum = sum.wrapping_add(s).wrapping_add(m.samples_to_ticks(s, sr));
|
||||
}
|
||||
assert!(sum != 0);
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user