Skip to main content

acorde_core/model/
score.rs

1use super::{
2    duration::Duration,
3    notation::{
4        Articulation, Barline, BeamState, ChordDefinition, ChordSymbol, Clef, CrossStaff, Dynamic,
5        FiguredBassFigure, GuitarTechnique, HairpinKind, KeySignature, Lyric, NoteHead, OttavaKind,
6        StyledText, TabPosition, TablatureConfig, TimeSignature, TupletInfo,
7    },
8    pitch::Pitch,
9};
10use crate::Error;
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ScoreMetadata {
16    pub title: String,
17    pub composer: String,
18    pub lyricist: String,
19    pub copyright: String,
20    pub work_number: String,
21    pub movement_title: String,
22}
23
24impl Default for ScoreMetadata {
25    fn default() -> Self {
26        Self {
27            title: "Untitled Score".to_string(),
28            composer: String::new(),
29            lyricist: String::new(),
30            copyright: String::new(),
31            work_number: String::new(),
32            movement_title: String::new(),
33        }
34    }
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct ScoreSettings {
39    pub tempo_bpm: u16,
40    pub time_signature: TimeSignature,
41    pub key_signature: KeySignature,
42}
43
44impl Default for ScoreSettings {
45    fn default() -> Self {
46        Self {
47            tempo_bpm: 120,
48            time_signature: TimeSignature::default(),
49            key_signature: KeySignature::default(),
50        }
51    }
52}
53
54/// Visual connector symbol for a group of adjacent parts.
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56pub enum PartGroupSymbol {
57    Bracket, // square bracket — orchestral strings, woodwinds
58    Brace,   // curly brace — piano grand staff
59    Line,    // thin vertical line
60}
61
62/// Groups a range of adjacent parts with a bracket or brace for rendering.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct PartGroup {
65    /// Index of the first part in the group (inclusive).
66    pub first_part: usize,
67    /// Index of the last part in the group (inclusive).
68    pub last_part: usize,
69    pub symbol: PartGroupSymbol,
70    /// Whether barlines are connected across all staves in the group.
71    #[serde(default)]
72    pub barlines_connect: bool,
73}
74
75/// Groups a range of adjacent staves within one part.
76///
77/// This is distinct from [`PartGroup`], which groups separate parts.  The
78/// distinction matters for MEI and MuseScore sources where a piano-like part
79/// can contain several staves and nested staff groups.
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81pub struct StaffGroup {
82    /// Index of the first staff in the group (inclusive).
83    pub first_staff: usize,
84    /// Index of the last staff in the group (inclusive).
85    pub last_staff: usize,
86    pub symbol: PartGroupSymbol,
87    /// Whether barlines are connected across all staves in the group.
88    #[serde(default)]
89    pub barlines_connect: bool,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct Score {
94    pub id: String,
95    /// JSON schema version. 0 when deserialized from files that predate this field.
96    #[serde(default)]
97    pub schema_version: u32,
98    pub metadata: ScoreMetadata,
99    pub settings: ScoreSettings,
100    pub parts: Vec<Part>,
101    #[serde(default)]
102    pub part_groups: Vec<PartGroup>,
103    /// Typed score-level text annotations retained independently of legacy text fields.
104    #[serde(default)]
105    pub texts: Vec<StyledText>,
106    /// Reusable chord/tablature definitions imported from interchange formats.
107    #[serde(default)]
108    pub chord_definitions: Vec<ChordDefinition>,
109}
110
111impl Default for Score {
112    fn default() -> Self {
113        let mut part = Part::new("Piano", "Pno.");
114        part.staves.push(Staff::new(Clef::Treble));
115        for _ in 0..4 {
116            part.staves[0].measures.push(Measure::empty(4, 4));
117        }
118        Self {
119            id: Uuid::new_v4().to_string(),
120            schema_version: 1,
121            metadata: ScoreMetadata::default(),
122            settings: ScoreSettings::default(),
123            parts: vec![part],
124            part_groups: Vec::new(),
125            texts: Vec::new(),
126            chord_definitions: Vec::new(),
127        }
128    }
129}
130
131/// Score template presets for common ensemble configurations.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
133pub enum ScoreTemplate {
134    /// Single treble-clef part (piano by default).
135    Solo,
136    /// One piano part with treble + bass grand staff.
137    Piano,
138    /// Violin I, Violin II, Viola, Cello.
139    StringQuartet,
140    /// Violin I, Violin II, Viola, Cello, Contrabass.
141    StringOrchestra,
142    /// Two trumpets, French horn, trombone, tuba.
143    BrassQuintet,
144}
145
146impl Score {
147    pub fn new(
148        title: &str,
149        tempo_bpm: u16,
150        numerator: u8,
151        denominator: u8,
152        fifths: i8,
153        measure_count: u32,
154    ) -> Self {
155        let mut score = Score::default();
156        score.metadata.title = title.to_string();
157        score.settings.tempo_bpm = tempo_bpm;
158        score.settings.time_signature = TimeSignature {
159            numerator,
160            denominator,
161        };
162        score.settings.key_signature = KeySignature {
163            fifths,
164            mode: "major".to_string(),
165        };
166
167        score.parts[0].staves[0].measures.clear();
168        for i in 0..measure_count {
169            let mut m = Measure::empty(numerator, denominator);
170            m.number = i + 1;
171            score.parts[0].staves[0].measures.push(m);
172        }
173        score
174    }
175
176    /// Create a score pre-populated with parts for the given ensemble template.
177    ///
178    /// Defaults: 120 BPM, 4/4, C major, 4 empty measures.
179    /// Use [`NewScoreCmd`](crate::model::commands::NewScoreCmd) to override those after creation.
180    pub fn template(kind: ScoreTemplate) -> Self {
181        fn measures(num: u8, den: u8, count: u32) -> Vec<Measure> {
182            (0..count)
183                .map(|i| {
184                    let mut m = Measure::empty(num, den);
185                    m.number = i + 1;
186                    m
187                })
188                .collect()
189        }
190        fn part(name: &str, short: &str, clef: Clef, program: u8) -> Part {
191            let mut p = Part::new(name, short);
192            p.midi_program = program;
193            let mut s = Staff::new(clef);
194            s.measures = measures(4, 4, 4);
195            p.staves.push(s);
196            p
197        }
198
199        let mut score = Score {
200            id: uuid::Uuid::new_v4().to_string(),
201            schema_version: 1,
202            metadata: ScoreMetadata::default(),
203            settings: ScoreSettings::default(),
204            parts: Vec::new(),
205            part_groups: Vec::new(),
206            texts: Vec::new(),
207            chord_definitions: Vec::new(),
208        };
209
210        match kind {
211            ScoreTemplate::Solo => {
212                score.parts.push(part("Piano", "Pno.", Clef::Treble, 0));
213            }
214            ScoreTemplate::Piano => {
215                let mut p = Part::new("Piano", "Pno.");
216                p.midi_program = 0;
217                let mut treble = Staff::new(Clef::Treble);
218                treble.measures = measures(4, 4, 4);
219                let mut bass = Staff::new(Clef::Bass);
220                bass.measures = measures(4, 4, 4);
221                p.staves.push(treble);
222                p.staves.push(bass);
223                score.parts.push(p);
224            }
225            ScoreTemplate::StringQuartet => {
226                score
227                    .parts
228                    .push(part("Violin I", "Vn. I", Clef::Treble, 40));
229                score
230                    .parts
231                    .push(part("Violin II", "Vn. II", Clef::Treble, 40));
232                score.parts.push(part("Viola", "Va.", Clef::Alto, 41));
233                score.parts.push(part("Cello", "Vc.", Clef::Bass, 42));
234            }
235            ScoreTemplate::StringOrchestra => {
236                score
237                    .parts
238                    .push(part("Violin I", "Vn. I", Clef::Treble, 40));
239                score
240                    .parts
241                    .push(part("Violin II", "Vn. II", Clef::Treble, 40));
242                score.parts.push(part("Viola", "Va.", Clef::Alto, 41));
243                score.parts.push(part("Cello", "Vc.", Clef::Bass, 42));
244                score.parts.push(part("Contrabass", "Cb.", Clef::Bass, 43));
245            }
246            ScoreTemplate::BrassQuintet => {
247                score
248                    .parts
249                    .push(part("Trumpet I", "Tpt. I", Clef::Treble, 56));
250                score
251                    .parts
252                    .push(part("Trumpet II", "Tpt. II", Clef::Treble, 56));
253                score
254                    .parts
255                    .push(part("French Horn", "Hn.", Clef::Treble, 60));
256                score.parts.push(part("Trombone", "Tbn.", Clef::Bass, 57));
257                score.parts.push(part("Tuba", "Tba.", Clef::Bass, 58));
258            }
259        }
260        score
261    }
262
263    pub fn measure_count(&self) -> usize {
264        self.parts
265            .first()
266            .and_then(|p| p.staves.first())
267            .map(|s| s.measures.len())
268            .unwrap_or(0)
269    }
270
271    /// Aggregate statistics about the score.
272    pub fn statistics(&self) -> ScoreStats {
273        let measure_count = self.measure_count();
274        let part_count = self.parts.len();
275
276        // Beat accumulation via measure_sequence so repeats are counted correctly.
277        let seq = measure_sequence(self);
278        let total_beats: f64 = self
279            .parts
280            .first()
281            .and_then(|p| p.staves.first())
282            .map(|s| {
283                seq.iter()
284                    .filter_map(|&idx| s.measures.get(idx))
285                    .flat_map(|m| m.voices.iter().flat_map(|v| v.iter()))
286                    .map(|n| n.beats())
287                    .sum()
288            })
289            .unwrap_or(0.0);
290
291        let mut note_count = 0usize;
292        let mut rest_count = 0usize;
293        for part in &self.parts {
294            for staff in &part.staves {
295                for measure in &staff.measures {
296                    for voice in &measure.voices {
297                        for note in voice {
298                            if note.is_rest {
299                                rest_count += 1;
300                            } else {
301                                note_count += 1;
302                            }
303                        }
304                    }
305                }
306            }
307        }
308
309        let bpm = self.settings.tempo_bpm as f64;
310        let estimated_duration_secs = if bpm > 0.0 {
311            total_beats / bpm * 60.0
312        } else {
313            0.0
314        };
315
316        ScoreStats {
317            measure_count,
318            note_count,
319            rest_count,
320            part_count,
321            estimated_duration_secs,
322        }
323    }
324
325    /// Return a new `Score` containing only the given part.
326    /// Returns `None` if `part_index` is out of range.
327    pub fn extract_part(&self, part_index: usize) -> Option<Score> {
328        let part = self.parts.get(part_index)?.clone();
329        Some(Score {
330            id: Uuid::new_v4().to_string(),
331            schema_version: 1,
332            metadata: self.metadata.clone(),
333            settings: self.settings.clone(),
334            parts: vec![part],
335            part_groups: Vec::new(),
336            texts: self.texts.clone(),
337            chord_definitions: self.chord_definitions.clone(),
338        })
339    }
340
341    /// Merge two scores by appending `other`'s parts to `self`'s parts.
342    /// Shorter scores are padded with empty measures to match the longer one.
343    /// Metadata and settings are taken from `self`.
344    pub fn merge(&self, other: &Score) -> Score {
345        let self_count = self.measure_count();
346        let other_count = other.measure_count();
347        let max_count = self_count.max(other_count);
348        let ts = self.settings.time_signature.clone();
349
350        let pad = |mut part: Part, from: usize| -> Part {
351            for staff in &mut part.staves {
352                for i in from..max_count {
353                    let mut m = Measure::empty(ts.numerator, ts.denominator);
354                    m.number = i as u32 + 1;
355                    staff.measures.push(m);
356                }
357            }
358            part
359        };
360
361        let mut parts: Vec<Part> = self
362            .parts
363            .iter()
364            .cloned()
365            .map(|p| pad(p, self_count))
366            .collect();
367        for p in &other.parts {
368            parts.push(pad(p.clone(), other_count));
369        }
370
371        Score {
372            id: Uuid::new_v4().to_string(),
373            schema_version: 1,
374            metadata: self.metadata.clone(),
375            settings: self.settings.clone(),
376            parts,
377            part_groups: Vec::new(),
378            texts: self.texts.clone(),
379            chord_definitions: self.chord_definitions.clone(),
380        }
381    }
382}
383
384/// Aggregate statistics returned by [`Score::statistics`].
385#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
386pub struct ScoreStats {
387    pub measure_count: usize,
388    /// Number of non-rest notes across all parts.
389    pub note_count: usize,
390    pub rest_count: usize,
391    pub part_count: usize,
392    /// Rough estimate: `total_beats(first part) / tempo_bpm * 60`.
393    pub estimated_duration_secs: f64,
394}
395
396// ── transpose ─────────────────────────────────────────────────────────────────
397
398use super::pitch::Step;
399use super::repeat::measure_sequence;
400
401/// Assign deterministic guitar tablature positions to eligible notes and chords.
402///
403/// Existing positions are preserved. For each note, the lowest reachable fret is
404/// selected, breaking ties toward the highest string (the lowest string number). For
405/// chords, strings are unique and the assignment minimizes total fret, then fret
406/// span, then highest fret. This is a bounded deterministic fingering heuristic,
407/// not a claim of instrument-specific playability.
408/// The staff tuning is interpreted as open-string MIDI pitches before capo, and
409/// assignments are limited to frets 0 through 24. Rests, chords, and notes with
410/// no reachable position are left unchanged. Returns the number of notes/chords
411/// assigned.
412pub fn assign_tablature_positions(score: &mut Score) -> usize {
413    const MAX_FRET: i16 = 24;
414    let mut assigned = 0;
415
416    for part in &mut score.parts {
417        for staff in &mut part.staves {
418            let Some(tab) = &staff.tablature else {
419                continue;
420            };
421            let tuning = tab.tuning_midi.clone();
422            let lines = tab.lines as usize;
423            let capo = i16::from(tab.capo);
424
425            for measure in &mut staff.measures {
426                for voice in &mut measure.voices {
427                    for note in voice.iter_mut() {
428                        if note.is_rest
429                            || note.tab_position.is_some()
430                            || !note.tab_positions.is_empty()
431                            || note.pitches.is_empty()
432                        {
433                            continue;
434                        }
435                        let pitches: Vec<i16> = note.pitches.iter().map(Pitch::to_midi).collect();
436                        let Some(positions) =
437                            best_tablature_assignment(&pitches, &tuning, lines, capo, MAX_FRET)
438                        else {
439                            continue;
440                        };
441
442                        note.tab_position = positions.first().cloned();
443                        note.tab_positions = positions;
444                        note.string_number = note.tab_position.as_ref().map(|p| p.string);
445                        assigned += 1;
446                    }
447                }
448            }
449        }
450    }
451
452    assigned
453}
454
455/// Optimize tablature positions across each voice using bounded position movement.
456///
457/// Explicit positions are treated as fixed anchors. Unassigned notes and chords
458/// receive positions that minimize fret load plus movement from the preceding
459/// event; ties are resolved deterministically. Returns the number of notes/chords
460/// newly assigned.
461pub fn optimize_tablature_positions(score: &mut Score) -> usize {
462    const MAX_FRET: i16 = 24;
463    let mut assigned = 0;
464
465    for part in &mut score.parts {
466        for staff in &mut part.staves {
467            let Some(tab) = &staff.tablature else {
468                continue;
469            };
470            let tuning = tab.tuning_midi.clone();
471            let lines = tab.lines as usize;
472            let capo = i16::from(tab.capo);
473            for voice in 0..4 {
474                // Work per measure/voice location so repeated note indices remain distinct.
475                let mut locations = Vec::new();
476                for (measure_index, measure) in staff.measures.iter().enumerate() {
477                    for (note_index, note) in measure.voices[voice].iter().enumerate() {
478                        if !note.is_rest && !note.pitches.is_empty() {
479                            locations.push((measure_index, note_index, note.clone()));
480                        }
481                    }
482                }
483                let candidates: Vec<Vec<Vec<TabPosition>>> = locations
484                    .iter()
485                    .map(|(_, _, note)| {
486                        if let Some(positions) = if !note.tab_positions.is_empty() {
487                            Some(note.tab_positions.clone())
488                        } else {
489                            note.tab_position.clone().map(|position| vec![position])
490                        } {
491                            vec![positions]
492                        } else {
493                            tablature_assignments(
494                                &note.pitches.iter().map(Pitch::to_midi).collect::<Vec<_>>(),
495                                &tuning,
496                                lines,
497                                capo,
498                                MAX_FRET,
499                            )
500                        }
501                    })
502                    .collect();
503                if candidates.iter().any(Vec::is_empty) {
504                    continue;
505                }
506
507                let mut costs: Vec<Vec<(u32, Option<usize>)>> = candidates
508                    .iter()
509                    .map(|events| vec![(u32::MAX, None); events.len()])
510                    .collect();
511                for (candidate_index, candidate) in candidates[0].iter().enumerate() {
512                    costs[0][candidate_index] = (tablature_load(candidate), None);
513                }
514                for event_index in 1..candidates.len() {
515                    for (candidate_index, candidate) in candidates[event_index].iter().enumerate() {
516                        let load = tablature_load(candidate);
517                        for (previous_index, previous) in
518                            candidates[event_index - 1].iter().enumerate()
519                        {
520                            let previous_cost = costs[event_index - 1][previous_index].0;
521                            let cost = previous_cost
522                                .saturating_add(load)
523                                .saturating_add(tablature_movement(previous, candidate));
524                            if cost < costs[event_index][candidate_index].0 {
525                                costs[event_index][candidate_index] = (cost, Some(previous_index));
526                            }
527                        }
528                    }
529                }
530                let mut selected = vec![0; candidates.len()];
531                if let Some((last, _)) = costs.last().and_then(|row| {
532                    row.iter()
533                        .enumerate()
534                        .min_by_key(|(index, (cost, _))| (*cost, *index))
535                }) {
536                    selected[candidates.len() - 1] = last;
537                    for event_index in (1..candidates.len()).rev() {
538                        selected[event_index - 1] =
539                            costs[event_index][selected[event_index]].1.unwrap_or(0);
540                    }
541                }
542
543                for (((measure_index, note_index, original), event_candidates), selected_index) in
544                    locations.into_iter().zip(candidates).zip(selected)
545                {
546                    if original.tab_position.is_none() && original.tab_positions.is_empty() {
547                        let note = &mut staff.measures[measure_index].voices[voice][note_index];
548                        let positions = event_candidates[selected_index].clone();
549                        note.tab_position = positions.first().cloned();
550                        note.tab_positions = positions;
551                        note.string_number = note.tab_position.as_ref().map(|p| p.string);
552                        assigned += 1;
553                    }
554                }
555            }
556        }
557    }
558
559    assigned
560}
561
562fn tablature_load(positions: &[TabPosition]) -> u32 {
563    let sum: u32 = positions
564        .iter()
565        .map(|position| u32::from(position.fret))
566        .sum();
567    let min = positions
568        .iter()
569        .map(|position| position.fret)
570        .min()
571        .unwrap_or(0);
572    let max = positions
573        .iter()
574        .map(|position| position.fret)
575        .max()
576        .unwrap_or(0);
577    let span = max - min;
578    // A four-fret hand position is a practical baseline; wide chords receive
579    // a strong penalty so a higher but compact voicing wins when available.
580    let stretch_penalty = span.saturating_sub(4) as u32 * 12;
581    sum + u32::from(span) * 2 + stretch_penalty
582}
583
584fn tablature_movement(previous: &[TabPosition], current: &[TabPosition]) -> u32 {
585    previous
586        .iter()
587        .zip(current)
588        .map(|(a, b)| u32::from(a.fret.abs_diff(b.fret)) + u32::from(a.string.abs_diff(b.string)))
589        .sum()
590}
591
592fn tablature_assignments(
593    pitches: &[i16],
594    tuning: &[i16],
595    lines: usize,
596    capo: i16,
597    max_fret: i16,
598) -> Vec<Vec<TabPosition>> {
599    #[allow(clippy::too_many_arguments)]
600    fn visit(
601        pitches: &[i16],
602        tuning: &[i16],
603        lines: usize,
604        capo: i16,
605        max_fret: i16,
606        index: usize,
607        used: &mut [bool],
608        current: &mut Vec<TabPosition>,
609        output: &mut Vec<Vec<TabPosition>>,
610    ) {
611        if index == pitches.len() {
612            output.push(current.clone());
613            return;
614        }
615        for (string, open) in tuning.iter().enumerate().take(lines) {
616            if used[string] {
617                continue;
618            }
619            let fret = pitches[index] - *open - capo;
620            if !(0..=max_fret).contains(&fret) {
621                continue;
622            }
623            used[string] = true;
624            current.push(TabPosition {
625                string: (string + 1) as u8,
626                fret: fret as u8,
627            });
628            visit(
629                pitches,
630                tuning,
631                lines,
632                capo,
633                max_fret,
634                index + 1,
635                used,
636                current,
637                output,
638            );
639            current.pop();
640            used[string] = false;
641        }
642    }
643
644    if pitches.is_empty() || pitches.len() > lines {
645        return Vec::new();
646    }
647    let mut output = Vec::new();
648    visit(
649        pitches,
650        tuning,
651        lines,
652        capo,
653        max_fret,
654        0,
655        &mut vec![false; lines],
656        &mut Vec::new(),
657        &mut output,
658    );
659    output
660}
661
662fn best_tablature_assignment(
663    pitches: &[i16],
664    tuning: &[i16],
665    lines: usize,
666    capo: i16,
667    max_fret: i16,
668) -> Option<Vec<TabPosition>> {
669    type Assignment = (i16, i16, i16, Vec<u8>, Vec<TabPosition>);
670
671    #[allow(clippy::too_many_arguments)]
672    fn search(
673        pitches: &[i16],
674        tuning: &[i16],
675        lines: usize,
676        capo: i16,
677        max_fret: i16,
678        index: usize,
679        used: &mut [bool],
680        current: &mut Vec<TabPosition>,
681        best: &mut Option<Assignment>,
682    ) {
683        if index == pitches.len() {
684            let sum: i16 = current.iter().map(|p| i16::from(p.fret)).sum();
685            let min = current.iter().map(|p| p.fret).min().unwrap_or(0);
686            let max = current.iter().map(|p| p.fret).max().unwrap_or(0);
687            let strings: Vec<u8> = current.iter().map(|p| p.string).collect();
688            let candidate = (
689                sum,
690                i16::from(max) - i16::from(min),
691                i16::from(max),
692                strings,
693                current.clone(),
694            );
695            if best.as_ref().is_none_or(|existing| {
696                (candidate.0, candidate.1, candidate.2, &candidate.3)
697                    < (existing.0, existing.1, existing.2, &existing.3)
698            }) {
699                *best = Some(candidate);
700            }
701            return;
702        }
703
704        for (string, open) in tuning.iter().enumerate().take(lines) {
705            if used[string] {
706                continue;
707            }
708            let fret = pitches[index] - *open - capo;
709            if !(0..=max_fret).contains(&fret) {
710                continue;
711            }
712            used[string] = true;
713            current.push(TabPosition {
714                string: (string + 1) as u8,
715                fret: fret as u8,
716            });
717            search(
718                pitches,
719                tuning,
720                lines,
721                capo,
722                max_fret,
723                index + 1,
724                used,
725                current,
726                best,
727            );
728            current.pop();
729            used[string] = false;
730        }
731    }
732
733    if pitches.len() > lines {
734        return None;
735    }
736    let mut used = vec![false; lines];
737    let mut current = Vec::with_capacity(pitches.len());
738    let mut best = None;
739    search(
740        pitches,
741        tuning,
742        lines,
743        capo,
744        max_fret,
745        0,
746        &mut used,
747        &mut current,
748        &mut best,
749    );
750    best.map(|(_, _, _, _, positions)| positions)
751}
752
753/// Return a new `Score` with all pitches shifted by `semitones`.
754/// Key signatures (global and per-measure) are updated accordingly.
755/// If `semitones == 0` the score is cloned unchanged.
756pub fn transpose(score: &Score, semitones: i8) -> Score {
757    if semitones == 0 {
758        return score.clone();
759    }
760    let mut out = score.clone();
761    out.settings.key_signature.fifths = transpose_fifths(
762        score.settings.key_signature.fifths,
763        &score.settings.key_signature.mode,
764        semitones,
765    );
766    for part in &mut out.parts {
767        for staff in &mut part.staves {
768            for measure in &mut staff.measures {
769                if let Some(ref mut ks) = measure.key_sig {
770                    ks.fifths = transpose_fifths(ks.fifths, &ks.mode, semitones);
771                }
772                for voice in &mut measure.voices {
773                    for note in voice.iter_mut() {
774                        for pitch in note.pitches.iter_mut() {
775                            *pitch = transpose_pitch(pitch, semitones);
776                        }
777                    }
778                }
779            }
780        }
781    }
782    out
783}
784
785fn transpose_pitch(pitch: &Pitch, semitones: i8) -> Pitch {
786    let new_midi = (pitch.to_midi() + semitones as i16).clamp(0, 127) as u8;
787    let pc = new_midi % 12;
788    let oct = (new_midi / 12) as i8 - 1;
789    let (step, alter): (Step, i8) = if semitones >= 0 {
790        match pc {
791            0 => (Step::C, 0),
792            1 => (Step::C, 1),
793            2 => (Step::D, 0),
794            3 => (Step::D, 1),
795            4 => (Step::E, 0),
796            5 => (Step::F, 0),
797            6 => (Step::F, 1),
798            7 => (Step::G, 0),
799            8 => (Step::G, 1),
800            9 => (Step::A, 0),
801            10 => (Step::A, 1),
802            11 => (Step::B, 0),
803            _ => (Step::C, 0),
804        }
805    } else {
806        match pc {
807            0 => (Step::C, 0),
808            1 => (Step::D, -1),
809            2 => (Step::D, 0),
810            3 => (Step::E, -1),
811            4 => (Step::E, 0),
812            5 => (Step::F, 0),
813            6 => (Step::G, -1),
814            7 => (Step::G, 0),
815            8 => (Step::A, -1),
816            9 => (Step::A, 0),
817            10 => (Step::B, -1),
818            11 => (Step::B, 0),
819            _ => (Step::C, 0),
820        }
821    };
822    Pitch::with_microtone(step, oct, alter, pitch.microtone_cents)
823}
824
825/// Shift a key signature's fifths value by `semitones`.
826///
827/// Uses the circle-of-fifths arithmetic:
828/// - `tonic_pc = (fifths * 7) mod 12`  (for major; minor adds 9 to get relative major tonic)
829/// - `new_fifths = (new_tonic_pc * 7) mod 12`, adjusted to `[-7, 7]`
830fn transpose_fifths(fifths: i8, mode: &str, semitones: i8) -> i8 {
831    let tonic_major_pc = ((fifths as i32 * 7).rem_euclid(12)) as u8;
832    let tonic_pc = if mode == "minor" {
833        ((tonic_major_pc as i32 + 9).rem_euclid(12)) as u8
834    } else {
835        tonic_major_pc
836    };
837    let new_tonic = ((tonic_pc as i32 + semitones as i32).rem_euclid(12)) as u8;
838    let major_tonic = if mode == "minor" {
839        ((new_tonic as i32 + 3).rem_euclid(12)) as u8
840    } else {
841        new_tonic
842    };
843    let raw = ((major_tonic as i32 * 7).rem_euclid(12)) as i8;
844    if raw > 6 { raw - 12 } else { raw }
845}
846
847#[derive(Debug, Clone, Serialize, Deserialize)]
848pub struct Part {
849    pub id: String,
850    pub name: String,
851    pub short_name: String,
852    pub staves: Vec<Staff>,
853    /// MIDI channel (0–15). Channel 9 is conventionally used for percussion.
854    #[serde(default)]
855    pub midi_channel: u8,
856    /// General MIDI program number (0–127). Default 0 = Acoustic Grand Piano.
857    #[serde(default)]
858    pub midi_program: u8,
859    /// MIDI pitch-bend events preserved from interchange input, in canonical 480 PPQ ticks.
860    #[serde(default)]
861    pub midi_pitch_bends: Vec<MidiPitchBend>,
862    /// MIDI Control Change events preserved from interchange input, in canonical 480 PPQ ticks.
863    #[serde(default)]
864    pub midi_control_changes: Vec<MidiControlChange>,
865    /// MIDI Program Change events preserved from interchange input, in canonical 480 PPQ ticks.
866    #[serde(default)]
867    pub midi_program_changes: Vec<MidiProgramChange>,
868    /// MIDI key/channel aftertouch events preserved from interchange input, in canonical 480 PPQ ticks.
869    #[serde(default)]
870    pub midi_aftertouch: Vec<MidiAftertouch>,
871    /// MusicXML score-instrument definitions for note-level instrument IDs.
872    #[serde(default)]
873    pub percussion_instruments: Vec<PercussionInstrument>,
874    /// MEI/MuseScore staff-group structure within this part.
875    #[serde(default)]
876    pub staff_groups: Vec<StaffGroup>,
877}
878
879#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
880pub struct MidiPitchBend {
881    pub tick: u64,
882    pub channel: u8,
883    /// Signed 14-bit MIDI bend value in the range -8192..=8191.
884    pub value: i16,
885}
886
887#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
888pub struct MidiControlChange {
889    pub tick: u64,
890    pub channel: u8,
891    /// MIDI controller number (0–127).
892    pub controller: u8,
893    /// Seven-bit controller value (0–127).
894    pub value: u8,
895}
896
897#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
898pub struct MidiProgramChange {
899    pub tick: u64,
900    pub channel: u8,
901    /// General MIDI program number (0–127).
902    pub program: u8,
903}
904
905#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
906pub struct MidiAftertouch {
907    pub tick: u64,
908    pub channel: u8,
909    /// Key number for key pressure, or `None` for channel pressure.
910    pub key: Option<u8>,
911    /// Seven-bit pressure value (0–127).
912    pub value: u8,
913}
914
915#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
916pub struct PercussionInstrument {
917    pub id: String,
918    #[serde(default)]
919    pub name: Option<String>,
920    #[serde(default)]
921    pub midi_unpitched: Option<u8>,
922}
923
924impl Part {
925    pub fn new(name: &str, short_name: &str) -> Self {
926        Self {
927            id: Uuid::new_v4().to_string(),
928            name: name.to_string(),
929            short_name: short_name.to_string(),
930            staves: Vec::new(),
931            midi_channel: 0,
932            midi_program: 0,
933            midi_pitch_bends: Vec::new(),
934            midi_control_changes: Vec::new(),
935            midi_program_changes: Vec::new(),
936            midi_aftertouch: Vec::new(),
937            percussion_instruments: Vec::new(),
938            staff_groups: Vec::new(),
939        }
940    }
941
942    /// Resolve the declared percussion instrument for an unpitched note.
943    ///
944    /// An explicit MusicXML `instrument@id` always takes precedence.  When no
945    /// identifier is attached, the retained display-key MIDI value is matched
946    /// against the part's declared `midi_unpitched` entries.  This deliberately
947    /// does not invent a sound identity for an unpitched note with no matching
948    /// declaration.
949    pub fn percussion_instrument_for_note(&self, note: &Note) -> Option<&PercussionInstrument> {
950        if !note.is_unpitched {
951            return None;
952        }
953        if let Some(instrument_id) = note.instrument_id.as_deref() {
954            return self
955                .percussion_instruments
956                .iter()
957                .find(|instrument| instrument.id == instrument_id);
958        }
959        let midi_key = u8::try_from(note.pitches.first()?.to_midi()).ok()?;
960        self.percussion_instruments
961            .iter()
962            .find(|instrument| instrument.midi_unpitched == Some(midi_key))
963    }
964}
965
966#[derive(Debug, Clone, Serialize, Deserialize)]
967pub struct Staff {
968    pub clef: Clef,
969    pub measures: Vec<Measure>,
970    /// Semitones to add to written pitch for concert pitch / MIDI output.
971    /// -2 = Bb instrument (clarinet, trumpet), -9 = Eb instrument (alto sax), etc.
972    #[serde(default)]
973    pub transpose_semitones: i8,
974    #[serde(default)]
975    pub tablature: Option<TablatureConfig>,
976}
977
978impl Staff {
979    pub fn new(clef: Clef) -> Self {
980        Self {
981            clef,
982            measures: Vec::new(),
983            transpose_semitones: 0,
984            tablature: None,
985        }
986    }
987}
988
989#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
990pub struct VoltaBracket {
991    /// Ending number (1, 2, …)
992    pub number: u8,
993    /// "begin" | "mid" | "end" | "begin_end"
994    pub kind: String,
995}
996
997#[derive(Debug, Clone, Serialize, Deserialize)]
998pub struct Measure {
999    pub number: u32,
1000    pub time_sig: Option<TimeSignature>,
1001    pub key_sig: Option<KeySignature>,
1002    pub clef: Option<Clef>,
1003    pub tempo: Option<u16>,
1004    pub barline_left: Barline,
1005    pub barline_right: Barline,
1006    #[serde(default)]
1007    pub volta: Option<VoltaBracket>,
1008    #[serde(default)]
1009    pub tempo_text: Option<String>,
1010    #[serde(default)]
1011    pub rehearsal: Option<String>,
1012    /// Navigation mark: "Segno" | "Coda" | "Fine" | "DaCapo" | "DaCapoAlFine" |
1013    /// "DaCapoAlCoda" | "DalSegno" | "DalSegnoAlFine" | "DalSegnoAlCoda" | "ToCoda"
1014    #[serde(default)]
1015    pub navigation: Option<String>,
1016    /// Expression / performance text ("dolce", "espressivo", "con fuoco", etc.).
1017    #[serde(default)]
1018    pub expression_text: Option<String>,
1019    #[serde(default)]
1020    pub texts: Vec<StyledText>,
1021    /// Structured MusicXML figured-bass figures in source order.
1022    #[serde(default)]
1023    pub figured_bass: Vec<FiguredBassFigure>,
1024    /// When ≥ 2, this measure is displayed as a multi-measure rest spanning N measures.
1025    #[serde(default)]
1026    pub multi_rest_count: Option<u8>,
1027    /// Force a new system (row) after this measure.
1028    #[serde(default)]
1029    pub system_break: bool,
1030    /// Force a new page after this measure.
1031    #[serde(default)]
1032    pub page_break: bool,
1033    /// Up to 4 voices; voice 0 is the primary voice.
1034    pub voices: [Vec<Note>; 4],
1035}
1036
1037impl Measure {
1038    pub fn empty(numerator: u8, denominator: u8) -> Self {
1039        let total_beats = TimeSignature {
1040            numerator,
1041            denominator,
1042        }
1043        .total_beats();
1044        let mut voice0: Vec<Note> = Vec::new();
1045        let mut remaining = total_beats;
1046        while remaining > 1e-9 {
1047            let dur = Duration::whole_filling_beats(remaining);
1048            remaining -= dur.beats(0);
1049            voice0.push(Note::rest(dur));
1050        }
1051        Self {
1052            number: 0,
1053            time_sig: None,
1054            key_sig: None,
1055            clef: None,
1056            tempo: None,
1057            barline_left: Barline::Normal,
1058            barline_right: Barline::Normal,
1059            volta: None,
1060            tempo_text: None,
1061            rehearsal: None,
1062            navigation: None,
1063            expression_text: None,
1064            texts: Vec::new(),
1065            figured_bass: Vec::new(),
1066            multi_rest_count: None,
1067            system_break: false,
1068            page_break: false,
1069            voices: [voice0, vec![], vec![], vec![]],
1070        }
1071    }
1072
1073    pub fn renumber(&mut self, n: u32) {
1074        self.number = n;
1075    }
1076}
1077
1078#[derive(Debug, Clone, Serialize, Deserialize)]
1079pub struct Note {
1080    pub id: String,
1081    pub is_rest: bool,
1082    /// MusicXML unpitched note; `pitches` then stores display placement only.
1083    #[serde(default)]
1084    pub is_unpitched: bool,
1085    /// Optional source instrument identifier (for example MusicXML note-level `instrument@id`).
1086    #[serde(default)]
1087    pub instrument_id: Option<String>,
1088    /// Single note: one pitch. Chord: multiple pitches (same duration).
1089    pub pitches: Vec<Pitch>,
1090    #[serde(default)]
1091    pub tab_position: Option<super::notation::TabPosition>,
1092    /// One tablature position per pitch; the first entry mirrors `tab_position`.
1093    /// This is populated for chords so each pitch can occupy a distinct string.
1094    #[serde(default)]
1095    pub tab_positions: Vec<super::notation::TabPosition>,
1096    pub duration: Duration,
1097    pub dot_count: u8,
1098    pub tie_start: bool,
1099    pub tie_end: bool,
1100    pub beam: BeamState,
1101    pub articulations: Vec<Articulation>,
1102    pub dynamic: Option<Dynamic>,
1103    pub stem_up: Option<bool>,
1104    #[serde(default)]
1105    pub hairpin_start: Option<HairpinKind>,
1106    #[serde(default)]
1107    pub hairpin_end: bool,
1108    #[serde(default)]
1109    pub tuplet: Option<TupletInfo>,
1110    #[serde(default)]
1111    pub chord_symbol: Option<ChordSymbol>,
1112    #[serde(default)]
1113    pub is_grace: bool,
1114    /// Acciaccatura: true (slash through stem). Appoggiatura: false.
1115    #[serde(default)]
1116    pub grace_slash: bool,
1117    #[serde(default)]
1118    pub ottava_start: Option<OttavaKind>,
1119    #[serde(default)]
1120    pub ottava_end: bool,
1121    #[serde(default)]
1122    pub lyric: Option<Lyric>,
1123    #[serde(default)]
1124    pub pedal_start: bool,
1125    #[serde(default)]
1126    pub pedal_end: bool,
1127    #[serde(default)]
1128    pub slur_start: bool,
1129    #[serde(default)]
1130    pub slur_end: bool,
1131    /// Arpeggiate direction: `Some(true)` = up, `Some(false)` = down, `None` = none.
1132    #[serde(default)]
1133    pub arpeggiate: Option<bool>,
1134    /// Technique/style instruction attached to this note ("pizz.", "arco", "con sord.", etc.).
1135    #[serde(default)]
1136    pub technique_text: Option<String>,
1137    #[serde(default)]
1138    pub glissando_start: bool,
1139    #[serde(default)]
1140    pub glissando_end: bool,
1141    #[serde(default)]
1142    pub cross_staff: Option<CrossStaff>,
1143    /// Left-hand fingering number (0 = open / thumb, 1–5 = fingers).
1144    #[serde(default)]
1145    pub fingering: Option<u8>,
1146    /// Alternate left-hand fingering candidates, in source order. The first
1147    /// entry mirrors `fingering` when present.
1148    #[serde(default)]
1149    pub fingerings: Vec<u8>,
1150    /// String number for plucked/bowed string instruments (1 = highest string).
1151    #[serde(default)]
1152    pub string_number: Option<u8>,
1153    #[serde(default)]
1154    pub note_head: NoteHead,
1155    /// Cue note (small-sized, does not count toward beat total).
1156    #[serde(default)]
1157    pub is_cue: bool,
1158    /// Start of a multi-note trill line span.
1159    #[serde(default)]
1160    pub trill_line_start: bool,
1161    /// End of a multi-note trill line span.
1162    #[serde(default)]
1163    pub trill_line_end: bool,
1164    /// Guitar-specific playing technique (bend, slide, hammer-on, pull-off).
1165    #[serde(default)]
1166    pub guitar_technique: Option<GuitarTechnique>,
1167    /// MusicXML bend amount in cents when supplied by the source.
1168    #[serde(default)]
1169    pub guitar_bend_alter_cents: Option<i16>,
1170}
1171
1172impl Note {
1173    /// Select one authored fingering candidate without changing the score.
1174    pub fn select_fingering(
1175        &self,
1176        policy: super::notation::FingeringSelectionPolicy,
1177    ) -> Option<u8> {
1178        let candidates = if self.fingerings.is_empty() {
1179            self.fingering.into_iter().collect::<Vec<_>>()
1180        } else {
1181            self.fingerings.clone()
1182        };
1183        match policy {
1184            super::notation::FingeringSelectionPolicy::SourceOrder => candidates.first().copied(),
1185            super::notation::FingeringSelectionPolicy::LowestNumber => {
1186                candidates.iter().copied().min()
1187            }
1188            super::notation::FingeringSelectionPolicy::HighestNumber => {
1189                candidates.iter().copied().max()
1190            }
1191        }
1192    }
1193
1194    pub fn new(pitch: Pitch, duration: Duration) -> Self {
1195        Self {
1196            id: Uuid::new_v4().to_string(),
1197            is_rest: false,
1198            is_unpitched: false,
1199            instrument_id: None,
1200            pitches: vec![pitch],
1201            tab_position: None,
1202            tab_positions: Vec::new(),
1203            duration,
1204            dot_count: 0,
1205            tie_start: false,
1206            tie_end: false,
1207            beam: BeamState::None,
1208            articulations: Vec::new(),
1209            dynamic: None,
1210            stem_up: None,
1211            hairpin_start: None,
1212            hairpin_end: false,
1213            tuplet: None,
1214            chord_symbol: None,
1215            is_grace: false,
1216            grace_slash: false,
1217            ottava_start: None,
1218            ottava_end: false,
1219            lyric: None,
1220            pedal_start: false,
1221            pedal_end: false,
1222            slur_start: false,
1223            slur_end: false,
1224            arpeggiate: None,
1225            technique_text: None,
1226            glissando_start: false,
1227            glissando_end: false,
1228            cross_staff: None,
1229            fingering: None,
1230            fingerings: Vec::new(),
1231            string_number: None,
1232            note_head: NoteHead::Normal,
1233            is_cue: false,
1234            trill_line_start: false,
1235            trill_line_end: false,
1236            guitar_technique: None,
1237            guitar_bend_alter_cents: None,
1238        }
1239    }
1240
1241    pub fn rest(duration: Duration) -> Self {
1242        Self {
1243            id: Uuid::new_v4().to_string(),
1244            is_rest: true,
1245            is_unpitched: false,
1246            instrument_id: None,
1247            pitches: Vec::new(),
1248            tab_position: None,
1249            tab_positions: Vec::new(),
1250            duration,
1251            dot_count: 0,
1252            tie_start: false,
1253            tie_end: false,
1254            beam: BeamState::None,
1255            articulations: Vec::new(),
1256            dynamic: None,
1257            stem_up: None,
1258            hairpin_start: None,
1259            hairpin_end: false,
1260            tuplet: None,
1261            chord_symbol: None,
1262            is_grace: false,
1263            grace_slash: false,
1264            ottava_start: None,
1265            ottava_end: false,
1266            lyric: None,
1267            pedal_start: false,
1268            pedal_end: false,
1269            slur_start: false,
1270            slur_end: false,
1271            arpeggiate: None,
1272            technique_text: None,
1273            glissando_start: false,
1274            glissando_end: false,
1275            cross_staff: None,
1276            fingering: None,
1277            fingerings: Vec::new(),
1278            string_number: None,
1279            note_head: NoteHead::Normal,
1280            is_cue: false,
1281            trill_line_start: false,
1282            trill_line_end: false,
1283            guitar_technique: None,
1284            guitar_bend_alter_cents: None,
1285        }
1286    }
1287
1288    pub fn beats(&self) -> f64 {
1289        if self.is_grace || self.is_cue {
1290            return 0.0;
1291        }
1292        let base = self.duration.beats(self.dot_count);
1293        if let Some(ref t) = self.tuplet {
1294            base * (t.normal_notes as f64) / (t.actual_notes as f64)
1295        } else {
1296            base
1297        }
1298    }
1299}
1300
1301impl Duration {
1302    /// Returns the largest single duration that fills the given number of beats.
1303    pub fn whole_filling_beats(beats: f64) -> Duration {
1304        if beats >= 4.0 {
1305            Duration::Whole
1306        } else if beats >= 2.0 {
1307            Duration::Half
1308        } else if beats >= 1.0 {
1309            Duration::Quarter
1310        } else if beats >= 0.5 {
1311            Duration::Eighth
1312        } else if beats >= 0.25 {
1313            Duration::Sixteenth
1314        } else if beats >= 0.125 {
1315            Duration::ThirtySecond
1316        } else {
1317            Duration::SixtyFourth
1318        }
1319    }
1320}
1321
1322// ── NoteAddr ──────────────────────────────────────────────────────────────────
1323
1324/// Physical address of a note within a score.
1325#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1326pub struct NoteAddr {
1327    pub part: usize,
1328    pub staff: usize,
1329    pub measure: usize,
1330    pub voice: usize,
1331    pub note: usize,
1332}
1333
1334// ── diff ──────────────────────────────────────────────────────────────────────
1335
1336/// A single change between two [`Score`] values as reported by [`diff`].
1337#[derive(Debug, Clone, Serialize, Deserialize)]
1338pub enum ScoreChange {
1339    MetadataChanged {
1340        field: String,
1341        old: String,
1342        new: String,
1343    },
1344    TempoChanged {
1345        old: u16,
1346        new: u16,
1347    },
1348    KeySignatureChanged {
1349        old: KeySignature,
1350        new: KeySignature,
1351    },
1352    PartAdded {
1353        part_index: usize,
1354    },
1355    PartRemoved {
1356        part_index: usize,
1357        name: String,
1358    },
1359    NoteAdded {
1360        part: usize,
1361        staff: usize,
1362        measure: usize,
1363        voice: usize,
1364        note_index: usize,
1365    },
1366    NoteRemoved {
1367        part: usize,
1368        staff: usize,
1369        measure: usize,
1370        voice: usize,
1371        note: Box<Note>,
1372    },
1373    NoteModified {
1374        part: usize,
1375        staff: usize,
1376        measure: usize,
1377        voice: usize,
1378        note_index: usize,
1379        old: Box<Note>,
1380        new: Box<Note>,
1381    },
1382    TimeSigChanged {
1383        part: usize,
1384        staff: usize,
1385        measure: usize,
1386        old: Option<TimeSignature>,
1387        new: Option<TimeSignature>,
1388    },
1389    MeasureTempoChanged {
1390        part: usize,
1391        staff: usize,
1392        measure: usize,
1393        old: Option<u16>,
1394        new: Option<u16>,
1395    },
1396    BarlineChanged {
1397        part: usize,
1398        staff: usize,
1399        measure: usize,
1400    },
1401    RehearsalMarkChanged {
1402        part: usize,
1403        staff: usize,
1404        measure: usize,
1405        old: Option<String>,
1406        new: Option<String>,
1407    },
1408    VoltaChanged {
1409        part: usize,
1410        staff: usize,
1411        measure: usize,
1412    },
1413}
1414
1415/// Compare two scores and return a list of differences.
1416///
1417/// Parts, staves, measures, and voices are compared by position. Notes are compared by
1418/// position within each voice, ignoring their `id` field. Metadata fields are compared
1419/// individually.
1420pub fn diff(a: &Score, b: &Score) -> Vec<ScoreChange> {
1421    let mut changes: Vec<ScoreChange> = Vec::new();
1422
1423    macro_rules! meta {
1424        ($field:ident, $name:literal) => {
1425            if a.metadata.$field != b.metadata.$field {
1426                changes.push(ScoreChange::MetadataChanged {
1427                    field: $name.to_string(),
1428                    old: a.metadata.$field.clone(),
1429                    new: b.metadata.$field.clone(),
1430                });
1431            }
1432        };
1433    }
1434    meta!(title, "title");
1435    meta!(composer, "composer");
1436    meta!(lyricist, "lyricist");
1437    meta!(copyright, "copyright");
1438    meta!(work_number, "work_number");
1439    meta!(movement_title, "movement_title");
1440
1441    if a.settings.tempo_bpm != b.settings.tempo_bpm {
1442        changes.push(ScoreChange::TempoChanged {
1443            old: a.settings.tempo_bpm,
1444            new: b.settings.tempo_bpm,
1445        });
1446    }
1447    if a.settings.key_signature != b.settings.key_signature {
1448        changes.push(ScoreChange::KeySignatureChanged {
1449            old: a.settings.key_signature.clone(),
1450            new: b.settings.key_signature.clone(),
1451        });
1452    }
1453
1454    let a_len = a.parts.len();
1455    let b_len = b.parts.len();
1456    for i in b_len..a_len {
1457        changes.push(ScoreChange::PartRemoved {
1458            part_index: i,
1459            name: a.parts[i].name.clone(),
1460        });
1461    }
1462    for i in a_len..b_len {
1463        changes.push(ScoreChange::PartAdded { part_index: i });
1464    }
1465
1466    for pi in 0..a_len.min(b_len) {
1467        let ap = &a.parts[pi];
1468        let bp = &b.parts[pi];
1469        for si in 0..ap.staves.len().min(bp.staves.len()) {
1470            let a_staff = &ap.staves[si];
1471            let b_staff = &bp.staves[si];
1472            for mi in 0..a_staff.measures.len().min(b_staff.measures.len()) {
1473                let am = &a_staff.measures[mi];
1474                let bm = &b_staff.measures[mi];
1475                for vi in 0..4usize {
1476                    let av = &am.voices[vi];
1477                    let bv = &bm.voices[vi];
1478                    for (ni, (a_note, b_note)) in av.iter().zip(bv.iter()).enumerate() {
1479                        if !note_content_eq(a_note, b_note) {
1480                            changes.push(ScoreChange::NoteModified {
1481                                part: pi,
1482                                staff: si,
1483                                measure: mi,
1484                                voice: vi,
1485                                note_index: ni,
1486                                old: Box::new(a_note.clone()),
1487                                new: Box::new(b_note.clone()),
1488                            });
1489                        }
1490                    }
1491                    for note in av.iter().skip(bv.len()) {
1492                        changes.push(ScoreChange::NoteRemoved {
1493                            part: pi,
1494                            staff: si,
1495                            measure: mi,
1496                            voice: vi,
1497                            note: Box::new(note.clone()),
1498                        });
1499                    }
1500                    for ni in av.len()..bv.len() {
1501                        changes.push(ScoreChange::NoteAdded {
1502                            part: pi,
1503                            staff: si,
1504                            measure: mi,
1505                            voice: vi,
1506                            note_index: ni,
1507                        });
1508                    }
1509                }
1510                if am.time_sig != bm.time_sig {
1511                    changes.push(ScoreChange::TimeSigChanged {
1512                        part: pi,
1513                        staff: si,
1514                        measure: mi,
1515                        old: am.time_sig.clone(),
1516                        new: bm.time_sig.clone(),
1517                    });
1518                }
1519                if am.tempo != bm.tempo {
1520                    changes.push(ScoreChange::MeasureTempoChanged {
1521                        part: pi,
1522                        staff: si,
1523                        measure: mi,
1524                        old: am.tempo,
1525                        new: bm.tempo,
1526                    });
1527                }
1528                if am.barline_left != bm.barline_left || am.barline_right != bm.barline_right {
1529                    changes.push(ScoreChange::BarlineChanged {
1530                        part: pi,
1531                        staff: si,
1532                        measure: mi,
1533                    });
1534                }
1535                if am.rehearsal != bm.rehearsal {
1536                    changes.push(ScoreChange::RehearsalMarkChanged {
1537                        part: pi,
1538                        staff: si,
1539                        measure: mi,
1540                        old: am.rehearsal.clone(),
1541                        new: bm.rehearsal.clone(),
1542                    });
1543                }
1544                if am.volta != bm.volta {
1545                    changes.push(ScoreChange::VoltaChanged {
1546                        part: pi,
1547                        staff: si,
1548                        measure: mi,
1549                    });
1550                }
1551            }
1552        }
1553    }
1554
1555    changes
1556}
1557
1558// ── ScorePatch ────────────────────────────────────────────────────────────────
1559
1560/// An individually applicable patch operation produced by [`score_patch`].
1561///
1562/// Unlike [`ScoreChange`], every variant carries enough data to apply the change to a
1563/// [`Score`] without needing the original score. Use [`apply_patch`] to apply a list.
1564#[derive(Debug, Clone, Serialize, Deserialize)]
1565pub enum ScorePatch {
1566    SetMetadata {
1567        field: String,
1568        value: String,
1569    },
1570    SetTempo {
1571        value: u16,
1572    },
1573    SetKeySignature {
1574        part: usize,
1575        staff: usize,
1576        measure: usize,
1577        value: Option<KeySignature>,
1578    },
1579    SetTimeSignature {
1580        part: usize,
1581        staff: usize,
1582        measure: usize,
1583        value: Option<TimeSignature>,
1584    },
1585    SetBarlines {
1586        part: usize,
1587        staff: usize,
1588        measure: usize,
1589        left: Barline,
1590        right: Barline,
1591    },
1592    SetRehearsal {
1593        part: usize,
1594        staff: usize,
1595        measure: usize,
1596        value: Option<String>,
1597    },
1598    SetVolta {
1599        part: usize,
1600        staff: usize,
1601        measure: usize,
1602        value: Option<VoltaBracket>,
1603    },
1604    /// Insert `note` at `note_index` in the given voice (existing notes shift right).
1605    AddNote {
1606        part: usize,
1607        staff: usize,
1608        measure: usize,
1609        voice: usize,
1610        /// Position for insertion. `usize::MAX` is the legacy append sentinel.
1611        #[serde(default = "legacy_append_index")]
1612        note_index: usize,
1613        note: Box<Note>,
1614    },
1615    RemoveNote {
1616        part: usize,
1617        staff: usize,
1618        measure: usize,
1619        voice: usize,
1620        note_index: usize,
1621    },
1622    /// Replace the note at `note_index` with `note`.
1623    ReplaceNote {
1624        part: usize,
1625        staff: usize,
1626        measure: usize,
1627        voice: usize,
1628        note_index: usize,
1629        note: Box<Note>,
1630    },
1631    SetMeasureTempo {
1632        part: usize,
1633        staff: usize,
1634        measure: usize,
1635        value: Option<u16>,
1636    },
1637    /// Replace the complete score when a change cannot be represented safely by
1638    /// positional operations (for example, a part or measure was added).
1639    ReplaceScore {
1640        score: Box<Score>,
1641    },
1642}
1643
1644fn legacy_append_index() -> usize {
1645    usize::MAX
1646}
1647
1648/// Return whether positional patches would lose score data. The patch format deliberately
1649/// keeps the common editing operations small; fields without a dedicated operation use the
1650/// complete-score fallback so an interchange round-trip never silently drops notation.
1651fn patch_requires_replace(a: &Score, b: &Score) -> bool {
1652    if a.settings.time_signature != b.settings.time_signature
1653        || a.settings.key_signature != b.settings.key_signature
1654        || a.parts.len() != b.parts.len()
1655        || a.part_groups.len() != b.part_groups.len()
1656    {
1657        return true;
1658    }
1659    if a.part_groups.iter().zip(&b.part_groups).any(|(x, y)| {
1660        x.first_part != y.first_part
1661            || x.last_part != y.last_part
1662            || x.symbol != y.symbol
1663            || x.barlines_connect != y.barlines_connect
1664    }) {
1665        return true;
1666    }
1667    for (ap, bp) in a.parts.iter().zip(&b.parts) {
1668        if ap.name != bp.name
1669            || ap.short_name != bp.short_name
1670            || ap.midi_channel != bp.midi_channel
1671            || ap.midi_program != bp.midi_program
1672            || ap.midi_pitch_bends != bp.midi_pitch_bends
1673            || ap.midi_control_changes != bp.midi_control_changes
1674            || ap.midi_program_changes != bp.midi_program_changes
1675            || ap.midi_aftertouch != bp.midi_aftertouch
1676            || ap.percussion_instruments != bp.percussion_instruments
1677            || ap.staff_groups != bp.staff_groups
1678            || ap.staves.len() != bp.staves.len()
1679        {
1680            return true;
1681        }
1682        for (as_, bs) in ap.staves.iter().zip(&bp.staves) {
1683            if as_.clef != bs.clef
1684                || as_.transpose_semitones != bs.transpose_semitones
1685                || as_.measures.len() != bs.measures.len()
1686            {
1687                return true;
1688            }
1689            for (am, bm) in as_.measures.iter().zip(&bs.measures) {
1690                if am.number != bm.number
1691                    || am.clef != bm.clef
1692                    || am.tempo_text != bm.tempo_text
1693                    || am.navigation != bm.navigation
1694                    || am.expression_text != bm.expression_text
1695                    || am.multi_rest_count != bm.multi_rest_count
1696                    || am.system_break != bm.system_break
1697                    || am.page_break != bm.page_break
1698                {
1699                    return true;
1700                }
1701            }
1702        }
1703    }
1704    false
1705}
1706
1707/// Compare two scores and return a list of [`ScorePatch`] operations.
1708///
1709/// Applying the patches to `a` via [`apply_patch`] produces a score structurally
1710/// equivalent to `b` (same parts, staves, measures, and note content).
1711pub fn score_patch(a: &Score, b: &Score) -> Vec<ScorePatch> {
1712    let mut patches: Vec<ScorePatch> = Vec::new();
1713
1714    if patch_requires_replace(a, b) {
1715        return vec![ScorePatch::ReplaceScore {
1716            score: Box::new(b.clone()),
1717        }];
1718    }
1719
1720    macro_rules! meta {
1721        ($field:ident, $name:literal) => {
1722            if a.metadata.$field != b.metadata.$field {
1723                patches.push(ScorePatch::SetMetadata {
1724                    field: $name.to_string(),
1725                    value: b.metadata.$field.clone(),
1726                });
1727            }
1728        };
1729    }
1730    meta!(title, "title");
1731    meta!(composer, "composer");
1732    meta!(lyricist, "lyricist");
1733    meta!(copyright, "copyright");
1734    meta!(work_number, "work_number");
1735    meta!(movement_title, "movement_title");
1736
1737    if a.settings.tempo_bpm != b.settings.tempo_bpm {
1738        patches.push(ScorePatch::SetTempo {
1739            value: b.settings.tempo_bpm,
1740        });
1741    }
1742
1743    for pi in 0..a.parts.len().min(b.parts.len()) {
1744        let ap = &a.parts[pi];
1745        let bp = &b.parts[pi];
1746        for si in 0..ap.staves.len().min(bp.staves.len()) {
1747            let a_staff = &ap.staves[si];
1748            let b_staff = &bp.staves[si];
1749            for mi in 0..a_staff.measures.len().min(b_staff.measures.len()) {
1750                let am = &a_staff.measures[mi];
1751                let bm = &b_staff.measures[mi];
1752
1753                if am.key_sig != bm.key_sig {
1754                    patches.push(ScorePatch::SetKeySignature {
1755                        part: pi,
1756                        staff: si,
1757                        measure: mi,
1758                        value: bm.key_sig.clone(),
1759                    });
1760                }
1761                if am.time_sig != bm.time_sig {
1762                    patches.push(ScorePatch::SetTimeSignature {
1763                        part: pi,
1764                        staff: si,
1765                        measure: mi,
1766                        value: bm.time_sig.clone(),
1767                    });
1768                }
1769                if am.barline_left != bm.barline_left || am.barline_right != bm.barline_right {
1770                    patches.push(ScorePatch::SetBarlines {
1771                        part: pi,
1772                        staff: si,
1773                        measure: mi,
1774                        left: bm.barline_left.clone(),
1775                        right: bm.barline_right.clone(),
1776                    });
1777                }
1778                if am.rehearsal != bm.rehearsal {
1779                    patches.push(ScorePatch::SetRehearsal {
1780                        part: pi,
1781                        staff: si,
1782                        measure: mi,
1783                        value: bm.rehearsal.clone(),
1784                    });
1785                }
1786                if am.volta != bm.volta {
1787                    patches.push(ScorePatch::SetVolta {
1788                        part: pi,
1789                        staff: si,
1790                        measure: mi,
1791                        value: bm.volta.clone(),
1792                    });
1793                }
1794                if am.tempo != bm.tempo {
1795                    patches.push(ScorePatch::SetMeasureTempo {
1796                        part: pi,
1797                        staff: si,
1798                        measure: mi,
1799                        value: bm.tempo,
1800                    });
1801                }
1802
1803                for vi in 0..4usize {
1804                    let av = &am.voices[vi];
1805                    let bv = &bm.voices[vi];
1806                    for (ni, (a_note, b_note)) in av.iter().zip(bv.iter()).enumerate() {
1807                        if !note_content_eq(a_note, b_note) {
1808                            patches.push(ScorePatch::ReplaceNote {
1809                                part: pi,
1810                                staff: si,
1811                                measure: mi,
1812                                voice: vi,
1813                                note_index: ni,
1814                                note: Box::new(b_note.clone()),
1815                            });
1816                        }
1817                    }
1818                    // Notes in `a` beyond `b` — remove in reverse order to preserve indices.
1819                    for ni in (bv.len()..av.len()).rev() {
1820                        patches.push(ScorePatch::RemoveNote {
1821                            part: pi,
1822                            staff: si,
1823                            measure: mi,
1824                            voice: vi,
1825                            note_index: ni,
1826                        });
1827                    }
1828                    // Notes in `b` beyond `a` — append.
1829                    for (offset, note) in bv.iter().skip(av.len()).enumerate() {
1830                        patches.push(ScorePatch::AddNote {
1831                            part: pi,
1832                            staff: si,
1833                            measure: mi,
1834                            voice: vi,
1835                            note_index: av.len() + offset,
1836                            note: Box::new(note.clone()),
1837                        });
1838                    }
1839                }
1840            }
1841        }
1842    }
1843
1844    patches
1845}
1846
1847/// Apply a list of [`ScorePatch`] operations to a cloned copy of `score`.
1848///
1849/// Returns `Err(Error::InvalidPatch)` if any patch references an out-of-bounds index.
1850/// The returned score is an independent clone — `score` is not modified.
1851pub fn apply_patch(score: &Score, patches: &[ScorePatch]) -> Result<Score, Error> {
1852    let mut s = score.clone();
1853    for patch in patches {
1854        match patch {
1855            ScorePatch::ReplaceScore { score } => {
1856                s = (**score).clone();
1857            }
1858            ScorePatch::SetMetadata { field, value } => match field.as_str() {
1859                "title" => s.metadata.title = value.clone(),
1860                "composer" => s.metadata.composer = value.clone(),
1861                "lyricist" => s.metadata.lyricist = value.clone(),
1862                "copyright" => s.metadata.copyright = value.clone(),
1863                "work_number" => s.metadata.work_number = value.clone(),
1864                "movement_title" => s.metadata.movement_title = value.clone(),
1865                other => {
1866                    return Err(Error::InvalidPatch(format!(
1867                        "unknown metadata field: {other}"
1868                    )));
1869                }
1870            },
1871            ScorePatch::SetTempo { value } => {
1872                s.settings.tempo_bpm = *value;
1873            }
1874            ScorePatch::SetKeySignature {
1875                part,
1876                staff,
1877                measure,
1878                value,
1879            } => {
1880                s.parts
1881                    .get_mut(*part)
1882                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
1883                    .staves
1884                    .get_mut(*staff)
1885                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
1886                    .measures
1887                    .get_mut(*measure)
1888                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
1889                    .key_sig = value.clone();
1890            }
1891            ScorePatch::SetTimeSignature {
1892                part,
1893                staff,
1894                measure,
1895                value,
1896            } => {
1897                s.parts
1898                    .get_mut(*part)
1899                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
1900                    .staves
1901                    .get_mut(*staff)
1902                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
1903                    .measures
1904                    .get_mut(*measure)
1905                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
1906                    .time_sig = value.clone();
1907            }
1908            ScorePatch::SetBarlines {
1909                part,
1910                staff,
1911                measure,
1912                left,
1913                right,
1914            } => {
1915                let m = s
1916                    .parts
1917                    .get_mut(*part)
1918                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
1919                    .staves
1920                    .get_mut(*staff)
1921                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
1922                    .measures
1923                    .get_mut(*measure)
1924                    .ok_or_else(|| {
1925                        Error::InvalidPatch(format!("measure {measure} out of range"))
1926                    })?;
1927                m.barline_left = left.clone();
1928                m.barline_right = right.clone();
1929            }
1930            ScorePatch::SetRehearsal {
1931                part,
1932                staff,
1933                measure,
1934                value,
1935            } => {
1936                s.parts
1937                    .get_mut(*part)
1938                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
1939                    .staves
1940                    .get_mut(*staff)
1941                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
1942                    .measures
1943                    .get_mut(*measure)
1944                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
1945                    .rehearsal = value.clone();
1946            }
1947            ScorePatch::SetVolta {
1948                part,
1949                staff,
1950                measure,
1951                value,
1952            } => {
1953                s.parts
1954                    .get_mut(*part)
1955                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
1956                    .staves
1957                    .get_mut(*staff)
1958                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
1959                    .measures
1960                    .get_mut(*measure)
1961                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
1962                    .volta = value.clone();
1963            }
1964            ScorePatch::AddNote {
1965                part,
1966                staff,
1967                measure,
1968                voice,
1969                note_index,
1970                note,
1971            } => {
1972                let v = s
1973                    .parts
1974                    .get_mut(*part)
1975                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
1976                    .staves
1977                    .get_mut(*staff)
1978                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
1979                    .measures
1980                    .get_mut(*measure)
1981                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
1982                    .voices
1983                    .get_mut(*voice)
1984                    .ok_or_else(|| Error::InvalidPatch(format!("voice {voice} out of range")))?;
1985                let insert_at = if *note_index == usize::MAX {
1986                    v.len()
1987                } else {
1988                    *note_index
1989                };
1990                if insert_at > v.len() {
1991                    return Err(Error::InvalidPatch(format!(
1992                        "note_index {note_index} out of range"
1993                    )));
1994                }
1995                v.insert(insert_at, *note.clone());
1996            }
1997            ScorePatch::RemoveNote {
1998                part,
1999                staff,
2000                measure,
2001                voice,
2002                note_index,
2003            } => {
2004                let v = s
2005                    .parts
2006                    .get_mut(*part)
2007                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
2008                    .staves
2009                    .get_mut(*staff)
2010                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
2011                    .measures
2012                    .get_mut(*measure)
2013                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
2014                    .voices
2015                    .get_mut(*voice)
2016                    .ok_or_else(|| Error::InvalidPatch(format!("voice {voice} out of range")))?;
2017                if *note_index >= v.len() {
2018                    return Err(Error::InvalidPatch(format!(
2019                        "note_index {note_index} out of range"
2020                    )));
2021                }
2022                v.remove(*note_index);
2023            }
2024            ScorePatch::ReplaceNote {
2025                part,
2026                staff,
2027                measure,
2028                voice,
2029                note_index,
2030                note,
2031            } => {
2032                let v = s
2033                    .parts
2034                    .get_mut(*part)
2035                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
2036                    .staves
2037                    .get_mut(*staff)
2038                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
2039                    .measures
2040                    .get_mut(*measure)
2041                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
2042                    .voices
2043                    .get_mut(*voice)
2044                    .ok_or_else(|| Error::InvalidPatch(format!("voice {voice} out of range")))?;
2045                if *note_index >= v.len() {
2046                    return Err(Error::InvalidPatch(format!(
2047                        "note_index {note_index} out of range"
2048                    )));
2049                }
2050                v[*note_index] = *note.clone();
2051            }
2052            ScorePatch::SetMeasureTempo {
2053                part,
2054                staff,
2055                measure,
2056                value,
2057            } => {
2058                s.parts
2059                    .get_mut(*part)
2060                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
2061                    .staves
2062                    .get_mut(*staff)
2063                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
2064                    .measures
2065                    .get_mut(*measure)
2066                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
2067                    .tempo = *value;
2068            }
2069        }
2070    }
2071    Ok(s)
2072}
2073
2074/// Respell all pitches in the score to prefer flats or sharps.
2075///
2076/// Applies [`Pitch::respell`] to every note in every part, staff, measure, and voice.
2077pub fn respell_score(score: &mut Score, prefer_flat: bool) {
2078    for part in &mut score.parts {
2079        for staff in &mut part.staves {
2080            for measure in &mut staff.measures {
2081                for voice in &mut measure.voices {
2082                    for note in voice.iter_mut() {
2083                        for pitch in &mut note.pitches {
2084                            *pitch = pitch.respell(prefer_flat);
2085                        }
2086                    }
2087                }
2088            }
2089        }
2090    }
2091}
2092
2093/// Respell all pitches to match the score's key signature spelling convention.
2094///
2095/// Flat-key signatures (fifths < 0) use flat spellings; sharp-key and C major use sharps.
2096pub fn respell_score_to_key(score: &mut Score) {
2097    let prefer_flat = score.settings.key_signature.fifths < 0;
2098    respell_score(score, prefer_flat);
2099}
2100
2101/// Compute total playback duration in seconds.
2102///
2103/// Uses `measure_sequence` for correct repeat handling. Lighter than generating
2104/// full playback events — suitable for progress bars and UI display.
2105pub fn score_duration_secs(score: &Score) -> f64 {
2106    if score.settings.tempo_bpm == 0 {
2107        return 0.0;
2108    }
2109    let seq = measure_sequence(score);
2110    let mut total_secs = 0.0f64;
2111    let mut current_bpm = score.settings.tempo_bpm as f64;
2112    if let Some(staff) = score.parts.first().and_then(|p| p.staves.first()) {
2113        for &idx in &seq {
2114            if let Some(m) = staff.measures.get(idx) {
2115                if let Some(b) = m.tempo {
2116                    current_bpm = b as f64;
2117                }
2118                if current_bpm == 0.0 {
2119                    continue;
2120                }
2121                let beats: f64 = m.voices[0].iter().map(|n| n.beats()).sum();
2122                total_secs += beats / current_bpm * 60.0;
2123            }
2124        }
2125    }
2126    total_secs
2127}
2128
2129/// Compute playback duration in seconds for a specific measure range (inclusive).
2130///
2131/// `region` is `(start_measure, end_measure)`, both 0-based. Measures outside the range
2132/// are excluded. Uses `measure_sequence` for correct repeat handling.
2133pub fn score_duration_secs_region(score: &Score, region: (usize, usize)) -> f64 {
2134    if score.settings.tempo_bpm == 0 {
2135        return 0.0;
2136    }
2137    let seq: Vec<usize> = measure_sequence(score)
2138        .into_iter()
2139        .filter(|&idx| idx >= region.0 && idx <= region.1)
2140        .collect();
2141    let mut total_secs = 0.0f64;
2142    let mut current_bpm = score.settings.tempo_bpm as f64;
2143    if let Some(staff) = score.parts.first().and_then(|p| p.staves.first()) {
2144        for &idx in &seq {
2145            if let Some(m) = staff.measures.get(idx) {
2146                if let Some(b) = m.tempo {
2147                    current_bpm = b as f64;
2148                }
2149                if current_bpm == 0.0 {
2150                    continue;
2151                }
2152                let beats: f64 = m.voices[0].iter().map(|n| n.beats()).sum();
2153                total_secs += beats / current_bpm * 60.0;
2154            }
2155        }
2156    }
2157    total_secs
2158}
2159
2160/// Return the number of beats available in a voice before it is full.
2161///
2162/// Uses [`Note::beats`] which correctly handles tuplet scaling.
2163/// Returns `Ok(0.0)` when the voice is already full or over-full.
2164pub fn measure_beats_remaining(
2165    score: &Score,
2166    part_index: usize,
2167    staff_index: usize,
2168    measure_index: usize,
2169    voice_index: usize,
2170) -> Result<f64, Error> {
2171    let part = score
2172        .parts
2173        .get(part_index)
2174        .ok_or(Error::PartNotFound(part_index))?;
2175    let staff = part
2176        .staves
2177        .get(staff_index)
2178        .ok_or(Error::StaffNotFound(staff_index))?;
2179    let measure = staff
2180        .measures
2181        .get(measure_index)
2182        .ok_or(Error::MeasureNotFound(measure_index))?;
2183    let voice = measure
2184        .voices
2185        .get(voice_index)
2186        .ok_or(Error::VoiceOutOfRange(voice_index))?;
2187    let ts = measure
2188        .time_sig
2189        .as_ref()
2190        .unwrap_or(&score.settings.time_signature);
2191    let used: f64 = voice.iter().map(|n| n.beats()).sum();
2192    Ok((ts.total_beats() - used).max(0.0))
2193}
2194
2195/// Suggest whether the stem should point up for the given pitches and clef.
2196///
2197/// Conventional rule: if the average MIDI pitch of the chord is below the staff
2198/// middle line, the stem points up; at or above, it points down.
2199/// For empty pitch lists (rests), returns `true` by convention.
2200pub fn suggested_stem_up(pitches: &[Pitch], clef: &Clef) -> bool {
2201    if pitches.is_empty() {
2202        return true;
2203    }
2204    let avg = pitches.iter().map(|p| p.to_midi() as f64).sum::<f64>() / pitches.len() as f64;
2205    avg < clef.middle_line_midi() as f64
2206}
2207
2208fn beam_beat_size(ts: &TimeSignature) -> f64 {
2209    if ts.numerator.is_multiple_of(3) && ts.numerator >= 6 && ts.denominator >= 8 {
2210        3.0 * 4.0 / ts.denominator as f64
2211    } else {
2212        4.0 / ts.denominator as f64
2213    }
2214}
2215
2216/// Compute recommended [`BeamState`] values for a voice's notes.
2217///
2218/// Groups beamable notes (eighth or shorter, non-rest) within beat boundaries.
2219/// Returns a `Vec` the same length as `notes`.
2220pub fn compute_beams(notes: &[Note], time_sig: &TimeSignature) -> Vec<BeamState> {
2221    let beat_size = beam_beat_size(time_sig);
2222    let n = notes.len();
2223    let mut result = vec![BeamState::None; n];
2224
2225    let is_beamable = |note: &Note| -> bool {
2226        !note.is_rest
2227            && matches!(
2228                note.duration,
2229                Duration::Eighth
2230                    | Duration::Sixteenth
2231                    | Duration::ThirtySecond
2232                    | Duration::SixtyFourth
2233            )
2234    };
2235
2236    // Compute beat start positions
2237    let mut starts = Vec::with_capacity(n);
2238    let mut pos = 0.0f64;
2239    for note in notes {
2240        starts.push(pos);
2241        pos += note.beats();
2242    }
2243
2244    // Assign beam group ids based on beat boundary
2245    let group_id = |i: usize| -> i64 { (starts[i] / beat_size).floor() as i64 };
2246
2247    let mut i = 0;
2248    while i < n {
2249        if !is_beamable(&notes[i]) {
2250            i += 1;
2251            continue;
2252        }
2253        let g = group_id(i);
2254        // Find the run of beamable notes in the same beat group
2255        let mut j = i;
2256        while j < n && is_beamable(&notes[j]) && group_id(j) == g {
2257            j += 1;
2258        }
2259        let run = j - i;
2260        if run == 1 {
2261            result[i] = BeamState::None;
2262        } else {
2263            result[i] = BeamState::Begin;
2264            result[i + 1..j - 1].fill(BeamState::Continue);
2265            result[j - 1] = BeamState::End;
2266        }
2267        i = j;
2268    }
2269    result
2270}
2271
2272fn note_content_eq(a: &Note, b: &Note) -> bool {
2273    a.is_rest == b.is_rest
2274        && a.is_unpitched == b.is_unpitched
2275        && a.instrument_id == b.instrument_id
2276        && a.pitches == b.pitches
2277        && a.duration == b.duration
2278        && a.dot_count == b.dot_count
2279        && a.tie_start == b.tie_start
2280        && a.tie_end == b.tie_end
2281        && a.beam == b.beam
2282        && a.articulations == b.articulations
2283        && a.dynamic == b.dynamic
2284        && a.stem_up == b.stem_up
2285        && a.hairpin_start == b.hairpin_start
2286        && a.hairpin_end == b.hairpin_end
2287        && a.tuplet == b.tuplet
2288        && a.chord_symbol == b.chord_symbol
2289        && a.is_grace == b.is_grace
2290        && a.grace_slash == b.grace_slash
2291        && a.ottava_start == b.ottava_start
2292        && a.ottava_end == b.ottava_end
2293        && a.lyric == b.lyric
2294        && a.pedal_start == b.pedal_start
2295        && a.pedal_end == b.pedal_end
2296        && a.slur_start == b.slur_start
2297        && a.slur_end == b.slur_end
2298        && a.arpeggiate == b.arpeggiate
2299        && a.tab_position == b.tab_position
2300        && a.tab_positions == b.tab_positions
2301        && a.guitar_technique == b.guitar_technique
2302        && a.guitar_bend_alter_cents == b.guitar_bend_alter_cents
2303}
2304
2305#[cfg(test)]
2306mod tests {
2307    use super::*;
2308    use crate::model::{notation::FingeringSelectionPolicy, pitch::Step};
2309
2310    #[test]
2311    fn fingering_selection_policy_is_deterministic_and_non_mutating() {
2312        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2313        note.fingerings = vec![3, 1, 4];
2314        note.fingering = Some(3);
2315        assert_eq!(
2316            note.select_fingering(FingeringSelectionPolicy::SourceOrder),
2317            Some(3)
2318        );
2319        assert_eq!(
2320            note.select_fingering(FingeringSelectionPolicy::LowestNumber),
2321            Some(1)
2322        );
2323        assert_eq!(
2324            note.select_fingering(FingeringSelectionPolicy::HighestNumber),
2325            Some(4)
2326        );
2327        assert_eq!(note.fingerings, vec![3, 1, 4]);
2328        assert_eq!(note.fingering, Some(3));
2329    }
2330
2331    #[test]
2332    fn default_score_has_one_part_four_measures() {
2333        let score = Score::default();
2334        assert_eq!(score.parts.len(), 1);
2335        assert_eq!(score.parts[0].staves.len(), 1);
2336        assert_eq!(score.parts[0].staves[0].measures.len(), 4);
2337    }
2338
2339    #[test]
2340    fn assign_tablature_positions_is_capo_aware_and_preserves_explicit_positions() {
2341        let mut score = Score::new("Guitar", 120, 4, 4, 0, 1);
2342        score.parts[0].staves[0].tablature = Some(TablatureConfig {
2343            lines: 6,
2344            tuning_midi: vec![64, 59, 55, 50, 45, 40],
2345            capo: 2,
2346        });
2347        score.parts[0].staves[0].measures[0].voices[0].push(Note::new(
2348            Pitch::with_alter(Step::F, 4, 1),
2349            Duration::Quarter,
2350        ));
2351        score.parts[0].staves[0].measures[0].voices[0]
2352            .push(Note::new(Pitch::new(Step::G, 3), Duration::Quarter));
2353        score.parts[0].staves[0].measures[0].voices[0][2].tab_position =
2354            Some(TabPosition { string: 6, fret: 7 });
2355
2356        assert_eq!(assign_tablature_positions(&mut score), 1);
2357        let notes = &score.parts[0].staves[0].measures[0].voices[0];
2358        assert_eq!(
2359            notes[1].tab_position,
2360            Some(TabPosition { string: 1, fret: 0 })
2361        );
2362        assert_eq!(notes[1].string_number, Some(1));
2363        assert_eq!(
2364            notes[2].tab_position,
2365            Some(TabPosition { string: 6, fret: 7 })
2366        );
2367    }
2368
2369    #[test]
2370    fn assign_tablature_positions_optimizes_chord_strings_and_fret_span() {
2371        let mut score = Score::new("Guitar", 120, 4, 4, 0, 1);
2372        score.parts[0].staves[0].tablature = Some(TablatureConfig {
2373            lines: 6,
2374            tuning_midi: vec![64, 59, 55, 50, 45, 40],
2375            capo: 0,
2376        });
2377        let mut chord = Note::new(Pitch::new(Step::E, 4), Duration::Quarter);
2378        chord.pitches.push(Pitch::new(Step::G, 4));
2379        score.parts[0].staves[0].measures[0].voices[0].push(chord);
2380
2381        assert_eq!(assign_tablature_positions(&mut score), 1);
2382        let positions = &score.parts[0].staves[0].measures[0].voices[0][1].tab_positions;
2383        assert_eq!(
2384            positions,
2385            &vec![
2386                TabPosition { string: 2, fret: 5 },
2387                TabPosition { string: 1, fret: 3 },
2388            ]
2389        );
2390    }
2391
2392    #[test]
2393    fn new_score_measure_count() {
2394        let score = Score::new("Test", 120, 4, 4, 0, 8);
2395        assert_eq!(score.measure_count(), 8);
2396    }
2397
2398    #[test]
2399    fn note_beats_quarter() {
2400        let note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2401        assert!((note.beats() - 1.0).abs() < 1e-9);
2402    }
2403
2404    #[test]
2405    fn note_beats_dotted_quarter() {
2406        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2407        note.dot_count = 1;
2408        assert!((note.beats() - 1.5).abs() < 1e-9);
2409    }
2410
2411    #[test]
2412    fn grace_note_beats_zero() {
2413        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Eighth);
2414        note.is_grace = true;
2415        assert_eq!(note.beats(), 0.0);
2416    }
2417
2418    #[test]
2419    fn measure_empty_4_4_fills_four_beats() {
2420        let m = Measure::empty(4, 4);
2421        let total: f64 = m.voices[0].iter().map(|n| n.beats()).sum();
2422        assert!((total - 4.0).abs() < 1e-9);
2423    }
2424
2425    #[test]
2426    fn measure_empty_3_4_fills_three_beats() {
2427        let m = Measure::empty(3, 4);
2428        let total: f64 = m.voices[0].iter().map(|n| n.beats()).sum();
2429        assert!((total - 3.0).abs() < 1e-9);
2430    }
2431
2432    #[test]
2433    fn whole_filling_beats() {
2434        assert_eq!(Duration::whole_filling_beats(4.0), Duration::Whole);
2435        assert_eq!(Duration::whole_filling_beats(2.0), Duration::Half);
2436        assert_eq!(Duration::whole_filling_beats(1.0), Duration::Quarter);
2437    }
2438
2439    // ── ScoreStats ────────────────────────────────────────────────────────────
2440
2441    #[test]
2442    fn statistics_default_score_all_rests() {
2443        let score = Score::default();
2444        let s = score.statistics();
2445        assert_eq!(s.part_count, 1);
2446        assert_eq!(s.measure_count, 4);
2447        assert_eq!(s.note_count, 0);
2448        assert!(s.rest_count > 0);
2449    }
2450
2451    #[test]
2452    fn statistics_duration_estimate() {
2453        // 4/4, 120 BPM, 1 measure → 4 beats → 2.0 s
2454        let score = Score::new("T", 120, 4, 4, 0, 1);
2455        let s = score.statistics();
2456        assert!((s.estimated_duration_secs - 2.0).abs() < 0.01);
2457    }
2458
2459    #[test]
2460    fn score_duration_secs_matches_statistics() {
2461        use super::score_duration_secs;
2462        let score = Score::new("T", 120, 4, 4, 0, 4);
2463        let secs = score_duration_secs(&score);
2464        // 4/4, 120 BPM, 4 measures → 16 beats → 8.0 s
2465        assert!((secs - 8.0).abs() < 0.01, "expected ~8.0 s, got {secs}");
2466    }
2467
2468    #[test]
2469    fn score_duration_secs_zero_bpm_returns_zero() {
2470        use super::score_duration_secs;
2471        let mut score = Score::new("T", 120, 4, 4, 0, 1);
2472        score.settings.tempo_bpm = 0;
2473        assert_eq!(score_duration_secs(&score), 0.0);
2474    }
2475
2476    #[test]
2477    fn score_duration_secs_per_measure_tempo() {
2478        use super::score_duration_secs;
2479        // 2 measures: measure 0 at 120 BPM (2.0 s), measure 1 at 60 BPM (4.0 s)
2480        let mut score = Score::new("T", 120, 4, 4, 0, 2);
2481        score.parts[0].staves[0].measures[1].tempo = Some(60);
2482        let secs = score_duration_secs(&score);
2483        assert!((secs - 6.0).abs() < 0.01, "expected ~6.0 s, got {secs}");
2484    }
2485
2486    // ── extract_part ──────────────────────────────────────────────────────────
2487
2488    #[test]
2489    fn extract_part_returns_single_part_score() {
2490        let mut score = Score::default();
2491        let mut p2 = Part::new("Violin", "Vln.");
2492        p2.staves.push(Staff::new(Clef::Treble));
2493        score.parts.push(p2);
2494        let ex = score.extract_part(0).unwrap();
2495        assert_eq!(ex.parts.len(), 1);
2496        assert_ne!(ex.id, score.id);
2497        assert_eq!(ex.metadata.title, score.metadata.title);
2498    }
2499
2500    #[test]
2501    fn extract_part_out_of_range_is_none() {
2502        let score = Score::default();
2503        assert!(score.extract_part(99).is_none());
2504    }
2505
2506    // ── transpose ─────────────────────────────────────────────────────────────
2507
2508    #[test]
2509    fn transpose_zero_is_clone() {
2510        let score = Score::new("T", 120, 4, 4, 0, 1);
2511        let t = transpose(&score, 0);
2512        assert_eq!(t.settings.key_signature.fifths, 0);
2513    }
2514
2515    #[test]
2516    fn transpose_c_major_up_2_to_d_major() {
2517        let score = Score::new("T", 120, 4, 4, 0, 1);
2518        assert_eq!(transpose(&score, 2).settings.key_signature.fifths, 2);
2519    }
2520
2521    #[test]
2522    fn transpose_d_major_up_5_to_g_major() {
2523        let score = Score::new("T", 120, 4, 4, 2, 1);
2524        assert_eq!(transpose(&score, 5).settings.key_signature.fifths, 1);
2525    }
2526
2527    #[test]
2528    fn transpose_c4_up_1_to_csharp4() {
2529        let p = transpose_pitch(&Pitch::new(Step::C, 4), 1);
2530        assert_eq!(p.to_midi(), 61);
2531        assert_eq!(p.step, Step::C);
2532        assert_eq!(p.alter, 1);
2533    }
2534
2535    #[test]
2536    fn transpose_c4_down_1_to_b3() {
2537        let p = transpose_pitch(&Pitch::new(Step::C, 4), -1);
2538        assert_eq!(p.to_midi(), 59);
2539        assert_eq!(p.step, Step::B);
2540        assert_eq!(p.alter, 0);
2541    }
2542
2543    #[test]
2544    fn transpose_up_octave_keeps_step() {
2545        let p = transpose_pitch(&Pitch::new(Step::A, 4), 12);
2546        assert_eq!(p.to_midi(), 81);
2547        assert_eq!(p.step, Step::A);
2548        assert_eq!(p.octave, 5);
2549    }
2550
2551    #[test]
2552    fn statistics_with_repeat_doubles_duration() {
2553        // 4/4, 120 BPM, 2 measures with RepeatStart+RepeatEnd → plays twice → 4 measures worth
2554        let mut score = Score::new("T", 120, 4, 4, 0, 2);
2555        score.parts[0].staves[0].measures[0].barline_left =
2556            crate::model::notation::Barline::RepeatStart;
2557        score.parts[0].staves[0].measures[1].barline_right =
2558            crate::model::notation::Barline::RepeatEnd;
2559        let s = score.statistics();
2560        // 4 beats × 4 measures (2 physical × 2 passes) ÷ 120 BPM × 60 = 8.0 s
2561        assert!((s.estimated_duration_secs - 8.0).abs() < 0.01);
2562    }
2563
2564    #[test]
2565    fn transpose_octave_boundary_b4_to_c5() {
2566        // B4 (midi=71) + 1 semitone = C5 (midi=72)
2567        let p = transpose_pitch(&Pitch::new(Step::B, 4), 1);
2568        assert_eq!(p.to_midi(), 72);
2569        assert_eq!(p.step, Step::C);
2570        assert_eq!(p.octave, 5);
2571    }
2572
2573    #[test]
2574    fn transpose_clamp_at_midi_127() {
2575        // G9 (midi=127) + 3 semitones → clamped to 127
2576        let p = transpose_pitch(&Pitch::new(Step::G, 9), 3);
2577        assert_eq!(p.to_midi(), 127);
2578    }
2579
2580    // ── merge ─────────────────────────────────────────────────────────────────
2581
2582    #[test]
2583    fn merge_combines_parts() {
2584        let mut a = Score::new("A", 120, 4, 4, 0, 2);
2585        let b = Score::new("B", 120, 4, 4, 0, 2);
2586        // Add a second part to score a
2587        let mut p2 = Part::new("Violin", "Vln.");
2588        p2.staves.push(Staff::new(Clef::Treble));
2589        for i in 0..2usize {
2590            let mut m = Measure::empty(4, 4);
2591            m.number = i as u32 + 1;
2592            p2.staves[0].measures.push(m);
2593        }
2594        a.parts.push(p2);
2595        let merged = a.merge(&b);
2596        // a has 2 parts, b has 1 part → merged has 3 parts
2597        assert_eq!(merged.parts.len(), 3);
2598    }
2599
2600    #[test]
2601    fn merge_pads_shorter_score() {
2602        let a = Score::new("A", 120, 4, 4, 0, 4);
2603        let b = Score::new("B", 120, 4, 4, 0, 2);
2604        let merged = a.merge(&b);
2605        // Both parts should have 4 measures
2606        assert_eq!(merged.parts[0].staves[0].measures.len(), 4);
2607        assert_eq!(merged.parts[1].staves[0].measures.len(), 4);
2608    }
2609
2610    #[test]
2611    fn merge_uses_self_metadata() {
2612        let mut a = Score::new("Title A", 120, 4, 4, 0, 2);
2613        a.metadata.composer = "Composer A".to_string();
2614        let b = Score::new("Title B", 120, 4, 4, 0, 2);
2615        let merged = a.merge(&b);
2616        assert_eq!(merged.metadata.title, "Title A");
2617        assert_eq!(merged.metadata.composer, "Composer A");
2618    }
2619
2620    #[test]
2621    fn merge_new_id_differs_from_both() {
2622        let a = Score::new("A", 120, 4, 4, 0, 2);
2623        let b = Score::new("B", 120, 4, 4, 0, 2);
2624        let merged = a.merge(&b);
2625        assert_ne!(merged.id, a.id);
2626        assert_ne!(merged.id, b.id);
2627    }
2628
2629    // ── Staff.transpose_semitones ─────────────────────────────────────────────
2630
2631    #[test]
2632    fn staff_default_transpose_is_zero() {
2633        let s = Staff::new(Clef::Treble);
2634        assert_eq!(s.transpose_semitones, 0);
2635    }
2636
2637    // ── schema_version ────────────────────────────────────────────────────────
2638
2639    #[test]
2640    fn score_default_has_schema_version_1() {
2641        let score = Score::default();
2642        assert_eq!(score.schema_version, 1);
2643    }
2644
2645    #[test]
2646    fn score_new_has_schema_version_1() {
2647        let score = Score::new("T", 120, 4, 4, 0, 4);
2648        assert_eq!(score.schema_version, 1);
2649    }
2650
2651    #[test]
2652    fn score_without_schema_version_deserializes_to_zero() {
2653        let json = r#"{"id":"abc","metadata":{"title":"T","composer":"","lyricist":"","copyright":"","work_number":"","movement_title":""},"settings":{"tempo_bpm":120,"time_signature":{"numerator":4,"denominator":4},"key_signature":{"fifths":0,"mode":"major"}},"parts":[]}"#;
2654        let score: Score = serde_json::from_str(json).unwrap();
2655        assert_eq!(score.schema_version, 0);
2656    }
2657
2658    #[test]
2659    fn note_without_new_percussion_fields_uses_serde_defaults() {
2660        let note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2661        let mut value = serde_json::to_value(note).unwrap();
2662        let object = value.as_object_mut().unwrap();
2663        object.remove("is_unpitched");
2664        object.remove("instrument_id");
2665        let restored: Note = serde_json::from_value(value).unwrap();
2666        assert!(!restored.is_unpitched);
2667        assert_eq!(restored.instrument_id, None);
2668    }
2669
2670    #[test]
2671    fn percussion_instrument_resolution_prefers_id_then_display_key() {
2672        let mut part = Part::new("Drums", "Dr.");
2673        part.percussion_instruments = vec![
2674            PercussionInstrument {
2675                id: "snare".to_string(),
2676                name: Some("Acoustic Snare".to_string()),
2677                midi_unpitched: Some(38),
2678            },
2679            PercussionInstrument {
2680                id: "rim".to_string(),
2681                name: Some("Side Stick".to_string()),
2682                midi_unpitched: Some(37),
2683            },
2684        ];
2685        let mut note = Note::new(Pitch::from_midi(38, false), Duration::Quarter);
2686        note.is_unpitched = true;
2687        assert_eq!(
2688            part.percussion_instrument_for_note(&note)
2689                .map(|instrument| instrument.id.as_str()),
2690            Some("snare")
2691        );
2692        note.instrument_id = Some("rim".to_string());
2693        assert_eq!(
2694            part.percussion_instrument_for_note(&note)
2695                .map(|instrument| instrument.id.as_str()),
2696            Some("rim")
2697        );
2698        note.instrument_id = Some("missing".to_string());
2699        assert!(part.percussion_instrument_for_note(&note).is_none());
2700        note.instrument_id = None;
2701        note.is_unpitched = false;
2702        assert!(part.percussion_instrument_for_note(&note).is_none());
2703    }
2704
2705    // ── ScoreTemplate ─────────────────────────────────────────────────────────
2706
2707    #[test]
2708    fn score_template_solo_has_one_part_treble() {
2709        let score = Score::template(ScoreTemplate::Solo);
2710        assert_eq!(score.parts.len(), 1);
2711        assert_eq!(score.parts[0].staves.len(), 1);
2712        assert_eq!(score.parts[0].staves[0].clef, Clef::Treble);
2713        assert_eq!(score.parts[0].midi_program, 0);
2714    }
2715
2716    #[test]
2717    fn score_template_piano_has_two_staves() {
2718        let score = Score::template(ScoreTemplate::Piano);
2719        assert_eq!(score.parts.len(), 1);
2720        assert_eq!(score.parts[0].staves.len(), 2);
2721        assert_eq!(score.parts[0].staves[0].clef, Clef::Treble);
2722        assert_eq!(score.parts[0].staves[1].clef, Clef::Bass);
2723    }
2724
2725    #[test]
2726    fn score_template_string_quartet_has_four_parts() {
2727        let score = Score::template(ScoreTemplate::StringQuartet);
2728        assert_eq!(score.parts.len(), 4);
2729        assert_eq!(score.parts[2].staves[0].clef, Clef::Alto); // Viola
2730        assert_eq!(score.parts[3].staves[0].clef, Clef::Bass); // Cello
2731        assert_eq!(score.parts[0].midi_program, 40);
2732        assert_eq!(score.parts[3].midi_program, 42);
2733    }
2734
2735    #[test]
2736    fn score_template_string_orchestra_has_five_parts() {
2737        let score = Score::template(ScoreTemplate::StringOrchestra);
2738        assert_eq!(score.parts.len(), 5);
2739        assert_eq!(score.parts[4].midi_program, 43); // Contrabass
2740    }
2741
2742    #[test]
2743    fn score_template_brass_quintet_has_five_parts() {
2744        let score = Score::template(ScoreTemplate::BrassQuintet);
2745        assert_eq!(score.parts.len(), 5);
2746        assert_eq!(score.parts[2].midi_program, 60); // French Horn
2747    }
2748
2749    #[test]
2750    fn score_template_default_measures_are_four() {
2751        let score = Score::template(ScoreTemplate::StringQuartet);
2752        for part in &score.parts {
2753            for staff in &part.staves {
2754                assert_eq!(staff.measures.len(), 4);
2755            }
2756        }
2757    }
2758
2759    // ── system_break / page_break ─────────────────────────────────────────────
2760
2761    #[test]
2762    fn measure_empty_has_no_breaks() {
2763        let m = Measure::empty(4, 4);
2764        assert!(!m.system_break);
2765        assert!(!m.page_break);
2766    }
2767
2768    #[test]
2769    fn system_break_survives_json_roundtrip() {
2770        let mut m = Measure::empty(4, 4);
2771        m.system_break = true;
2772        let json = serde_json::to_string(&m).unwrap();
2773        let m2: Measure = serde_json::from_str(&json).unwrap();
2774        assert!(m2.system_break);
2775        assert!(!m2.page_break);
2776    }
2777
2778    // ── diff ──────────────────────────────────────────────────────────────────
2779
2780    #[test]
2781    fn diff_identical_scores_is_empty() {
2782        let s = Score::new("T", 120, 4, 4, 0, 2);
2783        assert!(diff(&s, &s).is_empty());
2784    }
2785
2786    #[test]
2787    fn score_patch_covers_measure_semantics_and_note_insert_index() {
2788        let a = Score::new("T", 120, 4, 4, 0, 1);
2789        let mut b = a.clone();
2790        let measure = &mut b.parts[0].staves[0].measures[0];
2791        measure.key_sig = Some(KeySignature {
2792            fifths: -2,
2793            mode: "major".to_string(),
2794        });
2795        measure.time_sig = Some(TimeSignature {
2796            numerator: 3,
2797            denominator: 4,
2798        });
2799        measure.barline_left = Barline::RepeatStart;
2800        measure.barline_right = Barline::RepeatEnd;
2801        measure.rehearsal = Some("A".to_string());
2802        measure.volta = Some(VoltaBracket {
2803            number: 1,
2804            kind: "begin_end".to_string(),
2805        });
2806        measure.voices[0].insert(0, Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
2807        let expected = b.parts[0].staves[0].measures[0].clone();
2808
2809        let patches = score_patch(&a, &b);
2810        assert!(
2811            patches
2812                .iter()
2813                .any(|p| matches!(p, ScorePatch::SetTimeSignature { .. }))
2814        );
2815        assert!(
2816            patches
2817                .iter()
2818                .any(|p| matches!(p, ScorePatch::SetBarlines { .. }))
2819        );
2820        assert!(
2821            patches
2822                .iter()
2823                .any(|p| matches!(p, ScorePatch::SetRehearsal { .. }))
2824        );
2825        assert!(
2826            patches
2827                .iter()
2828                .any(|p| matches!(p, ScorePatch::SetVolta { .. }))
2829        );
2830        let result = apply_patch(&a, &patches).expect("patch application failed");
2831        let result_measure = &result.parts[0].staves[0].measures[0];
2832        assert_eq!(result_measure.key_sig, expected.key_sig);
2833        assert_eq!(result_measure.time_sig, expected.time_sig);
2834        assert_eq!(result_measure.barline_left, expected.barline_left);
2835        assert_eq!(result_measure.barline_right, expected.barline_right);
2836        assert_eq!(result_measure.rehearsal, expected.rehearsal);
2837        assert_eq!(result_measure.volta, expected.volta);
2838        assert_eq!(result_measure.voices[0].len(), expected.voices[0].len());
2839    }
2840
2841    #[test]
2842    fn score_patch_replaces_when_structure_or_uncovered_fields_change() {
2843        let a = Score::new("T", 120, 4, 4, 0, 1);
2844        let mut b = a.clone();
2845        b.parts[0].name = "Piano".to_string();
2846        b.parts[0].staves[0].measures[0].expression_text = Some("dolce".to_string());
2847        let patches = score_patch(&a, &b);
2848        assert!(matches!(
2849            patches.as_slice(),
2850            [ScorePatch::ReplaceScore { .. }]
2851        ));
2852        let result = apply_patch(&a, &patches).expect("replacement failed");
2853        assert_eq!(result.parts[0].name, "Piano");
2854        assert_eq!(
2855            result.parts[0].staves[0].measures[0].expression_text,
2856            Some("dolce".to_string())
2857        );
2858    }
2859
2860    #[test]
2861    fn diff_detects_tempo_change() {
2862        let a = Score::new("T", 120, 4, 4, 0, 1);
2863        let mut b = a.clone();
2864        b.settings.tempo_bpm = 90;
2865        let changes = diff(&a, &b);
2866        assert_eq!(changes.len(), 1);
2867        assert!(matches!(
2868            changes[0],
2869            ScoreChange::TempoChanged { old: 120, new: 90 }
2870        ));
2871    }
2872
2873    #[test]
2874    fn diff_detects_title_change() {
2875        let a = Score::new("Old Title", 120, 4, 4, 0, 1);
2876        let mut b = a.clone();
2877        b.metadata.title = "New Title".to_string();
2878        let changes = diff(&a, &b);
2879        assert!(
2880            changes.iter().any(
2881                |c| matches!(c, ScoreChange::MetadataChanged { field, .. } if field == "title")
2882            )
2883        );
2884    }
2885
2886    #[test]
2887    fn diff_detects_note_modification() {
2888        let mut a = Score::new("T", 120, 4, 4, 0, 1);
2889        a.parts[0].staves[0].measures[0].voices[0] =
2890            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
2891        let mut b = a.clone();
2892        b.parts[0].staves[0].measures[0].voices[0][0] =
2893            Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
2894        let changes = diff(&a, &b);
2895        assert!(
2896            changes
2897                .iter()
2898                .any(|c| matches!(c, ScoreChange::NoteModified { .. }))
2899        );
2900    }
2901
2902    #[test]
2903    fn diff_detects_part_added() {
2904        let a = Score::new("T", 120, 4, 4, 0, 1);
2905        let mut b = a.clone();
2906        let mut p = Part::new("Violin", "Vln.");
2907        p.staves.push(Staff::new(Clef::Treble));
2908        b.parts.push(p);
2909        let changes = diff(&a, &b);
2910        assert!(
2911            changes
2912                .iter()
2913                .any(|c| matches!(c, ScoreChange::PartAdded { part_index: 1 }))
2914        );
2915    }
2916
2917    #[test]
2918    fn diff_detects_measure_tempo_change() {
2919        let a = Score::new("T", 120, 4, 4, 0, 2);
2920        let mut b = a.clone();
2921        b.parts[0].staves[0].measures[1].tempo = Some(60);
2922        let changes = diff(&a, &b);
2923        assert!(changes.iter().any(|c| matches!(
2924            c,
2925            ScoreChange::MeasureTempoChanged {
2926                measure: 1,
2927                old: None,
2928                new: Some(60),
2929                ..
2930            }
2931        )));
2932    }
2933
2934    #[test]
2935    fn diff_detects_barline_change() {
2936        use crate::model::notation::Barline;
2937        let a = Score::new("T", 120, 4, 4, 0, 2);
2938        let mut b = a.clone();
2939        b.parts[0].staves[0].measures[0].barline_left = Barline::RepeatStart;
2940        let changes = diff(&a, &b);
2941        assert!(
2942            changes
2943                .iter()
2944                .any(|c| matches!(c, ScoreChange::BarlineChanged { measure: 0, .. }))
2945        );
2946    }
2947
2948    #[test]
2949    fn diff_detects_rehearsal_change() {
2950        let a = Score::new("T", 120, 4, 4, 0, 2);
2951        let mut b = a.clone();
2952        b.parts[0].staves[0].measures[0].rehearsal = Some("A".to_string());
2953        let changes = diff(&a, &b);
2954        assert!(
2955            changes
2956                .iter()
2957                .any(|c| matches!(c, ScoreChange::RehearsalMarkChanged { measure: 0, .. }))
2958        );
2959    }
2960
2961    #[test]
2962    fn diff_detects_volta_change() {
2963        use super::VoltaBracket;
2964        let a = Score::new("T", 120, 4, 4, 0, 2);
2965        let mut b = a.clone();
2966        b.parts[0].staves[0].measures[0].volta = Some(VoltaBracket {
2967            number: 1,
2968            kind: "begin_end".into(),
2969        });
2970        let changes = diff(&a, &b);
2971        assert!(
2972            changes
2973                .iter()
2974                .any(|c| matches!(c, ScoreChange::VoltaChanged { measure: 0, .. }))
2975        );
2976    }
2977
2978    #[test]
2979    fn diff_detects_key_signature_change() {
2980        let a = Score::new("T", 120, 4, 4, 0, 1);
2981        let mut b = a.clone();
2982        b.settings.key_signature.fifths = 2; // C major → D major
2983        let changes = diff(&a, &b);
2984        assert!(
2985            changes
2986                .iter()
2987                .any(|c| matches!(c, ScoreChange::KeySignatureChanged { .. }))
2988        );
2989    }
2990
2991    #[test]
2992    fn diff_same_key_signature_no_change() {
2993        let a = Score::new("T", 120, 4, 4, 2, 1);
2994        let changes = diff(&a, &a);
2995        assert!(changes.is_empty());
2996    }
2997
2998    #[test]
2999    fn score_duration_secs_region_partial() {
3000        use super::score_duration_secs_region;
3001        // 4/4, 120 BPM, 4 measures → each measure = 2.0 s; region [1,2] = 4.0 s
3002        let score = Score::new("T", 120, 4, 4, 0, 4);
3003        let secs = score_duration_secs_region(&score, (1, 2));
3004        assert!((secs - 4.0).abs() < 0.01, "expected ~4.0 s, got {secs}");
3005    }
3006
3007    #[test]
3008    fn score_duration_secs_region_single_measure() {
3009        use super::score_duration_secs_region;
3010        // 4/4, 120 BPM → 1 measure = 2.0 s
3011        let score = Score::new("T", 120, 4, 4, 0, 4);
3012        let secs = score_duration_secs_region(&score, (0, 0));
3013        assert!((secs - 2.0).abs() < 0.01, "expected ~2.0 s, got {secs}");
3014    }
3015
3016    // ── measure_beats_remaining ───────────────────────────────────────────────
3017
3018    #[test]
3019    fn measure_beats_remaining_empty_voice_returns_full() {
3020        use super::measure_beats_remaining;
3021        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3022        score.parts[0].staves[0].measures[0].voices[0].clear();
3023        let rem = measure_beats_remaining(&score, 0, 0, 0, 0).unwrap();
3024        assert!(
3025            (rem - 4.0).abs() < 1e-9,
3026            "expected 4.0 remaining, got {rem}"
3027        );
3028    }
3029
3030    #[test]
3031    fn measure_beats_remaining_half_full_returns_half() {
3032        use super::measure_beats_remaining;
3033        use crate::model::pitch::Step;
3034        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3035        score.parts[0].staves[0].measures[0].voices[0] = vec![
3036            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
3037            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
3038        ];
3039        let rem = measure_beats_remaining(&score, 0, 0, 0, 0).unwrap();
3040        assert!(
3041            (rem - 2.0).abs() < 1e-9,
3042            "expected 2.0 remaining, got {rem}"
3043        );
3044    }
3045
3046    #[test]
3047    fn measure_beats_remaining_full_voice_returns_zero() {
3048        use super::measure_beats_remaining;
3049        use crate::model::pitch::Step;
3050        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3051        score.parts[0].staves[0].measures[0].voices[0] =
3052            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
3053        let rem = measure_beats_remaining(&score, 0, 0, 0, 0).unwrap();
3054        assert!((rem).abs() < 1e-9, "expected 0.0 remaining, got {rem}");
3055    }
3056
3057    #[test]
3058    fn measure_beats_remaining_tuplet_accounting() {
3059        use super::measure_beats_remaining;
3060        use crate::model::notation::TupletInfo;
3061        use crate::model::pitch::Step;
3062        // 3 quarter-note triplets each take 2/3 of a beat → total 2.0 beats used → 2.0 remaining
3063        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3064        let tuplet = TupletInfo {
3065            actual_notes: 3,
3066            normal_notes: 2,
3067        };
3068        let mk = |step| {
3069            let mut n = Note::new(Pitch::new(step, 4), Duration::Quarter);
3070            n.tuplet = Some(tuplet.clone());
3071            n
3072        };
3073        score.parts[0].staves[0].measures[0].voices[0] =
3074            vec![mk(Step::C), mk(Step::D), mk(Step::E)];
3075        let rem = measure_beats_remaining(&score, 0, 0, 0, 0).unwrap();
3076        assert!(
3077            (rem - 2.0).abs() < 1e-9,
3078            "expected 2.0 remaining (triplets used 2.0), got {rem}"
3079        );
3080    }
3081
3082    #[test]
3083    fn measure_beats_remaining_out_of_range_returns_err() {
3084        use super::measure_beats_remaining;
3085        let score = Score::new("T", 120, 4, 4, 0, 1);
3086        assert!(measure_beats_remaining(&score, 99, 0, 0, 0).is_err());
3087        assert!(measure_beats_remaining(&score, 0, 99, 0, 0).is_err());
3088        assert!(measure_beats_remaining(&score, 0, 0, 99, 0).is_err());
3089        assert!(measure_beats_remaining(&score, 0, 0, 0, 4).is_err());
3090    }
3091
3092    #[test]
3093    fn note_content_eq_ignores_id() {
3094        use crate::model::pitch::Step;
3095        let mut a = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3096        let mut b = a.clone();
3097        b.id = "different-id".to_string();
3098        assert!(note_content_eq(&a, &b));
3099        // Actual pitch change should differ
3100        b.pitches[0] = Pitch::new(Step::D, 4);
3101        assert!(!note_content_eq(&a, &b));
3102        // stem_up difference
3103        let mut c = a.clone();
3104        a.stem_up = Some(true);
3105        c.stem_up = Some(false);
3106        assert!(!note_content_eq(&a, &c));
3107    }
3108
3109    #[test]
3110    fn suggested_stem_up_below_middle() {
3111        use crate::model::notation::Clef;
3112        // C4 = MIDI 60, Treble middle = B4 = 71 → stem up
3113        let pitches = vec![Pitch::new(Step::C, 4)];
3114        assert!(suggested_stem_up(&pitches, &Clef::Treble));
3115    }
3116
3117    #[test]
3118    fn suggested_stem_up_above_middle() {
3119        use crate::model::notation::Clef;
3120        // G5 = MIDI 79, Treble middle = 71 → stem down
3121        let pitches = vec![Pitch::new(Step::G, 5)];
3122        assert!(!suggested_stem_up(&pitches, &Clef::Treble));
3123    }
3124
3125    #[test]
3126    fn suggested_stem_up_at_middle_line() {
3127        use crate::model::notation::Clef;
3128        // B4 = MIDI 71, Treble middle = 71 → stem down (avg >= middle)
3129        let pitches = vec![Pitch::new(Step::B, 4)];
3130        assert!(!suggested_stem_up(&pitches, &Clef::Treble));
3131    }
3132
3133    #[test]
3134    fn suggested_stem_up_chord() {
3135        use crate::model::notation::Clef;
3136        // [C4=60, G4=67] avg=63.5 < 71 → stem up
3137        let pitches = vec![Pitch::new(Step::C, 4), Pitch::new(Step::G, 4)];
3138        assert!(suggested_stem_up(&pitches, &Clef::Treble));
3139    }
3140
3141    #[test]
3142    fn suggested_stem_up_bass_clef() {
3143        use crate::model::notation::Clef;
3144        // D3=50 is exactly at Bass middle line → stem down
3145        let pitches = vec![Pitch::new(Step::D, 3)];
3146        assert!(!suggested_stem_up(&pitches, &Clef::Bass));
3147        // C3=48 < 50 → stem up
3148        let pitches2 = vec![Pitch::new(Step::C, 3)];
3149        assert!(suggested_stem_up(&pitches2, &Clef::Bass));
3150    }
3151
3152    #[test]
3153    fn suggested_stem_up_empty_pitches() {
3154        use crate::model::notation::Clef;
3155        assert!(suggested_stem_up(&[], &Clef::Treble));
3156    }
3157
3158    fn eighth(pitch: Pitch) -> Note {
3159        Note::new(pitch, Duration::Eighth)
3160    }
3161    fn quarter(pitch: Pitch) -> Note {
3162        Note::new(pitch, Duration::Quarter)
3163    }
3164    fn rest_eighth() -> Note {
3165        Note::rest(Duration::Eighth)
3166    }
3167
3168    #[test]
3169    fn compute_beams_4_4_four_eighths() {
3170        use crate::model::notation::{Clef, TimeSignature};
3171        let _ = Clef::Treble; // suppress unused import warning
3172        let ts = TimeSignature {
3173            numerator: 4,
3174            denominator: 4,
3175        };
3176        let c4 = Pitch::new(Step::C, 4);
3177        let notes = vec![
3178            eighth(c4.clone()),
3179            eighth(c4.clone()),
3180            eighth(c4.clone()),
3181            eighth(c4.clone()),
3182        ];
3183        let beams = compute_beams(&notes, &ts);
3184        // 4 eighths in 4/4: beat size=1.0, two groups of 2 each
3185        assert_eq!(beams[0], BeamState::Begin);
3186        assert_eq!(beams[1], BeamState::End);
3187        assert_eq!(beams[2], BeamState::Begin);
3188        assert_eq!(beams[3], BeamState::End);
3189    }
3190
3191    #[test]
3192    fn compute_beams_4_4_all_eighth_one_group() {
3193        use crate::model::notation::TimeSignature;
3194        let ts = TimeSignature {
3195            numerator: 4,
3196            denominator: 4,
3197        };
3198        let c4 = Pitch::new(Step::C, 4);
3199        // 2 eighths in a beat → group of 2
3200        let notes = vec![eighth(c4.clone()), eighth(c4.clone())];
3201        let beams = compute_beams(&notes, &ts);
3202        assert_eq!(beams[0], BeamState::Begin);
3203        assert_eq!(beams[1], BeamState::End);
3204    }
3205
3206    #[test]
3207    fn compute_beams_quarter_not_beamed() {
3208        use crate::model::notation::TimeSignature;
3209        let ts = TimeSignature {
3210            numerator: 4,
3211            denominator: 4,
3212        };
3213        let c4 = Pitch::new(Step::C, 4);
3214        let notes = vec![quarter(c4.clone()), quarter(c4.clone())];
3215        let beams = compute_beams(&notes, &ts);
3216        assert_eq!(beams[0], BeamState::None);
3217        assert_eq!(beams[1], BeamState::None);
3218    }
3219
3220    #[test]
3221    fn compute_beams_rest_breaks_beam() {
3222        use crate::model::notation::TimeSignature;
3223        let ts = TimeSignature {
3224            numerator: 4,
3225            denominator: 4,
3226        };
3227        let c4 = Pitch::new(Step::C, 4);
3228        let notes = vec![eighth(c4.clone()), rest_eighth(), eighth(c4.clone())];
3229        let beams = compute_beams(&notes, &ts);
3230        // rest breaks beam group
3231        assert_eq!(beams[0], BeamState::None);
3232        assert_eq!(beams[1], BeamState::None);
3233        assert_eq!(beams[2], BeamState::None);
3234    }
3235
3236    #[test]
3237    fn compute_beams_6_8_compound() {
3238        use crate::model::notation::TimeSignature;
3239        let ts = TimeSignature {
3240            numerator: 6,
3241            denominator: 8,
3242        };
3243        let c4 = Pitch::new(Step::C, 4);
3244        // 6 eighths in 6/8 compound → two groups of 3 (beam size=1.5 beats)
3245        let notes: Vec<Note> = (0..6).map(|_| eighth(c4.clone())).collect();
3246        let beams = compute_beams(&notes, &ts);
3247        assert_eq!(beams[0], BeamState::Begin);
3248        assert_eq!(beams[1], BeamState::Continue);
3249        assert_eq!(beams[2], BeamState::End);
3250        assert_eq!(beams[3], BeamState::Begin);
3251        assert_eq!(beams[4], BeamState::Continue);
3252        assert_eq!(beams[5], BeamState::End);
3253    }
3254
3255    #[test]
3256    fn compute_beams_single_eighth() {
3257        use crate::model::notation::TimeSignature;
3258        let ts = TimeSignature {
3259            numerator: 4,
3260            denominator: 4,
3261        };
3262        let c4 = Pitch::new(Step::C, 4);
3263        let notes = vec![eighth(c4.clone())];
3264        let beams = compute_beams(&notes, &ts);
3265        assert_eq!(beams[0], BeamState::None);
3266    }
3267}