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    /// Typed notation spans. Legacy note-level boolean endpoints remain supported during migration.
110    #[serde(default)]
111    pub spanners: Vec<NotationSpanner>,
112}
113
114/// Bounded notation span kinds with stable source identity.
115#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
116pub enum NotationSpannerKind {
117    Slur,
118    Glissando,
119    TrillLine,
120    Pedal,
121    Ottava,
122}
123
124/// A typed, potentially cross-staff notation span between two canonical note addresses.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct NotationSpanner {
127    pub id: String,
128    pub kind: NotationSpannerKind,
129    pub start: NoteAddr,
130    pub end: NoteAddr,
131    #[serde(default)]
132    pub number: Option<u16>,
133    #[serde(default)]
134    pub line_type: Option<String>,
135    #[serde(default)]
136    pub text: Option<String>,
137    #[serde(default)]
138    pub placement: Option<String>,
139    #[serde(default)]
140    pub ottava_size: Option<u8>,
141    /// MusicXML octave-shift direction (`up` or `down`) when this is an ottava span.
142    #[serde(default)]
143    pub ottava_type: Option<String>,
144}
145
146impl Default for Score {
147    fn default() -> Self {
148        let mut part = Part::new("Piano", "Pno.");
149        part.staves.push(Staff::new(Clef::Treble));
150        for _ in 0..4 {
151            part.staves[0].measures.push(Measure::empty(4, 4));
152        }
153        Self {
154            id: Uuid::new_v4().to_string(),
155            schema_version: 1,
156            metadata: ScoreMetadata::default(),
157            settings: ScoreSettings::default(),
158            parts: vec![part],
159            part_groups: Vec::new(),
160            texts: Vec::new(),
161            chord_definitions: Vec::new(),
162            spanners: Vec::new(),
163        }
164    }
165}
166
167/// Score template presets for common ensemble configurations.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
169pub enum ScoreTemplate {
170    /// Single treble-clef part (piano by default).
171    Solo,
172    /// One piano part with treble + bass grand staff.
173    Piano,
174    /// Violin I, Violin II, Viola, Cello.
175    StringQuartet,
176    /// Violin I, Violin II, Viola, Cello, Contrabass.
177    StringOrchestra,
178    /// Two trumpets, French horn, trombone, tuba.
179    BrassQuintet,
180}
181
182impl Score {
183    pub fn new(
184        title: &str,
185        tempo_bpm: u16,
186        numerator: u8,
187        denominator: u8,
188        fifths: i8,
189        measure_count: u32,
190    ) -> Self {
191        let mut score = Score::default();
192        score.metadata.title = title.to_string();
193        score.settings.tempo_bpm = tempo_bpm;
194        score.settings.time_signature = TimeSignature {
195            numerator,
196            denominator,
197        };
198        score.settings.key_signature = KeySignature {
199            fifths,
200            mode: "major".to_string(),
201        };
202
203        score.parts[0].staves[0].measures.clear();
204        for i in 0..measure_count {
205            let mut m = Measure::empty(numerator, denominator);
206            m.number = i + 1;
207            score.parts[0].staves[0].measures.push(m);
208        }
209        score
210    }
211
212    /// Create a score pre-populated with parts for the given ensemble template.
213    ///
214    /// Defaults: 120 BPM, 4/4, C major, 4 empty measures.
215    /// Use [`NewScoreCmd`](crate::model::commands::NewScoreCmd) to override those after creation.
216    pub fn template(kind: ScoreTemplate) -> Self {
217        fn measures(num: u8, den: u8, count: u32) -> Vec<Measure> {
218            (0..count)
219                .map(|i| {
220                    let mut m = Measure::empty(num, den);
221                    m.number = i + 1;
222                    m
223                })
224                .collect()
225        }
226        fn part(name: &str, short: &str, clef: Clef, program: u8) -> Part {
227            let mut p = Part::new(name, short);
228            p.midi_program = program;
229            let mut s = Staff::new(clef);
230            s.measures = measures(4, 4, 4);
231            p.staves.push(s);
232            p
233        }
234
235        let mut score = Score {
236            id: uuid::Uuid::new_v4().to_string(),
237            schema_version: 1,
238            metadata: ScoreMetadata::default(),
239            settings: ScoreSettings::default(),
240            parts: Vec::new(),
241            part_groups: Vec::new(),
242            texts: Vec::new(),
243            chord_definitions: Vec::new(),
244            spanners: Vec::new(),
245        };
246
247        match kind {
248            ScoreTemplate::Solo => {
249                score.parts.push(part("Piano", "Pno.", Clef::Treble, 0));
250            }
251            ScoreTemplate::Piano => {
252                let mut p = Part::new("Piano", "Pno.");
253                p.midi_program = 0;
254                let mut treble = Staff::new(Clef::Treble);
255                treble.measures = measures(4, 4, 4);
256                let mut bass = Staff::new(Clef::Bass);
257                bass.measures = measures(4, 4, 4);
258                p.staves.push(treble);
259                p.staves.push(bass);
260                score.parts.push(p);
261            }
262            ScoreTemplate::StringQuartet => {
263                score
264                    .parts
265                    .push(part("Violin I", "Vn. I", Clef::Treble, 40));
266                score
267                    .parts
268                    .push(part("Violin II", "Vn. II", Clef::Treble, 40));
269                score.parts.push(part("Viola", "Va.", Clef::Alto, 41));
270                score.parts.push(part("Cello", "Vc.", Clef::Bass, 42));
271            }
272            ScoreTemplate::StringOrchestra => {
273                score
274                    .parts
275                    .push(part("Violin I", "Vn. I", Clef::Treble, 40));
276                score
277                    .parts
278                    .push(part("Violin II", "Vn. II", Clef::Treble, 40));
279                score.parts.push(part("Viola", "Va.", Clef::Alto, 41));
280                score.parts.push(part("Cello", "Vc.", Clef::Bass, 42));
281                score.parts.push(part("Contrabass", "Cb.", Clef::Bass, 43));
282            }
283            ScoreTemplate::BrassQuintet => {
284                score
285                    .parts
286                    .push(part("Trumpet I", "Tpt. I", Clef::Treble, 56));
287                score
288                    .parts
289                    .push(part("Trumpet II", "Tpt. II", Clef::Treble, 56));
290                score
291                    .parts
292                    .push(part("French Horn", "Hn.", Clef::Treble, 60));
293                score.parts.push(part("Trombone", "Tbn.", Clef::Bass, 57));
294                score.parts.push(part("Tuba", "Tba.", Clef::Bass, 58));
295            }
296        }
297        score
298    }
299
300    pub fn measure_count(&self) -> usize {
301        self.parts
302            .first()
303            .and_then(|p| p.staves.first())
304            .map(|s| s.measures.len())
305            .unwrap_or(0)
306    }
307
308    /// Aggregate statistics about the score.
309    pub fn statistics(&self) -> ScoreStats {
310        let measure_count = self.measure_count();
311        let part_count = self.parts.len();
312
313        // Beat accumulation via measure_sequence so repeats are counted correctly.
314        let seq = measure_sequence(self);
315        let total_beats: f64 = self
316            .parts
317            .first()
318            .and_then(|p| p.staves.first())
319            .map(|s| {
320                seq.iter()
321                    .filter_map(|&idx| s.measures.get(idx))
322                    .flat_map(|m| m.voices.iter().flat_map(|v| v.iter()))
323                    .map(|n| n.beats())
324                    .sum()
325            })
326            .unwrap_or(0.0);
327
328        let mut note_count = 0usize;
329        let mut rest_count = 0usize;
330        for part in &self.parts {
331            for staff in &part.staves {
332                for measure in &staff.measures {
333                    for voice in &measure.voices {
334                        for note in voice {
335                            if note.is_rest {
336                                rest_count += 1;
337                            } else {
338                                note_count += 1;
339                            }
340                        }
341                    }
342                }
343            }
344        }
345
346        let bpm = self.settings.tempo_bpm as f64;
347        let estimated_duration_secs = if bpm > 0.0 {
348            total_beats / bpm * 60.0
349        } else {
350            0.0
351        };
352
353        ScoreStats {
354            measure_count,
355            note_count,
356            rest_count,
357            part_count,
358            estimated_duration_secs,
359        }
360    }
361
362    /// Return a new `Score` containing only the given part.
363    /// Returns `None` if `part_index` is out of range.
364    pub fn extract_part(&self, part_index: usize) -> Option<Score> {
365        let part = self.parts.get(part_index)?.clone();
366        let spanners = self
367            .spanners
368            .iter()
369            .filter(|spanner| spanner.start.part == part_index && spanner.end.part == part_index)
370            .cloned()
371            .map(|mut spanner| {
372                spanner.start.part = 0;
373                spanner.end.part = 0;
374                spanner
375            })
376            .collect();
377        Some(Score {
378            id: Uuid::new_v4().to_string(),
379            schema_version: 1,
380            metadata: self.metadata.clone(),
381            settings: self.settings.clone(),
382            parts: vec![part],
383            part_groups: Vec::new(),
384            texts: self.texts.clone(),
385            chord_definitions: self.chord_definitions.clone(),
386            spanners,
387        })
388    }
389
390    /// Extract a part through the validated transformation boundary.
391    pub fn extract_part_checked(&self, part_index: usize) -> Result<Score, Error> {
392        if !super::validate::validate(self).is_valid() {
393            return Err(Error::InvalidScore);
394        }
395        let extracted = self
396            .extract_part(part_index)
397            .ok_or(Error::PartNotFound(part_index))?;
398        if !super::validate::validate(&extracted).is_valid() {
399            return Err(Error::InvalidScore);
400        }
401        Ok(extracted)
402    }
403
404    /// Merge two scores by appending `other`'s parts to `self`'s parts.
405    /// Shorter scores are padded with empty measures to match the longer one.
406    /// Metadata and settings are taken from `self`.
407    pub fn merge(&self, other: &Score) -> Score {
408        let self_count = self.measure_count();
409        let other_count = other.measure_count();
410        let max_count = self_count.max(other_count);
411        let ts = self.settings.time_signature.clone();
412
413        let pad = |mut part: Part, from: usize| -> Part {
414            for staff in &mut part.staves {
415                for i in from..max_count {
416                    let mut m = Measure::empty(ts.numerator, ts.denominator);
417                    m.number = i as u32 + 1;
418                    staff.measures.push(m);
419                }
420            }
421            part
422        };
423
424        let mut parts: Vec<Part> = self
425            .parts
426            .iter()
427            .cloned()
428            .map(|p| pad(p, self_count))
429            .collect();
430        for p in &other.parts {
431            parts.push(pad(p.clone(), other_count));
432        }
433        let self_part_count = self.parts.len();
434        let mut spanners = self.spanners.clone();
435        spanners.extend(other.spanners.iter().cloned().map(|mut spanner| {
436            spanner.start.part += self_part_count;
437            spanner.end.part += self_part_count;
438            spanner
439        }));
440
441        Score {
442            id: Uuid::new_v4().to_string(),
443            schema_version: 1,
444            metadata: self.metadata.clone(),
445            settings: self.settings.clone(),
446            parts,
447            part_groups: Vec::new(),
448            texts: self.texts.clone(),
449            chord_definitions: self.chord_definitions.clone(),
450            spanners,
451        }
452    }
453
454    /// Merge scores through the validated transformation boundary.
455    pub fn merge_checked(&self, other: &Score) -> Result<Score, Error> {
456        if !super::validate::validate(self).is_valid()
457            || !super::validate::validate(other).is_valid()
458        {
459            return Err(Error::InvalidScore);
460        }
461        let merged = self.merge(other);
462        if !super::validate::validate(&merged).is_valid() {
463            return Err(Error::InvalidScore);
464        }
465        Ok(merged)
466    }
467}
468
469/// Aggregate statistics returned by [`Score::statistics`].
470#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
471pub struct ScoreStats {
472    pub measure_count: usize,
473    /// Number of non-rest notes across all parts.
474    pub note_count: usize,
475    pub rest_count: usize,
476    pub part_count: usize,
477    /// Rough estimate: `total_beats(first part) / tempo_bpm * 60`.
478    pub estimated_duration_secs: f64,
479}
480
481// ── transpose ─────────────────────────────────────────────────────────────────
482
483use super::pitch::Step;
484use super::repeat::measure_sequence;
485
486/// Assign deterministic guitar tablature positions to eligible notes and chords.
487///
488/// Existing positions are preserved. For each note, the lowest reachable fret is
489/// selected, breaking ties toward the highest string (the lowest string number). For
490/// chords, strings are unique and the assignment minimizes total fret, then fret
491/// span, then highest fret. This is a bounded deterministic fingering heuristic,
492/// not a claim of instrument-specific playability.
493/// The staff tuning is interpreted as open-string MIDI pitches before capo, and
494/// assignments are limited to frets 0 through 24. Rests, chords, and notes with
495/// no reachable position are left unchanged. Returns the number of notes/chords
496/// assigned.
497pub fn assign_tablature_positions(score: &mut Score) -> usize {
498    const MAX_FRET: i16 = 24;
499    let mut assigned = 0;
500
501    for part in &mut score.parts {
502        for staff in &mut part.staves {
503            let Some(tab) = &staff.tablature else {
504                continue;
505            };
506            let tuning = tab.tuning_midi.clone();
507            let lines = tab.lines as usize;
508            let capo = i16::from(tab.capo);
509
510            for measure in &mut staff.measures {
511                for voice in &mut measure.voices {
512                    for note in voice.iter_mut() {
513                        if note.is_rest
514                            || note.tab_position.is_some()
515                            || !note.tab_positions.is_empty()
516                            || note.pitches.is_empty()
517                        {
518                            continue;
519                        }
520                        let pitches: Vec<i16> = note.pitches.iter().map(Pitch::to_midi).collect();
521                        let Some(positions) =
522                            best_tablature_assignment(&pitches, &tuning, lines, capo, MAX_FRET)
523                        else {
524                            continue;
525                        };
526
527                        note.tab_position = positions.first().cloned();
528                        note.tab_positions = positions;
529                        note.string_number = note.tab_position.as_ref().map(|p| p.string);
530                        assigned += 1;
531                    }
532                }
533            }
534        }
535    }
536
537    assigned
538}
539
540/// Optimize tablature positions across each voice using bounded position movement.
541///
542/// Explicit positions are treated as fixed anchors. Unassigned notes and chords
543/// receive positions that minimize fret load plus movement from the preceding
544/// event; ties are resolved deterministically. Returns the number of notes/chords
545/// newly assigned.
546pub fn optimize_tablature_positions(score: &mut Score) -> usize {
547    const MAX_FRET: i16 = 24;
548    let mut assigned = 0;
549
550    for part in &mut score.parts {
551        for staff in &mut part.staves {
552            let Some(tab) = &staff.tablature else {
553                continue;
554            };
555            let tuning = tab.tuning_midi.clone();
556            let lines = tab.lines as usize;
557            let capo = i16::from(tab.capo);
558            for voice in 0..4 {
559                // Work per measure/voice location so repeated note indices remain distinct.
560                let mut locations = Vec::new();
561                for (measure_index, measure) in staff.measures.iter().enumerate() {
562                    for (note_index, note) in measure.voices[voice].iter().enumerate() {
563                        if !note.is_rest && !note.pitches.is_empty() {
564                            locations.push((measure_index, note_index, note.clone()));
565                        }
566                    }
567                }
568                let candidates: Vec<Vec<Vec<TabPosition>>> = locations
569                    .iter()
570                    .map(|(_, _, note)| {
571                        if let Some(positions) = if !note.tab_positions.is_empty() {
572                            Some(note.tab_positions.clone())
573                        } else {
574                            note.tab_position.clone().map(|position| vec![position])
575                        } {
576                            vec![positions]
577                        } else {
578                            tablature_assignments(
579                                &note.pitches.iter().map(Pitch::to_midi).collect::<Vec<_>>(),
580                                &tuning,
581                                lines,
582                                capo,
583                                MAX_FRET,
584                            )
585                        }
586                    })
587                    .collect();
588                if candidates.iter().any(Vec::is_empty) {
589                    continue;
590                }
591
592                let mut costs: Vec<Vec<(u32, Option<usize>)>> = candidates
593                    .iter()
594                    .map(|events| vec![(u32::MAX, None); events.len()])
595                    .collect();
596                for (candidate_index, candidate) in candidates[0].iter().enumerate() {
597                    costs[0][candidate_index] = (tablature_load(candidate), None);
598                }
599                for event_index in 1..candidates.len() {
600                    for (candidate_index, candidate) in candidates[event_index].iter().enumerate() {
601                        let load = tablature_load(candidate);
602                        for (previous_index, previous) in
603                            candidates[event_index - 1].iter().enumerate()
604                        {
605                            let previous_cost = costs[event_index - 1][previous_index].0;
606                            let cost = previous_cost
607                                .saturating_add(load)
608                                .saturating_add(tablature_movement(previous, candidate));
609                            if cost < costs[event_index][candidate_index].0 {
610                                costs[event_index][candidate_index] = (cost, Some(previous_index));
611                            }
612                        }
613                    }
614                }
615                let mut selected = vec![0; candidates.len()];
616                if let Some((last, _)) = costs.last().and_then(|row| {
617                    row.iter()
618                        .enumerate()
619                        .min_by_key(|(index, (cost, _))| (*cost, *index))
620                }) {
621                    selected[candidates.len() - 1] = last;
622                    for event_index in (1..candidates.len()).rev() {
623                        selected[event_index - 1] =
624                            costs[event_index][selected[event_index]].1.unwrap_or(0);
625                    }
626                }
627
628                for (((measure_index, note_index, original), event_candidates), selected_index) in
629                    locations.into_iter().zip(candidates).zip(selected)
630                {
631                    if original.tab_position.is_none() && original.tab_positions.is_empty() {
632                        let note = &mut staff.measures[measure_index].voices[voice][note_index];
633                        let positions = event_candidates[selected_index].clone();
634                        note.tab_position = positions.first().cloned();
635                        note.tab_positions = positions;
636                        note.string_number = note.tab_position.as_ref().map(|p| p.string);
637                        assigned += 1;
638                    }
639                }
640            }
641        }
642    }
643
644    assigned
645}
646
647fn tablature_load(positions: &[TabPosition]) -> u32 {
648    let sum: u32 = positions
649        .iter()
650        .map(|position| u32::from(position.fret))
651        .sum();
652    let min = positions
653        .iter()
654        .map(|position| position.fret)
655        .min()
656        .unwrap_or(0);
657    let max = positions
658        .iter()
659        .map(|position| position.fret)
660        .max()
661        .unwrap_or(0);
662    let span = max - min;
663    // A four-fret hand position is a practical baseline; wide chords receive
664    // a strong penalty so a higher but compact voicing wins when available.
665    let stretch_penalty = span.saturating_sub(4) as u32 * 12;
666    sum + u32::from(span) * 2 + stretch_penalty
667}
668
669fn tablature_movement(previous: &[TabPosition], current: &[TabPosition]) -> u32 {
670    previous
671        .iter()
672        .zip(current)
673        .map(|(a, b)| u32::from(a.fret.abs_diff(b.fret)) + u32::from(a.string.abs_diff(b.string)))
674        .sum()
675}
676
677fn tablature_assignments(
678    pitches: &[i16],
679    tuning: &[i16],
680    lines: usize,
681    capo: i16,
682    max_fret: i16,
683) -> Vec<Vec<TabPosition>> {
684    #[allow(clippy::too_many_arguments)]
685    fn visit(
686        pitches: &[i16],
687        tuning: &[i16],
688        lines: usize,
689        capo: i16,
690        max_fret: i16,
691        index: usize,
692        used: &mut [bool],
693        current: &mut Vec<TabPosition>,
694        output: &mut Vec<Vec<TabPosition>>,
695    ) {
696        if index == pitches.len() {
697            output.push(current.clone());
698            return;
699        }
700        for (string, open) in tuning.iter().enumerate().take(lines) {
701            if used[string] {
702                continue;
703            }
704            let fret = pitches[index] - *open - capo;
705            if !(0..=max_fret).contains(&fret) {
706                continue;
707            }
708            used[string] = true;
709            current.push(TabPosition {
710                string: (string + 1) as u8,
711                fret: fret as u8,
712            });
713            visit(
714                pitches,
715                tuning,
716                lines,
717                capo,
718                max_fret,
719                index + 1,
720                used,
721                current,
722                output,
723            );
724            current.pop();
725            used[string] = false;
726        }
727    }
728
729    if pitches.is_empty() || pitches.len() > lines {
730        return Vec::new();
731    }
732    let mut output = Vec::new();
733    visit(
734        pitches,
735        tuning,
736        lines,
737        capo,
738        max_fret,
739        0,
740        &mut vec![false; lines],
741        &mut Vec::new(),
742        &mut output,
743    );
744    output
745}
746
747fn best_tablature_assignment(
748    pitches: &[i16],
749    tuning: &[i16],
750    lines: usize,
751    capo: i16,
752    max_fret: i16,
753) -> Option<Vec<TabPosition>> {
754    type Assignment = (i16, i16, i16, Vec<u8>, Vec<TabPosition>);
755
756    #[allow(clippy::too_many_arguments)]
757    fn search(
758        pitches: &[i16],
759        tuning: &[i16],
760        lines: usize,
761        capo: i16,
762        max_fret: i16,
763        index: usize,
764        used: &mut [bool],
765        current: &mut Vec<TabPosition>,
766        best: &mut Option<Assignment>,
767    ) {
768        if index == pitches.len() {
769            let sum: i16 = current.iter().map(|p| i16::from(p.fret)).sum();
770            let min = current.iter().map(|p| p.fret).min().unwrap_or(0);
771            let max = current.iter().map(|p| p.fret).max().unwrap_or(0);
772            let strings: Vec<u8> = current.iter().map(|p| p.string).collect();
773            let candidate = (
774                sum,
775                i16::from(max) - i16::from(min),
776                i16::from(max),
777                strings,
778                current.clone(),
779            );
780            if best.as_ref().is_none_or(|existing| {
781                (candidate.0, candidate.1, candidate.2, &candidate.3)
782                    < (existing.0, existing.1, existing.2, &existing.3)
783            }) {
784                *best = Some(candidate);
785            }
786            return;
787        }
788
789        for (string, open) in tuning.iter().enumerate().take(lines) {
790            if used[string] {
791                continue;
792            }
793            let fret = pitches[index] - *open - capo;
794            if !(0..=max_fret).contains(&fret) {
795                continue;
796            }
797            used[string] = true;
798            current.push(TabPosition {
799                string: (string + 1) as u8,
800                fret: fret as u8,
801            });
802            search(
803                pitches,
804                tuning,
805                lines,
806                capo,
807                max_fret,
808                index + 1,
809                used,
810                current,
811                best,
812            );
813            current.pop();
814            used[string] = false;
815        }
816    }
817
818    if pitches.len() > lines {
819        return None;
820    }
821    let mut used = vec![false; lines];
822    let mut current = Vec::with_capacity(pitches.len());
823    let mut best = None;
824    search(
825        pitches,
826        tuning,
827        lines,
828        capo,
829        max_fret,
830        0,
831        &mut used,
832        &mut current,
833        &mut best,
834    );
835    best.map(|(_, _, _, _, positions)| positions)
836}
837
838/// Return a new `Score` with all pitches shifted by `semitones`.
839/// Key signatures (global and per-measure) are updated accordingly.
840/// If `semitones == 0` the score is cloned unchanged.
841pub fn transpose(score: &Score, semitones: i8) -> Score {
842    if semitones == 0 {
843        return score.clone();
844    }
845    let mut out = score.clone();
846    out.settings.key_signature.fifths = transpose_fifths(
847        score.settings.key_signature.fifths,
848        &score.settings.key_signature.mode,
849        semitones,
850    );
851    for part in &mut out.parts {
852        for staff in &mut part.staves {
853            for measure in &mut staff.measures {
854                if let Some(ref mut ks) = measure.key_sig {
855                    ks.fifths = transpose_fifths(ks.fifths, &ks.mode, semitones);
856                }
857                for voice in &mut measure.voices {
858                    for note in voice.iter_mut() {
859                        for pitch in note.pitches.iter_mut() {
860                            *pitch = transpose_pitch(pitch, semitones);
861                        }
862                    }
863                }
864            }
865        }
866    }
867    out
868}
869
870/// Transpose a score through the validated transformation boundary.
871pub fn transpose_checked(score: &Score, semitones: i8) -> Result<Score, Error> {
872    if !super::validate::validate(score).is_valid() {
873        return Err(Error::InvalidScore);
874    }
875    let transposed = transpose(score, semitones);
876    if !super::validate::validate(&transposed).is_valid() {
877        return Err(Error::InvalidScore);
878    }
879    Ok(transposed)
880}
881
882fn transpose_pitch(pitch: &Pitch, semitones: i8) -> Pitch {
883    let new_midi = (pitch.to_midi() + semitones as i16).clamp(0, 127) as u8;
884    let pc = new_midi % 12;
885    let oct = (new_midi / 12) as i8 - 1;
886    let (step, alter): (Step, i8) = if semitones >= 0 {
887        match pc {
888            0 => (Step::C, 0),
889            1 => (Step::C, 1),
890            2 => (Step::D, 0),
891            3 => (Step::D, 1),
892            4 => (Step::E, 0),
893            5 => (Step::F, 0),
894            6 => (Step::F, 1),
895            7 => (Step::G, 0),
896            8 => (Step::G, 1),
897            9 => (Step::A, 0),
898            10 => (Step::A, 1),
899            11 => (Step::B, 0),
900            _ => (Step::C, 0),
901        }
902    } else {
903        match pc {
904            0 => (Step::C, 0),
905            1 => (Step::D, -1),
906            2 => (Step::D, 0),
907            3 => (Step::E, -1),
908            4 => (Step::E, 0),
909            5 => (Step::F, 0),
910            6 => (Step::G, -1),
911            7 => (Step::G, 0),
912            8 => (Step::A, -1),
913            9 => (Step::A, 0),
914            10 => (Step::B, -1),
915            11 => (Step::B, 0),
916            _ => (Step::C, 0),
917        }
918    };
919    Pitch::with_microtone(step, oct, alter, pitch.microtone_cents)
920}
921
922/// Shift a key signature's fifths value by `semitones`.
923///
924/// Uses the circle-of-fifths arithmetic:
925/// - `tonic_pc = (fifths * 7) mod 12`  (for major; minor adds 9 to get relative major tonic)
926/// - `new_fifths = (new_tonic_pc * 7) mod 12`, adjusted to `[-7, 7]`
927fn transpose_fifths(fifths: i8, mode: &str, semitones: i8) -> i8 {
928    let tonic_major_pc = ((fifths as i32 * 7).rem_euclid(12)) as u8;
929    let tonic_pc = if mode == "minor" {
930        ((tonic_major_pc as i32 + 9).rem_euclid(12)) as u8
931    } else {
932        tonic_major_pc
933    };
934    let new_tonic = ((tonic_pc as i32 + semitones as i32).rem_euclid(12)) as u8;
935    let major_tonic = if mode == "minor" {
936        ((new_tonic as i32 + 3).rem_euclid(12)) as u8
937    } else {
938        new_tonic
939    };
940    let raw = ((major_tonic as i32 * 7).rem_euclid(12)) as i8;
941    if raw > 6 { raw - 12 } else { raw }
942}
943
944#[derive(Debug, Clone, Serialize, Deserialize)]
945pub struct Part {
946    pub id: String,
947    pub name: String,
948    pub short_name: String,
949    pub staves: Vec<Staff>,
950    /// MIDI channel (0–15). Channel 9 is conventionally used for percussion.
951    #[serde(default)]
952    pub midi_channel: u8,
953    /// General MIDI program number (0–127). Default 0 = Acoustic Grand Piano.
954    #[serde(default)]
955    pub midi_program: u8,
956    /// MIDI pitch-bend events preserved from interchange input, in canonical 480 PPQ ticks.
957    #[serde(default)]
958    pub midi_pitch_bends: Vec<MidiPitchBend>,
959    /// MIDI Control Change events preserved from interchange input, in canonical 480 PPQ ticks.
960    #[serde(default)]
961    pub midi_control_changes: Vec<MidiControlChange>,
962    /// MIDI Program Change events preserved from interchange input, in canonical 480 PPQ ticks.
963    #[serde(default)]
964    pub midi_program_changes: Vec<MidiProgramChange>,
965    /// MIDI key/channel aftertouch events preserved from interchange input, in canonical 480 PPQ ticks.
966    #[serde(default)]
967    pub midi_aftertouch: Vec<MidiAftertouch>,
968    /// MusicXML score-instrument definitions for note-level instrument IDs.
969    #[serde(default)]
970    pub percussion_instruments: Vec<PercussionInstrument>,
971    /// MEI/MuseScore staff-group structure within this part.
972    #[serde(default)]
973    pub staff_groups: Vec<StaffGroup>,
974}
975
976#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
977pub struct MidiPitchBend {
978    pub tick: u64,
979    pub channel: u8,
980    /// Signed 14-bit MIDI bend value in the range -8192..=8191.
981    pub value: i16,
982}
983
984#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
985pub struct MidiControlChange {
986    pub tick: u64,
987    pub channel: u8,
988    /// MIDI controller number (0–127).
989    pub controller: u8,
990    /// Seven-bit controller value (0–127).
991    pub value: u8,
992}
993
994#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
995pub struct MidiProgramChange {
996    pub tick: u64,
997    pub channel: u8,
998    /// General MIDI program number (0–127).
999    pub program: u8,
1000}
1001
1002#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1003pub struct MidiAftertouch {
1004    pub tick: u64,
1005    pub channel: u8,
1006    /// Key number for key pressure, or `None` for channel pressure.
1007    pub key: Option<u8>,
1008    /// Seven-bit pressure value (0–127).
1009    pub value: u8,
1010}
1011
1012#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1013pub struct PercussionInstrument {
1014    pub id: String,
1015    #[serde(default)]
1016    pub name: Option<String>,
1017    #[serde(default)]
1018    pub midi_unpitched: Option<u8>,
1019}
1020
1021impl Part {
1022    pub fn new(name: &str, short_name: &str) -> Self {
1023        Self {
1024            id: Uuid::new_v4().to_string(),
1025            name: name.to_string(),
1026            short_name: short_name.to_string(),
1027            staves: Vec::new(),
1028            midi_channel: 0,
1029            midi_program: 0,
1030            midi_pitch_bends: Vec::new(),
1031            midi_control_changes: Vec::new(),
1032            midi_program_changes: Vec::new(),
1033            midi_aftertouch: Vec::new(),
1034            percussion_instruments: Vec::new(),
1035            staff_groups: Vec::new(),
1036        }
1037    }
1038
1039    /// Resolve the declared percussion instrument for an unpitched note.
1040    ///
1041    /// An explicit MusicXML `instrument@id` always takes precedence.  When no
1042    /// identifier is attached, the retained display-key MIDI value is matched
1043    /// against the part's declared `midi_unpitched` entries.  This deliberately
1044    /// does not invent a sound identity for an unpitched note with no matching
1045    /// declaration.
1046    pub fn percussion_instrument_for_note(&self, note: &Note) -> Option<&PercussionInstrument> {
1047        if !note.is_unpitched {
1048            return None;
1049        }
1050        if let Some(instrument_id) = note.instrument_id.as_deref() {
1051            return self
1052                .percussion_instruments
1053                .iter()
1054                .find(|instrument| instrument.id == instrument_id);
1055        }
1056        let midi_key = u8::try_from(note.pitches.first()?.to_midi()).ok()?;
1057        self.percussion_instruments
1058            .iter()
1059            .find(|instrument| instrument.midi_unpitched == Some(midi_key))
1060    }
1061}
1062
1063#[derive(Debug, Clone, Serialize, Deserialize)]
1064pub struct Staff {
1065    pub clef: Clef,
1066    pub measures: Vec<Measure>,
1067    /// Semitones to add to written pitch for concert pitch / MIDI output.
1068    /// -2 = Bb instrument (clarinet, trumpet), -9 = Eb instrument (alto sax), etc.
1069    #[serde(default)]
1070    pub transpose_semitones: i8,
1071    #[serde(default)]
1072    pub tablature: Option<TablatureConfig>,
1073}
1074
1075impl Staff {
1076    pub fn new(clef: Clef) -> Self {
1077        Self {
1078            clef,
1079            measures: Vec::new(),
1080            transpose_semitones: 0,
1081            tablature: None,
1082        }
1083    }
1084}
1085
1086#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1087pub struct VoltaBracket {
1088    /// Ending number (1, 2, …)
1089    pub number: u8,
1090    /// "begin" | "mid" | "end" | "begin_end"
1091    pub kind: String,
1092}
1093
1094#[derive(Debug, Clone, Serialize, Deserialize)]
1095pub struct Measure {
1096    pub number: u32,
1097    pub time_sig: Option<TimeSignature>,
1098    pub key_sig: Option<KeySignature>,
1099    pub clef: Option<Clef>,
1100    pub tempo: Option<u16>,
1101    pub barline_left: Barline,
1102    pub barline_right: Barline,
1103    #[serde(default)]
1104    pub volta: Option<VoltaBracket>,
1105    #[serde(default)]
1106    pub tempo_text: Option<String>,
1107    #[serde(default)]
1108    pub rehearsal: Option<String>,
1109    /// Navigation mark: "Segno" | "Coda" | "Fine" | "DaCapo" | "DaCapoAlFine" |
1110    /// "DaCapoAlCoda" | "DalSegno" | "DalSegnoAlFine" | "DalSegnoAlCoda" | "ToCoda"
1111    #[serde(default)]
1112    pub navigation: Option<String>,
1113    /// Expression / performance text ("dolce", "espressivo", "con fuoco", etc.).
1114    #[serde(default)]
1115    pub expression_text: Option<String>,
1116    #[serde(default)]
1117    pub texts: Vec<StyledText>,
1118    /// Structured MusicXML figured-bass figures in source order.
1119    #[serde(default)]
1120    pub figured_bass: Vec<FiguredBassFigure>,
1121    /// When ≥ 2, this measure is displayed as a multi-measure rest spanning N measures.
1122    #[serde(default)]
1123    pub multi_rest_count: Option<u8>,
1124    /// Force a new system (row) after this measure.
1125    #[serde(default)]
1126    pub system_break: bool,
1127    /// Force a new page after this measure.
1128    #[serde(default)]
1129    pub page_break: bool,
1130    /// Up to 4 voices; voice 0 is the primary voice.
1131    pub voices: [Vec<Note>; 4],
1132    /// Original positive MusicXML voice numbers associated with the four editable slots.
1133    ///
1134    /// `None` retains the legacy convention that slot `n` serializes as voice `n + 1`.
1135    /// This lets import preserve sparse source identifiers such as voices 1 and 5 without
1136    /// changing the established fixed-slot editing API.
1137    #[serde(default)]
1138    pub source_voice_numbers: [Option<u32>; 4],
1139}
1140
1141impl Measure {
1142    pub fn empty(numerator: u8, denominator: u8) -> Self {
1143        let total_beats = TimeSignature {
1144            numerator,
1145            denominator,
1146        }
1147        .total_beats();
1148        let mut voice0: Vec<Note> = Vec::new();
1149        let mut remaining = total_beats;
1150        while remaining > 1e-9 {
1151            let dur = Duration::whole_filling_beats(remaining);
1152            remaining -= dur.beats(0);
1153            voice0.push(Note::rest(dur));
1154        }
1155        Self {
1156            number: 0,
1157            time_sig: None,
1158            key_sig: None,
1159            clef: None,
1160            tempo: None,
1161            barline_left: Barline::Normal,
1162            barline_right: Barline::Normal,
1163            volta: None,
1164            tempo_text: None,
1165            rehearsal: None,
1166            navigation: None,
1167            expression_text: None,
1168            texts: Vec::new(),
1169            figured_bass: Vec::new(),
1170            multi_rest_count: None,
1171            system_break: false,
1172            page_break: false,
1173            voices: [voice0, vec![], vec![], vec![]],
1174            source_voice_numbers: [None; 4],
1175        }
1176    }
1177
1178    pub fn renumber(&mut self, n: u32) {
1179        self.number = n;
1180    }
1181}
1182
1183#[derive(Debug, Clone, Serialize, Deserialize)]
1184pub struct Note {
1185    pub id: String,
1186    pub is_rest: bool,
1187    /// MusicXML unpitched note; `pitches` then stores display placement only.
1188    #[serde(default)]
1189    pub is_unpitched: bool,
1190    /// Optional source instrument identifier (for example MusicXML note-level `instrument@id`).
1191    #[serde(default)]
1192    pub instrument_id: Option<String>,
1193    /// MusicXML note-level horizontal placement in tenths, relative to the rhythmic anchor.
1194    #[serde(default)]
1195    pub offset_x: Option<f64>,
1196    /// MusicXML note-level vertical placement in tenths, relative to the staff position.
1197    #[serde(default)]
1198    pub offset_y: Option<f64>,
1199    /// MusicXML note-level horizontal adjustment in tenths.
1200    #[serde(default)]
1201    pub relative_x: Option<f64>,
1202    /// MusicXML note-level vertical adjustment in tenths.
1203    #[serde(default)]
1204    pub relative_y: Option<f64>,
1205    /// Single note: one pitch. Chord: multiple pitches (same duration).
1206    pub pitches: Vec<Pitch>,
1207    #[serde(default)]
1208    pub tab_position: Option<super::notation::TabPosition>,
1209    /// One tablature position per pitch; the first entry mirrors `tab_position`.
1210    /// This is populated for chords so each pitch can occupy a distinct string.
1211    #[serde(default)]
1212    pub tab_positions: Vec<super::notation::TabPosition>,
1213    pub duration: Duration,
1214    pub dot_count: u8,
1215    pub tie_start: bool,
1216    pub tie_end: bool,
1217    pub beam: BeamState,
1218    pub articulations: Vec<Articulation>,
1219    pub dynamic: Option<Dynamic>,
1220    pub stem_up: Option<bool>,
1221    #[serde(default)]
1222    pub hairpin_start: Option<HairpinKind>,
1223    #[serde(default)]
1224    pub hairpin_end: bool,
1225    #[serde(default)]
1226    pub tuplet: Option<TupletInfo>,
1227    #[serde(default)]
1228    pub chord_symbol: Option<ChordSymbol>,
1229    #[serde(default)]
1230    pub is_grace: bool,
1231    /// Acciaccatura: true (slash through stem). Appoggiatura: false.
1232    #[serde(default)]
1233    pub grace_slash: bool,
1234    #[serde(default)]
1235    pub ottava_start: Option<OttavaKind>,
1236    #[serde(default)]
1237    pub ottava_end: bool,
1238    #[serde(default)]
1239    pub lyric: Option<Lyric>,
1240    #[serde(default)]
1241    pub pedal_start: bool,
1242    #[serde(default)]
1243    pub pedal_end: bool,
1244    #[serde(default)]
1245    pub slur_start: bool,
1246    #[serde(default)]
1247    pub slur_end: bool,
1248    /// Arpeggiate direction: `Some(true)` = up, `Some(false)` = down, `None` = none.
1249    #[serde(default)]
1250    pub arpeggiate: Option<bool>,
1251    /// Technique/style instruction attached to this note ("pizz.", "arco", "con sord.", etc.).
1252    #[serde(default)]
1253    pub technique_text: Option<String>,
1254    #[serde(default)]
1255    pub glissando_start: bool,
1256    #[serde(default)]
1257    pub glissando_end: bool,
1258    #[serde(default)]
1259    pub cross_staff: Option<CrossStaff>,
1260    /// Left-hand fingering number (0 = open / thumb, 1–5 = fingers).
1261    #[serde(default)]
1262    pub fingering: Option<u8>,
1263    /// Alternate left-hand fingering candidates, in source order. The first
1264    /// entry mirrors `fingering` when present.
1265    #[serde(default)]
1266    pub fingerings: Vec<u8>,
1267    /// String number for plucked/bowed string instruments (1 = highest string).
1268    #[serde(default)]
1269    pub string_number: Option<u8>,
1270    #[serde(default)]
1271    pub note_head: NoteHead,
1272    /// Cue note (small-sized, does not count toward beat total).
1273    #[serde(default)]
1274    pub is_cue: bool,
1275    /// Start of a multi-note trill line span.
1276    #[serde(default)]
1277    pub trill_line_start: bool,
1278    /// End of a multi-note trill line span.
1279    #[serde(default)]
1280    pub trill_line_end: bool,
1281    /// Guitar-specific playing technique (bend, slide, hammer-on, pull-off).
1282    #[serde(default)]
1283    pub guitar_technique: Option<GuitarTechnique>,
1284    /// MusicXML bend amount in cents when supplied by the source.
1285    #[serde(default)]
1286    pub guitar_bend_alter_cents: Option<i16>,
1287}
1288
1289impl Note {
1290    /// Select one authored fingering candidate without changing the score.
1291    pub fn select_fingering(
1292        &self,
1293        policy: super::notation::FingeringSelectionPolicy,
1294    ) -> Option<u8> {
1295        let candidates = if self.fingerings.is_empty() {
1296            self.fingering.into_iter().collect::<Vec<_>>()
1297        } else {
1298            self.fingerings.clone()
1299        };
1300        match policy {
1301            super::notation::FingeringSelectionPolicy::SourceOrder => candidates.first().copied(),
1302            super::notation::FingeringSelectionPolicy::LowestNumber => {
1303                candidates.iter().copied().min()
1304            }
1305            super::notation::FingeringSelectionPolicy::HighestNumber => {
1306                candidates.iter().copied().max()
1307            }
1308        }
1309    }
1310
1311    pub fn new(pitch: Pitch, duration: Duration) -> Self {
1312        Self {
1313            id: Uuid::new_v4().to_string(),
1314            is_rest: false,
1315            is_unpitched: false,
1316            instrument_id: None,
1317            offset_x: None,
1318            offset_y: None,
1319            relative_x: None,
1320            relative_y: None,
1321            pitches: vec![pitch],
1322            tab_position: None,
1323            tab_positions: Vec::new(),
1324            duration,
1325            dot_count: 0,
1326            tie_start: false,
1327            tie_end: false,
1328            beam: BeamState::None,
1329            articulations: Vec::new(),
1330            dynamic: None,
1331            stem_up: None,
1332            hairpin_start: None,
1333            hairpin_end: false,
1334            tuplet: None,
1335            chord_symbol: None,
1336            is_grace: false,
1337            grace_slash: false,
1338            ottava_start: None,
1339            ottava_end: false,
1340            lyric: None,
1341            pedal_start: false,
1342            pedal_end: false,
1343            slur_start: false,
1344            slur_end: false,
1345            arpeggiate: None,
1346            technique_text: None,
1347            glissando_start: false,
1348            glissando_end: false,
1349            cross_staff: None,
1350            fingering: None,
1351            fingerings: Vec::new(),
1352            string_number: None,
1353            note_head: NoteHead::Normal,
1354            is_cue: false,
1355            trill_line_start: false,
1356            trill_line_end: false,
1357            guitar_technique: None,
1358            guitar_bend_alter_cents: None,
1359        }
1360    }
1361
1362    pub fn rest(duration: Duration) -> Self {
1363        Self {
1364            id: Uuid::new_v4().to_string(),
1365            is_rest: true,
1366            is_unpitched: false,
1367            instrument_id: None,
1368            offset_x: None,
1369            offset_y: None,
1370            relative_x: None,
1371            relative_y: None,
1372            pitches: Vec::new(),
1373            tab_position: None,
1374            tab_positions: Vec::new(),
1375            duration,
1376            dot_count: 0,
1377            tie_start: false,
1378            tie_end: false,
1379            beam: BeamState::None,
1380            articulations: Vec::new(),
1381            dynamic: None,
1382            stem_up: None,
1383            hairpin_start: None,
1384            hairpin_end: false,
1385            tuplet: None,
1386            chord_symbol: None,
1387            is_grace: false,
1388            grace_slash: false,
1389            ottava_start: None,
1390            ottava_end: false,
1391            lyric: None,
1392            pedal_start: false,
1393            pedal_end: false,
1394            slur_start: false,
1395            slur_end: false,
1396            arpeggiate: None,
1397            technique_text: None,
1398            glissando_start: false,
1399            glissando_end: false,
1400            cross_staff: None,
1401            fingering: None,
1402            fingerings: Vec::new(),
1403            string_number: None,
1404            note_head: NoteHead::Normal,
1405            is_cue: false,
1406            trill_line_start: false,
1407            trill_line_end: false,
1408            guitar_technique: None,
1409            guitar_bend_alter_cents: None,
1410        }
1411    }
1412
1413    pub fn beats(&self) -> f64 {
1414        if self.is_grace || self.is_cue {
1415            return 0.0;
1416        }
1417        let base = self.duration.beats(self.dot_count);
1418        if let Some(ref t) = self.tuplet {
1419            base * (t.normal_notes as f64) / (t.actual_notes as f64)
1420        } else {
1421            base
1422        }
1423    }
1424}
1425
1426impl Duration {
1427    /// Returns the largest single duration that fills the given number of beats.
1428    pub fn whole_filling_beats(beats: f64) -> Duration {
1429        if beats >= 4.0 {
1430            Duration::Whole
1431        } else if beats >= 2.0 {
1432            Duration::Half
1433        } else if beats >= 1.0 {
1434            Duration::Quarter
1435        } else if beats >= 0.5 {
1436            Duration::Eighth
1437        } else if beats >= 0.25 {
1438            Duration::Sixteenth
1439        } else if beats >= 0.125 {
1440            Duration::ThirtySecond
1441        } else {
1442            Duration::SixtyFourth
1443        }
1444    }
1445}
1446
1447// ── NoteAddr ──────────────────────────────────────────────────────────────────
1448
1449/// Physical address of a note within a score.
1450#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1451pub struct NoteAddr {
1452    pub part: usize,
1453    pub staff: usize,
1454    pub measure: usize,
1455    pub voice: usize,
1456    pub note: usize,
1457}
1458
1459// ── diff ──────────────────────────────────────────────────────────────────────
1460
1461/// A single change between two [`Score`] values as reported by [`diff`].
1462#[derive(Debug, Clone, Serialize, Deserialize)]
1463pub enum ScoreChange {
1464    ScoreTextChanged {
1465        old: Vec<StyledText>,
1466        new: Vec<StyledText>,
1467    },
1468    MeasureTextChanged {
1469        part: usize,
1470        staff: usize,
1471        measure: usize,
1472        old: Vec<StyledText>,
1473        new: Vec<StyledText>,
1474    },
1475    FiguredBassChanged {
1476        part: usize,
1477        staff: usize,
1478        measure: usize,
1479        old: Vec<FiguredBassFigure>,
1480        new: Vec<FiguredBassFigure>,
1481    },
1482    ChordDefinitionsChanged {
1483        old: Vec<ChordDefinition>,
1484        new: Vec<ChordDefinition>,
1485    },
1486    PartNamesChanged {
1487        part: usize,
1488        old_name: String,
1489        new_name: String,
1490        old_short_name: String,
1491        new_short_name: String,
1492    },
1493    PartMidiChanged {
1494        part: usize,
1495        old_channel: u8,
1496        new_channel: u8,
1497        old_program: u8,
1498        new_program: u8,
1499    },
1500    PartMidiAutomationChanged {
1501        part: usize,
1502        old_pitch_bends: Vec<MidiPitchBend>,
1503        new_pitch_bends: Vec<MidiPitchBend>,
1504        old_control_changes: Vec<MidiControlChange>,
1505        new_control_changes: Vec<MidiControlChange>,
1506        old_program_changes: Vec<MidiProgramChange>,
1507        new_program_changes: Vec<MidiProgramChange>,
1508        old_aftertouch: Vec<MidiAftertouch>,
1509        new_aftertouch: Vec<MidiAftertouch>,
1510    },
1511    StaffConfigurationChanged {
1512        part: usize,
1513        staff: usize,
1514        old_clef: Clef,
1515        new_clef: Clef,
1516        old_transpose_semitones: i8,
1517        new_transpose_semitones: i8,
1518    },
1519    MeasurePresentationChanged {
1520        part: usize,
1521        staff: usize,
1522        measure: usize,
1523        old_number: u32,
1524        new_number: u32,
1525        old_clef: Option<Clef>,
1526        new_clef: Option<Clef>,
1527        old_tempo_text: Option<String>,
1528        new_tempo_text: Option<String>,
1529        old_navigation: Option<String>,
1530        new_navigation: Option<String>,
1531        old_expression_text: Option<String>,
1532        new_expression_text: Option<String>,
1533        old_multi_rest_count: Option<u8>,
1534        new_multi_rest_count: Option<u8>,
1535        old_system_break: bool,
1536        new_system_break: bool,
1537        old_page_break: bool,
1538        new_page_break: bool,
1539    },
1540    TablatureConfigChanged {
1541        part: usize,
1542        staff: usize,
1543        old: Option<TablatureConfig>,
1544        new: Option<TablatureConfig>,
1545    },
1546    /// A semantic field changed without a dedicated positional diff variant.
1547    ///
1548    /// The stable path keeps compatibility reports honest while the complete score remains
1549    /// available through [`ScorePatch::ReplaceScore`].
1550    UnrepresentedFieldChanged {
1551        path: String,
1552    },
1553    MetadataChanged {
1554        field: String,
1555        old: String,
1556        new: String,
1557    },
1558    TempoChanged {
1559        old: u16,
1560        new: u16,
1561    },
1562    KeySignatureChanged {
1563        old: KeySignature,
1564        new: KeySignature,
1565    },
1566    PartAdded {
1567        part_index: usize,
1568    },
1569    PartRemoved {
1570        part_index: usize,
1571        name: String,
1572    },
1573    NoteAdded {
1574        part: usize,
1575        staff: usize,
1576        measure: usize,
1577        voice: usize,
1578        note_index: usize,
1579    },
1580    NoteRemoved {
1581        part: usize,
1582        staff: usize,
1583        measure: usize,
1584        voice: usize,
1585        note: Box<Note>,
1586    },
1587    NoteModified {
1588        part: usize,
1589        staff: usize,
1590        measure: usize,
1591        voice: usize,
1592        note_index: usize,
1593        old: Box<Note>,
1594        new: Box<Note>,
1595    },
1596    TimeSigChanged {
1597        part: usize,
1598        staff: usize,
1599        measure: usize,
1600        old: Option<TimeSignature>,
1601        new: Option<TimeSignature>,
1602    },
1603    MeasureTempoChanged {
1604        part: usize,
1605        staff: usize,
1606        measure: usize,
1607        old: Option<u16>,
1608        new: Option<u16>,
1609    },
1610    BarlineChanged {
1611        part: usize,
1612        staff: usize,
1613        measure: usize,
1614    },
1615    RehearsalMarkChanged {
1616        part: usize,
1617        staff: usize,
1618        measure: usize,
1619        old: Option<String>,
1620        new: Option<String>,
1621    },
1622    VoltaChanged {
1623        part: usize,
1624        staff: usize,
1625        measure: usize,
1626    },
1627}
1628
1629/// Compare two scores and return a list of differences.
1630///
1631/// Parts, staves, measures, and voices are compared by position. Notes are compared by
1632/// position within each voice, ignoring their `id` field. Metadata fields are compared
1633/// individually.
1634pub fn diff(a: &Score, b: &Score) -> Vec<ScoreChange> {
1635    let mut changes: Vec<ScoreChange> = Vec::new();
1636
1637    if a.texts != b.texts {
1638        changes.push(ScoreChange::ScoreTextChanged {
1639            old: a.texts.clone(),
1640            new: b.texts.clone(),
1641        });
1642    }
1643    if a.chord_definitions != b.chord_definitions {
1644        changes.push(ScoreChange::ChordDefinitionsChanged {
1645            old: a.chord_definitions.clone(),
1646            new: b.chord_definitions.clone(),
1647        });
1648    }
1649    if let Some(path) = first_unrepresented_field_change(a, b) {
1650        changes.push(ScoreChange::UnrepresentedFieldChanged { path });
1651    }
1652
1653    macro_rules! meta {
1654        ($field:ident, $name:literal) => {
1655            if a.metadata.$field != b.metadata.$field {
1656                changes.push(ScoreChange::MetadataChanged {
1657                    field: $name.to_string(),
1658                    old: a.metadata.$field.clone(),
1659                    new: b.metadata.$field.clone(),
1660                });
1661            }
1662        };
1663    }
1664    meta!(title, "title");
1665    meta!(composer, "composer");
1666    meta!(lyricist, "lyricist");
1667    meta!(copyright, "copyright");
1668    meta!(work_number, "work_number");
1669    meta!(movement_title, "movement_title");
1670
1671    if a.settings.tempo_bpm != b.settings.tempo_bpm {
1672        changes.push(ScoreChange::TempoChanged {
1673            old: a.settings.tempo_bpm,
1674            new: b.settings.tempo_bpm,
1675        });
1676    }
1677    if a.settings.key_signature != b.settings.key_signature {
1678        changes.push(ScoreChange::KeySignatureChanged {
1679            old: a.settings.key_signature.clone(),
1680            new: b.settings.key_signature.clone(),
1681        });
1682    }
1683
1684    let a_len = a.parts.len();
1685    let b_len = b.parts.len();
1686    for i in b_len..a_len {
1687        changes.push(ScoreChange::PartRemoved {
1688            part_index: i,
1689            name: a.parts[i].name.clone(),
1690        });
1691    }
1692    for i in a_len..b_len {
1693        changes.push(ScoreChange::PartAdded { part_index: i });
1694    }
1695
1696    for pi in 0..a_len.min(b_len) {
1697        let ap = &a.parts[pi];
1698        let bp = &b.parts[pi];
1699        if ap.name != bp.name || ap.short_name != bp.short_name {
1700            changes.push(ScoreChange::PartNamesChanged {
1701                part: pi,
1702                old_name: ap.name.clone(),
1703                new_name: bp.name.clone(),
1704                old_short_name: ap.short_name.clone(),
1705                new_short_name: bp.short_name.clone(),
1706            });
1707        }
1708        if ap.midi_channel != bp.midi_channel || ap.midi_program != bp.midi_program {
1709            changes.push(ScoreChange::PartMidiChanged {
1710                part: pi,
1711                old_channel: ap.midi_channel,
1712                new_channel: bp.midi_channel,
1713                old_program: ap.midi_program,
1714                new_program: bp.midi_program,
1715            });
1716        }
1717        if ap.midi_pitch_bends != bp.midi_pitch_bends
1718            || ap.midi_control_changes != bp.midi_control_changes
1719            || ap.midi_program_changes != bp.midi_program_changes
1720            || ap.midi_aftertouch != bp.midi_aftertouch
1721        {
1722            changes.push(ScoreChange::PartMidiAutomationChanged {
1723                part: pi,
1724                old_pitch_bends: ap.midi_pitch_bends.clone(),
1725                new_pitch_bends: bp.midi_pitch_bends.clone(),
1726                old_control_changes: ap.midi_control_changes.clone(),
1727                new_control_changes: bp.midi_control_changes.clone(),
1728                old_program_changes: ap.midi_program_changes.clone(),
1729                new_program_changes: bp.midi_program_changes.clone(),
1730                old_aftertouch: ap.midi_aftertouch.clone(),
1731                new_aftertouch: bp.midi_aftertouch.clone(),
1732            });
1733        }
1734        for si in 0..ap.staves.len().min(bp.staves.len()) {
1735            let a_staff = &ap.staves[si];
1736            let b_staff = &bp.staves[si];
1737            if a_staff.clef != b_staff.clef
1738                || a_staff.transpose_semitones != b_staff.transpose_semitones
1739            {
1740                changes.push(ScoreChange::StaffConfigurationChanged {
1741                    part: pi,
1742                    staff: si,
1743                    old_clef: a_staff.clef.clone(),
1744                    new_clef: b_staff.clef.clone(),
1745                    old_transpose_semitones: a_staff.transpose_semitones,
1746                    new_transpose_semitones: b_staff.transpose_semitones,
1747                });
1748            }
1749            if a_staff.tablature != b_staff.tablature {
1750                changes.push(ScoreChange::TablatureConfigChanged {
1751                    part: pi,
1752                    staff: si,
1753                    old: a_staff.tablature.clone(),
1754                    new: b_staff.tablature.clone(),
1755                });
1756            }
1757            for mi in 0..a_staff.measures.len().min(b_staff.measures.len()) {
1758                let am = &a_staff.measures[mi];
1759                let bm = &b_staff.measures[mi];
1760                if am.number != bm.number
1761                    || am.clef != bm.clef
1762                    || am.tempo_text != bm.tempo_text
1763                    || am.navigation != bm.navigation
1764                    || am.expression_text != bm.expression_text
1765                    || am.multi_rest_count != bm.multi_rest_count
1766                    || am.system_break != bm.system_break
1767                    || am.page_break != bm.page_break
1768                {
1769                    changes.push(ScoreChange::MeasurePresentationChanged {
1770                        part: pi,
1771                        staff: si,
1772                        measure: mi,
1773                        old_number: am.number,
1774                        new_number: bm.number,
1775                        old_clef: am.clef.clone(),
1776                        new_clef: bm.clef.clone(),
1777                        old_tempo_text: am.tempo_text.clone(),
1778                        new_tempo_text: bm.tempo_text.clone(),
1779                        old_navigation: am.navigation.clone(),
1780                        new_navigation: bm.navigation.clone(),
1781                        old_expression_text: am.expression_text.clone(),
1782                        new_expression_text: bm.expression_text.clone(),
1783                        old_multi_rest_count: am.multi_rest_count,
1784                        new_multi_rest_count: bm.multi_rest_count,
1785                        old_system_break: am.system_break,
1786                        new_system_break: bm.system_break,
1787                        old_page_break: am.page_break,
1788                        new_page_break: bm.page_break,
1789                    });
1790                }
1791                for vi in 0..4usize {
1792                    let av = &am.voices[vi];
1793                    let bv = &bm.voices[vi];
1794                    for (ni, (a_note, b_note)) in av.iter().zip(bv.iter()).enumerate() {
1795                        if !note_content_eq(a_note, b_note) {
1796                            changes.push(ScoreChange::NoteModified {
1797                                part: pi,
1798                                staff: si,
1799                                measure: mi,
1800                                voice: vi,
1801                                note_index: ni,
1802                                old: Box::new(a_note.clone()),
1803                                new: Box::new(b_note.clone()),
1804                            });
1805                        }
1806                    }
1807                    for note in av.iter().skip(bv.len()) {
1808                        changes.push(ScoreChange::NoteRemoved {
1809                            part: pi,
1810                            staff: si,
1811                            measure: mi,
1812                            voice: vi,
1813                            note: Box::new(note.clone()),
1814                        });
1815                    }
1816                    for ni in av.len()..bv.len() {
1817                        changes.push(ScoreChange::NoteAdded {
1818                            part: pi,
1819                            staff: si,
1820                            measure: mi,
1821                            voice: vi,
1822                            note_index: ni,
1823                        });
1824                    }
1825                }
1826                if am.time_sig != bm.time_sig {
1827                    changes.push(ScoreChange::TimeSigChanged {
1828                        part: pi,
1829                        staff: si,
1830                        measure: mi,
1831                        old: am.time_sig.clone(),
1832                        new: bm.time_sig.clone(),
1833                    });
1834                }
1835                if am.tempo != bm.tempo {
1836                    changes.push(ScoreChange::MeasureTempoChanged {
1837                        part: pi,
1838                        staff: si,
1839                        measure: mi,
1840                        old: am.tempo,
1841                        new: bm.tempo,
1842                    });
1843                }
1844                if am.barline_left != bm.barline_left || am.barline_right != bm.barline_right {
1845                    changes.push(ScoreChange::BarlineChanged {
1846                        part: pi,
1847                        staff: si,
1848                        measure: mi,
1849                    });
1850                }
1851                if am.rehearsal != bm.rehearsal {
1852                    changes.push(ScoreChange::RehearsalMarkChanged {
1853                        part: pi,
1854                        staff: si,
1855                        measure: mi,
1856                        old: am.rehearsal.clone(),
1857                        new: bm.rehearsal.clone(),
1858                    });
1859                }
1860                if am.volta != bm.volta {
1861                    changes.push(ScoreChange::VoltaChanged {
1862                        part: pi,
1863                        staff: si,
1864                        measure: mi,
1865                    });
1866                }
1867                if am.texts != bm.texts {
1868                    changes.push(ScoreChange::MeasureTextChanged {
1869                        part: pi,
1870                        staff: si,
1871                        measure: mi,
1872                        old: am.texts.clone(),
1873                        new: bm.texts.clone(),
1874                    });
1875                }
1876                if am.figured_bass != bm.figured_bass {
1877                    changes.push(ScoreChange::FiguredBassChanged {
1878                        part: pi,
1879                        staff: si,
1880                        measure: mi,
1881                        old: am.figured_bass.clone(),
1882                        new: bm.figured_bass.clone(),
1883                    });
1884                }
1885            }
1886        }
1887    }
1888
1889    changes
1890}
1891
1892fn first_unrepresented_field_change(a: &Score, b: &Score) -> Option<String> {
1893    if a.settings.time_signature != b.settings.time_signature {
1894        return Some("settings.time_signature".to_string());
1895    }
1896    if a.part_groups.len() != b.part_groups.len()
1897        || a.part_groups.iter().zip(&b.part_groups).any(|(x, y)| {
1898            x.first_part != y.first_part
1899                || x.last_part != y.last_part
1900                || x.symbol != y.symbol
1901                || x.barlines_connect != y.barlines_connect
1902        })
1903    {
1904        return Some("part_groups".to_string());
1905    }
1906    a.parts
1907        .iter()
1908        .zip(&b.parts)
1909        .enumerate()
1910        .find_map(|(part_index, (ap, bp))| first_unrepresented_part_change(part_index, ap, bp))
1911}
1912
1913fn first_unrepresented_part_change(part_index: usize, a: &Part, b: &Part) -> Option<String> {
1914    let prefix = format!("parts[{part_index}]");
1915    if a.percussion_instruments != b.percussion_instruments {
1916        return Some(format!("{prefix}.percussion_instruments"));
1917    }
1918    if a.staff_groups != b.staff_groups {
1919        return Some(format!("{prefix}.staff_groups"));
1920    }
1921    if a.staves.len() != b.staves.len() {
1922        return Some(format!("{prefix}.staves"));
1923    }
1924    a.staves
1925        .iter()
1926        .zip(&b.staves)
1927        .enumerate()
1928        .find_map(|(staff_index, (a, b))| {
1929            first_unrepresented_staff_change(&prefix, staff_index, a, b)
1930        })
1931}
1932
1933fn first_unrepresented_staff_change(
1934    part_prefix: &str,
1935    staff_index: usize,
1936    a: &Staff,
1937    b: &Staff,
1938) -> Option<String> {
1939    let prefix = format!("{part_prefix}.staves[{staff_index}]");
1940    if a.measures.len() != b.measures.len() {
1941        return Some(format!("{prefix}.measures"));
1942    }
1943    a.measures
1944        .iter()
1945        .zip(&b.measures)
1946        .enumerate()
1947        .find_map(|(measure_index, (a, b))| {
1948            first_unrepresented_measure_change(&prefix, measure_index, a, b)
1949        })
1950}
1951
1952fn first_unrepresented_measure_change(
1953    _staff_prefix: &str,
1954    _measure_index: usize,
1955    _a: &Measure,
1956    _b: &Measure,
1957) -> Option<String> {
1958    None
1959}
1960
1961// ── ScorePatch ────────────────────────────────────────────────────────────────
1962
1963/// An individually applicable patch operation produced by [`score_patch`].
1964///
1965/// Unlike [`ScoreChange`], every variant carries enough data to apply the change to a
1966/// [`Score`] without needing the original score. Use [`apply_patch`] to apply a list.
1967#[derive(Debug, Clone, Serialize, Deserialize)]
1968pub enum ScorePatch {
1969    SetScoreTexts {
1970        value: Vec<StyledText>,
1971    },
1972    SetMeasureTexts {
1973        part: usize,
1974        staff: usize,
1975        measure: usize,
1976        value: Vec<StyledText>,
1977    },
1978    SetFiguredBass {
1979        part: usize,
1980        staff: usize,
1981        measure: usize,
1982        value: Vec<FiguredBassFigure>,
1983    },
1984    SetChordDefinitions {
1985        value: Vec<ChordDefinition>,
1986    },
1987    SetPartNames {
1988        part: usize,
1989        name: String,
1990        short_name: String,
1991    },
1992    SetPartMidi {
1993        part: usize,
1994        channel: u8,
1995        program: u8,
1996    },
1997    SetPartMidiAutomation {
1998        part: usize,
1999        pitch_bends: Vec<MidiPitchBend>,
2000        control_changes: Vec<MidiControlChange>,
2001        program_changes: Vec<MidiProgramChange>,
2002        aftertouch: Vec<MidiAftertouch>,
2003    },
2004    SetStaffConfiguration {
2005        part: usize,
2006        staff: usize,
2007        clef: Clef,
2008        transpose_semitones: i8,
2009    },
2010    SetMeasurePresentation {
2011        part: usize,
2012        staff: usize,
2013        measure: usize,
2014        number: u32,
2015        clef: Option<Clef>,
2016        tempo_text: Option<String>,
2017        navigation: Option<String>,
2018        expression_text: Option<String>,
2019        multi_rest_count: Option<u8>,
2020        system_break: bool,
2021        page_break: bool,
2022    },
2023    SetTablatureConfig {
2024        part: usize,
2025        staff: usize,
2026        value: Option<TablatureConfig>,
2027    },
2028    SetMetadata {
2029        field: String,
2030        value: String,
2031    },
2032    SetTempo {
2033        value: u16,
2034    },
2035    SetKeySignature {
2036        part: usize,
2037        staff: usize,
2038        measure: usize,
2039        value: Option<KeySignature>,
2040    },
2041    SetTimeSignature {
2042        part: usize,
2043        staff: usize,
2044        measure: usize,
2045        value: Option<TimeSignature>,
2046    },
2047    SetBarlines {
2048        part: usize,
2049        staff: usize,
2050        measure: usize,
2051        left: Barline,
2052        right: Barline,
2053    },
2054    SetRehearsal {
2055        part: usize,
2056        staff: usize,
2057        measure: usize,
2058        value: Option<String>,
2059    },
2060    SetVolta {
2061        part: usize,
2062        staff: usize,
2063        measure: usize,
2064        value: Option<VoltaBracket>,
2065    },
2066    /// Insert `note` at `note_index` in the given voice (existing notes shift right).
2067    AddNote {
2068        part: usize,
2069        staff: usize,
2070        measure: usize,
2071        voice: usize,
2072        /// Position for insertion. `usize::MAX` is the legacy append sentinel.
2073        #[serde(default = "legacy_append_index")]
2074        note_index: usize,
2075        note: Box<Note>,
2076    },
2077    RemoveNote {
2078        part: usize,
2079        staff: usize,
2080        measure: usize,
2081        voice: usize,
2082        note_index: usize,
2083    },
2084    /// Replace the note at `note_index` with `note`.
2085    ReplaceNote {
2086        part: usize,
2087        staff: usize,
2088        measure: usize,
2089        voice: usize,
2090        note_index: usize,
2091        note: Box<Note>,
2092    },
2093    SetMeasureTempo {
2094        part: usize,
2095        staff: usize,
2096        measure: usize,
2097        value: Option<u16>,
2098    },
2099    /// Replace the complete score when a change cannot be represented safely by
2100    /// positional operations (for example, a part or measure was added).
2101    ReplaceScore {
2102        score: Box<Score>,
2103    },
2104}
2105
2106fn legacy_append_index() -> usize {
2107    usize::MAX
2108}
2109
2110/// Return whether positional patches would lose score data. The patch format deliberately
2111/// keeps the common editing operations small; fields without a dedicated operation use the
2112/// complete-score fallback so an interchange round-trip never silently drops notation.
2113fn patch_requires_replace(a: &Score, b: &Score) -> bool {
2114    if a.settings.time_signature != b.settings.time_signature
2115        || a.settings.key_signature != b.settings.key_signature
2116        || a.parts.len() != b.parts.len()
2117        || a.part_groups.len() != b.part_groups.len()
2118    {
2119        return true;
2120    }
2121    if a.part_groups.iter().zip(&b.part_groups).any(|(x, y)| {
2122        x.first_part != y.first_part
2123            || x.last_part != y.last_part
2124            || x.symbol != y.symbol
2125            || x.barlines_connect != y.barlines_connect
2126    }) {
2127        return true;
2128    }
2129    for (ap, bp) in a.parts.iter().zip(&b.parts) {
2130        if ap.percussion_instruments != bp.percussion_instruments
2131            || ap.staff_groups != bp.staff_groups
2132            || ap.staves.len() != bp.staves.len()
2133        {
2134            return true;
2135        }
2136        for (as_, bs) in ap.staves.iter().zip(&bp.staves) {
2137            if as_.measures.len() != bs.measures.len() {
2138                return true;
2139            }
2140        }
2141    }
2142    false
2143}
2144
2145/// Compare two scores and return a list of [`ScorePatch`] operations.
2146///
2147/// Applying the patches to `a` via [`apply_patch`] produces a score structurally
2148/// equivalent to `b` (same parts, staves, measures, and note content).
2149pub fn score_patch(a: &Score, b: &Score) -> Vec<ScorePatch> {
2150    let mut patches: Vec<ScorePatch> = Vec::new();
2151
2152    if patch_requires_replace(a, b) {
2153        return vec![ScorePatch::ReplaceScore {
2154            score: Box::new(b.clone()),
2155        }];
2156    }
2157
2158    if a.texts != b.texts {
2159        patches.push(ScorePatch::SetScoreTexts {
2160            value: b.texts.clone(),
2161        });
2162    }
2163    if a.chord_definitions != b.chord_definitions {
2164        patches.push(ScorePatch::SetChordDefinitions {
2165            value: b.chord_definitions.clone(),
2166        });
2167    }
2168
2169    macro_rules! meta {
2170        ($field:ident, $name:literal) => {
2171            if a.metadata.$field != b.metadata.$field {
2172                patches.push(ScorePatch::SetMetadata {
2173                    field: $name.to_string(),
2174                    value: b.metadata.$field.clone(),
2175                });
2176            }
2177        };
2178    }
2179    meta!(title, "title");
2180    meta!(composer, "composer");
2181    meta!(lyricist, "lyricist");
2182    meta!(copyright, "copyright");
2183    meta!(work_number, "work_number");
2184    meta!(movement_title, "movement_title");
2185
2186    if a.settings.tempo_bpm != b.settings.tempo_bpm {
2187        patches.push(ScorePatch::SetTempo {
2188            value: b.settings.tempo_bpm,
2189        });
2190    }
2191
2192    for pi in 0..a.parts.len().min(b.parts.len()) {
2193        let ap = &a.parts[pi];
2194        let bp = &b.parts[pi];
2195        if ap.name != bp.name || ap.short_name != bp.short_name {
2196            patches.push(ScorePatch::SetPartNames {
2197                part: pi,
2198                name: bp.name.clone(),
2199                short_name: bp.short_name.clone(),
2200            });
2201        }
2202        if ap.midi_channel != bp.midi_channel || ap.midi_program != bp.midi_program {
2203            patches.push(ScorePatch::SetPartMidi {
2204                part: pi,
2205                channel: bp.midi_channel,
2206                program: bp.midi_program,
2207            });
2208        }
2209        if ap.midi_pitch_bends != bp.midi_pitch_bends
2210            || ap.midi_control_changes != bp.midi_control_changes
2211            || ap.midi_program_changes != bp.midi_program_changes
2212            || ap.midi_aftertouch != bp.midi_aftertouch
2213        {
2214            patches.push(ScorePatch::SetPartMidiAutomation {
2215                part: pi,
2216                pitch_bends: bp.midi_pitch_bends.clone(),
2217                control_changes: bp.midi_control_changes.clone(),
2218                program_changes: bp.midi_program_changes.clone(),
2219                aftertouch: bp.midi_aftertouch.clone(),
2220            });
2221        }
2222        for si in 0..ap.staves.len().min(bp.staves.len()) {
2223            let a_staff = &ap.staves[si];
2224            let b_staff = &bp.staves[si];
2225            if a_staff.clef != b_staff.clef
2226                || a_staff.transpose_semitones != b_staff.transpose_semitones
2227            {
2228                patches.push(ScorePatch::SetStaffConfiguration {
2229                    part: pi,
2230                    staff: si,
2231                    clef: b_staff.clef.clone(),
2232                    transpose_semitones: b_staff.transpose_semitones,
2233                });
2234            }
2235            if a_staff.tablature != b_staff.tablature {
2236                patches.push(ScorePatch::SetTablatureConfig {
2237                    part: pi,
2238                    staff: si,
2239                    value: b_staff.tablature.clone(),
2240                });
2241            }
2242            for mi in 0..a_staff.measures.len().min(b_staff.measures.len()) {
2243                let am = &a_staff.measures[mi];
2244                let bm = &b_staff.measures[mi];
2245
2246                if am.number != bm.number
2247                    || am.clef != bm.clef
2248                    || am.tempo_text != bm.tempo_text
2249                    || am.navigation != bm.navigation
2250                    || am.expression_text != bm.expression_text
2251                    || am.multi_rest_count != bm.multi_rest_count
2252                    || am.system_break != bm.system_break
2253                    || am.page_break != bm.page_break
2254                {
2255                    patches.push(ScorePatch::SetMeasurePresentation {
2256                        part: pi,
2257                        staff: si,
2258                        measure: mi,
2259                        number: bm.number,
2260                        clef: bm.clef.clone(),
2261                        tempo_text: bm.tempo_text.clone(),
2262                        navigation: bm.navigation.clone(),
2263                        expression_text: bm.expression_text.clone(),
2264                        multi_rest_count: bm.multi_rest_count,
2265                        system_break: bm.system_break,
2266                        page_break: bm.page_break,
2267                    });
2268                }
2269                if am.key_sig != bm.key_sig {
2270                    patches.push(ScorePatch::SetKeySignature {
2271                        part: pi,
2272                        staff: si,
2273                        measure: mi,
2274                        value: bm.key_sig.clone(),
2275                    });
2276                }
2277                if am.time_sig != bm.time_sig {
2278                    patches.push(ScorePatch::SetTimeSignature {
2279                        part: pi,
2280                        staff: si,
2281                        measure: mi,
2282                        value: bm.time_sig.clone(),
2283                    });
2284                }
2285                if am.barline_left != bm.barline_left || am.barline_right != bm.barline_right {
2286                    patches.push(ScorePatch::SetBarlines {
2287                        part: pi,
2288                        staff: si,
2289                        measure: mi,
2290                        left: bm.barline_left.clone(),
2291                        right: bm.barline_right.clone(),
2292                    });
2293                }
2294                if am.rehearsal != bm.rehearsal {
2295                    patches.push(ScorePatch::SetRehearsal {
2296                        part: pi,
2297                        staff: si,
2298                        measure: mi,
2299                        value: bm.rehearsal.clone(),
2300                    });
2301                }
2302                if am.volta != bm.volta {
2303                    patches.push(ScorePatch::SetVolta {
2304                        part: pi,
2305                        staff: si,
2306                        measure: mi,
2307                        value: bm.volta.clone(),
2308                    });
2309                }
2310                if am.tempo != bm.tempo {
2311                    patches.push(ScorePatch::SetMeasureTempo {
2312                        part: pi,
2313                        staff: si,
2314                        measure: mi,
2315                        value: bm.tempo,
2316                    });
2317                }
2318                if am.texts != bm.texts {
2319                    patches.push(ScorePatch::SetMeasureTexts {
2320                        part: pi,
2321                        staff: si,
2322                        measure: mi,
2323                        value: bm.texts.clone(),
2324                    });
2325                }
2326                if am.figured_bass != bm.figured_bass {
2327                    patches.push(ScorePatch::SetFiguredBass {
2328                        part: pi,
2329                        staff: si,
2330                        measure: mi,
2331                        value: bm.figured_bass.clone(),
2332                    });
2333                }
2334
2335                for vi in 0..4usize {
2336                    let av = &am.voices[vi];
2337                    let bv = &bm.voices[vi];
2338                    for (ni, (a_note, b_note)) in av.iter().zip(bv.iter()).enumerate() {
2339                        if !note_content_eq(a_note, b_note) {
2340                            patches.push(ScorePatch::ReplaceNote {
2341                                part: pi,
2342                                staff: si,
2343                                measure: mi,
2344                                voice: vi,
2345                                note_index: ni,
2346                                note: Box::new(b_note.clone()),
2347                            });
2348                        }
2349                    }
2350                    // Notes in `a` beyond `b` — remove in reverse order to preserve indices.
2351                    for ni in (bv.len()..av.len()).rev() {
2352                        patches.push(ScorePatch::RemoveNote {
2353                            part: pi,
2354                            staff: si,
2355                            measure: mi,
2356                            voice: vi,
2357                            note_index: ni,
2358                        });
2359                    }
2360                    // Notes in `b` beyond `a` — append.
2361                    for (offset, note) in bv.iter().skip(av.len()).enumerate() {
2362                        patches.push(ScorePatch::AddNote {
2363                            part: pi,
2364                            staff: si,
2365                            measure: mi,
2366                            voice: vi,
2367                            note_index: av.len() + offset,
2368                            note: Box::new(note.clone()),
2369                        });
2370                    }
2371                }
2372            }
2373        }
2374    }
2375
2376    patches
2377}
2378
2379/// Apply a list of [`ScorePatch`] operations to a cloned copy of `score`.
2380///
2381/// Returns `Err(Error::InvalidPatch)` if any patch references an out-of-bounds index.
2382/// The returned score is an independent clone — `score` is not modified.
2383pub fn apply_patch(score: &Score, patches: &[ScorePatch]) -> Result<Score, Error> {
2384    let mut s = score.clone();
2385    for patch in patches {
2386        match patch {
2387            ScorePatch::ReplaceScore { score } => {
2388                s = (**score).clone();
2389            }
2390            ScorePatch::SetScoreTexts { value } => {
2391                s.texts = value.clone();
2392            }
2393            ScorePatch::SetMeasureTexts {
2394                part,
2395                staff,
2396                measure,
2397                value,
2398            } => {
2399                s.parts
2400                    .get_mut(*part)
2401                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
2402                    .staves
2403                    .get_mut(*staff)
2404                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
2405                    .measures
2406                    .get_mut(*measure)
2407                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
2408                    .texts = value.clone();
2409            }
2410            ScorePatch::SetFiguredBass {
2411                part,
2412                staff,
2413                measure,
2414                value,
2415            } => {
2416                s.parts
2417                    .get_mut(*part)
2418                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
2419                    .staves
2420                    .get_mut(*staff)
2421                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
2422                    .measures
2423                    .get_mut(*measure)
2424                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
2425                    .figured_bass = value.clone();
2426            }
2427            ScorePatch::SetChordDefinitions { value } => {
2428                s.chord_definitions = value.clone();
2429            }
2430            ScorePatch::SetPartNames {
2431                part,
2432                name,
2433                short_name,
2434            } => {
2435                let target = s
2436                    .parts
2437                    .get_mut(*part)
2438                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?;
2439                target.name = name.clone();
2440                target.short_name = short_name.clone();
2441            }
2442            ScorePatch::SetPartMidi {
2443                part,
2444                channel,
2445                program,
2446            } => {
2447                let target = s
2448                    .parts
2449                    .get_mut(*part)
2450                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?;
2451                target.midi_channel = *channel;
2452                target.midi_program = *program;
2453            }
2454            ScorePatch::SetPartMidiAutomation {
2455                part,
2456                pitch_bends,
2457                control_changes,
2458                program_changes,
2459                aftertouch,
2460            } => {
2461                let target = s
2462                    .parts
2463                    .get_mut(*part)
2464                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?;
2465                target.midi_pitch_bends = pitch_bends.clone();
2466                target.midi_control_changes = control_changes.clone();
2467                target.midi_program_changes = program_changes.clone();
2468                target.midi_aftertouch = aftertouch.clone();
2469            }
2470            ScorePatch::SetStaffConfiguration {
2471                part,
2472                staff,
2473                clef,
2474                transpose_semitones,
2475            } => {
2476                let target = s
2477                    .parts
2478                    .get_mut(*part)
2479                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
2480                    .staves
2481                    .get_mut(*staff)
2482                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?;
2483                target.clef = clef.clone();
2484                target.transpose_semitones = *transpose_semitones;
2485            }
2486            ScorePatch::SetMeasurePresentation {
2487                part,
2488                staff,
2489                measure,
2490                number,
2491                clef,
2492                tempo_text,
2493                navigation,
2494                expression_text,
2495                multi_rest_count,
2496                system_break,
2497                page_break,
2498            } => {
2499                let target = s
2500                    .parts
2501                    .get_mut(*part)
2502                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
2503                    .staves
2504                    .get_mut(*staff)
2505                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
2506                    .measures
2507                    .get_mut(*measure)
2508                    .ok_or_else(|| {
2509                        Error::InvalidPatch(format!("measure {measure} out of range"))
2510                    })?;
2511                target.number = *number;
2512                target.clef = clef.clone();
2513                target.tempo_text = tempo_text.clone();
2514                target.navigation = navigation.clone();
2515                target.expression_text = expression_text.clone();
2516                target.multi_rest_count = *multi_rest_count;
2517                target.system_break = *system_break;
2518                target.page_break = *page_break;
2519            }
2520            ScorePatch::SetTablatureConfig { part, staff, value } => {
2521                s.parts
2522                    .get_mut(*part)
2523                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
2524                    .staves
2525                    .get_mut(*staff)
2526                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
2527                    .tablature = value.clone();
2528            }
2529            ScorePatch::SetMetadata { field, value } => match field.as_str() {
2530                "title" => s.metadata.title = value.clone(),
2531                "composer" => s.metadata.composer = value.clone(),
2532                "lyricist" => s.metadata.lyricist = value.clone(),
2533                "copyright" => s.metadata.copyright = value.clone(),
2534                "work_number" => s.metadata.work_number = value.clone(),
2535                "movement_title" => s.metadata.movement_title = value.clone(),
2536                other => {
2537                    return Err(Error::InvalidPatch(format!(
2538                        "unknown metadata field: {other}"
2539                    )));
2540                }
2541            },
2542            ScorePatch::SetTempo { value } => {
2543                s.settings.tempo_bpm = *value;
2544            }
2545            ScorePatch::SetKeySignature {
2546                part,
2547                staff,
2548                measure,
2549                value,
2550            } => {
2551                s.parts
2552                    .get_mut(*part)
2553                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
2554                    .staves
2555                    .get_mut(*staff)
2556                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
2557                    .measures
2558                    .get_mut(*measure)
2559                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
2560                    .key_sig = value.clone();
2561            }
2562            ScorePatch::SetTimeSignature {
2563                part,
2564                staff,
2565                measure,
2566                value,
2567            } => {
2568                s.parts
2569                    .get_mut(*part)
2570                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
2571                    .staves
2572                    .get_mut(*staff)
2573                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
2574                    .measures
2575                    .get_mut(*measure)
2576                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
2577                    .time_sig = value.clone();
2578            }
2579            ScorePatch::SetBarlines {
2580                part,
2581                staff,
2582                measure,
2583                left,
2584                right,
2585            } => {
2586                let m = s
2587                    .parts
2588                    .get_mut(*part)
2589                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
2590                    .staves
2591                    .get_mut(*staff)
2592                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
2593                    .measures
2594                    .get_mut(*measure)
2595                    .ok_or_else(|| {
2596                        Error::InvalidPatch(format!("measure {measure} out of range"))
2597                    })?;
2598                m.barline_left = left.clone();
2599                m.barline_right = right.clone();
2600            }
2601            ScorePatch::SetRehearsal {
2602                part,
2603                staff,
2604                measure,
2605                value,
2606            } => {
2607                s.parts
2608                    .get_mut(*part)
2609                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
2610                    .staves
2611                    .get_mut(*staff)
2612                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
2613                    .measures
2614                    .get_mut(*measure)
2615                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
2616                    .rehearsal = value.clone();
2617            }
2618            ScorePatch::SetVolta {
2619                part,
2620                staff,
2621                measure,
2622                value,
2623            } => {
2624                s.parts
2625                    .get_mut(*part)
2626                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
2627                    .staves
2628                    .get_mut(*staff)
2629                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
2630                    .measures
2631                    .get_mut(*measure)
2632                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
2633                    .volta = value.clone();
2634            }
2635            ScorePatch::AddNote {
2636                part,
2637                staff,
2638                measure,
2639                voice,
2640                note_index,
2641                note,
2642            } => {
2643                let v = s
2644                    .parts
2645                    .get_mut(*part)
2646                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
2647                    .staves
2648                    .get_mut(*staff)
2649                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
2650                    .measures
2651                    .get_mut(*measure)
2652                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
2653                    .voices
2654                    .get_mut(*voice)
2655                    .ok_or_else(|| Error::InvalidPatch(format!("voice {voice} out of range")))?;
2656                let insert_at = if *note_index == usize::MAX {
2657                    v.len()
2658                } else {
2659                    *note_index
2660                };
2661                if insert_at > v.len() {
2662                    return Err(Error::InvalidPatch(format!(
2663                        "note_index {note_index} out of range"
2664                    )));
2665                }
2666                v.insert(insert_at, *note.clone());
2667            }
2668            ScorePatch::RemoveNote {
2669                part,
2670                staff,
2671                measure,
2672                voice,
2673                note_index,
2674            } => {
2675                let v = s
2676                    .parts
2677                    .get_mut(*part)
2678                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
2679                    .staves
2680                    .get_mut(*staff)
2681                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
2682                    .measures
2683                    .get_mut(*measure)
2684                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
2685                    .voices
2686                    .get_mut(*voice)
2687                    .ok_or_else(|| Error::InvalidPatch(format!("voice {voice} out of range")))?;
2688                if *note_index >= v.len() {
2689                    return Err(Error::InvalidPatch(format!(
2690                        "note_index {note_index} out of range"
2691                    )));
2692                }
2693                v.remove(*note_index);
2694            }
2695            ScorePatch::ReplaceNote {
2696                part,
2697                staff,
2698                measure,
2699                voice,
2700                note_index,
2701                note,
2702            } => {
2703                let v = s
2704                    .parts
2705                    .get_mut(*part)
2706                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
2707                    .staves
2708                    .get_mut(*staff)
2709                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
2710                    .measures
2711                    .get_mut(*measure)
2712                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
2713                    .voices
2714                    .get_mut(*voice)
2715                    .ok_or_else(|| Error::InvalidPatch(format!("voice {voice} out of range")))?;
2716                if *note_index >= v.len() {
2717                    return Err(Error::InvalidPatch(format!(
2718                        "note_index {note_index} out of range"
2719                    )));
2720                }
2721                v[*note_index] = *note.clone();
2722            }
2723            ScorePatch::SetMeasureTempo {
2724                part,
2725                staff,
2726                measure,
2727                value,
2728            } => {
2729                s.parts
2730                    .get_mut(*part)
2731                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
2732                    .staves
2733                    .get_mut(*staff)
2734                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
2735                    .measures
2736                    .get_mut(*measure)
2737                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
2738                    .tempo = *value;
2739            }
2740        }
2741    }
2742    if !super::validate::validate(&s).is_valid() {
2743        return Err(Error::InvalidScore);
2744    }
2745    Ok(s)
2746}
2747
2748/// Respell all pitches in the score to prefer flats or sharps.
2749///
2750/// Applies [`Pitch::respell`] to every note in every part, staff, measure, and voice.
2751pub fn respell_score(score: &mut Score, prefer_flat: bool) {
2752    for part in &mut score.parts {
2753        for staff in &mut part.staves {
2754            for measure in &mut staff.measures {
2755                for voice in &mut measure.voices {
2756                    for note in voice.iter_mut() {
2757                        for pitch in &mut note.pitches {
2758                            *pitch = pitch.respell(prefer_flat);
2759                        }
2760                    }
2761                }
2762            }
2763        }
2764    }
2765}
2766
2767/// Respell all pitches to match the score's key signature spelling convention.
2768///
2769/// Flat-key signatures (fifths < 0) use flat spellings; sharp-key and C major use sharps.
2770pub fn respell_score_to_key(score: &mut Score) {
2771    let prefer_flat = score.settings.key_signature.fifths < 0;
2772    respell_score(score, prefer_flat);
2773}
2774
2775/// Compute total playback duration in seconds.
2776///
2777/// Uses `measure_sequence` for correct repeat handling. Lighter than generating
2778/// full playback events — suitable for progress bars and UI display.
2779pub fn score_duration_secs(score: &Score) -> f64 {
2780    if score.settings.tempo_bpm == 0 {
2781        return 0.0;
2782    }
2783    let seq = measure_sequence(score);
2784    let mut total_secs = 0.0f64;
2785    let mut current_bpm = score.settings.tempo_bpm as f64;
2786    if let Some(staff) = score.parts.first().and_then(|p| p.staves.first()) {
2787        for &idx in &seq {
2788            if let Some(m) = staff.measures.get(idx) {
2789                if let Some(b) = m.tempo {
2790                    current_bpm = b as f64;
2791                }
2792                if current_bpm == 0.0 {
2793                    continue;
2794                }
2795                let beats: f64 = m.voices[0].iter().map(|n| n.beats()).sum();
2796                total_secs += beats / current_bpm * 60.0;
2797            }
2798        }
2799    }
2800    total_secs
2801}
2802
2803/// Compute playback duration in seconds for a specific measure range (inclusive).
2804///
2805/// `region` is `(start_measure, end_measure)`, both 0-based. Measures outside the range
2806/// are excluded. Uses `measure_sequence` for correct repeat handling.
2807pub fn score_duration_secs_region(score: &Score, region: (usize, usize)) -> f64 {
2808    if score.settings.tempo_bpm == 0 {
2809        return 0.0;
2810    }
2811    let seq: Vec<usize> = measure_sequence(score)
2812        .into_iter()
2813        .filter(|&idx| idx >= region.0 && idx <= region.1)
2814        .collect();
2815    let mut total_secs = 0.0f64;
2816    let mut current_bpm = score.settings.tempo_bpm as f64;
2817    if let Some(staff) = score.parts.first().and_then(|p| p.staves.first()) {
2818        for &idx in &seq {
2819            if let Some(m) = staff.measures.get(idx) {
2820                if let Some(b) = m.tempo {
2821                    current_bpm = b as f64;
2822                }
2823                if current_bpm == 0.0 {
2824                    continue;
2825                }
2826                let beats: f64 = m.voices[0].iter().map(|n| n.beats()).sum();
2827                total_secs += beats / current_bpm * 60.0;
2828            }
2829        }
2830    }
2831    total_secs
2832}
2833
2834/// Return the number of beats available in a voice before it is full.
2835///
2836/// Uses [`Note::beats`] which correctly handles tuplet scaling.
2837/// Returns `Ok(0.0)` when the voice is already full or over-full.
2838pub fn measure_beats_remaining(
2839    score: &Score,
2840    part_index: usize,
2841    staff_index: usize,
2842    measure_index: usize,
2843    voice_index: usize,
2844) -> Result<f64, Error> {
2845    let part = score
2846        .parts
2847        .get(part_index)
2848        .ok_or(Error::PartNotFound(part_index))?;
2849    let staff = part
2850        .staves
2851        .get(staff_index)
2852        .ok_or(Error::StaffNotFound(staff_index))?;
2853    let measure = staff
2854        .measures
2855        .get(measure_index)
2856        .ok_or(Error::MeasureNotFound(measure_index))?;
2857    let voice = measure
2858        .voices
2859        .get(voice_index)
2860        .ok_or(Error::VoiceOutOfRange(voice_index))?;
2861    let ts = measure
2862        .time_sig
2863        .as_ref()
2864        .unwrap_or(&score.settings.time_signature);
2865    let used: f64 = voice.iter().map(|n| n.beats()).sum();
2866    Ok((ts.total_beats() - used).max(0.0))
2867}
2868
2869/// Suggest whether the stem should point up for the given pitches and clef.
2870///
2871/// Conventional rule: if the average MIDI pitch of the chord is below the staff
2872/// middle line, the stem points up; at or above, it points down.
2873/// For empty pitch lists (rests), returns `true` by convention.
2874pub fn suggested_stem_up(pitches: &[Pitch], clef: &Clef) -> bool {
2875    if pitches.is_empty() {
2876        return true;
2877    }
2878    let avg = pitches.iter().map(|p| p.to_midi() as f64).sum::<f64>() / pitches.len() as f64;
2879    avg < clef.middle_line_midi() as f64
2880}
2881
2882fn beam_beat_size(ts: &TimeSignature) -> f64 {
2883    if ts.numerator.is_multiple_of(3) && ts.numerator >= 6 && ts.denominator >= 8 {
2884        3.0 * 4.0 / ts.denominator as f64
2885    } else {
2886        4.0 / ts.denominator as f64
2887    }
2888}
2889
2890/// Compute recommended [`BeamState`] values for a voice's notes.
2891///
2892/// Groups beamable notes (eighth or shorter, non-rest) within beat boundaries.
2893/// Returns a `Vec` the same length as `notes`.
2894pub fn compute_beams(notes: &[Note], time_sig: &TimeSignature) -> Vec<BeamState> {
2895    let beat_size = beam_beat_size(time_sig);
2896    let n = notes.len();
2897    let mut result = vec![BeamState::None; n];
2898
2899    let is_beamable = |note: &Note| -> bool {
2900        !note.is_rest
2901            && matches!(
2902                note.duration,
2903                Duration::Eighth
2904                    | Duration::Sixteenth
2905                    | Duration::ThirtySecond
2906                    | Duration::SixtyFourth
2907            )
2908    };
2909
2910    // Compute beat start positions
2911    let mut starts = Vec::with_capacity(n);
2912    let mut pos = 0.0f64;
2913    for note in notes {
2914        starts.push(pos);
2915        pos += note.beats();
2916    }
2917
2918    // Assign beam group ids based on beat boundary
2919    let group_id = |i: usize| -> i64 { (starts[i] / beat_size).floor() as i64 };
2920
2921    let mut i = 0;
2922    while i < n {
2923        if !is_beamable(&notes[i]) {
2924            i += 1;
2925            continue;
2926        }
2927        let g = group_id(i);
2928        // Find the run of beamable notes in the same beat group
2929        let mut j = i;
2930        while j < n && is_beamable(&notes[j]) && group_id(j) == g {
2931            j += 1;
2932        }
2933        let run = j - i;
2934        if run == 1 {
2935            result[i] = BeamState::None;
2936        } else {
2937            result[i] = BeamState::Begin;
2938            result[i + 1..j - 1].fill(BeamState::Continue);
2939            result[j - 1] = BeamState::End;
2940        }
2941        i = j;
2942    }
2943    result
2944}
2945
2946fn note_content_eq(a: &Note, b: &Note) -> bool {
2947    a.is_rest == b.is_rest
2948        && a.is_unpitched == b.is_unpitched
2949        && a.instrument_id == b.instrument_id
2950        && a.offset_x == b.offset_x
2951        && a.offset_y == b.offset_y
2952        && a.relative_x == b.relative_x
2953        && a.relative_y == b.relative_y
2954        && a.pitches == b.pitches
2955        && a.duration == b.duration
2956        && a.dot_count == b.dot_count
2957        && a.tie_start == b.tie_start
2958        && a.tie_end == b.tie_end
2959        && a.beam == b.beam
2960        && a.articulations == b.articulations
2961        && a.dynamic == b.dynamic
2962        && a.stem_up == b.stem_up
2963        && a.hairpin_start == b.hairpin_start
2964        && a.hairpin_end == b.hairpin_end
2965        && a.tuplet == b.tuplet
2966        && a.chord_symbol == b.chord_symbol
2967        && a.is_grace == b.is_grace
2968        && a.grace_slash == b.grace_slash
2969        && a.ottava_start == b.ottava_start
2970        && a.ottava_end == b.ottava_end
2971        && a.lyric == b.lyric
2972        && a.pedal_start == b.pedal_start
2973        && a.pedal_end == b.pedal_end
2974        && a.slur_start == b.slur_start
2975        && a.slur_end == b.slur_end
2976        && a.arpeggiate == b.arpeggiate
2977        && a.tab_position == b.tab_position
2978        && a.tab_positions == b.tab_positions
2979        && a.guitar_technique == b.guitar_technique
2980        && a.guitar_bend_alter_cents == b.guitar_bend_alter_cents
2981}
2982
2983#[cfg(test)]
2984mod tests {
2985    use super::*;
2986    use crate::model::{
2987        notation::{FingeringSelectionPolicy, TextStyle},
2988        pitch::Step,
2989    };
2990
2991    #[test]
2992    fn fingering_selection_policy_is_deterministic_and_non_mutating() {
2993        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2994        note.fingerings = vec![3, 1, 4];
2995        note.fingering = Some(3);
2996        assert_eq!(
2997            note.select_fingering(FingeringSelectionPolicy::SourceOrder),
2998            Some(3)
2999        );
3000        assert_eq!(
3001            note.select_fingering(FingeringSelectionPolicy::LowestNumber),
3002            Some(1)
3003        );
3004        assert_eq!(
3005            note.select_fingering(FingeringSelectionPolicy::HighestNumber),
3006            Some(4)
3007        );
3008        assert_eq!(note.fingerings, vec![3, 1, 4]);
3009        assert_eq!(note.fingering, Some(3));
3010    }
3011
3012    #[test]
3013    fn default_score_has_one_part_four_measures() {
3014        let score = Score::default();
3015        assert_eq!(score.parts.len(), 1);
3016        assert_eq!(score.parts[0].staves.len(), 1);
3017        assert_eq!(score.parts[0].staves[0].measures.len(), 4);
3018    }
3019
3020    #[test]
3021    fn assign_tablature_positions_is_capo_aware_and_preserves_explicit_positions() {
3022        let mut score = Score::new("Guitar", 120, 4, 4, 0, 1);
3023        score.parts[0].staves[0].tablature = Some(TablatureConfig {
3024            lines: 6,
3025            tuning_midi: vec![64, 59, 55, 50, 45, 40],
3026            capo: 2,
3027        });
3028        score.parts[0].staves[0].measures[0].voices[0].push(Note::new(
3029            Pitch::with_alter(Step::F, 4, 1),
3030            Duration::Quarter,
3031        ));
3032        score.parts[0].staves[0].measures[0].voices[0]
3033            .push(Note::new(Pitch::new(Step::G, 3), Duration::Quarter));
3034        score.parts[0].staves[0].measures[0].voices[0][2].tab_position =
3035            Some(TabPosition { string: 6, fret: 7 });
3036
3037        assert_eq!(assign_tablature_positions(&mut score), 1);
3038        let notes = &score.parts[0].staves[0].measures[0].voices[0];
3039        assert_eq!(
3040            notes[1].tab_position,
3041            Some(TabPosition { string: 1, fret: 0 })
3042        );
3043        assert_eq!(notes[1].string_number, Some(1));
3044        assert_eq!(
3045            notes[2].tab_position,
3046            Some(TabPosition { string: 6, fret: 7 })
3047        );
3048    }
3049
3050    #[test]
3051    fn assign_tablature_positions_optimizes_chord_strings_and_fret_span() {
3052        let mut score = Score::new("Guitar", 120, 4, 4, 0, 1);
3053        score.parts[0].staves[0].tablature = Some(TablatureConfig {
3054            lines: 6,
3055            tuning_midi: vec![64, 59, 55, 50, 45, 40],
3056            capo: 0,
3057        });
3058        let mut chord = Note::new(Pitch::new(Step::E, 4), Duration::Quarter);
3059        chord.pitches.push(Pitch::new(Step::G, 4));
3060        score.parts[0].staves[0].measures[0].voices[0].push(chord);
3061
3062        assert_eq!(assign_tablature_positions(&mut score), 1);
3063        let positions = &score.parts[0].staves[0].measures[0].voices[0][1].tab_positions;
3064        assert_eq!(
3065            positions,
3066            &vec![
3067                TabPosition { string: 2, fret: 5 },
3068                TabPosition { string: 1, fret: 3 },
3069            ]
3070        );
3071    }
3072
3073    #[test]
3074    fn new_score_measure_count() {
3075        let score = Score::new("Test", 120, 4, 4, 0, 8);
3076        assert_eq!(score.measure_count(), 8);
3077    }
3078
3079    #[test]
3080    fn note_beats_quarter() {
3081        let note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3082        assert!((note.beats() - 1.0).abs() < 1e-9);
3083    }
3084
3085    #[test]
3086    fn note_beats_dotted_quarter() {
3087        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3088        note.dot_count = 1;
3089        assert!((note.beats() - 1.5).abs() < 1e-9);
3090    }
3091
3092    #[test]
3093    fn grace_note_beats_zero() {
3094        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Eighth);
3095        note.is_grace = true;
3096        assert_eq!(note.beats(), 0.0);
3097    }
3098
3099    #[test]
3100    fn measure_empty_4_4_fills_four_beats() {
3101        let m = Measure::empty(4, 4);
3102        let total: f64 = m.voices[0].iter().map(|n| n.beats()).sum();
3103        assert!((total - 4.0).abs() < 1e-9);
3104    }
3105
3106    #[test]
3107    fn measure_empty_3_4_fills_three_beats() {
3108        let m = Measure::empty(3, 4);
3109        let total: f64 = m.voices[0].iter().map(|n| n.beats()).sum();
3110        assert!((total - 3.0).abs() < 1e-9);
3111    }
3112
3113    #[test]
3114    fn whole_filling_beats() {
3115        assert_eq!(Duration::whole_filling_beats(4.0), Duration::Whole);
3116        assert_eq!(Duration::whole_filling_beats(2.0), Duration::Half);
3117        assert_eq!(Duration::whole_filling_beats(1.0), Duration::Quarter);
3118    }
3119
3120    // ── ScoreStats ────────────────────────────────────────────────────────────
3121
3122    #[test]
3123    fn statistics_default_score_all_rests() {
3124        let score = Score::default();
3125        let s = score.statistics();
3126        assert_eq!(s.part_count, 1);
3127        assert_eq!(s.measure_count, 4);
3128        assert_eq!(s.note_count, 0);
3129        assert!(s.rest_count > 0);
3130    }
3131
3132    #[test]
3133    fn statistics_duration_estimate() {
3134        // 4/4, 120 BPM, 1 measure → 4 beats → 2.0 s
3135        let score = Score::new("T", 120, 4, 4, 0, 1);
3136        let s = score.statistics();
3137        assert!((s.estimated_duration_secs - 2.0).abs() < 0.01);
3138    }
3139
3140    #[test]
3141    fn score_duration_secs_matches_statistics() {
3142        use super::score_duration_secs;
3143        let score = Score::new("T", 120, 4, 4, 0, 4);
3144        let secs = score_duration_secs(&score);
3145        // 4/4, 120 BPM, 4 measures → 16 beats → 8.0 s
3146        assert!((secs - 8.0).abs() < 0.01, "expected ~8.0 s, got {secs}");
3147    }
3148
3149    #[test]
3150    fn score_duration_secs_zero_bpm_returns_zero() {
3151        use super::score_duration_secs;
3152        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3153        score.settings.tempo_bpm = 0;
3154        assert_eq!(score_duration_secs(&score), 0.0);
3155    }
3156
3157    #[test]
3158    fn score_duration_secs_per_measure_tempo() {
3159        use super::score_duration_secs;
3160        // 2 measures: measure 0 at 120 BPM (2.0 s), measure 1 at 60 BPM (4.0 s)
3161        let mut score = Score::new("T", 120, 4, 4, 0, 2);
3162        score.parts[0].staves[0].measures[1].tempo = Some(60);
3163        let secs = score_duration_secs(&score);
3164        assert!((secs - 6.0).abs() < 0.01, "expected ~6.0 s, got {secs}");
3165    }
3166
3167    // ── extract_part ──────────────────────────────────────────────────────────
3168
3169    #[test]
3170    fn extract_part_returns_single_part_score() {
3171        let mut score = Score::default();
3172        let mut p2 = Part::new("Violin", "Vln.");
3173        p2.staves.push(Staff::new(Clef::Treble));
3174        score.parts.push(p2);
3175        let ex = score.extract_part(0).unwrap();
3176        assert_eq!(ex.parts.len(), 1);
3177        assert_ne!(ex.id, score.id);
3178        assert_eq!(ex.metadata.title, score.metadata.title);
3179    }
3180
3181    #[test]
3182    fn extract_part_out_of_range_is_none() {
3183        let score = Score::default();
3184        assert!(score.extract_part(99).is_none());
3185    }
3186
3187    #[test]
3188    fn extract_and_merge_remap_typed_spanner_part_addresses() {
3189        let mut left = Score::template(ScoreTemplate::StringQuartet);
3190        let address = NoteAddr {
3191            part: 1,
3192            staff: 0,
3193            measure: 0,
3194            voice: 0,
3195            note: 0,
3196        };
3197        left.spanners.push(NotationSpanner {
3198            id: "left-span".to_string(),
3199            kind: NotationSpannerKind::Slur,
3200            start: address.clone(),
3201            end: address,
3202            number: Some(1),
3203            line_type: None,
3204            text: None,
3205            placement: None,
3206            ottava_size: None,
3207            ottava_type: None,
3208        });
3209        let extracted = left.extract_part_checked(1).expect("valid extracted part");
3210        assert_eq!(extracted.spanners[0].start.part, 0);
3211        assert_eq!(extracted.spanners[0].end.part, 0);
3212
3213        let mut right = Score::new("R", 120, 4, 4, 0, 1);
3214        let right_address = NoteAddr {
3215            part: 0,
3216            staff: 0,
3217            measure: 0,
3218            voice: 0,
3219            note: 0,
3220        };
3221        right.spanners.push(NotationSpanner {
3222            id: "right-span".to_string(),
3223            kind: NotationSpannerKind::Pedal,
3224            start: right_address.clone(),
3225            end: right_address,
3226            number: Some(1),
3227            line_type: None,
3228            text: None,
3229            placement: None,
3230            ottava_size: None,
3231            ottava_type: None,
3232        });
3233        let merged = left.merge_checked(&right).expect("valid merged score");
3234        let right_span = merged
3235            .spanners
3236            .iter()
3237            .find(|spanner| spanner.id == "right-span")
3238            .expect("merged right span");
3239        assert_eq!(right_span.start.part, left.parts.len());
3240        assert_eq!(right_span.end.part, left.parts.len());
3241    }
3242
3243    // ── transpose ─────────────────────────────────────────────────────────────
3244
3245    #[test]
3246    fn transpose_zero_is_clone() {
3247        let score = Score::new("T", 120, 4, 4, 0, 1);
3248        let t = transpose(&score, 0);
3249        assert_eq!(t.settings.key_signature.fifths, 0);
3250    }
3251
3252    #[test]
3253    fn transpose_c_major_up_2_to_d_major() {
3254        let score = Score::new("T", 120, 4, 4, 0, 1);
3255        assert_eq!(transpose(&score, 2).settings.key_signature.fifths, 2);
3256    }
3257
3258    #[test]
3259    fn transpose_d_major_up_5_to_g_major() {
3260        let score = Score::new("T", 120, 4, 4, 2, 1);
3261        assert_eq!(transpose(&score, 5).settings.key_signature.fifths, 1);
3262    }
3263
3264    #[test]
3265    fn transpose_c4_up_1_to_csharp4() {
3266        let p = transpose_pitch(&Pitch::new(Step::C, 4), 1);
3267        assert_eq!(p.to_midi(), 61);
3268        assert_eq!(p.step, Step::C);
3269        assert_eq!(p.alter, 1);
3270    }
3271
3272    #[test]
3273    fn transpose_c4_down_1_to_b3() {
3274        let p = transpose_pitch(&Pitch::new(Step::C, 4), -1);
3275        assert_eq!(p.to_midi(), 59);
3276        assert_eq!(p.step, Step::B);
3277        assert_eq!(p.alter, 0);
3278    }
3279
3280    #[test]
3281    fn transpose_up_octave_keeps_step() {
3282        let p = transpose_pitch(&Pitch::new(Step::A, 4), 12);
3283        assert_eq!(p.to_midi(), 81);
3284        assert_eq!(p.step, Step::A);
3285        assert_eq!(p.octave, 5);
3286    }
3287
3288    #[test]
3289    fn statistics_with_repeat_doubles_duration() {
3290        // 4/4, 120 BPM, 2 measures with RepeatStart+RepeatEnd → plays twice → 4 measures worth
3291        let mut score = Score::new("T", 120, 4, 4, 0, 2);
3292        score.parts[0].staves[0].measures[0].barline_left =
3293            crate::model::notation::Barline::RepeatStart;
3294        score.parts[0].staves[0].measures[1].barline_right =
3295            crate::model::notation::Barline::RepeatEnd;
3296        let s = score.statistics();
3297        // 4 beats × 4 measures (2 physical × 2 passes) ÷ 120 BPM × 60 = 8.0 s
3298        assert!((s.estimated_duration_secs - 8.0).abs() < 0.01);
3299    }
3300
3301    #[test]
3302    fn transpose_octave_boundary_b4_to_c5() {
3303        // B4 (midi=71) + 1 semitone = C5 (midi=72)
3304        let p = transpose_pitch(&Pitch::new(Step::B, 4), 1);
3305        assert_eq!(p.to_midi(), 72);
3306        assert_eq!(p.step, Step::C);
3307        assert_eq!(p.octave, 5);
3308    }
3309
3310    #[test]
3311    fn transpose_clamp_at_midi_127() {
3312        // G9 (midi=127) + 3 semitones → clamped to 127
3313        let p = transpose_pitch(&Pitch::new(Step::G, 9), 3);
3314        assert_eq!(p.to_midi(), 127);
3315    }
3316
3317    // ── merge ─────────────────────────────────────────────────────────────────
3318
3319    #[test]
3320    fn merge_combines_parts() {
3321        let mut a = Score::new("A", 120, 4, 4, 0, 2);
3322        let b = Score::new("B", 120, 4, 4, 0, 2);
3323        // Add a second part to score a
3324        let mut p2 = Part::new("Violin", "Vln.");
3325        p2.staves.push(Staff::new(Clef::Treble));
3326        for i in 0..2usize {
3327            let mut m = Measure::empty(4, 4);
3328            m.number = i as u32 + 1;
3329            p2.staves[0].measures.push(m);
3330        }
3331        a.parts.push(p2);
3332        let merged = a.merge(&b);
3333        // a has 2 parts, b has 1 part → merged has 3 parts
3334        assert_eq!(merged.parts.len(), 3);
3335    }
3336
3337    #[test]
3338    fn merge_pads_shorter_score() {
3339        let a = Score::new("A", 120, 4, 4, 0, 4);
3340        let b = Score::new("B", 120, 4, 4, 0, 2);
3341        let merged = a.merge(&b);
3342        // Both parts should have 4 measures
3343        assert_eq!(merged.parts[0].staves[0].measures.len(), 4);
3344        assert_eq!(merged.parts[1].staves[0].measures.len(), 4);
3345    }
3346
3347    #[test]
3348    fn merge_uses_self_metadata() {
3349        let mut a = Score::new("Title A", 120, 4, 4, 0, 2);
3350        a.metadata.composer = "Composer A".to_string();
3351        let b = Score::new("Title B", 120, 4, 4, 0, 2);
3352        let merged = a.merge(&b);
3353        assert_eq!(merged.metadata.title, "Title A");
3354        assert_eq!(merged.metadata.composer, "Composer A");
3355    }
3356
3357    #[test]
3358    fn merge_new_id_differs_from_both() {
3359        let a = Score::new("A", 120, 4, 4, 0, 2);
3360        let b = Score::new("B", 120, 4, 4, 0, 2);
3361        let merged = a.merge(&b);
3362        assert_ne!(merged.id, a.id);
3363        assert_ne!(merged.id, b.id);
3364    }
3365
3366    // ── Staff.transpose_semitones ─────────────────────────────────────────────
3367
3368    #[test]
3369    fn staff_default_transpose_is_zero() {
3370        let s = Staff::new(Clef::Treble);
3371        assert_eq!(s.transpose_semitones, 0);
3372    }
3373
3374    // ── schema_version ────────────────────────────────────────────────────────
3375
3376    #[test]
3377    fn score_default_has_schema_version_1() {
3378        let score = Score::default();
3379        assert_eq!(score.schema_version, 1);
3380    }
3381
3382    #[test]
3383    fn score_new_has_schema_version_1() {
3384        let score = Score::new("T", 120, 4, 4, 0, 4);
3385        assert_eq!(score.schema_version, 1);
3386    }
3387
3388    #[test]
3389    fn score_without_schema_version_deserializes_to_zero() {
3390        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":[]}"#;
3391        let score: Score = serde_json::from_str(json).unwrap();
3392        assert_eq!(score.schema_version, 0);
3393    }
3394
3395    #[test]
3396    fn legacy_score_json_defaults_typed_spanners() {
3397        let score = Score::new("Legacy", 120, 4, 4, 0, 1);
3398        let mut value = serde_json::to_value(score).expect("score serializes");
3399        value
3400            .as_object_mut()
3401            .expect("score is an object")
3402            .remove("spanners");
3403        let restored: Score = serde_json::from_value(value).expect("legacy score deserializes");
3404        assert!(restored.spanners.is_empty());
3405    }
3406
3407    #[test]
3408    fn legacy_measure_json_defaults_source_voice_numbers() {
3409        let score = Score::new("Legacy", 120, 4, 4, 0, 1);
3410        let mut value = serde_json::to_value(&score).expect("score serializes");
3411        value["parts"][0]["staves"][0]["measures"][0]
3412            .as_object_mut()
3413            .expect("measure is an object")
3414            .remove("source_voice_numbers");
3415
3416        let restored: Score = serde_json::from_value(value).expect("legacy score deserializes");
3417        assert_eq!(
3418            restored.parts[0].staves[0].measures[0].source_voice_numbers,
3419            [None; 4]
3420        );
3421    }
3422
3423    #[test]
3424    fn note_without_new_percussion_fields_uses_serde_defaults() {
3425        let note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3426        let mut value = serde_json::to_value(note).unwrap();
3427        let object = value.as_object_mut().unwrap();
3428        object.remove("is_unpitched");
3429        object.remove("instrument_id");
3430        let restored: Note = serde_json::from_value(value).unwrap();
3431        assert!(!restored.is_unpitched);
3432        assert_eq!(restored.instrument_id, None);
3433    }
3434
3435    #[test]
3436    fn percussion_instrument_resolution_prefers_id_then_display_key() {
3437        let mut part = Part::new("Drums", "Dr.");
3438        part.percussion_instruments = vec![
3439            PercussionInstrument {
3440                id: "snare".to_string(),
3441                name: Some("Acoustic Snare".to_string()),
3442                midi_unpitched: Some(38),
3443            },
3444            PercussionInstrument {
3445                id: "rim".to_string(),
3446                name: Some("Side Stick".to_string()),
3447                midi_unpitched: Some(37),
3448            },
3449        ];
3450        let mut note = Note::new(Pitch::from_midi(38, false), Duration::Quarter);
3451        note.is_unpitched = true;
3452        assert_eq!(
3453            part.percussion_instrument_for_note(&note)
3454                .map(|instrument| instrument.id.as_str()),
3455            Some("snare")
3456        );
3457        note.instrument_id = Some("rim".to_string());
3458        assert_eq!(
3459            part.percussion_instrument_for_note(&note)
3460                .map(|instrument| instrument.id.as_str()),
3461            Some("rim")
3462        );
3463        note.instrument_id = Some("missing".to_string());
3464        assert!(part.percussion_instrument_for_note(&note).is_none());
3465        note.instrument_id = None;
3466        note.is_unpitched = false;
3467        assert!(part.percussion_instrument_for_note(&note).is_none());
3468    }
3469
3470    // ── ScoreTemplate ─────────────────────────────────────────────────────────
3471
3472    #[test]
3473    fn score_template_solo_has_one_part_treble() {
3474        let score = Score::template(ScoreTemplate::Solo);
3475        assert_eq!(score.parts.len(), 1);
3476        assert_eq!(score.parts[0].staves.len(), 1);
3477        assert_eq!(score.parts[0].staves[0].clef, Clef::Treble);
3478        assert_eq!(score.parts[0].midi_program, 0);
3479    }
3480
3481    #[test]
3482    fn score_template_piano_has_two_staves() {
3483        let score = Score::template(ScoreTemplate::Piano);
3484        assert_eq!(score.parts.len(), 1);
3485        assert_eq!(score.parts[0].staves.len(), 2);
3486        assert_eq!(score.parts[0].staves[0].clef, Clef::Treble);
3487        assert_eq!(score.parts[0].staves[1].clef, Clef::Bass);
3488    }
3489
3490    #[test]
3491    fn score_template_string_quartet_has_four_parts() {
3492        let score = Score::template(ScoreTemplate::StringQuartet);
3493        assert_eq!(score.parts.len(), 4);
3494        assert_eq!(score.parts[2].staves[0].clef, Clef::Alto); // Viola
3495        assert_eq!(score.parts[3].staves[0].clef, Clef::Bass); // Cello
3496        assert_eq!(score.parts[0].midi_program, 40);
3497        assert_eq!(score.parts[3].midi_program, 42);
3498    }
3499
3500    #[test]
3501    fn score_template_string_orchestra_has_five_parts() {
3502        let score = Score::template(ScoreTemplate::StringOrchestra);
3503        assert_eq!(score.parts.len(), 5);
3504        assert_eq!(score.parts[4].midi_program, 43); // Contrabass
3505    }
3506
3507    #[test]
3508    fn score_template_brass_quintet_has_five_parts() {
3509        let score = Score::template(ScoreTemplate::BrassQuintet);
3510        assert_eq!(score.parts.len(), 5);
3511        assert_eq!(score.parts[2].midi_program, 60); // French Horn
3512    }
3513
3514    #[test]
3515    fn score_template_default_measures_are_four() {
3516        let score = Score::template(ScoreTemplate::StringQuartet);
3517        for part in &score.parts {
3518            for staff in &part.staves {
3519                assert_eq!(staff.measures.len(), 4);
3520            }
3521        }
3522    }
3523
3524    // ── system_break / page_break ─────────────────────────────────────────────
3525
3526    #[test]
3527    fn measure_empty_has_no_breaks() {
3528        let m = Measure::empty(4, 4);
3529        assert!(!m.system_break);
3530        assert!(!m.page_break);
3531    }
3532
3533    #[test]
3534    fn system_break_survives_json_roundtrip() {
3535        let mut m = Measure::empty(4, 4);
3536        m.system_break = true;
3537        let json = serde_json::to_string(&m).unwrap();
3538        let m2: Measure = serde_json::from_str(&json).unwrap();
3539        assert!(m2.system_break);
3540        assert!(!m2.page_break);
3541    }
3542
3543    // ── diff ──────────────────────────────────────────────────────────────────
3544
3545    #[test]
3546    fn diff_identical_scores_is_empty() {
3547        let s = Score::new("T", 120, 4, 4, 0, 2);
3548        assert!(diff(&s, &s).is_empty());
3549    }
3550
3551    #[test]
3552    fn score_patch_covers_measure_semantics_and_note_insert_index() {
3553        let mut a = Score::new("T", 120, 4, 4, 0, 1);
3554        a.parts[0].staves[0].measures[0].voices[0].clear();
3555        let mut b = a.clone();
3556        let measure = &mut b.parts[0].staves[0].measures[0];
3557        measure.key_sig = Some(KeySignature {
3558            fifths: -2,
3559            mode: "major".to_string(),
3560        });
3561        measure.time_sig = Some(TimeSignature {
3562            numerator: 3,
3563            denominator: 4,
3564        });
3565        measure.barline_left = Barline::RepeatStart;
3566        measure.barline_right = Barline::RepeatEnd;
3567        measure.rehearsal = Some("A".to_string());
3568        measure.volta = Some(VoltaBracket {
3569            number: 1,
3570            kind: "begin_end".to_string(),
3571        });
3572        measure.texts.push(StyledText {
3573            style: TextStyle::RehearsalMark,
3574            text: "A".to_string(),
3575            placement: None,
3576            offset_x: None,
3577            offset_y: None,
3578            relative_x: None,
3579            relative_y: None,
3580        });
3581        measure.figured_bass.push(FiguredBassFigure {
3582            number: "6".to_string(),
3583            alter: None,
3584            prefix: None,
3585            suffix: None,
3586            extender: false,
3587        });
3588        measure.voices[0].insert(0, Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
3589        let expected = b.parts[0].staves[0].measures[0].clone();
3590
3591        let patches = score_patch(&a, &b);
3592        assert!(
3593            patches
3594                .iter()
3595                .any(|p| matches!(p, ScorePatch::SetTimeSignature { .. }))
3596        );
3597        assert!(
3598            patches
3599                .iter()
3600                .any(|p| matches!(p, ScorePatch::SetBarlines { .. }))
3601        );
3602        assert!(
3603            patches
3604                .iter()
3605                .any(|p| matches!(p, ScorePatch::SetRehearsal { .. }))
3606        );
3607        assert!(
3608            patches
3609                .iter()
3610                .any(|p| matches!(p, ScorePatch::SetVolta { .. }))
3611        );
3612        assert!(
3613            patches
3614                .iter()
3615                .any(|p| matches!(p, ScorePatch::SetMeasureTexts { .. }))
3616        );
3617        assert!(
3618            patches
3619                .iter()
3620                .any(|p| matches!(p, ScorePatch::SetFiguredBass { .. }))
3621        );
3622        let result = apply_patch(&a, &patches).expect("patch application failed");
3623        let result_measure = &result.parts[0].staves[0].measures[0];
3624        assert_eq!(result_measure.key_sig, expected.key_sig);
3625        assert_eq!(result_measure.time_sig, expected.time_sig);
3626        assert_eq!(result_measure.barline_left, expected.barline_left);
3627        assert_eq!(result_measure.barline_right, expected.barline_right);
3628        assert_eq!(result_measure.rehearsal, expected.rehearsal);
3629        assert_eq!(result_measure.volta, expected.volta);
3630        assert_eq!(result_measure.texts, expected.texts);
3631        assert_eq!(result_measure.figured_bass, expected.figured_bass);
3632        assert_eq!(result_measure.voices[0].len(), expected.voices[0].len());
3633    }
3634
3635    #[test]
3636    fn diff_and_patch_preserve_score_level_texts() {
3637        let a = Score::new("T", 120, 4, 4, 0, 1);
3638        let mut b = a.clone();
3639        b.texts.push(StyledText {
3640            style: TextStyle::Expression,
3641            text: "Prelude".to_string(),
3642            placement: Some("above".to_string()),
3643            offset_x: Some(12.0),
3644            offset_y: Some(-8.0),
3645            relative_x: None,
3646            relative_y: None,
3647        });
3648
3649        let changes = diff(&a, &b);
3650        assert!(changes.iter().any(|change| matches!(
3651            change,
3652            ScoreChange::ScoreTextChanged { old, new }
3653                if old.is_empty() && new == &b.texts
3654        )));
3655
3656        let patches = score_patch(&a, &b);
3657        assert!(patches.iter().any(|patch| matches!(
3658            patch,
3659            ScorePatch::SetScoreTexts { value } if value == &b.texts
3660        )));
3661        let result = apply_patch(&a, &patches).expect("score text patch failed");
3662        assert_eq!(result.texts, b.texts);
3663    }
3664
3665    #[test]
3666    fn measure_presentation_changes_use_typed_diff_and_local_patch() {
3667        let a = Score::new("T", 120, 4, 4, 0, 1);
3668        let mut b = a.clone();
3669        let measure = &mut b.parts[0].staves[0].measures[0];
3670        measure.number = 8;
3671        measure.clef = Some(Clef::Bass);
3672        measure.tempo_text = Some("Allegro".to_string());
3673        measure.navigation = Some("D.S.".to_string());
3674        measure.expression_text = Some("espressivo".to_string());
3675        measure.multi_rest_count = Some(3);
3676        measure.system_break = true;
3677        measure.page_break = true;
3678
3679        let changes = diff(&a, &b);
3680        assert!(changes.iter().any(|change| matches!(
3681            change,
3682            ScoreChange::MeasurePresentationChanged {
3683                part: 0,
3684                staff: 0,
3685                measure: 0,
3686                old_number: 1,
3687                new_number: 8,
3688                old_clef: None,
3689                new_clef: Some(Clef::Bass),
3690                new_tempo_text: Some(text),
3691                new_navigation: Some(navigation),
3692                new_expression_text: Some(expression),
3693                new_multi_rest_count: Some(3),
3694                old_system_break: false,
3695                new_system_break: true,
3696                old_page_break: false,
3697                new_page_break: true,
3698                ..
3699            } if text == "Allegro" && navigation == "D.S." && expression == "espressivo"
3700        )));
3701        assert!(!changes.iter().any(|change| matches!(
3702            change,
3703            ScoreChange::UnrepresentedFieldChanged { path }
3704                if path == "parts[0].staves[0].measures[0].number"
3705        )));
3706
3707        let patches = score_patch(&a, &b);
3708        assert!(patches.iter().any(|patch| matches!(
3709            patch,
3710            ScorePatch::SetMeasurePresentation {
3711                part: 0,
3712                staff: 0,
3713                measure: 0,
3714                number: 8,
3715                clef: Some(Clef::Bass),
3716                tempo_text: Some(text),
3717                navigation: Some(navigation),
3718                expression_text: Some(expression),
3719                multi_rest_count: Some(3),
3720                system_break: true,
3721                page_break: true,
3722            } if text == "Allegro" && navigation == "D.S." && expression == "espressivo"
3723        )));
3724        assert!(
3725            !patches
3726                .iter()
3727                .any(|patch| matches!(patch, ScorePatch::ReplaceScore { .. }))
3728        );
3729        let json = serde_json::to_string(&patches).expect("measure presentation patch JSON");
3730        let decoded: Vec<ScorePatch> =
3731            serde_json::from_str(&json).expect("measure presentation patch should decode");
3732        let result = apply_patch(&a, &decoded).expect("measure presentation patch failed");
3733        assert_eq!(result.parts[0].staves[0].measures[0].number, 8);
3734        assert_eq!(result.parts[0].staves[0].measures[0].clef, Some(Clef::Bass));
3735        assert_eq!(
3736            serde_json::to_value(&result).expect("patched score JSON"),
3737            serde_json::to_value(&b).expect("expected score JSON")
3738        );
3739    }
3740
3741    #[test]
3742    fn chord_definition_changes_use_typed_diff_and_local_patch() {
3743        let a = Score::new("T", 120, 4, 4, 0, 1);
3744        let mut b = a.clone();
3745        b.chord_definitions.push(ChordDefinition {
3746            id: Some("c-major".to_string()),
3747            label: Some("C".to_string()),
3748            kind: Some("major".to_string()),
3749            fret_position: Some(0),
3750            tab_strings: Some("x32010".to_string()),
3751            tab_courses: None,
3752            members: Vec::new(),
3753            barres: Vec::new(),
3754        });
3755
3756        let changes = diff(&a, &b);
3757        assert!(changes.iter().any(|change| matches!(
3758            change,
3759            ScoreChange::ChordDefinitionsChanged { old, new }
3760                if old.is_empty() && new == &b.chord_definitions
3761        )));
3762        assert!(!changes.iter().any(|change| matches!(
3763            change,
3764            ScoreChange::UnrepresentedFieldChanged { path }
3765                if path == "chord_definitions"
3766        )));
3767
3768        let patches = score_patch(&a, &b);
3769        assert!(patches.iter().any(|patch| matches!(
3770            patch,
3771            ScorePatch::SetChordDefinitions { value } if value == &b.chord_definitions
3772        )));
3773        assert!(
3774            !patches
3775                .iter()
3776                .any(|patch| matches!(patch, ScorePatch::ReplaceScore { .. }))
3777        );
3778        let json = serde_json::to_string(&patches).expect("chord definition patch JSON");
3779        let decoded: Vec<ScorePatch> =
3780            serde_json::from_str(&json).expect("chord definition patch should decode");
3781        let result = apply_patch(&a, &decoded).expect("chord definition patch failed");
3782        assert_eq!(result.chord_definitions, b.chord_definitions);
3783    }
3784
3785    #[test]
3786    fn part_name_changes_use_typed_diff_and_local_patch() {
3787        let a = Score::new("T", 120, 4, 4, 0, 1);
3788        let mut b = a.clone();
3789        b.parts[0].name = "Violin".to_string();
3790        b.parts[0].short_name = "Vln.".to_string();
3791        b.parts[0].midi_channel = 4;
3792        b.parts[0].midi_program = 40;
3793        b.parts[0].midi_pitch_bends.push(MidiPitchBend {
3794            tick: 120,
3795            channel: 4,
3796            value: 2048,
3797        });
3798        b.parts[0].midi_control_changes.push(MidiControlChange {
3799            tick: 240,
3800            channel: 4,
3801            controller: 64,
3802            value: 127,
3803        });
3804        b.parts[0].midi_program_changes.push(MidiProgramChange {
3805            tick: 0,
3806            channel: 4,
3807            program: 40,
3808        });
3809        b.parts[0].midi_aftertouch.push(MidiAftertouch {
3810            tick: 360,
3811            channel: 4,
3812            key: Some(64),
3813            value: 80,
3814        });
3815        b.parts[0].staves[0].clef = Clef::Bass;
3816        b.parts[0].staves[0].transpose_semitones = -2;
3817
3818        let changes = diff(&a, &b);
3819        assert!(changes.iter().any(|change| matches!(
3820            change,
3821            ScoreChange::PartNamesChanged {
3822                part: 0,
3823                old_name,
3824                new_name,
3825                old_short_name,
3826                new_short_name,
3827            } if old_name == "Piano"
3828                && new_name == "Violin"
3829                && old_short_name == "Pno."
3830                && new_short_name == "Vln."
3831        )));
3832        assert!(changes.iter().any(|change| matches!(
3833            change,
3834            ScoreChange::PartMidiChanged {
3835                part: 0,
3836                old_channel: 0,
3837                new_channel: 4,
3838                old_program: 0,
3839                new_program: 40,
3840            }
3841        )));
3842        assert!(changes.iter().any(|change| matches!(
3843            change,
3844            ScoreChange::PartMidiAutomationChanged {
3845                part: 0,
3846                new_pitch_bends,
3847                new_control_changes,
3848                new_program_changes,
3849                new_aftertouch,
3850                ..
3851            } if new_pitch_bends == &b.parts[0].midi_pitch_bends
3852                && new_control_changes == &b.parts[0].midi_control_changes
3853                && new_program_changes == &b.parts[0].midi_program_changes
3854                && new_aftertouch == &b.parts[0].midi_aftertouch
3855        )));
3856        assert!(changes.iter().any(|change| matches!(
3857            change,
3858            ScoreChange::StaffConfigurationChanged {
3859                part: 0,
3860                staff: 0,
3861                old_clef: Clef::Treble,
3862                new_clef: Clef::Bass,
3863                old_transpose_semitones: 0,
3864                new_transpose_semitones: -2,
3865            }
3866        )));
3867        assert!(!changes.iter().any(|change| matches!(
3868            change,
3869            ScoreChange::UnrepresentedFieldChanged { path }
3870                if path == "parts[0].name"
3871        )));
3872
3873        let patches = score_patch(&a, &b);
3874        assert!(patches.iter().any(|patch| matches!(
3875            patch,
3876            ScorePatch::SetPartNames {
3877                part: 0,
3878                name,
3879                short_name,
3880            } if name == "Violin" && short_name == "Vln."
3881        )));
3882        assert!(
3883            !patches
3884                .iter()
3885                .any(|patch| matches!(patch, ScorePatch::ReplaceScore { .. }))
3886        );
3887        assert!(patches.iter().any(|patch| matches!(
3888            patch,
3889            ScorePatch::SetPartMidi {
3890                part: 0,
3891                channel: 4,
3892                program: 40,
3893            }
3894        )));
3895        assert!(patches.iter().any(|patch| matches!(
3896            patch,
3897            ScorePatch::SetPartMidiAutomation {
3898                part: 0,
3899                pitch_bends,
3900                control_changes,
3901                program_changes,
3902                aftertouch,
3903            } if pitch_bends == &b.parts[0].midi_pitch_bends
3904                && control_changes == &b.parts[0].midi_control_changes
3905                && program_changes == &b.parts[0].midi_program_changes
3906                && aftertouch == &b.parts[0].midi_aftertouch
3907        )));
3908        assert!(patches.iter().any(|patch| matches!(
3909            patch,
3910            ScorePatch::SetStaffConfiguration {
3911                part: 0,
3912                staff: 0,
3913                clef: Clef::Bass,
3914                transpose_semitones: -2,
3915            }
3916        )));
3917        let result = apply_patch(&a, &patches).expect("part name patch failed");
3918        assert_eq!(result.parts[0].name, b.parts[0].name);
3919        assert_eq!(result.parts[0].short_name, b.parts[0].short_name);
3920        assert_eq!(result.parts[0].midi_channel, b.parts[0].midi_channel);
3921        assert_eq!(result.parts[0].midi_program, b.parts[0].midi_program);
3922        assert_eq!(
3923            result.parts[0].midi_pitch_bends,
3924            b.parts[0].midi_pitch_bends
3925        );
3926        assert_eq!(
3927            result.parts[0].midi_control_changes,
3928            b.parts[0].midi_control_changes
3929        );
3930        assert_eq!(
3931            result.parts[0].midi_program_changes,
3932            b.parts[0].midi_program_changes
3933        );
3934        assert_eq!(result.parts[0].midi_aftertouch, b.parts[0].midi_aftertouch);
3935        assert_eq!(result.parts[0].staves[0].clef, b.parts[0].staves[0].clef);
3936        assert_eq!(
3937            result.parts[0].staves[0].transpose_semitones,
3938            b.parts[0].staves[0].transpose_semitones
3939        );
3940    }
3941
3942    #[test]
3943    fn diff_reports_measure_text_and_figured_bass_changes() {
3944        let a = Score::new("T", 120, 4, 4, 0, 1);
3945        let mut b = a.clone();
3946        let measure = &mut b.parts[0].staves[0].measures[0];
3947        measure.texts.push(StyledText {
3948            style: TextStyle::Lyrics,
3949            text: "la".to_string(),
3950            placement: None,
3951            offset_x: None,
3952            offset_y: None,
3953            relative_x: None,
3954            relative_y: None,
3955        });
3956        measure.figured_bass.push(FiguredBassFigure {
3957            number: "6".to_string(),
3958            alter: None,
3959            prefix: None,
3960            suffix: None,
3961            extender: false,
3962        });
3963
3964        let changes = diff(&a, &b);
3965        assert!(changes.iter().any(|change| matches!(
3966            change,
3967            ScoreChange::MeasureTextChanged { part: 0, staff: 0, measure: 0, old, new }
3968                if old.is_empty() && new.len() == 1
3969        )));
3970        assert!(changes.iter().any(|change| matches!(
3971            change,
3972            ScoreChange::FiguredBassChanged { part: 0, staff: 0, measure: 0, old, new }
3973                if old.is_empty() && new.len() == 1
3974        )));
3975    }
3976
3977    #[test]
3978    fn diff_reports_tablature_changes_and_patches_them_locally() {
3979        let a = Score::new("T", 120, 4, 4, 0, 1);
3980        let mut b = a.clone();
3981        b.parts[0].staves[0].tablature = Some(TablatureConfig {
3982            lines: 6,
3983            tuning_midi: vec![40, 45, 50, 55, 59, 64],
3984            capo: 2,
3985        });
3986
3987        let changes = diff(&a, &b);
3988        assert!(!changes.iter().any(|change| matches!(
3989            change,
3990            ScoreChange::UnrepresentedFieldChanged { path }
3991                if path == "parts[0].staves[0].tablature"
3992        )));
3993        assert!(changes.iter().any(|change| matches!(
3994            change,
3995            ScoreChange::TablatureConfigChanged { part: 0, staff: 0, old: None, new: Some(config) }
3996                if config.lines == 6 && config.capo == 2
3997        )));
3998        let patches = score_patch(&a, &b);
3999        assert!(patches.iter().any(|patch| matches!(
4000            patch,
4001            ScorePatch::SetTablatureConfig { part: 0, staff: 0, value: Some(config) }
4002                if config.lines == 6 && config.capo == 2
4003        )));
4004        assert_eq!(
4005            apply_patch(&a, &patches).unwrap().parts[0].staves[0].tablature,
4006            b.parts[0].staves[0].tablature
4007        );
4008    }
4009
4010    #[test]
4011    fn score_patch_uses_local_presentation_patch_for_display_fields() {
4012        let a = Score::new("T", 120, 4, 4, 0, 1);
4013        let mut b = a.clone();
4014        b.parts[0].name = "Piano".to_string();
4015        b.parts[0].staves[0].measures[0].expression_text = Some("dolce".to_string());
4016        let patches = score_patch(&a, &b);
4017        assert!(patches.iter().any(|patch| matches!(
4018            patch,
4019            ScorePatch::SetMeasurePresentation {
4020                part: 0,
4021                staff: 0,
4022                measure: 0,
4023                expression_text: Some(text),
4024                ..
4025            } if text == "dolce"
4026        )));
4027        assert!(
4028            !patches
4029                .iter()
4030                .any(|patch| matches!(patch, ScorePatch::ReplaceScore { .. }))
4031        );
4032        let result = apply_patch(&a, &patches).expect("measure presentation patch failed");
4033        assert_eq!(result.parts[0].name, "Piano");
4034        assert_eq!(
4035            result.parts[0].staves[0].measures[0].expression_text,
4036            Some("dolce".to_string())
4037        );
4038    }
4039
4040    #[test]
4041    fn diff_detects_tempo_change() {
4042        let a = Score::new("T", 120, 4, 4, 0, 1);
4043        let mut b = a.clone();
4044        b.settings.tempo_bpm = 90;
4045        let changes = diff(&a, &b);
4046        assert_eq!(changes.len(), 1);
4047        assert!(matches!(
4048            changes[0],
4049            ScoreChange::TempoChanged { old: 120, new: 90 }
4050        ));
4051    }
4052
4053    #[test]
4054    fn diff_detects_title_change() {
4055        let a = Score::new("Old Title", 120, 4, 4, 0, 1);
4056        let mut b = a.clone();
4057        b.metadata.title = "New Title".to_string();
4058        let changes = diff(&a, &b);
4059        assert!(
4060            changes.iter().any(
4061                |c| matches!(c, ScoreChange::MetadataChanged { field, .. } if field == "title")
4062            )
4063        );
4064    }
4065
4066    #[test]
4067    fn diff_detects_note_modification() {
4068        let mut a = Score::new("T", 120, 4, 4, 0, 1);
4069        a.parts[0].staves[0].measures[0].voices[0] =
4070            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
4071        let mut b = a.clone();
4072        b.parts[0].staves[0].measures[0].voices[0][0] =
4073            Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
4074        let changes = diff(&a, &b);
4075        assert!(
4076            changes
4077                .iter()
4078                .any(|c| matches!(c, ScoreChange::NoteModified { .. }))
4079        );
4080    }
4081
4082    #[test]
4083    fn diff_detects_part_added() {
4084        let a = Score::new("T", 120, 4, 4, 0, 1);
4085        let mut b = a.clone();
4086        let mut p = Part::new("Violin", "Vln.");
4087        p.staves.push(Staff::new(Clef::Treble));
4088        b.parts.push(p);
4089        let changes = diff(&a, &b);
4090        assert!(
4091            changes
4092                .iter()
4093                .any(|c| matches!(c, ScoreChange::PartAdded { part_index: 1 }))
4094        );
4095    }
4096
4097    #[test]
4098    fn diff_detects_measure_tempo_change() {
4099        let a = Score::new("T", 120, 4, 4, 0, 2);
4100        let mut b = a.clone();
4101        b.parts[0].staves[0].measures[1].tempo = Some(60);
4102        let changes = diff(&a, &b);
4103        assert!(changes.iter().any(|c| matches!(
4104            c,
4105            ScoreChange::MeasureTempoChanged {
4106                measure: 1,
4107                old: None,
4108                new: Some(60),
4109                ..
4110            }
4111        )));
4112    }
4113
4114    #[test]
4115    fn diff_detects_barline_change() {
4116        use crate::model::notation::Barline;
4117        let a = Score::new("T", 120, 4, 4, 0, 2);
4118        let mut b = a.clone();
4119        b.parts[0].staves[0].measures[0].barline_left = Barline::RepeatStart;
4120        let changes = diff(&a, &b);
4121        assert!(
4122            changes
4123                .iter()
4124                .any(|c| matches!(c, ScoreChange::BarlineChanged { measure: 0, .. }))
4125        );
4126    }
4127
4128    #[test]
4129    fn diff_detects_rehearsal_change() {
4130        let a = Score::new("T", 120, 4, 4, 0, 2);
4131        let mut b = a.clone();
4132        b.parts[0].staves[0].measures[0].rehearsal = Some("A".to_string());
4133        let changes = diff(&a, &b);
4134        assert!(
4135            changes
4136                .iter()
4137                .any(|c| matches!(c, ScoreChange::RehearsalMarkChanged { measure: 0, .. }))
4138        );
4139    }
4140
4141    #[test]
4142    fn diff_detects_volta_change() {
4143        use super::VoltaBracket;
4144        let a = Score::new("T", 120, 4, 4, 0, 2);
4145        let mut b = a.clone();
4146        b.parts[0].staves[0].measures[0].volta = Some(VoltaBracket {
4147            number: 1,
4148            kind: "begin_end".into(),
4149        });
4150        let changes = diff(&a, &b);
4151        assert!(
4152            changes
4153                .iter()
4154                .any(|c| matches!(c, ScoreChange::VoltaChanged { measure: 0, .. }))
4155        );
4156    }
4157
4158    #[test]
4159    fn diff_detects_key_signature_change() {
4160        let a = Score::new("T", 120, 4, 4, 0, 1);
4161        let mut b = a.clone();
4162        b.settings.key_signature.fifths = 2; // C major → D major
4163        let changes = diff(&a, &b);
4164        assert!(
4165            changes
4166                .iter()
4167                .any(|c| matches!(c, ScoreChange::KeySignatureChanged { .. }))
4168        );
4169    }
4170
4171    #[test]
4172    fn measure_key_signature_uses_typed_local_patch() {
4173        let a = Score::new("T", 120, 4, 4, 0, 1);
4174        let mut b = a.clone();
4175        b.parts[0].staves[0].measures[0].key_sig = Some(KeySignature {
4176            fifths: 2,
4177            mode: "major".to_string(),
4178        });
4179        let changes = diff(&a, &b);
4180        assert!(!changes.iter().any(|change| matches!(
4181            change,
4182            ScoreChange::UnrepresentedFieldChanged { path }
4183                if path == "parts[0].staves[0].measures[0].key_sig"
4184        )));
4185        let patches = score_patch(&a, &b);
4186        assert!(patches.iter().any(|patch| matches!(
4187            patch,
4188            ScorePatch::SetKeySignature {
4189                part: 0,
4190                staff: 0,
4191                measure: 0,
4192                value: Some(KeySignature { fifths: 2, .. }),
4193            }
4194        )));
4195        assert!(
4196            !patches
4197                .iter()
4198                .any(|patch| matches!(patch, ScorePatch::ReplaceScore { .. }))
4199        );
4200    }
4201
4202    #[test]
4203    fn diff_same_key_signature_no_change() {
4204        let a = Score::new("T", 120, 4, 4, 2, 1);
4205        let changes = diff(&a, &a);
4206        assert!(changes.is_empty());
4207    }
4208
4209    #[test]
4210    fn score_duration_secs_region_partial() {
4211        use super::score_duration_secs_region;
4212        // 4/4, 120 BPM, 4 measures → each measure = 2.0 s; region [1,2] = 4.0 s
4213        let score = Score::new("T", 120, 4, 4, 0, 4);
4214        let secs = score_duration_secs_region(&score, (1, 2));
4215        assert!((secs - 4.0).abs() < 0.01, "expected ~4.0 s, got {secs}");
4216    }
4217
4218    #[test]
4219    fn score_duration_secs_region_single_measure() {
4220        use super::score_duration_secs_region;
4221        // 4/4, 120 BPM → 1 measure = 2.0 s
4222        let score = Score::new("T", 120, 4, 4, 0, 4);
4223        let secs = score_duration_secs_region(&score, (0, 0));
4224        assert!((secs - 2.0).abs() < 0.01, "expected ~2.0 s, got {secs}");
4225    }
4226
4227    // ── measure_beats_remaining ───────────────────────────────────────────────
4228
4229    #[test]
4230    fn measure_beats_remaining_empty_voice_returns_full() {
4231        use super::measure_beats_remaining;
4232        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4233        score.parts[0].staves[0].measures[0].voices[0].clear();
4234        let rem = measure_beats_remaining(&score, 0, 0, 0, 0).unwrap();
4235        assert!(
4236            (rem - 4.0).abs() < 1e-9,
4237            "expected 4.0 remaining, got {rem}"
4238        );
4239    }
4240
4241    #[test]
4242    fn measure_beats_remaining_half_full_returns_half() {
4243        use super::measure_beats_remaining;
4244        use crate::model::pitch::Step;
4245        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4246        score.parts[0].staves[0].measures[0].voices[0] = vec![
4247            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
4248            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
4249        ];
4250        let rem = measure_beats_remaining(&score, 0, 0, 0, 0).unwrap();
4251        assert!(
4252            (rem - 2.0).abs() < 1e-9,
4253            "expected 2.0 remaining, got {rem}"
4254        );
4255    }
4256
4257    #[test]
4258    fn measure_beats_remaining_full_voice_returns_zero() {
4259        use super::measure_beats_remaining;
4260        use crate::model::pitch::Step;
4261        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4262        score.parts[0].staves[0].measures[0].voices[0] =
4263            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
4264        let rem = measure_beats_remaining(&score, 0, 0, 0, 0).unwrap();
4265        assert!((rem).abs() < 1e-9, "expected 0.0 remaining, got {rem}");
4266    }
4267
4268    #[test]
4269    fn measure_beats_remaining_tuplet_accounting() {
4270        use super::measure_beats_remaining;
4271        use crate::model::notation::TupletInfo;
4272        use crate::model::pitch::Step;
4273        // 3 quarter-note triplets each take 2/3 of a beat → total 2.0 beats used → 2.0 remaining
4274        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4275        let tuplet = TupletInfo {
4276            actual_notes: 3,
4277            normal_notes: 2,
4278        };
4279        let mk = |step| {
4280            let mut n = Note::new(Pitch::new(step, 4), Duration::Quarter);
4281            n.tuplet = Some(tuplet.clone());
4282            n
4283        };
4284        score.parts[0].staves[0].measures[0].voices[0] =
4285            vec![mk(Step::C), mk(Step::D), mk(Step::E)];
4286        let rem = measure_beats_remaining(&score, 0, 0, 0, 0).unwrap();
4287        assert!(
4288            (rem - 2.0).abs() < 1e-9,
4289            "expected 2.0 remaining (triplets used 2.0), got {rem}"
4290        );
4291    }
4292
4293    #[test]
4294    fn measure_beats_remaining_out_of_range_returns_err() {
4295        use super::measure_beats_remaining;
4296        let score = Score::new("T", 120, 4, 4, 0, 1);
4297        assert!(measure_beats_remaining(&score, 99, 0, 0, 0).is_err());
4298        assert!(measure_beats_remaining(&score, 0, 99, 0, 0).is_err());
4299        assert!(measure_beats_remaining(&score, 0, 0, 99, 0).is_err());
4300        assert!(measure_beats_remaining(&score, 0, 0, 0, 4).is_err());
4301    }
4302
4303    #[test]
4304    fn note_content_eq_ignores_id() {
4305        use crate::model::pitch::Step;
4306        let mut a = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4307        let mut b = a.clone();
4308        b.id = "different-id".to_string();
4309        assert!(note_content_eq(&a, &b));
4310        // Actual pitch change should differ
4311        b.pitches[0] = Pitch::new(Step::D, 4);
4312        assert!(!note_content_eq(&a, &b));
4313        // stem_up difference
4314        let mut c = a.clone();
4315        a.stem_up = Some(true);
4316        c.stem_up = Some(false);
4317        assert!(!note_content_eq(&a, &c));
4318    }
4319
4320    #[test]
4321    fn suggested_stem_up_below_middle() {
4322        use crate::model::notation::Clef;
4323        // C4 = MIDI 60, Treble middle = B4 = 71 → stem up
4324        let pitches = vec![Pitch::new(Step::C, 4)];
4325        assert!(suggested_stem_up(&pitches, &Clef::Treble));
4326    }
4327
4328    #[test]
4329    fn suggested_stem_up_above_middle() {
4330        use crate::model::notation::Clef;
4331        // G5 = MIDI 79, Treble middle = 71 → stem down
4332        let pitches = vec![Pitch::new(Step::G, 5)];
4333        assert!(!suggested_stem_up(&pitches, &Clef::Treble));
4334    }
4335
4336    #[test]
4337    fn suggested_stem_up_at_middle_line() {
4338        use crate::model::notation::Clef;
4339        // B4 = MIDI 71, Treble middle = 71 → stem down (avg >= middle)
4340        let pitches = vec![Pitch::new(Step::B, 4)];
4341        assert!(!suggested_stem_up(&pitches, &Clef::Treble));
4342    }
4343
4344    #[test]
4345    fn suggested_stem_up_chord() {
4346        use crate::model::notation::Clef;
4347        // [C4=60, G4=67] avg=63.5 < 71 → stem up
4348        let pitches = vec![Pitch::new(Step::C, 4), Pitch::new(Step::G, 4)];
4349        assert!(suggested_stem_up(&pitches, &Clef::Treble));
4350    }
4351
4352    #[test]
4353    fn suggested_stem_up_bass_clef() {
4354        use crate::model::notation::Clef;
4355        // D3=50 is exactly at Bass middle line → stem down
4356        let pitches = vec![Pitch::new(Step::D, 3)];
4357        assert!(!suggested_stem_up(&pitches, &Clef::Bass));
4358        // C3=48 < 50 → stem up
4359        let pitches2 = vec![Pitch::new(Step::C, 3)];
4360        assert!(suggested_stem_up(&pitches2, &Clef::Bass));
4361    }
4362
4363    #[test]
4364    fn suggested_stem_up_empty_pitches() {
4365        use crate::model::notation::Clef;
4366        assert!(suggested_stem_up(&[], &Clef::Treble));
4367    }
4368
4369    fn eighth(pitch: Pitch) -> Note {
4370        Note::new(pitch, Duration::Eighth)
4371    }
4372    fn quarter(pitch: Pitch) -> Note {
4373        Note::new(pitch, Duration::Quarter)
4374    }
4375    fn rest_eighth() -> Note {
4376        Note::rest(Duration::Eighth)
4377    }
4378
4379    #[test]
4380    fn compute_beams_4_4_four_eighths() {
4381        use crate::model::notation::{Clef, TimeSignature};
4382        let _ = Clef::Treble; // suppress unused import warning
4383        let ts = TimeSignature {
4384            numerator: 4,
4385            denominator: 4,
4386        };
4387        let c4 = Pitch::new(Step::C, 4);
4388        let notes = vec![
4389            eighth(c4.clone()),
4390            eighth(c4.clone()),
4391            eighth(c4.clone()),
4392            eighth(c4.clone()),
4393        ];
4394        let beams = compute_beams(&notes, &ts);
4395        // 4 eighths in 4/4: beat size=1.0, two groups of 2 each
4396        assert_eq!(beams[0], BeamState::Begin);
4397        assert_eq!(beams[1], BeamState::End);
4398        assert_eq!(beams[2], BeamState::Begin);
4399        assert_eq!(beams[3], BeamState::End);
4400    }
4401
4402    #[test]
4403    fn compute_beams_4_4_all_eighth_one_group() {
4404        use crate::model::notation::TimeSignature;
4405        let ts = TimeSignature {
4406            numerator: 4,
4407            denominator: 4,
4408        };
4409        let c4 = Pitch::new(Step::C, 4);
4410        // 2 eighths in a beat → group of 2
4411        let notes = vec![eighth(c4.clone()), eighth(c4.clone())];
4412        let beams = compute_beams(&notes, &ts);
4413        assert_eq!(beams[0], BeamState::Begin);
4414        assert_eq!(beams[1], BeamState::End);
4415    }
4416
4417    #[test]
4418    fn compute_beams_quarter_not_beamed() {
4419        use crate::model::notation::TimeSignature;
4420        let ts = TimeSignature {
4421            numerator: 4,
4422            denominator: 4,
4423        };
4424        let c4 = Pitch::new(Step::C, 4);
4425        let notes = vec![quarter(c4.clone()), quarter(c4.clone())];
4426        let beams = compute_beams(&notes, &ts);
4427        assert_eq!(beams[0], BeamState::None);
4428        assert_eq!(beams[1], BeamState::None);
4429    }
4430
4431    #[test]
4432    fn compute_beams_rest_breaks_beam() {
4433        use crate::model::notation::TimeSignature;
4434        let ts = TimeSignature {
4435            numerator: 4,
4436            denominator: 4,
4437        };
4438        let c4 = Pitch::new(Step::C, 4);
4439        let notes = vec![eighth(c4.clone()), rest_eighth(), eighth(c4.clone())];
4440        let beams = compute_beams(&notes, &ts);
4441        // rest breaks beam group
4442        assert_eq!(beams[0], BeamState::None);
4443        assert_eq!(beams[1], BeamState::None);
4444        assert_eq!(beams[2], BeamState::None);
4445    }
4446
4447    #[test]
4448    fn compute_beams_6_8_compound() {
4449        use crate::model::notation::TimeSignature;
4450        let ts = TimeSignature {
4451            numerator: 6,
4452            denominator: 8,
4453        };
4454        let c4 = Pitch::new(Step::C, 4);
4455        // 6 eighths in 6/8 compound → two groups of 3 (beam size=1.5 beats)
4456        let notes: Vec<Note> = (0..6).map(|_| eighth(c4.clone())).collect();
4457        let beams = compute_beams(&notes, &ts);
4458        assert_eq!(beams[0], BeamState::Begin);
4459        assert_eq!(beams[1], BeamState::Continue);
4460        assert_eq!(beams[2], BeamState::End);
4461        assert_eq!(beams[3], BeamState::Begin);
4462        assert_eq!(beams[4], BeamState::Continue);
4463        assert_eq!(beams[5], BeamState::End);
4464    }
4465
4466    #[test]
4467    fn compute_beams_single_eighth() {
4468        use crate::model::notation::TimeSignature;
4469        let ts = TimeSignature {
4470            numerator: 4,
4471            denominator: 4,
4472        };
4473        let c4 = Pitch::new(Step::C, 4);
4474        let notes = vec![eighth(c4.clone())];
4475        let beams = compute_beams(&notes, &ts);
4476        assert_eq!(beams[0], BeamState::None);
4477    }
4478}