Skip to main content

acorde_core/model/
score.rs

1use super::{
2    duration::Duration,
3    notation::{
4        AccidentalDisplay, Articulation, Barline, BeamState, ChordDefinition, ChordSymbol, Clef,
5        CrossStaff, Dynamic, FiguredBassFigure, GuitarTechnique, HairpinKind, KeySignature, Lyric,
6        NoteHead, OttavaKind, StyledText, TabPosition, TablatureConfig, TimeSignature, TimeSymbol,
7        TupletBoundary, TupletInfo, VerseLyric,
8    },
9    pitch::Pitch,
10};
11use crate::Error;
12use serde::{Deserialize, Serialize};
13use uuid::Uuid;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ScoreMetadata {
17    pub title: String,
18    pub composer: String,
19    pub lyricist: String,
20    pub copyright: String,
21    pub work_number: String,
22    pub movement_title: String,
23}
24
25impl Default for ScoreMetadata {
26    fn default() -> Self {
27        Self {
28            title: "Untitled Score".to_string(),
29            composer: String::new(),
30            lyricist: String::new(),
31            copyright: String::new(),
32            work_number: String::new(),
33            movement_title: String::new(),
34        }
35    }
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct ScoreSettings {
40    pub tempo_bpm: u16,
41    pub time_signature: TimeSignature,
42    pub key_signature: KeySignature,
43}
44
45impl Default for ScoreSettings {
46    fn default() -> Self {
47        Self {
48            tempo_bpm: 120,
49            time_signature: TimeSignature::default(),
50            key_signature: KeySignature::default(),
51        }
52    }
53}
54
55/// Visual connector symbol for a group of adjacent parts.
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
57pub enum PartGroupSymbol {
58    Bracket, // square bracket — orchestral strings, woodwinds
59    Brace,   // curly brace — piano grand staff
60    Line,    // thin vertical line
61}
62
63/// Groups a range of adjacent parts with a bracket or brace for rendering.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct PartGroup {
66    /// Index of the first part in the group (inclusive).
67    pub first_part: usize,
68    /// Index of the last part in the group (inclusive).
69    pub last_part: usize,
70    pub symbol: PartGroupSymbol,
71    /// Whether barlines are connected across all staves in the group.
72    #[serde(default)]
73    pub barlines_connect: bool,
74}
75
76/// Groups a range of adjacent staves within one part.
77///
78/// This is distinct from [`PartGroup`], which groups separate parts.  The
79/// distinction matters for MEI and MuseScore sources where a piano-like part
80/// can contain several staves and nested staff groups.
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
82pub struct StaffGroup {
83    /// Index of the first staff in the group (inclusive).
84    pub first_staff: usize,
85    /// Index of the last staff in the group (inclusive).
86    pub last_staff: usize,
87    pub symbol: PartGroupSymbol,
88    /// Whether barlines are connected across all staves in the group.
89    #[serde(default)]
90    pub barlines_connect: bool,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct Score {
95    pub id: String,
96    /// JSON schema version. 0 when deserialized from files that predate this field.
97    #[serde(default)]
98    pub schema_version: u32,
99    pub metadata: ScoreMetadata,
100    pub settings: ScoreSettings,
101    pub parts: Vec<Part>,
102    #[serde(default)]
103    pub part_groups: Vec<PartGroup>,
104    /// Typed score-level text annotations retained independently of legacy text fields.
105    #[serde(default)]
106    pub texts: Vec<StyledText>,
107    /// Typed score-wide style defaults. A linked view may override each property locally.
108    #[serde(default)]
109    pub style_overrides: Vec<ViewStyleOverride>,
110    /// Typed presentation overrides attached to stable score objects rather than a renderer key.
111    #[serde(default)]
112    pub object_style_overrides: Vec<ObjectStyleOverride>,
113    /// Reusable chord/tablature definitions imported from interchange formats.
114    #[serde(default)]
115    pub chord_definitions: Vec<ChordDefinition>,
116    /// Typed notation spans. Legacy note-level boolean endpoints remain supported during migration.
117    #[serde(default)]
118    pub spanners: Vec<NotationSpanner>,
119    /// Named non-destructive projections used for linked parts and alternate layouts.
120    #[serde(default)]
121    pub views: Vec<ScoreView>,
122}
123
124/// A stable part/staff address used by a [`ScoreView`].
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
126pub struct ViewStaffRef {
127    pub part: usize,
128    pub staff: usize,
129}
130
131/// Whether a view presents written notation or concert pitch.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
133#[serde(rename_all = "snake_case")]
134pub enum ViewTranspositionPolicy {
135    #[default]
136    Written,
137    Concert,
138}
139
140/// Layout choices which may differ for a linked part without changing musical content.
141#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
142pub struct ScoreViewLayoutOverrides {
143    #[serde(default)]
144    pub measures_per_row: Option<usize>,
145    #[serde(default)]
146    pub hidden_staves: Vec<ViewStaffRef>,
147    #[serde(default)]
148    pub system_breaks: Vec<usize>,
149    #[serde(default)]
150    pub page_breaks: Vec<usize>,
151    /// Deterministic key/value style overrides interpreted by a renderer or host.
152    #[serde(default)]
153    pub style_overrides: Vec<(String, String)>,
154    /// Typed, bounded styling values preferred over the legacy string bridge.
155    #[serde(default)]
156    pub typed_style_overrides: Vec<ViewStyleOverride>,
157}
158
159/// A stable view-level style property with renderer-independent units.
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(rename_all = "snake_case")]
162pub enum ViewStyleProperty {
163    StaffSpace,
164    TextScale,
165    AnnotationGap,
166    SystemGap,
167}
168
169/// One typed style override. `StaffSpace` is a renderer scale multiplier; the remaining
170/// spacing values use staff-space units, while `TextScale` is dimensionless.
171#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
172pub struct ViewStyleOverride {
173    pub property: ViewStyleProperty,
174    pub value: f32,
175}
176
177/// A stable score object to which a typed presentation override applies.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(rename_all = "snake_case", tag = "kind")]
180pub enum ObjectStyleTarget {
181    ScoreText {
182        text_index: usize,
183    },
184    MeasureText {
185        part: usize,
186        staff: usize,
187        measure: usize,
188        text_index: usize,
189    },
190    Note {
191        address: NoteAddr,
192    },
193}
194
195/// Bounded source information retained when an object style originated in interchange.
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197pub struct StyleImportProvenance {
198    pub format: String,
199    pub source_location: String,
200}
201
202/// A renderer-neutral typed style override attached to a score object.
203#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
204pub struct ObjectStyleOverride {
205    pub target: ObjectStyleTarget,
206    pub property: ViewStyleProperty,
207    pub value: f32,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub provenance: Option<StyleImportProvenance>,
210}
211
212impl ScoreViewLayoutOverrides {
213    /// Return the last authored override for a property, preserving deterministic source order.
214    pub fn style_value(&self, property: ViewStyleProperty) -> Option<f32> {
215        self.typed_style_overrides
216            .iter()
217            .rev()
218            .find(|override_| override_.property == property)
219            .map(|override_| override_.value)
220    }
221
222    /// Resolve this view's typed style overrides onto the stable default style.
223    pub fn resolved_style(&self) -> ViewStyle {
224        let mut style = ViewStyle::default();
225        apply_style_overrides(&mut style, &self.typed_style_overrides);
226        style
227    }
228}
229
230fn apply_style_overrides(style: &mut ViewStyle, overrides: &[ViewStyleOverride]) {
231    for override_ in overrides {
232        match override_.property {
233            ViewStyleProperty::StaffSpace => style.staff_space = override_.value,
234            ViewStyleProperty::TextScale => style.text_scale = override_.value,
235            ViewStyleProperty::AnnotationGap => style.annotation_gap = override_.value,
236            ViewStyleProperty::SystemGap => style.system_gap = override_.value,
237        }
238    }
239}
240
241/// Renderer-independent effective style for a linked view.
242///
243/// `staff_space` and `text_scale` are dimensionless multipliers. Annotation and system gaps
244/// use staff-space units.
245#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
246pub struct ViewStyle {
247    pub staff_space: f32,
248    pub text_scale: f32,
249    pub annotation_gap: f32,
250    pub system_gap: f32,
251}
252
253impl Default for ViewStyle {
254    fn default() -> Self {
255        Self {
256            staff_space: 1.0,
257            text_scale: 1.0,
258            annotation_gap: 1.0,
259            system_gap: 2.0,
260        }
261    }
262}
263
264/// A view-local staff-kind override which never changes the source score.
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
266pub struct ViewStaffKindOverride {
267    pub staff: ViewStaffRef,
268    pub kind: StaffKind,
269}
270
271/// A linked, non-destructive score projection.
272#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
273pub struct ScoreView {
274    pub id: String,
275    pub name: String,
276    #[serde(default)]
277    pub parts: Vec<usize>,
278    #[serde(default)]
279    pub transposition_policy: ViewTranspositionPolicy,
280    /// Presentation kinds applied only when this view is resolved.
281    #[serde(default)]
282    pub staff_kind_overrides: Vec<ViewStaffKindOverride>,
283    #[serde(default)]
284    pub layout: ScoreViewLayoutOverrides,
285}
286
287impl ScoreView {
288    /// Construct a linked part view for one source part.
289    pub fn linked_part(id: impl Into<String>, name: impl Into<String>, part: usize) -> Self {
290        Self {
291            id: id.into(),
292            name: name.into(),
293            parts: vec![part],
294            transposition_policy: ViewTranspositionPolicy::Written,
295            staff_kind_overrides: Vec::new(),
296            layout: ScoreViewLayoutOverrides::default(),
297        }
298    }
299
300    /// Construct a linked part view that presents one selected staff as tablature.
301    pub fn linked_tablature_staff(
302        id: impl Into<String>,
303        name: impl Into<String>,
304        part: usize,
305        staff: usize,
306    ) -> Self {
307        let mut view = Self::linked_part(id, name, part);
308        view.staff_kind_overrides.push(ViewStaffKindOverride {
309            staff: ViewStaffRef { part, staff },
310            kind: StaffKind::Tablature,
311        });
312        view
313    }
314
315    /// Construct a linked part view that presents one selected staff as standard notation.
316    pub fn linked_standard_staff(
317        id: impl Into<String>,
318        name: impl Into<String>,
319        part: usize,
320        staff: usize,
321    ) -> Self {
322        let mut view = Self::linked_part(id, name, part);
323        view.staff_kind_overrides.push(ViewStaffKindOverride {
324            staff: ViewStaffRef { part, staff },
325            kind: StaffKind::Standard,
326        });
327        view
328    }
329}
330
331/// Bounded notation span kinds with stable source identity.
332#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
333pub enum NotationSpannerKind {
334    Slur,
335    Glissando,
336    TrillLine,
337    Pedal,
338    Ottava,
339    /// A dashed continuation line after a text direction ("cresc. - - -"), MusicXML
340    /// `<dashes>`.
341    Dashes,
342}
343
344/// A typed, potentially cross-staff notation span between two canonical note addresses.
345#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
346pub struct NotationSpanner {
347    pub id: String,
348    pub kind: NotationSpannerKind,
349    pub start: NoteAddr,
350    pub end: NoteAddr,
351    #[serde(default)]
352    pub number: Option<u16>,
353    #[serde(default)]
354    pub line_type: Option<String>,
355    #[serde(default)]
356    pub text: Option<String>,
357    #[serde(default)]
358    pub placement: Option<String>,
359    #[serde(default)]
360    pub ottava_size: Option<u8>,
361    /// MusicXML octave-shift direction (`up` or `down`) when this is an ottava span.
362    #[serde(default)]
363    pub ottava_type: Option<String>,
364}
365
366impl Default for Score {
367    fn default() -> Self {
368        let mut part = Part::new("Piano", "Pno.");
369        part.staves.push(Staff::new(Clef::Treble));
370        for _ in 0..4 {
371            part.staves[0].measures.push(Measure::empty(4, 4));
372        }
373        Self {
374            id: Uuid::new_v4().to_string(),
375            schema_version: 1,
376            metadata: ScoreMetadata::default(),
377            settings: ScoreSettings::default(),
378            parts: vec![part],
379            part_groups: Vec::new(),
380            texts: Vec::new(),
381            style_overrides: Vec::new(),
382            object_style_overrides: Vec::new(),
383            chord_definitions: Vec::new(),
384            spanners: Vec::new(),
385            views: Vec::new(),
386        }
387    }
388}
389
390/// Score template presets for common ensemble configurations.
391#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
392pub enum ScoreTemplate {
393    /// Single treble-clef part (piano by default).
394    Solo,
395    /// One piano part with treble + bass grand staff.
396    Piano,
397    /// Violin I, Violin II, Viola, Cello.
398    StringQuartet,
399    /// Violin I, Violin II, Viola, Cello, Contrabass.
400    StringOrchestra,
401    /// Two trumpets, French horn, trombone, tuba.
402    BrassQuintet,
403}
404
405impl Score {
406    /// Duration of a physical measure on the shared playback clock. An interchange file may
407    /// write an irregular length on only one staff while other parts are silent there.
408    pub fn playback_measure_beats(&self, measure_index: usize) -> f64 {
409        for part in &self.parts {
410            for staff in &part.staves {
411                if let Some(beats) = staff
412                    .measures
413                    .get(measure_index)
414                    .and_then(|measure| measure.actual_length)
415                    .and_then(|length| length.beats())
416                {
417                    return beats;
418                }
419            }
420        }
421        self.parts
422            .iter()
423            .flat_map(|part| &part.staves)
424            .find(|staff| staff.measures.get(measure_index).is_some())
425            .map_or_else(
426                || self.settings.time_signature.total_beats(),
427                |staff| staff.measure_beats(measure_index, &self.settings.time_signature),
428            )
429    }
430
431    /// This score with every typed [`NotationSpanner`] also marked on its endpoint notes'
432    /// legacy flags (`slur_start`/`slur_end`, `pedal_start`/`pedal_end`, …). Exporters and
433    /// renderers that read only the legacy flags use it so a span authored or edited through
434    /// `Score::spanners` (whose commands clear the flags) is not silently dropped. Borrowed
435    /// unchanged when there is nothing to add.
436    pub fn with_legacy_spanner_flags(&self) -> std::borrow::Cow<'_, Score> {
437        use crate::model::notation::OttavaKind;
438        let missing = |spanner: &NotationSpanner| {
439            let start = self.note_at(&spanner.start);
440            let end = self.note_at(&spanner.end);
441            match spanner.kind {
442                NotationSpannerKind::Slur => {
443                    !start.is_some_and(|n| n.slur_start) || !end.is_some_and(|n| n.slur_end)
444                }
445                NotationSpannerKind::Glissando => {
446                    !start.is_some_and(|n| n.glissando_start)
447                        || !end.is_some_and(|n| n.glissando_end)
448                }
449                NotationSpannerKind::TrillLine => {
450                    !start.is_some_and(|n| n.trill_line_start)
451                        || !end.is_some_and(|n| n.trill_line_end)
452                }
453                NotationSpannerKind::Pedal => {
454                    !start.is_some_and(|n| n.pedal_start) || !end.is_some_and(|n| n.pedal_end)
455                }
456                NotationSpannerKind::Ottava => {
457                    !start.is_some_and(|n| n.ottava_start.is_some())
458                        || !end.is_some_and(|n| n.ottava_end)
459                }
460                // No note flag holds dashes; consumers read the typed spanner.
461                NotationSpannerKind::Dashes => false,
462            }
463        };
464        if !self.spanners.iter().any(missing) {
465            return std::borrow::Cow::Borrowed(self);
466        }
467        let mut score = self.clone();
468        for spanner in &self.spanners {
469            if let Some(note) = score.note_at_mut(&spanner.start) {
470                match spanner.kind {
471                    NotationSpannerKind::Slur => note.slur_start = true,
472                    NotationSpannerKind::Glissando => note.glissando_start = true,
473                    NotationSpannerKind::TrillLine => note.trill_line_start = true,
474                    NotationSpannerKind::Pedal => note.pedal_start = true,
475                    NotationSpannerKind::Ottava if note.ottava_start.is_none() => {
476                        let two = spanner.ottava_size.is_some_and(|size| size >= 15);
477                        // MusicXML `up` displays the notes higher: an 8vb.
478                        let up = spanner.ottava_type.as_deref() == Some("up");
479                        note.ottava_start = Some(match (up, two) {
480                            (false, false) => OttavaKind::Va8,
481                            (false, true) => OttavaKind::Ma15,
482                            (true, false) => OttavaKind::Vb8,
483                            (true, true) => OttavaKind::Mb15,
484                        });
485                    }
486                    NotationSpannerKind::Ottava | NotationSpannerKind::Dashes => {}
487                }
488            }
489            if let Some(note) = score.note_at_mut(&spanner.end) {
490                match spanner.kind {
491                    NotationSpannerKind::Slur => note.slur_end = true,
492                    NotationSpannerKind::Glissando => note.glissando_end = true,
493                    NotationSpannerKind::TrillLine => note.trill_line_end = true,
494                    NotationSpannerKind::Pedal => note.pedal_end = true,
495                    NotationSpannerKind::Ottava => note.ottava_end = true,
496                    NotationSpannerKind::Dashes => {}
497                }
498            }
499        }
500        std::borrow::Cow::Owned(score)
501    }
502
503    fn note_at(&self, address: &NoteAddr) -> Option<&Note> {
504        self.parts
505            .get(address.part)?
506            .staves
507            .get(address.staff)?
508            .measures
509            .get(address.measure)?
510            .voices
511            .get(address.voice)?
512            .get(address.note)
513    }
514
515    fn note_at_mut(&mut self, address: &NoteAddr) -> Option<&mut Note> {
516        self.parts
517            .get_mut(address.part)?
518            .staves
519            .get_mut(address.staff)?
520            .measures
521            .get_mut(address.measure)?
522            .voices
523            .get_mut(address.voice)?
524            .get_mut(address.note)
525    }
526
527    /// Resolve score-wide defaults followed by the supplied view's local overrides.
528    pub fn resolved_view_style(&self, layout: &ScoreViewLayoutOverrides) -> ViewStyle {
529        let mut style = ViewStyle::default();
530        apply_style_overrides(&mut style, &self.style_overrides);
531        apply_style_overrides(&mut style, &layout.typed_style_overrides);
532        style
533    }
534
535    pub fn new(
536        title: &str,
537        tempo_bpm: u16,
538        numerator: u8,
539        denominator: u8,
540        fifths: i8,
541        measure_count: u32,
542    ) -> Self {
543        let mut score = Score::default();
544        score.metadata.title = title.to_string();
545        score.settings.tempo_bpm = tempo_bpm;
546        score.settings.time_signature = TimeSignature {
547            numerator,
548            denominator,
549        };
550        score.settings.key_signature = KeySignature {
551            fifths,
552            mode: "major".to_string(),
553        };
554
555        score.parts[0].staves[0].measures.clear();
556        for i in 0..measure_count {
557            let mut m = Measure::empty(numerator, denominator);
558            m.number = i + 1;
559            score.parts[0].staves[0].measures.push(m);
560        }
561        score
562    }
563
564    /// Create a score pre-populated with parts for the given ensemble template.
565    ///
566    /// Defaults: 120 BPM, 4/4, C major, 4 empty measures.
567    /// Use [`NewScoreCmd`](crate::model::commands::NewScoreCmd) to override those after creation.
568    pub fn template(kind: ScoreTemplate) -> Self {
569        fn measures(num: u8, den: u8, count: u32) -> Vec<Measure> {
570            (0..count)
571                .map(|i| {
572                    let mut m = Measure::empty(num, den);
573                    m.number = i + 1;
574                    m
575                })
576                .collect()
577        }
578        fn part(name: &str, short: &str, clef: Clef, program: u8) -> Part {
579            let mut p = Part::new(name, short);
580            p.midi_program = program;
581            let mut s = Staff::new(clef);
582            s.measures = measures(4, 4, 4);
583            p.staves.push(s);
584            p
585        }
586
587        let mut score = Score {
588            id: uuid::Uuid::new_v4().to_string(),
589            schema_version: 1,
590            metadata: ScoreMetadata::default(),
591            settings: ScoreSettings::default(),
592            parts: Vec::new(),
593            part_groups: Vec::new(),
594            texts: Vec::new(),
595            style_overrides: Vec::new(),
596            object_style_overrides: Vec::new(),
597            chord_definitions: Vec::new(),
598            spanners: Vec::new(),
599            views: Vec::new(),
600        };
601
602        match kind {
603            ScoreTemplate::Solo => {
604                score.parts.push(part("Piano", "Pno.", Clef::Treble, 0));
605            }
606            ScoreTemplate::Piano => {
607                let mut p = Part::new("Piano", "Pno.");
608                p.midi_program = 0;
609                let mut treble = Staff::new(Clef::Treble);
610                treble.measures = measures(4, 4, 4);
611                let mut bass = Staff::new(Clef::Bass);
612                bass.measures = measures(4, 4, 4);
613                p.staves.push(treble);
614                p.staves.push(bass);
615                score.parts.push(p);
616            }
617            ScoreTemplate::StringQuartet => {
618                score
619                    .parts
620                    .push(part("Violin I", "Vn. I", Clef::Treble, 40));
621                score
622                    .parts
623                    .push(part("Violin II", "Vn. II", Clef::Treble, 40));
624                score.parts.push(part("Viola", "Va.", Clef::Alto, 41));
625                score.parts.push(part("Cello", "Vc.", Clef::Bass, 42));
626            }
627            ScoreTemplate::StringOrchestra => {
628                score
629                    .parts
630                    .push(part("Violin I", "Vn. I", Clef::Treble, 40));
631                score
632                    .parts
633                    .push(part("Violin II", "Vn. II", Clef::Treble, 40));
634                score.parts.push(part("Viola", "Va.", Clef::Alto, 41));
635                score.parts.push(part("Cello", "Vc.", Clef::Bass, 42));
636                score.parts.push(part("Contrabass", "Cb.", Clef::Bass, 43));
637            }
638            ScoreTemplate::BrassQuintet => {
639                score
640                    .parts
641                    .push(part("Trumpet I", "Tpt. I", Clef::Treble, 56));
642                score
643                    .parts
644                    .push(part("Trumpet II", "Tpt. II", Clef::Treble, 56));
645                score
646                    .parts
647                    .push(part("French Horn", "Hn.", Clef::Treble, 60));
648                score.parts.push(part("Trombone", "Tbn.", Clef::Bass, 57));
649                score.parts.push(part("Tuba", "Tba.", Clef::Bass, 58));
650            }
651        }
652        score
653    }
654
655    pub fn measure_count(&self) -> usize {
656        self.parts
657            .first()
658            .and_then(|p| p.staves.first())
659            .map(|s| s.measures.len())
660            .unwrap_or(0)
661    }
662
663    /// Resolve the inclusive section containing `measure_index`.
664    ///
665    /// A section boundary belongs to the measure where it starts. Layout breaks are
666    /// intentionally ignored: they have no editor-range semantics.
667    pub fn section_range(
668        &self,
669        measure_index: usize,
670    ) -> Result<std::ops::RangeInclusive<usize>, Error> {
671        let measures = self
672            .parts
673            .first()
674            .and_then(|part| part.staves.first())
675            .map(|staff| &staff.measures)
676            .ok_or(Error::MeasureNotFound(measure_index))?;
677        if measure_index >= measures.len() {
678            return Err(Error::MeasureNotFound(measure_index));
679        }
680        let start = (0..=measure_index)
681            .rev()
682            .find(|&index| measures[index].section_break)
683            .unwrap_or(0);
684        let end = ((measure_index + 1)..measures.len())
685            .find(|&index| measures[index].section_break)
686            .map(|index| index - 1)
687            .unwrap_or(measures.len() - 1);
688        Ok(start..=end)
689    }
690
691    /// Aggregate statistics about the score.
692    pub fn statistics(&self) -> ScoreStats {
693        let measure_count = self.measure_count();
694        let part_count = self.parts.len();
695
696        // Beat accumulation via measure_sequence so repeats are counted correctly.
697        let seq = measure_sequence(self);
698        let total_beats: f64 = self
699            .parts
700            .first()
701            .and_then(|p| p.staves.first())
702            .map(|s| {
703                seq.iter()
704                    .filter_map(|&idx| s.measures.get(idx))
705                    .flat_map(|m| m.voices.iter().flat_map(|v| v.iter()))
706                    .map(|n| n.beats())
707                    .sum()
708            })
709            .unwrap_or(0.0);
710
711        let mut note_count = 0usize;
712        let mut rest_count = 0usize;
713        for part in &self.parts {
714            for staff in &part.staves {
715                for measure in &staff.measures {
716                    for voice in &measure.voices {
717                        for note in voice {
718                            if note.is_rest {
719                                rest_count += 1;
720                            } else {
721                                note_count += 1;
722                            }
723                        }
724                    }
725                }
726            }
727        }
728
729        let bpm = self.settings.tempo_bpm as f64;
730        let estimated_duration_secs = if bpm > 0.0 {
731            total_beats / bpm * 60.0
732        } else {
733            0.0
734        };
735
736        ScoreStats {
737            measure_count,
738            note_count,
739            rest_count,
740            part_count,
741            estimated_duration_secs,
742        }
743    }
744
745    /// Resolve a linked view to an independent score snapshot without mutating this score.
746    ///
747    /// The snapshot contains only the selected parts and notation spans whose endpoints remain
748    /// inside the projection. View-only layout settings are deliberately retained in the returned
749    /// view list so a layout consumer can apply them without changing the source score.
750    pub fn resolve_view(&self, view_id: &str) -> Result<Score, Error> {
751        let view = self
752            .views
753            .iter()
754            .find(|view| view.id == view_id)
755            .ok_or_else(|| {
756                Error::InvalidCommand(format!("score view '{view_id}' does not exist"))
757            })?;
758        let mut source_to_target = vec![None; self.parts.len()];
759        let mut parts = Vec::with_capacity(view.parts.len());
760        for &source_index in &view.parts {
761            let source = self
762                .parts
763                .get(source_index)
764                .ok_or(Error::PartNotFound(source_index))?;
765            if source_to_target[source_index].is_some() {
766                return Err(Error::InvalidCommand(format!(
767                    "score view '{}' selects part {} more than once",
768                    view.id, source_index
769                )));
770            }
771            source_to_target[source_index] = Some(parts.len());
772            parts.push(source.clone());
773        }
774        for override_ in &view.staff_kind_overrides {
775            let target_part = source_to_target
776                .get(override_.staff.part)
777                .copied()
778                .flatten()
779                .ok_or_else(|| {
780                    Error::InvalidCommand(
781                        "view overrides a staff outside its selected parts".into(),
782                    )
783                })?;
784            let target_staff = parts[target_part]
785                .staves
786                .get_mut(override_.staff.staff)
787                .ok_or_else(|| {
788                    Error::InvalidCommand(format!(
789                        "view overrides staff {} outside part {}",
790                        override_.staff.staff, override_.staff.part
791                    ))
792                })?;
793            if override_.kind == StaffKind::Tablature && target_staff.tablature.is_none() {
794                return Err(Error::InvalidCommand(
795                    "tablature view requires a tablature configuration on its source staff".into(),
796                ));
797            }
798            target_staff.presentation.kind = override_.kind;
799        }
800        let spanners = self
801            .spanners
802            .iter()
803            .filter_map(|spanner| {
804                let start = source_to_target
805                    .get(spanner.start.part)
806                    .copied()
807                    .flatten()?;
808                let end = source_to_target.get(spanner.end.part).copied().flatten()?;
809                let mut projected = spanner.clone();
810                projected.start.part = start;
811                projected.end.part = end;
812                Some(projected)
813            })
814            .collect();
815        let part_groups = self
816            .part_groups
817            .iter()
818            .filter_map(|group| {
819                let first = source_to_target.get(group.first_part).copied().flatten()?;
820                let last = source_to_target.get(group.last_part).copied().flatten()?;
821                Some(PartGroup {
822                    first_part: first,
823                    last_part: last,
824                    symbol: group.symbol.clone(),
825                    barlines_connect: group.barlines_connect,
826                })
827            })
828            .collect();
829        let mut projected_view = view.clone();
830        projected_view.parts = (0..parts.len()).collect();
831        for reference in &mut projected_view.layout.hidden_staves {
832            reference.part = source_to_target
833                .get(reference.part)
834                .copied()
835                .flatten()
836                .ok_or_else(|| {
837                    Error::InvalidCommand("view hides a part outside its selection".into())
838                })?;
839        }
840        for override_ in &mut projected_view.staff_kind_overrides {
841            override_.staff.part = source_to_target
842                .get(override_.staff.part)
843                .copied()
844                .flatten()
845                .ok_or_else(|| {
846                    Error::InvalidCommand("view overrides a staff outside its selection".into())
847                })?;
848        }
849        Ok(Score {
850            id: Uuid::new_v4().to_string(),
851            schema_version: self.schema_version,
852            metadata: self.metadata.clone(),
853            settings: self.settings.clone(),
854            parts,
855            part_groups,
856            texts: self.texts.clone(),
857            style_overrides: self.style_overrides.clone(),
858            object_style_overrides: self.object_style_overrides.clone(),
859            chord_definitions: self.chord_definitions.clone(),
860            spanners,
861            views: vec![projected_view],
862        })
863    }
864
865    /// Return a new `Score` containing only the given part.
866    /// Returns `None` if `part_index` is out of range.
867    pub fn extract_part(&self, part_index: usize) -> Option<Score> {
868        let part = self.parts.get(part_index)?.clone();
869        let spanners = self
870            .spanners
871            .iter()
872            .filter(|spanner| spanner.start.part == part_index && spanner.end.part == part_index)
873            .cloned()
874            .map(|mut spanner| {
875                spanner.start.part = 0;
876                spanner.end.part = 0;
877                spanner
878            })
879            .collect();
880        Some(Score {
881            id: Uuid::new_v4().to_string(),
882            schema_version: 1,
883            metadata: self.metadata.clone(),
884            settings: self.settings.clone(),
885            parts: vec![part],
886            part_groups: Vec::new(),
887            texts: self.texts.clone(),
888            style_overrides: self.style_overrides.clone(),
889            object_style_overrides: self.object_style_overrides.clone(),
890            chord_definitions: self.chord_definitions.clone(),
891            spanners,
892            views: Vec::new(),
893        })
894    }
895
896    /// Extract a part through the validated transformation boundary.
897    pub fn extract_part_checked(&self, part_index: usize) -> Result<Score, Error> {
898        if !super::validate::validate(self).is_valid() {
899            return Err(Error::InvalidScore);
900        }
901        let extracted = self
902            .extract_part(part_index)
903            .ok_or(Error::PartNotFound(part_index))?;
904        if !super::validate::validate(&extracted).is_valid() {
905            return Err(Error::InvalidScore);
906        }
907        Ok(extracted)
908    }
909
910    /// Merge two scores by appending `other`'s parts to `self`'s parts.
911    /// Shorter scores are padded with empty measures to match the longer one.
912    /// Metadata and settings are taken from `self`.
913    pub fn merge(&self, other: &Score) -> Score {
914        let self_count = self.measure_count();
915        let other_count = other.measure_count();
916        let max_count = self_count.max(other_count);
917        let ts = self.settings.time_signature.clone();
918
919        let pad = |mut part: Part, from: usize| -> Part {
920            for staff in &mut part.staves {
921                for i in from..max_count {
922                    let mut m = Measure::empty(ts.numerator, ts.denominator);
923                    m.number = i as u32 + 1;
924                    staff.measures.push(m);
925                }
926            }
927            part
928        };
929
930        let mut parts: Vec<Part> = self
931            .parts
932            .iter()
933            .cloned()
934            .map(|p| pad(p, self_count))
935            .collect();
936        for p in &other.parts {
937            parts.push(pad(p.clone(), other_count));
938        }
939        let self_part_count = self.parts.len();
940        let mut spanners = self.spanners.clone();
941        spanners.extend(other.spanners.iter().cloned().map(|mut spanner| {
942            spanner.start.part += self_part_count;
943            spanner.end.part += self_part_count;
944            spanner
945        }));
946
947        Score {
948            id: Uuid::new_v4().to_string(),
949            schema_version: 1,
950            metadata: self.metadata.clone(),
951            settings: self.settings.clone(),
952            parts,
953            part_groups: Vec::new(),
954            texts: self.texts.clone(),
955            style_overrides: self.style_overrides.clone(),
956            object_style_overrides: self.object_style_overrides.clone(),
957            chord_definitions: self.chord_definitions.clone(),
958            spanners,
959            views: Vec::new(),
960        }
961    }
962
963    /// Merge scores through the validated transformation boundary.
964    pub fn merge_checked(&self, other: &Score) -> Result<Score, Error> {
965        if !super::validate::validate(self).is_valid()
966            || !super::validate::validate(other).is_valid()
967        {
968            return Err(Error::InvalidScore);
969        }
970        let merged = self.merge(other);
971        if !super::validate::validate(&merged).is_valid() {
972            return Err(Error::InvalidScore);
973        }
974        Ok(merged)
975    }
976}
977
978/// Aggregate statistics returned by [`Score::statistics`].
979#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
980pub struct ScoreStats {
981    pub measure_count: usize,
982    /// Number of non-rest notes across all parts.
983    pub note_count: usize,
984    pub rest_count: usize,
985    pub part_count: usize,
986    /// Rough estimate: `total_beats(first part) / tempo_bpm * 60`.
987    pub estimated_duration_secs: f64,
988}
989
990// ── transpose ─────────────────────────────────────────────────────────────────
991
992use super::pitch::Step;
993use super::repeat::measure_sequence;
994
995/// Assign deterministic guitar tablature positions to eligible notes and chords.
996///
997/// Existing positions are preserved. For each note, the lowest reachable fret is
998/// selected, breaking ties toward the highest string (the lowest string number). For
999/// chords, strings are unique and the assignment minimizes total fret, then fret
1000/// span, then highest fret. This is a bounded deterministic fingering heuristic,
1001/// not a claim of instrument-specific playability.
1002/// The staff tuning is interpreted as open-string MIDI pitches before capo, and
1003/// assignments are limited to frets 0 through 24. Rests, chords, and notes with
1004/// no reachable position are left unchanged. Returns the number of notes/chords
1005/// assigned.
1006pub fn assign_tablature_positions(score: &mut Score) -> usize {
1007    const MAX_FRET: i16 = 24;
1008    let mut assigned = 0;
1009
1010    for part in &mut score.parts {
1011        for staff in &mut part.staves {
1012            if staff.tablature.is_none() {
1013                continue;
1014            }
1015            let configurations: Vec<Option<TablatureConfig>> = (0..staff.measures.len())
1016                .map(|measure_index| staff.tablature_at(measure_index))
1017                .collect();
1018
1019            for (measure_index, measure) in staff.measures.iter_mut().enumerate() {
1020                let Some(tab) = configurations[measure_index].as_ref() else {
1021                    continue;
1022                };
1023                for voice in &mut measure.voices {
1024                    for note in voice.iter_mut() {
1025                        if note.is_rest
1026                            || note.tab_position.is_some()
1027                            || !note.tab_positions.is_empty()
1028                            || note.pitches.is_empty()
1029                        {
1030                            continue;
1031                        }
1032                        let pitches: Vec<i16> = note.pitches.iter().map(Pitch::to_midi).collect();
1033                        let Some(positions) = best_tablature_assignment(
1034                            &pitches,
1035                            &tab.tuning_midi,
1036                            tab.lines as usize,
1037                            i16::from(tab.capo),
1038                            MAX_FRET,
1039                        ) else {
1040                            continue;
1041                        };
1042
1043                        note.tab_position = positions.first().cloned();
1044                        note.tab_positions = positions;
1045                        note.string_number = note.tab_position.as_ref().map(|p| p.string);
1046                        assigned += 1;
1047                    }
1048                }
1049            }
1050        }
1051    }
1052
1053    assigned
1054}
1055
1056/// Optimize tablature positions across each voice using bounded position movement.
1057///
1058/// Explicit positions are treated as fixed anchors. Unassigned notes and chords
1059/// receive positions that minimize fret load plus movement from the preceding
1060/// event; ties are resolved deterministically. Returns the number of notes/chords
1061/// newly assigned.
1062pub fn optimize_tablature_positions(score: &mut Score) -> usize {
1063    const MAX_FRET: i16 = 24;
1064    let mut assigned = 0;
1065
1066    for part in &mut score.parts {
1067        for staff in &mut part.staves {
1068            if staff.tablature.is_none() {
1069                continue;
1070            }
1071            let configurations: Vec<Option<TablatureConfig>> = (0..staff.measures.len())
1072                .map(|measure_index| staff.tablature_at(measure_index))
1073                .collect();
1074            for voice in 0..4 {
1075                // Work per measure/voice location so repeated note indices remain distinct.
1076                let mut locations = Vec::new();
1077                for (measure_index, measure) in staff.measures.iter().enumerate() {
1078                    for (note_index, note) in measure.voices[voice].iter().enumerate() {
1079                        if !note.is_rest && !note.pitches.is_empty() {
1080                            locations.push((measure_index, note_index, note.clone()));
1081                        }
1082                    }
1083                }
1084                if locations.is_empty() {
1085                    continue;
1086                }
1087                let candidates: Vec<Vec<Vec<TabPosition>>> = locations
1088                    .iter()
1089                    .map(|(measure_index, _, note)| {
1090                        if let Some(positions) = if !note.tab_positions.is_empty() {
1091                            Some(note.tab_positions.clone())
1092                        } else {
1093                            note.tab_position.clone().map(|position| vec![position])
1094                        } {
1095                            vec![positions]
1096                        } else {
1097                            let Some(tab) = configurations[*measure_index].as_ref() else {
1098                                return Vec::new();
1099                            };
1100                            tablature_assignments(
1101                                &note.pitches.iter().map(Pitch::to_midi).collect::<Vec<_>>(),
1102                                &tab.tuning_midi,
1103                                tab.lines as usize,
1104                                i16::from(tab.capo),
1105                                MAX_FRET,
1106                            )
1107                        }
1108                    })
1109                    .collect();
1110                if candidates.iter().any(Vec::is_empty) {
1111                    continue;
1112                }
1113
1114                let mut costs: Vec<Vec<(u32, Option<usize>)>> = candidates
1115                    .iter()
1116                    .map(|events| vec![(u32::MAX, None); events.len()])
1117                    .collect();
1118                for (candidate_index, candidate) in candidates[0].iter().enumerate() {
1119                    costs[0][candidate_index] = (tablature_load(candidate), None);
1120                }
1121                for event_index in 1..candidates.len() {
1122                    for (candidate_index, candidate) in candidates[event_index].iter().enumerate() {
1123                        let load = tablature_load(candidate);
1124                        for (previous_index, previous) in
1125                            candidates[event_index - 1].iter().enumerate()
1126                        {
1127                            let previous_cost = costs[event_index - 1][previous_index].0;
1128                            let cost = previous_cost
1129                                .saturating_add(load)
1130                                .saturating_add(tablature_movement(previous, candidate));
1131                            if cost < costs[event_index][candidate_index].0 {
1132                                costs[event_index][candidate_index] = (cost, Some(previous_index));
1133                            }
1134                        }
1135                    }
1136                }
1137                let mut selected = vec![0; candidates.len()];
1138                if let Some((last, _)) = costs.last().and_then(|row| {
1139                    row.iter()
1140                        .enumerate()
1141                        .min_by_key(|(index, (cost, _))| (*cost, *index))
1142                }) {
1143                    selected[candidates.len() - 1] = last;
1144                    for event_index in (1..candidates.len()).rev() {
1145                        selected[event_index - 1] =
1146                            costs[event_index][selected[event_index]].1.unwrap_or(0);
1147                    }
1148                }
1149
1150                for (((measure_index, note_index, original), event_candidates), selected_index) in
1151                    locations.into_iter().zip(candidates).zip(selected)
1152                {
1153                    if original.tab_position.is_none() && original.tab_positions.is_empty() {
1154                        let note = &mut staff.measures[measure_index].voices[voice][note_index];
1155                        let positions = event_candidates[selected_index].clone();
1156                        note.tab_position = positions.first().cloned();
1157                        note.tab_positions = positions;
1158                        note.string_number = note.tab_position.as_ref().map(|p| p.string);
1159                        assigned += 1;
1160                    }
1161                }
1162            }
1163        }
1164    }
1165
1166    assigned
1167}
1168
1169fn tablature_load(positions: &[TabPosition]) -> u32 {
1170    let sum: u32 = positions
1171        .iter()
1172        .map(|position| u32::from(position.fret))
1173        .sum();
1174    let min = positions
1175        .iter()
1176        .map(|position| position.fret)
1177        .min()
1178        .unwrap_or(0);
1179    let max = positions
1180        .iter()
1181        .map(|position| position.fret)
1182        .max()
1183        .unwrap_or(0);
1184    let span = max - min;
1185    // A four-fret hand position is a practical baseline; wide chords receive
1186    // a strong penalty so a higher but compact voicing wins when available.
1187    let stretch_penalty = span.saturating_sub(4) as u32 * 12;
1188    sum + u32::from(span) * 2 + stretch_penalty
1189}
1190
1191fn tablature_movement(previous: &[TabPosition], current: &[TabPosition]) -> u32 {
1192    previous
1193        .iter()
1194        .zip(current)
1195        .map(|(a, b)| u32::from(a.fret.abs_diff(b.fret)) + u32::from(a.string.abs_diff(b.string)))
1196        .sum()
1197}
1198
1199fn tablature_assignments(
1200    pitches: &[i16],
1201    tuning: &[i16],
1202    lines: usize,
1203    capo: i16,
1204    max_fret: i16,
1205) -> Vec<Vec<TabPosition>> {
1206    #[allow(clippy::too_many_arguments)]
1207    fn visit(
1208        pitches: &[i16],
1209        tuning: &[i16],
1210        lines: usize,
1211        capo: i16,
1212        max_fret: i16,
1213        index: usize,
1214        used: &mut [bool],
1215        current: &mut Vec<TabPosition>,
1216        output: &mut Vec<Vec<TabPosition>>,
1217    ) {
1218        if index == pitches.len() {
1219            output.push(current.clone());
1220            return;
1221        }
1222        for (string, open) in tuning.iter().enumerate().take(lines) {
1223            if used[string] {
1224                continue;
1225            }
1226            let fret = pitches[index] - *open - capo;
1227            if !(0..=max_fret).contains(&fret) {
1228                continue;
1229            }
1230            used[string] = true;
1231            current.push(TabPosition {
1232                string: (string + 1) as u8,
1233                fret: fret as u8,
1234            });
1235            visit(
1236                pitches,
1237                tuning,
1238                lines,
1239                capo,
1240                max_fret,
1241                index + 1,
1242                used,
1243                current,
1244                output,
1245            );
1246            current.pop();
1247            used[string] = false;
1248        }
1249    }
1250
1251    if pitches.is_empty() || pitches.len() > lines {
1252        return Vec::new();
1253    }
1254    let mut output = Vec::new();
1255    visit(
1256        pitches,
1257        tuning,
1258        lines,
1259        capo,
1260        max_fret,
1261        0,
1262        &mut vec![false; lines],
1263        &mut Vec::new(),
1264        &mut output,
1265    );
1266    output
1267}
1268
1269fn best_tablature_assignment(
1270    pitches: &[i16],
1271    tuning: &[i16],
1272    lines: usize,
1273    capo: i16,
1274    max_fret: i16,
1275) -> Option<Vec<TabPosition>> {
1276    type Assignment = (i16, i16, i16, Vec<u8>, Vec<TabPosition>);
1277
1278    #[allow(clippy::too_many_arguments)]
1279    fn search(
1280        pitches: &[i16],
1281        tuning: &[i16],
1282        lines: usize,
1283        capo: i16,
1284        max_fret: i16,
1285        index: usize,
1286        used: &mut [bool],
1287        current: &mut Vec<TabPosition>,
1288        best: &mut Option<Assignment>,
1289    ) {
1290        if index == pitches.len() {
1291            let sum: i16 = current.iter().map(|p| i16::from(p.fret)).sum();
1292            let min = current.iter().map(|p| p.fret).min().unwrap_or(0);
1293            let max = current.iter().map(|p| p.fret).max().unwrap_or(0);
1294            let strings: Vec<u8> = current.iter().map(|p| p.string).collect();
1295            let candidate = (
1296                sum,
1297                i16::from(max) - i16::from(min),
1298                i16::from(max),
1299                strings,
1300                current.clone(),
1301            );
1302            if best.as_ref().is_none_or(|existing| {
1303                (candidate.0, candidate.1, candidate.2, &candidate.3)
1304                    < (existing.0, existing.1, existing.2, &existing.3)
1305            }) {
1306                *best = Some(candidate);
1307            }
1308            return;
1309        }
1310
1311        for (string, open) in tuning.iter().enumerate().take(lines) {
1312            if used[string] {
1313                continue;
1314            }
1315            let fret = pitches[index] - *open - capo;
1316            if !(0..=max_fret).contains(&fret) {
1317                continue;
1318            }
1319            used[string] = true;
1320            current.push(TabPosition {
1321                string: (string + 1) as u8,
1322                fret: fret as u8,
1323            });
1324            search(
1325                pitches,
1326                tuning,
1327                lines,
1328                capo,
1329                max_fret,
1330                index + 1,
1331                used,
1332                current,
1333                best,
1334            );
1335            current.pop();
1336            used[string] = false;
1337        }
1338    }
1339
1340    if pitches.len() > lines {
1341        return None;
1342    }
1343    let mut used = vec![false; lines];
1344    let mut current = Vec::with_capacity(pitches.len());
1345    let mut best = None;
1346    search(
1347        pitches,
1348        tuning,
1349        lines,
1350        capo,
1351        max_fret,
1352        0,
1353        &mut used,
1354        &mut current,
1355        &mut best,
1356    );
1357    best.map(|(_, _, _, _, positions)| positions)
1358}
1359
1360/// Return a new `Score` with all pitches shifted by `semitones`.
1361/// Key signatures (global and per-measure) are updated accordingly.
1362/// If `semitones == 0` the score is cloned unchanged.
1363pub fn transpose(score: &Score, semitones: i8) -> Score {
1364    if semitones == 0 {
1365        return score.clone();
1366    }
1367    let mut out = score.clone();
1368    out.settings.key_signature.fifths = transpose_fifths(
1369        score.settings.key_signature.fifths,
1370        &score.settings.key_signature.mode,
1371        semitones,
1372    );
1373    for part in &mut out.parts {
1374        for staff in &mut part.staves {
1375            for measure in &mut staff.measures {
1376                if let Some(ref mut ks) = measure.key_sig {
1377                    ks.fifths = transpose_fifths(ks.fifths, &ks.mode, semitones);
1378                }
1379                for voice in &mut measure.voices {
1380                    for note in voice.iter_mut() {
1381                        for pitch in note.pitches.iter_mut() {
1382                            *pitch = transpose_pitch(pitch, semitones);
1383                        }
1384                    }
1385                }
1386            }
1387        }
1388    }
1389    out
1390}
1391
1392/// Transpose a score through the validated transformation boundary.
1393pub fn transpose_checked(score: &Score, semitones: i8) -> Result<Score, Error> {
1394    if !super::validate::validate(score).is_valid() {
1395        return Err(Error::InvalidScore);
1396    }
1397    let transposed = transpose(score, semitones);
1398    if !super::validate::validate(&transposed).is_valid() {
1399        return Err(Error::InvalidScore);
1400    }
1401    Ok(transposed)
1402}
1403
1404/// Choose whether a regional transposition rewrites notation or concert sounding pitch.
1405#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1406#[serde(rename_all = "snake_case")]
1407pub enum RegionalTranspositionTarget {
1408    /// Rewrite written pitches and local key signatures in the selected measures.
1409    Written,
1410    /// Keep written notation unchanged and adjust the staff's concert-pitch offset.
1411    Concert,
1412}
1413
1414/// Transpose one inclusive-start, exclusive-end staff measure range through the validated
1415/// transformation boundary. The source score is never mutated.
1416pub fn transpose_staff_region_checked(
1417    score: &Score,
1418    part_index: usize,
1419    staff_index: usize,
1420    start_measure: usize,
1421    end_measure: usize,
1422    semitones: i8,
1423    target: RegionalTranspositionTarget,
1424) -> Result<Score, Error> {
1425    if !super::validate::validate(score).is_valid() {
1426        return Err(Error::InvalidScore);
1427    }
1428    let source_staff = score
1429        .parts
1430        .get(part_index)
1431        .ok_or(Error::PartNotFound(part_index))?
1432        .staves
1433        .get(staff_index)
1434        .ok_or(Error::StaffNotFound(staff_index))?;
1435    if start_measure >= end_measure || end_measure > source_staff.measures.len() {
1436        return Err(Error::InvalidCommand(format!(
1437            "invalid measure range {start_measure}..{end_measure}"
1438        )));
1439    }
1440    if target == RegionalTranspositionTarget::Concert
1441        && (start_measure != 0 || end_measure != source_staff.measures.len())
1442    {
1443        return Err(Error::InvalidCommand(
1444            "concert-pitch staff transposition requires the full staff range".into(),
1445        ));
1446    }
1447    let mut transformed = score.clone();
1448    let staff = &mut transformed.parts[part_index].staves[staff_index];
1449    match target {
1450        RegionalTranspositionTarget::Written => {
1451            for measure in &mut staff.measures[start_measure..end_measure] {
1452                if let Some(key) = &mut measure.key_sig {
1453                    key.fifths = transpose_fifths(key.fifths, &key.mode, semitones);
1454                }
1455                for voice in &mut measure.voices {
1456                    for note in voice {
1457                        for pitch in &mut note.pitches {
1458                            *pitch = transpose_pitch(pitch, semitones);
1459                        }
1460                    }
1461                }
1462            }
1463        }
1464        RegionalTranspositionTarget::Concert => {
1465            staff.transpose_semitones = staff.transpose_semitones.saturating_add(semitones);
1466        }
1467    }
1468    if !super::validate::validate(&transformed).is_valid() {
1469        return Err(Error::InvalidScore);
1470    }
1471    Ok(transformed)
1472}
1473
1474fn transpose_pitch(pitch: &Pitch, semitones: i8) -> Pitch {
1475    let new_midi = (pitch.to_midi() + semitones as i16).clamp(0, 127) as u8;
1476    let pc = new_midi % 12;
1477    let oct = (new_midi / 12) as i8 - 1;
1478    let (step, alter): (Step, i8) = if semitones >= 0 {
1479        match pc {
1480            0 => (Step::C, 0),
1481            1 => (Step::C, 1),
1482            2 => (Step::D, 0),
1483            3 => (Step::D, 1),
1484            4 => (Step::E, 0),
1485            5 => (Step::F, 0),
1486            6 => (Step::F, 1),
1487            7 => (Step::G, 0),
1488            8 => (Step::G, 1),
1489            9 => (Step::A, 0),
1490            10 => (Step::A, 1),
1491            11 => (Step::B, 0),
1492            _ => (Step::C, 0),
1493        }
1494    } else {
1495        match pc {
1496            0 => (Step::C, 0),
1497            1 => (Step::D, -1),
1498            2 => (Step::D, 0),
1499            3 => (Step::E, -1),
1500            4 => (Step::E, 0),
1501            5 => (Step::F, 0),
1502            6 => (Step::G, -1),
1503            7 => (Step::G, 0),
1504            8 => (Step::A, -1),
1505            9 => (Step::A, 0),
1506            10 => (Step::B, -1),
1507            11 => (Step::B, 0),
1508            _ => (Step::C, 0),
1509        }
1510    };
1511    Pitch::with_microtone(step, oct, alter, pitch.microtone_cents)
1512}
1513
1514/// Shift a key signature's fifths value by `semitones`.
1515///
1516/// Uses the circle-of-fifths arithmetic:
1517/// - `tonic_pc = (fifths * 7) mod 12`  (for major; minor adds 9 to get relative major tonic)
1518/// - `new_fifths = (new_tonic_pc * 7) mod 12`, adjusted to `[-7, 7]`
1519fn transpose_fifths(fifths: i8, mode: &str, semitones: i8) -> i8 {
1520    let tonic_major_pc = ((fifths as i32 * 7).rem_euclid(12)) as u8;
1521    let tonic_pc = if mode == "minor" {
1522        ((tonic_major_pc as i32 + 9).rem_euclid(12)) as u8
1523    } else {
1524        tonic_major_pc
1525    };
1526    let new_tonic = ((tonic_pc as i32 + semitones as i32).rem_euclid(12)) as u8;
1527    let major_tonic = if mode == "minor" {
1528        ((new_tonic as i32 + 3).rem_euclid(12)) as u8
1529    } else {
1530        new_tonic
1531    };
1532    let raw = ((major_tonic as i32 * 7).rem_euclid(12)) as i8;
1533    if raw > 6 { raw - 12 } else { raw }
1534}
1535
1536/// Inclusive MIDI note-number range declared by an instrument definition.
1537#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1538pub struct InstrumentRange {
1539    pub lowest: u8,
1540    pub highest: u8,
1541}
1542
1543/// Stable, renderer-independent instrument semantics for a part.
1544///
1545/// This is deliberately separate from a part's current MIDI state: the latter
1546/// records imported/performance values, while this definition supplies the
1547/// instrument identity, notation defaults, and practical written/sounding
1548/// ranges used by editors and layout clients.
1549#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1550pub struct InstrumentDefinition {
1551    /// Stable source or application-defined identifier. It must be non-empty.
1552    pub id: String,
1553    #[serde(default)]
1554    pub name: String,
1555    #[serde(default)]
1556    pub short_name: String,
1557    #[serde(default)]
1558    pub family: Option<String>,
1559    #[serde(default)]
1560    pub transpose_semitones: i8,
1561    #[serde(default)]
1562    pub written_range: Option<InstrumentRange>,
1563    #[serde(default)]
1564    pub sounding_range: Option<InstrumentRange>,
1565    #[serde(default)]
1566    pub default_clefs: Vec<Clef>,
1567    #[serde(default = "default_instrument_staff_count")]
1568    pub staff_count: u8,
1569    #[serde(default)]
1570    pub staff_kind: StaffKind,
1571    #[serde(default)]
1572    pub midi_channel: u8,
1573    #[serde(default)]
1574    pub midi_program: u8,
1575    #[serde(default)]
1576    pub percussion_map_id: Option<String>,
1577}
1578
1579const fn default_instrument_staff_count() -> u8 {
1580    1
1581}
1582
1583impl InstrumentDefinition {
1584    /// Construct a minimal single-staff instrument declaration.
1585    pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
1586        Self {
1587            id: id.into(),
1588            name: name.into(),
1589            short_name: String::new(),
1590            family: None,
1591            transpose_semitones: 0,
1592            written_range: None,
1593            sounding_range: None,
1594            default_clefs: Vec::new(),
1595            staff_count: 1,
1596            staff_kind: StaffKind::Standard,
1597            midi_channel: 0,
1598            midi_program: 0,
1599            percussion_map_id: None,
1600        }
1601    }
1602}
1603
1604/// One named instrument channel, such as a MuseScore arco or pizzicato channel.
1605#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1606pub struct NamedInstrumentChannel {
1607    pub name: String,
1608    pub program: u8,
1609    pub midi_channel: u8,
1610}
1611
1612/// A named instrument-channel switch for one voice at a position within a measure.
1613#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1614pub struct VoiceChannelSwitch {
1615    pub voice: usize,
1616    pub position_beats: f64,
1617    pub channel_name: String,
1618}
1619
1620#[derive(Debug, Clone, Serialize, Deserialize)]
1621pub struct Part {
1622    pub id: String,
1623    pub name: String,
1624    pub short_name: String,
1625    pub staves: Vec<Staff>,
1626    /// MIDI channel (0–15). Channel 9 is conventionally used for percussion.
1627    #[serde(default)]
1628    pub midi_channel: u8,
1629    /// General MIDI program number (0–127). Default 0 = Acoustic Grand Piano.
1630    #[serde(default)]
1631    pub midi_program: u8,
1632    /// Named alternate channels declared by the source instrument.
1633    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1634    pub named_channels: Vec<NamedInstrumentChannel>,
1635    /// MIDI pitch-bend events preserved from interchange input, in canonical 480 PPQ ticks.
1636    #[serde(default)]
1637    pub midi_pitch_bends: Vec<MidiPitchBend>,
1638    /// MIDI Control Change events preserved from interchange input, in canonical 480 PPQ ticks.
1639    #[serde(default)]
1640    pub midi_control_changes: Vec<MidiControlChange>,
1641    /// MIDI Program Change events preserved from interchange input, in canonical 480 PPQ ticks.
1642    #[serde(default)]
1643    pub midi_program_changes: Vec<MidiProgramChange>,
1644    /// MIDI key/channel aftertouch events preserved from interchange input, in canonical 480 PPQ ticks.
1645    #[serde(default)]
1646    pub midi_aftertouch: Vec<MidiAftertouch>,
1647    /// MusicXML score-instrument definitions for note-level instrument IDs.
1648    #[serde(default)]
1649    pub percussion_instruments: Vec<PercussionInstrument>,
1650    /// MEI/MuseScore staff-group structure within this part.
1651    #[serde(default)]
1652    pub staff_groups: Vec<StaffGroup>,
1653    /// Optional named instrument semantics. Omitted in older score JSON.
1654    #[serde(default)]
1655    pub instrument: Option<InstrumentDefinition>,
1656}
1657
1658#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1659pub struct MidiPitchBend {
1660    pub tick: u64,
1661    pub channel: u8,
1662    /// Signed 14-bit MIDI bend value in the range -8192..=8191.
1663    pub value: i16,
1664}
1665
1666#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1667pub struct MidiControlChange {
1668    pub tick: u64,
1669    pub channel: u8,
1670    /// MIDI controller number (0–127).
1671    pub controller: u8,
1672    /// Seven-bit controller value (0–127).
1673    pub value: u8,
1674}
1675
1676#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1677pub struct MidiProgramChange {
1678    pub tick: u64,
1679    pub channel: u8,
1680    /// General MIDI program number (0–127).
1681    pub program: u8,
1682}
1683
1684#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1685pub struct MidiAftertouch {
1686    pub tick: u64,
1687    pub channel: u8,
1688    /// Key number for key pressure, or `None` for channel pressure.
1689    pub key: Option<u8>,
1690    /// Seven-bit pressure value (0–127).
1691    pub value: u8,
1692}
1693
1694/// An editable percussion-kit mapping associated with a MusicXML score-instrument identity.
1695///
1696/// The MIDI key remains the playback fallback, while the remaining fields
1697/// describe renderer-independent notation defaults. Individual notes may still
1698/// override these defaults.
1699impl PercussionInstrument {
1700    /// Diatonic steps of a displayed unpitched note from the five-line staff's middle line
1701    /// (B4 in the treble/percussion layout), the unit of `staff_position`.
1702    pub fn display_position(pitch: &Pitch) -> i8 {
1703        let step = "CDEFGAB"
1704            .find(pitch.step.to_char())
1705            .map_or(0, |index| index as i32);
1706        let steps = i32::from(pitch.octave) * 7 + step;
1707        (steps - (4 * 7 + 6)).clamp(i32::from(i8::MIN), i32::from(i8::MAX)) as i8
1708    }
1709}
1710
1711#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1712pub struct PercussionInstrument {
1713    /// Stable MusicXML score-instrument identity.
1714    pub id: String,
1715    #[serde(default)]
1716    pub name: Option<String>,
1717    /// General MIDI percussion key (0-based, 35 = acoustic bass drum, 38 = acoustic snare)
1718    /// played for this instrument. MusicXML `<midi-unpitched>` is 1-based and is converted
1719    /// on import and export.
1720    #[serde(default)]
1721    pub midi_unpitched: Option<u8>,
1722    /// Diatonic staff position relative to the middle line.
1723    #[serde(default)]
1724    pub staff_position: Option<i8>,
1725    /// Default glyph when a note does not supply a renderer-specific override.
1726    #[serde(default)]
1727    pub notehead: Option<NoteHead>,
1728    /// Preferred one-based score voice for newly entered kit notes.
1729    #[serde(default)]
1730    pub preferred_voice: Option<u8>,
1731    /// Named kit-specific variants such as rim-shot or open.
1732    #[serde(default)]
1733    pub techniques: Vec<String>,
1734}
1735
1736impl Part {
1737    pub fn new(name: &str, short_name: &str) -> Self {
1738        Self {
1739            id: Uuid::new_v4().to_string(),
1740            name: name.to_string(),
1741            short_name: short_name.to_string(),
1742            staves: Vec::new(),
1743            midi_channel: 0,
1744            midi_program: 0,
1745            named_channels: Vec::new(),
1746            midi_pitch_bends: Vec::new(),
1747            midi_control_changes: Vec::new(),
1748            midi_program_changes: Vec::new(),
1749            midi_aftertouch: Vec::new(),
1750            percussion_instruments: Vec::new(),
1751            staff_groups: Vec::new(),
1752            instrument: None,
1753        }
1754    }
1755
1756    /// General MIDI key to sound for pitch `pitch_index` of an unpitched note: the declared
1757    /// instrument's `midi_unpitched`, found by the note's `instrument_id` (for its first pitch)
1758    /// or else by the instrument whose `staff_position` matches the pitch's display position.
1759    /// `None` when the note is pitched or nothing matches; playback then uses the display
1760    /// pitch, as before.
1761    pub fn percussion_key(&self, note: &Note, pitch_index: usize) -> Option<u8> {
1762        if !note.is_unpitched {
1763            return None;
1764        }
1765        let pitch = note.pitches.get(pitch_index)?;
1766        let by_id = note
1767            .instrument_id
1768            .as_deref()
1769            .filter(|_| pitch_index == 0)
1770            .and_then(|id| {
1771                self.percussion_instruments
1772                    .iter()
1773                    .find(|instrument| instrument.id == id)
1774            })
1775            .and_then(|instrument| instrument.midi_unpitched);
1776        by_id.or_else(|| {
1777            let position = PercussionInstrument::display_position(pitch);
1778            self.percussion_instruments
1779                .iter()
1780                .filter(|instrument| instrument.staff_position == Some(position))
1781                .find_map(|instrument| instrument.midi_unpitched)
1782        })
1783    }
1784
1785    /// Resolve the declared percussion instrument for an unpitched note.
1786    ///
1787    /// An explicit MusicXML `instrument@id` always takes precedence.  When no
1788    /// identifier is attached, the retained display-key MIDI value is matched
1789    /// against the part's declared `midi_unpitched` entries.  This deliberately
1790    /// does not invent a sound identity for an unpitched note with no matching
1791    /// declaration.
1792    pub fn percussion_instrument_for_note(&self, note: &Note) -> Option<&PercussionInstrument> {
1793        if !note.is_unpitched {
1794            return None;
1795        }
1796        if let Some(instrument_id) = note.instrument_id.as_deref() {
1797            return self
1798                .percussion_instruments
1799                .iter()
1800                .find(|instrument| instrument.id == instrument_id);
1801        }
1802        let midi_key = u8::try_from(note.pitches.first()?.to_midi()).ok()?;
1803        self.percussion_instruments
1804            .iter()
1805            .find(|instrument| instrument.midi_unpitched == Some(midi_key))
1806    }
1807}
1808
1809/// The semantic notation mode of a staff, independent of renderer-specific styling.
1810#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1811#[serde(rename_all = "snake_case")]
1812pub enum StaffKind {
1813    #[default]
1814    Standard,
1815    Tablature,
1816    Percussion,
1817}
1818
1819/// A score-level notehead convention selected for an entire staff.
1820#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1821#[serde(rename_all = "snake_case")]
1822pub enum StaffNoteheadScheme {
1823    #[default]
1824    Standard,
1825    PitchNames,
1826    ShapeNotes,
1827}
1828
1829/// How a tablature staff exposes rhythmic duration in addition to fret numbers.
1830#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1831#[serde(rename_all = "snake_case")]
1832pub enum TablatureRhythmDisplay {
1833    /// Fret numbers only; duration remains available in the score model.
1834    #[default]
1835    FretOnly,
1836    /// Add stems and flags to the tablature staff's fret numbers.
1837    Stems,
1838}
1839
1840/// Renderer-independent staff appearance and semantic settings.
1841///
1842/// Values are expressed in staff-space units, never pixels. Existing tablature
1843/// tuning data remains in [`Staff::tablature`]; `kind` records how a renderer
1844/// should interpret the staff.
1845#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1846pub struct StaffPresentation {
1847    #[serde(default)]
1848    pub kind: StaffKind,
1849    #[serde(default = "default_staff_line_count")]
1850    pub lines: u8,
1851    #[serde(default = "default_staff_line_distance")]
1852    pub line_distance: f32,
1853    #[serde(default)]
1854    pub small: bool,
1855    #[serde(default)]
1856    pub cutaway: bool,
1857    #[serde(default = "default_staff_visible")]
1858    pub visible: bool,
1859    #[serde(default)]
1860    pub notehead_scheme: StaffNoteheadScheme,
1861    /// Tablature-only duration presentation. Ignored on non-tablature staves.
1862    #[serde(default)]
1863    pub tablature_rhythm_display: TablatureRhythmDisplay,
1864    /// Tablature fret-label convention. Ignored on non-tablature staves.
1865    #[serde(default)]
1866    pub tablature_fret_mark_style: TablatureFretMarkStyle,
1867}
1868
1869const fn default_staff_line_count() -> u8 {
1870    5
1871}
1872
1873const fn default_staff_line_distance() -> f32 {
1874    1.0
1875}
1876
1877const fn default_staff_visible() -> bool {
1878    true
1879}
1880
1881impl Default for StaffPresentation {
1882    fn default() -> Self {
1883        Self {
1884            kind: StaffKind::Standard,
1885            lines: default_staff_line_count(),
1886            line_distance: default_staff_line_distance(),
1887            small: false,
1888            cutaway: false,
1889            visible: true,
1890            notehead_scheme: StaffNoteheadScheme::Standard,
1891            tablature_rhythm_display: TablatureRhythmDisplay::FretOnly,
1892            tablature_fret_mark_style: TablatureFretMarkStyle::Arabic,
1893        }
1894    }
1895}
1896
1897/// Glyph convention used to label tablature frets.
1898#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1899#[serde(rename_all = "snake_case")]
1900pub enum TablatureFretMarkStyle {
1901    #[default]
1902    Arabic,
1903    RomanUpper,
1904    RomanLower,
1905}
1906
1907impl StaffPresentation {
1908    fn for_clef(clef: &Clef) -> Self {
1909        let mut presentation = Self::default();
1910        if matches!(clef, Clef::Percussion) {
1911            presentation.kind = StaffKind::Percussion;
1912        }
1913        presentation
1914    }
1915}
1916
1917#[derive(Debug, Clone, Serialize, Deserialize)]
1918pub struct Staff {
1919    pub clef: Clef,
1920    pub measures: Vec<Measure>,
1921    /// Semitones to add to written pitch for concert pitch / MIDI output.
1922    /// -2 = Bb instrument (clarinet, trumpet), -9 = Eb instrument (alto sax), etc.
1923    #[serde(default)]
1924    pub transpose_semitones: i8,
1925    #[serde(default)]
1926    pub tablature: Option<TablatureConfig>,
1927    #[serde(default)]
1928    pub presentation: StaffPresentation,
1929}
1930
1931impl Staff {
1932    /// The meter in force at `measure_index`: that bar's time signature or the last one before
1933    /// it, else `default` (the score's). A bar without its own signature continues the meter
1934    /// of the change before it, not the score's opening one.
1935    pub fn meter_at<'a>(
1936        &'a self,
1937        measure_index: usize,
1938        default: &'a TimeSignature,
1939    ) -> &'a TimeSignature {
1940        self.measures
1941            .iter()
1942            .take(measure_index.saturating_add(1))
1943            .rev()
1944            .find_map(|measure| measure.time_sig.as_ref())
1945            .unwrap_or(default)
1946    }
1947
1948    /// A bar's length in beats: its `actual_length`, else the meter in force there.
1949    pub fn measure_beats(&self, measure_index: usize, default: &TimeSignature) -> f64 {
1950        self.measures.get(measure_index).map_or_else(
1951            || self.meter_at(measure_index, default).total_beats(),
1952            |measure| measure.duration_beats(self.meter_at(measure_index, default)),
1953        )
1954    }
1955
1956    pub fn new(clef: Clef) -> Self {
1957        Self {
1958            presentation: StaffPresentation::for_clef(&clef),
1959            clef,
1960            measures: Vec::new(),
1961            transpose_semitones: 0,
1962            tablature: None,
1963        }
1964    }
1965
1966    /// Resolve the tablature tuning and capo active at a physical measure.
1967    ///
1968    /// A measure-local change starts at its own boundary and continues until
1969    /// replaced or cleared. Line-count changes are rejected by validation so
1970    /// the staff geometry remains stable within a system.
1971    pub fn tablature_at(&self, measure_index: usize) -> Option<TablatureConfig> {
1972        let mut active = self.tablature.clone();
1973        for measure in self.measures.iter().take(measure_index.saturating_add(1)) {
1974            if let Some(change) = &measure.tablature_change {
1975                active = Some(change.clone());
1976            }
1977        }
1978        active
1979    }
1980}
1981
1982#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1983pub struct VoltaBracket {
1984    /// Ending number (1, 2, …); the first when the ending serves several passes.
1985    pub number: u8,
1986    /// "begin" | "mid" | "end" | "begin_end"
1987    pub kind: String,
1988    /// Every pass the ending is played on when it serves several (`1, 2` or `1-3`); empty
1989    /// (the usual case) means `number` alone.
1990    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1991    pub numbers: Vec<u8>,
1992}
1993
1994impl VoltaBracket {
1995    /// The passes this ending is played on, in order.
1996    pub fn passes(&self) -> Vec<u8> {
1997        if self.numbers.is_empty() {
1998            vec![self.number]
1999        } else {
2000            self.numbers.clone()
2001        }
2002    }
2003
2004    /// Whether the ending is played on repeat pass `pass` (1 = first time through).
2005    pub fn plays_on(&self, pass: u8) -> bool {
2006        if self.numbers.is_empty() {
2007            self.number == pass
2008        } else {
2009            self.numbers.contains(&pass)
2010        }
2011    }
2012
2013    /// The ending's label as scores print it: `1`, `1, 2`, `1-3` (a run of three or more).
2014    pub fn label(&self) -> String {
2015        let passes = self.passes();
2016        match passes.as_slice() {
2017            [first, .., last]
2018                if passes.len() >= 3 && passes.windows(2).all(|pair| pair[1] == pair[0] + 1) =>
2019            {
2020                format!("{first}-{last}")
2021            }
2022            _ => passes
2023                .iter()
2024                .map(u8::to_string)
2025                .collect::<Vec<_>>()
2026                .join(", "),
2027        }
2028    }
2029
2030    /// Pass numbers from a label such as `1`, `1, 2`, `1.2.` or `1-3`.
2031    pub fn parse_passes(label: &str) -> Vec<u8> {
2032        let mut passes = Vec::new();
2033        for part in label
2034            .split([',', ' ', '.', '+', '/'])
2035            .filter(|part| !part.is_empty())
2036        {
2037            if let Some((from, to)) = part.split_once(['-', '–'])
2038                && let (Ok(from), Ok(to)) = (from.trim().parse::<u8>(), to.trim().parse::<u8>())
2039                && from <= to
2040                && to - from < 32
2041            {
2042                passes.extend(from..=to);
2043            } else if let Ok(pass) = part.trim().parse::<u8>() {
2044                passes.push(pass);
2045            }
2046        }
2047        passes.sort_unstable();
2048        passes.dedup();
2049        passes
2050    }
2051}
2052
2053/// One of the three mechanically available positions of a concert-harp pedal.
2054#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2055#[serde(rename_all = "kebab-case")]
2056pub enum HarpPedalPosition {
2057    Flat,
2058    #[default]
2059    Natural,
2060    Sharp,
2061}
2062
2063/// A MusicXML-compatible harp-pedal diagram attached to one measure.
2064///
2065/// Positions are always ordered D, C, B, E, F, G, A, which is the conventional two-row
2066/// pedal-diagram order. The optional placement is retained without inventing pixel geometry.
2067#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2068pub struct HarpPedalDiagram {
2069    #[serde(default)]
2070    pub positions: [HarpPedalPosition; 7],
2071    #[serde(default, skip_serializing_if = "Option::is_none")]
2072    pub placement: Option<String>,
2073}
2074
2075impl Default for HarpPedalDiagram {
2076    fn default() -> Self {
2077        Self {
2078            positions: [HarpPedalPosition::Natural; 7],
2079            placement: None,
2080        }
2081    }
2082}
2083
2084/// Authored length of a measure that differs from its time signature: a pickup (anacrusis), an
2085/// incomplete final bar, or any irregular bar, as MuseScore's measure `len="1/4"`. The value is a
2086/// fraction of a whole note, so `1/4` lasts one quarter-note beat.
2087#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2088pub struct MeasureLength {
2089    pub numerator: u32,
2090    pub denominator: u32,
2091}
2092
2093impl MeasureLength {
2094    /// Largest supported measure length, in quarter-note beats.
2095    pub const MAX_BEATS: f64 = 256.0;
2096
2097    /// Length in quarter-note beats, or `None` for a zero, non-finite, or oversized fraction.
2098    pub fn beats(&self) -> Option<f64> {
2099        if self.numerator == 0 || self.denominator == 0 {
2100            return None;
2101        }
2102        let beats = 4.0 * f64::from(self.numerator) / f64::from(self.denominator);
2103        (beats.is_finite() && beats <= Self::MAX_BEATS).then_some(beats)
2104    }
2105
2106    /// Fraction for a length in quarter-note beats, exact for binary, triplet, quintuplet
2107    /// and septuplet subdivisions; `None` for a zero, negative, or oversized length.
2108    pub fn from_beats(beats: f64) -> Option<Self> {
2109        const DIVISIONS: u32 = 3 * 5 * 7 * 64;
2110        if !beats.is_finite() || beats <= 0.0 || beats > Self::MAX_BEATS {
2111            return None;
2112        }
2113        let ticks = (beats * f64::from(DIVISIONS)).round();
2114        (ticks >= 1.0).then(|| Self::from_ticks(ticks as u32, DIVISIONS))?
2115    }
2116
2117    /// Exact fraction for `ticks` at `divisions` ticks per quarter note, in lowest terms.
2118    pub fn from_ticks(ticks: u32, divisions: u32) -> Option<Self> {
2119        let numerator = u64::from(ticks);
2120        let denominator = u64::from(divisions).checked_mul(4)?;
2121        if numerator == 0 || denominator == 0 {
2122            return None;
2123        }
2124        let gcd = {
2125            let (mut a, mut b) = (numerator, denominator);
2126            while b != 0 {
2127                (a, b) = (b, a % b);
2128            }
2129            a
2130        };
2131        let length = Self {
2132            numerator: u32::try_from(numerator / gcd).ok()?,
2133            denominator: u32::try_from(denominator / gcd).ok()?,
2134        };
2135        length.beats().map(|_| length)
2136    }
2137}
2138
2139/// A clef change partway through a staff's measure. `offset` is the time from the start of
2140/// the bar at which the new clef takes effect, as a fraction of a whole note (`1/4` = one
2141/// quarter-note beat). A change at the barline itself is [`Measure::clef`] instead.
2142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2143pub struct MidMeasureClef {
2144    pub offset: MeasureLength,
2145    pub clef: Clef,
2146}
2147
2148/// A written MusicXML metronome mark, distinct from the playback tempo.
2149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2150pub struct MetronomeMark {
2151    pub beat_unit: Duration,
2152    #[serde(default)]
2153    pub beat_unit_dots: u8,
2154    pub per_minute: u16,
2155    #[serde(default)]
2156    pub parentheses: bool,
2157}
2158
2159/// A pedal direction at a score-time position within this staff's measure.
2160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2161pub struct TimedPedalDirection {
2162    /// Position in quarter-note beats from the start of this measure.
2163    pub position_beats: f64,
2164    /// True for pedal down, false for pedal release.
2165    pub down: bool,
2166    /// MusicXML pedal line number.
2167    pub number: u16,
2168    /// The source explicitly hides the pedal sign.
2169    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2170    pub hide_sign: bool,
2171    /// Optional authored placement, such as "above" or "below".
2172    #[serde(default, skip_serializing_if = "Option::is_none")]
2173    pub placement: Option<String>,
2174}
2175
2176#[derive(Debug, Clone, Serialize, Deserialize)]
2177pub struct Measure {
2178    pub number: u32,
2179    pub time_sig: Option<TimeSignature>,
2180    /// Suppress this time signature while retaining its musical duration.
2181    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2182    pub time_sig_hidden: bool,
2183    /// Common or cut meter glyph selected by the source; `None` prints numerals.
2184    #[serde(default, skip_serializing_if = "Option::is_none")]
2185    pub time_symbol: Option<TimeSymbol>,
2186    pub key_sig: Option<KeySignature>,
2187    pub clef: Option<Clef>,
2188    /// Clef changes inside the bar, in increasing `offset` order, each strictly after the
2189    /// bar's start and before its end. Notes from an offset on are read in that clef.
2190    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2191    pub mid_clefs: Vec<MidMeasureClef>,
2192    pub tempo: Option<u16>,
2193    /// Explicitly printed tempo equation; a bare playback `<sound tempo>` leaves this empty.
2194    #[serde(default, skip_serializing_if = "Option::is_none")]
2195    pub metronome_mark: Option<MetronomeMark>,
2196    /// Optional target BPM at this measure's end. The playback contract linearly interpolates
2197    /// BPM across the measure; `None` keeps a constant tempo until the next change.
2198    #[serde(default)]
2199    pub tempo_ramp_to: Option<u16>,
2200    /// Instrument semantics beginning at this staff-local measure boundary.
2201    #[serde(default)]
2202    pub instrument_change: Option<InstrumentDefinition>,
2203    /// Tablature tuning/capo beginning at this staff-local measure boundary.
2204    #[serde(default)]
2205    pub tablature_change: Option<TablatureConfig>,
2206    pub barline_left: Barline,
2207    pub barline_right: Barline,
2208    #[serde(default)]
2209    pub volta: Option<VoltaBracket>,
2210    #[serde(default)]
2211    pub tempo_text: Option<String>,
2212    #[serde(default)]
2213    pub rehearsal: Option<String>,
2214    /// Navigation mark: "Segno" | "Coda" | "Fine" | "DaCapo" | "DaCapoAlFine" |
2215    /// "DaCapoAlCoda" | "DalSegno" | "DalSegnoAlFine" | "DalSegnoAlCoda" | "ToCoda"
2216    #[serde(default)]
2217    pub navigation: Option<String>,
2218    /// Expression / performance text ("dolce", "espressivo", "con fuoco", etc.).
2219    #[serde(default)]
2220    pub expression_text: Option<String>,
2221    #[serde(default)]
2222    pub texts: Vec<StyledText>,
2223    /// Structured MusicXML figured-bass figures in source order.
2224    #[serde(default)]
2225    pub figured_bass: Vec<FiguredBassFigure>,
2226    /// Concert-harp pedal diagrams declared by MusicXML `<harp-pedals>` directions.
2227    #[serde(default)]
2228    pub harp_pedal_diagrams: Vec<HarpPedalDiagram>,
2229    /// Voice-specific named channel changes at written score positions.
2230    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2231    pub channel_switches: Vec<VoiceChannelSwitch>,
2232    /// Pedal signs whose score-time positions need not coincide with notes.
2233    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2234    pub timed_pedals: Vec<TimedPedalDirection>,
2235    /// When ≥ 2, this measure is displayed as a multi-measure rest spanning N measures.
2236    #[serde(default)]
2237    pub multi_rest_count: Option<u8>,
2238    /// Force a new system (row) after this measure.
2239    #[serde(default)]
2240    pub system_break: bool,
2241    /// Force a new page after this measure.
2242    #[serde(default)]
2243    pub page_break: bool,
2244    /// Semantic section boundary beginning at this measure. Unlike system and page breaks,
2245    /// this is an editor/navigation marker and has no layout implication.
2246    #[serde(default)]
2247    pub section_break: bool,
2248    /// Up to 4 voices; voice 0 is the primary voice.
2249    pub voices: [Vec<Note>; 4],
2250    /// Original positive MusicXML voice numbers associated with the four editable slots.
2251    ///
2252    /// `None` retains the legacy convention that slot `n` serializes as voice `n + 1`.
2253    /// This lets import preserve sparse source identifiers such as voices 1 and 5 without
2254    /// changing the established fixed-slot editing API.
2255    #[serde(default)]
2256    pub source_voice_numbers: [Option<u32>; 4],
2257    /// Authored length when it differs from the time signature (pickup or irregular bar).
2258    /// `None` means the measure lasts exactly one bar of its time signature.
2259    #[serde(default, skip_serializing_if = "Option::is_none")]
2260    pub actual_length: Option<MeasureLength>,
2261    /// Display this measure as a repeat of the previous `n` measures (MuseScore measure repeat,
2262    /// MusicXML `<measure-repeat>`). Its voices hold a copy of the repeated content, so playback,
2263    /// validation, and editing see real notes; renderers draw the repeat sign instead.
2264    #[serde(default, skip_serializing_if = "Option::is_none")]
2265    pub measure_repeat: Option<u8>,
2266}
2267
2268impl Measure {
2269    /// The clef in effect `beats` quarter-note beats into this bar, given the clef in effect
2270    /// at its start (after [`Measure::clef`]): the latest mid-bar change at or before `beats`.
2271    pub fn clef_at(&self, start_clef: &Clef, beats: f64) -> Clef {
2272        self.mid_clefs
2273            .iter()
2274            .take_while(|change| {
2275                change
2276                    .offset
2277                    .beats()
2278                    .is_some_and(|offset| offset <= beats + 1e-9)
2279            })
2280            .last()
2281            .map_or_else(|| start_clef.clone(), |change| change.clef.clone())
2282    }
2283
2284    pub fn empty(numerator: u8, denominator: u8) -> Self {
2285        let total_beats = TimeSignature {
2286            numerator,
2287            denominator,
2288        }
2289        .total_beats();
2290        let mut voice0: Vec<Note> = Vec::new();
2291        let mut remaining = total_beats;
2292        while remaining > 1e-9 {
2293            let dur = Duration::whole_filling_beats(remaining);
2294            remaining -= dur.beats(0);
2295            voice0.push(Note::rest(dur));
2296        }
2297        Self {
2298            number: 0,
2299            time_sig: None,
2300            time_sig_hidden: false,
2301            time_symbol: None,
2302            key_sig: None,
2303            clef: None,
2304            mid_clefs: Vec::new(),
2305            tempo: None,
2306            metronome_mark: None,
2307            tempo_ramp_to: None,
2308            instrument_change: None,
2309            tablature_change: None,
2310            barline_left: Barline::Normal,
2311            barline_right: Barline::Normal,
2312            volta: None,
2313            tempo_text: None,
2314            rehearsal: None,
2315            navigation: None,
2316            expression_text: None,
2317            texts: Vec::new(),
2318            figured_bass: Vec::new(),
2319            harp_pedal_diagrams: Vec::new(),
2320            channel_switches: Vec::new(),
2321            timed_pedals: Vec::new(),
2322            multi_rest_count: None,
2323            system_break: false,
2324            page_break: false,
2325            section_break: false,
2326            voices: [voice0, vec![], vec![], vec![]],
2327            source_voice_numbers: [None; 4],
2328            actual_length: None,
2329            measure_repeat: None,
2330        }
2331    }
2332
2333    /// Replace this measure's voices with a playable copy of `source`, as a measure repeat
2334    /// stores its repeated content; see [`Note::repeated_copy`].
2335    pub fn repeat_content_from(&mut self, source: &Measure) {
2336        for (target, voice) in self.voices.iter_mut().zip(&source.voices) {
2337            *target = voice.iter().map(Note::repeated_copy).collect();
2338        }
2339        self.source_voice_numbers = source.source_voice_numbers;
2340    }
2341
2342    /// Whether this measure's voices sound like `other`'s: the same rests, pitches, durations,
2343    /// dots, and tuplets in every voice.
2344    pub fn same_sounding_content(&self, other: &Measure) -> bool {
2345        self.voices.iter().zip(&other.voices).all(|(left, right)| {
2346            left.len() == right.len()
2347                && left.iter().zip(right).all(|(a, b)| {
2348                    a.is_rest == b.is_rest
2349                        && a.pitches == b.pitches
2350                        && a.duration == b.duration
2351                        && a.dot_count == b.dot_count
2352                        && a.tuplet == b.tuplet
2353                        && a.is_grace == b.is_grace
2354                })
2355        })
2356    }
2357
2358    /// Beats this measure lasts: its [`actual_length`](Self::actual_length) when present and
2359    /// valid, otherwise its own time signature, otherwise `time_signature` (the one in effect).
2360    pub fn duration_beats(&self, time_signature: &TimeSignature) -> f64 {
2361        self.actual_length
2362            .and_then(|length| length.beats())
2363            .unwrap_or_else(|| {
2364                self.time_sig
2365                    .as_ref()
2366                    .unwrap_or(time_signature)
2367                    .total_beats()
2368            })
2369    }
2370
2371    pub fn renumber(&mut self, n: u32) {
2372        self.number = n;
2373    }
2374}
2375
2376#[derive(Debug, Clone, Serialize, Deserialize)]
2377pub struct Note {
2378    pub id: String,
2379    pub is_rest: bool,
2380    /// MusicXML unpitched note; `pitches` then stores display placement only.
2381    #[serde(default)]
2382    pub is_unpitched: bool,
2383    /// Optional source instrument identifier (for example MusicXML note-level `instrument@id`).
2384    #[serde(default)]
2385    pub instrument_id: Option<String>,
2386    /// Horizontal nudge in tenths from the note's computed (rhythmic) position, set by hosts.
2387    /// MusicXML import does not fill it from `default-x`, which is an absolute position in the
2388    /// source engraver's layout; MusicXML export writes it as part of `relative-x`.
2389    #[serde(default)]
2390    pub offset_x: Option<f64>,
2391    /// Vertical nudge in tenths from the note's staff position; see `offset_x`.
2392    #[serde(default)]
2393    pub offset_y: Option<f64>,
2394    /// MusicXML note-level horizontal adjustment in tenths.
2395    #[serde(default)]
2396    pub relative_x: Option<f64>,
2397    /// MusicXML note-level vertical adjustment in tenths.
2398    #[serde(default)]
2399    pub relative_y: Option<f64>,
2400    /// Single note: one pitch. Chord: multiple pitches (same duration).
2401    pub pitches: Vec<Pitch>,
2402    #[serde(default)]
2403    pub tab_position: Option<super::notation::TabPosition>,
2404    /// One tablature position per pitch; the first entry mirrors `tab_position`.
2405    /// This is populated for chords so each pitch can occupy a distinct string.
2406    #[serde(default)]
2407    pub tab_positions: Vec<super::notation::TabPosition>,
2408    pub duration: Duration,
2409    pub dot_count: u8,
2410    pub tie_start: bool,
2411    pub tie_end: bool,
2412    /// Per-pitch tie starts, parallel to `pitches`, for a chord whose notes are not all tied.
2413    /// Empty (the usual case) means `tie_start` applies to every pitch. A vector whose length
2414    /// differs from `pitches` is ignored in favour of `tie_start`; see [`Note::pitch_tie_start`].
2415    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2416    pub pitch_tie_starts: Vec<bool>,
2417    /// Per-pitch tie ends, parallel to `pitches`; empty means `tie_end` applies to every pitch.
2418    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2419    pub pitch_tie_ends: Vec<bool>,
2420    /// Per-pitch accidental display, parallel to `pitches`. Empty (the usual case) leaves every
2421    /// accidental to the notation rules; pitches past the end are `Auto`, and a vector longer
2422    /// than `pitches` is ignored. See [`Note::accidental_display`].
2423    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2424    pub pitch_accidentals: Vec<AccidentalDisplay>,
2425    /// Per-pitch parenthesized noteheads (ghost notes), parallel to `pitches`: MusicXML
2426    /// `<notehead parentheses="yes">`, MEI `@enclose="paren"`, MuseScore `<ghost>`, Guitar Pro
2427    /// ghost notes. Empty (the usual case) means none; pitches past the end are not
2428    /// parenthesized, and a vector longer than `pitches` is ignored.
2429    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2430    pub pitch_parentheses: Vec<bool>,
2431    /// Left-hand fingering per chord member, parallel to `pitches` (MusicXML `<fingering>` on
2432    /// each `<chord/>` note, MuseScore `<Fingering>` in each `<Note>`, Guitar Pro fingering on
2433    /// each string). Empty unless a chord's fingerings belong to particular notes;
2434    /// `fingering`/`fingerings` still hold them all for display.
2435    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2436    pub pitch_fingerings: Vec<Option<u8>>,
2437    pub beam: BeamState,
2438    /// An authored instruction to leave this short note unbeamed. An absent beam mark is
2439    /// unspecified and may be grouped by an opt-in display policy.
2440    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2441    pub explicit_unbeamed: bool,
2442    pub articulations: Vec<Articulation>,
2443    /// Two-note tremolo endpoint ("start" or "stop"); absent for a single-note tremolo.
2444    #[serde(default, skip_serializing_if = "Option::is_none")]
2445    pub tremolo_pair: Option<String>,
2446    pub dynamic: Option<Dynamic>,
2447    pub stem_up: Option<bool>,
2448    #[serde(default)]
2449    pub hairpin_start: Option<HairpinKind>,
2450    #[serde(default)]
2451    pub hairpin_end: bool,
2452    #[serde(default)]
2453    pub tuplet: Option<TupletInfo>,
2454    /// Start/stop of an authored tuplet group, independent of the rhythmic ratio.
2455    #[serde(default, skip_serializing_if = "Option::is_none")]
2456    pub tuplet_boundary: Option<TupletBoundary>,
2457    #[serde(default)]
2458    pub chord_symbol: Option<ChordSymbol>,
2459    #[serde(default)]
2460    pub is_grace: bool,
2461    /// Acciaccatura: true (slash through stem). Appoggiatura: false.
2462    #[serde(default)]
2463    pub grace_slash: bool,
2464    #[serde(default)]
2465    pub ottava_start: Option<OttavaKind>,
2466    #[serde(default)]
2467    pub ottava_end: bool,
2468    #[serde(default)]
2469    pub lyric: Option<Lyric>,
2470    /// Lyrics for verse 2 and later, in ascending verse order; verse 1 is [`lyric`](Self::lyric).
2471    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2472    pub additional_lyrics: Vec<VerseLyric>,
2473    #[serde(default)]
2474    pub pedal_start: bool,
2475    #[serde(default)]
2476    pub pedal_end: bool,
2477    #[serde(default)]
2478    pub slur_start: bool,
2479    #[serde(default)]
2480    pub slur_end: bool,
2481    /// Arpeggiate direction: `Some(true)` = up, `Some(false)` = down, `None` = none.
2482    #[serde(default)]
2483    pub arpeggiate: Option<bool>,
2484    /// Technique/style instruction attached to this note ("pizz.", "arco", "con sord.", etc.).
2485    #[serde(default)]
2486    pub technique_text: Option<String>,
2487    #[serde(default)]
2488    pub glissando_start: bool,
2489    #[serde(default)]
2490    pub glissando_end: bool,
2491    #[serde(default)]
2492    pub cross_staff: Option<CrossStaff>,
2493    /// Left-hand fingering number (0 = open / thumb, 1–5 = fingers).
2494    #[serde(default)]
2495    pub fingering: Option<u8>,
2496    /// Alternate left-hand fingering candidates, in source order. The first
2497    /// entry mirrors `fingering` when present.
2498    #[serde(default)]
2499    pub fingerings: Vec<u8>,
2500    /// String number for plucked/bowed string instruments (1 = highest string).
2501    #[serde(default)]
2502    pub string_number: Option<u8>,
2503    #[serde(default)]
2504    pub note_head: NoteHead,
2505    /// Cue note (small-sized, does not count toward beat total).
2506    #[serde(default)]
2507    pub is_cue: bool,
2508    /// Not drawn (MusicXML `print-object="no"`, MEI `@visible="false"`, MuseScore
2509    /// `<visible>0</visible>`): the note or rest still takes its time and sounds, as a hidden
2510    /// rest in a second voice or a written-out trill does.
2511    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2512    pub hidden: bool,
2513    /// Start of a multi-note trill line span.
2514    #[serde(default)]
2515    pub trill_line_start: bool,
2516    /// End of a multi-note trill line span.
2517    #[serde(default)]
2518    pub trill_line_end: bool,
2519    /// Guitar-specific playing technique (bend, slide, hammer-on, pull-off).
2520    #[serde(default)]
2521    pub guitar_technique: Option<GuitarTechnique>,
2522    /// MusicXML bend amount in cents when supplied by the source.
2523    #[serde(default)]
2524    pub guitar_bend_alter_cents: Option<i16>,
2525    /// Ordered bend curve points. Position is relative note time in per-mille (0..=1000).
2526    #[serde(default)]
2527    pub guitar_bend_curve: Vec<GuitarBendPoint>,
2528}
2529
2530/// One point of an authored guitar bend curve.
2531#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2532pub struct GuitarBendPoint {
2533    pub position_per_mille: u16,
2534    pub alter_cents: i16,
2535}
2536
2537impl Note {
2538    /// Select one authored fingering candidate without changing the score.
2539    pub fn select_fingering(
2540        &self,
2541        policy: super::notation::FingeringSelectionPolicy,
2542    ) -> Option<u8> {
2543        let candidates = if self.fingerings.is_empty() {
2544            self.fingering.into_iter().collect::<Vec<_>>()
2545        } else {
2546            self.fingerings.clone()
2547        };
2548        match policy {
2549            super::notation::FingeringSelectionPolicy::SourceOrder => candidates.first().copied(),
2550            super::notation::FingeringSelectionPolicy::LowestNumber => {
2551                candidates.iter().copied().min()
2552            }
2553            super::notation::FingeringSelectionPolicy::HighestNumber => {
2554                candidates.iter().copied().max()
2555            }
2556        }
2557    }
2558
2559    pub fn new(pitch: Pitch, duration: Duration) -> Self {
2560        Self {
2561            id: Uuid::new_v4().to_string(),
2562            is_rest: false,
2563            is_unpitched: false,
2564            instrument_id: None,
2565            offset_x: None,
2566            offset_y: None,
2567            relative_x: None,
2568            relative_y: None,
2569            pitches: vec![pitch],
2570            tab_position: None,
2571            tab_positions: Vec::new(),
2572            duration,
2573            dot_count: 0,
2574            tie_start: false,
2575            tie_end: false,
2576            pitch_tie_starts: Vec::new(),
2577            pitch_tie_ends: Vec::new(),
2578            pitch_accidentals: Vec::new(),
2579            pitch_parentheses: Vec::new(),
2580            pitch_fingerings: Vec::new(),
2581            beam: BeamState::None,
2582            explicit_unbeamed: false,
2583            articulations: Vec::new(),
2584            tremolo_pair: None,
2585            dynamic: None,
2586            stem_up: None,
2587            hairpin_start: None,
2588            hairpin_end: false,
2589            tuplet: None,
2590            tuplet_boundary: None,
2591            chord_symbol: None,
2592            is_grace: false,
2593            grace_slash: false,
2594            ottava_start: None,
2595            ottava_end: false,
2596            lyric: None,
2597            additional_lyrics: Vec::new(),
2598            pedal_start: false,
2599            pedal_end: false,
2600            slur_start: false,
2601            slur_end: false,
2602            arpeggiate: None,
2603            technique_text: None,
2604            glissando_start: false,
2605            glissando_end: false,
2606            cross_staff: None,
2607            fingering: None,
2608            fingerings: Vec::new(),
2609            string_number: None,
2610            note_head: NoteHead::Normal,
2611            is_cue: false,
2612            hidden: false,
2613            trill_line_start: false,
2614            trill_line_end: false,
2615            guitar_technique: None,
2616            guitar_bend_alter_cents: None,
2617            guitar_bend_curve: Vec::new(),
2618        }
2619    }
2620
2621    pub fn rest(duration: Duration) -> Self {
2622        Self {
2623            id: Uuid::new_v4().to_string(),
2624            is_rest: true,
2625            is_unpitched: false,
2626            instrument_id: None,
2627            offset_x: None,
2628            offset_y: None,
2629            relative_x: None,
2630            relative_y: None,
2631            pitches: Vec::new(),
2632            tab_position: None,
2633            tab_positions: Vec::new(),
2634            duration,
2635            dot_count: 0,
2636            tie_start: false,
2637            tie_end: false,
2638            pitch_tie_starts: Vec::new(),
2639            pitch_tie_ends: Vec::new(),
2640            pitch_accidentals: Vec::new(),
2641            pitch_parentheses: Vec::new(),
2642            pitch_fingerings: Vec::new(),
2643            beam: BeamState::None,
2644            explicit_unbeamed: false,
2645            articulations: Vec::new(),
2646            tremolo_pair: None,
2647            dynamic: None,
2648            stem_up: None,
2649            hairpin_start: None,
2650            hairpin_end: false,
2651            tuplet: None,
2652            tuplet_boundary: None,
2653            chord_symbol: None,
2654            is_grace: false,
2655            grace_slash: false,
2656            ottava_start: None,
2657            ottava_end: false,
2658            lyric: None,
2659            additional_lyrics: Vec::new(),
2660            pedal_start: false,
2661            pedal_end: false,
2662            slur_start: false,
2663            slur_end: false,
2664            arpeggiate: None,
2665            technique_text: None,
2666            glissando_start: false,
2667            glissando_end: false,
2668            cross_staff: None,
2669            fingering: None,
2670            fingerings: Vec::new(),
2671            string_number: None,
2672            note_head: NoteHead::Normal,
2673            is_cue: false,
2674            hidden: false,
2675            trill_line_start: false,
2676            trill_line_end: false,
2677            guitar_technique: None,
2678            guitar_bend_alter_cents: None,
2679            guitar_bend_curve: Vec::new(),
2680        }
2681    }
2682
2683    /// Copy for a repeated measure: a fresh id and the same sounding content, without ties,
2684    /// slurs, lines, lyrics, chord symbol, or dynamic, which belong to the written original.
2685    pub fn repeated_copy(&self) -> Note {
2686        let mut copy = self.clone();
2687        copy.id = Uuid::new_v4().to_string();
2688        copy.tie_start = false;
2689        copy.tie_end = false;
2690        copy.slur_start = false;
2691        copy.slur_end = false;
2692        copy.hairpin_start = None;
2693        copy.hairpin_end = false;
2694        copy.pedal_start = false;
2695        copy.pedal_end = false;
2696        copy.ottava_start = None;
2697        copy.ottava_end = false;
2698        copy.glissando_start = false;
2699        copy.glissando_end = false;
2700        copy.trill_line_start = false;
2701        copy.trill_line_end = false;
2702        copy.lyric = None;
2703        copy.additional_lyrics.clear();
2704        copy.chord_symbol = None;
2705        copy.dynamic = None;
2706        copy
2707    }
2708
2709    /// Whether the pitch at `index` starts a tie: its own flag when per-pitch ties are recorded,
2710    /// otherwise the chord-level `tie_start`.
2711    pub fn pitch_tie_start(&self, index: usize) -> bool {
2712        per_pitch_flag(
2713            &self.pitch_tie_starts,
2714            self.pitches.len(),
2715            index,
2716            self.tie_start,
2717        )
2718    }
2719
2720    /// Whether the pitch at `index` ends a tie; see [`Note::pitch_tie_start`].
2721    pub fn pitch_tie_end(&self, index: usize) -> bool {
2722        per_pitch_flag(
2723            &self.pitch_tie_ends,
2724            self.pitches.len(),
2725            index,
2726            self.tie_end,
2727        )
2728    }
2729
2730    /// Record which pitches of this note start (`starts`) and end (`ends`) a tie. The chord-level
2731    /// flags become "any pitch", and per-pitch vectors are kept only when the pitches differ, so a
2732    /// uniformly tied chord stays in the plain form. Slices whose length is not the pitch count
2733    /// are ignored.
2734    pub fn set_pitch_ties(&mut self, starts: &[bool], ends: &[bool]) {
2735        let count = self.pitches.len();
2736        if starts.len() == count {
2737            self.tie_start = starts.iter().any(|tied| *tied);
2738            self.pitch_tie_starts = if starts.iter().all(|tied| *tied == self.tie_start) {
2739                Vec::new()
2740            } else {
2741                starts.to_vec()
2742            };
2743        }
2744        if ends.len() == count {
2745            self.tie_end = ends.iter().any(|tied| *tied);
2746            self.pitch_tie_ends = if ends.iter().all(|tied| *tied == self.tie_end) {
2747                Vec::new()
2748            } else {
2749                ends.to_vec()
2750            };
2751        }
2752    }
2753
2754    /// How the accidental of the pitch at `index` is shown (`Auto` when not recorded).
2755    pub fn accidental_display(&self, index: usize) -> AccidentalDisplay {
2756        if self.pitch_accidentals.len() > self.pitches.len() {
2757            return AccidentalDisplay::Auto;
2758        }
2759        self.pitch_accidentals
2760            .get(index)
2761            .copied()
2762            .unwrap_or_default()
2763    }
2764
2765    /// Set how the accidental of the pitch at `index` is shown. The vector is dropped again
2766    /// when every pitch is back to `Auto`.
2767    pub fn set_accidental_display(&mut self, index: usize, display: AccidentalDisplay) {
2768        if index >= self.pitches.len() {
2769            return;
2770        }
2771        if self.pitch_accidentals.len() > self.pitches.len() {
2772            self.pitch_accidentals.clear();
2773        }
2774        self.pitch_accidentals
2775            .resize(self.pitches.len(), AccidentalDisplay::Auto);
2776        self.pitch_accidentals[index] = display;
2777        if self
2778            .pitch_accidentals
2779            .iter()
2780            .all(|display| *display == AccidentalDisplay::Auto)
2781        {
2782            self.pitch_accidentals.clear();
2783        }
2784    }
2785
2786    /// The fingering of the chord member at `index`, when fingerings are recorded per pitch.
2787    pub fn pitch_fingering(&self, index: usize) -> Option<u8> {
2788        if self.pitch_fingerings.len() > self.pitches.len() {
2789            return None;
2790        }
2791        self.pitch_fingerings.get(index).copied().flatten()
2792    }
2793
2794    /// Record the fingering of the chord member at `index` (and add it to the displayed
2795    /// fingerings).
2796    pub fn set_pitch_fingering(&mut self, index: usize, fingering: u8) {
2797        if index >= self.pitches.len() {
2798            return;
2799        }
2800        if self.pitch_fingerings.len() > self.pitches.len() {
2801            self.pitch_fingerings.clear();
2802        }
2803        if self.pitch_fingerings.len() <= index {
2804            self.pitch_fingerings.resize(index + 1, None);
2805        }
2806        self.pitch_fingerings[index] = Some(fingering);
2807        if self.fingering.is_none() {
2808            self.fingering = Some(fingering);
2809        }
2810        self.fingerings.push(fingering);
2811    }
2812
2813    /// Whether the notehead of the pitch at `index` is in parentheses (a ghost note).
2814    pub fn is_parenthesized(&self, index: usize) -> bool {
2815        self.pitch_parentheses.len() <= self.pitches.len()
2816            && self.pitch_parentheses.get(index).copied().unwrap_or(false)
2817    }
2818
2819    /// Put the notehead of the pitch at `index` in parentheses or take it out. The vector is
2820    /// dropped again when no pitch is parenthesized.
2821    pub fn set_parenthesized(&mut self, index: usize, parenthesized: bool) {
2822        if index >= self.pitches.len() {
2823            return;
2824        }
2825        if self.pitch_parentheses.len() > self.pitches.len() {
2826            self.pitch_parentheses.clear();
2827        }
2828        self.pitch_parentheses.resize(self.pitches.len(), false);
2829        self.pitch_parentheses[index] = parenthesized;
2830        if !self.pitch_parentheses.contains(&true) {
2831            self.pitch_parentheses.clear();
2832        }
2833    }
2834
2835    /// Per-pitch tie starts as a full vector (chord-level flag repeated when not recorded).
2836    pub fn pitch_tie_starts_or_uniform(&self) -> Vec<bool> {
2837        (0..self.pitches.len())
2838            .map(|index| self.pitch_tie_start(index))
2839            .collect()
2840    }
2841
2842    /// Per-pitch tie ends as a full vector (chord-level flag repeated when not recorded).
2843    pub fn pitch_tie_ends_or_uniform(&self) -> Vec<bool> {
2844        (0..self.pitches.len())
2845            .map(|index| self.pitch_tie_end(index))
2846            .collect()
2847    }
2848
2849    /// A plain whole rest, the form MusicXML `<rest measure="yes"/>` and MuseScore
2850    /// `durationType=measure` import to. Alone in a voice it is a measure rest; see
2851    /// [`voice_duration_beats`].
2852    pub fn is_plain_whole_rest(&self) -> bool {
2853        self.is_rest
2854            && matches!(self.duration, Duration::Whole)
2855            && self.dot_count == 0
2856            && self.tuplet.is_none()
2857            && !self.is_grace
2858            && !self.is_cue
2859    }
2860
2861    pub fn beats(&self) -> f64 {
2862        if self.is_grace || self.is_cue {
2863            return 0.0;
2864        }
2865        let base = self.duration.beats(self.dot_count);
2866        if let Some(ref t) = self.tuplet {
2867            base * (t.normal_notes as f64) / (t.actual_notes as f64)
2868        } else {
2869            base
2870        }
2871    }
2872}
2873
2874impl Duration {
2875    /// Returns the largest single duration that fills the given number of beats.
2876    pub fn whole_filling_beats(beats: f64) -> Duration {
2877        if beats >= 16.0 {
2878            Duration::Longa
2879        } else if beats >= 8.0 {
2880            Duration::Breve
2881        } else if beats >= 4.0 {
2882            Duration::Whole
2883        } else if beats >= 2.0 {
2884            Duration::Half
2885        } else if beats >= 1.0 {
2886            Duration::Quarter
2887        } else if beats >= 0.5 {
2888            Duration::Eighth
2889        } else if beats >= 0.25 {
2890            Duration::Sixteenth
2891        } else if beats >= 0.125 {
2892            Duration::ThirtySecond
2893        } else if beats >= 0.0625 {
2894            Duration::SixtyFourth
2895        } else {
2896            Duration::HundredTwentyEighth
2897        }
2898    }
2899}
2900
2901/// Written tuplet ranges (inclusive note indices), using explicit boundaries when present.
2902/// Legacy scores without boundaries are split at complete written note-value groups.
2903pub fn tuplet_group_ranges(voice: &[Note]) -> Vec<(usize, usize)> {
2904    let mut groups = Vec::new();
2905    let mut open: Option<(usize, TupletInfo, u64, bool)> = None;
2906    let mut previous_timed = 0;
2907    for (index, note) in voice.iter().enumerate() {
2908        if note.is_grace {
2909            continue;
2910        }
2911        let has_start = matches!(
2912            note.tuplet_boundary,
2913            Some(TupletBoundary::Start | TupletBoundary::StartStop)
2914        );
2915        let has_stop = matches!(
2916            note.tuplet_boundary,
2917            Some(TupletBoundary::Stop | TupletBoundary::StartStop)
2918        );
2919        let must_close = open
2920            .as_ref()
2921            .is_some_and(|(_, ratio, _, _)| note.tuplet.as_ref() != Some(ratio) || has_start);
2922        if must_close && let Some((start, _, _, _)) = open.take() {
2923            groups.push((start, previous_timed));
2924        }
2925        if let Some(ratio) = &note.tuplet {
2926            let (numerator, denominator) = note.duration.as_fraction();
2927            let base = 4096 * u64::from(numerator) / u64::from(denominator.max(1));
2928            let dots = u32::from(note.dot_count.min(4));
2929            let written = base * ((1u64 << (dots + 1)) - 1) / (1u64 << dots);
2930            let (_, current_ratio, total_written, explicit) =
2931                open.get_or_insert_with(|| (index, ratio.clone(), 0, has_start));
2932            *total_written += written;
2933            let count = u64::from(current_ratio.actual_notes.max(1));
2934            let unit = *total_written / count;
2935            let legacy_complete = !*explicit
2936                && *total_written % count == 0
2937                && unit > 0
2938                && unit.is_power_of_two()
2939                && unit <= 4096;
2940            if (has_stop || legacy_complete)
2941                && let Some((start, _, _, _)) = open.take()
2942            {
2943                groups.push((start, index));
2944            }
2945        }
2946        previous_timed = index;
2947    }
2948    if let Some((start, _, _, _)) = open {
2949        groups.push((start, previous_timed));
2950    }
2951    groups
2952}
2953
2954// ── NoteAddr ──────────────────────────────────────────────────────────────────
2955
2956/// Physical address of a note within a score.
2957#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2958pub struct NoteAddr {
2959    pub part: usize,
2960    pub staff: usize,
2961    pub measure: usize,
2962    pub voice: usize,
2963    pub note: usize,
2964}
2965
2966// ── diff ──────────────────────────────────────────────────────────────────────
2967
2968/// A single change between two [`Score`] values as reported by [`diff`].
2969#[derive(Debug, Clone, Serialize, Deserialize)]
2970pub enum ScoreChange {
2971    ScoreViewsChanged {
2972        old: Vec<ScoreView>,
2973        new: Vec<ScoreView>,
2974    },
2975    ScoreTextChanged {
2976        old: Vec<StyledText>,
2977        new: Vec<StyledText>,
2978    },
2979    MeasureTextChanged {
2980        part: usize,
2981        staff: usize,
2982        measure: usize,
2983        old: Vec<StyledText>,
2984        new: Vec<StyledText>,
2985    },
2986    FiguredBassChanged {
2987        part: usize,
2988        staff: usize,
2989        measure: usize,
2990        old: Vec<FiguredBassFigure>,
2991        new: Vec<FiguredBassFigure>,
2992    },
2993    HarpPedalDiagramsChanged {
2994        part: usize,
2995        staff: usize,
2996        measure: usize,
2997        old: Vec<HarpPedalDiagram>,
2998        new: Vec<HarpPedalDiagram>,
2999    },
3000    TimedPedalsChanged {
3001        part: usize,
3002        staff: usize,
3003        measure: usize,
3004        old: Vec<TimedPedalDirection>,
3005        new: Vec<TimedPedalDirection>,
3006    },
3007    ChordDefinitionsChanged {
3008        old: Vec<ChordDefinition>,
3009        new: Vec<ChordDefinition>,
3010    },
3011    PartNamesChanged {
3012        part: usize,
3013        old_name: String,
3014        new_name: String,
3015        old_short_name: String,
3016        new_short_name: String,
3017    },
3018    PartMidiChanged {
3019        part: usize,
3020        old_channel: u8,
3021        new_channel: u8,
3022        old_program: u8,
3023        new_program: u8,
3024    },
3025    InstrumentDefinitionChanged {
3026        part: usize,
3027        old: Option<InstrumentDefinition>,
3028        new: Option<InstrumentDefinition>,
3029    },
3030    PartMidiAutomationChanged {
3031        part: usize,
3032        old_pitch_bends: Vec<MidiPitchBend>,
3033        new_pitch_bends: Vec<MidiPitchBend>,
3034        old_control_changes: Vec<MidiControlChange>,
3035        new_control_changes: Vec<MidiControlChange>,
3036        old_program_changes: Vec<MidiProgramChange>,
3037        new_program_changes: Vec<MidiProgramChange>,
3038        old_aftertouch: Vec<MidiAftertouch>,
3039        new_aftertouch: Vec<MidiAftertouch>,
3040    },
3041    StaffConfigurationChanged {
3042        part: usize,
3043        staff: usize,
3044        old_clef: Clef,
3045        new_clef: Clef,
3046        old_transpose_semitones: i8,
3047        new_transpose_semitones: i8,
3048    },
3049    StaffPresentationChanged {
3050        part: usize,
3051        staff: usize,
3052        old: StaffPresentation,
3053        new: StaffPresentation,
3054    },
3055    MeasurePresentationChanged {
3056        part: usize,
3057        staff: usize,
3058        measure: usize,
3059        old_number: u32,
3060        new_number: u32,
3061        old_clef: Option<Clef>,
3062        new_clef: Option<Clef>,
3063        old_tempo_text: Option<String>,
3064        new_tempo_text: Option<String>,
3065        old_navigation: Option<String>,
3066        new_navigation: Option<String>,
3067        old_expression_text: Option<String>,
3068        new_expression_text: Option<String>,
3069        old_multi_rest_count: Option<u8>,
3070        new_multi_rest_count: Option<u8>,
3071        old_system_break: bool,
3072        new_system_break: bool,
3073        old_page_break: bool,
3074        new_page_break: bool,
3075        old_section_break: bool,
3076        new_section_break: bool,
3077    },
3078    TablatureConfigChanged {
3079        part: usize,
3080        staff: usize,
3081        old: Option<TablatureConfig>,
3082        new: Option<TablatureConfig>,
3083    },
3084    TablatureChangeChanged {
3085        part: usize,
3086        staff: usize,
3087        measure: usize,
3088        old: Option<TablatureConfig>,
3089        new: Option<TablatureConfig>,
3090    },
3091    MidMeasureClefsChanged {
3092        part: usize,
3093        staff: usize,
3094        measure: usize,
3095        old: Vec<MidMeasureClef>,
3096        new: Vec<MidMeasureClef>,
3097    },
3098    /// A semantic field changed without a dedicated positional diff variant.
3099    ///
3100    /// The stable path keeps compatibility reports honest while the complete score remains
3101    /// available through [`ScorePatch::ReplaceScore`].
3102    UnrepresentedFieldChanged {
3103        path: String,
3104    },
3105    MetadataChanged {
3106        field: String,
3107        old: String,
3108        new: String,
3109    },
3110    TempoChanged {
3111        old: u16,
3112        new: u16,
3113    },
3114    KeySignatureChanged {
3115        old: KeySignature,
3116        new: KeySignature,
3117    },
3118    PartAdded {
3119        part_index: usize,
3120    },
3121    PartRemoved {
3122        part_index: usize,
3123        name: String,
3124    },
3125    NoteAdded {
3126        part: usize,
3127        staff: usize,
3128        measure: usize,
3129        voice: usize,
3130        note_index: usize,
3131    },
3132    NoteRemoved {
3133        part: usize,
3134        staff: usize,
3135        measure: usize,
3136        voice: usize,
3137        note: Box<Note>,
3138    },
3139    NoteModified {
3140        part: usize,
3141        staff: usize,
3142        measure: usize,
3143        voice: usize,
3144        note_index: usize,
3145        old: Box<Note>,
3146        new: Box<Note>,
3147    },
3148    TimeSigChanged {
3149        part: usize,
3150        staff: usize,
3151        measure: usize,
3152        old: Option<TimeSignature>,
3153        new: Option<TimeSignature>,
3154    },
3155    TimeVisibilityChanged {
3156        part: usize,
3157        staff: usize,
3158        measure: usize,
3159        old: bool,
3160        new: bool,
3161    },
3162    TimeSymbolChanged {
3163        part: usize,
3164        staff: usize,
3165        measure: usize,
3166        old: Option<TimeSymbol>,
3167        new: Option<TimeSymbol>,
3168    },
3169    MeasureTempoChanged {
3170        part: usize,
3171        staff: usize,
3172        measure: usize,
3173        old: Option<u16>,
3174        new: Option<u16>,
3175    },
3176    MeasureMetronomeMarkChanged {
3177        part: usize,
3178        staff: usize,
3179        measure: usize,
3180        old: Option<MetronomeMark>,
3181        new: Option<MetronomeMark>,
3182    },
3183    MeasureTempoRampChanged {
3184        part: usize,
3185        staff: usize,
3186        measure: usize,
3187        old: Option<u16>,
3188        new: Option<u16>,
3189    },
3190    BarlineChanged {
3191        part: usize,
3192        staff: usize,
3193        measure: usize,
3194    },
3195    RehearsalMarkChanged {
3196        part: usize,
3197        staff: usize,
3198        measure: usize,
3199        old: Option<String>,
3200        new: Option<String>,
3201    },
3202    VoltaChanged {
3203        part: usize,
3204        staff: usize,
3205        measure: usize,
3206    },
3207}
3208
3209/// Compare two scores and return a list of differences.
3210///
3211/// Parts, staves, measures, and voices are compared by position. Notes are compared by
3212/// position within each voice, ignoring their `id` field. Metadata fields are compared
3213/// individually.
3214pub fn diff(a: &Score, b: &Score) -> Vec<ScoreChange> {
3215    let mut changes: Vec<ScoreChange> = Vec::new();
3216
3217    if a.views != b.views {
3218        changes.push(ScoreChange::ScoreViewsChanged {
3219            old: a.views.clone(),
3220            new: b.views.clone(),
3221        });
3222    }
3223
3224    if a.texts != b.texts {
3225        changes.push(ScoreChange::ScoreTextChanged {
3226            old: a.texts.clone(),
3227            new: b.texts.clone(),
3228        });
3229    }
3230    if a.chord_definitions != b.chord_definitions {
3231        changes.push(ScoreChange::ChordDefinitionsChanged {
3232            old: a.chord_definitions.clone(),
3233            new: b.chord_definitions.clone(),
3234        });
3235    }
3236    if let Some(path) = first_unrepresented_field_change(a, b) {
3237        changes.push(ScoreChange::UnrepresentedFieldChanged { path });
3238    }
3239
3240    macro_rules! meta {
3241        ($field:ident, $name:literal) => {
3242            if a.metadata.$field != b.metadata.$field {
3243                changes.push(ScoreChange::MetadataChanged {
3244                    field: $name.to_string(),
3245                    old: a.metadata.$field.clone(),
3246                    new: b.metadata.$field.clone(),
3247                });
3248            }
3249        };
3250    }
3251    meta!(title, "title");
3252    meta!(composer, "composer");
3253    meta!(lyricist, "lyricist");
3254    meta!(copyright, "copyright");
3255    meta!(work_number, "work_number");
3256    meta!(movement_title, "movement_title");
3257
3258    if a.settings.tempo_bpm != b.settings.tempo_bpm {
3259        changes.push(ScoreChange::TempoChanged {
3260            old: a.settings.tempo_bpm,
3261            new: b.settings.tempo_bpm,
3262        });
3263    }
3264    if a.settings.key_signature != b.settings.key_signature {
3265        changes.push(ScoreChange::KeySignatureChanged {
3266            old: a.settings.key_signature.clone(),
3267            new: b.settings.key_signature.clone(),
3268        });
3269    }
3270
3271    let a_len = a.parts.len();
3272    let b_len = b.parts.len();
3273    for i in b_len..a_len {
3274        changes.push(ScoreChange::PartRemoved {
3275            part_index: i,
3276            name: a.parts[i].name.clone(),
3277        });
3278    }
3279    for i in a_len..b_len {
3280        changes.push(ScoreChange::PartAdded { part_index: i });
3281    }
3282
3283    for pi in 0..a_len.min(b_len) {
3284        let ap = &a.parts[pi];
3285        let bp = &b.parts[pi];
3286        if ap.name != bp.name || ap.short_name != bp.short_name {
3287            changes.push(ScoreChange::PartNamesChanged {
3288                part: pi,
3289                old_name: ap.name.clone(),
3290                new_name: bp.name.clone(),
3291                old_short_name: ap.short_name.clone(),
3292                new_short_name: bp.short_name.clone(),
3293            });
3294        }
3295        if ap.midi_channel != bp.midi_channel || ap.midi_program != bp.midi_program {
3296            changes.push(ScoreChange::PartMidiChanged {
3297                part: pi,
3298                old_channel: ap.midi_channel,
3299                new_channel: bp.midi_channel,
3300                old_program: ap.midi_program,
3301                new_program: bp.midi_program,
3302            });
3303        }
3304        if ap.instrument != bp.instrument {
3305            changes.push(ScoreChange::InstrumentDefinitionChanged {
3306                part: pi,
3307                old: ap.instrument.clone(),
3308                new: bp.instrument.clone(),
3309            });
3310        }
3311        if ap.midi_pitch_bends != bp.midi_pitch_bends
3312            || ap.midi_control_changes != bp.midi_control_changes
3313            || ap.midi_program_changes != bp.midi_program_changes
3314            || ap.midi_aftertouch != bp.midi_aftertouch
3315        {
3316            changes.push(ScoreChange::PartMidiAutomationChanged {
3317                part: pi,
3318                old_pitch_bends: ap.midi_pitch_bends.clone(),
3319                new_pitch_bends: bp.midi_pitch_bends.clone(),
3320                old_control_changes: ap.midi_control_changes.clone(),
3321                new_control_changes: bp.midi_control_changes.clone(),
3322                old_program_changes: ap.midi_program_changes.clone(),
3323                new_program_changes: bp.midi_program_changes.clone(),
3324                old_aftertouch: ap.midi_aftertouch.clone(),
3325                new_aftertouch: bp.midi_aftertouch.clone(),
3326            });
3327        }
3328        for si in 0..ap.staves.len().min(bp.staves.len()) {
3329            let a_staff = &ap.staves[si];
3330            let b_staff = &bp.staves[si];
3331            if a_staff.clef != b_staff.clef
3332                || a_staff.transpose_semitones != b_staff.transpose_semitones
3333            {
3334                changes.push(ScoreChange::StaffConfigurationChanged {
3335                    part: pi,
3336                    staff: si,
3337                    old_clef: a_staff.clef.clone(),
3338                    new_clef: b_staff.clef.clone(),
3339                    old_transpose_semitones: a_staff.transpose_semitones,
3340                    new_transpose_semitones: b_staff.transpose_semitones,
3341                });
3342            }
3343            if a_staff.tablature != b_staff.tablature {
3344                changes.push(ScoreChange::TablatureConfigChanged {
3345                    part: pi,
3346                    staff: si,
3347                    old: a_staff.tablature.clone(),
3348                    new: b_staff.tablature.clone(),
3349                });
3350            }
3351            if a_staff.presentation != b_staff.presentation {
3352                changes.push(ScoreChange::StaffPresentationChanged {
3353                    part: pi,
3354                    staff: si,
3355                    old: a_staff.presentation.clone(),
3356                    new: b_staff.presentation.clone(),
3357                });
3358            }
3359            for mi in 0..a_staff.measures.len().min(b_staff.measures.len()) {
3360                let am = &a_staff.measures[mi];
3361                let bm = &b_staff.measures[mi];
3362                if am.tablature_change != bm.tablature_change {
3363                    changes.push(ScoreChange::TablatureChangeChanged {
3364                        part: pi,
3365                        staff: si,
3366                        measure: mi,
3367                        old: am.tablature_change.clone(),
3368                        new: bm.tablature_change.clone(),
3369                    });
3370                }
3371                if am.mid_clefs != bm.mid_clefs {
3372                    changes.push(ScoreChange::MidMeasureClefsChanged {
3373                        part: pi,
3374                        staff: si,
3375                        measure: mi,
3376                        old: am.mid_clefs.clone(),
3377                        new: bm.mid_clefs.clone(),
3378                    });
3379                }
3380                if am.number != bm.number
3381                    || am.clef != bm.clef
3382                    || am.tempo_text != bm.tempo_text
3383                    || am.navigation != bm.navigation
3384                    || am.expression_text != bm.expression_text
3385                    || am.multi_rest_count != bm.multi_rest_count
3386                    || am.system_break != bm.system_break
3387                    || am.page_break != bm.page_break
3388                    || am.section_break != bm.section_break
3389                {
3390                    changes.push(ScoreChange::MeasurePresentationChanged {
3391                        part: pi,
3392                        staff: si,
3393                        measure: mi,
3394                        old_number: am.number,
3395                        new_number: bm.number,
3396                        old_clef: am.clef.clone(),
3397                        new_clef: bm.clef.clone(),
3398                        old_tempo_text: am.tempo_text.clone(),
3399                        new_tempo_text: bm.tempo_text.clone(),
3400                        old_navigation: am.navigation.clone(),
3401                        new_navigation: bm.navigation.clone(),
3402                        old_expression_text: am.expression_text.clone(),
3403                        new_expression_text: bm.expression_text.clone(),
3404                        old_multi_rest_count: am.multi_rest_count,
3405                        new_multi_rest_count: bm.multi_rest_count,
3406                        old_system_break: am.system_break,
3407                        new_system_break: bm.system_break,
3408                        old_page_break: am.page_break,
3409                        new_page_break: bm.page_break,
3410                        old_section_break: am.section_break,
3411                        new_section_break: bm.section_break,
3412                    });
3413                }
3414                for vi in 0..4usize {
3415                    let av = &am.voices[vi];
3416                    let bv = &bm.voices[vi];
3417                    for (ni, (a_note, b_note)) in av.iter().zip(bv.iter()).enumerate() {
3418                        if !note_content_eq(a_note, b_note) {
3419                            changes.push(ScoreChange::NoteModified {
3420                                part: pi,
3421                                staff: si,
3422                                measure: mi,
3423                                voice: vi,
3424                                note_index: ni,
3425                                old: Box::new(a_note.clone()),
3426                                new: Box::new(b_note.clone()),
3427                            });
3428                        }
3429                    }
3430                    for note in av.iter().skip(bv.len()) {
3431                        changes.push(ScoreChange::NoteRemoved {
3432                            part: pi,
3433                            staff: si,
3434                            measure: mi,
3435                            voice: vi,
3436                            note: Box::new(note.clone()),
3437                        });
3438                    }
3439                    for ni in av.len()..bv.len() {
3440                        changes.push(ScoreChange::NoteAdded {
3441                            part: pi,
3442                            staff: si,
3443                            measure: mi,
3444                            voice: vi,
3445                            note_index: ni,
3446                        });
3447                    }
3448                }
3449                if am.time_sig != bm.time_sig {
3450                    changes.push(ScoreChange::TimeSigChanged {
3451                        part: pi,
3452                        staff: si,
3453                        measure: mi,
3454                        old: am.time_sig.clone(),
3455                        new: bm.time_sig.clone(),
3456                    });
3457                }
3458                if am.time_sig_hidden != bm.time_sig_hidden {
3459                    changes.push(ScoreChange::TimeVisibilityChanged {
3460                        part: pi,
3461                        staff: si,
3462                        measure: mi,
3463                        old: am.time_sig_hidden,
3464                        new: bm.time_sig_hidden,
3465                    });
3466                }
3467                if am.time_symbol != bm.time_symbol {
3468                    changes.push(ScoreChange::TimeSymbolChanged {
3469                        part: pi,
3470                        staff: si,
3471                        measure: mi,
3472                        old: am.time_symbol,
3473                        new: bm.time_symbol,
3474                    });
3475                }
3476                if am.tempo != bm.tempo {
3477                    changes.push(ScoreChange::MeasureTempoChanged {
3478                        part: pi,
3479                        staff: si,
3480                        measure: mi,
3481                        old: am.tempo,
3482                        new: bm.tempo,
3483                    });
3484                }
3485                if am.metronome_mark != bm.metronome_mark {
3486                    changes.push(ScoreChange::MeasureMetronomeMarkChanged {
3487                        part: pi,
3488                        staff: si,
3489                        measure: mi,
3490                        old: am.metronome_mark.clone(),
3491                        new: bm.metronome_mark.clone(),
3492                    });
3493                }
3494                if am.tempo_ramp_to != bm.tempo_ramp_to {
3495                    changes.push(ScoreChange::MeasureTempoRampChanged {
3496                        part: pi,
3497                        staff: si,
3498                        measure: mi,
3499                        old: am.tempo_ramp_to,
3500                        new: bm.tempo_ramp_to,
3501                    });
3502                }
3503                if am.barline_left != bm.barline_left || am.barline_right != bm.barline_right {
3504                    changes.push(ScoreChange::BarlineChanged {
3505                        part: pi,
3506                        staff: si,
3507                        measure: mi,
3508                    });
3509                }
3510                if am.rehearsal != bm.rehearsal {
3511                    changes.push(ScoreChange::RehearsalMarkChanged {
3512                        part: pi,
3513                        staff: si,
3514                        measure: mi,
3515                        old: am.rehearsal.clone(),
3516                        new: bm.rehearsal.clone(),
3517                    });
3518                }
3519                if am.volta != bm.volta {
3520                    changes.push(ScoreChange::VoltaChanged {
3521                        part: pi,
3522                        staff: si,
3523                        measure: mi,
3524                    });
3525                }
3526                if am.texts != bm.texts {
3527                    changes.push(ScoreChange::MeasureTextChanged {
3528                        part: pi,
3529                        staff: si,
3530                        measure: mi,
3531                        old: am.texts.clone(),
3532                        new: bm.texts.clone(),
3533                    });
3534                }
3535                if am.figured_bass != bm.figured_bass {
3536                    changes.push(ScoreChange::FiguredBassChanged {
3537                        part: pi,
3538                        staff: si,
3539                        measure: mi,
3540                        old: am.figured_bass.clone(),
3541                        new: bm.figured_bass.clone(),
3542                    });
3543                }
3544                if am.harp_pedal_diagrams != bm.harp_pedal_diagrams {
3545                    changes.push(ScoreChange::HarpPedalDiagramsChanged {
3546                        part: pi,
3547                        staff: si,
3548                        measure: mi,
3549                        old: am.harp_pedal_diagrams.clone(),
3550                        new: bm.harp_pedal_diagrams.clone(),
3551                    });
3552                }
3553                if am.timed_pedals != bm.timed_pedals {
3554                    changes.push(ScoreChange::TimedPedalsChanged {
3555                        part: pi,
3556                        staff: si,
3557                        measure: mi,
3558                        old: am.timed_pedals.clone(),
3559                        new: bm.timed_pedals.clone(),
3560                    });
3561                }
3562            }
3563        }
3564    }
3565
3566    changes
3567}
3568
3569fn first_unrepresented_field_change(a: &Score, b: &Score) -> Option<String> {
3570    if a.settings.time_signature != b.settings.time_signature {
3571        return Some("settings.time_signature".to_string());
3572    }
3573    if a.part_groups.len() != b.part_groups.len()
3574        || a.part_groups.iter().zip(&b.part_groups).any(|(x, y)| {
3575            x.first_part != y.first_part
3576                || x.last_part != y.last_part
3577                || x.symbol != y.symbol
3578                || x.barlines_connect != y.barlines_connect
3579        })
3580    {
3581        return Some("part_groups".to_string());
3582    }
3583    a.parts
3584        .iter()
3585        .zip(&b.parts)
3586        .enumerate()
3587        .find_map(|(part_index, (ap, bp))| first_unrepresented_part_change(part_index, ap, bp))
3588}
3589
3590fn first_unrepresented_part_change(part_index: usize, a: &Part, b: &Part) -> Option<String> {
3591    let prefix = format!("parts[{part_index}]");
3592    if a.named_channels != b.named_channels {
3593        return Some(format!("{prefix}.named_channels"));
3594    }
3595    if a.percussion_instruments != b.percussion_instruments {
3596        return Some(format!("{prefix}.percussion_instruments"));
3597    }
3598    if a.staff_groups != b.staff_groups {
3599        return Some(format!("{prefix}.staff_groups"));
3600    }
3601    if a.staves.len() != b.staves.len() {
3602        return Some(format!("{prefix}.staves"));
3603    }
3604    a.staves
3605        .iter()
3606        .zip(&b.staves)
3607        .enumerate()
3608        .find_map(|(staff_index, (a, b))| {
3609            first_unrepresented_staff_change(&prefix, staff_index, a, b)
3610        })
3611}
3612
3613fn first_unrepresented_staff_change(
3614    part_prefix: &str,
3615    staff_index: usize,
3616    a: &Staff,
3617    b: &Staff,
3618) -> Option<String> {
3619    let prefix = format!("{part_prefix}.staves[{staff_index}]");
3620    if a.measures.len() != b.measures.len() {
3621        return Some(format!("{prefix}.measures"));
3622    }
3623    a.measures
3624        .iter()
3625        .zip(&b.measures)
3626        .enumerate()
3627        .find_map(|(measure_index, (a, b))| {
3628            first_unrepresented_measure_change(&prefix, measure_index, a, b)
3629        })
3630}
3631
3632fn first_unrepresented_measure_change(
3633    staff_prefix: &str,
3634    measure_index: usize,
3635    a: &Measure,
3636    b: &Measure,
3637) -> Option<String> {
3638    (a.channel_switches != b.channel_switches)
3639        .then(|| format!("{staff_prefix}.measures[{measure_index}].channel_switches"))
3640}
3641
3642// ── ScorePatch ────────────────────────────────────────────────────────────────
3643
3644/// An individually applicable patch operation produced by [`score_patch`].
3645///
3646/// Unlike [`ScoreChange`], every variant carries enough data to apply the change to a
3647/// [`Score`] without needing the original score. Use [`apply_patch`] to apply a list.
3648#[derive(Debug, Clone, Serialize, Deserialize)]
3649pub enum ScorePatch {
3650    SetScoreViews {
3651        value: Vec<ScoreView>,
3652    },
3653    SetScoreTexts {
3654        value: Vec<StyledText>,
3655    },
3656    SetMeasureTexts {
3657        part: usize,
3658        staff: usize,
3659        measure: usize,
3660        value: Vec<StyledText>,
3661    },
3662    SetFiguredBass {
3663        part: usize,
3664        staff: usize,
3665        measure: usize,
3666        value: Vec<FiguredBassFigure>,
3667    },
3668    SetHarpPedalDiagrams {
3669        part: usize,
3670        staff: usize,
3671        measure: usize,
3672        value: Vec<HarpPedalDiagram>,
3673    },
3674    SetTimedPedals {
3675        part: usize,
3676        staff: usize,
3677        measure: usize,
3678        value: Vec<TimedPedalDirection>,
3679    },
3680    SetChordDefinitions {
3681        value: Vec<ChordDefinition>,
3682    },
3683    SetPartNames {
3684        part: usize,
3685        name: String,
3686        short_name: String,
3687    },
3688    SetPartMidi {
3689        part: usize,
3690        channel: u8,
3691        program: u8,
3692    },
3693    SetInstrumentDefinition {
3694        part: usize,
3695        value: Option<InstrumentDefinition>,
3696    },
3697    SetPartMidiAutomation {
3698        part: usize,
3699        pitch_bends: Vec<MidiPitchBend>,
3700        control_changes: Vec<MidiControlChange>,
3701        program_changes: Vec<MidiProgramChange>,
3702        aftertouch: Vec<MidiAftertouch>,
3703    },
3704    SetStaffConfiguration {
3705        part: usize,
3706        staff: usize,
3707        clef: Clef,
3708        transpose_semitones: i8,
3709    },
3710    SetStaffPresentation {
3711        part: usize,
3712        staff: usize,
3713        value: StaffPresentation,
3714    },
3715    SetMeasurePresentation {
3716        part: usize,
3717        staff: usize,
3718        measure: usize,
3719        number: u32,
3720        clef: Option<Clef>,
3721        tempo_text: Option<String>,
3722        navigation: Option<String>,
3723        expression_text: Option<String>,
3724        multi_rest_count: Option<u8>,
3725        system_break: bool,
3726        page_break: bool,
3727        #[serde(default)]
3728        section_break: bool,
3729    },
3730    SetTablatureConfig {
3731        part: usize,
3732        staff: usize,
3733        value: Option<TablatureConfig>,
3734    },
3735    SetMeasureTablatureChange {
3736        part: usize,
3737        staff: usize,
3738        measure: usize,
3739        value: Option<TablatureConfig>,
3740    },
3741    SetMeasureMidClefs {
3742        part: usize,
3743        staff: usize,
3744        measure: usize,
3745        value: Vec<MidMeasureClef>,
3746    },
3747    SetMetadata {
3748        field: String,
3749        value: String,
3750    },
3751    SetTempo {
3752        value: u16,
3753    },
3754    SetKeySignature {
3755        part: usize,
3756        staff: usize,
3757        measure: usize,
3758        value: Option<KeySignature>,
3759    },
3760    SetTimeSignature {
3761        part: usize,
3762        staff: usize,
3763        measure: usize,
3764        value: Option<TimeSignature>,
3765    },
3766    SetTimeVisibility {
3767        part: usize,
3768        staff: usize,
3769        measure: usize,
3770        hidden: bool,
3771    },
3772    SetTimeSymbol {
3773        part: usize,
3774        staff: usize,
3775        measure: usize,
3776        value: Option<TimeSymbol>,
3777    },
3778    SetBarlines {
3779        part: usize,
3780        staff: usize,
3781        measure: usize,
3782        left: Barline,
3783        right: Barline,
3784    },
3785    SetRehearsal {
3786        part: usize,
3787        staff: usize,
3788        measure: usize,
3789        value: Option<String>,
3790    },
3791    SetVolta {
3792        part: usize,
3793        staff: usize,
3794        measure: usize,
3795        value: Option<VoltaBracket>,
3796    },
3797    /// Insert `note` at `note_index` in the given voice (existing notes shift right).
3798    AddNote {
3799        part: usize,
3800        staff: usize,
3801        measure: usize,
3802        voice: usize,
3803        /// Position for insertion. `usize::MAX` is the legacy append sentinel.
3804        #[serde(default = "legacy_append_index")]
3805        note_index: usize,
3806        note: Box<Note>,
3807    },
3808    RemoveNote {
3809        part: usize,
3810        staff: usize,
3811        measure: usize,
3812        voice: usize,
3813        note_index: usize,
3814    },
3815    /// Replace the note at `note_index` with `note`.
3816    ReplaceNote {
3817        part: usize,
3818        staff: usize,
3819        measure: usize,
3820        voice: usize,
3821        note_index: usize,
3822        note: Box<Note>,
3823    },
3824    SetMeasureTempo {
3825        part: usize,
3826        staff: usize,
3827        measure: usize,
3828        value: Option<u16>,
3829    },
3830    SetMeasureMetronomeMark {
3831        part: usize,
3832        staff: usize,
3833        measure: usize,
3834        value: Option<MetronomeMark>,
3835    },
3836    SetMeasureTempoRamp {
3837        part: usize,
3838        staff: usize,
3839        measure: usize,
3840        value: Option<u16>,
3841    },
3842    /// Replace the complete score when a change cannot be represented safely by
3843    /// positional operations (for example, a part or measure was added).
3844    ReplaceScore {
3845        score: Box<Score>,
3846    },
3847}
3848
3849fn legacy_append_index() -> usize {
3850    usize::MAX
3851}
3852
3853/// Return whether positional patches would lose score data. The patch format deliberately
3854/// keeps the common editing operations small; fields without a dedicated operation use the
3855/// complete-score fallback so an interchange round-trip never silently drops notation.
3856fn patch_requires_replace(a: &Score, b: &Score) -> bool {
3857    if a.settings.time_signature != b.settings.time_signature
3858        || a.settings.key_signature != b.settings.key_signature
3859        || a.parts.len() != b.parts.len()
3860        || a.part_groups.len() != b.part_groups.len()
3861    {
3862        return true;
3863    }
3864    if a.part_groups.iter().zip(&b.part_groups).any(|(x, y)| {
3865        x.first_part != y.first_part
3866            || x.last_part != y.last_part
3867            || x.symbol != y.symbol
3868            || x.barlines_connect != y.barlines_connect
3869    }) {
3870        return true;
3871    }
3872    for (ap, bp) in a.parts.iter().zip(&b.parts) {
3873        if ap.percussion_instruments != bp.percussion_instruments
3874            || ap.named_channels != bp.named_channels
3875            || ap.staff_groups != bp.staff_groups
3876            || ap.staves.len() != bp.staves.len()
3877        {
3878            return true;
3879        }
3880        for (as_, bs) in ap.staves.iter().zip(&bp.staves) {
3881            if as_.measures.len() != bs.measures.len()
3882                || as_
3883                    .measures
3884                    .iter()
3885                    .zip(&bs.measures)
3886                    .any(|(a, b)| a.channel_switches != b.channel_switches)
3887            {
3888                return true;
3889            }
3890        }
3891    }
3892    false
3893}
3894
3895/// Compare two scores and return a list of [`ScorePatch`] operations.
3896///
3897/// Applying the patches to `a` via [`apply_patch`] produces a score structurally
3898/// equivalent to `b` (same parts, staves, measures, and note content).
3899pub fn score_patch(a: &Score, b: &Score) -> Vec<ScorePatch> {
3900    let mut patches: Vec<ScorePatch> = Vec::new();
3901
3902    if patch_requires_replace(a, b) {
3903        return vec![ScorePatch::ReplaceScore {
3904            score: Box::new(b.clone()),
3905        }];
3906    }
3907
3908    if a.views != b.views {
3909        patches.push(ScorePatch::SetScoreViews {
3910            value: b.views.clone(),
3911        });
3912    }
3913
3914    if a.texts != b.texts {
3915        patches.push(ScorePatch::SetScoreTexts {
3916            value: b.texts.clone(),
3917        });
3918    }
3919    if a.chord_definitions != b.chord_definitions {
3920        patches.push(ScorePatch::SetChordDefinitions {
3921            value: b.chord_definitions.clone(),
3922        });
3923    }
3924
3925    macro_rules! meta {
3926        ($field:ident, $name:literal) => {
3927            if a.metadata.$field != b.metadata.$field {
3928                patches.push(ScorePatch::SetMetadata {
3929                    field: $name.to_string(),
3930                    value: b.metadata.$field.clone(),
3931                });
3932            }
3933        };
3934    }
3935    meta!(title, "title");
3936    meta!(composer, "composer");
3937    meta!(lyricist, "lyricist");
3938    meta!(copyright, "copyright");
3939    meta!(work_number, "work_number");
3940    meta!(movement_title, "movement_title");
3941
3942    if a.settings.tempo_bpm != b.settings.tempo_bpm {
3943        patches.push(ScorePatch::SetTempo {
3944            value: b.settings.tempo_bpm,
3945        });
3946    }
3947
3948    for pi in 0..a.parts.len().min(b.parts.len()) {
3949        let ap = &a.parts[pi];
3950        let bp = &b.parts[pi];
3951        if ap.name != bp.name || ap.short_name != bp.short_name {
3952            patches.push(ScorePatch::SetPartNames {
3953                part: pi,
3954                name: bp.name.clone(),
3955                short_name: bp.short_name.clone(),
3956            });
3957        }
3958        if ap.midi_channel != bp.midi_channel || ap.midi_program != bp.midi_program {
3959            patches.push(ScorePatch::SetPartMidi {
3960                part: pi,
3961                channel: bp.midi_channel,
3962                program: bp.midi_program,
3963            });
3964        }
3965        if ap.instrument != bp.instrument {
3966            patches.push(ScorePatch::SetInstrumentDefinition {
3967                part: pi,
3968                value: bp.instrument.clone(),
3969            });
3970        }
3971        if ap.midi_pitch_bends != bp.midi_pitch_bends
3972            || ap.midi_control_changes != bp.midi_control_changes
3973            || ap.midi_program_changes != bp.midi_program_changes
3974            || ap.midi_aftertouch != bp.midi_aftertouch
3975        {
3976            patches.push(ScorePatch::SetPartMidiAutomation {
3977                part: pi,
3978                pitch_bends: bp.midi_pitch_bends.clone(),
3979                control_changes: bp.midi_control_changes.clone(),
3980                program_changes: bp.midi_program_changes.clone(),
3981                aftertouch: bp.midi_aftertouch.clone(),
3982            });
3983        }
3984        for si in 0..ap.staves.len().min(bp.staves.len()) {
3985            let a_staff = &ap.staves[si];
3986            let b_staff = &bp.staves[si];
3987            if a_staff.clef != b_staff.clef
3988                || a_staff.transpose_semitones != b_staff.transpose_semitones
3989            {
3990                patches.push(ScorePatch::SetStaffConfiguration {
3991                    part: pi,
3992                    staff: si,
3993                    clef: b_staff.clef.clone(),
3994                    transpose_semitones: b_staff.transpose_semitones,
3995                });
3996            }
3997            if a_staff.tablature != b_staff.tablature {
3998                patches.push(ScorePatch::SetTablatureConfig {
3999                    part: pi,
4000                    staff: si,
4001                    value: b_staff.tablature.clone(),
4002                });
4003            }
4004            if a_staff.presentation != b_staff.presentation {
4005                patches.push(ScorePatch::SetStaffPresentation {
4006                    part: pi,
4007                    staff: si,
4008                    value: b_staff.presentation.clone(),
4009                });
4010            }
4011            for mi in 0..a_staff.measures.len().min(b_staff.measures.len()) {
4012                let am = &a_staff.measures[mi];
4013                let bm = &b_staff.measures[mi];
4014
4015                if am.tablature_change != bm.tablature_change {
4016                    patches.push(ScorePatch::SetMeasureTablatureChange {
4017                        part: pi,
4018                        staff: si,
4019                        measure: mi,
4020                        value: bm.tablature_change.clone(),
4021                    });
4022                }
4023
4024                if am.mid_clefs != bm.mid_clefs {
4025                    patches.push(ScorePatch::SetMeasureMidClefs {
4026                        part: pi,
4027                        staff: si,
4028                        measure: mi,
4029                        value: bm.mid_clefs.clone(),
4030                    });
4031                }
4032
4033                if am.number != bm.number
4034                    || am.clef != bm.clef
4035                    || am.tempo_text != bm.tempo_text
4036                    || am.navigation != bm.navigation
4037                    || am.expression_text != bm.expression_text
4038                    || am.multi_rest_count != bm.multi_rest_count
4039                    || am.system_break != bm.system_break
4040                    || am.page_break != bm.page_break
4041                    || am.section_break != bm.section_break
4042                {
4043                    patches.push(ScorePatch::SetMeasurePresentation {
4044                        part: pi,
4045                        staff: si,
4046                        measure: mi,
4047                        number: bm.number,
4048                        clef: bm.clef.clone(),
4049                        tempo_text: bm.tempo_text.clone(),
4050                        navigation: bm.navigation.clone(),
4051                        expression_text: bm.expression_text.clone(),
4052                        multi_rest_count: bm.multi_rest_count,
4053                        system_break: bm.system_break,
4054                        page_break: bm.page_break,
4055                        section_break: bm.section_break,
4056                    });
4057                }
4058                if am.key_sig != bm.key_sig {
4059                    patches.push(ScorePatch::SetKeySignature {
4060                        part: pi,
4061                        staff: si,
4062                        measure: mi,
4063                        value: bm.key_sig.clone(),
4064                    });
4065                }
4066                if am.time_sig != bm.time_sig {
4067                    patches.push(ScorePatch::SetTimeSignature {
4068                        part: pi,
4069                        staff: si,
4070                        measure: mi,
4071                        value: bm.time_sig.clone(),
4072                    });
4073                }
4074                if am.time_sig_hidden != bm.time_sig_hidden {
4075                    patches.push(ScorePatch::SetTimeVisibility {
4076                        part: pi,
4077                        staff: si,
4078                        measure: mi,
4079                        hidden: bm.time_sig_hidden,
4080                    });
4081                }
4082                if am.time_symbol != bm.time_symbol {
4083                    patches.push(ScorePatch::SetTimeSymbol {
4084                        part: pi,
4085                        staff: si,
4086                        measure: mi,
4087                        value: bm.time_symbol,
4088                    });
4089                }
4090                if am.barline_left != bm.barline_left || am.barline_right != bm.barline_right {
4091                    patches.push(ScorePatch::SetBarlines {
4092                        part: pi,
4093                        staff: si,
4094                        measure: mi,
4095                        left: bm.barline_left.clone(),
4096                        right: bm.barline_right.clone(),
4097                    });
4098                }
4099                if am.rehearsal != bm.rehearsal {
4100                    patches.push(ScorePatch::SetRehearsal {
4101                        part: pi,
4102                        staff: si,
4103                        measure: mi,
4104                        value: bm.rehearsal.clone(),
4105                    });
4106                }
4107                if am.volta != bm.volta {
4108                    patches.push(ScorePatch::SetVolta {
4109                        part: pi,
4110                        staff: si,
4111                        measure: mi,
4112                        value: bm.volta.clone(),
4113                    });
4114                }
4115                if am.tempo != bm.tempo {
4116                    patches.push(ScorePatch::SetMeasureTempo {
4117                        part: pi,
4118                        staff: si,
4119                        measure: mi,
4120                        value: bm.tempo,
4121                    });
4122                }
4123                if am.metronome_mark != bm.metronome_mark {
4124                    patches.push(ScorePatch::SetMeasureMetronomeMark {
4125                        part: pi,
4126                        staff: si,
4127                        measure: mi,
4128                        value: bm.metronome_mark.clone(),
4129                    });
4130                }
4131                if am.tempo_ramp_to != bm.tempo_ramp_to {
4132                    patches.push(ScorePatch::SetMeasureTempoRamp {
4133                        part: pi,
4134                        staff: si,
4135                        measure: mi,
4136                        value: bm.tempo_ramp_to,
4137                    });
4138                }
4139                if am.texts != bm.texts {
4140                    patches.push(ScorePatch::SetMeasureTexts {
4141                        part: pi,
4142                        staff: si,
4143                        measure: mi,
4144                        value: bm.texts.clone(),
4145                    });
4146                }
4147                if am.figured_bass != bm.figured_bass {
4148                    patches.push(ScorePatch::SetFiguredBass {
4149                        part: pi,
4150                        staff: si,
4151                        measure: mi,
4152                        value: bm.figured_bass.clone(),
4153                    });
4154                }
4155                if am.harp_pedal_diagrams != bm.harp_pedal_diagrams {
4156                    patches.push(ScorePatch::SetHarpPedalDiagrams {
4157                        part: pi,
4158                        staff: si,
4159                        measure: mi,
4160                        value: bm.harp_pedal_diagrams.clone(),
4161                    });
4162                }
4163
4164                if am.timed_pedals != bm.timed_pedals {
4165                    patches.push(ScorePatch::SetTimedPedals {
4166                        part: pi,
4167                        staff: si,
4168                        measure: mi,
4169                        value: bm.timed_pedals.clone(),
4170                    });
4171                }
4172
4173                for vi in 0..4usize {
4174                    let av = &am.voices[vi];
4175                    let bv = &bm.voices[vi];
4176                    for (ni, (a_note, b_note)) in av.iter().zip(bv.iter()).enumerate() {
4177                        if !note_content_eq(a_note, b_note) {
4178                            patches.push(ScorePatch::ReplaceNote {
4179                                part: pi,
4180                                staff: si,
4181                                measure: mi,
4182                                voice: vi,
4183                                note_index: ni,
4184                                note: Box::new(b_note.clone()),
4185                            });
4186                        }
4187                    }
4188                    // Notes in `a` beyond `b` — remove in reverse order to preserve indices.
4189                    for ni in (bv.len()..av.len()).rev() {
4190                        patches.push(ScorePatch::RemoveNote {
4191                            part: pi,
4192                            staff: si,
4193                            measure: mi,
4194                            voice: vi,
4195                            note_index: ni,
4196                        });
4197                    }
4198                    // Notes in `b` beyond `a` — append.
4199                    for (offset, note) in bv.iter().skip(av.len()).enumerate() {
4200                        patches.push(ScorePatch::AddNote {
4201                            part: pi,
4202                            staff: si,
4203                            measure: mi,
4204                            voice: vi,
4205                            note_index: av.len() + offset,
4206                            note: Box::new(note.clone()),
4207                        });
4208                    }
4209                }
4210            }
4211        }
4212    }
4213
4214    patches
4215}
4216
4217/// Apply a list of [`ScorePatch`] operations to a cloned copy of `score`.
4218///
4219/// Returns `Err(Error::InvalidPatch)` if any patch references an out-of-bounds index.
4220/// The returned score is an independent clone — `score` is not modified.
4221pub fn apply_patch(score: &Score, patches: &[ScorePatch]) -> Result<Score, Error> {
4222    let mut s = score.clone();
4223    for patch in patches {
4224        match patch {
4225            ScorePatch::ReplaceScore { score } => {
4226                s = (**score).clone();
4227            }
4228            ScorePatch::SetScoreViews { value } => {
4229                s.views = value.clone();
4230            }
4231            ScorePatch::SetScoreTexts { value } => {
4232                s.texts = value.clone();
4233            }
4234            ScorePatch::SetMeasureTexts {
4235                part,
4236                staff,
4237                measure,
4238                value,
4239            } => {
4240                s.parts
4241                    .get_mut(*part)
4242                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4243                    .staves
4244                    .get_mut(*staff)
4245                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4246                    .measures
4247                    .get_mut(*measure)
4248                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4249                    .texts = value.clone();
4250            }
4251            ScorePatch::SetFiguredBass {
4252                part,
4253                staff,
4254                measure,
4255                value,
4256            } => {
4257                s.parts
4258                    .get_mut(*part)
4259                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4260                    .staves
4261                    .get_mut(*staff)
4262                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4263                    .measures
4264                    .get_mut(*measure)
4265                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4266                    .figured_bass = value.clone();
4267            }
4268            ScorePatch::SetHarpPedalDiagrams {
4269                part,
4270                staff,
4271                measure,
4272                value,
4273            } => {
4274                s.parts
4275                    .get_mut(*part)
4276                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4277                    .staves
4278                    .get_mut(*staff)
4279                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4280                    .measures
4281                    .get_mut(*measure)
4282                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4283                    .harp_pedal_diagrams = value.clone();
4284            }
4285            ScorePatch::SetTimedPedals {
4286                part,
4287                staff,
4288                measure,
4289                value,
4290            } => {
4291                s.parts
4292                    .get_mut(*part)
4293                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4294                    .staves
4295                    .get_mut(*staff)
4296                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4297                    .measures
4298                    .get_mut(*measure)
4299                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4300                    .timed_pedals = value.clone();
4301            }
4302            ScorePatch::SetChordDefinitions { value } => {
4303                s.chord_definitions = value.clone();
4304            }
4305            ScorePatch::SetPartNames {
4306                part,
4307                name,
4308                short_name,
4309            } => {
4310                let target = s
4311                    .parts
4312                    .get_mut(*part)
4313                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?;
4314                target.name = name.clone();
4315                target.short_name = short_name.clone();
4316            }
4317            ScorePatch::SetPartMidi {
4318                part,
4319                channel,
4320                program,
4321            } => {
4322                let target = s
4323                    .parts
4324                    .get_mut(*part)
4325                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?;
4326                target.midi_channel = *channel;
4327                target.midi_program = *program;
4328            }
4329            ScorePatch::SetInstrumentDefinition { part, value } => {
4330                s.parts
4331                    .get_mut(*part)
4332                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4333                    .instrument = value.clone();
4334            }
4335            ScorePatch::SetPartMidiAutomation {
4336                part,
4337                pitch_bends,
4338                control_changes,
4339                program_changes,
4340                aftertouch,
4341            } => {
4342                let target = s
4343                    .parts
4344                    .get_mut(*part)
4345                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?;
4346                target.midi_pitch_bends = pitch_bends.clone();
4347                target.midi_control_changes = control_changes.clone();
4348                target.midi_program_changes = program_changes.clone();
4349                target.midi_aftertouch = aftertouch.clone();
4350            }
4351            ScorePatch::SetStaffConfiguration {
4352                part,
4353                staff,
4354                clef,
4355                transpose_semitones,
4356            } => {
4357                let target = s
4358                    .parts
4359                    .get_mut(*part)
4360                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4361                    .staves
4362                    .get_mut(*staff)
4363                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?;
4364                target.clef = clef.clone();
4365                target.transpose_semitones = *transpose_semitones;
4366            }
4367            ScorePatch::SetStaffPresentation { part, staff, value } => {
4368                let target = s
4369                    .parts
4370                    .get_mut(*part)
4371                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4372                    .staves
4373                    .get_mut(*staff)
4374                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?;
4375                target.presentation = value.clone();
4376            }
4377            ScorePatch::SetMeasurePresentation {
4378                part,
4379                staff,
4380                measure,
4381                number,
4382                clef,
4383                tempo_text,
4384                navigation,
4385                expression_text,
4386                multi_rest_count,
4387                system_break,
4388                page_break,
4389                section_break,
4390            } => {
4391                let target = s
4392                    .parts
4393                    .get_mut(*part)
4394                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4395                    .staves
4396                    .get_mut(*staff)
4397                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4398                    .measures
4399                    .get_mut(*measure)
4400                    .ok_or_else(|| {
4401                        Error::InvalidPatch(format!("measure {measure} out of range"))
4402                    })?;
4403                target.number = *number;
4404                target.clef = clef.clone();
4405                target.tempo_text = tempo_text.clone();
4406                target.navigation = navigation.clone();
4407                target.expression_text = expression_text.clone();
4408                target.multi_rest_count = *multi_rest_count;
4409                target.system_break = *system_break;
4410                target.page_break = *page_break;
4411                target.section_break = *section_break;
4412            }
4413            ScorePatch::SetTablatureConfig { part, staff, value } => {
4414                s.parts
4415                    .get_mut(*part)
4416                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4417                    .staves
4418                    .get_mut(*staff)
4419                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4420                    .tablature = value.clone();
4421            }
4422            ScorePatch::SetMeasureTablatureChange {
4423                part,
4424                staff,
4425                measure,
4426                value,
4427            } => {
4428                s.parts
4429                    .get_mut(*part)
4430                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4431                    .staves
4432                    .get_mut(*staff)
4433                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4434                    .measures
4435                    .get_mut(*measure)
4436                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4437                    .tablature_change = value.clone();
4438            }
4439            ScorePatch::SetMeasureMidClefs {
4440                part,
4441                staff,
4442                measure,
4443                value,
4444            } => {
4445                s.parts
4446                    .get_mut(*part)
4447                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4448                    .staves
4449                    .get_mut(*staff)
4450                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4451                    .measures
4452                    .get_mut(*measure)
4453                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4454                    .mid_clefs = value.clone();
4455            }
4456            ScorePatch::SetMetadata { field, value } => match field.as_str() {
4457                "title" => s.metadata.title = value.clone(),
4458                "composer" => s.metadata.composer = value.clone(),
4459                "lyricist" => s.metadata.lyricist = value.clone(),
4460                "copyright" => s.metadata.copyright = value.clone(),
4461                "work_number" => s.metadata.work_number = value.clone(),
4462                "movement_title" => s.metadata.movement_title = value.clone(),
4463                other => {
4464                    return Err(Error::InvalidPatch(format!(
4465                        "unknown metadata field: {other}"
4466                    )));
4467                }
4468            },
4469            ScorePatch::SetTempo { value } => {
4470                s.settings.tempo_bpm = *value;
4471            }
4472            ScorePatch::SetKeySignature {
4473                part,
4474                staff,
4475                measure,
4476                value,
4477            } => {
4478                s.parts
4479                    .get_mut(*part)
4480                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4481                    .staves
4482                    .get_mut(*staff)
4483                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4484                    .measures
4485                    .get_mut(*measure)
4486                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4487                    .key_sig = value.clone();
4488            }
4489            ScorePatch::SetTimeSignature {
4490                part,
4491                staff,
4492                measure,
4493                value,
4494            } => {
4495                s.parts
4496                    .get_mut(*part)
4497                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4498                    .staves
4499                    .get_mut(*staff)
4500                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4501                    .measures
4502                    .get_mut(*measure)
4503                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4504                    .time_sig = value.clone();
4505            }
4506            ScorePatch::SetTimeVisibility {
4507                part,
4508                staff,
4509                measure,
4510                hidden,
4511            } => {
4512                s.parts
4513                    .get_mut(*part)
4514                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4515                    .staves
4516                    .get_mut(*staff)
4517                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4518                    .measures
4519                    .get_mut(*measure)
4520                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4521                    .time_sig_hidden = *hidden;
4522            }
4523            ScorePatch::SetTimeSymbol {
4524                part,
4525                staff,
4526                measure,
4527                value,
4528            } => {
4529                s.parts
4530                    .get_mut(*part)
4531                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4532                    .staves
4533                    .get_mut(*staff)
4534                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4535                    .measures
4536                    .get_mut(*measure)
4537                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4538                    .time_symbol = *value;
4539            }
4540            ScorePatch::SetBarlines {
4541                part,
4542                staff,
4543                measure,
4544                left,
4545                right,
4546            } => {
4547                let m = s
4548                    .parts
4549                    .get_mut(*part)
4550                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4551                    .staves
4552                    .get_mut(*staff)
4553                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4554                    .measures
4555                    .get_mut(*measure)
4556                    .ok_or_else(|| {
4557                        Error::InvalidPatch(format!("measure {measure} out of range"))
4558                    })?;
4559                m.barline_left = left.clone();
4560                m.barline_right = right.clone();
4561            }
4562            ScorePatch::SetRehearsal {
4563                part,
4564                staff,
4565                measure,
4566                value,
4567            } => {
4568                s.parts
4569                    .get_mut(*part)
4570                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4571                    .staves
4572                    .get_mut(*staff)
4573                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4574                    .measures
4575                    .get_mut(*measure)
4576                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4577                    .rehearsal = value.clone();
4578            }
4579            ScorePatch::SetVolta {
4580                part,
4581                staff,
4582                measure,
4583                value,
4584            } => {
4585                s.parts
4586                    .get_mut(*part)
4587                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4588                    .staves
4589                    .get_mut(*staff)
4590                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4591                    .measures
4592                    .get_mut(*measure)
4593                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4594                    .volta = value.clone();
4595            }
4596            ScorePatch::AddNote {
4597                part,
4598                staff,
4599                measure,
4600                voice,
4601                note_index,
4602                note,
4603            } => {
4604                let v = s
4605                    .parts
4606                    .get_mut(*part)
4607                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4608                    .staves
4609                    .get_mut(*staff)
4610                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4611                    .measures
4612                    .get_mut(*measure)
4613                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4614                    .voices
4615                    .get_mut(*voice)
4616                    .ok_or_else(|| Error::InvalidPatch(format!("voice {voice} out of range")))?;
4617                let insert_at = if *note_index == usize::MAX {
4618                    v.len()
4619                } else {
4620                    *note_index
4621                };
4622                if insert_at > v.len() {
4623                    return Err(Error::InvalidPatch(format!(
4624                        "note_index {note_index} out of range"
4625                    )));
4626                }
4627                v.insert(insert_at, *note.clone());
4628            }
4629            ScorePatch::RemoveNote {
4630                part,
4631                staff,
4632                measure,
4633                voice,
4634                note_index,
4635            } => {
4636                let v = s
4637                    .parts
4638                    .get_mut(*part)
4639                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4640                    .staves
4641                    .get_mut(*staff)
4642                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4643                    .measures
4644                    .get_mut(*measure)
4645                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4646                    .voices
4647                    .get_mut(*voice)
4648                    .ok_or_else(|| Error::InvalidPatch(format!("voice {voice} out of range")))?;
4649                if *note_index >= v.len() {
4650                    return Err(Error::InvalidPatch(format!(
4651                        "note_index {note_index} out of range"
4652                    )));
4653                }
4654                v.remove(*note_index);
4655            }
4656            ScorePatch::ReplaceNote {
4657                part,
4658                staff,
4659                measure,
4660                voice,
4661                note_index,
4662                note,
4663            } => {
4664                let v = s
4665                    .parts
4666                    .get_mut(*part)
4667                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4668                    .staves
4669                    .get_mut(*staff)
4670                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4671                    .measures
4672                    .get_mut(*measure)
4673                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4674                    .voices
4675                    .get_mut(*voice)
4676                    .ok_or_else(|| Error::InvalidPatch(format!("voice {voice} out of range")))?;
4677                if *note_index >= v.len() {
4678                    return Err(Error::InvalidPatch(format!(
4679                        "note_index {note_index} out of range"
4680                    )));
4681                }
4682                v[*note_index] = *note.clone();
4683            }
4684            ScorePatch::SetMeasureTempo {
4685                part,
4686                staff,
4687                measure,
4688                value,
4689            } => {
4690                s.parts
4691                    .get_mut(*part)
4692                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4693                    .staves
4694                    .get_mut(*staff)
4695                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4696                    .measures
4697                    .get_mut(*measure)
4698                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4699                    .tempo = *value;
4700            }
4701            ScorePatch::SetMeasureTempoRamp {
4702                part,
4703                staff,
4704                measure,
4705                value,
4706            } => {
4707                s.parts
4708                    .get_mut(*part)
4709                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4710                    .staves
4711                    .get_mut(*staff)
4712                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4713                    .measures
4714                    .get_mut(*measure)
4715                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4716                    .tempo_ramp_to = *value;
4717            }
4718            ScorePatch::SetMeasureMetronomeMark {
4719                part,
4720                staff,
4721                measure,
4722                value,
4723            } => {
4724                s.parts
4725                    .get_mut(*part)
4726                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4727                    .staves
4728                    .get_mut(*staff)
4729                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4730                    .measures
4731                    .get_mut(*measure)
4732                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4733                    .metronome_mark = value.clone();
4734            }
4735        }
4736    }
4737    if !super::validate::validate(&s).is_valid() {
4738        return Err(Error::InvalidScore);
4739    }
4740    Ok(s)
4741}
4742
4743/// Sounding octave displacement for notes covered by staff-wide ottava lines.
4744///
4745/// Start and end flags are paired in their originating voice, but the resulting
4746/// interval applies to every voice on the staff. End notes are included.
4747pub fn staff_ottava_octave_shifts(
4748    staff: &Staff,
4749    default_time: &TimeSignature,
4750) -> std::collections::HashMap<(usize, usize, usize), i8> {
4751    // Most staves have no ottava; avoid allocating and sorting every note in those scores.
4752    if !staff
4753        .measures
4754        .iter()
4755        .flat_map(|m| &m.voices)
4756        .flatten()
4757        .any(|n| n.ottava_start.is_some())
4758    {
4759        return std::collections::HashMap::new();
4760    }
4761    // Events sort by time, then end, start, and note, respectively.
4762    type OttavaEvent = (f64, u8, i8, (usize, usize, usize));
4763    let mut events: Vec<OttavaEvent> = Vec::new();
4764    for voice in 0..4 {
4765        let mut measure_start = 0.0;
4766        let mut active = None;
4767        for (measure_index, measure) in staff.measures.iter().enumerate() {
4768            let mut position = measure_start;
4769            for (note_index, note) in measure.voices[voice].iter().enumerate() {
4770                let key = (measure_index, voice, note_index);
4771                if let Some(kind) = note.ottava_start {
4772                    if let Some(previous) = active.take() {
4773                        events.push((position, 0, previous, key));
4774                    }
4775                    let shift = match kind {
4776                        OttavaKind::Va8 => 1,
4777                        OttavaKind::Vb8 => -1,
4778                        OttavaKind::Ma15 => 2,
4779                        OttavaKind::Mb15 => -2,
4780                    };
4781                    events.push((position, 1, shift, key));
4782                    active = Some(shift);
4783                }
4784                events.push((position, 2, 0, key));
4785                let duration = note.beats();
4786                if note.ottava_end
4787                    && let Some(shift) = active.take()
4788                {
4789                    events.push((position + duration.max(1e-9), 0, shift, key));
4790                }
4791                position += duration;
4792            }
4793            measure_start += staff.measure_beats(measure_index, default_time);
4794        }
4795    }
4796    events.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
4797    // MuseScore keeps one staff pitch-offset state. A later start replaces the
4798    // previous value, and an end resets it instead of reviving an older line.
4799    // Equal-time starts retain the original voice order (the last wins).
4800    let mut current = 0_i8;
4801    let mut result = std::collections::HashMap::new();
4802    for (_, kind, shift, key) in events {
4803        match kind {
4804            0 => current = 0,
4805            1 => current = shift,
4806            _ if current != 0 => {
4807                result.insert(key, current);
4808            }
4809            _ => {}
4810        }
4811    }
4812    result
4813}
4814
4815/// Beats a voice occupies in a measure lasting `measure_beats`. A voice holding only one plain
4816/// whole rest is a measure rest and lasts exactly the measure in any time signature (as in
4817/// MusicXML and MuseScore); otherwise the voice lasts the sum of its notes.
4818pub fn voice_duration_beats(voice: &[Note], measure_beats: f64) -> f64 {
4819    match voice {
4820        [only] if only.is_plain_whole_rest() => measure_beats,
4821        _ => voice.iter().map(Note::beats).sum(),
4822    }
4823}
4824
4825/// Respell all pitches in the score to prefer flats or sharps.
4826///
4827/// Applies [`Pitch::respell`] to every note in every part, staff, measure, and voice.
4828pub fn respell_score(score: &mut Score, prefer_flat: bool) {
4829    for part in &mut score.parts {
4830        for staff in &mut part.staves {
4831            for measure in &mut staff.measures {
4832                for voice in &mut measure.voices {
4833                    for note in voice.iter_mut() {
4834                        for pitch in &mut note.pitches {
4835                            *pitch = pitch.respell(prefer_flat);
4836                        }
4837                    }
4838                }
4839            }
4840        }
4841    }
4842}
4843
4844/// Spelling policy for [`respell_staff_region`].
4845#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4846#[serde(rename_all = "snake_case")]
4847pub enum RespellPolicy {
4848    /// Spell black-key pitches with flats.
4849    Flat,
4850    /// Spell black-key pitches with sharps.
4851    Sharp,
4852    /// Follow the key signature in effect at each measure: flat keys use flats, others sharps.
4853    Key,
4854}
4855
4856/// Respell the pitched notes of one staff in the measure range `start_measure..end_measure`
4857/// (end exclusive), the selection form of [`respell_score`].
4858///
4859/// With [`RespellPolicy::Key`], each measure uses its own key change, then the part's first-staff
4860/// key change, then the score key. Rests and unpitched notes are left unchanged, because an
4861/// unpitched note's pitch is its staff position. Sounding pitch and microtones never change.
4862///
4863/// A tie never joins two spellings: a tied chain takes the spelling of its first note inside the
4864/// range, including chain members before or after the range. Returns the inclusive measure span
4865/// that changed, which is wider than the range when a tie crosses its boundary.
4866pub fn respell_staff_region(
4867    score: &mut Score,
4868    part_index: usize,
4869    staff_index: usize,
4870    start_measure: usize,
4871    end_measure: usize,
4872    policy: RespellPolicy,
4873) -> Result<(usize, usize), Error> {
4874    let score_key_flat = score.settings.key_signature.fifths < 0;
4875    let part = score
4876        .parts
4877        .get_mut(part_index)
4878        .ok_or(Error::PartNotFound(part_index))?;
4879    let staff_count = part.staves.len();
4880    if staff_index >= staff_count {
4881        return Err(Error::StaffNotFound(staff_index));
4882    }
4883    let measure_count = part.staves[staff_index].measures.len();
4884    if start_measure >= end_measure || end_measure > measure_count {
4885        return Err(Error::InvalidCommand(format!(
4886            "invalid measure range {start_measure}..{end_measure}"
4887        )));
4888    }
4889    let mut prefer_flat = Vec::with_capacity(end_measure);
4890    let mut running_flat = score_key_flat;
4891    for index in 0..end_measure {
4892        let key = part.staves[staff_index].measures[index]
4893            .key_sig
4894            .as_ref()
4895            .or_else(|| {
4896                part.staves[0]
4897                    .measures
4898                    .get(index)
4899                    .and_then(|measure| measure.key_sig.as_ref())
4900            });
4901        if let Some(key) = key {
4902            running_flat = key.fifths < 0;
4903        }
4904        prefer_flat.push(match policy {
4905            RespellPolicy::Flat => true,
4906            RespellPolicy::Sharp => false,
4907            RespellPolicy::Key => running_flat,
4908        });
4909    }
4910
4911    let measures = &mut part.staves[staff_index].measures;
4912    for (index, measure) in measures
4913        .iter_mut()
4914        .enumerate()
4915        .take(end_measure)
4916        .skip(start_measure)
4917    {
4918        for voice in &mut measure.voices {
4919            for note in voice
4920                .iter_mut()
4921                .filter(|note| !note.is_rest && !note.is_unpitched)
4922            {
4923                for pitch in &mut note.pitches {
4924                    *pitch = pitch.respell(prefer_flat[index]);
4925                }
4926            }
4927        }
4928    }
4929
4930    let mut changed = (start_measure, end_measure - 1);
4931    for voice in 0..4 {
4932        for measure in start_measure..end_measure {
4933            for note in 0..measures[measure].voices[voice].len() {
4934                if measures[measure].voices[voice][note].tie_start {
4935                    let last = propagate_tied_spelling(measures, voice, (measure, note), true);
4936                    changed.1 = changed.1.max(last);
4937                }
4938            }
4939        }
4940        if !measures[start_measure].voices[voice].is_empty() {
4941            let first = propagate_tied_spelling(measures, voice, (start_measure, 0), false);
4942            changed.0 = changed.0.min(first);
4943        }
4944    }
4945    Ok(changed)
4946}
4947
4948/// Copy one note's spelling along its tie chain (forward through `tie_start`, or backward through
4949/// `tie_end`), matching chord members by sounding pitch. Returns the last measure reached.
4950fn propagate_tied_spelling(
4951    measures: &mut [Measure],
4952    voice: usize,
4953    from: (usize, usize),
4954    forward: bool,
4955) -> usize {
4956    let mut current = from;
4957    loop {
4958        let note = &measures[current.0].voices[voice][current.1];
4959        if !(if forward {
4960            note.tie_start
4961        } else {
4962            note.tie_end
4963        }) {
4964            return current.0;
4965        }
4966        let neighbor = if forward {
4967            if current.1 + 1 < measures[current.0].voices[voice].len() {
4968                Some((current.0, current.1 + 1))
4969            } else {
4970                measures
4971                    .get(current.0 + 1)
4972                    .filter(|measure| !measure.voices[voice].is_empty())
4973                    .map(|_| (current.0 + 1, 0))
4974            }
4975        } else if current.1 > 0 {
4976            Some((current.0, current.1 - 1))
4977        } else {
4978            current.0.checked_sub(1).and_then(|previous| {
4979                measures[previous].voices[voice]
4980                    .len()
4981                    .checked_sub(1)
4982                    .map(|last| (previous, last))
4983            })
4984        };
4985        let Some(next) = neighbor else {
4986            return current.0;
4987        };
4988        let next_note = &measures[next.0].voices[voice][next.1];
4989        if !(if forward {
4990            next_note.tie_end
4991        } else {
4992            next_note.tie_start
4993        }) {
4994            return current.0;
4995        }
4996        let source = measures[current.0].voices[voice][current.1].pitches.clone();
4997        for pitch in &mut measures[next.0].voices[voice][next.1].pitches {
4998            if let Some(spelled) = source.iter().find(|candidate| {
4999                candidate.to_midi() == pitch.to_midi()
5000                    && candidate.microtone_cents == pitch.microtone_cents
5001            }) {
5002                *pitch = spelled.clone();
5003            }
5004        }
5005        current = next;
5006    }
5007}
5008
5009/// Respell all pitches to match the score's key signature spelling convention.
5010///
5011/// Flat-key signatures (fifths < 0) use flat spellings; sharp-key and C major use sharps.
5012pub fn respell_score_to_key(score: &mut Score) {
5013    let prefer_flat = score.settings.key_signature.fifths < 0;
5014    respell_score(score, prefer_flat);
5015}
5016
5017/// Compute total playback duration in seconds.
5018///
5019/// Uses `measure_sequence` for correct repeat handling. Lighter than generating
5020/// full playback events — suitable for progress bars and UI display. Includes
5021/// the default score-wide fermata holds.
5022pub fn score_duration_secs(score: &Score) -> f64 {
5023    if score.settings.tempo_bpm == 0 {
5024        return 0.0;
5025    }
5026    let seq = measure_sequence(score);
5027    let mut total_secs = 0.0f64;
5028    let mut current_bpm = score.settings.tempo_bpm as f64;
5029    if let Some(staff) = score.parts.first().and_then(|p| p.staves.first()) {
5030        for &idx in &seq {
5031            if let Some(m) = staff.measures.get(idx) {
5032                if let Some(b) = m.tempo {
5033                    current_bpm = b as f64;
5034                }
5035                if current_bpm == 0.0 {
5036                    continue;
5037                }
5038                let beats = voice_duration_beats(
5039                    &m.voices[0],
5040                    staff.measure_beats(idx, &score.settings.time_signature),
5041                );
5042                total_secs += tempo_ramp_duration_secs(current_bpm, m.tempo_ramp_to, beats);
5043                if let Some(target) = m.tempo_ramp_to.filter(|target| *target > 0) {
5044                    current_bpm = f64::from(target);
5045                }
5046            }
5047        }
5048    }
5049    total_secs + super::playback::fermata_total_secs(score, None)
5050}
5051
5052/// Compute playback duration in seconds for a specific measure range (inclusive).
5053///
5054/// `region` is `(start_measure, end_measure)`, both 0-based. Measures outside the range
5055/// are excluded. Uses `measure_sequence` for correct repeat handling and includes
5056/// the default score-wide fermata holds.
5057pub fn score_duration_secs_region(score: &Score, region: (usize, usize)) -> f64 {
5058    if score.settings.tempo_bpm == 0 {
5059        return 0.0;
5060    }
5061    let seq: Vec<usize> = measure_sequence(score)
5062        .into_iter()
5063        .filter(|&idx| idx >= region.0 && idx <= region.1)
5064        .collect();
5065    let mut total_secs = 0.0f64;
5066    let mut current_bpm = score.settings.tempo_bpm as f64;
5067    if let Some(staff) = score.parts.first().and_then(|p| p.staves.first()) {
5068        for &idx in &seq {
5069            if let Some(m) = staff.measures.get(idx) {
5070                if let Some(b) = m.tempo {
5071                    current_bpm = b as f64;
5072                }
5073                if current_bpm == 0.0 {
5074                    continue;
5075                }
5076                let beats = voice_duration_beats(
5077                    &m.voices[0],
5078                    staff.measure_beats(idx, &score.settings.time_signature),
5079                );
5080                total_secs += tempo_ramp_duration_secs(current_bpm, m.tempo_ramp_to, beats);
5081                if let Some(target) = m.tempo_ramp_to.filter(|target| *target > 0) {
5082                    current_bpm = f64::from(target);
5083                }
5084            }
5085        }
5086    }
5087    total_secs + super::playback::fermata_total_secs(score, Some(region))
5088}
5089
5090fn tempo_ramp_duration_secs(start_bpm: f64, target_bpm: Option<u16>, beats: f64) -> f64 {
5091    let Some(target_bpm) = target_bpm.filter(|target| *target > 0) else {
5092        return beats / start_bpm * 60.0;
5093    };
5094    let end_bpm = f64::from(target_bpm);
5095    let delta = end_bpm - start_bpm;
5096    if delta.abs() < f64::EPSILON {
5097        return beats / start_bpm * 60.0;
5098    }
5099    60.0 * beats / delta * (end_bpm / start_bpm).ln()
5100}
5101
5102/// Return the number of beats available in a voice before it is full.
5103///
5104/// Uses [`Note::beats`] which correctly handles tuplet scaling.
5105/// Returns `Ok(0.0)` when the voice is already full or over-full.
5106pub fn measure_beats_remaining(
5107    score: &Score,
5108    part_index: usize,
5109    staff_index: usize,
5110    measure_index: usize,
5111    voice_index: usize,
5112) -> Result<f64, Error> {
5113    let part = score
5114        .parts
5115        .get(part_index)
5116        .ok_or(Error::PartNotFound(part_index))?;
5117    let staff = part
5118        .staves
5119        .get(staff_index)
5120        .ok_or(Error::StaffNotFound(staff_index))?;
5121    let measure = staff
5122        .measures
5123        .get(measure_index)
5124        .ok_or(Error::MeasureNotFound(measure_index))?;
5125    let voice = measure
5126        .voices
5127        .get(voice_index)
5128        .ok_or(Error::VoiceOutOfRange(voice_index))?;
5129    let capacity = staff.measure_beats(measure_index, &score.settings.time_signature);
5130    Ok((capacity - voice_duration_beats(voice, capacity)).max(0.0))
5131}
5132
5133/// Suggest whether the stem should point up for the given pitches and clef.
5134///
5135/// Conventional rule: if the average MIDI pitch of the chord is below the staff
5136/// middle line, the stem points up; at or above, it points down.
5137/// For empty pitch lists (rests), returns `true` by convention.
5138pub fn suggested_stem_up(pitches: &[Pitch], clef: &Clef) -> bool {
5139    if pitches.is_empty() {
5140        return true;
5141    }
5142    let avg = pitches.iter().map(|p| p.to_midi() as f64).sum::<f64>() / pitches.len() as f64;
5143    avg < clef.middle_line_midi() as f64
5144}
5145
5146fn beam_beat_size(ts: &TimeSignature) -> f64 {
5147    if ts.numerator.is_multiple_of(3) && ts.numerator >= 6 && ts.denominator >= 8 {
5148        3.0 * 4.0 / ts.denominator as f64
5149    } else {
5150        4.0 / ts.denominator as f64
5151    }
5152}
5153
5154/// Compute recommended [`BeamState`] values for a voice's notes.
5155///
5156/// Groups beamable notes (eighth or shorter, non-rest) within beat boundaries.
5157/// Returns a `Vec` the same length as `notes`.
5158pub fn compute_beams(notes: &[Note], time_sig: &TimeSignature) -> Vec<BeamState> {
5159    let beat_size = beam_beat_size(time_sig);
5160    let n = notes.len();
5161    let mut result = vec![BeamState::None; n];
5162
5163    let is_beamable = |note: &Note| -> bool {
5164        !note.is_rest
5165            && matches!(
5166                note.duration,
5167                Duration::Eighth
5168                    | Duration::Sixteenth
5169                    | Duration::ThirtySecond
5170                    | Duration::SixtyFourth
5171                    | Duration::HundredTwentyEighth
5172            )
5173    };
5174
5175    // Compute beat start positions
5176    let mut starts = Vec::with_capacity(n);
5177    let mut pos = 0.0f64;
5178    for note in notes {
5179        starts.push(pos);
5180        pos += note.beats();
5181    }
5182
5183    // A tuplet can end exactly on a beat while the accumulated f64 start is a
5184    // few ulps below it (15 copies of 1/15, for example). Snap only starts
5185    // within a much smaller interval than any supported note value.
5186    let group_id = |i: usize| -> i64 {
5187        let beat = starts[i] / beat_size;
5188        let nearest = beat.round();
5189        if (beat - nearest).abs() <= 1e-9 {
5190            nearest as i64
5191        } else {
5192            beat.floor() as i64
5193        }
5194    };
5195
5196    let mut i = 0;
5197    while i < n {
5198        if !is_beamable(&notes[i]) {
5199            i += 1;
5200            continue;
5201        }
5202        let g = group_id(i);
5203        // Find the run of beamable notes in the same beat group
5204        let mut j = i;
5205        while j < n && is_beamable(&notes[j]) && group_id(j) == g {
5206            j += 1;
5207        }
5208        let run = j - i;
5209        if run == 1 {
5210            result[i] = BeamState::None;
5211        } else {
5212            result[i] = BeamState::Begin;
5213            result[i + 1..j - 1].fill(BeamState::Continue);
5214            result[j - 1] = BeamState::End;
5215        }
5216        i = j;
5217    }
5218    result
5219}
5220
5221fn note_content_eq(a: &Note, b: &Note) -> bool {
5222    a.is_rest == b.is_rest
5223        && a.is_unpitched == b.is_unpitched
5224        && a.instrument_id == b.instrument_id
5225        && a.offset_x == b.offset_x
5226        && a.offset_y == b.offset_y
5227        && a.relative_x == b.relative_x
5228        && a.relative_y == b.relative_y
5229        && a.pitches == b.pitches
5230        && a.duration == b.duration
5231        && a.dot_count == b.dot_count
5232        && a.tie_start == b.tie_start
5233        && a.tie_end == b.tie_end
5234        && a.beam == b.beam
5235        && a.explicit_unbeamed == b.explicit_unbeamed
5236        && a.articulations == b.articulations
5237        && a.dynamic == b.dynamic
5238        && a.stem_up == b.stem_up
5239        && a.hairpin_start == b.hairpin_start
5240        && a.hairpin_end == b.hairpin_end
5241        && a.tuplet == b.tuplet
5242        && a.chord_symbol == b.chord_symbol
5243        && a.is_grace == b.is_grace
5244        && a.grace_slash == b.grace_slash
5245        && a.ottava_start == b.ottava_start
5246        && a.ottava_end == b.ottava_end
5247        && a.lyric == b.lyric
5248        && a.additional_lyrics == b.additional_lyrics
5249        && a.pedal_start == b.pedal_start
5250        && a.pedal_end == b.pedal_end
5251        && a.slur_start == b.slur_start
5252        && a.slur_end == b.slur_end
5253        && a.arpeggiate == b.arpeggiate
5254        && a.tab_position == b.tab_position
5255        && a.tab_positions == b.tab_positions
5256        && a.guitar_technique == b.guitar_technique
5257        && a.guitar_bend_alter_cents == b.guitar_bend_alter_cents
5258        && a.guitar_bend_curve == b.guitar_bend_curve
5259}
5260
5261fn per_pitch_flag(flags: &[bool], pitch_count: usize, index: usize, chord: bool) -> bool {
5262    if flags.len() == pitch_count {
5263        flags.get(index).copied().unwrap_or(chord)
5264    } else {
5265        chord
5266    }
5267}
5268
5269#[cfg(test)]
5270mod tests {
5271    #[test]
5272    fn ottava_interval_covers_other_voices_only_at_matching_times() {
5273        use super::*;
5274        use crate::model::{duration::Duration, pitch::Step};
5275        let mut score = Score::new("Ottava", 120, 4, 4, 0, 2);
5276        let note = || Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
5277        let staff = &mut score.parts[0].staves[0];
5278        let mut start = note();
5279        start.ottava_start = Some(OttavaKind::Va8);
5280        staff.measures[0].voices[0] = vec![Note::rest(Duration::Half), start, note()];
5281        let mut end = note();
5282        end.ottava_end = true;
5283        staff.measures[1].voices[0] = vec![end, note()];
5284        staff.measures[0].voices[1] = vec![note(), note(), note(), note()];
5285        staff.measures[1].voices[1] = vec![note(), note()];
5286        let shifts = staff_ottava_octave_shifts(staff, &score.settings.time_signature);
5287        assert_eq!(shifts.get(&(0, 1, 1)), None);
5288        assert_eq!(shifts.get(&(0, 1, 2)), Some(&1));
5289        assert_eq!(shifts.get(&(0, 1, 3)), Some(&1));
5290        assert_eq!(shifts.get(&(1, 1, 0)), Some(&1));
5291        assert_eq!(shifts.get(&(1, 1, 1)), None);
5292    }
5293
5294    use super::*;
5295    use crate::model::{
5296        notation::{FingeringSelectionPolicy, TextStyle},
5297        pitch::Step,
5298    };
5299
5300    #[test]
5301    fn fingering_selection_policy_is_deterministic_and_non_mutating() {
5302        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
5303        note.fingerings = vec![3, 1, 4];
5304        note.fingering = Some(3);
5305        assert_eq!(
5306            note.select_fingering(FingeringSelectionPolicy::SourceOrder),
5307            Some(3)
5308        );
5309        assert_eq!(
5310            note.select_fingering(FingeringSelectionPolicy::LowestNumber),
5311            Some(1)
5312        );
5313        assert_eq!(
5314            note.select_fingering(FingeringSelectionPolicy::HighestNumber),
5315            Some(4)
5316        );
5317        assert_eq!(note.fingerings, vec![3, 1, 4]);
5318        assert_eq!(note.fingering, Some(3));
5319    }
5320
5321    #[test]
5322    fn default_score_has_one_part_four_measures() {
5323        let score = Score::default();
5324        assert_eq!(score.parts.len(), 1);
5325        assert_eq!(score.parts[0].staves.len(), 1);
5326        assert_eq!(score.parts[0].staves[0].measures.len(), 4);
5327    }
5328
5329    #[test]
5330    fn assign_tablature_positions_is_capo_aware_and_preserves_explicit_positions() {
5331        let mut score = Score::new("Guitar", 120, 4, 4, 0, 1);
5332        score.parts[0].staves[0].tablature = Some(TablatureConfig {
5333            lines: 6,
5334            tuning_midi: vec![64, 59, 55, 50, 45, 40],
5335            capo: 2,
5336        });
5337        score.parts[0].staves[0].measures[0].voices[0].push(Note::new(
5338            Pitch::with_alter(Step::F, 4, 1),
5339            Duration::Quarter,
5340        ));
5341        score.parts[0].staves[0].measures[0].voices[0]
5342            .push(Note::new(Pitch::new(Step::G, 3), Duration::Quarter));
5343        score.parts[0].staves[0].measures[0].voices[0][2].tab_position =
5344            Some(TabPosition { string: 6, fret: 7 });
5345
5346        assert_eq!(assign_tablature_positions(&mut score), 1);
5347        let notes = &score.parts[0].staves[0].measures[0].voices[0];
5348        assert_eq!(
5349            notes[1].tab_position,
5350            Some(TabPosition { string: 1, fret: 0 })
5351        );
5352        assert_eq!(notes[1].string_number, Some(1));
5353        assert_eq!(
5354            notes[2].tab_position,
5355            Some(TabPosition { string: 6, fret: 7 })
5356        );
5357    }
5358
5359    #[test]
5360    fn assign_tablature_positions_uses_measure_local_capo_change() {
5361        let mut score = Score::new("Guitar", 120, 4, 4, 0, 2);
5362        score.parts[0].staves[0].tablature = Some(TablatureConfig {
5363            lines: 6,
5364            tuning_midi: vec![40, 45, 50, 55, 59, 64],
5365            capo: 0,
5366        });
5367        score.parts[0].staves[0].measures[1].tablature_change = Some(TablatureConfig {
5368            lines: 6,
5369            tuning_midi: vec![40, 45, 50, 55, 59, 64],
5370            capo: 2,
5371        });
5372        score.parts[0].staves[0].measures[1].voices[0]
5373            .push(Note::new(Pitch::new(Step::E, 4), Duration::Quarter));
5374
5375        assert_eq!(assign_tablature_positions(&mut score), 1);
5376        assert_eq!(
5377            score.parts[0].staves[0].measures[1].voices[0][1].tab_position,
5378            Some(TabPosition { string: 5, fret: 3 })
5379        );
5380    }
5381
5382    #[test]
5383    fn assign_tablature_positions_optimizes_chord_strings_and_fret_span() {
5384        let mut score = Score::new("Guitar", 120, 4, 4, 0, 1);
5385        score.parts[0].staves[0].tablature = Some(TablatureConfig {
5386            lines: 6,
5387            tuning_midi: vec![64, 59, 55, 50, 45, 40],
5388            capo: 0,
5389        });
5390        let mut chord = Note::new(Pitch::new(Step::E, 4), Duration::Quarter);
5391        chord.pitches.push(Pitch::new(Step::G, 4));
5392        score.parts[0].staves[0].measures[0].voices[0].push(chord);
5393
5394        assert_eq!(assign_tablature_positions(&mut score), 1);
5395        let positions = &score.parts[0].staves[0].measures[0].voices[0][1].tab_positions;
5396        assert_eq!(
5397            positions,
5398            &vec![
5399                TabPosition { string: 2, fret: 5 },
5400                TabPosition { string: 1, fret: 3 },
5401            ]
5402        );
5403    }
5404
5405    #[test]
5406    fn optimized_tablature_respects_an_authored_anchor_across_measures() {
5407        let mut score = Score::new("Two-string bass", 120, 4, 4, 0, 2);
5408        score.parts[0].staves[0].tablature = Some(TablatureConfig {
5409            lines: 2,
5410            tuning_midi: vec![40, 45],
5411            capo: 0,
5412        });
5413        let a2 = Pitch::new(Step::A, 2);
5414        let mut anchor = Note::new(a2.clone(), Duration::Quarter);
5415        anchor.tab_position = Some(TabPosition { string: 1, fret: 5 });
5416        score.parts[0].staves[0].measures[0].voices[0] = vec![anchor];
5417        score.parts[0].staves[0].measures[1].voices[0] = vec![
5418            Note::rest(Duration::Quarter),
5419            Note::new(a2, Duration::Quarter),
5420        ];
5421
5422        assert_eq!(optimize_tablature_positions(&mut score), 1);
5423        let anchor = &score.parts[0].staves[0].measures[0].voices[0][0];
5424        let assigned = &score.parts[0].staves[0].measures[1].voices[0][1];
5425        assert_eq!(
5426            anchor.tab_position,
5427            Some(TabPosition { string: 1, fret: 5 })
5428        );
5429        // The open second string has a lower fret but would require a larger hand move.
5430        assert_eq!(
5431            assigned.tab_position,
5432            Some(TabPosition { string: 1, fret: 5 })
5433        );
5434        assert_eq!(assigned.string_number, Some(1));
5435        assert_eq!(
5436            assigned.tab_positions,
5437            vec![TabPosition { string: 1, fret: 5 }]
5438        );
5439        assert_eq!(optimize_tablature_positions(&mut score), 0);
5440    }
5441
5442    #[test]
5443    fn optimized_tablature_assigns_distinct_strings_to_a_chord() {
5444        let mut score = Score::new("Guitar chord", 120, 4, 4, 0, 1);
5445        score.parts[0].staves[0].tablature = Some(TablatureConfig {
5446            lines: 6,
5447            tuning_midi: vec![64, 59, 55, 50, 45, 40],
5448            capo: 0,
5449        });
5450        let mut chord = Note::new(Pitch::new(Step::E, 4), Duration::Quarter);
5451        chord.pitches.push(Pitch::new(Step::G, 4));
5452        score.parts[0].staves[0].measures[0].voices[0] = vec![chord];
5453
5454        assert_eq!(optimize_tablature_positions(&mut score), 1);
5455        let assigned = &score.parts[0].staves[0].measures[0].voices[0][0];
5456        assert_eq!(assigned.tab_positions.len(), 2);
5457        assert_ne!(
5458            assigned.tab_positions[0].string,
5459            assigned.tab_positions[1].string
5460        );
5461        for (pitch, position) in assigned.pitches.iter().zip(&assigned.tab_positions) {
5462            let open = score.parts[0].staves[0]
5463                .tablature
5464                .as_ref()
5465                .unwrap()
5466                .tuning_midi[usize::from(position.string - 1)];
5467            assert_eq!(pitch.to_midi(), open + i16::from(position.fret));
5468        }
5469    }
5470
5471    #[test]
5472    fn new_score_measure_count() {
5473        let score = Score::new("Test", 120, 4, 4, 0, 8);
5474        assert_eq!(score.measure_count(), 8);
5475    }
5476
5477    #[test]
5478    fn note_beats_quarter() {
5479        let note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
5480        assert!((note.beats() - 1.0).abs() < 1e-9);
5481    }
5482
5483    #[test]
5484    fn note_beats_dotted_quarter() {
5485        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
5486        note.dot_count = 1;
5487        assert!((note.beats() - 1.5).abs() < 1e-9);
5488    }
5489
5490    #[test]
5491    fn grace_note_beats_zero() {
5492        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Eighth);
5493        note.is_grace = true;
5494        assert_eq!(note.beats(), 0.0);
5495    }
5496
5497    #[test]
5498    fn measure_empty_4_4_fills_four_beats() {
5499        let m = Measure::empty(4, 4);
5500        let total: f64 = m.voices[0].iter().map(|n| n.beats()).sum();
5501        assert!((total - 4.0).abs() < 1e-9);
5502    }
5503
5504    #[test]
5505    fn measure_empty_3_4_fills_three_beats() {
5506        let m = Measure::empty(3, 4);
5507        let total: f64 = m.voices[0].iter().map(|n| n.beats()).sum();
5508        assert!((total - 3.0).abs() < 1e-9);
5509    }
5510
5511    #[test]
5512    fn whole_filling_beats() {
5513        assert_eq!(Duration::whole_filling_beats(16.0), Duration::Longa);
5514        assert_eq!(Duration::whole_filling_beats(8.0), Duration::Breve);
5515        assert_eq!(Duration::whole_filling_beats(4.0), Duration::Whole);
5516        assert_eq!(Duration::whole_filling_beats(2.0), Duration::Half);
5517        assert_eq!(Duration::whole_filling_beats(1.0), Duration::Quarter);
5518    }
5519
5520    // ── ScoreStats ────────────────────────────────────────────────────────────
5521
5522    #[test]
5523    fn statistics_default_score_all_rests() {
5524        let score = Score::default();
5525        let s = score.statistics();
5526        assert_eq!(s.part_count, 1);
5527        assert_eq!(s.measure_count, 4);
5528        assert_eq!(s.note_count, 0);
5529        assert!(s.rest_count > 0);
5530    }
5531
5532    #[test]
5533    fn statistics_duration_estimate() {
5534        // 4/4, 120 BPM, 1 measure → 4 beats → 2.0 s
5535        let score = Score::new("T", 120, 4, 4, 0, 1);
5536        let s = score.statistics();
5537        assert!((s.estimated_duration_secs - 2.0).abs() < 0.01);
5538    }
5539
5540    #[test]
5541    fn score_duration_secs_matches_statistics() {
5542        use super::score_duration_secs;
5543        let score = Score::new("T", 120, 4, 4, 0, 4);
5544        let secs = score_duration_secs(&score);
5545        // 4/4, 120 BPM, 4 measures → 16 beats → 8.0 s
5546        assert!((secs - 8.0).abs() < 0.01, "expected ~8.0 s, got {secs}");
5547    }
5548
5549    #[test]
5550    fn score_duration_secs_integrates_measure_tempo_ramp() {
5551        use super::score_duration_secs;
5552        let mut score = Score::new("Ramp", 120, 4, 4, 0, 1);
5553        score.parts[0].staves[0].measures[0].tempo_ramp_to = Some(60);
5554        let expected = 4.0 * 60.0 / (60.0 - 120.0) * (60.0f64 / 120.0).ln();
5555        assert!((score_duration_secs(&score) - expected).abs() < 1e-9);
5556    }
5557
5558    #[test]
5559    fn score_duration_secs_zero_bpm_returns_zero() {
5560        use super::score_duration_secs;
5561        let mut score = Score::new("T", 120, 4, 4, 0, 1);
5562        score.settings.tempo_bpm = 0;
5563        assert_eq!(score_duration_secs(&score), 0.0);
5564    }
5565
5566    #[test]
5567    fn score_duration_secs_per_measure_tempo() {
5568        use super::score_duration_secs;
5569        // 2 measures: measure 0 at 120 BPM (2.0 s), measure 1 at 60 BPM (4.0 s)
5570        let mut score = Score::new("T", 120, 4, 4, 0, 2);
5571        score.parts[0].staves[0].measures[1].tempo = Some(60);
5572        let secs = score_duration_secs(&score);
5573        assert!((secs - 6.0).abs() < 0.01, "expected ~6.0 s, got {secs}");
5574    }
5575
5576    // ── extract_part ──────────────────────────────────────────────────────────
5577
5578    #[test]
5579    fn extract_part_returns_single_part_score() {
5580        let mut score = Score::default();
5581        let mut p2 = Part::new("Violin", "Vln.");
5582        p2.staves.push(Staff::new(Clef::Treble));
5583        score.parts.push(p2);
5584        let ex = score.extract_part(0).unwrap();
5585        assert_eq!(ex.parts.len(), 1);
5586        assert_ne!(ex.id, score.id);
5587        assert_eq!(ex.metadata.title, score.metadata.title);
5588    }
5589
5590    #[test]
5591    fn extract_part_out_of_range_is_none() {
5592        let score = Score::default();
5593        assert!(score.extract_part(99).is_none());
5594    }
5595
5596    #[test]
5597    fn extract_and_merge_remap_typed_spanner_part_addresses() {
5598        let mut left = Score::template(ScoreTemplate::StringQuartet);
5599        let address = NoteAddr {
5600            part: 1,
5601            staff: 0,
5602            measure: 0,
5603            voice: 0,
5604            note: 0,
5605        };
5606        left.spanners.push(NotationSpanner {
5607            id: "left-span".to_string(),
5608            kind: NotationSpannerKind::Slur,
5609            start: address.clone(),
5610            end: address,
5611            number: Some(1),
5612            line_type: None,
5613            text: None,
5614            placement: None,
5615            ottava_size: None,
5616            ottava_type: None,
5617        });
5618        let extracted = left.extract_part_checked(1).expect("valid extracted part");
5619        assert_eq!(extracted.spanners[0].start.part, 0);
5620        assert_eq!(extracted.spanners[0].end.part, 0);
5621
5622        let mut right = Score::new("R", 120, 4, 4, 0, 1);
5623        let right_address = NoteAddr {
5624            part: 0,
5625            staff: 0,
5626            measure: 0,
5627            voice: 0,
5628            note: 0,
5629        };
5630        right.spanners.push(NotationSpanner {
5631            id: "right-span".to_string(),
5632            kind: NotationSpannerKind::Pedal,
5633            start: right_address.clone(),
5634            end: right_address,
5635            number: Some(1),
5636            line_type: None,
5637            text: None,
5638            placement: None,
5639            ottava_size: None,
5640            ottava_type: None,
5641        });
5642        let merged = left.merge_checked(&right).expect("valid merged score");
5643        let right_span = merged
5644            .spanners
5645            .iter()
5646            .find(|spanner| spanner.id == "right-span")
5647            .expect("merged right span");
5648        assert_eq!(right_span.start.part, left.parts.len());
5649        assert_eq!(right_span.end.part, left.parts.len());
5650    }
5651
5652    // ── transpose ─────────────────────────────────────────────────────────────
5653
5654    #[test]
5655    fn transpose_zero_is_clone() {
5656        let score = Score::new("T", 120, 4, 4, 0, 1);
5657        let t = transpose(&score, 0);
5658        assert_eq!(t.settings.key_signature.fifths, 0);
5659    }
5660
5661    #[test]
5662    fn transpose_staff_region_rewrites_only_selected_written_measures() {
5663        let mut score = Score::new("T", 120, 4, 4, 0, 2);
5664        for measure in &mut score.parts[0].staves[0].measures {
5665            measure.voices[0] = vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
5666        }
5667        let transformed = transpose_staff_region_checked(
5668            &score,
5669            0,
5670            0,
5671            1,
5672            2,
5673            2,
5674            RegionalTranspositionTarget::Written,
5675        )
5676        .expect("region should transpose");
5677        assert_eq!(
5678            transformed.parts[0].staves[0].measures[0].voices[0][0].pitches[0],
5679            Pitch::new(Step::C, 4)
5680        );
5681        assert_eq!(
5682            transformed.parts[0].staves[0].measures[1].voices[0][0].pitches[0],
5683            Pitch::new(Step::D, 4)
5684        );
5685        assert_eq!(
5686            score.parts[0].staves[0].measures[1].voices[0][0].pitches[0],
5687            Pitch::new(Step::C, 4)
5688        );
5689    }
5690
5691    #[test]
5692    fn regional_concert_transposition_requires_full_staff() {
5693        let score = Score::new("T", 120, 4, 4, 0, 2);
5694        assert!(
5695            transpose_staff_region_checked(
5696                &score,
5697                0,
5698                0,
5699                1,
5700                2,
5701                -2,
5702                RegionalTranspositionTarget::Concert,
5703            )
5704            .is_err()
5705        );
5706    }
5707
5708    #[test]
5709    fn transpose_c_major_up_2_to_d_major() {
5710        let score = Score::new("T", 120, 4, 4, 0, 1);
5711        assert_eq!(transpose(&score, 2).settings.key_signature.fifths, 2);
5712    }
5713
5714    #[test]
5715    fn transpose_d_major_up_5_to_g_major() {
5716        let score = Score::new("T", 120, 4, 4, 2, 1);
5717        assert_eq!(transpose(&score, 5).settings.key_signature.fifths, 1);
5718    }
5719
5720    #[test]
5721    fn transpose_c4_up_1_to_csharp4() {
5722        let p = transpose_pitch(&Pitch::new(Step::C, 4), 1);
5723        assert_eq!(p.to_midi(), 61);
5724        assert_eq!(p.step, Step::C);
5725        assert_eq!(p.alter, 1);
5726    }
5727
5728    #[test]
5729    fn transpose_c4_down_1_to_b3() {
5730        let p = transpose_pitch(&Pitch::new(Step::C, 4), -1);
5731        assert_eq!(p.to_midi(), 59);
5732        assert_eq!(p.step, Step::B);
5733        assert_eq!(p.alter, 0);
5734    }
5735
5736    #[test]
5737    fn transpose_up_octave_keeps_step() {
5738        let p = transpose_pitch(&Pitch::new(Step::A, 4), 12);
5739        assert_eq!(p.to_midi(), 81);
5740        assert_eq!(p.step, Step::A);
5741        assert_eq!(p.octave, 5);
5742    }
5743
5744    #[test]
5745    fn statistics_with_repeat_doubles_duration() {
5746        // 4/4, 120 BPM, 2 measures with RepeatStart+RepeatEnd → plays twice → 4 measures worth
5747        let mut score = Score::new("T", 120, 4, 4, 0, 2);
5748        score.parts[0].staves[0].measures[0].barline_left =
5749            crate::model::notation::Barline::RepeatStart;
5750        score.parts[0].staves[0].measures[1].barline_right =
5751            crate::model::notation::Barline::RepeatEnd;
5752        let s = score.statistics();
5753        // 4 beats × 4 measures (2 physical × 2 passes) ÷ 120 BPM × 60 = 8.0 s
5754        assert!((s.estimated_duration_secs - 8.0).abs() < 0.01);
5755    }
5756
5757    #[test]
5758    fn transpose_octave_boundary_b4_to_c5() {
5759        // B4 (midi=71) + 1 semitone = C5 (midi=72)
5760        let p = transpose_pitch(&Pitch::new(Step::B, 4), 1);
5761        assert_eq!(p.to_midi(), 72);
5762        assert_eq!(p.step, Step::C);
5763        assert_eq!(p.octave, 5);
5764    }
5765
5766    #[test]
5767    fn transpose_clamp_at_midi_127() {
5768        // G9 (midi=127) + 3 semitones → clamped to 127
5769        let p = transpose_pitch(&Pitch::new(Step::G, 9), 3);
5770        assert_eq!(p.to_midi(), 127);
5771    }
5772
5773    // ── merge ─────────────────────────────────────────────────────────────────
5774
5775    #[test]
5776    fn merge_combines_parts() {
5777        let mut a = Score::new("A", 120, 4, 4, 0, 2);
5778        let b = Score::new("B", 120, 4, 4, 0, 2);
5779        // Add a second part to score a
5780        let mut p2 = Part::new("Violin", "Vln.");
5781        p2.staves.push(Staff::new(Clef::Treble));
5782        for i in 0..2usize {
5783            let mut m = Measure::empty(4, 4);
5784            m.number = i as u32 + 1;
5785            p2.staves[0].measures.push(m);
5786        }
5787        a.parts.push(p2);
5788        let merged = a.merge(&b);
5789        // a has 2 parts, b has 1 part → merged has 3 parts
5790        assert_eq!(merged.parts.len(), 3);
5791    }
5792
5793    #[test]
5794    fn merge_pads_shorter_score() {
5795        let a = Score::new("A", 120, 4, 4, 0, 4);
5796        let b = Score::new("B", 120, 4, 4, 0, 2);
5797        let merged = a.merge(&b);
5798        // Both parts should have 4 measures
5799        assert_eq!(merged.parts[0].staves[0].measures.len(), 4);
5800        assert_eq!(merged.parts[1].staves[0].measures.len(), 4);
5801    }
5802
5803    #[test]
5804    fn merge_uses_self_metadata() {
5805        let mut a = Score::new("Title A", 120, 4, 4, 0, 2);
5806        a.metadata.composer = "Composer A".to_string();
5807        let b = Score::new("Title B", 120, 4, 4, 0, 2);
5808        let merged = a.merge(&b);
5809        assert_eq!(merged.metadata.title, "Title A");
5810        assert_eq!(merged.metadata.composer, "Composer A");
5811    }
5812
5813    #[test]
5814    fn merge_new_id_differs_from_both() {
5815        let a = Score::new("A", 120, 4, 4, 0, 2);
5816        let b = Score::new("B", 120, 4, 4, 0, 2);
5817        let merged = a.merge(&b);
5818        assert_ne!(merged.id, a.id);
5819        assert_ne!(merged.id, b.id);
5820    }
5821
5822    // ── Staff.transpose_semitones ─────────────────────────────────────────────
5823
5824    #[test]
5825    fn staff_default_transpose_is_zero() {
5826        let s = Staff::new(Clef::Treble);
5827        assert_eq!(s.transpose_semitones, 0);
5828    }
5829
5830    #[test]
5831    fn staff_presentation_defaults_and_tracks_percussion_clef() {
5832        let standard = Staff::new(Clef::Treble);
5833        assert_eq!(standard.presentation, StaffPresentation::default());
5834
5835        let percussion = Staff::new(Clef::Percussion);
5836        assert_eq!(percussion.presentation.kind, StaffKind::Percussion);
5837        assert_eq!(percussion.presentation.lines, 5);
5838        assert!(percussion.presentation.visible);
5839    }
5840
5841    // ── schema_version ────────────────────────────────────────────────────────
5842
5843    #[test]
5844    fn score_default_has_schema_version_1() {
5845        let score = Score::default();
5846        assert_eq!(score.schema_version, 1);
5847    }
5848
5849    #[test]
5850    fn score_new_has_schema_version_1() {
5851        let score = Score::new("T", 120, 4, 4, 0, 4);
5852        assert_eq!(score.schema_version, 1);
5853    }
5854
5855    #[test]
5856    fn chromatic_transposition_covers_every_pitch_class_and_keeps_microtones() {
5857        for midi in 1..=126 {
5858            let mut original = Pitch::from_midi(midi, false);
5859            original.microtone_cents = 25;
5860            for shift in [-1_i8, 1] {
5861                let shifted = transpose_pitch(&original, shift);
5862                assert_eq!(shifted.to_midi(), i16::from(midi) + i16::from(shift));
5863                assert_eq!(shifted.microtone_cents, 25);
5864                assert!(if shift > 0 {
5865                    shifted.alter >= 0
5866                } else {
5867                    shifted.alter <= 0
5868                });
5869            }
5870        }
5871    }
5872
5873    #[test]
5874    fn score_without_schema_version_deserializes_to_zero() {
5875        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":[]}"#;
5876        let score: Score = serde_json::from_str(json).unwrap();
5877        assert_eq!(score.schema_version, 0);
5878    }
5879
5880    #[test]
5881    fn legacy_score_json_defaults_typed_spanners() {
5882        let score = Score::new("Legacy", 120, 4, 4, 0, 1);
5883        let mut value = serde_json::to_value(score).expect("score serializes");
5884        value
5885            .as_object_mut()
5886            .expect("score is an object")
5887            .remove("spanners");
5888        let restored: Score = serde_json::from_value(value).expect("legacy score deserializes");
5889        assert!(restored.spanners.is_empty());
5890    }
5891
5892    #[test]
5893    fn legacy_staff_json_defaults_presentation() {
5894        let score = Score::new("Legacy", 120, 4, 4, 0, 1);
5895        let mut value = serde_json::to_value(score).expect("score serializes");
5896        value["parts"][0]["staves"][0]
5897            .as_object_mut()
5898            .expect("staff is an object")
5899            .remove("presentation");
5900
5901        let restored: Score = serde_json::from_value(value).expect("legacy score deserializes");
5902        assert_eq!(
5903            restored.parts[0].staves[0].presentation,
5904            StaffPresentation::default()
5905        );
5906    }
5907
5908    #[test]
5909    fn legacy_part_json_defaults_instrument_definition() {
5910        let score = Score::new("Legacy", 120, 4, 4, 0, 1);
5911        let mut value = serde_json::to_value(score).expect("score serializes");
5912        value["parts"][0]
5913            .as_object_mut()
5914            .expect("part is an object")
5915            .remove("instrument");
5916
5917        let restored: Score = serde_json::from_value(value).expect("legacy score deserializes");
5918        assert!(restored.parts[0].instrument.is_none());
5919    }
5920
5921    #[test]
5922    fn resolve_view_projects_linked_part_without_mutating_source_score() {
5923        let mut score = Score::template(ScoreTemplate::StringQuartet);
5924        let mut view = ScoreView::linked_part("violin-2", "Violin II", 1);
5925        view.layout.measures_per_row = Some(2);
5926        score.views.push(view);
5927
5928        let projected = score.resolve_view("violin-2").expect("view resolves");
5929        assert_eq!(projected.parts.len(), 1);
5930        assert_eq!(projected.parts[0].name, "Violin II");
5931        assert_eq!(projected.views.len(), 1);
5932        assert_eq!(projected.views[0].parts, vec![0]);
5933        assert_eq!(score.parts.len(), 4);
5934        assert_eq!(score.views[0].parts, vec![1]);
5935    }
5936
5937    #[test]
5938    fn linked_view_checks_staff_references_and_projects_part_groups() {
5939        let mut score = Score::template(ScoreTemplate::StringQuartet);
5940        score.part_groups.push(PartGroup {
5941            first_part: 1,
5942            last_part: 2,
5943            symbol: PartGroupSymbol::Bracket,
5944            barlines_connect: true,
5945        });
5946        let mut view = ScoreView::linked_standard_staff("middle", "Middle voices", 1, 0);
5947        view.parts.push(2);
5948        view.layout
5949            .hidden_staves
5950            .push(ViewStaffRef { part: 2, staff: 0 });
5951        score.views.push(view);
5952        let before = serde_json::to_value(&score).unwrap();
5953        let projected = score.resolve_view("middle").unwrap();
5954        assert_eq!(projected.parts.len(), 2);
5955        assert_eq!(
5956            (
5957                projected.part_groups[0].first_part,
5958                projected.part_groups[0].last_part
5959            ),
5960            (0, 1)
5961        );
5962        assert_eq!(projected.views[0].layout.hidden_staves[0].part, 1);
5963        assert_eq!(serde_json::to_value(&score).unwrap(), before);
5964
5965        let mut invalid = score.clone();
5966        invalid.views[0].parts.push(1);
5967        assert!(invalid.resolve_view("middle").is_err());
5968        invalid.views[0].parts.pop();
5969        invalid.views[0].layout.hidden_staves[0].part = 3;
5970        assert!(invalid.resolve_view("middle").is_err());
5971        invalid.views[0].layout.hidden_staves[0].part = 2;
5972        invalid.views[0].staff_kind_overrides[0].staff.staff = 9;
5973        assert!(invalid.resolve_view("middle").is_err());
5974        assert!(score.resolve_view("missing").is_err());
5975    }
5976
5977    #[test]
5978    fn resolve_view_applies_linked_standard_and_tablature_presentations_non_destructively() {
5979        let mut score = Score::new("Guitar", 120, 4, 4, 0, 1);
5980        score.parts[0].staves[0].tablature = Some(TablatureConfig {
5981            lines: 6,
5982            tuning_midi: vec![40, 45, 50, 55, 59, 64],
5983            capo: 0,
5984        });
5985        score.parts[0].staves[0].presentation.kind = StaffKind::Standard;
5986        score.views.push(ScoreView::linked_tablature_staff(
5987            "guitar-tab",
5988            "Guitar Tab",
5989            0,
5990            0,
5991        ));
5992
5993        let projected = score.resolve_view("guitar-tab").expect("view resolves");
5994        assert_eq!(
5995            projected.parts[0].staves[0].presentation.kind,
5996            StaffKind::Tablature
5997        );
5998        assert_eq!(
5999            projected.views[0].staff_kind_overrides[0].staff,
6000            ViewStaffRef { part: 0, staff: 0 }
6001        );
6002        assert_eq!(
6003            score.parts[0].staves[0].presentation.kind,
6004            StaffKind::Standard
6005        );
6006    }
6007
6008    #[test]
6009    fn legacy_score_view_json_defaults_staff_kind_overrides() {
6010        let mut score = Score::new("Legacy", 120, 4, 4, 0, 1);
6011        score.views.push(ScoreView::linked_part("part", "Part", 0));
6012        let mut value = serde_json::to_value(score).expect("score serializes");
6013        value["views"][0]
6014            .as_object_mut()
6015            .expect("view is an object")
6016            .remove("staff_kind_overrides");
6017
6018        let restored: Score = serde_json::from_value(value).expect("legacy score deserializes");
6019        assert!(restored.views[0].staff_kind_overrides.is_empty());
6020    }
6021
6022    #[test]
6023    fn typed_view_style_overrides_are_ordered_and_json_compatible() {
6024        let mut view = ScoreView::linked_part("part", "Part", 0);
6025        view.layout.typed_style_overrides = vec![
6026            ViewStyleOverride {
6027                property: ViewStyleProperty::TextScale,
6028                value: 0.9,
6029            },
6030            ViewStyleOverride {
6031                property: ViewStyleProperty::TextScale,
6032                value: 1.1,
6033            },
6034        ];
6035        assert_eq!(
6036            view.layout.style_value(ViewStyleProperty::TextScale),
6037            Some(1.1)
6038        );
6039        let restored: ScoreView =
6040            serde_json::from_str(&serde_json::to_string(&view).unwrap()).unwrap();
6041        assert_eq!(
6042            restored.layout.typed_style_overrides,
6043            view.layout.typed_style_overrides
6044        );
6045        assert_eq!(view.layout.resolved_style().text_scale, 1.1);
6046        assert_eq!(view.layout.resolved_style().system_gap, 2.0);
6047    }
6048
6049    #[test]
6050    fn score_style_defaults_are_inherited_then_overridden_by_view() {
6051        let mut score = Score::new("Style", 120, 4, 4, 0, 1);
6052        score.style_overrides = vec![
6053            ViewStyleOverride {
6054                property: ViewStyleProperty::StaffSpace,
6055                value: 1.2,
6056            },
6057            ViewStyleOverride {
6058                property: ViewStyleProperty::TextScale,
6059                value: 0.9,
6060            },
6061        ];
6062        let mut view = ScoreView::linked_part("part", "Part", 0);
6063        view.layout.typed_style_overrides.push(ViewStyleOverride {
6064            property: ViewStyleProperty::TextScale,
6065            value: 1.1,
6066        });
6067        let style = score.resolved_view_style(&view.layout);
6068        assert_eq!(style.staff_space, 1.2);
6069        assert_eq!(style.text_scale, 1.1);
6070        let restored: Score =
6071            serde_json::from_str(&serde_json::to_string(&score).unwrap()).unwrap();
6072        assert_eq!(restored.style_overrides, score.style_overrides);
6073    }
6074
6075    #[test]
6076    fn legacy_measure_json_defaults_source_voice_numbers() {
6077        let score = Score::new("Legacy", 120, 4, 4, 0, 1);
6078        let mut value = serde_json::to_value(&score).expect("score serializes");
6079        value["parts"][0]["staves"][0]["measures"][0]
6080            .as_object_mut()
6081            .expect("measure is an object")
6082            .remove("source_voice_numbers");
6083
6084        let restored: Score = serde_json::from_value(value).expect("legacy score deserializes");
6085        assert_eq!(
6086            restored.parts[0].staves[0].measures[0].source_voice_numbers,
6087            [None; 4]
6088        );
6089    }
6090
6091    #[test]
6092    fn legacy_measure_json_defaults_tempo_ramp() {
6093        let measure: Measure = serde_json::from_str(
6094            r#"{"number":1,"time_sig":null,"key_sig":null,"clef":null,"tempo":120,"barline_left":"Normal","barline_right":"Normal","voices":[[],[],[],[]]}"#,
6095        )
6096        .expect("legacy measure deserializes");
6097        assert_eq!(measure.tempo_ramp_to, None);
6098    }
6099
6100    #[test]
6101    fn note_without_new_percussion_fields_uses_serde_defaults() {
6102        let note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
6103        let mut value = serde_json::to_value(note).unwrap();
6104        let object = value.as_object_mut().unwrap();
6105        object.remove("is_unpitched");
6106        object.remove("instrument_id");
6107        let restored: Note = serde_json::from_value(value).unwrap();
6108        assert!(!restored.is_unpitched);
6109        assert_eq!(restored.instrument_id, None);
6110    }
6111
6112    #[test]
6113    fn percussion_instrument_resolution_prefers_id_then_display_key() {
6114        let mut part = Part::new("Drums", "Dr.");
6115        part.percussion_instruments = vec![
6116            PercussionInstrument {
6117                id: "snare".to_string(),
6118                name: Some("Acoustic Snare".to_string()),
6119                midi_unpitched: Some(38),
6120                staff_position: None,
6121                notehead: None,
6122                preferred_voice: None,
6123                techniques: Vec::new(),
6124            },
6125            PercussionInstrument {
6126                id: "rim".to_string(),
6127                name: Some("Side Stick".to_string()),
6128                midi_unpitched: Some(37),
6129                staff_position: None,
6130                notehead: None,
6131                preferred_voice: None,
6132                techniques: Vec::new(),
6133            },
6134        ];
6135        let mut note = Note::new(Pitch::from_midi(38, false), Duration::Quarter);
6136        note.is_unpitched = true;
6137        assert_eq!(
6138            part.percussion_instrument_for_note(&note)
6139                .map(|instrument| instrument.id.as_str()),
6140            Some("snare")
6141        );
6142        note.instrument_id = Some("rim".to_string());
6143        assert_eq!(
6144            part.percussion_instrument_for_note(&note)
6145                .map(|instrument| instrument.id.as_str()),
6146            Some("rim")
6147        );
6148        note.instrument_id = Some("missing".to_string());
6149        assert!(part.percussion_instrument_for_note(&note).is_none());
6150        note.instrument_id = None;
6151        note.is_unpitched = false;
6152        assert!(part.percussion_instrument_for_note(&note).is_none());
6153    }
6154
6155    #[test]
6156    fn percussion_kit_extensions_are_json_backward_compatible() {
6157        let mut instrument = PercussionInstrument {
6158            id: "snare".to_string(),
6159            name: Some("Acoustic Snare".to_string()),
6160            midi_unpitched: Some(38),
6161            staff_position: Some(0),
6162            notehead: Some(NoteHead::Cross),
6163            preferred_voice: Some(1),
6164            techniques: vec!["rim-shot".to_string()],
6165        };
6166        let mut value = serde_json::to_value(&instrument).expect("instrument serializes");
6167        let object = value
6168            .as_object_mut()
6169            .expect("percussion instrument is an object");
6170        object.remove("staff_position");
6171        object.remove("notehead");
6172        object.remove("preferred_voice");
6173        object.remove("techniques");
6174        instrument = serde_json::from_value(value).expect("legacy instrument deserializes");
6175        assert_eq!(instrument.staff_position, None);
6176        assert_eq!(instrument.notehead, None);
6177        assert_eq!(instrument.preferred_voice, None);
6178        assert!(instrument.techniques.is_empty());
6179    }
6180
6181    // ── ScoreTemplate ─────────────────────────────────────────────────────────
6182
6183    #[test]
6184    fn score_template_solo_has_one_part_treble() {
6185        let score = Score::template(ScoreTemplate::Solo);
6186        assert_eq!(score.parts.len(), 1);
6187        assert_eq!(score.parts[0].staves.len(), 1);
6188        assert_eq!(score.parts[0].staves[0].clef, Clef::Treble);
6189        assert_eq!(score.parts[0].midi_program, 0);
6190    }
6191
6192    #[test]
6193    fn score_template_piano_has_two_staves() {
6194        let score = Score::template(ScoreTemplate::Piano);
6195        assert_eq!(score.parts.len(), 1);
6196        assert_eq!(score.parts[0].staves.len(), 2);
6197        assert_eq!(score.parts[0].staves[0].clef, Clef::Treble);
6198        assert_eq!(score.parts[0].staves[1].clef, Clef::Bass);
6199    }
6200
6201    #[test]
6202    fn score_template_string_quartet_has_four_parts() {
6203        let score = Score::template(ScoreTemplate::StringQuartet);
6204        assert_eq!(score.parts.len(), 4);
6205        assert_eq!(score.parts[2].staves[0].clef, Clef::Alto); // Viola
6206        assert_eq!(score.parts[3].staves[0].clef, Clef::Bass); // Cello
6207        assert_eq!(score.parts[0].midi_program, 40);
6208        assert_eq!(score.parts[3].midi_program, 42);
6209    }
6210
6211    #[test]
6212    fn score_template_string_orchestra_has_five_parts() {
6213        let score = Score::template(ScoreTemplate::StringOrchestra);
6214        assert_eq!(score.parts.len(), 5);
6215        assert_eq!(score.parts[4].midi_program, 43); // Contrabass
6216    }
6217
6218    #[test]
6219    fn score_template_brass_quintet_has_five_parts() {
6220        let score = Score::template(ScoreTemplate::BrassQuintet);
6221        assert_eq!(score.parts.len(), 5);
6222        assert_eq!(score.parts[2].midi_program, 60); // French Horn
6223    }
6224
6225    #[test]
6226    fn score_template_default_measures_are_four() {
6227        let score = Score::template(ScoreTemplate::StringQuartet);
6228        for part in &score.parts {
6229            for staff in &part.staves {
6230                assert_eq!(staff.measures.len(), 4);
6231            }
6232        }
6233    }
6234
6235    // ── system_break / page_break ─────────────────────────────────────────────
6236
6237    #[test]
6238    fn measure_empty_has_no_breaks() {
6239        let m = Measure::empty(4, 4);
6240        assert!(!m.system_break);
6241        assert!(!m.page_break);
6242        assert!(!m.section_break);
6243    }
6244
6245    #[test]
6246    fn section_range_uses_semantic_boundaries_not_layout_breaks() {
6247        let mut score = Score::new("sections", 120, 4, 4, 0, 6);
6248        score.parts[0].staves[0].measures[0].section_break = true;
6249        score.parts[0].staves[0].measures[2].section_break = true;
6250        score.parts[0].staves[0].measures[4].section_break = true;
6251        score.parts[0].staves[0].measures[5].section_break = true;
6252        score.parts[0].staves[0].measures[1].system_break = true;
6253        score.parts[0].staves[0].measures[3].page_break = true;
6254        assert_eq!(score.section_range(0).unwrap(), 0..=1);
6255        assert_eq!(score.section_range(3).unwrap(), 2..=3);
6256        assert_eq!(score.section_range(4).unwrap(), 4..=4);
6257        assert_eq!(score.section_range(5).unwrap(), 5..=5);
6258        assert!(score.section_range(6).is_err());
6259    }
6260
6261    #[test]
6262    fn system_break_survives_json_roundtrip() {
6263        let mut m = Measure::empty(4, 4);
6264        m.system_break = true;
6265        let json = serde_json::to_string(&m).unwrap();
6266        let m2: Measure = serde_json::from_str(&json).unwrap();
6267        assert!(m2.system_break);
6268        assert!(!m2.page_break);
6269    }
6270
6271    // ── diff ──────────────────────────────────────────────────────────────────
6272
6273    #[test]
6274    fn diff_identical_scores_is_empty() {
6275        let s = Score::new("T", 120, 4, 4, 0, 2);
6276        assert!(diff(&s, &s).is_empty());
6277    }
6278
6279    #[test]
6280    fn score_patch_covers_measure_semantics_and_note_insert_index() {
6281        let mut a = Score::new("T", 120, 4, 4, 0, 1);
6282        a.parts[0].staves[0].measures[0].voices[0].clear();
6283        let mut b = a.clone();
6284        let measure = &mut b.parts[0].staves[0].measures[0];
6285        measure.key_sig = Some(KeySignature {
6286            fifths: -2,
6287            mode: "major".to_string(),
6288        });
6289        measure.time_sig = Some(TimeSignature {
6290            numerator: 3,
6291            denominator: 4,
6292        });
6293        measure.barline_left = Barline::RepeatStart;
6294        measure.barline_right = Barline::RepeatEnd;
6295        measure.rehearsal = Some("A".to_string());
6296        measure.volta = Some(VoltaBracket {
6297            number: 1,
6298            kind: "begin_end".to_string(),
6299            numbers: Vec::new(),
6300        });
6301        measure.texts.push(StyledText {
6302            position_beats: None,
6303            style: TextStyle::RehearsalMark,
6304            text: "A".to_string(),
6305            placement: None,
6306            offset_x: None,
6307            offset_y: None,
6308            relative_x: None,
6309            relative_y: None,
6310        });
6311        measure.figured_bass.push(FiguredBassFigure {
6312            number: "6".to_string(),
6313            alter: None,
6314            prefix: None,
6315            suffix: None,
6316            extender: false,
6317        });
6318        measure.harp_pedal_diagrams.push(HarpPedalDiagram {
6319            positions: [
6320                HarpPedalPosition::Flat,
6321                HarpPedalPosition::Natural,
6322                HarpPedalPosition::Sharp,
6323                HarpPedalPosition::Natural,
6324                HarpPedalPosition::Flat,
6325                HarpPedalPosition::Sharp,
6326                HarpPedalPosition::Natural,
6327            ],
6328            placement: Some("above".to_string()),
6329        });
6330        measure.voices[0].insert(0, Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
6331        let expected = b.parts[0].staves[0].measures[0].clone();
6332
6333        let patches = score_patch(&a, &b);
6334        assert!(
6335            patches
6336                .iter()
6337                .any(|p| matches!(p, ScorePatch::SetTimeSignature { .. }))
6338        );
6339        assert!(
6340            patches
6341                .iter()
6342                .any(|p| matches!(p, ScorePatch::SetBarlines { .. }))
6343        );
6344        assert!(
6345            patches
6346                .iter()
6347                .any(|p| matches!(p, ScorePatch::SetRehearsal { .. }))
6348        );
6349        assert!(
6350            patches
6351                .iter()
6352                .any(|p| matches!(p, ScorePatch::SetVolta { .. }))
6353        );
6354        assert!(
6355            patches
6356                .iter()
6357                .any(|p| matches!(p, ScorePatch::SetMeasureTexts { .. }))
6358        );
6359        assert!(
6360            patches
6361                .iter()
6362                .any(|p| matches!(p, ScorePatch::SetFiguredBass { .. }))
6363        );
6364        assert!(
6365            patches
6366                .iter()
6367                .any(|p| matches!(p, ScorePatch::SetHarpPedalDiagrams { .. }))
6368        );
6369        let result = apply_patch(&a, &patches).expect("patch application failed");
6370        let result_measure = &result.parts[0].staves[0].measures[0];
6371        assert_eq!(result_measure.key_sig, expected.key_sig);
6372        assert_eq!(result_measure.time_sig, expected.time_sig);
6373        assert_eq!(result_measure.barline_left, expected.barline_left);
6374        assert_eq!(result_measure.barline_right, expected.barline_right);
6375        assert_eq!(result_measure.rehearsal, expected.rehearsal);
6376        assert_eq!(result_measure.volta, expected.volta);
6377        assert_eq!(result_measure.texts, expected.texts);
6378        assert_eq!(result_measure.figured_bass, expected.figured_bass);
6379        assert_eq!(
6380            result_measure.harp_pedal_diagrams,
6381            expected.harp_pedal_diagrams
6382        );
6383        assert_eq!(result_measure.voices[0].len(), expected.voices[0].len());
6384    }
6385
6386    #[test]
6387    fn malformed_note_patches_do_not_mutate_the_original_score() {
6388        let score = Score::new("Patch boundary", 120, 4, 4, 0, 1);
6389        let before = serde_json::to_value(&score).unwrap();
6390        let note = Box::new(Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
6391        for (part, staff, measure, voice, note_index) in [
6392            (8, 0, 0, 0, 0),
6393            (0, 8, 0, 0, 0),
6394            (0, 0, 8, 0, 0),
6395            (0, 0, 0, 8, 0),
6396            (0, 0, 0, 0, 99),
6397        ] {
6398            let patches = [
6399                ScorePatch::AddNote {
6400                    part,
6401                    staff,
6402                    measure,
6403                    voice,
6404                    note_index,
6405                    note: note.clone(),
6406                },
6407                ScorePatch::RemoveNote {
6408                    part,
6409                    staff,
6410                    measure,
6411                    voice,
6412                    note_index,
6413                },
6414                ScorePatch::ReplaceNote {
6415                    part,
6416                    staff,
6417                    measure,
6418                    voice,
6419                    note_index,
6420                    note: note.clone(),
6421                },
6422            ];
6423            for patch in patches {
6424                assert!(apply_patch(&score, &[patch]).is_err());
6425                assert_eq!(serde_json::to_value(&score).unwrap(), before);
6426            }
6427        }
6428    }
6429
6430    #[test]
6431    fn measure_patches_reject_missing_part_staff_and_measure() {
6432        let score = Score::new("Patch boundary", 120, 4, 4, 0, 1);
6433        let before = serde_json::to_value(&score).unwrap();
6434        for (part, staff, measure) in [(8, 0, 0), (0, 8, 0), (0, 0, 8)] {
6435            let patches = vec![
6436                ScorePatch::SetMeasureTexts {
6437                    part,
6438                    staff,
6439                    measure,
6440                    value: vec![],
6441                },
6442                ScorePatch::SetFiguredBass {
6443                    part,
6444                    staff,
6445                    measure,
6446                    value: vec![],
6447                },
6448                ScorePatch::SetHarpPedalDiagrams {
6449                    part,
6450                    staff,
6451                    measure,
6452                    value: vec![],
6453                },
6454                ScorePatch::SetMeasureTablatureChange {
6455                    part,
6456                    staff,
6457                    measure,
6458                    value: None,
6459                },
6460                ScorePatch::SetMeasureMidClefs {
6461                    part,
6462                    staff,
6463                    measure,
6464                    value: vec![],
6465                },
6466                ScorePatch::SetKeySignature {
6467                    part,
6468                    staff,
6469                    measure,
6470                    value: None,
6471                },
6472                ScorePatch::SetTimeSignature {
6473                    part,
6474                    staff,
6475                    measure,
6476                    value: None,
6477                },
6478                ScorePatch::SetTimeSymbol {
6479                    part,
6480                    staff,
6481                    measure,
6482                    value: None,
6483                },
6484                ScorePatch::SetBarlines {
6485                    part,
6486                    staff,
6487                    measure,
6488                    left: Barline::default(),
6489                    right: Barline::default(),
6490                },
6491                ScorePatch::SetRehearsal {
6492                    part,
6493                    staff,
6494                    measure,
6495                    value: None,
6496                },
6497                ScorePatch::SetVolta {
6498                    part,
6499                    staff,
6500                    measure,
6501                    value: None,
6502                },
6503                ScorePatch::SetMeasureTempo {
6504                    part,
6505                    staff,
6506                    measure,
6507                    value: None,
6508                },
6509                ScorePatch::SetMeasureTempoRamp {
6510                    part,
6511                    staff,
6512                    measure,
6513                    value: None,
6514                },
6515                ScorePatch::SetMeasureMetronomeMark {
6516                    part,
6517                    staff,
6518                    measure,
6519                    value: None,
6520                },
6521            ];
6522            for patch in patches {
6523                assert!(apply_patch(&score, &[patch]).is_err());
6524                assert_eq!(serde_json::to_value(&score).unwrap(), before);
6525            }
6526        }
6527    }
6528
6529    #[test]
6530    fn part_and_staff_patches_reject_missing_targets() {
6531        let score = Score::new("Patch targets", 120, 4, 4, 0, 1);
6532        let original = serde_json::to_value(&score).unwrap();
6533        let part = 99;
6534        let patches = vec![
6535            ScorePatch::SetPartNames {
6536                part,
6537                name: "Violin".into(),
6538                short_name: "Vln".into(),
6539            },
6540            ScorePatch::SetPartMidi {
6541                part,
6542                channel: 1,
6543                program: 40,
6544            },
6545            ScorePatch::SetInstrumentDefinition { part, value: None },
6546            ScorePatch::SetPartMidiAutomation {
6547                part,
6548                pitch_bends: vec![],
6549                control_changes: vec![],
6550                program_changes: vec![],
6551                aftertouch: vec![],
6552            },
6553        ];
6554        for patch in patches {
6555            assert!(apply_patch(&score, &[patch]).is_err());
6556        }
6557        for (part, staff) in [(99, 0), (0, 99)] {
6558            let patches = vec![
6559                ScorePatch::SetStaffConfiguration {
6560                    part,
6561                    staff,
6562                    clef: Clef::Treble,
6563                    transpose_semitones: 0,
6564                },
6565                ScorePatch::SetStaffPresentation {
6566                    part,
6567                    staff,
6568                    value: StaffPresentation::default(),
6569                },
6570                ScorePatch::SetTablatureConfig {
6571                    part,
6572                    staff,
6573                    value: None,
6574                },
6575            ];
6576            for patch in patches {
6577                assert!(apply_patch(&score, &[patch]).is_err());
6578            }
6579        }
6580        assert_eq!(serde_json::to_value(&score).unwrap(), original);
6581    }
6582
6583    #[test]
6584    fn diff_and_patch_preserve_score_level_texts() {
6585        let a = Score::new("T", 120, 4, 4, 0, 1);
6586        let mut b = a.clone();
6587        b.texts.push(StyledText {
6588            position_beats: None,
6589            style: TextStyle::Expression,
6590            text: "Prelude".to_string(),
6591            placement: Some("above".to_string()),
6592            offset_x: Some(12.0),
6593            offset_y: Some(-8.0),
6594            relative_x: None,
6595            relative_y: None,
6596        });
6597
6598        let changes = diff(&a, &b);
6599        assert!(changes.iter().any(|change| matches!(
6600            change,
6601            ScoreChange::ScoreTextChanged { old, new }
6602                if old.is_empty() && new == &b.texts
6603        )));
6604
6605        let patches = score_patch(&a, &b);
6606        assert!(patches.iter().any(|patch| matches!(
6607            patch,
6608            ScorePatch::SetScoreTexts { value } if value == &b.texts
6609        )));
6610        let result = apply_patch(&a, &patches).expect("score text patch failed");
6611        assert_eq!(result.texts, b.texts);
6612    }
6613
6614    #[test]
6615    fn mixed_editor_changes_round_trip_as_local_patches() {
6616        let mut before = Score::new("Before", 120, 4, 4, 0, 2);
6617        before.parts[0].staves[0].measures[0].voices[0] = vec![
6618            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
6619            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
6620        ];
6621        before.parts[0].staves[0].measures[1].voices[0] = vec![
6622            Note::new(Pitch::new(Step::E, 4), Duration::Quarter),
6623            Note::new(Pitch::new(Step::F, 4), Duration::Quarter),
6624        ];
6625        let mut after = before.clone();
6626        after.metadata.title = "After".into();
6627        after.metadata.composer = "Composer".into();
6628        after.metadata.lyricist = "Lyricist".into();
6629        after.metadata.copyright = "Copyright".into();
6630        after.metadata.work_number = "Op. 1".into();
6631        after.metadata.movement_title = "Prelude".into();
6632        after.settings.tempo_bpm = 96;
6633        after.parts[0].name = "Piano".into();
6634        after.parts[0].short_name = "Pno.".into();
6635        after.parts[0].midi_channel = 1;
6636        after.parts[0].midi_program = 41;
6637        after.parts[0].midi_pitch_bends.push(MidiPitchBend {
6638            tick: 240,
6639            channel: 1,
6640            value: 512,
6641        });
6642        after.parts[0].staves[0].clef = Clef::Bass;
6643        after.parts[0].staves[0].measures[0].voices[0][0] =
6644            Note::new(Pitch::new(Step::G, 3), Duration::Quarter);
6645        after.parts[0].staves[0].measures[0].voices[0]
6646            .push(Note::new(Pitch::new(Step::A, 3), Duration::Quarter));
6647        after.parts[0].staves[0].measures[1].voices[0].pop();
6648
6649        let changes = diff(&before, &after);
6650        assert!(
6651            changes
6652                .iter()
6653                .any(|change| matches!(change, ScoreChange::PartMidiAutomationChanged { .. }))
6654        );
6655        assert!(
6656            changes
6657                .iter()
6658                .any(|change| matches!(change, ScoreChange::NoteModified { .. }))
6659        );
6660        assert!(
6661            changes
6662                .iter()
6663                .any(|change| matches!(change, ScoreChange::NoteAdded { .. }))
6664        );
6665        assert!(
6666            changes
6667                .iter()
6668                .any(|change| matches!(change, ScoreChange::NoteRemoved { .. }))
6669        );
6670        let patches = score_patch(&before, &after);
6671        assert!(
6672            !patches
6673                .iter()
6674                .any(|patch| matches!(patch, ScorePatch::ReplaceScore { .. }))
6675        );
6676        let result = apply_patch(&before, &patches).expect("local patches apply");
6677        assert_eq!(
6678            serde_json::to_value(result).expect("patched score"),
6679            serde_json::to_value(after).expect("expected score")
6680        );
6681    }
6682
6683    #[test]
6684    fn measure_presentation_changes_use_typed_diff_and_local_patch() {
6685        let a = Score::new("T", 120, 4, 4, 0, 1);
6686        let mut b = a.clone();
6687        let measure = &mut b.parts[0].staves[0].measures[0];
6688        measure.number = 8;
6689        measure.clef = Some(Clef::Bass);
6690        measure.tempo_text = Some("Allegro".to_string());
6691        measure.navigation = Some("D.S.".to_string());
6692        measure.expression_text = Some("espressivo".to_string());
6693        measure.multi_rest_count = Some(3);
6694        measure.system_break = true;
6695        measure.page_break = true;
6696
6697        let changes = diff(&a, &b);
6698        assert!(changes.iter().any(|change| matches!(
6699            change,
6700            ScoreChange::MeasurePresentationChanged {
6701                part: 0,
6702                staff: 0,
6703                measure: 0,
6704                old_number: 1,
6705                new_number: 8,
6706                old_clef: None,
6707                new_clef: Some(Clef::Bass),
6708                new_tempo_text: Some(text),
6709                new_navigation: Some(navigation),
6710                new_expression_text: Some(expression),
6711                new_multi_rest_count: Some(3),
6712                old_system_break: false,
6713                new_system_break: true,
6714                old_page_break: false,
6715                new_page_break: true,
6716                ..
6717            } if text == "Allegro" && navigation == "D.S." && expression == "espressivo"
6718        )));
6719        assert!(!changes.iter().any(|change| matches!(
6720            change,
6721            ScoreChange::UnrepresentedFieldChanged { path }
6722                if path == "parts[0].staves[0].measures[0].number"
6723        )));
6724
6725        let patches = score_patch(&a, &b);
6726        assert!(patches.iter().any(|patch| matches!(
6727            patch,
6728            ScorePatch::SetMeasurePresentation {
6729                part: 0,
6730                staff: 0,
6731                measure: 0,
6732                number: 8,
6733                clef: Some(Clef::Bass),
6734                tempo_text: Some(text),
6735                navigation: Some(navigation),
6736                expression_text: Some(expression),
6737                multi_rest_count: Some(3),
6738                system_break: true,
6739                page_break: true,
6740                section_break: false,
6741            } if text == "Allegro" && navigation == "D.S." && expression == "espressivo"
6742        )));
6743        assert!(
6744            !patches
6745                .iter()
6746                .any(|patch| matches!(patch, ScorePatch::ReplaceScore { .. }))
6747        );
6748        let json = serde_json::to_string(&patches).expect("measure presentation patch JSON");
6749        let decoded: Vec<ScorePatch> =
6750            serde_json::from_str(&json).expect("measure presentation patch should decode");
6751        let mut legacy_json: serde_json::Value =
6752            serde_json::from_str(&json).expect("patch JSON value");
6753        for patch in legacy_json.as_array_mut().expect("patch JSON array") {
6754            patch
6755                .as_object_mut()
6756                .expect("patch JSON object")
6757                .remove("section_break");
6758        }
6759        let legacy: Vec<ScorePatch> =
6760            serde_json::from_value(legacy_json).expect("legacy patch should decode");
6761        assert!(matches!(
6762            legacy.first(),
6763            Some(ScorePatch::SetMeasurePresentation {
6764                section_break: false,
6765                ..
6766            })
6767        ));
6768        let result = apply_patch(&a, &decoded).expect("measure presentation patch failed");
6769        assert_eq!(result.parts[0].staves[0].measures[0].number, 8);
6770        assert_eq!(result.parts[0].staves[0].measures[0].clef, Some(Clef::Bass));
6771        assert_eq!(
6772            serde_json::to_value(&result).expect("patched score JSON"),
6773            serde_json::to_value(&b).expect("expected score JSON")
6774        );
6775    }
6776
6777    #[test]
6778    fn chord_definition_changes_use_typed_diff_and_local_patch() {
6779        let a = Score::new("T", 120, 4, 4, 0, 1);
6780        let mut b = a.clone();
6781        b.chord_definitions.push(ChordDefinition {
6782            id: Some("c-major".to_string()),
6783            label: Some("C".to_string()),
6784            kind: Some("major".to_string()),
6785            fret_position: Some(0),
6786            tab_strings: Some("x32010".to_string()),
6787            tab_courses: None,
6788            members: Vec::new(),
6789            barres: Vec::new(),
6790        });
6791
6792        let changes = diff(&a, &b);
6793        assert!(changes.iter().any(|change| matches!(
6794            change,
6795            ScoreChange::ChordDefinitionsChanged { old, new }
6796                if old.is_empty() && new == &b.chord_definitions
6797        )));
6798        assert!(!changes.iter().any(|change| matches!(
6799            change,
6800            ScoreChange::UnrepresentedFieldChanged { path }
6801                if path == "chord_definitions"
6802        )));
6803
6804        let patches = score_patch(&a, &b);
6805        assert!(patches.iter().any(|patch| matches!(
6806            patch,
6807            ScorePatch::SetChordDefinitions { value } if value == &b.chord_definitions
6808        )));
6809        assert!(
6810            !patches
6811                .iter()
6812                .any(|patch| matches!(patch, ScorePatch::ReplaceScore { .. }))
6813        );
6814        let json = serde_json::to_string(&patches).expect("chord definition patch JSON");
6815        let decoded: Vec<ScorePatch> =
6816            serde_json::from_str(&json).expect("chord definition patch should decode");
6817        let result = apply_patch(&a, &decoded).expect("chord definition patch failed");
6818        assert_eq!(result.chord_definitions, b.chord_definitions);
6819    }
6820
6821    #[test]
6822    fn part_name_changes_use_typed_diff_and_local_patch() {
6823        let a = Score::new("T", 120, 4, 4, 0, 1);
6824        let mut b = a.clone();
6825        b.parts[0].name = "Violin".to_string();
6826        b.parts[0].short_name = "Vln.".to_string();
6827        b.parts[0].midi_channel = 4;
6828        b.parts[0].midi_program = 40;
6829        b.parts[0].midi_pitch_bends.push(MidiPitchBend {
6830            tick: 120,
6831            channel: 4,
6832            value: 2048,
6833        });
6834        b.parts[0].midi_control_changes.push(MidiControlChange {
6835            tick: 240,
6836            channel: 4,
6837            controller: 64,
6838            value: 127,
6839        });
6840        b.parts[0].midi_program_changes.push(MidiProgramChange {
6841            tick: 0,
6842            channel: 4,
6843            program: 40,
6844        });
6845        b.parts[0].midi_aftertouch.push(MidiAftertouch {
6846            tick: 360,
6847            channel: 4,
6848            key: Some(64),
6849            value: 80,
6850        });
6851        b.parts[0].staves[0].clef = Clef::Bass;
6852        b.parts[0].staves[0].transpose_semitones = -2;
6853
6854        let changes = diff(&a, &b);
6855        assert!(changes.iter().any(|change| matches!(
6856            change,
6857            ScoreChange::PartNamesChanged {
6858                part: 0,
6859                old_name,
6860                new_name,
6861                old_short_name,
6862                new_short_name,
6863            } if old_name == "Piano"
6864                && new_name == "Violin"
6865                && old_short_name == "Pno."
6866                && new_short_name == "Vln."
6867        )));
6868        assert!(changes.iter().any(|change| matches!(
6869            change,
6870            ScoreChange::PartMidiChanged {
6871                part: 0,
6872                old_channel: 0,
6873                new_channel: 4,
6874                old_program: 0,
6875                new_program: 40,
6876            }
6877        )));
6878        assert!(changes.iter().any(|change| matches!(
6879            change,
6880            ScoreChange::PartMidiAutomationChanged {
6881                part: 0,
6882                new_pitch_bends,
6883                new_control_changes,
6884                new_program_changes,
6885                new_aftertouch,
6886                ..
6887            } if new_pitch_bends == &b.parts[0].midi_pitch_bends
6888                && new_control_changes == &b.parts[0].midi_control_changes
6889                && new_program_changes == &b.parts[0].midi_program_changes
6890                && new_aftertouch == &b.parts[0].midi_aftertouch
6891        )));
6892        assert!(changes.iter().any(|change| matches!(
6893            change,
6894            ScoreChange::StaffConfigurationChanged {
6895                part: 0,
6896                staff: 0,
6897                old_clef: Clef::Treble,
6898                new_clef: Clef::Bass,
6899                old_transpose_semitones: 0,
6900                new_transpose_semitones: -2,
6901            }
6902        )));
6903        assert!(!changes.iter().any(|change| matches!(
6904            change,
6905            ScoreChange::UnrepresentedFieldChanged { path }
6906                if path == "parts[0].name"
6907        )));
6908
6909        let patches = score_patch(&a, &b);
6910        assert!(patches.iter().any(|patch| matches!(
6911            patch,
6912            ScorePatch::SetPartNames {
6913                part: 0,
6914                name,
6915                short_name,
6916            } if name == "Violin" && short_name == "Vln."
6917        )));
6918        assert!(
6919            !patches
6920                .iter()
6921                .any(|patch| matches!(patch, ScorePatch::ReplaceScore { .. }))
6922        );
6923        assert!(patches.iter().any(|patch| matches!(
6924            patch,
6925            ScorePatch::SetPartMidi {
6926                part: 0,
6927                channel: 4,
6928                program: 40,
6929            }
6930        )));
6931        assert!(patches.iter().any(|patch| matches!(
6932            patch,
6933            ScorePatch::SetPartMidiAutomation {
6934                part: 0,
6935                pitch_bends,
6936                control_changes,
6937                program_changes,
6938                aftertouch,
6939            } if pitch_bends == &b.parts[0].midi_pitch_bends
6940                && control_changes == &b.parts[0].midi_control_changes
6941                && program_changes == &b.parts[0].midi_program_changes
6942                && aftertouch == &b.parts[0].midi_aftertouch
6943        )));
6944        assert!(patches.iter().any(|patch| matches!(
6945            patch,
6946            ScorePatch::SetStaffConfiguration {
6947                part: 0,
6948                staff: 0,
6949                clef: Clef::Bass,
6950                transpose_semitones: -2,
6951            }
6952        )));
6953        let result = apply_patch(&a, &patches).expect("part name patch failed");
6954        assert_eq!(result.parts[0].name, b.parts[0].name);
6955        assert_eq!(result.parts[0].short_name, b.parts[0].short_name);
6956        assert_eq!(result.parts[0].midi_channel, b.parts[0].midi_channel);
6957        assert_eq!(result.parts[0].midi_program, b.parts[0].midi_program);
6958        assert_eq!(
6959            result.parts[0].midi_pitch_bends,
6960            b.parts[0].midi_pitch_bends
6961        );
6962        assert_eq!(
6963            result.parts[0].midi_control_changes,
6964            b.parts[0].midi_control_changes
6965        );
6966        assert_eq!(
6967            result.parts[0].midi_program_changes,
6968            b.parts[0].midi_program_changes
6969        );
6970        assert_eq!(result.parts[0].midi_aftertouch, b.parts[0].midi_aftertouch);
6971        assert_eq!(result.parts[0].staves[0].clef, b.parts[0].staves[0].clef);
6972        assert_eq!(
6973            result.parts[0].staves[0].transpose_semitones,
6974            b.parts[0].staves[0].transpose_semitones
6975        );
6976    }
6977
6978    #[test]
6979    fn diff_reports_measure_text_and_figured_bass_changes() {
6980        let a = Score::new("T", 120, 4, 4, 0, 1);
6981        let mut b = a.clone();
6982        let measure = &mut b.parts[0].staves[0].measures[0];
6983        measure.texts.push(StyledText {
6984            position_beats: None,
6985            style: TextStyle::Lyrics,
6986            text: "la".to_string(),
6987            placement: None,
6988            offset_x: None,
6989            offset_y: None,
6990            relative_x: None,
6991            relative_y: None,
6992        });
6993        measure.figured_bass.push(FiguredBassFigure {
6994            number: "6".to_string(),
6995            alter: None,
6996            prefix: None,
6997            suffix: None,
6998            extender: false,
6999        });
7000
7001        let changes = diff(&a, &b);
7002        assert!(changes.iter().any(|change| matches!(
7003            change,
7004            ScoreChange::MeasureTextChanged { part: 0, staff: 0, measure: 0, old, new }
7005                if old.is_empty() && new.len() == 1
7006        )));
7007        assert!(changes.iter().any(|change| matches!(
7008            change,
7009            ScoreChange::FiguredBassChanged { part: 0, staff: 0, measure: 0, old, new }
7010                if old.is_empty() && new.len() == 1
7011        )));
7012    }
7013
7014    #[test]
7015    fn diff_reports_tablature_changes_and_patches_them_locally() {
7016        let a = Score::new("T", 120, 4, 4, 0, 1);
7017        let mut b = a.clone();
7018        b.parts[0].staves[0].tablature = Some(TablatureConfig {
7019            lines: 6,
7020            tuning_midi: vec![40, 45, 50, 55, 59, 64],
7021            capo: 2,
7022        });
7023
7024        let changes = diff(&a, &b);
7025        assert!(!changes.iter().any(|change| matches!(
7026            change,
7027            ScoreChange::UnrepresentedFieldChanged { path }
7028                if path == "parts[0].staves[0].tablature"
7029        )));
7030        assert!(changes.iter().any(|change| matches!(
7031            change,
7032            ScoreChange::TablatureConfigChanged { part: 0, staff: 0, old: None, new: Some(config) }
7033                if config.lines == 6 && config.capo == 2
7034        )));
7035        let patches = score_patch(&a, &b);
7036        assert!(patches.iter().any(|patch| matches!(
7037            patch,
7038            ScorePatch::SetTablatureConfig { part: 0, staff: 0, value: Some(config) }
7039                if config.lines == 6 && config.capo == 2
7040        )));
7041        assert_eq!(
7042            apply_patch(&a, &patches).unwrap().parts[0].staves[0].tablature,
7043            b.parts[0].staves[0].tablature
7044        );
7045    }
7046
7047    #[test]
7048    fn diff_and_patch_preserve_hidden_time_signature() {
7049        let a = Score::new("Meter", 120, 4, 4, 0, 1);
7050        let mut b = a.clone();
7051        b.parts[0].staves[0].measures[0].time_sig_hidden = true;
7052        assert!(diff(&a, &b).iter().any(|change| matches!(
7053            change,
7054            ScoreChange::TimeVisibilityChanged {
7055                old: false,
7056                new: true,
7057                ..
7058            }
7059        )));
7060        let patch = score_patch(&a, &b);
7061        assert!(matches!(
7062            patch.as_slice(),
7063            [ScorePatch::SetTimeVisibility { hidden: true, .. }]
7064        ));
7065        assert!(apply_patch(&a, &patch).unwrap().parts[0].staves[0].measures[0].time_sig_hidden);
7066    }
7067
7068    #[test]
7069    fn diff_and_patch_preserve_common_time_symbol() {
7070        let a = Score::new("Meter", 120, 4, 4, 0, 1);
7071        let mut b = a.clone();
7072        b.parts[0].staves[0].measures[0].time_symbol = Some(TimeSymbol::Common);
7073        assert!(diff(&a, &b).iter().any(|change| matches!(
7074            change,
7075            ScoreChange::TimeSymbolChanged {
7076                part: 0,
7077                staff: 0,
7078                measure: 0,
7079                new: Some(TimeSymbol::Common),
7080                ..
7081            }
7082        )));
7083        let patches = score_patch(&a, &b);
7084        assert!(patches.iter().any(|patch| matches!(
7085            patch,
7086            ScorePatch::SetTimeSymbol {
7087                part: 0,
7088                staff: 0,
7089                measure: 0,
7090                value: Some(TimeSymbol::Common)
7091            }
7092        )));
7093        assert_eq!(
7094            apply_patch(&a, &patches).unwrap().parts[0].staves[0].measures[0].time_symbol,
7095            Some(TimeSymbol::Common)
7096        );
7097    }
7098
7099    #[test]
7100    fn diff_and_patch_preserve_written_metronome_marks() {
7101        let a = Score::new("T", 120, 4, 4, 0, 1);
7102        let mut b = a.clone();
7103        b.parts[0].staves[0].measures[0].metronome_mark = Some(MetronomeMark {
7104            beat_unit: Duration::Quarter,
7105            beat_unit_dots: 0,
7106            per_minute: 69,
7107            parentheses: true,
7108        });
7109        let changes = diff(&a, &b);
7110        assert!(changes.iter().any(|change| matches!(
7111            change,
7112            ScoreChange::MeasureMetronomeMarkChanged {
7113                part: 0,
7114                staff: 0,
7115                measure: 0,
7116                ..
7117            }
7118        )));
7119        let patches = score_patch(&a, &b);
7120        assert!(patches.iter().any(|patch| matches!(
7121            patch,
7122            ScorePatch::SetMeasureMetronomeMark {
7123                part: 0,
7124                staff: 0,
7125                measure: 0,
7126                ..
7127            }
7128        )));
7129        assert_eq!(
7130            apply_patch(&a, &patches).unwrap().parts[0].staves[0].measures[0].metronome_mark,
7131            b.parts[0].staves[0].measures[0].metronome_mark
7132        );
7133    }
7134
7135    #[test]
7136    fn diff_reports_measure_tablature_changes_and_patches_them_locally() {
7137        let mut a = Score::new("T", 120, 4, 4, 0, 2);
7138        a.parts[0].staves[0].tablature = Some(TablatureConfig {
7139            lines: 6,
7140            tuning_midi: vec![40, 45, 50, 55, 59, 64],
7141            capo: 0,
7142        });
7143        let mut b = a.clone();
7144        b.parts[0].staves[0].measures[1].tablature_change = Some(TablatureConfig {
7145            lines: 6,
7146            tuning_midi: vec![40, 45, 50, 55, 59, 64],
7147            capo: 3,
7148        });
7149
7150        let changes = diff(&a, &b);
7151        assert!(changes.iter().any(|change| matches!(
7152            change,
7153            ScoreChange::TablatureChangeChanged {
7154                part: 0,
7155                staff: 0,
7156                measure: 1,
7157                old: None,
7158                new: Some(config),
7159            } if config.capo == 3
7160        )));
7161        let patches = score_patch(&a, &b);
7162        assert!(patches.iter().any(|patch| matches!(
7163            patch,
7164            ScorePatch::SetMeasureTablatureChange {
7165                part: 0,
7166                staff: 0,
7167                measure: 1,
7168                value: Some(config),
7169            } if config.capo == 3
7170        )));
7171        assert_eq!(
7172            apply_patch(&a, &patches).unwrap().parts[0].staves[0].measures[1].tablature_change,
7173            b.parts[0].staves[0].measures[1].tablature_change
7174        );
7175    }
7176
7177    #[test]
7178    fn diff_reports_measure_tempo_ramps_and_patches_them_locally() {
7179        let a = Score::new("Ramp", 120, 4, 4, 0, 2);
7180        let mut b = a.clone();
7181        b.parts[0].staves[0].measures[1].tempo_ramp_to = Some(72);
7182
7183        assert!(diff(&a, &b).iter().any(|change| matches!(
7184            change,
7185            ScoreChange::MeasureTempoRampChanged {
7186                part: 0,
7187                staff: 0,
7188                measure: 1,
7189                old: None,
7190                new: Some(72),
7191            }
7192        )));
7193        let patches = score_patch(&a, &b);
7194        assert!(patches.iter().any(|patch| matches!(
7195            patch,
7196            ScorePatch::SetMeasureTempoRamp {
7197                part: 0,
7198                staff: 0,
7199                measure: 1,
7200                value: Some(72),
7201            }
7202        )));
7203        assert_eq!(
7204            apply_patch(&a, &patches).expect("patch applies").parts[0].staves[0].measures[1]
7205                .tempo_ramp_to,
7206            b.parts[0].staves[0].measures[1].tempo_ramp_to
7207        );
7208    }
7209
7210    #[test]
7211    fn diff_reports_staff_presentation_changes_and_patches_them_locally() {
7212        let a = Score::new("T", 120, 4, 4, 0, 1);
7213        let mut b = a.clone();
7214        b.parts[0].staves[0].presentation = StaffPresentation {
7215            kind: StaffKind::Percussion,
7216            lines: 1,
7217            line_distance: 1.5,
7218            small: true,
7219            cutaway: true,
7220            visible: false,
7221            notehead_scheme: StaffNoteheadScheme::PitchNames,
7222            tablature_rhythm_display: TablatureRhythmDisplay::FretOnly,
7223            tablature_fret_mark_style: TablatureFretMarkStyle::Arabic,
7224        };
7225
7226        let changes = diff(&a, &b);
7227        assert!(changes.iter().any(|change| matches!(
7228            change,
7229            ScoreChange::StaffPresentationChanged { part: 0, staff: 0, new, .. }
7230                if new == &b.parts[0].staves[0].presentation
7231        )));
7232        let patches = score_patch(&a, &b);
7233        assert!(patches.iter().any(|patch| matches!(
7234            patch,
7235            ScorePatch::SetStaffPresentation { part: 0, staff: 0, value }
7236                if value == &b.parts[0].staves[0].presentation
7237        )));
7238        assert!(
7239            !patches
7240                .iter()
7241                .any(|patch| matches!(patch, ScorePatch::ReplaceScore { .. }))
7242        );
7243        assert_eq!(
7244            apply_patch(&a, &patches).unwrap().parts[0].staves[0].presentation,
7245            b.parts[0].staves[0].presentation
7246        );
7247    }
7248
7249    #[test]
7250    fn score_patch_uses_local_presentation_patch_for_display_fields() {
7251        let a = Score::new("T", 120, 4, 4, 0, 1);
7252        let mut b = a.clone();
7253        b.parts[0].name = "Piano".to_string();
7254        b.parts[0].staves[0].measures[0].expression_text = Some("dolce".to_string());
7255        let patches = score_patch(&a, &b);
7256        assert!(patches.iter().any(|patch| matches!(
7257            patch,
7258            ScorePatch::SetMeasurePresentation {
7259                part: 0,
7260                staff: 0,
7261                measure: 0,
7262                expression_text: Some(text),
7263                ..
7264            } if text == "dolce"
7265        )));
7266        assert!(
7267            !patches
7268                .iter()
7269                .any(|patch| matches!(patch, ScorePatch::ReplaceScore { .. }))
7270        );
7271        let result = apply_patch(&a, &patches).expect("measure presentation patch failed");
7272        assert_eq!(result.parts[0].name, "Piano");
7273        assert_eq!(
7274            result.parts[0].staves[0].measures[0].expression_text,
7275            Some("dolce".to_string())
7276        );
7277    }
7278
7279    #[test]
7280    fn diff_detects_tempo_change() {
7281        let a = Score::new("T", 120, 4, 4, 0, 1);
7282        let mut b = a.clone();
7283        b.settings.tempo_bpm = 90;
7284        let changes = diff(&a, &b);
7285        assert_eq!(changes.len(), 1);
7286        assert!(matches!(
7287            changes[0],
7288            ScoreChange::TempoChanged { old: 120, new: 90 }
7289        ));
7290    }
7291
7292    #[test]
7293    fn diff_detects_title_change() {
7294        let a = Score::new("Old Title", 120, 4, 4, 0, 1);
7295        let mut b = a.clone();
7296        b.metadata.title = "New Title".to_string();
7297        let changes = diff(&a, &b);
7298        assert!(
7299            changes.iter().any(
7300                |c| matches!(c, ScoreChange::MetadataChanged { field, .. } if field == "title")
7301            )
7302        );
7303    }
7304
7305    #[test]
7306    fn diff_detects_note_modification() {
7307        let mut a = Score::new("T", 120, 4, 4, 0, 1);
7308        a.parts[0].staves[0].measures[0].voices[0] =
7309            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
7310        let mut b = a.clone();
7311        b.parts[0].staves[0].measures[0].voices[0][0] =
7312            Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
7313        let changes = diff(&a, &b);
7314        assert!(
7315            changes
7316                .iter()
7317                .any(|c| matches!(c, ScoreChange::NoteModified { .. }))
7318        );
7319    }
7320
7321    #[test]
7322    fn diff_detects_part_added() {
7323        let a = Score::new("T", 120, 4, 4, 0, 1);
7324        let mut b = a.clone();
7325        let mut p = Part::new("Violin", "Vln.");
7326        p.staves.push(Staff::new(Clef::Treble));
7327        b.parts.push(p);
7328        let changes = diff(&a, &b);
7329        assert!(
7330            changes
7331                .iter()
7332                .any(|c| matches!(c, ScoreChange::PartAdded { part_index: 1 }))
7333        );
7334    }
7335
7336    #[test]
7337    fn diff_detects_measure_tempo_change() {
7338        let a = Score::new("T", 120, 4, 4, 0, 2);
7339        let mut b = a.clone();
7340        b.parts[0].staves[0].measures[1].tempo = Some(60);
7341        let changes = diff(&a, &b);
7342        assert!(changes.iter().any(|c| matches!(
7343            c,
7344            ScoreChange::MeasureTempoChanged {
7345                measure: 1,
7346                old: None,
7347                new: Some(60),
7348                ..
7349            }
7350        )));
7351    }
7352
7353    #[test]
7354    fn diff_detects_barline_change() {
7355        use crate::model::notation::Barline;
7356        let a = Score::new("T", 120, 4, 4, 0, 2);
7357        let mut b = a.clone();
7358        b.parts[0].staves[0].measures[0].barline_left = Barline::RepeatStart;
7359        let changes = diff(&a, &b);
7360        assert!(
7361            changes
7362                .iter()
7363                .any(|c| matches!(c, ScoreChange::BarlineChanged { measure: 0, .. }))
7364        );
7365    }
7366
7367    #[test]
7368    fn diff_detects_rehearsal_change() {
7369        let a = Score::new("T", 120, 4, 4, 0, 2);
7370        let mut b = a.clone();
7371        b.parts[0].staves[0].measures[0].rehearsal = Some("A".to_string());
7372        let changes = diff(&a, &b);
7373        assert!(
7374            changes
7375                .iter()
7376                .any(|c| matches!(c, ScoreChange::RehearsalMarkChanged { measure: 0, .. }))
7377        );
7378    }
7379
7380    #[test]
7381    fn diff_detects_volta_change() {
7382        use super::VoltaBracket;
7383        let a = Score::new("T", 120, 4, 4, 0, 2);
7384        let mut b = a.clone();
7385        b.parts[0].staves[0].measures[0].volta = Some(VoltaBracket {
7386            number: 1,
7387            kind: "begin_end".into(),
7388            numbers: Vec::new(),
7389        });
7390        let changes = diff(&a, &b);
7391        assert!(
7392            changes
7393                .iter()
7394                .any(|c| matches!(c, ScoreChange::VoltaChanged { measure: 0, .. }))
7395        );
7396    }
7397
7398    #[test]
7399    fn diff_detects_key_signature_change() {
7400        let a = Score::new("T", 120, 4, 4, 0, 1);
7401        let mut b = a.clone();
7402        b.settings.key_signature.fifths = 2; // C major → D major
7403        let changes = diff(&a, &b);
7404        assert!(
7405            changes
7406                .iter()
7407                .any(|c| matches!(c, ScoreChange::KeySignatureChanged { .. }))
7408        );
7409    }
7410
7411    #[test]
7412    fn measure_key_signature_uses_typed_local_patch() {
7413        let a = Score::new("T", 120, 4, 4, 0, 1);
7414        let mut b = a.clone();
7415        b.parts[0].staves[0].measures[0].key_sig = Some(KeySignature {
7416            fifths: 2,
7417            mode: "major".to_string(),
7418        });
7419        let changes = diff(&a, &b);
7420        assert!(!changes.iter().any(|change| matches!(
7421            change,
7422            ScoreChange::UnrepresentedFieldChanged { path }
7423                if path == "parts[0].staves[0].measures[0].key_sig"
7424        )));
7425        let patches = score_patch(&a, &b);
7426        assert!(patches.iter().any(|patch| matches!(
7427            patch,
7428            ScorePatch::SetKeySignature {
7429                part: 0,
7430                staff: 0,
7431                measure: 0,
7432                value: Some(KeySignature { fifths: 2, .. }),
7433            }
7434        )));
7435        assert!(
7436            !patches
7437                .iter()
7438                .any(|patch| matches!(patch, ScorePatch::ReplaceScore { .. }))
7439        );
7440    }
7441
7442    #[test]
7443    fn diff_same_key_signature_no_change() {
7444        let a = Score::new("T", 120, 4, 4, 2, 1);
7445        let changes = diff(&a, &a);
7446        assert!(changes.is_empty());
7447    }
7448
7449    #[test]
7450    fn score_duration_secs_region_partial() {
7451        use super::score_duration_secs_region;
7452        // 4/4, 120 BPM, 4 measures → each measure = 2.0 s; region [1,2] = 4.0 s
7453        let score = Score::new("T", 120, 4, 4, 0, 4);
7454        let secs = score_duration_secs_region(&score, (1, 2));
7455        assert!((secs - 4.0).abs() < 0.01, "expected ~4.0 s, got {secs}");
7456    }
7457
7458    #[test]
7459    fn score_duration_secs_region_single_measure() {
7460        use super::score_duration_secs_region;
7461        // 4/4, 120 BPM → 1 measure = 2.0 s
7462        let score = Score::new("T", 120, 4, 4, 0, 4);
7463        let secs = score_duration_secs_region(&score, (0, 0));
7464        assert!((secs - 2.0).abs() < 0.01, "expected ~2.0 s, got {secs}");
7465    }
7466
7467    // ── measure_beats_remaining ───────────────────────────────────────────────
7468
7469    #[test]
7470    fn measure_beats_remaining_empty_voice_returns_full() {
7471        use super::measure_beats_remaining;
7472        let mut score = Score::new("T", 120, 4, 4, 0, 1);
7473        score.parts[0].staves[0].measures[0].voices[0].clear();
7474        let rem = measure_beats_remaining(&score, 0, 0, 0, 0).unwrap();
7475        assert!(
7476            (rem - 4.0).abs() < 1e-9,
7477            "expected 4.0 remaining, got {rem}"
7478        );
7479    }
7480
7481    #[test]
7482    fn measure_beats_remaining_half_full_returns_half() {
7483        use super::measure_beats_remaining;
7484        use crate::model::pitch::Step;
7485        let mut score = Score::new("T", 120, 4, 4, 0, 1);
7486        score.parts[0].staves[0].measures[0].voices[0] = vec![
7487            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
7488            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
7489        ];
7490        let rem = measure_beats_remaining(&score, 0, 0, 0, 0).unwrap();
7491        assert!(
7492            (rem - 2.0).abs() < 1e-9,
7493            "expected 2.0 remaining, got {rem}"
7494        );
7495    }
7496
7497    #[test]
7498    fn measure_beats_remaining_full_voice_returns_zero() {
7499        use super::measure_beats_remaining;
7500        use crate::model::pitch::Step;
7501        let mut score = Score::new("T", 120, 4, 4, 0, 1);
7502        score.parts[0].staves[0].measures[0].voices[0] =
7503            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
7504        let rem = measure_beats_remaining(&score, 0, 0, 0, 0).unwrap();
7505        assert!((rem).abs() < 1e-9, "expected 0.0 remaining, got {rem}");
7506    }
7507
7508    #[test]
7509    fn measure_beats_remaining_tuplet_accounting() {
7510        use super::measure_beats_remaining;
7511        use crate::model::notation::TupletInfo;
7512        use crate::model::pitch::Step;
7513        // 3 quarter-note triplets each take 2/3 of a beat → total 2.0 beats used → 2.0 remaining
7514        let mut score = Score::new("T", 120, 4, 4, 0, 1);
7515        let tuplet = TupletInfo {
7516            actual_notes: 3,
7517            normal_notes: 2,
7518        };
7519        let mk = |step| {
7520            let mut n = Note::new(Pitch::new(step, 4), Duration::Quarter);
7521            n.tuplet = Some(tuplet.clone());
7522            n
7523        };
7524        score.parts[0].staves[0].measures[0].voices[0] =
7525            vec![mk(Step::C), mk(Step::D), mk(Step::E)];
7526        let rem = measure_beats_remaining(&score, 0, 0, 0, 0).unwrap();
7527        assert!(
7528            (rem - 2.0).abs() < 1e-9,
7529            "expected 2.0 remaining (triplets used 2.0), got {rem}"
7530        );
7531    }
7532
7533    #[test]
7534    fn measure_beats_remaining_out_of_range_returns_err() {
7535        use super::measure_beats_remaining;
7536        let score = Score::new("T", 120, 4, 4, 0, 1);
7537        assert!(measure_beats_remaining(&score, 99, 0, 0, 0).is_err());
7538        assert!(measure_beats_remaining(&score, 0, 99, 0, 0).is_err());
7539        assert!(measure_beats_remaining(&score, 0, 0, 99, 0).is_err());
7540        assert!(measure_beats_remaining(&score, 0, 0, 0, 4).is_err());
7541    }
7542
7543    #[test]
7544    fn note_content_eq_ignores_id() {
7545        use crate::model::pitch::Step;
7546        let mut a = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
7547        let mut b = a.clone();
7548        b.id = "different-id".to_string();
7549        assert!(note_content_eq(&a, &b));
7550        // Actual pitch change should differ
7551        b.pitches[0] = Pitch::new(Step::D, 4);
7552        assert!(!note_content_eq(&a, &b));
7553        // stem_up difference
7554        let mut c = a.clone();
7555        a.stem_up = Some(true);
7556        c.stem_up = Some(false);
7557        assert!(!note_content_eq(&a, &c));
7558    }
7559
7560    #[test]
7561    fn suggested_stem_up_below_middle() {
7562        use crate::model::notation::Clef;
7563        // C4 = MIDI 60, Treble middle = B4 = 71 → stem up
7564        let pitches = vec![Pitch::new(Step::C, 4)];
7565        assert!(suggested_stem_up(&pitches, &Clef::Treble));
7566    }
7567
7568    #[test]
7569    fn suggested_stem_up_above_middle() {
7570        use crate::model::notation::Clef;
7571        // G5 = MIDI 79, Treble middle = 71 → stem down
7572        let pitches = vec![Pitch::new(Step::G, 5)];
7573        assert!(!suggested_stem_up(&pitches, &Clef::Treble));
7574    }
7575
7576    #[test]
7577    fn suggested_stem_up_at_middle_line() {
7578        use crate::model::notation::Clef;
7579        // B4 = MIDI 71, Treble middle = 71 → stem down (avg >= middle)
7580        let pitches = vec![Pitch::new(Step::B, 4)];
7581        assert!(!suggested_stem_up(&pitches, &Clef::Treble));
7582    }
7583
7584    #[test]
7585    fn suggested_stem_up_chord() {
7586        use crate::model::notation::Clef;
7587        // [C4=60, G4=67] avg=63.5 < 71 → stem up
7588        let pitches = vec![Pitch::new(Step::C, 4), Pitch::new(Step::G, 4)];
7589        assert!(suggested_stem_up(&pitches, &Clef::Treble));
7590    }
7591
7592    #[test]
7593    fn suggested_stem_up_bass_clef() {
7594        use crate::model::notation::Clef;
7595        // D3=50 is exactly at Bass middle line → stem down
7596        let pitches = vec![Pitch::new(Step::D, 3)];
7597        assert!(!suggested_stem_up(&pitches, &Clef::Bass));
7598        // C3=48 < 50 → stem up
7599        let pitches2 = vec![Pitch::new(Step::C, 3)];
7600        assert!(suggested_stem_up(&pitches2, &Clef::Bass));
7601    }
7602
7603    #[test]
7604    fn suggested_stem_up_empty_pitches() {
7605        use crate::model::notation::Clef;
7606        assert!(suggested_stem_up(&[], &Clef::Treble));
7607    }
7608
7609    fn eighth(pitch: Pitch) -> Note {
7610        Note::new(pitch, Duration::Eighth)
7611    }
7612    fn quarter(pitch: Pitch) -> Note {
7613        Note::new(pitch, Duration::Quarter)
7614    }
7615    fn rest_eighth() -> Note {
7616        Note::rest(Duration::Eighth)
7617    }
7618
7619    #[test]
7620    fn compute_beams_4_4_four_eighths() {
7621        use crate::model::notation::{Clef, TimeSignature};
7622        let _ = Clef::Treble; // suppress unused import warning
7623        let ts = TimeSignature {
7624            numerator: 4,
7625            denominator: 4,
7626        };
7627        let c4 = Pitch::new(Step::C, 4);
7628        let notes = vec![
7629            eighth(c4.clone()),
7630            eighth(c4.clone()),
7631            eighth(c4.clone()),
7632            eighth(c4.clone()),
7633        ];
7634        let beams = compute_beams(&notes, &ts);
7635        // 4 eighths in 4/4: beat size=1.0, two groups of 2 each
7636        assert_eq!(beams[0], BeamState::Begin);
7637        assert_eq!(beams[1], BeamState::End);
7638        assert_eq!(beams[2], BeamState::Begin);
7639        assert_eq!(beams[3], BeamState::End);
7640    }
7641
7642    #[test]
7643    fn compute_beams_4_4_all_eighth_one_group() {
7644        use crate::model::notation::TimeSignature;
7645        let ts = TimeSignature {
7646            numerator: 4,
7647            denominator: 4,
7648        };
7649        let c4 = Pitch::new(Step::C, 4);
7650        // 2 eighths in a beat → group of 2
7651        let notes = vec![eighth(c4.clone()), eighth(c4.clone())];
7652        let beams = compute_beams(&notes, &ts);
7653        assert_eq!(beams[0], BeamState::Begin);
7654        assert_eq!(beams[1], BeamState::End);
7655    }
7656
7657    #[test]
7658    fn compute_beams_quarter_not_beamed() {
7659        use crate::model::notation::TimeSignature;
7660        let ts = TimeSignature {
7661            numerator: 4,
7662            denominator: 4,
7663        };
7664        let c4 = Pitch::new(Step::C, 4);
7665        let notes = vec![quarter(c4.clone()), quarter(c4.clone())];
7666        let beams = compute_beams(&notes, &ts);
7667        assert_eq!(beams[0], BeamState::None);
7668        assert_eq!(beams[1], BeamState::None);
7669    }
7670
7671    #[test]
7672    fn compute_beams_rest_breaks_beam() {
7673        use crate::model::notation::TimeSignature;
7674        let ts = TimeSignature {
7675            numerator: 4,
7676            denominator: 4,
7677        };
7678        let c4 = Pitch::new(Step::C, 4);
7679        let notes = vec![eighth(c4.clone()), rest_eighth(), eighth(c4.clone())];
7680        let beams = compute_beams(&notes, &ts);
7681        // rest breaks beam group
7682        assert_eq!(beams[0], BeamState::None);
7683        assert_eq!(beams[1], BeamState::None);
7684        assert_eq!(beams[2], BeamState::None);
7685    }
7686
7687    #[test]
7688    fn compute_beams_6_8_compound() {
7689        use crate::model::notation::TimeSignature;
7690        let ts = TimeSignature {
7691            numerator: 6,
7692            denominator: 8,
7693        };
7694        let c4 = Pitch::new(Step::C, 4);
7695        // 6 eighths in 6/8 compound → two groups of 3 (beam size=1.5 beats)
7696        let notes: Vec<Note> = (0..6).map(|_| eighth(c4.clone())).collect();
7697        let beams = compute_beams(&notes, &ts);
7698        assert_eq!(beams[0], BeamState::Begin);
7699        assert_eq!(beams[1], BeamState::Continue);
7700        assert_eq!(beams[2], BeamState::End);
7701        assert_eq!(beams[3], BeamState::Begin);
7702        assert_eq!(beams[4], BeamState::Continue);
7703        assert_eq!(beams[5], BeamState::End);
7704    }
7705
7706    #[test]
7707    fn compute_beams_single_eighth() {
7708        use crate::model::notation::TimeSignature;
7709        let ts = TimeSignature {
7710            numerator: 4,
7711            denominator: 4,
7712        };
7713        let c4 = Pitch::new(Step::C, 4);
7714        let notes = vec![eighth(c4.clone())];
7715        let beams = compute_beams(&notes, &ts);
7716        assert_eq!(beams[0], BeamState::None);
7717    }
7718
7719    #[test]
7720    fn compute_beams_stops_fifteen_in_eight_tuplet_at_the_beat_boundary() {
7721        let ts = TimeSignature {
7722            numerator: 2,
7723            denominator: 4,
7724        };
7725        let pitch = Pitch::new(Step::C, 4);
7726        let mut tuplet_note = Note::new(pitch.clone(), Duration::ThirtySecond);
7727        tuplet_note.tuplet = Some(TupletInfo {
7728            actual_notes: 15,
7729            normal_notes: 8,
7730        });
7731        let mut notes = vec![tuplet_note; 15];
7732        notes.extend([eighth(pitch.clone()), eighth(pitch)]);
7733        let original = serde_json::to_value(&notes).unwrap();
7734
7735        let beams = compute_beams(&notes, &ts);
7736        let mut expected = vec![BeamState::Begin];
7737        expected.extend([BeamState::Continue; 13]);
7738        expected.extend([BeamState::End, BeamState::Begin, BeamState::End]);
7739        assert_eq!(beams, expected);
7740        assert_eq!(
7741            serde_json::to_value(&notes).unwrap(),
7742            original,
7743            "beam inference must not change notation or timing"
7744        );
7745        assert!((notes[..15].iter().map(Note::beats).sum::<f64>() - 1.0).abs() < 1e-12);
7746    }
7747}
7748
7749#[cfg(test)]
7750mod mid_clef_tests {
7751    use super::*;
7752    use crate::{Command, JoinMeasuresCmd, ScoreEngine, SetMidMeasureClefsCmd, SplitMeasureCmd};
7753
7754    fn quarter_notes() -> Vec<Note> {
7755        [Step::C, Step::D, Step::E, Step::F]
7756            .into_iter()
7757            .map(|step| Note::new(Pitch::new(step, 4), Duration::Quarter))
7758            .collect()
7759    }
7760
7761    fn change(beats: f64, clef: Clef) -> MidMeasureClef {
7762        MidMeasureClef {
7763            offset: MeasureLength::from_beats(beats).unwrap(),
7764            clef,
7765        }
7766    }
7767
7768    #[test]
7769    fn measure_length_from_beats_is_exact_for_tuplet_subdivisions() {
7770        let third = MeasureLength::from_beats(1.0 / 3.0).unwrap();
7771        assert_eq!((third.numerator, third.denominator), (1, 12));
7772        let two = MeasureLength::from_beats(2.0).unwrap();
7773        assert_eq!((two.numerator, two.denominator), (1, 2));
7774        assert!(MeasureLength::from_beats(0.0).is_none());
7775    }
7776
7777    #[test]
7778    fn clef_at_follows_mid_bar_changes_and_json_omits_an_empty_list() {
7779        let mut measure = Measure::empty(4, 4);
7780        let json = serde_json::to_string(&measure).unwrap();
7781        assert!(!json.contains("mid_clefs"));
7782        measure.mid_clefs = vec![change(2.0, Clef::Bass)];
7783        assert_eq!(measure.clef_at(&Clef::Treble, 1.0), Clef::Treble);
7784        assert_eq!(measure.clef_at(&Clef::Treble, 2.0), Clef::Bass);
7785        let restored: Measure =
7786            serde_json::from_str(&serde_json::to_string(&measure).unwrap()).unwrap();
7787        assert_eq!(restored.mid_clefs, measure.mid_clefs);
7788    }
7789
7790    #[test]
7791    fn validation_rejects_mid_bar_clefs_outside_the_bar_or_out_of_order() {
7792        let mut score = Score::new("clefs", 120, 4, 4, 0, 1);
7793        score.parts[0].staves[0].measures[0].voices[0] = quarter_notes();
7794        score.parts[0].staves[0].measures[0].mid_clefs =
7795            vec![change(2.0, Clef::Bass), change(1.0, Clef::Treble)];
7796        let errors = crate::validate(&score).errors;
7797        assert!(errors.iter().any(|error| matches!(
7798            error,
7799            crate::ValidationError::InvalidMidMeasureClef { index: 1, .. }
7800        )));
7801        score.parts[0].staves[0].measures[0].mid_clefs = vec![change(4.0, Clef::Bass)];
7802        assert!(!crate::validate(&score).errors.is_empty());
7803    }
7804
7805    #[test]
7806    fn command_split_join_diff_and_patch_keep_mid_bar_clefs() {
7807        let mut engine = ScoreEngine::new();
7808        engine.score.parts[0].staves[0].measures[0].voices[0] = quarter_notes();
7809        engine
7810            .apply(Command::SetMidMeasureClefs(SetMidMeasureClefsCmd {
7811                part_index: 0,
7812                staff_index: 0,
7813                measure_index: 0,
7814                clefs: vec![change(1.0, Clef::Bass), change(3.0, Clef::Alto)],
7815            }))
7816            .unwrap();
7817        let with_clefs = engine.score.clone();
7818        let plain = {
7819            let mut score = with_clefs.clone();
7820            score.parts[0].staves[0].measures[0].mid_clefs.clear();
7821            score
7822        };
7823        assert!(diff(&plain, &with_clefs).iter().any(|change| matches!(
7824            change,
7825            ScoreChange::MidMeasureClefsChanged { measure: 0, .. }
7826        )));
7827        let patched = apply_patch(&plain, &score_patch(&plain, &with_clefs)).unwrap();
7828        assert_eq!(
7829            patched.parts[0].staves[0].measures[0].mid_clefs,
7830            with_clefs.parts[0].staves[0].measures[0].mid_clefs
7831        );
7832
7833        engine
7834            .apply(Command::SplitMeasure(SplitMeasureCmd {
7835                measure_index: 0,
7836                split_at_beats: 1.0,
7837            }))
7838            .unwrap();
7839        let measures = &engine.score.parts[0].staves[0].measures;
7840        assert!(measures[0].mid_clefs.is_empty());
7841        assert_eq!(measures[1].clef, Some(Clef::Bass));
7842        assert_eq!(measures[1].mid_clefs, vec![change(2.0, Clef::Alto)]);
7843
7844        engine
7845            .apply(Command::JoinMeasures(JoinMeasuresCmd { measure_index: 0 }))
7846            .unwrap();
7847        let measure = &engine.score.parts[0].staves[0].measures[0];
7848        assert_eq!(
7849            measure.mid_clefs,
7850            vec![change(1.0, Clef::Bass), change(3.0, Clef::Alto)]
7851        );
7852
7853        let invalid = engine.apply(Command::SetMidMeasureClefs(SetMidMeasureClefsCmd {
7854            part_index: 0,
7855            staff_index: 0,
7856            measure_index: 0,
7857            clefs: vec![change(0.0 + 5.0, Clef::Bass)],
7858        }));
7859        assert!(invalid.is_err());
7860    }
7861}
7862
7863#[cfg(test)]
7864mod percussion_key_tests {
7865    use super::*;
7866
7867    #[test]
7868    fn unpitched_chord_members_sound_their_kit_keys_by_display_position() {
7869        let mut part = Part::new("Drums", "Dr.");
7870        part.midi_channel = 9;
7871        let kit = |id: &str, key: u8, display: Pitch| PercussionInstrument {
7872            id: id.into(),
7873            name: None,
7874            midi_unpitched: Some(key),
7875            staff_position: Some(PercussionInstrument::display_position(&display)),
7876            notehead: None,
7877            preferred_voice: None,
7878            techniques: Vec::new(),
7879        };
7880        part.percussion_instruments = vec![
7881            kit("kick", 35, Pitch::new(Step::F, 4)),
7882            kit("snare", 38, Pitch::new(Step::C, 5)),
7883        ];
7884        let mut note = Note::new(Pitch::new(Step::C, 5), Duration::Quarter);
7885        note.pitches.push(Pitch::new(Step::F, 4));
7886        note.is_unpitched = true;
7887        note.instrument_id = Some("snare".into());
7888        assert_eq!(part.percussion_key(&note, 0), Some(38));
7889        assert_eq!(part.percussion_key(&note, 1), Some(35));
7890        note.is_unpitched = false;
7891        assert_eq!(part.percussion_key(&note, 0), None);
7892        assert_eq!(
7893            PercussionInstrument::display_position(&Pitch::new(Step::B, 4)),
7894            0
7895        );
7896    }
7897}