Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions crates/os-app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19690,6 +19690,22 @@ mod tests {
let _ = std::fs::remove_file(&wav);
}

/// The playlist draws with a 3/8 project bar and a time-signature marker (bars of another length): no panic, the
/// ruler and grid repaint after a scroll the same as a fresh paint.
#[test]
fn playlist_paints_other_bars() {
use crate::playlist as pl;
let Ok(mut c) = Canvas::new(1920, 1080, 1.0) else { return };
let (mut app, _, _, _) = ruler_app(&mut c);
app.state.project.bar = Some((3, 2));
pl::add_marker(&mut app.state, os_model::TimeMarker { kind: os_model::TimeMarker::SIGNATURE, numerator: 5, denominator: 4, ..os_model::TimeMarker::new(1000, "5/4") });
frame(&mut app, &mut c, None);
app.state.playlist_scroll.0 = 700.0;
frame(&mut app, &mut c, None);
let g = app.pl_grid.expect("playlist shown");
assert!(g.w > 0.0);
}

/// Tools menu (spec: ui/playlist.md §10): quick quantize moves clip starts onto the snap grid (one undo step);
/// export renders every playing track to its own file, all as long as the song.
#[test]
Expand Down
4 changes: 2 additions & 2 deletions crates/os-app/src/hold.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ impl App {
let g = pl::PGeom::new(&self.state, grid);
let unit = match pl::grid_snap(&self.state) {
pl::Snap::None => g.ppq / 4,
sn => sn.ticks(g.ppq, g.bar_w),
sn => pl::snap_ticks(&self.state, sn, g.bar_w),
};
// `fresh`: the copies are a change even where they end up.
let d = PlDrag::Move { x0: x0 - unit as f32 * g.ppt, y0, orig: copies, fresh: true };
Expand Down Expand Up @@ -490,7 +490,7 @@ mod tests {
let (mut app, (x, y)) = playlist_app(&mut c);
let orig = pl::clips(&app.state)[0];
let g = pl::PGeom::new(&app.state, app.pl_grid().unwrap());
let unit = pl::grid_snap(&app.state).ticks(g.ppq, g.bar_w);
let unit = pl::snap_unit(&app.state, g.bar_w);
let steps = app.state.history.len();
press(&mut app, x, y);
let now = at(&app, 700);
Expand Down
193 changes: 176 additions & 17 deletions crates/os-app/src/playlist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,19 @@ impl Snap {
}
}

/// `sn`'s unit in ticks in the playlist: as [`Snap::ticks`], but a bar is the project's (its time signature).
pub fn snap_ticks(s: &State, sn: Snap, bar_w: f32) -> u32 {
match sn {
Snap::Bar => s.project.bar_ticks(),
sn => sn.ticks(ppq(s), bar_w),
}
}

/// The playlist's current snap unit in ticks at the zoom `bar_w` ([`snap_ticks`]).
pub fn snap_unit(s: &State, bar_w: f32) -> u32 {
snap_ticks(s, grid_snap(s), bar_w)
}

/// Ticks between the drawn grid lines: steps when a beat is wide, beats normally, bars when zoomed far out.
pub fn grid_ticks(ppq: u32, bar_w: f32) -> u32 {
let beat_px = bar_w / 4.0;
Expand Down Expand Up @@ -1229,10 +1242,101 @@ fn ppq(s: &State) -> u32 {
u32::from(s.project.ppq.max(4))
}

/// The project's bar in ticks (its time signature: `Project::bar_ticks`).
fn bar(s: &State) -> u32 {
s.project.bar_ticks()
}

/// Four quarter notes: the span the playlist's zoom is counted in (`State::playlist_bar_w` is the width of this many
/// ticks, whatever the time signature), so the width of a tick does not change with it.
fn ref_bar(s: &State) -> u32 {
ppq(s) * 4
}

/// The song's bars. The project's bar from the start; a time-signature marker of the arrangement starts bars of its
/// own length at its position (the bar it lands in is cut short there), as a pattern's marker sets the pattern's bars
/// (spec: ui/pattern-time-signature.md). Bar numbers count from 0.
#[derive(Debug, Clone)]
pub struct Bars {
/// (first tick, bar length in ticks), ascending; the first starts at 0.
segs: Vec<(u32, u32)>,
}

impl Bars {
pub fn new(s: &State) -> Bars {
let ppq = u32::from(s.project.ppq.max(1));
let mut marks: Vec<&TimeMarker> = markers(s).iter().filter(|m| m.kind == TimeMarker::SIGNATURE && m.numerator > 0 && m.denominator > 0).collect();
marks.sort_by_key(|m| m.position);
let mut segs = vec![(0, s.project.bar_ticks())];
for m in marks {
let len = (ppq * 4 * u32::from(m.numerator) / u32::from(m.denominator)).max(1);
match segs.last_mut() {
Some(l) if l.0 == m.position => l.1 = len,
_ => segs.push((m.position, len)),
}
}
Bars { segs }
}

fn seg(&self, t: u32) -> usize {
self.segs.partition_point(|&(st, _)| st <= t).saturating_sub(1)
}

/// How many bars segment `i` holds (the last one cut short counts), `None` for the open-ended last segment.
fn count(&self, i: usize) -> Option<u32> {
let (st, len) = self.segs[i];
self.segs.get(i + 1).map(|n| (n.0 - st).div_ceil(len))
}

/// The start of the bar `t` is in.
pub fn floor(&self, t: u32) -> u32 {
let (st, len) = self.segs[self.seg(t)];
st + (t - st) / len * len
}

/// The nearest bar line to `t` (halfway goes up).
pub fn nearest(&self, t: f32) -> u32 {
let t = t.max(0.0);
let f = self.floor(t as u32);
let i = self.seg(f);
let (_, len) = self.segs[i];
let next = self.segs.get(i + 1).map_or(f + len, |n| (f + len).min(n.0));
if t - f as f32 >= (next - f) as f32 / 2.0 { next } else { f }
}

/// The number of the bar `t` is in.
pub fn index(&self, t: u32) -> i32 {
let i = self.seg(t);
let before: u32 = (0..i).filter_map(|k| self.count(k)).sum();
(before + (t - self.segs[i].0) / self.segs[i].1) as i32
}

/// The bars that overlap `t0..t1`: (number, first tick, length).
pub fn lines(&self, t0: u32, t1: u32) -> Vec<(i32, u32, u32)> {
let mut out = Vec::new();
let first = self.seg(t0);
let mut before: u32 = (0..first).filter_map(|k| self.count(k)).sum();
for i in first..self.segs.len() {
let (st, len) = self.segs[i];
let end = self.segs.get(i + 1).map(|n| n.0);
if st >= t1 {
break;
}
let mut k = if i == first { (t0 - st) / len } else { 0 };
loop {
let at = st + k * len;
if at >= t1 || end.is_some_and(|e| at >= e) {
break;
}
out.push(((before + k) as i32, at, end.map_or(len, |e| len.min(e - at))));
k += 1;
}
before += self.count(i).unwrap_or(0);
}
out
}
}

fn channel_of(s: &State, ch: u16) -> Option<&Channel> {
s.project.channel(ch)
}
Expand Down Expand Up @@ -1444,7 +1548,7 @@ pub fn source_len(s: &State, src: ClipSource) -> u32 {
ClipSource::Channel(c) => {
if let Some(a) = s.project.automation(c) {
let beats = a.points.last().map_or(0.0, |p| p.beat);
return ((beats * f64::from(ppq(s))).round() as u32).max(bar / 4);
return ((beats * f64::from(ppq(s))).round() as u32).max(ppq(s));
}
// The whole sample at the channel's speed (`fit_audio`).
let t = channel_of(s, c).map_or(0.0, |ch| audio_ticks(s, ch));
Expand Down Expand Up @@ -1559,8 +1663,15 @@ pub fn snap_place(s: &State, g: &PGeom, tick: f32, alt: bool) -> u32 {
if alt || sn == Snap::None {
return t as u32;
}
let u = sn.ticks(g.ppq, g.bar_w);
if sn == Snap::Cell { t as u32 / u * u } else { (t / u as f32).round() as u32 * u }
let bars = Bars::new(s);
if sn == Snap::Bar {
return bars.nearest(t);
}
// Units count from the start of the bar the tick is in (the same as from 0 while every bar is a multiple of it).
let u = snap_ticks(s, sn, g.bar_w);
let o = bars.floor(t as u32);
let r = t - o as f32;
o + if sn == Snap::Cell { r as u32 / u * u } else { (r / u as f32).round() as u32 * u }
}

/// The song position set on the ruler: down to the unit (Alt: the tick under the pointer).
Expand All @@ -1572,8 +1683,9 @@ fn snap_down(s: &State, g: &PGeom, tick: f32, alt: bool) -> u32 {
if let Some(c) = catch(s, g, tick.max(0.0), &[]) {
return c;
}
let u = grid_snap(s).ticks(g.ppq, g.bar_w);
t - t % u
let o = Bars::new(s).floor(t);
let u = snap_unit(s, g.bar_w);
t - (t - o) % u
}

/// A drag's delta for the edge at `from` (a moved clip's start, a resized edge): under Events and Markers the edge goes
Expand All @@ -1590,7 +1702,7 @@ pub fn snap_delta(s: &State, g: &PGeom, dt: f32, alt: bool) -> i64 {
if alt {
return dt.round() as i64;
}
let u = grid_snap(s).ticks(g.ppq, g.bar_w) as f32;
let u = snap_unit(s, g.bar_w) as f32;
((dt / u).round() * u) as i64
}

Expand Down Expand Up @@ -1793,9 +1905,9 @@ pub fn load_view(s: &mut State) {
s.playlist.row_h = (v.row_px() as f32).clamp(ROW_MIN, ROW_MAX);
let tpp = v.ticks_per_px();
if tpp.is_finite() && tpp > 0.0 {
s.playlist_bar_w = (f64::from(bar(s)) / tpp).clamp(4.0, 600.0) as f32;
s.playlist_bar_w = (f64::from(ref_bar(s)) / tpp).clamp(4.0, 600.0) as f32;
let left = v.left();
let tpp = f64::from(bar(s)) / f64::from(s.playlist_bar_w);
let tpp = f64::from(ref_bar(s)) / f64::from(s.playlist_bar_w);
s.playlist_scroll.0 = if left.is_finite() { (left / tpp).max(0.0) as f32 } else { 0.0 };
}
s.playlist_scroll.1 = v.scroll_y() as f32;
Expand All @@ -1804,7 +1916,7 @@ pub fn load_view(s: &mut State) {
/// Write the playlist's track height, time zoom and scroll into the project for saving (event 242).
pub fn store_view(s: &mut State) {
let mut v = s.project.playlist_view.unwrap_or_else(|| os_model::PlaylistView::new(s.project.ppq));
let tpp = f64::from(bar(s)) / f64::from(s.playlist_bar_w.max(1.0));
let tpp = f64::from(ref_bar(s)) / f64::from(s.playlist_bar_w.max(1.0));
v.set_ticks_per_px(tpp);
v.set_left(f64::from(s.playlist_scroll.0) * tpp);
v.set_scroll_y(s.playlist_scroll.1.max(0.0).round() as u32);
Expand Down Expand Up @@ -2913,7 +3025,7 @@ pub fn reveal_source(s: &mut State, ch: u16) -> bool {
s.playlist.sel = clips(s).iter().enumerate().filter(|(_, c)| c.source == src).map(|(i, _)| i).collect();
s.raise(crate::app::Win::Playlist);
let Some(first) = clips(s).iter().filter(|c| c.source == src).min_by_key(|c| (c.position, c.track)).copied() else { return false };
s.playlist_scroll.0 = first.position as f32 / bar(s) as f32 * s.playlist_bar_w;
s.playlist_scroll.0 = first.position as f32 / ref_bar(s) as f32 * s.playlist_bar_w;
s.playlist_scroll.1 = Rows::new(s).top(first.track);
true
}
Expand Down Expand Up @@ -3585,7 +3697,7 @@ fn snap_round(s: &State, g: &PGeom, tick: f32, alt: bool) -> u32 {
if alt {
return tick.max(0.0).round() as u32;
}
let u = grid_snap(s).ticks(g.ppq, g.bar_w) as f32;
let u = snap_unit(s, g.bar_w) as f32;
((tick / u).round() * u).max(0.0) as u32
}

Expand Down Expand Up @@ -3689,7 +3801,7 @@ pub fn drag_move(app: &mut App, d: PlDrag, grid: Rect) {
PlDrag::ResizeR { x0, orig } => {
let skip: Vec<usize> = orig.iter().map(|&(i, _)| i).collect();
let dt = orig.first().map_or(0, |(_, c)| snap_edge(s, &g, c.end(), (x - x0) / g.ppt, alt, &skip));
let min = if alt { 1 } else { grid_snap(s).ticks(g.ppq, g.bar_w) } as i64;
let min = if alt { 1 } else { snap_unit(s, g.bar_w) } as i64;
for &(i, o) in &orig {
let len = (i64::from(o.length) + dt).max(min) as u32;
if !stretch_clip(s, i, &o, o.position, len) {
Expand Down Expand Up @@ -3785,7 +3897,7 @@ pub fn drag_move(app: &mut App, d: PlDrag, grid: Rect) {
let s = &mut app.state;
let mut beat = beat0 + f64::from((x - x0) / g.ppt) / f64::from(g.ppq);
if !alt {
let u = f64::from(grid_snap(s).ticks(g.ppq, g.bar_w)) / f64::from(g.ppq);
let u = f64::from(snap_unit(s, g.bar_w)) / f64::from(g.ppq);
let (w0, _) = window(s, &c);
beat = w0 + ((beat - w0) / u).round() * u;
}
Expand Down Expand Up @@ -3890,7 +4002,7 @@ pub fn drag_move(app: &mut App, d: PlDrag, grid: Rect) {
fn clips_jumped(s: &State, g: &PGeom, grid: Rect, alt: bool, from: Vec<(usize, Rect)>) -> Vec<Rect> {
/// A magnet's step this small (px) is no step.
const NO_STEP: f32 = 3.0;
let stepped = !alt && grid_snap(s).ticks(g.ppq, g.bar_w) as f32 * g.ppt >= NO_STEP;
let stepped = !alt && snap_unit(s, g.bar_w) as f32 * g.ppt >= NO_STEP;
let count = clips(s).len();
let mut dirty = Vec::new();
for (i, r0) in from {
Expand Down Expand Up @@ -4026,7 +4138,7 @@ fn zoom_toggle(app: &mut App) {
pub fn zoom_on(s: &mut State, grid: (f32, f32), idx: &[usize]) {
let cs: Vec<Clip> = idx.iter().filter_map(|&i| clips(s).get(i).copied()).collect();
let (Some(a), Some(b)) = (cs.iter().map(|c| c.position).min(), cs.iter().map(|c| c.end()).max()) else { return };
let bar = bar(s) as f32;
let bar = ref_bar(s) as f32;
let span = ((b - a) as f32 / bar).max(1.0);
s.playlist_bar_w = (grid.0 / span).clamp(4.0, 600.0);
s.playlist_scroll.0 = a as f32 / bar * s.playlist_bar_w;
Expand Down Expand Up @@ -4671,7 +4783,7 @@ pub fn run(app: &mut App, cmd: Cmd) -> Option<String> {
}
Cmd::ShiftTime(d) => {
snapshot(s);
let u = grid.map_or(ppq(s), |g| grid_snap(s).ticks(ppq(s), PGeom::new(s, g).bar_w)) as i64;
let u = grid.map_or(ppq(s), |g| snap_unit(s, PGeom::new(s, g).bar_w)) as i64;
let idx = targets(s);
let arr = arr_mut(s);
let min = idx.iter().filter_map(|&i| arr.clips.get(i)).map(|c| i64::from(c.position)).min().unwrap_or(0);
Expand Down Expand Up @@ -5211,7 +5323,7 @@ pub(crate) fn hsl_rgb(h: f32, s: f32, l: f32) -> Rgb {
/// nearest snap step (on the nearest beat when snapping is off); lengths stay. One undo step.
pub fn quantize_starts(s: &mut State) -> String {
let p = ppq(s);
let unit = match grid_snap(s).ticks(p, s.playlist_bar_w) {
let unit = match snap_unit(s, s.playlist_bar_w) {
0 | 1 => p,
u => u,
};
Expand Down Expand Up @@ -5695,6 +5807,53 @@ mod tests {
app
}

fn sig(pos: u32, num: u8, den: u8) -> TimeMarker {
TimeMarker { kind: TimeMarker::SIGNATURE, numerator: num, denominator: den, ..TimeMarker::new(pos, &format!("{num}/{den}")) }
}

fn lens(s: &State, to: u32) -> Vec<(i32, u32, u32)> {
Bars::new(s).lines(0, to)
}

/// The playlist's bars are the project's (3/8: 144 ticks at PPQ 96), not always four beats; Snap > Bar and the
/// ruler's bar lines follow, and a time-signature marker of the arrangement starts bars of its own length.
#[test]
fn bars_follow_the_time_signature() {
let mut app = app();
let s = &mut app.state;
assert_eq!(lens(s, 800), [(0, 0, 384), (1, 384, 384), (2, 768, 384)], "4/4");
assert_eq!(snap_ticks(s, Snap::Bar, 0.0), 384);
// The project's bar: 3 beats of 2 steps = 3/8.
s.project.bar = Some((3, 2));
assert_eq!(snap_ticks(s, Snap::Bar, 0.0), 144);
assert_eq!(lens(s, 300), [(0, 0, 144), (1, 144, 144), (2, 288, 144)], "3/8");
let b = Bars::new(s);
assert_eq!((b.floor(300), b.index(300), b.nearest(200.0), b.nearest(230.0)), (288, 2, 144, 288));
// The snap itself: the nearest bar line, a quarter-bar unit counted from the bar's start.
s.playlist.snap = crate::snap::EditorSnap::Grid(Snap::Bar);
let g = PGeom::new(s, Rect::new(0.0, 0.0, 1000.0, 500.0));
assert_eq!(snap_place(s, &g, 200.0, false), 144);
assert_eq!(snap_place(s, &g, 230.0, false), 288);
assert_eq!(snap_down(s, &g, 300.0, false), 288);
s.playlist.snap = crate::snap::EditorSnap::Grid(Snap::Step);
assert_eq!(snap_place(s, &g, 300.0, false), 288 + 24 * 1, "a step counted from the bar's start (12 ticks off is half a step)");
// 4/4 again, a 3/4 marker at bar 3 (tick 768): its bars are 288 ticks and count on from the 3rd.
s.project.bar = None;
add_marker(s, sig(768, 3, 4));
assert_eq!(lens(s, 1400), [(0, 0, 384), (1, 384, 384), (2, 768, 288), (3, 1056, 288), (4, 1344, 288)]);
let b = Bars::new(s);
assert_eq!((b.floor(800), b.index(1100), b.index(100)), (768, 3, 0));
s.playlist.snap = crate::snap::EditorSnap::Grid(Snap::Bar);
assert_eq!(snap_place(s, &g, 1000.0, false), 1056);
// A marker inside a bar cuts it short and starts new bars there; one at 0 sets the first bar.
s.project.arrangement_mut().unwrap().markers.clear();
add_marker(s, sig(1000, 3, 4));
assert_eq!(lens(s, 1290), [(0, 0, 384), (1, 384, 384), (2, 768, 232), (3, 1000, 288), (4, 1288, 288)]);
s.project.arrangement_mut().unwrap().markers.clear();
add_marker(s, sig(0, 5, 8));
assert_eq!(lens(s, 500)[0], (0, 0, 240));
}

/// Rename... on an audio or automation clip renames its channel in the rack's name box; a pattern clip still edits
/// its pattern's name in place.
#[test]
Expand Down
Loading
Loading