diff --git a/cli/src/main.rs b/cli/src/main.rs index 1c132756..9c22789e 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -916,8 +916,10 @@ fn chunks_for_segments( .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 { + // rest, not a cue to measure a different part. Trivial fragments + // (one-bar cuts, a lone note) are dropped the same way (#76). + let notes = primary_voice_note_count(sub.tracks.get(track)?); + if split::is_trivial_phrase(seg.end.saturating_sub(seg.start), notes) { return None; } Some((seg.start, seg.end, sub)) diff --git a/core/src/split.rs b/core/src/split.rs index 3ccf6e75..78935a94 100644 --- a/core/src/split.rs +++ b/core/src/split.rs @@ -18,6 +18,14 @@ use crate::score::MasterBar; /// ordinary 4–16-bar ones whole — a sensible default, not a tuned constant. pub const MAX_PHRASE_BARS: usize = 16; +/// Minimum bars a kept phrase must span; shorter segments are dropped as trivial +/// (#76) — a stray one-bar cut is not a phrase worth curating. +pub const MIN_PHRASE_BARS: usize = 2; + +/// Minimum notes a kept phrase must carry; sparser segments are dropped as +/// trivial (#76) — a lone note over silence is not a phrase. +pub const MIN_PHRASE_NOTES: usize = 2; + /// 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 @@ -77,6 +85,18 @@ pub fn cap_segment_bars(segments: &[Range], max_bars: usize) -> Vec bool { + bars < MIN_PHRASE_BARS || notes < MIN_PHRASE_NOTES +} + /// Index of the bar whose half-open tick range contains `tick`. fn bar_containing(master_bars: &[MasterBar], tick: u32) -> Option { master_bars @@ -92,7 +112,7 @@ mod tests { clippy::single_range_in_vec_init )] - use super::{bar_segments, cap_segment_bars}; + use super::{bar_segments, cap_segment_bars, is_trivial_phrase}; use crate::event::{Tempo, Ticks, TimeSignature}; use crate::score::{MasterBar, RepeatMarker}; use crate::slice::TickRange; @@ -169,4 +189,21 @@ mod tests { vec![0..4, 4..20, 20..36, 36..40] ); } + + #[test] + fn trivial_phrases_are_short_or_sparse() { + // Dropped: too few bars (a stray one-bar cut), even when note-rich. + assert!(is_trivial_phrase(1, 50)); + // Dropped: too few notes (a lone note over an otherwise empty span). + assert!(is_trivial_phrase(8, 1)); + // Dropped: fully silent is trivial on both counts. + assert!(is_trivial_phrase(4, 0)); + } + + #[test] + fn substantial_phrases_are_kept() { + // Kept: meets both thresholds (>= 2 bars and >= 2 notes) at the boundary. + assert!(!is_trivial_phrase(2, 2)); + assert!(!is_trivial_phrase(9, 47)); + } } diff --git a/web/src/lib.rs b/web/src/lib.rs index 7b8b1b40..e5e720bb 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, cap_segment_bars, MAX_PHRASE_BARS}; +use griff_core::split::{bar_segments, cap_segment_bars, is_trivial_phrase, MAX_PHRASE_BARS}; use griff_core::boundary::{self, BoundaryConfig}; use griff_core::corpus::{ @@ -692,13 +692,15 @@ fn split_segments_to_json( created_at: &str, updated_at: &str, ) -> String { - // Slice each segment; keep only those where the detected track sounds. + // Slice each segment; drop phrase rests (silent on the detected track) and + // trivial fragments (one-bar cuts, a lone note) the same way (#76). let kept: Vec<(usize, usize, Score)> = segments .iter() .filter_map(|seg| { let sub = extract_bars(score, seg.clone()); + let bars = seg.end.saturating_sub(seg.start); match sub.tracks.get(track_index) { - Some(t) if note_count(t) > 0 => Some((seg.start, seg.end, sub)), + Some(t) if !is_trivial_phrase(bars, note_count(t)) => Some((seg.start, seg.end, sub)), _ => None, } })