diff --git a/cli/src/main.rs b/cli/src/main.rs index 3976dff0..548eee66 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,6 +1,7 @@ use std::{ fmt, fs, io::{self, Error as IoError, Write as IoWrite}, + ops::Range, path::{Path, PathBuf}, process::ExitCode, }; @@ -20,8 +21,8 @@ use griff_core::{ import::{self, ImportError}, midi::{self, MidiError}, score::{AtomEvent, Score, Track, Voice}, - slice::TickRange, - structure, technique, unfold, + slice::{self, TickRange}, + split, structure, technique, unfold, }; /// griff — guitar riff engine. @@ -141,6 +142,20 @@ enum Command { ensemble: bool, }, + /// Split a MIDI or Guitar Pro file into one corpus `ChunkMeta` per detected + /// phrase: each chunk is a standalone bar-range slice carrying its own + /// measurements and `bar_range` provenance. Chunks land at + /// `.p.chunk.json`. + Split { + /// Path to the MIDI or Guitar Pro file to split. + #[arg(value_name = "FILE")] + path: PathBuf, + /// Output *stem* (default: ``): phrase chunks land at + /// `.p.chunk.json`. + #[arg(short, long, value_name = "OUTPUT")] + output: Option, + }, + /// Build a corpus manifest from a directory of curated `*.chunk.json` / /// `*.group.json` records and print a coverage summary (count toward the /// S7 ~100-phrase gate, cohort mix, rights, and review status). @@ -181,6 +196,7 @@ fn run() -> Result<(), CliError> { output, ensemble, } => cmd_curate(&path, output.as_deref(), ensemble), + Command::Split { path, output } => cmd_split(&path, output.as_deref()), Command::Manifest { dir, output } => cmd_manifest(&dir, output.as_deref()), } } @@ -833,6 +849,112 @@ fn curate_ensemble( ) } +/// Phrase-split curation: one chunk per detected phrase, each a standalone +/// bar-range slice carrying its own measurements and `bar_range` provenance. +fn cmd_split(path: &Path, output: Option<&Path>) -> Result<(), CliError> { + let data = fs::read(path)?; + let score = import::import_score_auto(&data)?; + + print_score_summary(path, &score); + // Split always curates single-track chunks, never an ensemble. + let inputs = gather_curate_inputs(false)?; + curate_phrases(path, output, &score, &inputs) +} + +/// Builds one [`ChunkMeta`] per detected phrase of the first note-bearing track: +/// phrase boundaries cut the bars into segments, each segment is sliced into a +/// standalone score, measured, and stamped with its source `bar_range` (the +/// original bar indices it covers). +fn phrase_chunks( + path: &Path, + score: &Score, + inputs: &CurateInputs, +) -> Result, CliError> { + let track = score + .tracks + .iter() + .position(|t| primary_voice_note_count(t) > 0) + .ok_or_else(|| { + CliError::Split("split needs a track with notes in its primary voice".to_owned()) + })?; + let cuts: Vec = detect_boundaries(score, track) + .iter() + .map(|b| b.start_tick) + .collect(); + let segments = split::bar_segments(&score.master_bars, &cuts); + if segments.is_empty() { + return Err(CliError::Split("score has no bars to split".to_owned())); + } + + Ok(chunks_for_segments(path, score, inputs, track, &segments)) +} + +/// Builds one [`ChunkMeta`] per segment in which `track` sounds, renumbered +/// from 0. +/// +/// `track` is the part chosen for boundary detection; every chunk is cut *and* +/// measured on that same part so its boundaries and measurements describe one +/// voice (`griff split` is single-track). A segment where `track` is silent is +/// a rest in the phrase: it is dropped rather than re-measured on a later part +/// that happens to have notes there, or written as a measurement-less chunk. +/// `extract_bars` preserves track indices, so `track` addresses the same part +/// in each slice. The stored `bar_range` is inclusive `[first, last]` — the +/// half-open end minus one — matching [`griff_core::corpus::SourceRef`]. +fn chunks_for_segments( + path: &Path, + score: &Score, + inputs: &CurateInputs, + track: usize, + segments: &[Range], +) -> Vec { + segments + .iter() + .filter_map(|seg| { + let sub = slice::extract_bars(score, seg.clone()); + // Stay on the detected track: a segment silent there is a phrase + // rest, not a cue to measure a different part. + if primary_voice_note_count(sub.tracks.get(track)?) == 0 { + return None; + } + Some((seg.start, seg.end, sub)) + }) + .enumerate() + .map(|(phrase, (start, end, sub))| { + let id = format!("{}_p{phrase}", inputs.id); + let title = format!("{} (phrase {phrase})", inputs.title); + let mut meta = build_chunk_meta(&sub, path, Some(track), id, title, inputs, None); + let last = end.saturating_sub(1); + meta.source.bar_range = + Some((u32::try_from(start).unwrap_or(0), u32::try_from(last).unwrap_or(0))); + meta + }) + .collect() +} + +/// Splits `score` into phrase chunks and writes each to `.p.chunk.json`. +fn curate_phrases( + path: &Path, + output: Option<&Path>, + score: &Score, + inputs: &CurateInputs, +) -> Result<(), CliError> { + let chunks = phrase_chunks(path, score, inputs)?; + let stem = output.map_or_else(|| path.with_extension(""), Path::to_path_buf); + + for (phrase, meta) in chunks.iter().enumerate() { + let (lo, hi) = meta.source.bar_range.unwrap_or((0, 0)); + println!("phrase {phrase} (bars {lo}..{hi}):"); + print_measurements(meta); + let json = serde_json::to_string_pretty(meta).map_err(CliError::Json)?; + write_output( + &PathBuf::from(format!("{}.p{phrase}.chunk.json", stem.display())), + &json, + )?; + } + println!("split into {} phrase chunk(s)", chunks.len()); + Ok(()) +} + /// Measured pairwise relation axes over the group's parts, ordered by /// `(a, b)` part indices (axes read *b relative to a*). /// @@ -1233,6 +1355,7 @@ enum CliError { Json(serde_json::Error), Argument(String), Ensemble(String), + Split(String), Generate(generate::GenerationError), Complement(complement::ComplementError), } @@ -1246,6 +1369,7 @@ impl fmt::Display for CliError { Self::Json(e) => write!(f, "JSON error: {e}"), Self::Argument(msg) => write!(f, "argument error: {msg}"), Self::Ensemble(msg) => write!(f, "ensemble error: {msg}"), + Self::Split(msg) => write!(f, "split error: {msg}"), Self::Generate(e) => write!(f, "generation error: {e:?}"), Self::Complement(e) => write!(f, "complement error: {e:?}"), } @@ -1481,6 +1605,152 @@ mod tests { assert_eq!(primary_voice_note_count(&measurable), 1); } + /// One 4/4 bar with explicit bounds (no arithmetic in the fixture). + fn mbar(index: usize, start: u32, end: u32) -> MasterBar { + MasterBar { + index, + tick_range: TickRange::new(Ticks(start), Ticks(end)).expect("ordered"), + time_signature: TimeSignature { + numerator: 4, + denominator: 4, + }, + tempo: Tempo::new(120.0).expect("bpm"), + repeat: RepeatMarker::default(), + } + } + + /// Four contiguous 4/4 bars spanning ticks 0..7680. + fn split_master_bars() -> Vec { + vec![ + mbar(0, 0, 1920), + mbar(1, 1920, 3840), + mbar(2, 3840, 5760), + mbar(3, 5760, 7680), + ] + } + + /// Single-track curation inputs with id `dgd`. + fn split_inputs() -> super::CurateInputs { + use griff_core::corpus::{Acquisition, QualityFlag, RightsInfo, RightsStatus, StyleCohort}; + super::CurateInputs { + id: "dgd".to_owned(), + title: "Riff".to_owned(), + tuning: "standard_e".to_owned(), + style_cohort: StyleCohort::Core, + tags: Vec::new(), + quality_flags: vec![QualityFlag::Clean], + reviewer: None, + rights: RightsInfo { + rights_status: RightsStatus::CopyrightedComposition, + acquisition: Acquisition::CommunityTabSite, + redistributable: false, + notes: String::new(), + }, + } + } + + #[test] + fn phrase_chunks_tile_the_bars_with_inclusive_bar_range_and_ids() { + use super::phrase_chunks; + use std::path::Path; + + // Four bars, a note on each downbeat. + let voice = voice_of( + 0, + vec![quarter(0, 60), quarter(1920, 62), quarter(3840, 64), quarter(5760, 65)], + ); + let score = Score { + ticks_per_quarter: 480, + master_bars: split_master_bars(), + tracks: vec![track_of(vec![voice])], + source_meta: None, + loss: LossReport::new(), + }; + + let chunks = + phrase_chunks(Path::new("riff.gp5"), &score, &split_inputs()).expect("splits"); + assert!(!chunks.is_empty(), "at least one phrase chunk"); + + // Whatever the detector decides, the chunks tile the four bars with + // inclusive `[first, last]` ranges, each id suffixed by its phrase index. + let mut next = 0_u32; + for (i, chunk) in chunks.iter().enumerate() { + let (lo, hi) = chunk.source.bar_range.expect("bar_range set"); + assert_eq!(lo, next, "first bar follows the previous chunk's last + 1"); + assert!(hi >= lo, "inclusive last bar is at least the first"); + assert_eq!(chunk.id.0, format!("dgd_p{i}")); + next = hi.saturating_add(1); + } + assert_eq!(next, 4, "inclusive ranges cover all four bars"); + } + + #[test] + fn chunks_for_segments_skips_silent_and_uses_inclusive_bar_range() { + use super::chunks_for_segments; + use std::path::Path; + + // Notes only in bars 0–1; bars 2–3 are silent. + let voice = voice_of(0, vec![quarter(0, 60), quarter(1920, 62)]); + let score = Score { + ticks_per_quarter: 480, + master_bars: split_master_bars(), + tracks: vec![track_of(vec![voice])], + source_meta: None, + loss: LossReport::new(), + }; + + // A sounding [0,2) segment and a silent [2,4) one. + let chunks = + chunks_for_segments(Path::new("riff.gp5"), &score, &split_inputs(), 0, &[0..2, 2..4]); + assert_eq!(chunks.len(), 1, "the silent [2,4) segment is dropped"); + assert_eq!( + chunks[0].source.bar_range, + Some((0, 1)), + "inclusive last bar is end-1, not the half-open end" + ); + assert_eq!(chunks[0].id.0, "dgd_p0", "kept chunks renumber from 0"); + } + + #[test] + fn chunks_for_segments_stays_on_the_detected_track() { + use super::chunks_for_segments; + use std::path::Path; + + // Track 0 — the boundary-detection track — sounds only in bars 0–1; a + // second track sounds only in bars 2–3. `griff split` cuts single-track + // chunks from the detected track, so the [2,4) segment, silent on that + // track, is a rest in this phrase and must be dropped rather than + // re-measured on the later track that happens to have notes there. + let detected = track_of(vec![voice_of(0, vec![quarter(0, 60), quarter(1920, 62)])]); + let other = track_of(vec![voice_of(0, vec![quarter(3840, 48), quarter(5760, 50)])]); + let score = Score { + ticks_per_quarter: 480, + master_bars: split_master_bars(), + tracks: vec![detected, other], + source_meta: None, + loss: LossReport::new(), + }; + + let chunks = chunks_for_segments( + Path::new("riff.gp5"), + &score, + &split_inputs(), + 0, + &[0..2, 2..4], + ); + assert_eq!( + chunks.len(), + 1, + "the [2,4) segment is silent on the detected track and is dropped, \ + even though a later track has notes there" + ); + assert_eq!( + chunks[0].source.bar_range, + Some((0, 1)), + "the kept chunk is the detected track's sounding bars 0–1" + ); + } + #[test] fn group_relations_measure_all_pairs() { let score = one_bar_score(vec![ diff --git a/core/src/lib.rs b/core/src/lib.rs index a1f22679..504ebcd0 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -24,6 +24,7 @@ pub mod score; pub mod scoring; pub mod similarity; pub mod slice; +pub mod split; pub mod structure; pub mod technique; pub mod unfold; diff --git a/core/src/slice.rs b/core/src/slice.rs index e9273938..16c1a0f4 100644 --- a/core/src/slice.rs +++ b/core/src/slice.rs @@ -1,6 +1,13 @@ -//! Half-open tick-range primitive shared across the canonical model. +//! Half-open tick-range primitive and bar-range extraction over the canonical +//! model. + +use std::ops::Range; use crate::event::{Ticks, ValidationError}; +use crate::score::{ + AtomEvent, AtomNote, AtomRest, EventGroup, LossReport, MasterBar, Score, TechniqueSpan, Track, + Voice, +}; /// Half-open tick range: `start <= tick < end`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -42,6 +49,127 @@ impl TickRange { } } +/// Extracts a contiguous run of bars `bars` as a standalone [`Score`]. +/// +/// The selected bars are re-indexed from 0 and every tick — bars, notes, rests, +/// technique spans — is rebased so the slice starts at tick 0; notes and rests +/// outside the span are dropped by onset, and spans are clamped to it. `bars.end` +/// is clamped to the bar count, and an empty or reversed range yields a score +/// with no bars (tracks and voices are preserved, their event groups empty). The +/// result is independently measurable — the cut `griff split` makes per phrase. +#[must_use] +pub fn extract_bars(score: &Score, bars: Range) -> Score { + let count = score.master_bars.len(); + let lo = bars.start.min(count); + let hi = bars.end.clamp(lo, count); + let selected = score.master_bars.get(lo..hi).unwrap_or_default(); + + let offset = selected.first().map_or(0, |b| b.tick_range.start.0); + let seg_end = selected.last().map_or(offset, |b| b.tick_range.end.0); + + let master_bars = selected + .iter() + .enumerate() + .map(|(i, b)| MasterBar { + index: i, + tick_range: rebased_range(b.tick_range, offset), + time_signature: b.time_signature, + tempo: b.tempo, + repeat: b.repeat, + }) + .collect(); + + let tracks = score + .tracks + .iter() + .map(|t| Track { + name: t.name.clone(), + channel: t.channel, + tuning: t.tuning.clone(), + voices: t + .voices + .iter() + .map(|v| Voice { + id: v.id, + event_groups: v + .event_groups + .iter() + .filter_map(|g| sliced_group(g, offset, seg_end)) + .collect(), + }) + .collect(), + }) + .collect(); + + Score { + ticks_per_quarter: score.ticks_per_quarter, + master_bars, + tracks, + source_meta: score.source_meta.clone(), + loss: LossReport::new(), + } +} + +/// Shifts a range down by `offset`, saturating at zero. +const fn rebased_range(range: TickRange, offset: u32) -> TickRange { + TickRange { + start: Ticks(range.start.0.saturating_sub(offset)), + end: Ticks(range.end.0.saturating_sub(offset)), + } +} + +/// Keeps a group's atoms whose onset falls in `[seg_start, seg_end)` and the +/// spans that overlap it, all rebased to the slice. `None` when nothing remains. +fn sliced_group(group: &EventGroup, seg_start: u32, seg_end: u32) -> Option { + let atoms: Vec = group + .atoms + .iter() + .filter(|a| { + let onset = a.absolute_start().0; + onset >= seg_start && onset < seg_end + }) + .map(|a| rebased_atom(*a, seg_start)) + .collect(); + if atoms.is_empty() { + return None; + } + let technique_spans = group + .technique_spans + .iter() + .filter(|s| s.tick_range.start.0 < seg_end && s.tick_range.end.0 > seg_start) + .map(|s| TechniqueSpan { + technique: s.technique, + tick_range: rebased_range( + TickRange { + start: Ticks(s.tick_range.start.0.max(seg_start)), + end: Ticks(s.tick_range.end.0.min(seg_end)), + }, + seg_start, + ), + evidence: s.evidence, + }) + .collect(); + Some(EventGroup { + kind: group.kind, + atoms, + technique_spans, + }) +} + +/// Rebases a single atom's onset down by `offset`, saturating at zero. +const fn rebased_atom(atom: AtomEvent, offset: u32) -> AtomEvent { + match atom { + AtomEvent::Note(n) => AtomEvent::Note(AtomNote { + absolute_start: Ticks(n.absolute_start.0.saturating_sub(offset)), + ..n + }), + AtomEvent::Rest(r) => AtomEvent::Rest(AtomRest { + absolute_start: Ticks(r.absolute_start.0.saturating_sub(offset)), + ..r + }), + } +} + #[cfg(test)] mod tests { use super::TickRange; diff --git a/core/src/split.rs b/core/src/split.rs new file mode 100644 index 00000000..426cb67a --- /dev/null +++ b/core/src/split.rs @@ -0,0 +1,102 @@ +//! Cutting a track's bars into phrase-aligned segments for `griff split`. +//! +//! [`bar_segments`] turns phrase-boundary onset ticks into contiguous, +//! non-overlapping bar ranges covering the whole track — one per phrase. Paired +//! with [`crate::slice::extract_bars`] it yields one standalone, measurable score +//! per phrase, which curation writes as one chunk each. + +use std::collections::BTreeSet; +use std::ops::Range; + +use crate::score::MasterBar; + +/// Partitions `master_bars` into contiguous bar ranges cut at `cut_ticks`. +/// +/// Each cut tick is snapped to the bar that contains it; a cut at bar 0 (or the +/// track start) is a no-op, since the first segment always starts at bar 0. +/// Cuts in the same bar collapse to one. The returned ranges are sorted, +/// non-overlapping, and cover every bar, so the segments reassemble the whole +/// track. Empty when there are no bars. +#[must_use] +pub fn bar_segments(master_bars: &[MasterBar], cut_ticks: &[u32]) -> Vec> { + let bar_count = master_bars.len(); + if bar_count == 0 { + return Vec::new(); + } + let mut cuts: BTreeSet = BTreeSet::new(); + cuts.insert(0); + cuts.insert(bar_count); + for &tick in cut_ticks { + match bar_containing(master_bars, tick) { + Some(idx) if idx > 0 => { + cuts.insert(idx); + } + _ => {} + } + } + cuts.iter() + .zip(cuts.iter().skip(1)) + .map(|(&start, &end)| start..end) + .collect() +} + +/// Index of the bar whose half-open tick range contains `tick`. +fn bar_containing(master_bars: &[MasterBar], tick: u32) -> Option { + master_bars + .iter() + .position(|b| tick >= b.tick_range.start.0 && tick < b.tick_range.end.0) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, clippy::arithmetic_side_effects)] + + use super::bar_segments; + use crate::event::{Tempo, Ticks, TimeSignature}; + use crate::score::{MasterBar, RepeatMarker}; + use crate::slice::TickRange; + + /// `n` consecutive 4/4 bars of 1920 ticks each. + fn bars(n: usize) -> Vec { + let mut out = Vec::new(); + let mut start = 0_u32; + for index in 0..n { + out.push(MasterBar { + index, + tick_range: TickRange::new(Ticks(start), Ticks(start + 1920)).expect("ordered"), + time_signature: TimeSignature { + numerator: 4, + denominator: 4, + }, + tempo: Tempo::new(120.0).expect("bpm"), + repeat: RepeatMarker::default(), + }); + start += 1920; + } + out + } + + #[test] + fn no_cuts_yield_one_whole_track_segment() { + assert_eq!(bar_segments(&bars(4), &[]), vec![0..4]); + } + + #[test] + fn cuts_snap_to_their_containing_bar() { + // 3840 is bar 2's downbeat; 1920 is bar 1's, 5760 is bar 3's. + assert_eq!(bar_segments(&bars(4), &[3840]), vec![0..2, 2..4]); + assert_eq!(bar_segments(&bars(4), &[1920, 5760]), vec![0..1, 1..3, 3..4]); + } + + #[test] + fn start_cut_is_a_noop_and_same_bar_cuts_collapse() { + assert_eq!(bar_segments(&bars(4), &[0]), vec![0..4]); + // 3840 and 3850 share bar 2 → a single cut. + assert_eq!(bar_segments(&bars(4), &[3840, 3850]), vec![0..2, 2..4]); + } + + #[test] + fn no_bars_yield_no_segments() { + assert!(bar_segments(&[], &[1920]).is_empty()); + } +} diff --git a/core/tests/slice_extract.rs b/core/tests/slice_extract.rs new file mode 100644 index 00000000..bf5590db --- /dev/null +++ b/core/tests/slice_extract.rs @@ -0,0 +1,121 @@ +//! Red → tests for `slice::extract_bars` (auto-split keystone, feature #2a). +//! +//! `griff split` slices a track into one chunk per phrase; each chunk is a +//! standalone, independently-measurable score over a contiguous run of bars. +//! `extract_bars` is that cut: whole bars re-indexed from 0, ticks rebased to 0, +//! notes outside the span dropped by onset. References API that does not exist +//! yet, so the suite fails to compile until the green step. + +#![allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::panic, + clippy::missing_assert_message, + clippy::indexing_slicing, + clippy::arithmetic_side_effects, + clippy::missing_const_for_fn +)] + +use griff_core::event::{NoteMarks, Pitch, Tempo, Ticks, TimeSignature, Tuning, Velocity}; +use griff_core::score::{ + AtomEvent, AtomNote, EventGroup, EventGroupKind, LossReport, MasterBar, RepeatMarker, Score, + Track, Voice, +}; +use griff_core::slice::{extract_bars, TickRange}; + +const BAR: u32 = 1920; // 4/4 at 480 ppqn + +fn note(start: u32, pitch: u8) -> AtomEvent { + AtomEvent::Note(AtomNote { + absolute_start: Ticks(start), + duration: Ticks(480), + pitch: Pitch(pitch), + velocity: Velocity(90), + marks: NoteMarks::empty(), + position: None, + }) +} + +fn bar(index: usize, start: u32) -> MasterBar { + MasterBar { + index, + tick_range: TickRange::new(Ticks(start), Ticks(start + BAR)).expect("ordered"), + time_signature: TimeSignature { + numerator: 4, + denominator: 4, + }, + tempo: Tempo::new(120.0).expect("bpm"), + repeat: RepeatMarker::default(), + } +} + +/// Four bars, one note on each downbeat (pitch 60 + bar index). +fn four_bar_score() -> Score { + let notes = [note(0, 60), note(BAR, 61), note(2 * BAR, 62), note(3 * BAR, 63)]; + Score { + ticks_per_quarter: 480, + master_bars: vec![bar(0, 0), bar(1, BAR), bar(2, 2 * BAR), bar(3, 3 * BAR)], + tracks: vec![Track { + name: Some("g".to_owned()), + channel: 0, + voices: vec![Voice { + id: 0, + event_groups: notes + .into_iter() + .map(|a| EventGroup { + kind: EventGroupKind::Single, + atoms: vec![a], + technique_spans: Vec::new(), + }) + .collect(), + }], + tuning: Tuning::standard_e(), + }], + source_meta: None, + loss: LossReport::new(), + } +} + +fn onsets(score: &Score) -> Vec<(u32, u8)> { + score.tracks[0] + .voices[0] + .event_groups + .iter() + .flat_map(|g| &g.atoms) + .filter_map(|a| match a { + AtomEvent::Note(n) => Some((n.absolute_start.0, n.pitch.0)), + AtomEvent::Rest(_) => None, + }) + .collect() +} + +#[test] +fn extracts_a_contiguous_bar_run_reindexed_and_rebased() { + let sub = extract_bars(&four_bar_score(), 1..3); + + // Two whole bars, re-indexed from 0 and rebased to tick 0. + assert_eq!(sub.master_bars.len(), 2); + assert_eq!((sub.master_bars[0].index, sub.master_bars[1].index), (0, 1)); + assert_eq!(sub.master_bars[0].tick_range.start.0, 0); + assert_eq!(sub.master_bars[0].tick_range.end.0, BAR); + assert_eq!(sub.master_bars[1].tick_range.start.0, BAR); + assert_eq!(sub.master_bars[1].tick_range.end.0, 2 * BAR); + assert_eq!(sub.ticks_per_quarter, 480); + assert_eq!(sub.tracks[0].tuning, Tuning::standard_e()); + + // Only the bar-1 and bar-2 notes survive, rebased; bars 0 and 3 dropped. + assert_eq!(onsets(&sub), vec![(0, 61), (BAR, 62)]); +} + +#[test] +fn empty_range_yields_no_bars_and_out_of_range_end_clamps() { + // Empty selection → no bars (and no notes). + let empty = extract_bars(&four_bar_score(), 2..2); + assert_eq!(empty.master_bars.len(), 0); + assert!(onsets(&empty).is_empty()); + + // End past the last bar clamps to what exists (all four bars here). + let all = extract_bars(&four_bar_score(), 0..9); + assert_eq!(all.master_bars.len(), 4); + assert_eq!(onsets(&all), vec![(0, 60), (BAR, 61), (2 * BAR, 62), (3 * BAR, 63)]); +}