diff --git a/cli/src/main.rs b/cli/src/main.rs index 548eee66..1c132756 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -881,7 +881,11 @@ fn phrase_chunks( .iter() .map(|b| b.start_tick) .collect(); - let segments = split::bar_segments(&score.master_bars, &cuts); + // Cap over-long phrases so curation never sees a 30-bar blob (#76). + let segments = split::cap_segment_bars( + &split::bar_segments(&score.master_bars, &cuts), + split::MAX_PHRASE_BARS, + ); if segments.is_empty() { return Err(CliError::Split("score has no bars to split".to_owned())); } diff --git a/core/src/split.rs b/core/src/split.rs index 426cb67a..3ccf6e75 100644 --- a/core/src/split.rs +++ b/core/src/split.rs @@ -10,6 +10,14 @@ use std::ops::Range; use crate::score::MasterBar; +/// Default upper bound, in bars, on a single phrase segment. +/// +/// Segments longer than this are subdivided by [`cap_segment_bars`] so curation +/// never faces a 30-bar blob spanning several rhythmic patterns (#76). +/// Deliberately generous: it splits only clearly over-long phrases, leaving +/// ordinary 4–16-bar ones whole — a sensible default, not a tuned constant. +pub const MAX_PHRASE_BARS: usize = 16; + /// 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 @@ -40,6 +48,35 @@ pub fn bar_segments(master_bars: &[MasterBar], cut_ticks: &[u32]) -> Vec], max_bars: usize) -> Vec> { + if max_bars == 0 { + return segments.to_vec(); + } + let mut out: Vec> = Vec::with_capacity(segments.len()); + for seg in segments { + let mut start = seg.start; + // Emit full cap-sized blocks while more than a cap remains, so the final + // piece is the leftover (1..=max_bars bars), never an empty range. + while seg.end.saturating_sub(start) > max_bars { + let next = start.saturating_add(max_bars); + out.push(start..next); + start = next; + } + if start < seg.end { + out.push(start..seg.end); + } + } + out +} + /// Index of the bar whose half-open tick range contains `tick`. fn bar_containing(master_bars: &[MasterBar], tick: u32) -> Option { master_bars @@ -49,9 +86,13 @@ fn bar_containing(master_bars: &[MasterBar], tick: u32) -> Option { #[cfg(test)] mod tests { - #![allow(clippy::expect_used, clippy::arithmetic_side_effects)] + #![allow( + clippy::expect_used, + clippy::arithmetic_side_effects, + clippy::single_range_in_vec_init + )] - use super::bar_segments; + use super::{bar_segments, cap_segment_bars}; use crate::event::{Tempo, Ticks, TimeSignature}; use crate::score::{MasterBar, RepeatMarker}; use crate::slice::TickRange; @@ -99,4 +140,33 @@ mod tests { fn no_bars_yield_no_segments() { assert!(bar_segments(&[], &[1920]).is_empty()); } + + #[test] + fn cap_zero_leaves_segments_unchanged() { + assert_eq!(cap_segment_bars(&[0..30], 0), vec![0..30]); + assert_eq!(cap_segment_bars(&[0..4, 4..9], 0), vec![0..4, 4..9]); + } + + #[test] + fn segments_within_the_cap_are_kept_whole() { + assert_eq!(cap_segment_bars(&[0..16], 16), vec![0..16]); // == cap + assert_eq!(cap_segment_bars(&[0..5], 16), vec![0..5]); // < cap + } + + #[test] + fn over_long_segments_split_into_cap_blocks_plus_remainder() { + assert_eq!(cap_segment_bars(&[0..30], 16), vec![0..16, 16..30]); + // Exact multiple → even blocks, no stray empty range. + assert_eq!(cap_segment_bars(&[0..32], 16), vec![0..16, 16..32]); + // A non-zero offset is preserved as the sub-ranges advance. + assert_eq!(cap_segment_bars(&[4..40], 16), vec![4..20, 20..36, 36..40]); + } + + #[test] + fn caps_each_segment_independently() { + assert_eq!( + cap_segment_bars(&[0..4, 4..40], 16), + vec![0..4, 4..20, 20..36, 36..40] + ); + } } diff --git a/web/src/lib.rs b/web/src/lib.rs index eec0a676..7b8b1b40 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -34,7 +34,7 @@ use griff_core::score::{ AtomEvent, EventGroup, EventGroupKind, LossReport, MasterBar, RepeatMarker, Score, Track, Voice, }; use griff_core::slice::{extract_bars, TickRange}; -use griff_core::split::bar_segments; +use griff_core::split::{bar_segments, cap_segment_bars, MAX_PHRASE_BARS}; use griff_core::boundary::{self, BoundaryConfig}; use griff_core::corpus::{ @@ -791,7 +791,8 @@ fn split_to_json( .iter() .map(|b| b.start_tick) .collect(); - let segments = bar_segments(&score.master_bars, &cuts); + // Cap over-long phrases so curation never sees a 30-bar blob (#76). + let segments = cap_segment_bars(&bar_segments(&score.master_bars, &cuts), MAX_PHRASE_BARS); if segments.is_empty() { return "{\"error\":\"score has no bars to split\",\"chunks\":[]}".to_owned(); }