From 4c238f37265ccab948f74ce012c789991617f22e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 12:19:36 +0000 Subject: [PATCH 1/8] =?UTF-8?q?test(core):=20red=20=E2=80=94=20slice::extr?= =?UTF-8?q?act=5Fbars=20for=20phrase-split=20keystone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit griff split (feature #2a) slices a track into one chunk per phrase; each chunk must be a standalone, independently-measurable score over a contiguous run of bars. Pins extract_bars(score, bars): whole bars re-indexed from 0, ticks rebased to 0, notes outside the span dropped by onset, out-of-range end clamped. References griff_core::slice::extract_bars, which does not exist yet — fails to compile until the green step. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TTUbGjzD8ysnVnCJnZJE95 --- core/tests/slice_extract.rs | 119 ++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 core/tests/slice_extract.rs diff --git a/core/tests/slice_extract.rs b/core/tests/slice_extract.rs new file mode 100644 index 00000000..ce35d6a3 --- /dev/null +++ b/core/tests/slice_extract.rs @@ -0,0 +1,119 @@ +//! 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 +)] + +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)]); +} From 3486980f5be511cd02aadc0869133cd51f305065 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 12:24:02 +0000 Subject: [PATCH 2/8] =?UTF-8?q?feat(core):=20green=20=E2=80=94=20slice::ex?= =?UTF-8?q?tract=5Fbars=20extracts=20a=20bar=20run=20as=20a=20Score?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extract_bars(score, bars) returns a standalone, independently-measurable score over a contiguous run of bars: bars re-indexed from 0, all ticks (bars, notes, rests, technique spans) rebased to 0, notes/rests outside the span dropped by onset, spans clamped to it. bars.end clamps to the bar count; an empty or reversed range yields no bars while preserving the track/voice skeleton. The keystone for `griff split` (one chunk per phrase). 2/2 slice_extract tests pass; clippy -D warnings (incl. nursery) clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TTUbGjzD8ysnVnCJnZJE95 --- core/src/slice.rs | 130 +++++++++++++++++++++++++++++++++++- core/tests/slice_extract.rs | 4 +- 2 files changed, 132 insertions(+), 2 deletions(-) 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/tests/slice_extract.rs b/core/tests/slice_extract.rs index ce35d6a3..bf5590db 100644 --- a/core/tests/slice_extract.rs +++ b/core/tests/slice_extract.rs @@ -11,7 +11,9 @@ clippy::unwrap_used, clippy::panic, clippy::missing_assert_message, - clippy::indexing_slicing + clippy::indexing_slicing, + clippy::arithmetic_side_effects, + clippy::missing_const_for_fn )] use griff_core::event::{NoteMarks, Pitch, Tempo, Ticks, TimeSignature, Tuning, Velocity}; From 3c8e8d62c4e36631d366be4035d30986ef6c3347 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 12:27:30 +0000 Subject: [PATCH 3/8] =?UTF-8?q?feat(core):=20split::bar=5Fsegments=20?= =?UTF-8?q?=E2=80=94=20phrase-boundary=20ticks=20to=20bar=20ranges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bar_segments(master_bars, cut_ticks) partitions a track's bars into contiguous, non-overlapping ranges cut at the phrase-boundary onsets — each snapped to its containing bar, with start-of-track and same-bar cuts collapsing. Paired with slice::extract_bars it yields one standalone score per phrase: the segmentation `griff split` (and the web split in #2b) reuse. 4/4 tests; clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TTUbGjzD8ysnVnCJnZJE95 --- core/src/lib.rs | 1 + core/src/split.rs | 102 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 core/src/split.rs 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/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()); + } +} From a96e41139b4498a7b5b38615a3aad996dec46bf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 12:33:49 +0000 Subject: [PATCH 4/8] =?UTF-8?q?feat(cli):=20griff=20split=20=E2=80=94=20on?= =?UTF-8?q?e=20chunk=20per=20detected=20phrase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `griff split ` slices the first note-bearing track at its phrase boundaries (split::bar_segments over detect_boundaries' cuts) and writes one standalone chunk per phrase to .p.chunk.json. Each chunk is a slice::extract_bars sub-score, measured on its own bars and stamped with its source bar_range (the original bar indices it covers); the curator's tags, rights and cohort are gathered once and inherited by every phrase. phrase_chunks (the pure builder) is unit-tested: the chunks tile the bars contiguously, carry bar_range, and id-suffix per phrase. Full workspace green; clippy -D warnings (incl. nursery) clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TTUbGjzD8ysnVnCJnZJE95 --- cli/src/main.rs | 173 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 171 insertions(+), 2 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index 3976dff0..c3599d93 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -20,8 +20,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 +141,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 +195,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 +848,86 @@ 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); + 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(segments + .into_iter() + .enumerate() + .map(|(phrase, seg)| { + let (start, end) = (seg.start, seg.end); + let sub = slice::extract_bars(score, seg); + let measured = sub + .tracks + .iter() + .position(|t| primary_voice_note_count(t) > 0); + let id = format!("{}_p{phrase}", inputs.id); + let title = format!("{} (phrase {phrase})", inputs.title); + let mut meta = build_chunk_meta(&sub, path, measured, id, title, inputs, None); + meta.source.bar_range = + Some((u32::try_from(start).unwrap_or(0), u32::try_from(end).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 +1328,7 @@ enum CliError { Json(serde_json::Error), Argument(String), Ensemble(String), + Split(String), Generate(generate::GenerationError), Complement(complement::ComplementError), } @@ -1246,6 +1342,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 +1578,78 @@ mod tests { assert_eq!(primary_voice_note_count(&measurable), 1); } + #[test] + fn phrase_chunks_tile_the_bars_with_bar_range_and_ids() { + use super::{phrase_chunks, CurateInputs}; + use griff_core::corpus::{ + Acquisition, QualityFlag, RightsInfo, RightsStatus, StyleCohort, + }; + use std::path::Path; + + /// 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 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: vec![ + mbar(0, 0, 1920), + mbar(1, 1920, 3840), + mbar(2, 3840, 5760), + mbar(3, 5760, 7680), + ], + tracks: vec![track_of(vec![voice])], + source_meta: None, + loss: LossReport::new(), + }; + + let inputs = 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(), + }, + }; + + let chunks = phrase_chunks(Path::new("riff.gp5"), &score, &inputs).expect("splits"); + assert!(!chunks.is_empty(), "at least one phrase chunk"); + + // Whatever the detector decides, the chunks tile [0,4) contiguously and + // each id is 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, "segment is contiguous with the previous"); + assert!(hi > lo, "segment spans at least one bar"); + assert_eq!(chunk.id.0, format!("dgd_p{i}")); + next = hi; + } + assert_eq!(next, 4, "segments cover all four bars"); + } + #[test] fn group_relations_measure_all_pairs() { let score = one_bar_score(vec![ From a43ddc1746778ed72f25122d02d021f4a05754fc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 12:50:17 +0000 Subject: [PATCH 5/8] =?UTF-8?q?test(cli):=20red=20=E2=80=94=20inclusive=20?= =?UTF-8?q?bar=5Frange=20and=20silent-segment=20skip=20for=20split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex flagged two corpus-correctness bugs in `griff split`: - P1: source.bar_range is stored as the half-open (start, end), but SourceRef documents it as inclusive [first, last] (core/src/corpus.rs:67) and the preview split/merge math (preview/src/curation.rs) assumes inclusive — so a bar [0,4) chunk is recorded as (0,4) instead of (0,3), off by one. - P2: a phrase segment with only rests is still written as a silent, measurement-less ChunkMeta, polluting the corpus. Pins both: rewrites the tiling test for inclusive [first,last] and adds a chunks_for_segments test driving an explicit sounding + silent segment pair, asserting the silent one is dropped and the stored range ends at end-1. References super::chunks_for_segments, which does not exist yet — fails to compile until green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TTUbGjzD8ysnVnCJnZJE95 --- cli/src/main.rs | 124 ++++++++++++++++++++++++++++++------------------ 1 file changed, 79 insertions(+), 45 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index c3599d93..8f7d38af 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1578,47 +1578,34 @@ mod tests { assert_eq!(primary_voice_note_count(&measurable), 1); } - #[test] - fn phrase_chunks_tile_the_bars_with_bar_range_and_ids() { - use super::{phrase_chunks, CurateInputs}; - use griff_core::corpus::{ - Acquisition, QualityFlag, RightsInfo, RightsStatus, StyleCohort, - }; - use std::path::Path; - - /// 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(), - } + /// 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 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: vec![ - mbar(0, 0, 1920), - mbar(1, 1920, 3840), - mbar(2, 3840, 5760), - mbar(3, 5760, 7680), - ], - tracks: vec![track_of(vec![voice])], - source_meta: None, - loss: LossReport::new(), - }; + /// 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), + ] + } - let inputs = CurateInputs { + /// 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(), @@ -1632,22 +1619,69 @@ mod tests { 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, &inputs).expect("splits"); + 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 [0,4) contiguously and - // each id is suffixed by its phrase index. + // 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, "segment is contiguous with the previous"); - assert!(hi > lo, "segment spans at least one bar"); + 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; + next = hi.saturating_add(1); } - assert_eq!(next, 4, "segments cover all four bars"); + 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..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] From 8a79674de61678b6497625344fe91636f0b58788 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 12:52:09 +0000 Subject: [PATCH 6/8] =?UTF-8?q?fix(cli):=20green=20=E2=80=94=20inclusive?= =?UTF-8?q?=20bar=5Frange,=20skip=20silent=20split=20segments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chunks_for_segments now backs phrase_chunks: it stores source.bar_range as the inclusive [first, last] = (start, end-1) that SourceRef documents and the preview split/merge math expects (fixes the off-by-one, Codex P1), and drops segments whose slice has no note-bearing track instead of writing a silent, measurement-less chunk — kept chunks renumber from 0 (Codex P2). Also names the single-track intent at the gather_curate_inputs call site (CodeRabbit nitpick). Both new tests pass; full workspace green; clippy -D warnings clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TTUbGjzD8ysnVnCJnZJE95 --- cli/src/main.rs | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index 8f7d38af..8d44385c 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, }; @@ -855,6 +856,7 @@ fn cmd_split(path: &Path, output: Option<&Path>) -> Result<(), CliError> { 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) } @@ -884,24 +886,42 @@ fn phrase_chunks( return Err(CliError::Split("score has no bars to split".to_owned())); } - Ok(segments - .into_iter() - .enumerate() - .map(|(phrase, seg)| { - let (start, end) = (seg.start, seg.end); - let sub = slice::extract_bars(score, seg); + Ok(chunks_for_segments(path, score, inputs, &segments)) +} + +/// Builds one [`ChunkMeta`] per non-silent segment, renumbered from 0. +/// +/// A segment whose slice has no note-bearing track is dropped rather than +/// written as a silent, measurement-less chunk. 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, + segments: &[Range], +) -> Vec { + segments + .iter() + .filter_map(|seg| { + let sub = slice::extract_bars(score, seg.clone()); let measured = sub .tracks .iter() - .position(|t| primary_voice_note_count(t) > 0); + .position(|t| primary_voice_note_count(t) > 0)?; + Some((seg.start, seg.end, sub, measured)) + }) + .enumerate() + .map(|(phrase, (start, end, sub, measured))| { let id = format!("{}_p{phrase}", inputs.id); let title = format!("{} (phrase {phrase})", inputs.title); - let mut meta = build_chunk_meta(&sub, path, measured, id, title, inputs, None); + let mut meta = build_chunk_meta(&sub, path, Some(measured), 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(end).unwrap_or(0))); + Some((u32::try_from(start).unwrap_or(0), u32::try_from(last).unwrap_or(0))); meta }) - .collect()) + .collect() } /// Splits `score` into phrase chunks and writes each to `.p.chunk.json`. From 2d6ba2598d768f667c342edaffdafebbcf8c6ec2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 13:54:48 +0000 Subject: [PATCH 7/8] =?UTF-8?q?test(cli):=20red=20=E2=80=94=20split=20stay?= =?UTF-8?q?s=20on=20the=20detected=20track?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In a multi-track tab where the boundary-detection track is silent in a sliced bar range but a later track has notes there, chunks_for_segments re-selects that later track, so the chunk is cut on one part yet measured and provenance-stamped from another. griff split is documented as single-track chunks from the first note-bearing track. Thread the detected track index through chunks_for_segments and add a failing test asserting a segment silent on that track is dropped (treated as a phrase rest) rather than re-measured on a later track. Codex P2, PR #70. --- cli/src/main.rs | 45 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index 8d44385c..fbb97129 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -886,7 +886,7 @@ fn phrase_chunks( return Err(CliError::Split("score has no bars to split".to_owned())); } - Ok(chunks_for_segments(path, score, inputs, &segments)) + Ok(chunks_for_segments(path, score, inputs, track, &segments)) } /// Builds one [`ChunkMeta`] per non-silent segment, renumbered from 0. @@ -899,6 +899,7 @@ fn chunks_for_segments( path: &Path, score: &Score, inputs: &CurateInputs, + _track: usize, segments: &[Range], ) -> Vec { segments @@ -1694,7 +1695,7 @@ mod tests { // A sounding [0,2) segment and a silent [2,4) one. let chunks = - chunks_for_segments(Path::new("riff.gp5"), &score, &split_inputs(), &[0..2, 2..4]); + 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, @@ -1704,6 +1705,46 @@ mod tests { 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![ From 65e1751c94c19d1dc7a6c3e601cfc9c7d4233a3a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 13:57:53 +0000 Subject: [PATCH 8/8] =?UTF-8?q?fix(cli):=20green=20=E2=80=94=20split=20mea?= =?UTF-8?q?sures=20the=20detected=20track,=20drops=20its=20rests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chunks_for_segments now cuts and measures every chunk on the same track that boundary detection chose, instead of re-selecting the first note-bearing track of each slice. A segment where the detected track is silent is a phrase rest and is dropped, even when a later track has notes there — so a chunk's boundaries and measurements always describe one part, matching griff split's single-track contract. extract_bars preserves track indices, so the detected index addresses the same part in each slice. Codex P2, PR #70. --- cli/src/main.rs | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index fbb97129..548eee66 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -889,34 +889,40 @@ fn phrase_chunks( Ok(chunks_for_segments(path, score, inputs, track, &segments)) } -/// Builds one [`ChunkMeta`] per non-silent segment, renumbered from 0. +/// Builds one [`ChunkMeta`] per segment in which `track` sounds, renumbered +/// from 0. /// -/// A segment whose slice has no note-bearing track is dropped rather than -/// written as a silent, measurement-less chunk. The stored `bar_range` is -/// inclusive `[first, last]` — the half-open end minus one — matching -/// [`griff_core::corpus::SourceRef`]. +/// `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, + track: usize, segments: &[Range], ) -> Vec { segments .iter() .filter_map(|seg| { let sub = slice::extract_bars(score, seg.clone()); - let measured = sub - .tracks - .iter() - .position(|t| primary_voice_note_count(t) > 0)?; - Some((seg.start, seg.end, sub, measured)) + // 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, measured))| { + .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(measured), id, title, inputs, None); + 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)));