Compare commits

..
5 changed files with 144 additions and 24 deletions
+1 -7
View File
@@ -166,13 +166,7 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
let message = message.strip_suffix('\n').unwrap_or(message);
// Control characters are written escaped: a message quoting a session file (a plugin id, a
// path) must not start a line of its own in the log or steer the terminal.
let mut escaped = String::with_capacity(message.len());
for c in message.chars() {
if c.is_control() { escaped.extend(c.escape_default()) } else { escaped.push(c) }
}
format!("{ts} {level:<5} [{thread}] {target}: {escaped}\n")
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
}
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
+2 -9
View File
@@ -158,15 +158,8 @@ fn a_line_carries_time_level_thread_target_and_message() {
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "soundcraft_mix", "eq_7band: the plugin did not accept its stored state"),
"2026-10-08T07:59:17.728Z WARN [main] soundcraft_mix: eq_7band: the plugin did not accept its stored state\n"
);
// A message with breaks of its own stays one record; the record still ends in exactly one newline.
assert!(
format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\\nb\n"),
"a break inside the message is escaped, the final one dropped"
);
assert!(
format_line("t", log::Level::Error, "w", "x", "plugin \u{1b}[2J\r\n2026 INFO forged")
.ends_with("x: plugin \\u{1b}[2J\\r\\n2026 INFO forged\n")
);
// A multi-line message keeps its lines; the record still ends in exactly one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
}
#[test]
+37
View File
@@ -125,6 +125,43 @@ pub fn separate_at_if(s: &mut Session, track: TrackId, at: Samples, auto_name: i
count
}
/// Split every clip on a track's active playlist at each of `points`, in one pass over the
/// playlist: [`separate_at_if`] once per point with `|_| false` (no piece takes an Auto Rename
/// name here), without rebuilding the playlist per point. Returns the number of splits.
pub fn separate_at_points(s: &mut Session, track: TrackId, points: &[Samples]) -> usize {
let mut pts = points.to_vec();
pts.sort_unstable();
pts.dedup();
let tc_tempo = s.tempo.clone();
let t = TimeCtx { tempo: &tc_tempo, sr: s.sample_rate };
// The points strictly inside a clip.
let within = |c: &Clip| {
let (lo, hi) = (pts.partition_point(|p| *p <= c.start), pts.partition_point(|p| *p < c.end()));
lo..hi.max(lo)
};
let n_split: usize = s.track(track).map_or(0, |tr| tr.clips().iter().map(|c| within(c).len()).sum());
let mut ids: Vec<ClipId> = (0..n_split).map(|_| s.new_clip_id()).collect();
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
let mut out = Vec::with_capacity(pl.clips.len() + n_split);
for c in pl.clips.drain(..) {
let mut rest = c;
for p in &pts[within(&rest)] {
let Some(id) = ids.pop() else { break };
match split_clip(&rest, *p, id, t) {
Some((l, r)) => {
out.push(l);
rest = r;
}
None => break,
}
}
out.push(rest);
}
pl.clips = out;
pl.sort();
n_split
}
/// Gives `c` the next name of its Auto Rename scheme, when it has one, and counts the scheme up.
fn take_auto_name(schemes: &mut std::collections::BTreeMap<u64, soundcraft_model::AutoName>, c: &mut Clip) {
if let Some(scheme) = c.auto_name.and_then(|k| schemes.get_mut(&k)) {
+37 -5
View File
@@ -117,9 +117,7 @@ pub fn strip_silence(
}
cut_points.push(r.end);
let old: std::collections::HashSet<_> = s.track(t).map(|x| x.clips().iter().map(|c| c.id).collect()).unwrap_or_default();
for p in &cut_points {
crate::edit::separate_at_if(s, t, *p, |_| false);
}
crate::edit::separate_at_points(s, t, &cut_points);
let kept = strip_pieces(s, t, r, &keep, mode);
crate::edit::auto_name_new_clips(s, t, &old, |c| !(named && c.is_audio() && c.start >= r.start && c.end() <= r.end));
Ok(kept)
@@ -144,10 +142,24 @@ fn strip_pieces(s: &mut Session, t: TrackId, r: Range, keep: &[(Samples, Samples
let audio: Vec<Range> = keep.iter().map(|(a, b)| Range::new(*a, *b)).collect();
let remove = if mode == StripMode::Extract { &audio } else { &gaps };
let inside = |c: &Clip, g: &Range| c.start >= g.start && c.end() <= g.end;
// The range of `rs` holding `c`: by binary search when `rs` is ascending and disjoint (the
// usual case, checked once by the caller), by scan otherwise.
let holder = |rs: &[Range], sorted: bool, c: &Clip| {
if sorted {
let i = rs.partition_point(|g| g.end < c.end());
rs.get(i).filter(|g| inside(c, g)).map(|_| i)
} else {
rs.iter().position(|g| inside(c, g))
}
};
let ordered = |rs: &[Range]| rs.windows(2).all(|w| w[0].end <= w[1].start);
let Some(pl) = s.track_mut(t).and_then(|x| x.playlist_mut()) else { return if mode == StripMode::Extract { 0 } else { keep.len() } };
pl.clips.retain(|c| !remove.iter().any(|g| inside(c, g)));
let sorted = ordered(remove);
pl.clips.retain(|c| holder(remove, sorted, c).is_none());
if mode == StripMode::Extract {
return gaps.iter().filter(|g| pl.clips.iter().any(|c| inside(c, g))).count();
let sorted = ordered(&gaps);
let held: std::collections::HashSet<usize> = pl.clips.iter().filter_map(|c| holder(&gaps, sorted, c)).collect();
return held.len();
}
keep.len()
}
@@ -294,6 +306,26 @@ mod strip_tests {
(r, e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.end())).collect())
}
#[test]
fn cutting_at_many_points_in_one_pass_equals_cutting_at_each() {
let spans = |e: &Engine, t: TrackId| {
let mut v: Vec<_> = e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.end())).collect();
v.sort_unstable();
v
};
let points = [30_000, 10, 5_000, 30_000, 120_000, 0, 20_000, 1_000_000];
let (mut one, t) = tones(&[0, 20_000, 100_000], 40_000);
let (mut each, _) = tones(&[0, 20_000, 100_000], 40_000);
crate::edit::separate_at_points(one.session_mut(), t, &points);
for p in points {
crate::edit::separate_at_if(each.session_mut(), t, p, |_| false);
}
assert_eq!(spans(&one, t), spans(&each, t));
assert!(spans(&one, t).len() > 3);
let ids: std::collections::HashSet<_> = one.session().track(t).unwrap().clips().iter().map(|c| c.id).collect();
assert_eq!(ids.len(), spans(&one, t).len(), "new pieces have distinct ids");
}
#[test]
fn strip_keeps_the_audio_and_removes_the_silence() {
let (r, clips) = strip_mode("strip");
+67 -3
View File
@@ -213,18 +213,52 @@ impl TempoMap {
}
}
/// Remove the meters after `tick`, returning each with the bar it starts.
/// Remove the meters after `tick`, returning each with the bar it starts: the bar
/// [`Self::bar_beat_at_tick`] gives its tick, counted in one pass over the map rather than one
/// walk per meter, so a session with many meter changes edits in linear time.
fn take_meters_after(&mut self, tick: i64) -> Vec<(i64, MeterEvent)> {
let later: Vec<_> = self.meters.iter().filter(|e| e.tick > tick).map(|e| (self.bar_beat_at_tick(e.tick).bar, *e)).collect();
let mut later = Vec::new();
let (mut bar, mut seg_tick) = (1i64, 0i64);
for (i, m) in self.meters.iter().enumerate() {
let tpb = m.ticks_per_bar();
if m.tick > tick {
later.push((bar.saturating_add(m.tick.saturating_sub(seg_tick) / tpb), *m));
}
if let Some(n) = self.meters.get(i + 1) {
bar = bar.saturating_add(ceil_div(n.tick.saturating_sub(seg_tick), tpb));
seg_tick = n.tick;
}
}
self.meters.retain(|e| e.tick <= tick);
later
}
/// The bar and tick of the last meter's segment, where [`Self::bar_start_tick`]'s walk ends.
fn last_segment(&self) -> (i64, i64) {
let (mut cur_bar, mut cur_tick) = (1i64, 0i64);
for (i, m) in self.meters.iter().enumerate() {
if let Some(n) = self.meters.get(i + 1) {
cur_bar = cur_bar.saturating_add(ceil_div(n.tick.saturating_sub(cur_tick), m.ticks_per_bar()));
cur_tick = n.tick;
}
}
(cur_bar, cur_tick)
}
/// Put meters back at the start of their bars under the current earlier meters, in bar order.
/// Each lands at or past the last meter, so the walk to its bar resumes from there; a bar
/// before it (meters off their bar lines) takes the full walk.
fn restore_meters_at_bars(&mut self, later: Vec<(i64, MeterEvent)>) {
let (mut cur_bar, mut cur_tick) = self.last_segment();
for (bar, ev) in later {
let tick = self.bar_start_tick(bar);
let tick = if bar >= cur_bar {
let tpb = self.meters.last().map_or(MeterEvent { tick: 0, numerator: 4, denominator: 4 }, |m| *m).ticks_per_bar();
cur_tick.saturating_add(bar.saturating_sub(cur_bar).saturating_mul(tpb))
} else {
self.bar_start_tick(bar)
};
self.put_meter(MeterEvent { tick, ..ev });
(cur_bar, cur_tick) = if bar >= cur_bar { (bar, tick) } else { self.last_segment() };
}
}
@@ -441,6 +475,36 @@ mod tests {
assert_eq!(m.bar_beat_at_tick(m.tick_at_bar_beat(bb)), bb);
}
#[test]
fn editing_an_early_meter_keeps_later_bars_and_scales_linearly() {
// 200 000 meter changes, one per bar, built directly: a walk per meter would take
// minutes to edit here, and even to build through `set_meter`.
let mut m = TempoMap::new(120.0, 4, 4).unwrap();
let mut tick = 0;
for bar in 1..200_000i64 {
let numerator = 3 + (bar % 2) as u32;
tick += m.meters.last().map_or(0, MeterEvent::ticks_per_bar);
m.meters.push(MeterEvent { tick, numerator, denominator: 4 });
}
let sample = [1usize, 1_000, 199_998];
let bars = |m: &TempoMap| sample.map(|i| m.bar_beat_at_tick(m.meters[i].tick).bar);
assert_eq!(bars(&m), [2, 1_001, 199_999]);
m.set_meter(0, 7, 8).unwrap();
assert_eq!(bars(&m), [2, 1_001, 199_999], "later meters keep their bar numbers");
assert_eq!(m.meters.len(), 200_000);
assert!(m.meters.windows(2).all(|w| w[0].tick < w[1].tick));
assert!(m.remove_meter(m.meters[1].tick));
assert_eq!([1usize, 999, 199_997].map(|i| m.bar_beat_at_tick(m.meters[i].tick).bar), [3, 1_001, 199_999]);
// Meters off their bar lines (counted as bars 2 and 4) land where they always did: on
// bar 2 of the new meter, then two bars of their own later.
let mut off = TempoMap::new(120.0, 4, 4).unwrap();
off.meters.push(MeterEvent { tick: 100, numerator: 3, denominator: 4 });
off.meters.push(MeterEvent { tick: 5_000, numerator: 5, denominator: 4 });
off.set_meter(0, 2, 4).unwrap();
let placed: Vec<(i64, u32)> = off.meters.iter().map(|e| (e.tick, e.numerator)).collect();
assert_eq!(placed, vec![(0, 2), (2 * TICKS_PER_QUARTER, 3), (8 * TICKS_PER_QUARTER, 5)]);
}
#[test]
fn eighth_note_meters() {
let m = TempoMap::new(120.0, 6, 8).unwrap();