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#[derive(Debug, Clone, Serialize, Deserialize)]
1605pub struct Part {
1606    pub id: String,
1607    pub name: String,
1608    pub short_name: String,
1609    pub staves: Vec<Staff>,
1610    /// MIDI channel (0–15). Channel 9 is conventionally used for percussion.
1611    #[serde(default)]
1612    pub midi_channel: u8,
1613    /// General MIDI program number (0–127). Default 0 = Acoustic Grand Piano.
1614    #[serde(default)]
1615    pub midi_program: u8,
1616    /// MIDI pitch-bend events preserved from interchange input, in canonical 480 PPQ ticks.
1617    #[serde(default)]
1618    pub midi_pitch_bends: Vec<MidiPitchBend>,
1619    /// MIDI Control Change events preserved from interchange input, in canonical 480 PPQ ticks.
1620    #[serde(default)]
1621    pub midi_control_changes: Vec<MidiControlChange>,
1622    /// MIDI Program Change events preserved from interchange input, in canonical 480 PPQ ticks.
1623    #[serde(default)]
1624    pub midi_program_changes: Vec<MidiProgramChange>,
1625    /// MIDI key/channel aftertouch events preserved from interchange input, in canonical 480 PPQ ticks.
1626    #[serde(default)]
1627    pub midi_aftertouch: Vec<MidiAftertouch>,
1628    /// MusicXML score-instrument definitions for note-level instrument IDs.
1629    #[serde(default)]
1630    pub percussion_instruments: Vec<PercussionInstrument>,
1631    /// MEI/MuseScore staff-group structure within this part.
1632    #[serde(default)]
1633    pub staff_groups: Vec<StaffGroup>,
1634    /// Optional named instrument semantics. Omitted in older score JSON.
1635    #[serde(default)]
1636    pub instrument: Option<InstrumentDefinition>,
1637}
1638
1639#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1640pub struct MidiPitchBend {
1641    pub tick: u64,
1642    pub channel: u8,
1643    /// Signed 14-bit MIDI bend value in the range -8192..=8191.
1644    pub value: i16,
1645}
1646
1647#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1648pub struct MidiControlChange {
1649    pub tick: u64,
1650    pub channel: u8,
1651    /// MIDI controller number (0–127).
1652    pub controller: u8,
1653    /// Seven-bit controller value (0–127).
1654    pub value: u8,
1655}
1656
1657#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1658pub struct MidiProgramChange {
1659    pub tick: u64,
1660    pub channel: u8,
1661    /// General MIDI program number (0–127).
1662    pub program: u8,
1663}
1664
1665#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1666pub struct MidiAftertouch {
1667    pub tick: u64,
1668    pub channel: u8,
1669    /// Key number for key pressure, or `None` for channel pressure.
1670    pub key: Option<u8>,
1671    /// Seven-bit pressure value (0–127).
1672    pub value: u8,
1673}
1674
1675/// An editable percussion-kit mapping associated with a MusicXML score-instrument identity.
1676///
1677/// The MIDI key remains the playback fallback, while the remaining fields
1678/// describe renderer-independent notation defaults. Individual notes may still
1679/// override these defaults.
1680impl PercussionInstrument {
1681    /// Diatonic steps of a displayed unpitched note from the five-line staff's middle line
1682    /// (B4 in the treble/percussion layout), the unit of `staff_position`.
1683    pub fn display_position(pitch: &Pitch) -> i8 {
1684        let step = "CDEFGAB"
1685            .find(pitch.step.to_char())
1686            .map_or(0, |index| index as i32);
1687        let steps = i32::from(pitch.octave) * 7 + step;
1688        (steps - (4 * 7 + 6)).clamp(i32::from(i8::MIN), i32::from(i8::MAX)) as i8
1689    }
1690}
1691
1692#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1693pub struct PercussionInstrument {
1694    /// Stable MusicXML score-instrument identity.
1695    pub id: String,
1696    #[serde(default)]
1697    pub name: Option<String>,
1698    /// General MIDI percussion key (0-based, 35 = acoustic bass drum, 38 = acoustic snare)
1699    /// played for this instrument. MusicXML `<midi-unpitched>` is 1-based and is converted
1700    /// on import and export.
1701    #[serde(default)]
1702    pub midi_unpitched: Option<u8>,
1703    /// Diatonic staff position relative to the middle line.
1704    #[serde(default)]
1705    pub staff_position: Option<i8>,
1706    /// Default glyph when a note does not supply a renderer-specific override.
1707    #[serde(default)]
1708    pub notehead: Option<NoteHead>,
1709    /// Preferred one-based score voice for newly entered kit notes.
1710    #[serde(default)]
1711    pub preferred_voice: Option<u8>,
1712    /// Named kit-specific variants such as rim-shot or open.
1713    #[serde(default)]
1714    pub techniques: Vec<String>,
1715}
1716
1717impl Part {
1718    pub fn new(name: &str, short_name: &str) -> Self {
1719        Self {
1720            id: Uuid::new_v4().to_string(),
1721            name: name.to_string(),
1722            short_name: short_name.to_string(),
1723            staves: Vec::new(),
1724            midi_channel: 0,
1725            midi_program: 0,
1726            midi_pitch_bends: Vec::new(),
1727            midi_control_changes: Vec::new(),
1728            midi_program_changes: Vec::new(),
1729            midi_aftertouch: Vec::new(),
1730            percussion_instruments: Vec::new(),
1731            staff_groups: Vec::new(),
1732            instrument: None,
1733        }
1734    }
1735
1736    /// General MIDI key to sound for pitch `pitch_index` of an unpitched note: the declared
1737    /// instrument's `midi_unpitched`, found by the note's `instrument_id` (for its first pitch)
1738    /// or else by the instrument whose `staff_position` matches the pitch's display position.
1739    /// `None` when the note is pitched or nothing matches; playback then uses the display
1740    /// pitch, as before.
1741    pub fn percussion_key(&self, note: &Note, pitch_index: usize) -> Option<u8> {
1742        if !note.is_unpitched {
1743            return None;
1744        }
1745        let pitch = note.pitches.get(pitch_index)?;
1746        let by_id = note
1747            .instrument_id
1748            .as_deref()
1749            .filter(|_| pitch_index == 0)
1750            .and_then(|id| {
1751                self.percussion_instruments
1752                    .iter()
1753                    .find(|instrument| instrument.id == id)
1754            })
1755            .and_then(|instrument| instrument.midi_unpitched);
1756        by_id.or_else(|| {
1757            let position = PercussionInstrument::display_position(pitch);
1758            self.percussion_instruments
1759                .iter()
1760                .filter(|instrument| instrument.staff_position == Some(position))
1761                .find_map(|instrument| instrument.midi_unpitched)
1762        })
1763    }
1764
1765    /// Resolve the declared percussion instrument for an unpitched note.
1766    ///
1767    /// An explicit MusicXML `instrument@id` always takes precedence.  When no
1768    /// identifier is attached, the retained display-key MIDI value is matched
1769    /// against the part's declared `midi_unpitched` entries.  This deliberately
1770    /// does not invent a sound identity for an unpitched note with no matching
1771    /// declaration.
1772    pub fn percussion_instrument_for_note(&self, note: &Note) -> Option<&PercussionInstrument> {
1773        if !note.is_unpitched {
1774            return None;
1775        }
1776        if let Some(instrument_id) = note.instrument_id.as_deref() {
1777            return self
1778                .percussion_instruments
1779                .iter()
1780                .find(|instrument| instrument.id == instrument_id);
1781        }
1782        let midi_key = u8::try_from(note.pitches.first()?.to_midi()).ok()?;
1783        self.percussion_instruments
1784            .iter()
1785            .find(|instrument| instrument.midi_unpitched == Some(midi_key))
1786    }
1787}
1788
1789/// The semantic notation mode of a staff, independent of renderer-specific styling.
1790#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1791#[serde(rename_all = "snake_case")]
1792pub enum StaffKind {
1793    #[default]
1794    Standard,
1795    Tablature,
1796    Percussion,
1797}
1798
1799/// A score-level notehead convention selected for an entire staff.
1800#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1801#[serde(rename_all = "snake_case")]
1802pub enum StaffNoteheadScheme {
1803    #[default]
1804    Standard,
1805    PitchNames,
1806    ShapeNotes,
1807}
1808
1809/// How a tablature staff exposes rhythmic duration in addition to fret numbers.
1810#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1811#[serde(rename_all = "snake_case")]
1812pub enum TablatureRhythmDisplay {
1813    /// Fret numbers only; duration remains available in the score model.
1814    #[default]
1815    FretOnly,
1816    /// Add stems and flags to the tablature staff's fret numbers.
1817    Stems,
1818}
1819
1820/// Renderer-independent staff appearance and semantic settings.
1821///
1822/// Values are expressed in staff-space units, never pixels. Existing tablature
1823/// tuning data remains in [`Staff::tablature`]; `kind` records how a renderer
1824/// should interpret the staff.
1825#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1826pub struct StaffPresentation {
1827    #[serde(default)]
1828    pub kind: StaffKind,
1829    #[serde(default = "default_staff_line_count")]
1830    pub lines: u8,
1831    #[serde(default = "default_staff_line_distance")]
1832    pub line_distance: f32,
1833    #[serde(default)]
1834    pub small: bool,
1835    #[serde(default)]
1836    pub cutaway: bool,
1837    #[serde(default = "default_staff_visible")]
1838    pub visible: bool,
1839    #[serde(default)]
1840    pub notehead_scheme: StaffNoteheadScheme,
1841    /// Tablature-only duration presentation. Ignored on non-tablature staves.
1842    #[serde(default)]
1843    pub tablature_rhythm_display: TablatureRhythmDisplay,
1844    /// Tablature fret-label convention. Ignored on non-tablature staves.
1845    #[serde(default)]
1846    pub tablature_fret_mark_style: TablatureFretMarkStyle,
1847}
1848
1849const fn default_staff_line_count() -> u8 {
1850    5
1851}
1852
1853const fn default_staff_line_distance() -> f32 {
1854    1.0
1855}
1856
1857const fn default_staff_visible() -> bool {
1858    true
1859}
1860
1861impl Default for StaffPresentation {
1862    fn default() -> Self {
1863        Self {
1864            kind: StaffKind::Standard,
1865            lines: default_staff_line_count(),
1866            line_distance: default_staff_line_distance(),
1867            small: false,
1868            cutaway: false,
1869            visible: true,
1870            notehead_scheme: StaffNoteheadScheme::Standard,
1871            tablature_rhythm_display: TablatureRhythmDisplay::FretOnly,
1872            tablature_fret_mark_style: TablatureFretMarkStyle::Arabic,
1873        }
1874    }
1875}
1876
1877/// Glyph convention used to label tablature frets.
1878#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1879#[serde(rename_all = "snake_case")]
1880pub enum TablatureFretMarkStyle {
1881    #[default]
1882    Arabic,
1883    RomanUpper,
1884    RomanLower,
1885}
1886
1887impl StaffPresentation {
1888    fn for_clef(clef: &Clef) -> Self {
1889        let mut presentation = Self::default();
1890        if matches!(clef, Clef::Percussion) {
1891            presentation.kind = StaffKind::Percussion;
1892        }
1893        presentation
1894    }
1895}
1896
1897#[derive(Debug, Clone, Serialize, Deserialize)]
1898pub struct Staff {
1899    pub clef: Clef,
1900    pub measures: Vec<Measure>,
1901    /// Semitones to add to written pitch for concert pitch / MIDI output.
1902    /// -2 = Bb instrument (clarinet, trumpet), -9 = Eb instrument (alto sax), etc.
1903    #[serde(default)]
1904    pub transpose_semitones: i8,
1905    #[serde(default)]
1906    pub tablature: Option<TablatureConfig>,
1907    #[serde(default)]
1908    pub presentation: StaffPresentation,
1909}
1910
1911impl Staff {
1912    /// The meter in force at `measure_index`: that bar's time signature or the last one before
1913    /// it, else `default` (the score's). A bar without its own signature continues the meter
1914    /// of the change before it, not the score's opening one.
1915    pub fn meter_at<'a>(
1916        &'a self,
1917        measure_index: usize,
1918        default: &'a TimeSignature,
1919    ) -> &'a TimeSignature {
1920        self.measures
1921            .iter()
1922            .take(measure_index.saturating_add(1))
1923            .rev()
1924            .find_map(|measure| measure.time_sig.as_ref())
1925            .unwrap_or(default)
1926    }
1927
1928    /// A bar's length in beats: its `actual_length`, else the meter in force there.
1929    pub fn measure_beats(&self, measure_index: usize, default: &TimeSignature) -> f64 {
1930        self.measures.get(measure_index).map_or_else(
1931            || self.meter_at(measure_index, default).total_beats(),
1932            |measure| measure.duration_beats(self.meter_at(measure_index, default)),
1933        )
1934    }
1935
1936    pub fn new(clef: Clef) -> Self {
1937        Self {
1938            presentation: StaffPresentation::for_clef(&clef),
1939            clef,
1940            measures: Vec::new(),
1941            transpose_semitones: 0,
1942            tablature: None,
1943        }
1944    }
1945
1946    /// Resolve the tablature tuning and capo active at a physical measure.
1947    ///
1948    /// A measure-local change starts at its own boundary and continues until
1949    /// replaced or cleared. Line-count changes are rejected by validation so
1950    /// the staff geometry remains stable within a system.
1951    pub fn tablature_at(&self, measure_index: usize) -> Option<TablatureConfig> {
1952        let mut active = self.tablature.clone();
1953        for measure in self.measures.iter().take(measure_index.saturating_add(1)) {
1954            if let Some(change) = &measure.tablature_change {
1955                active = Some(change.clone());
1956            }
1957        }
1958        active
1959    }
1960}
1961
1962#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1963pub struct VoltaBracket {
1964    /// Ending number (1, 2, …); the first when the ending serves several passes.
1965    pub number: u8,
1966    /// "begin" | "mid" | "end" | "begin_end"
1967    pub kind: String,
1968    /// Every pass the ending is played on when it serves several (`1, 2` or `1-3`); empty
1969    /// (the usual case) means `number` alone.
1970    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1971    pub numbers: Vec<u8>,
1972}
1973
1974impl VoltaBracket {
1975    /// The passes this ending is played on, in order.
1976    pub fn passes(&self) -> Vec<u8> {
1977        if self.numbers.is_empty() {
1978            vec![self.number]
1979        } else {
1980            self.numbers.clone()
1981        }
1982    }
1983
1984    /// Whether the ending is played on repeat pass `pass` (1 = first time through).
1985    pub fn plays_on(&self, pass: u8) -> bool {
1986        if self.numbers.is_empty() {
1987            self.number == pass
1988        } else {
1989            self.numbers.contains(&pass)
1990        }
1991    }
1992
1993    /// The ending's label as scores print it: `1`, `1, 2`, `1-3` (a run of three or more).
1994    pub fn label(&self) -> String {
1995        let passes = self.passes();
1996        match passes.as_slice() {
1997            [first, .., last]
1998                if passes.len() >= 3 && passes.windows(2).all(|pair| pair[1] == pair[0] + 1) =>
1999            {
2000                format!("{first}-{last}")
2001            }
2002            _ => passes
2003                .iter()
2004                .map(u8::to_string)
2005                .collect::<Vec<_>>()
2006                .join(", "),
2007        }
2008    }
2009
2010    /// Pass numbers from a label such as `1`, `1, 2`, `1.2.` or `1-3`.
2011    pub fn parse_passes(label: &str) -> Vec<u8> {
2012        let mut passes = Vec::new();
2013        for part in label
2014            .split([',', ' ', '.', '+', '/'])
2015            .filter(|part| !part.is_empty())
2016        {
2017            if let Some((from, to)) = part.split_once(['-', '–'])
2018                && let (Ok(from), Ok(to)) = (from.trim().parse::<u8>(), to.trim().parse::<u8>())
2019                && from <= to
2020                && to - from < 32
2021            {
2022                passes.extend(from..=to);
2023            } else if let Ok(pass) = part.trim().parse::<u8>() {
2024                passes.push(pass);
2025            }
2026        }
2027        passes.sort_unstable();
2028        passes.dedup();
2029        passes
2030    }
2031}
2032
2033/// One of the three mechanically available positions of a concert-harp pedal.
2034#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2035#[serde(rename_all = "kebab-case")]
2036pub enum HarpPedalPosition {
2037    Flat,
2038    #[default]
2039    Natural,
2040    Sharp,
2041}
2042
2043/// A MusicXML-compatible harp-pedal diagram attached to one measure.
2044///
2045/// Positions are always ordered D, C, B, E, F, G, A, which is the conventional two-row
2046/// pedal-diagram order. The optional placement is retained without inventing pixel geometry.
2047#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2048pub struct HarpPedalDiagram {
2049    #[serde(default)]
2050    pub positions: [HarpPedalPosition; 7],
2051    #[serde(default, skip_serializing_if = "Option::is_none")]
2052    pub placement: Option<String>,
2053}
2054
2055impl Default for HarpPedalDiagram {
2056    fn default() -> Self {
2057        Self {
2058            positions: [HarpPedalPosition::Natural; 7],
2059            placement: None,
2060        }
2061    }
2062}
2063
2064/// Authored length of a measure that differs from its time signature: a pickup (anacrusis), an
2065/// incomplete final bar, or any irregular bar, as MuseScore's measure `len="1/4"`. The value is a
2066/// fraction of a whole note, so `1/4` lasts one quarter-note beat.
2067#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2068pub struct MeasureLength {
2069    pub numerator: u32,
2070    pub denominator: u32,
2071}
2072
2073impl MeasureLength {
2074    /// Largest supported measure length, in quarter-note beats.
2075    pub const MAX_BEATS: f64 = 256.0;
2076
2077    /// Length in quarter-note beats, or `None` for a zero, non-finite, or oversized fraction.
2078    pub fn beats(&self) -> Option<f64> {
2079        if self.numerator == 0 || self.denominator == 0 {
2080            return None;
2081        }
2082        let beats = 4.0 * f64::from(self.numerator) / f64::from(self.denominator);
2083        (beats.is_finite() && beats <= Self::MAX_BEATS).then_some(beats)
2084    }
2085
2086    /// Fraction for a length in quarter-note beats, exact for binary, triplet, quintuplet
2087    /// and septuplet subdivisions; `None` for a zero, negative, or oversized length.
2088    pub fn from_beats(beats: f64) -> Option<Self> {
2089        const DIVISIONS: u32 = 3 * 5 * 7 * 64;
2090        if !beats.is_finite() || beats <= 0.0 || beats > Self::MAX_BEATS {
2091            return None;
2092        }
2093        let ticks = (beats * f64::from(DIVISIONS)).round();
2094        (ticks >= 1.0).then(|| Self::from_ticks(ticks as u32, DIVISIONS))?
2095    }
2096
2097    /// Exact fraction for `ticks` at `divisions` ticks per quarter note, in lowest terms.
2098    pub fn from_ticks(ticks: u32, divisions: u32) -> Option<Self> {
2099        let numerator = u64::from(ticks);
2100        let denominator = u64::from(divisions).checked_mul(4)?;
2101        if numerator == 0 || denominator == 0 {
2102            return None;
2103        }
2104        let gcd = {
2105            let (mut a, mut b) = (numerator, denominator);
2106            while b != 0 {
2107                (a, b) = (b, a % b);
2108            }
2109            a
2110        };
2111        let length = Self {
2112            numerator: u32::try_from(numerator / gcd).ok()?,
2113            denominator: u32::try_from(denominator / gcd).ok()?,
2114        };
2115        length.beats().map(|_| length)
2116    }
2117}
2118
2119/// A clef change partway through a staff's measure. `offset` is the time from the start of
2120/// the bar at which the new clef takes effect, as a fraction of a whole note (`1/4` = one
2121/// quarter-note beat). A change at the barline itself is [`Measure::clef`] instead.
2122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2123pub struct MidMeasureClef {
2124    pub offset: MeasureLength,
2125    pub clef: Clef,
2126}
2127
2128/// A written MusicXML metronome mark, distinct from the playback tempo.
2129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2130pub struct MetronomeMark {
2131    pub beat_unit: Duration,
2132    #[serde(default)]
2133    pub beat_unit_dots: u8,
2134    pub per_minute: u16,
2135    #[serde(default)]
2136    pub parentheses: bool,
2137}
2138
2139#[derive(Debug, Clone, Serialize, Deserialize)]
2140pub struct Measure {
2141    pub number: u32,
2142    pub time_sig: Option<TimeSignature>,
2143    /// Common or cut meter glyph selected by the source; `None` prints numerals.
2144    #[serde(default, skip_serializing_if = "Option::is_none")]
2145    pub time_symbol: Option<TimeSymbol>,
2146    pub key_sig: Option<KeySignature>,
2147    pub clef: Option<Clef>,
2148    /// Clef changes inside the bar, in increasing `offset` order, each strictly after the
2149    /// bar's start and before its end. Notes from an offset on are read in that clef.
2150    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2151    pub mid_clefs: Vec<MidMeasureClef>,
2152    pub tempo: Option<u16>,
2153    /// Explicitly printed tempo equation; a bare playback `<sound tempo>` leaves this empty.
2154    #[serde(default, skip_serializing_if = "Option::is_none")]
2155    pub metronome_mark: Option<MetronomeMark>,
2156    /// Optional target BPM at this measure's end. The playback contract linearly interpolates
2157    /// BPM across the measure; `None` keeps a constant tempo until the next change.
2158    #[serde(default)]
2159    pub tempo_ramp_to: Option<u16>,
2160    /// Instrument semantics beginning at this staff-local measure boundary.
2161    #[serde(default)]
2162    pub instrument_change: Option<InstrumentDefinition>,
2163    /// Tablature tuning/capo beginning at this staff-local measure boundary.
2164    #[serde(default)]
2165    pub tablature_change: Option<TablatureConfig>,
2166    pub barline_left: Barline,
2167    pub barline_right: Barline,
2168    #[serde(default)]
2169    pub volta: Option<VoltaBracket>,
2170    #[serde(default)]
2171    pub tempo_text: Option<String>,
2172    #[serde(default)]
2173    pub rehearsal: Option<String>,
2174    /// Navigation mark: "Segno" | "Coda" | "Fine" | "DaCapo" | "DaCapoAlFine" |
2175    /// "DaCapoAlCoda" | "DalSegno" | "DalSegnoAlFine" | "DalSegnoAlCoda" | "ToCoda"
2176    #[serde(default)]
2177    pub navigation: Option<String>,
2178    /// Expression / performance text ("dolce", "espressivo", "con fuoco", etc.).
2179    #[serde(default)]
2180    pub expression_text: Option<String>,
2181    #[serde(default)]
2182    pub texts: Vec<StyledText>,
2183    /// Structured MusicXML figured-bass figures in source order.
2184    #[serde(default)]
2185    pub figured_bass: Vec<FiguredBassFigure>,
2186    /// Concert-harp pedal diagrams declared by MusicXML `<harp-pedals>` directions.
2187    #[serde(default)]
2188    pub harp_pedal_diagrams: Vec<HarpPedalDiagram>,
2189    /// When ≥ 2, this measure is displayed as a multi-measure rest spanning N measures.
2190    #[serde(default)]
2191    pub multi_rest_count: Option<u8>,
2192    /// Force a new system (row) after this measure.
2193    #[serde(default)]
2194    pub system_break: bool,
2195    /// Force a new page after this measure.
2196    #[serde(default)]
2197    pub page_break: bool,
2198    /// Semantic section boundary beginning at this measure. Unlike system and page breaks,
2199    /// this is an editor/navigation marker and has no layout implication.
2200    #[serde(default)]
2201    pub section_break: bool,
2202    /// Up to 4 voices; voice 0 is the primary voice.
2203    pub voices: [Vec<Note>; 4],
2204    /// Original positive MusicXML voice numbers associated with the four editable slots.
2205    ///
2206    /// `None` retains the legacy convention that slot `n` serializes as voice `n + 1`.
2207    /// This lets import preserve sparse source identifiers such as voices 1 and 5 without
2208    /// changing the established fixed-slot editing API.
2209    #[serde(default)]
2210    pub source_voice_numbers: [Option<u32>; 4],
2211    /// Authored length when it differs from the time signature (pickup or irregular bar).
2212    /// `None` means the measure lasts exactly one bar of its time signature.
2213    #[serde(default, skip_serializing_if = "Option::is_none")]
2214    pub actual_length: Option<MeasureLength>,
2215    /// Display this measure as a repeat of the previous `n` measures (MuseScore measure repeat,
2216    /// MusicXML `<measure-repeat>`). Its voices hold a copy of the repeated content, so playback,
2217    /// validation, and editing see real notes; renderers draw the repeat sign instead.
2218    #[serde(default, skip_serializing_if = "Option::is_none")]
2219    pub measure_repeat: Option<u8>,
2220}
2221
2222impl Measure {
2223    /// The clef in effect `beats` quarter-note beats into this bar, given the clef in effect
2224    /// at its start (after [`Measure::clef`]): the latest mid-bar change at or before `beats`.
2225    pub fn clef_at(&self, start_clef: &Clef, beats: f64) -> Clef {
2226        self.mid_clefs
2227            .iter()
2228            .take_while(|change| {
2229                change
2230                    .offset
2231                    .beats()
2232                    .is_some_and(|offset| offset <= beats + 1e-9)
2233            })
2234            .last()
2235            .map_or_else(|| start_clef.clone(), |change| change.clef.clone())
2236    }
2237
2238    pub fn empty(numerator: u8, denominator: u8) -> Self {
2239        let total_beats = TimeSignature {
2240            numerator,
2241            denominator,
2242        }
2243        .total_beats();
2244        let mut voice0: Vec<Note> = Vec::new();
2245        let mut remaining = total_beats;
2246        while remaining > 1e-9 {
2247            let dur = Duration::whole_filling_beats(remaining);
2248            remaining -= dur.beats(0);
2249            voice0.push(Note::rest(dur));
2250        }
2251        Self {
2252            number: 0,
2253            time_sig: None,
2254            time_symbol: None,
2255            key_sig: None,
2256            clef: None,
2257            mid_clefs: Vec::new(),
2258            tempo: None,
2259            metronome_mark: None,
2260            tempo_ramp_to: None,
2261            instrument_change: None,
2262            tablature_change: None,
2263            barline_left: Barline::Normal,
2264            barline_right: Barline::Normal,
2265            volta: None,
2266            tempo_text: None,
2267            rehearsal: None,
2268            navigation: None,
2269            expression_text: None,
2270            texts: Vec::new(),
2271            figured_bass: Vec::new(),
2272            harp_pedal_diagrams: Vec::new(),
2273            multi_rest_count: None,
2274            system_break: false,
2275            page_break: false,
2276            section_break: false,
2277            voices: [voice0, vec![], vec![], vec![]],
2278            source_voice_numbers: [None; 4],
2279            actual_length: None,
2280            measure_repeat: None,
2281        }
2282    }
2283
2284    /// Replace this measure's voices with a playable copy of `source`, as a measure repeat
2285    /// stores its repeated content; see [`Note::repeated_copy`].
2286    pub fn repeat_content_from(&mut self, source: &Measure) {
2287        for (target, voice) in self.voices.iter_mut().zip(&source.voices) {
2288            *target = voice.iter().map(Note::repeated_copy).collect();
2289        }
2290        self.source_voice_numbers = source.source_voice_numbers;
2291    }
2292
2293    /// Whether this measure's voices sound like `other`'s: the same rests, pitches, durations,
2294    /// dots, and tuplets in every voice.
2295    pub fn same_sounding_content(&self, other: &Measure) -> bool {
2296        self.voices.iter().zip(&other.voices).all(|(left, right)| {
2297            left.len() == right.len()
2298                && left.iter().zip(right).all(|(a, b)| {
2299                    a.is_rest == b.is_rest
2300                        && a.pitches == b.pitches
2301                        && a.duration == b.duration
2302                        && a.dot_count == b.dot_count
2303                        && a.tuplet == b.tuplet
2304                        && a.is_grace == b.is_grace
2305                })
2306        })
2307    }
2308
2309    /// Beats this measure lasts: its [`actual_length`](Self::actual_length) when present and
2310    /// valid, otherwise its own time signature, otherwise `time_signature` (the one in effect).
2311    pub fn duration_beats(&self, time_signature: &TimeSignature) -> f64 {
2312        self.actual_length
2313            .and_then(|length| length.beats())
2314            .unwrap_or_else(|| {
2315                self.time_sig
2316                    .as_ref()
2317                    .unwrap_or(time_signature)
2318                    .total_beats()
2319            })
2320    }
2321
2322    pub fn renumber(&mut self, n: u32) {
2323        self.number = n;
2324    }
2325}
2326
2327#[derive(Debug, Clone, Serialize, Deserialize)]
2328pub struct Note {
2329    pub id: String,
2330    pub is_rest: bool,
2331    /// MusicXML unpitched note; `pitches` then stores display placement only.
2332    #[serde(default)]
2333    pub is_unpitched: bool,
2334    /// Optional source instrument identifier (for example MusicXML note-level `instrument@id`).
2335    #[serde(default)]
2336    pub instrument_id: Option<String>,
2337    /// Horizontal nudge in tenths from the note's computed (rhythmic) position, set by hosts.
2338    /// MusicXML import does not fill it from `default-x`, which is an absolute position in the
2339    /// source engraver's layout; MusicXML export writes it as part of `relative-x`.
2340    #[serde(default)]
2341    pub offset_x: Option<f64>,
2342    /// Vertical nudge in tenths from the note's staff position; see `offset_x`.
2343    #[serde(default)]
2344    pub offset_y: Option<f64>,
2345    /// MusicXML note-level horizontal adjustment in tenths.
2346    #[serde(default)]
2347    pub relative_x: Option<f64>,
2348    /// MusicXML note-level vertical adjustment in tenths.
2349    #[serde(default)]
2350    pub relative_y: Option<f64>,
2351    /// Single note: one pitch. Chord: multiple pitches (same duration).
2352    pub pitches: Vec<Pitch>,
2353    #[serde(default)]
2354    pub tab_position: Option<super::notation::TabPosition>,
2355    /// One tablature position per pitch; the first entry mirrors `tab_position`.
2356    /// This is populated for chords so each pitch can occupy a distinct string.
2357    #[serde(default)]
2358    pub tab_positions: Vec<super::notation::TabPosition>,
2359    pub duration: Duration,
2360    pub dot_count: u8,
2361    pub tie_start: bool,
2362    pub tie_end: bool,
2363    /// Per-pitch tie starts, parallel to `pitches`, for a chord whose notes are not all tied.
2364    /// Empty (the usual case) means `tie_start` applies to every pitch. A vector whose length
2365    /// differs from `pitches` is ignored in favour of `tie_start`; see [`Note::pitch_tie_start`].
2366    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2367    pub pitch_tie_starts: Vec<bool>,
2368    /// Per-pitch tie ends, parallel to `pitches`; empty means `tie_end` applies to every pitch.
2369    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2370    pub pitch_tie_ends: Vec<bool>,
2371    /// Per-pitch accidental display, parallel to `pitches`. Empty (the usual case) leaves every
2372    /// accidental to the notation rules; pitches past the end are `Auto`, and a vector longer
2373    /// than `pitches` is ignored. See [`Note::accidental_display`].
2374    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2375    pub pitch_accidentals: Vec<AccidentalDisplay>,
2376    /// Per-pitch parenthesized noteheads (ghost notes), parallel to `pitches`: MusicXML
2377    /// `<notehead parentheses="yes">`, MEI `@enclose="paren"`, MuseScore `<ghost>`, Guitar Pro
2378    /// ghost notes. Empty (the usual case) means none; pitches past the end are not
2379    /// parenthesized, and a vector longer than `pitches` is ignored.
2380    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2381    pub pitch_parentheses: Vec<bool>,
2382    /// Left-hand fingering per chord member, parallel to `pitches` (MusicXML `<fingering>` on
2383    /// each `<chord/>` note, MuseScore `<Fingering>` in each `<Note>`, Guitar Pro fingering on
2384    /// each string). Empty unless a chord's fingerings belong to particular notes;
2385    /// `fingering`/`fingerings` still hold them all for display.
2386    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2387    pub pitch_fingerings: Vec<Option<u8>>,
2388    pub beam: BeamState,
2389    /// An authored instruction to leave this short note unbeamed. An absent beam mark is
2390    /// unspecified and may be grouped by an opt-in display policy.
2391    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2392    pub explicit_unbeamed: bool,
2393    pub articulations: Vec<Articulation>,
2394    pub dynamic: Option<Dynamic>,
2395    pub stem_up: Option<bool>,
2396    #[serde(default)]
2397    pub hairpin_start: Option<HairpinKind>,
2398    #[serde(default)]
2399    pub hairpin_end: bool,
2400    #[serde(default)]
2401    pub tuplet: Option<TupletInfo>,
2402    /// Start/stop of an authored tuplet group, independent of the rhythmic ratio.
2403    #[serde(default, skip_serializing_if = "Option::is_none")]
2404    pub tuplet_boundary: Option<TupletBoundary>,
2405    #[serde(default)]
2406    pub chord_symbol: Option<ChordSymbol>,
2407    #[serde(default)]
2408    pub is_grace: bool,
2409    /// Acciaccatura: true (slash through stem). Appoggiatura: false.
2410    #[serde(default)]
2411    pub grace_slash: bool,
2412    #[serde(default)]
2413    pub ottava_start: Option<OttavaKind>,
2414    #[serde(default)]
2415    pub ottava_end: bool,
2416    #[serde(default)]
2417    pub lyric: Option<Lyric>,
2418    /// Lyrics for verse 2 and later, in ascending verse order; verse 1 is [`lyric`](Self::lyric).
2419    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2420    pub additional_lyrics: Vec<VerseLyric>,
2421    #[serde(default)]
2422    pub pedal_start: bool,
2423    #[serde(default)]
2424    pub pedal_end: bool,
2425    #[serde(default)]
2426    pub slur_start: bool,
2427    #[serde(default)]
2428    pub slur_end: bool,
2429    /// Arpeggiate direction: `Some(true)` = up, `Some(false)` = down, `None` = none.
2430    #[serde(default)]
2431    pub arpeggiate: Option<bool>,
2432    /// Technique/style instruction attached to this note ("pizz.", "arco", "con sord.", etc.).
2433    #[serde(default)]
2434    pub technique_text: Option<String>,
2435    #[serde(default)]
2436    pub glissando_start: bool,
2437    #[serde(default)]
2438    pub glissando_end: bool,
2439    #[serde(default)]
2440    pub cross_staff: Option<CrossStaff>,
2441    /// Left-hand fingering number (0 = open / thumb, 1–5 = fingers).
2442    #[serde(default)]
2443    pub fingering: Option<u8>,
2444    /// Alternate left-hand fingering candidates, in source order. The first
2445    /// entry mirrors `fingering` when present.
2446    #[serde(default)]
2447    pub fingerings: Vec<u8>,
2448    /// String number for plucked/bowed string instruments (1 = highest string).
2449    #[serde(default)]
2450    pub string_number: Option<u8>,
2451    #[serde(default)]
2452    pub note_head: NoteHead,
2453    /// Cue note (small-sized, does not count toward beat total).
2454    #[serde(default)]
2455    pub is_cue: bool,
2456    /// Not drawn (MusicXML `print-object="no"`, MEI `@visible="false"`, MuseScore
2457    /// `<visible>0</visible>`): the note or rest still takes its time and sounds, as a hidden
2458    /// rest in a second voice or a written-out trill does.
2459    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2460    pub hidden: bool,
2461    /// Start of a multi-note trill line span.
2462    #[serde(default)]
2463    pub trill_line_start: bool,
2464    /// End of a multi-note trill line span.
2465    #[serde(default)]
2466    pub trill_line_end: bool,
2467    /// Guitar-specific playing technique (bend, slide, hammer-on, pull-off).
2468    #[serde(default)]
2469    pub guitar_technique: Option<GuitarTechnique>,
2470    /// MusicXML bend amount in cents when supplied by the source.
2471    #[serde(default)]
2472    pub guitar_bend_alter_cents: Option<i16>,
2473    /// Ordered bend curve points. Position is relative note time in per-mille (0..=1000).
2474    #[serde(default)]
2475    pub guitar_bend_curve: Vec<GuitarBendPoint>,
2476}
2477
2478/// One point of an authored guitar bend curve.
2479#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2480pub struct GuitarBendPoint {
2481    pub position_per_mille: u16,
2482    pub alter_cents: i16,
2483}
2484
2485impl Note {
2486    /// Select one authored fingering candidate without changing the score.
2487    pub fn select_fingering(
2488        &self,
2489        policy: super::notation::FingeringSelectionPolicy,
2490    ) -> Option<u8> {
2491        let candidates = if self.fingerings.is_empty() {
2492            self.fingering.into_iter().collect::<Vec<_>>()
2493        } else {
2494            self.fingerings.clone()
2495        };
2496        match policy {
2497            super::notation::FingeringSelectionPolicy::SourceOrder => candidates.first().copied(),
2498            super::notation::FingeringSelectionPolicy::LowestNumber => {
2499                candidates.iter().copied().min()
2500            }
2501            super::notation::FingeringSelectionPolicy::HighestNumber => {
2502                candidates.iter().copied().max()
2503            }
2504        }
2505    }
2506
2507    pub fn new(pitch: Pitch, duration: Duration) -> Self {
2508        Self {
2509            id: Uuid::new_v4().to_string(),
2510            is_rest: false,
2511            is_unpitched: false,
2512            instrument_id: None,
2513            offset_x: None,
2514            offset_y: None,
2515            relative_x: None,
2516            relative_y: None,
2517            pitches: vec![pitch],
2518            tab_position: None,
2519            tab_positions: Vec::new(),
2520            duration,
2521            dot_count: 0,
2522            tie_start: false,
2523            tie_end: false,
2524            pitch_tie_starts: Vec::new(),
2525            pitch_tie_ends: Vec::new(),
2526            pitch_accidentals: Vec::new(),
2527            pitch_parentheses: Vec::new(),
2528            pitch_fingerings: Vec::new(),
2529            beam: BeamState::None,
2530            explicit_unbeamed: false,
2531            articulations: Vec::new(),
2532            dynamic: None,
2533            stem_up: None,
2534            hairpin_start: None,
2535            hairpin_end: false,
2536            tuplet: None,
2537            tuplet_boundary: None,
2538            chord_symbol: None,
2539            is_grace: false,
2540            grace_slash: false,
2541            ottava_start: None,
2542            ottava_end: false,
2543            lyric: None,
2544            additional_lyrics: Vec::new(),
2545            pedal_start: false,
2546            pedal_end: false,
2547            slur_start: false,
2548            slur_end: false,
2549            arpeggiate: None,
2550            technique_text: None,
2551            glissando_start: false,
2552            glissando_end: false,
2553            cross_staff: None,
2554            fingering: None,
2555            fingerings: Vec::new(),
2556            string_number: None,
2557            note_head: NoteHead::Normal,
2558            is_cue: false,
2559            hidden: false,
2560            trill_line_start: false,
2561            trill_line_end: false,
2562            guitar_technique: None,
2563            guitar_bend_alter_cents: None,
2564            guitar_bend_curve: Vec::new(),
2565        }
2566    }
2567
2568    pub fn rest(duration: Duration) -> Self {
2569        Self {
2570            id: Uuid::new_v4().to_string(),
2571            is_rest: true,
2572            is_unpitched: false,
2573            instrument_id: None,
2574            offset_x: None,
2575            offset_y: None,
2576            relative_x: None,
2577            relative_y: None,
2578            pitches: Vec::new(),
2579            tab_position: None,
2580            tab_positions: Vec::new(),
2581            duration,
2582            dot_count: 0,
2583            tie_start: false,
2584            tie_end: false,
2585            pitch_tie_starts: Vec::new(),
2586            pitch_tie_ends: Vec::new(),
2587            pitch_accidentals: Vec::new(),
2588            pitch_parentheses: Vec::new(),
2589            pitch_fingerings: Vec::new(),
2590            beam: BeamState::None,
2591            explicit_unbeamed: false,
2592            articulations: Vec::new(),
2593            dynamic: None,
2594            stem_up: None,
2595            hairpin_start: None,
2596            hairpin_end: false,
2597            tuplet: None,
2598            tuplet_boundary: None,
2599            chord_symbol: None,
2600            is_grace: false,
2601            grace_slash: false,
2602            ottava_start: None,
2603            ottava_end: false,
2604            lyric: None,
2605            additional_lyrics: Vec::new(),
2606            pedal_start: false,
2607            pedal_end: false,
2608            slur_start: false,
2609            slur_end: false,
2610            arpeggiate: None,
2611            technique_text: None,
2612            glissando_start: false,
2613            glissando_end: false,
2614            cross_staff: None,
2615            fingering: None,
2616            fingerings: Vec::new(),
2617            string_number: None,
2618            note_head: NoteHead::Normal,
2619            is_cue: false,
2620            hidden: false,
2621            trill_line_start: false,
2622            trill_line_end: false,
2623            guitar_technique: None,
2624            guitar_bend_alter_cents: None,
2625            guitar_bend_curve: Vec::new(),
2626        }
2627    }
2628
2629    /// Copy for a repeated measure: a fresh id and the same sounding content, without ties,
2630    /// slurs, lines, lyrics, chord symbol, or dynamic, which belong to the written original.
2631    pub fn repeated_copy(&self) -> Note {
2632        let mut copy = self.clone();
2633        copy.id = Uuid::new_v4().to_string();
2634        copy.tie_start = false;
2635        copy.tie_end = false;
2636        copy.slur_start = false;
2637        copy.slur_end = false;
2638        copy.hairpin_start = None;
2639        copy.hairpin_end = false;
2640        copy.pedal_start = false;
2641        copy.pedal_end = false;
2642        copy.ottava_start = None;
2643        copy.ottava_end = false;
2644        copy.glissando_start = false;
2645        copy.glissando_end = false;
2646        copy.trill_line_start = false;
2647        copy.trill_line_end = false;
2648        copy.lyric = None;
2649        copy.additional_lyrics.clear();
2650        copy.chord_symbol = None;
2651        copy.dynamic = None;
2652        copy
2653    }
2654
2655    /// Whether the pitch at `index` starts a tie: its own flag when per-pitch ties are recorded,
2656    /// otherwise the chord-level `tie_start`.
2657    pub fn pitch_tie_start(&self, index: usize) -> bool {
2658        per_pitch_flag(
2659            &self.pitch_tie_starts,
2660            self.pitches.len(),
2661            index,
2662            self.tie_start,
2663        )
2664    }
2665
2666    /// Whether the pitch at `index` ends a tie; see [`Note::pitch_tie_start`].
2667    pub fn pitch_tie_end(&self, index: usize) -> bool {
2668        per_pitch_flag(
2669            &self.pitch_tie_ends,
2670            self.pitches.len(),
2671            index,
2672            self.tie_end,
2673        )
2674    }
2675
2676    /// Record which pitches of this note start (`starts`) and end (`ends`) a tie. The chord-level
2677    /// flags become "any pitch", and per-pitch vectors are kept only when the pitches differ, so a
2678    /// uniformly tied chord stays in the plain form. Slices whose length is not the pitch count
2679    /// are ignored.
2680    pub fn set_pitch_ties(&mut self, starts: &[bool], ends: &[bool]) {
2681        let count = self.pitches.len();
2682        if starts.len() == count {
2683            self.tie_start = starts.iter().any(|tied| *tied);
2684            self.pitch_tie_starts = if starts.iter().all(|tied| *tied == self.tie_start) {
2685                Vec::new()
2686            } else {
2687                starts.to_vec()
2688            };
2689        }
2690        if ends.len() == count {
2691            self.tie_end = ends.iter().any(|tied| *tied);
2692            self.pitch_tie_ends = if ends.iter().all(|tied| *tied == self.tie_end) {
2693                Vec::new()
2694            } else {
2695                ends.to_vec()
2696            };
2697        }
2698    }
2699
2700    /// How the accidental of the pitch at `index` is shown (`Auto` when not recorded).
2701    pub fn accidental_display(&self, index: usize) -> AccidentalDisplay {
2702        if self.pitch_accidentals.len() > self.pitches.len() {
2703            return AccidentalDisplay::Auto;
2704        }
2705        self.pitch_accidentals
2706            .get(index)
2707            .copied()
2708            .unwrap_or_default()
2709    }
2710
2711    /// Set how the accidental of the pitch at `index` is shown. The vector is dropped again
2712    /// when every pitch is back to `Auto`.
2713    pub fn set_accidental_display(&mut self, index: usize, display: AccidentalDisplay) {
2714        if index >= self.pitches.len() {
2715            return;
2716        }
2717        if self.pitch_accidentals.len() > self.pitches.len() {
2718            self.pitch_accidentals.clear();
2719        }
2720        self.pitch_accidentals
2721            .resize(self.pitches.len(), AccidentalDisplay::Auto);
2722        self.pitch_accidentals[index] = display;
2723        if self
2724            .pitch_accidentals
2725            .iter()
2726            .all(|display| *display == AccidentalDisplay::Auto)
2727        {
2728            self.pitch_accidentals.clear();
2729        }
2730    }
2731
2732    /// The fingering of the chord member at `index`, when fingerings are recorded per pitch.
2733    pub fn pitch_fingering(&self, index: usize) -> Option<u8> {
2734        if self.pitch_fingerings.len() > self.pitches.len() {
2735            return None;
2736        }
2737        self.pitch_fingerings.get(index).copied().flatten()
2738    }
2739
2740    /// Record the fingering of the chord member at `index` (and add it to the displayed
2741    /// fingerings).
2742    pub fn set_pitch_fingering(&mut self, index: usize, fingering: u8) {
2743        if index >= self.pitches.len() {
2744            return;
2745        }
2746        if self.pitch_fingerings.len() > self.pitches.len() {
2747            self.pitch_fingerings.clear();
2748        }
2749        if self.pitch_fingerings.len() <= index {
2750            self.pitch_fingerings.resize(index + 1, None);
2751        }
2752        self.pitch_fingerings[index] = Some(fingering);
2753        if self.fingering.is_none() {
2754            self.fingering = Some(fingering);
2755        }
2756        self.fingerings.push(fingering);
2757    }
2758
2759    /// Whether the notehead of the pitch at `index` is in parentheses (a ghost note).
2760    pub fn is_parenthesized(&self, index: usize) -> bool {
2761        self.pitch_parentheses.len() <= self.pitches.len()
2762            && self.pitch_parentheses.get(index).copied().unwrap_or(false)
2763    }
2764
2765    /// Put the notehead of the pitch at `index` in parentheses or take it out. The vector is
2766    /// dropped again when no pitch is parenthesized.
2767    pub fn set_parenthesized(&mut self, index: usize, parenthesized: bool) {
2768        if index >= self.pitches.len() {
2769            return;
2770        }
2771        if self.pitch_parentheses.len() > self.pitches.len() {
2772            self.pitch_parentheses.clear();
2773        }
2774        self.pitch_parentheses.resize(self.pitches.len(), false);
2775        self.pitch_parentheses[index] = parenthesized;
2776        if !self.pitch_parentheses.contains(&true) {
2777            self.pitch_parentheses.clear();
2778        }
2779    }
2780
2781    /// Per-pitch tie starts as a full vector (chord-level flag repeated when not recorded).
2782    pub fn pitch_tie_starts_or_uniform(&self) -> Vec<bool> {
2783        (0..self.pitches.len())
2784            .map(|index| self.pitch_tie_start(index))
2785            .collect()
2786    }
2787
2788    /// Per-pitch tie ends as a full vector (chord-level flag repeated when not recorded).
2789    pub fn pitch_tie_ends_or_uniform(&self) -> Vec<bool> {
2790        (0..self.pitches.len())
2791            .map(|index| self.pitch_tie_end(index))
2792            .collect()
2793    }
2794
2795    /// A plain whole rest, the form MusicXML `<rest measure="yes"/>` and MuseScore
2796    /// `durationType=measure` import to. Alone in a voice it is a measure rest; see
2797    /// [`voice_duration_beats`].
2798    pub fn is_plain_whole_rest(&self) -> bool {
2799        self.is_rest
2800            && matches!(self.duration, Duration::Whole)
2801            && self.dot_count == 0
2802            && self.tuplet.is_none()
2803            && !self.is_grace
2804            && !self.is_cue
2805    }
2806
2807    pub fn beats(&self) -> f64 {
2808        if self.is_grace || self.is_cue {
2809            return 0.0;
2810        }
2811        let base = self.duration.beats(self.dot_count);
2812        if let Some(ref t) = self.tuplet {
2813            base * (t.normal_notes as f64) / (t.actual_notes as f64)
2814        } else {
2815            base
2816        }
2817    }
2818}
2819
2820impl Duration {
2821    /// Returns the largest single duration that fills the given number of beats.
2822    pub fn whole_filling_beats(beats: f64) -> Duration {
2823        if beats >= 16.0 {
2824            Duration::Longa
2825        } else if beats >= 8.0 {
2826            Duration::Breve
2827        } else if beats >= 4.0 {
2828            Duration::Whole
2829        } else if beats >= 2.0 {
2830            Duration::Half
2831        } else if beats >= 1.0 {
2832            Duration::Quarter
2833        } else if beats >= 0.5 {
2834            Duration::Eighth
2835        } else if beats >= 0.25 {
2836            Duration::Sixteenth
2837        } else if beats >= 0.125 {
2838            Duration::ThirtySecond
2839        } else if beats >= 0.0625 {
2840            Duration::SixtyFourth
2841        } else {
2842            Duration::HundredTwentyEighth
2843        }
2844    }
2845}
2846
2847/// Written tuplet ranges (inclusive note indices), using explicit boundaries when present.
2848/// Legacy scores without boundaries are split at complete written note-value groups.
2849pub fn tuplet_group_ranges(voice: &[Note]) -> Vec<(usize, usize)> {
2850    let mut groups = Vec::new();
2851    let mut open: Option<(usize, TupletInfo, u64, bool)> = None;
2852    let mut previous_timed = 0;
2853    for (index, note) in voice.iter().enumerate() {
2854        if note.is_grace {
2855            continue;
2856        }
2857        let has_start = matches!(
2858            note.tuplet_boundary,
2859            Some(TupletBoundary::Start | TupletBoundary::StartStop)
2860        );
2861        let has_stop = matches!(
2862            note.tuplet_boundary,
2863            Some(TupletBoundary::Stop | TupletBoundary::StartStop)
2864        );
2865        let must_close = open
2866            .as_ref()
2867            .is_some_and(|(_, ratio, _, _)| note.tuplet.as_ref() != Some(ratio) || has_start);
2868        if must_close && let Some((start, _, _, _)) = open.take() {
2869            groups.push((start, previous_timed));
2870        }
2871        if let Some(ratio) = &note.tuplet {
2872            let (numerator, denominator) = note.duration.as_fraction();
2873            let base = 4096 * u64::from(numerator) / u64::from(denominator.max(1));
2874            let dots = u32::from(note.dot_count.min(4));
2875            let written = base * ((1u64 << (dots + 1)) - 1) / (1u64 << dots);
2876            let (_, current_ratio, total_written, explicit) =
2877                open.get_or_insert_with(|| (index, ratio.clone(), 0, has_start));
2878            *total_written += written;
2879            let count = u64::from(current_ratio.actual_notes.max(1));
2880            let unit = *total_written / count;
2881            let legacy_complete = !*explicit
2882                && *total_written % count == 0
2883                && unit > 0
2884                && unit.is_power_of_two()
2885                && unit <= 4096;
2886            if (has_stop || legacy_complete)
2887                && let Some((start, _, _, _)) = open.take()
2888            {
2889                groups.push((start, index));
2890            }
2891        }
2892        previous_timed = index;
2893    }
2894    if let Some((start, _, _, _)) = open {
2895        groups.push((start, previous_timed));
2896    }
2897    groups
2898}
2899
2900// ── NoteAddr ──────────────────────────────────────────────────────────────────
2901
2902/// Physical address of a note within a score.
2903#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2904pub struct NoteAddr {
2905    pub part: usize,
2906    pub staff: usize,
2907    pub measure: usize,
2908    pub voice: usize,
2909    pub note: usize,
2910}
2911
2912// ── diff ──────────────────────────────────────────────────────────────────────
2913
2914/// A single change between two [`Score`] values as reported by [`diff`].
2915#[derive(Debug, Clone, Serialize, Deserialize)]
2916pub enum ScoreChange {
2917    ScoreViewsChanged {
2918        old: Vec<ScoreView>,
2919        new: Vec<ScoreView>,
2920    },
2921    ScoreTextChanged {
2922        old: Vec<StyledText>,
2923        new: Vec<StyledText>,
2924    },
2925    MeasureTextChanged {
2926        part: usize,
2927        staff: usize,
2928        measure: usize,
2929        old: Vec<StyledText>,
2930        new: Vec<StyledText>,
2931    },
2932    FiguredBassChanged {
2933        part: usize,
2934        staff: usize,
2935        measure: usize,
2936        old: Vec<FiguredBassFigure>,
2937        new: Vec<FiguredBassFigure>,
2938    },
2939    HarpPedalDiagramsChanged {
2940        part: usize,
2941        staff: usize,
2942        measure: usize,
2943        old: Vec<HarpPedalDiagram>,
2944        new: Vec<HarpPedalDiagram>,
2945    },
2946    ChordDefinitionsChanged {
2947        old: Vec<ChordDefinition>,
2948        new: Vec<ChordDefinition>,
2949    },
2950    PartNamesChanged {
2951        part: usize,
2952        old_name: String,
2953        new_name: String,
2954        old_short_name: String,
2955        new_short_name: String,
2956    },
2957    PartMidiChanged {
2958        part: usize,
2959        old_channel: u8,
2960        new_channel: u8,
2961        old_program: u8,
2962        new_program: u8,
2963    },
2964    InstrumentDefinitionChanged {
2965        part: usize,
2966        old: Option<InstrumentDefinition>,
2967        new: Option<InstrumentDefinition>,
2968    },
2969    PartMidiAutomationChanged {
2970        part: usize,
2971        old_pitch_bends: Vec<MidiPitchBend>,
2972        new_pitch_bends: Vec<MidiPitchBend>,
2973        old_control_changes: Vec<MidiControlChange>,
2974        new_control_changes: Vec<MidiControlChange>,
2975        old_program_changes: Vec<MidiProgramChange>,
2976        new_program_changes: Vec<MidiProgramChange>,
2977        old_aftertouch: Vec<MidiAftertouch>,
2978        new_aftertouch: Vec<MidiAftertouch>,
2979    },
2980    StaffConfigurationChanged {
2981        part: usize,
2982        staff: usize,
2983        old_clef: Clef,
2984        new_clef: Clef,
2985        old_transpose_semitones: i8,
2986        new_transpose_semitones: i8,
2987    },
2988    StaffPresentationChanged {
2989        part: usize,
2990        staff: usize,
2991        old: StaffPresentation,
2992        new: StaffPresentation,
2993    },
2994    MeasurePresentationChanged {
2995        part: usize,
2996        staff: usize,
2997        measure: usize,
2998        old_number: u32,
2999        new_number: u32,
3000        old_clef: Option<Clef>,
3001        new_clef: Option<Clef>,
3002        old_tempo_text: Option<String>,
3003        new_tempo_text: Option<String>,
3004        old_navigation: Option<String>,
3005        new_navigation: Option<String>,
3006        old_expression_text: Option<String>,
3007        new_expression_text: Option<String>,
3008        old_multi_rest_count: Option<u8>,
3009        new_multi_rest_count: Option<u8>,
3010        old_system_break: bool,
3011        new_system_break: bool,
3012        old_page_break: bool,
3013        new_page_break: bool,
3014        old_section_break: bool,
3015        new_section_break: bool,
3016    },
3017    TablatureConfigChanged {
3018        part: usize,
3019        staff: usize,
3020        old: Option<TablatureConfig>,
3021        new: Option<TablatureConfig>,
3022    },
3023    TablatureChangeChanged {
3024        part: usize,
3025        staff: usize,
3026        measure: usize,
3027        old: Option<TablatureConfig>,
3028        new: Option<TablatureConfig>,
3029    },
3030    MidMeasureClefsChanged {
3031        part: usize,
3032        staff: usize,
3033        measure: usize,
3034        old: Vec<MidMeasureClef>,
3035        new: Vec<MidMeasureClef>,
3036    },
3037    /// A semantic field changed without a dedicated positional diff variant.
3038    ///
3039    /// The stable path keeps compatibility reports honest while the complete score remains
3040    /// available through [`ScorePatch::ReplaceScore`].
3041    UnrepresentedFieldChanged {
3042        path: String,
3043    },
3044    MetadataChanged {
3045        field: String,
3046        old: String,
3047        new: String,
3048    },
3049    TempoChanged {
3050        old: u16,
3051        new: u16,
3052    },
3053    KeySignatureChanged {
3054        old: KeySignature,
3055        new: KeySignature,
3056    },
3057    PartAdded {
3058        part_index: usize,
3059    },
3060    PartRemoved {
3061        part_index: usize,
3062        name: String,
3063    },
3064    NoteAdded {
3065        part: usize,
3066        staff: usize,
3067        measure: usize,
3068        voice: usize,
3069        note_index: usize,
3070    },
3071    NoteRemoved {
3072        part: usize,
3073        staff: usize,
3074        measure: usize,
3075        voice: usize,
3076        note: Box<Note>,
3077    },
3078    NoteModified {
3079        part: usize,
3080        staff: usize,
3081        measure: usize,
3082        voice: usize,
3083        note_index: usize,
3084        old: Box<Note>,
3085        new: Box<Note>,
3086    },
3087    TimeSigChanged {
3088        part: usize,
3089        staff: usize,
3090        measure: usize,
3091        old: Option<TimeSignature>,
3092        new: Option<TimeSignature>,
3093    },
3094    TimeSymbolChanged {
3095        part: usize,
3096        staff: usize,
3097        measure: usize,
3098        old: Option<TimeSymbol>,
3099        new: Option<TimeSymbol>,
3100    },
3101    MeasureTempoChanged {
3102        part: usize,
3103        staff: usize,
3104        measure: usize,
3105        old: Option<u16>,
3106        new: Option<u16>,
3107    },
3108    MeasureMetronomeMarkChanged {
3109        part: usize,
3110        staff: usize,
3111        measure: usize,
3112        old: Option<MetronomeMark>,
3113        new: Option<MetronomeMark>,
3114    },
3115    MeasureTempoRampChanged {
3116        part: usize,
3117        staff: usize,
3118        measure: usize,
3119        old: Option<u16>,
3120        new: Option<u16>,
3121    },
3122    BarlineChanged {
3123        part: usize,
3124        staff: usize,
3125        measure: usize,
3126    },
3127    RehearsalMarkChanged {
3128        part: usize,
3129        staff: usize,
3130        measure: usize,
3131        old: Option<String>,
3132        new: Option<String>,
3133    },
3134    VoltaChanged {
3135        part: usize,
3136        staff: usize,
3137        measure: usize,
3138    },
3139}
3140
3141/// Compare two scores and return a list of differences.
3142///
3143/// Parts, staves, measures, and voices are compared by position. Notes are compared by
3144/// position within each voice, ignoring their `id` field. Metadata fields are compared
3145/// individually.
3146pub fn diff(a: &Score, b: &Score) -> Vec<ScoreChange> {
3147    let mut changes: Vec<ScoreChange> = Vec::new();
3148
3149    if a.views != b.views {
3150        changes.push(ScoreChange::ScoreViewsChanged {
3151            old: a.views.clone(),
3152            new: b.views.clone(),
3153        });
3154    }
3155
3156    if a.texts != b.texts {
3157        changes.push(ScoreChange::ScoreTextChanged {
3158            old: a.texts.clone(),
3159            new: b.texts.clone(),
3160        });
3161    }
3162    if a.chord_definitions != b.chord_definitions {
3163        changes.push(ScoreChange::ChordDefinitionsChanged {
3164            old: a.chord_definitions.clone(),
3165            new: b.chord_definitions.clone(),
3166        });
3167    }
3168    if let Some(path) = first_unrepresented_field_change(a, b) {
3169        changes.push(ScoreChange::UnrepresentedFieldChanged { path });
3170    }
3171
3172    macro_rules! meta {
3173        ($field:ident, $name:literal) => {
3174            if a.metadata.$field != b.metadata.$field {
3175                changes.push(ScoreChange::MetadataChanged {
3176                    field: $name.to_string(),
3177                    old: a.metadata.$field.clone(),
3178                    new: b.metadata.$field.clone(),
3179                });
3180            }
3181        };
3182    }
3183    meta!(title, "title");
3184    meta!(composer, "composer");
3185    meta!(lyricist, "lyricist");
3186    meta!(copyright, "copyright");
3187    meta!(work_number, "work_number");
3188    meta!(movement_title, "movement_title");
3189
3190    if a.settings.tempo_bpm != b.settings.tempo_bpm {
3191        changes.push(ScoreChange::TempoChanged {
3192            old: a.settings.tempo_bpm,
3193            new: b.settings.tempo_bpm,
3194        });
3195    }
3196    if a.settings.key_signature != b.settings.key_signature {
3197        changes.push(ScoreChange::KeySignatureChanged {
3198            old: a.settings.key_signature.clone(),
3199            new: b.settings.key_signature.clone(),
3200        });
3201    }
3202
3203    let a_len = a.parts.len();
3204    let b_len = b.parts.len();
3205    for i in b_len..a_len {
3206        changes.push(ScoreChange::PartRemoved {
3207            part_index: i,
3208            name: a.parts[i].name.clone(),
3209        });
3210    }
3211    for i in a_len..b_len {
3212        changes.push(ScoreChange::PartAdded { part_index: i });
3213    }
3214
3215    for pi in 0..a_len.min(b_len) {
3216        let ap = &a.parts[pi];
3217        let bp = &b.parts[pi];
3218        if ap.name != bp.name || ap.short_name != bp.short_name {
3219            changes.push(ScoreChange::PartNamesChanged {
3220                part: pi,
3221                old_name: ap.name.clone(),
3222                new_name: bp.name.clone(),
3223                old_short_name: ap.short_name.clone(),
3224                new_short_name: bp.short_name.clone(),
3225            });
3226        }
3227        if ap.midi_channel != bp.midi_channel || ap.midi_program != bp.midi_program {
3228            changes.push(ScoreChange::PartMidiChanged {
3229                part: pi,
3230                old_channel: ap.midi_channel,
3231                new_channel: bp.midi_channel,
3232                old_program: ap.midi_program,
3233                new_program: bp.midi_program,
3234            });
3235        }
3236        if ap.instrument != bp.instrument {
3237            changes.push(ScoreChange::InstrumentDefinitionChanged {
3238                part: pi,
3239                old: ap.instrument.clone(),
3240                new: bp.instrument.clone(),
3241            });
3242        }
3243        if ap.midi_pitch_bends != bp.midi_pitch_bends
3244            || ap.midi_control_changes != bp.midi_control_changes
3245            || ap.midi_program_changes != bp.midi_program_changes
3246            || ap.midi_aftertouch != bp.midi_aftertouch
3247        {
3248            changes.push(ScoreChange::PartMidiAutomationChanged {
3249                part: pi,
3250                old_pitch_bends: ap.midi_pitch_bends.clone(),
3251                new_pitch_bends: bp.midi_pitch_bends.clone(),
3252                old_control_changes: ap.midi_control_changes.clone(),
3253                new_control_changes: bp.midi_control_changes.clone(),
3254                old_program_changes: ap.midi_program_changes.clone(),
3255                new_program_changes: bp.midi_program_changes.clone(),
3256                old_aftertouch: ap.midi_aftertouch.clone(),
3257                new_aftertouch: bp.midi_aftertouch.clone(),
3258            });
3259        }
3260        for si in 0..ap.staves.len().min(bp.staves.len()) {
3261            let a_staff = &ap.staves[si];
3262            let b_staff = &bp.staves[si];
3263            if a_staff.clef != b_staff.clef
3264                || a_staff.transpose_semitones != b_staff.transpose_semitones
3265            {
3266                changes.push(ScoreChange::StaffConfigurationChanged {
3267                    part: pi,
3268                    staff: si,
3269                    old_clef: a_staff.clef.clone(),
3270                    new_clef: b_staff.clef.clone(),
3271                    old_transpose_semitones: a_staff.transpose_semitones,
3272                    new_transpose_semitones: b_staff.transpose_semitones,
3273                });
3274            }
3275            if a_staff.tablature != b_staff.tablature {
3276                changes.push(ScoreChange::TablatureConfigChanged {
3277                    part: pi,
3278                    staff: si,
3279                    old: a_staff.tablature.clone(),
3280                    new: b_staff.tablature.clone(),
3281                });
3282            }
3283            if a_staff.presentation != b_staff.presentation {
3284                changes.push(ScoreChange::StaffPresentationChanged {
3285                    part: pi,
3286                    staff: si,
3287                    old: a_staff.presentation.clone(),
3288                    new: b_staff.presentation.clone(),
3289                });
3290            }
3291            for mi in 0..a_staff.measures.len().min(b_staff.measures.len()) {
3292                let am = &a_staff.measures[mi];
3293                let bm = &b_staff.measures[mi];
3294                if am.tablature_change != bm.tablature_change {
3295                    changes.push(ScoreChange::TablatureChangeChanged {
3296                        part: pi,
3297                        staff: si,
3298                        measure: mi,
3299                        old: am.tablature_change.clone(),
3300                        new: bm.tablature_change.clone(),
3301                    });
3302                }
3303                if am.mid_clefs != bm.mid_clefs {
3304                    changes.push(ScoreChange::MidMeasureClefsChanged {
3305                        part: pi,
3306                        staff: si,
3307                        measure: mi,
3308                        old: am.mid_clefs.clone(),
3309                        new: bm.mid_clefs.clone(),
3310                    });
3311                }
3312                if am.number != bm.number
3313                    || am.clef != bm.clef
3314                    || am.tempo_text != bm.tempo_text
3315                    || am.navigation != bm.navigation
3316                    || am.expression_text != bm.expression_text
3317                    || am.multi_rest_count != bm.multi_rest_count
3318                    || am.system_break != bm.system_break
3319                    || am.page_break != bm.page_break
3320                    || am.section_break != bm.section_break
3321                {
3322                    changes.push(ScoreChange::MeasurePresentationChanged {
3323                        part: pi,
3324                        staff: si,
3325                        measure: mi,
3326                        old_number: am.number,
3327                        new_number: bm.number,
3328                        old_clef: am.clef.clone(),
3329                        new_clef: bm.clef.clone(),
3330                        old_tempo_text: am.tempo_text.clone(),
3331                        new_tempo_text: bm.tempo_text.clone(),
3332                        old_navigation: am.navigation.clone(),
3333                        new_navigation: bm.navigation.clone(),
3334                        old_expression_text: am.expression_text.clone(),
3335                        new_expression_text: bm.expression_text.clone(),
3336                        old_multi_rest_count: am.multi_rest_count,
3337                        new_multi_rest_count: bm.multi_rest_count,
3338                        old_system_break: am.system_break,
3339                        new_system_break: bm.system_break,
3340                        old_page_break: am.page_break,
3341                        new_page_break: bm.page_break,
3342                        old_section_break: am.section_break,
3343                        new_section_break: bm.section_break,
3344                    });
3345                }
3346                for vi in 0..4usize {
3347                    let av = &am.voices[vi];
3348                    let bv = &bm.voices[vi];
3349                    for (ni, (a_note, b_note)) in av.iter().zip(bv.iter()).enumerate() {
3350                        if !note_content_eq(a_note, b_note) {
3351                            changes.push(ScoreChange::NoteModified {
3352                                part: pi,
3353                                staff: si,
3354                                measure: mi,
3355                                voice: vi,
3356                                note_index: ni,
3357                                old: Box::new(a_note.clone()),
3358                                new: Box::new(b_note.clone()),
3359                            });
3360                        }
3361                    }
3362                    for note in av.iter().skip(bv.len()) {
3363                        changes.push(ScoreChange::NoteRemoved {
3364                            part: pi,
3365                            staff: si,
3366                            measure: mi,
3367                            voice: vi,
3368                            note: Box::new(note.clone()),
3369                        });
3370                    }
3371                    for ni in av.len()..bv.len() {
3372                        changes.push(ScoreChange::NoteAdded {
3373                            part: pi,
3374                            staff: si,
3375                            measure: mi,
3376                            voice: vi,
3377                            note_index: ni,
3378                        });
3379                    }
3380                }
3381                if am.time_sig != bm.time_sig {
3382                    changes.push(ScoreChange::TimeSigChanged {
3383                        part: pi,
3384                        staff: si,
3385                        measure: mi,
3386                        old: am.time_sig.clone(),
3387                        new: bm.time_sig.clone(),
3388                    });
3389                }
3390                if am.time_symbol != bm.time_symbol {
3391                    changes.push(ScoreChange::TimeSymbolChanged {
3392                        part: pi,
3393                        staff: si,
3394                        measure: mi,
3395                        old: am.time_symbol,
3396                        new: bm.time_symbol,
3397                    });
3398                }
3399                if am.tempo != bm.tempo {
3400                    changes.push(ScoreChange::MeasureTempoChanged {
3401                        part: pi,
3402                        staff: si,
3403                        measure: mi,
3404                        old: am.tempo,
3405                        new: bm.tempo,
3406                    });
3407                }
3408                if am.metronome_mark != bm.metronome_mark {
3409                    changes.push(ScoreChange::MeasureMetronomeMarkChanged {
3410                        part: pi,
3411                        staff: si,
3412                        measure: mi,
3413                        old: am.metronome_mark.clone(),
3414                        new: bm.metronome_mark.clone(),
3415                    });
3416                }
3417                if am.tempo_ramp_to != bm.tempo_ramp_to {
3418                    changes.push(ScoreChange::MeasureTempoRampChanged {
3419                        part: pi,
3420                        staff: si,
3421                        measure: mi,
3422                        old: am.tempo_ramp_to,
3423                        new: bm.tempo_ramp_to,
3424                    });
3425                }
3426                if am.barline_left != bm.barline_left || am.barline_right != bm.barline_right {
3427                    changes.push(ScoreChange::BarlineChanged {
3428                        part: pi,
3429                        staff: si,
3430                        measure: mi,
3431                    });
3432                }
3433                if am.rehearsal != bm.rehearsal {
3434                    changes.push(ScoreChange::RehearsalMarkChanged {
3435                        part: pi,
3436                        staff: si,
3437                        measure: mi,
3438                        old: am.rehearsal.clone(),
3439                        new: bm.rehearsal.clone(),
3440                    });
3441                }
3442                if am.volta != bm.volta {
3443                    changes.push(ScoreChange::VoltaChanged {
3444                        part: pi,
3445                        staff: si,
3446                        measure: mi,
3447                    });
3448                }
3449                if am.texts != bm.texts {
3450                    changes.push(ScoreChange::MeasureTextChanged {
3451                        part: pi,
3452                        staff: si,
3453                        measure: mi,
3454                        old: am.texts.clone(),
3455                        new: bm.texts.clone(),
3456                    });
3457                }
3458                if am.figured_bass != bm.figured_bass {
3459                    changes.push(ScoreChange::FiguredBassChanged {
3460                        part: pi,
3461                        staff: si,
3462                        measure: mi,
3463                        old: am.figured_bass.clone(),
3464                        new: bm.figured_bass.clone(),
3465                    });
3466                }
3467                if am.harp_pedal_diagrams != bm.harp_pedal_diagrams {
3468                    changes.push(ScoreChange::HarpPedalDiagramsChanged {
3469                        part: pi,
3470                        staff: si,
3471                        measure: mi,
3472                        old: am.harp_pedal_diagrams.clone(),
3473                        new: bm.harp_pedal_diagrams.clone(),
3474                    });
3475                }
3476            }
3477        }
3478    }
3479
3480    changes
3481}
3482
3483fn first_unrepresented_field_change(a: &Score, b: &Score) -> Option<String> {
3484    if a.settings.time_signature != b.settings.time_signature {
3485        return Some("settings.time_signature".to_string());
3486    }
3487    if a.part_groups.len() != b.part_groups.len()
3488        || a.part_groups.iter().zip(&b.part_groups).any(|(x, y)| {
3489            x.first_part != y.first_part
3490                || x.last_part != y.last_part
3491                || x.symbol != y.symbol
3492                || x.barlines_connect != y.barlines_connect
3493        })
3494    {
3495        return Some("part_groups".to_string());
3496    }
3497    a.parts
3498        .iter()
3499        .zip(&b.parts)
3500        .enumerate()
3501        .find_map(|(part_index, (ap, bp))| first_unrepresented_part_change(part_index, ap, bp))
3502}
3503
3504fn first_unrepresented_part_change(part_index: usize, a: &Part, b: &Part) -> Option<String> {
3505    let prefix = format!("parts[{part_index}]");
3506    if a.percussion_instruments != b.percussion_instruments {
3507        return Some(format!("{prefix}.percussion_instruments"));
3508    }
3509    if a.staff_groups != b.staff_groups {
3510        return Some(format!("{prefix}.staff_groups"));
3511    }
3512    if a.staves.len() != b.staves.len() {
3513        return Some(format!("{prefix}.staves"));
3514    }
3515    a.staves
3516        .iter()
3517        .zip(&b.staves)
3518        .enumerate()
3519        .find_map(|(staff_index, (a, b))| {
3520            first_unrepresented_staff_change(&prefix, staff_index, a, b)
3521        })
3522}
3523
3524fn first_unrepresented_staff_change(
3525    part_prefix: &str,
3526    staff_index: usize,
3527    a: &Staff,
3528    b: &Staff,
3529) -> Option<String> {
3530    let prefix = format!("{part_prefix}.staves[{staff_index}]");
3531    if a.measures.len() != b.measures.len() {
3532        return Some(format!("{prefix}.measures"));
3533    }
3534    a.measures
3535        .iter()
3536        .zip(&b.measures)
3537        .enumerate()
3538        .find_map(|(measure_index, (a, b))| {
3539            first_unrepresented_measure_change(&prefix, measure_index, a, b)
3540        })
3541}
3542
3543fn first_unrepresented_measure_change(
3544    _staff_prefix: &str,
3545    _measure_index: usize,
3546    _a: &Measure,
3547    _b: &Measure,
3548) -> Option<String> {
3549    None
3550}
3551
3552// ── ScorePatch ────────────────────────────────────────────────────────────────
3553
3554/// An individually applicable patch operation produced by [`score_patch`].
3555///
3556/// Unlike [`ScoreChange`], every variant carries enough data to apply the change to a
3557/// [`Score`] without needing the original score. Use [`apply_patch`] to apply a list.
3558#[derive(Debug, Clone, Serialize, Deserialize)]
3559pub enum ScorePatch {
3560    SetScoreViews {
3561        value: Vec<ScoreView>,
3562    },
3563    SetScoreTexts {
3564        value: Vec<StyledText>,
3565    },
3566    SetMeasureTexts {
3567        part: usize,
3568        staff: usize,
3569        measure: usize,
3570        value: Vec<StyledText>,
3571    },
3572    SetFiguredBass {
3573        part: usize,
3574        staff: usize,
3575        measure: usize,
3576        value: Vec<FiguredBassFigure>,
3577    },
3578    SetHarpPedalDiagrams {
3579        part: usize,
3580        staff: usize,
3581        measure: usize,
3582        value: Vec<HarpPedalDiagram>,
3583    },
3584    SetChordDefinitions {
3585        value: Vec<ChordDefinition>,
3586    },
3587    SetPartNames {
3588        part: usize,
3589        name: String,
3590        short_name: String,
3591    },
3592    SetPartMidi {
3593        part: usize,
3594        channel: u8,
3595        program: u8,
3596    },
3597    SetInstrumentDefinition {
3598        part: usize,
3599        value: Option<InstrumentDefinition>,
3600    },
3601    SetPartMidiAutomation {
3602        part: usize,
3603        pitch_bends: Vec<MidiPitchBend>,
3604        control_changes: Vec<MidiControlChange>,
3605        program_changes: Vec<MidiProgramChange>,
3606        aftertouch: Vec<MidiAftertouch>,
3607    },
3608    SetStaffConfiguration {
3609        part: usize,
3610        staff: usize,
3611        clef: Clef,
3612        transpose_semitones: i8,
3613    },
3614    SetStaffPresentation {
3615        part: usize,
3616        staff: usize,
3617        value: StaffPresentation,
3618    },
3619    SetMeasurePresentation {
3620        part: usize,
3621        staff: usize,
3622        measure: usize,
3623        number: u32,
3624        clef: Option<Clef>,
3625        tempo_text: Option<String>,
3626        navigation: Option<String>,
3627        expression_text: Option<String>,
3628        multi_rest_count: Option<u8>,
3629        system_break: bool,
3630        page_break: bool,
3631        #[serde(default)]
3632        section_break: bool,
3633    },
3634    SetTablatureConfig {
3635        part: usize,
3636        staff: usize,
3637        value: Option<TablatureConfig>,
3638    },
3639    SetMeasureTablatureChange {
3640        part: usize,
3641        staff: usize,
3642        measure: usize,
3643        value: Option<TablatureConfig>,
3644    },
3645    SetMeasureMidClefs {
3646        part: usize,
3647        staff: usize,
3648        measure: usize,
3649        value: Vec<MidMeasureClef>,
3650    },
3651    SetMetadata {
3652        field: String,
3653        value: String,
3654    },
3655    SetTempo {
3656        value: u16,
3657    },
3658    SetKeySignature {
3659        part: usize,
3660        staff: usize,
3661        measure: usize,
3662        value: Option<KeySignature>,
3663    },
3664    SetTimeSignature {
3665        part: usize,
3666        staff: usize,
3667        measure: usize,
3668        value: Option<TimeSignature>,
3669    },
3670    SetTimeSymbol {
3671        part: usize,
3672        staff: usize,
3673        measure: usize,
3674        value: Option<TimeSymbol>,
3675    },
3676    SetBarlines {
3677        part: usize,
3678        staff: usize,
3679        measure: usize,
3680        left: Barline,
3681        right: Barline,
3682    },
3683    SetRehearsal {
3684        part: usize,
3685        staff: usize,
3686        measure: usize,
3687        value: Option<String>,
3688    },
3689    SetVolta {
3690        part: usize,
3691        staff: usize,
3692        measure: usize,
3693        value: Option<VoltaBracket>,
3694    },
3695    /// Insert `note` at `note_index` in the given voice (existing notes shift right).
3696    AddNote {
3697        part: usize,
3698        staff: usize,
3699        measure: usize,
3700        voice: usize,
3701        /// Position for insertion. `usize::MAX` is the legacy append sentinel.
3702        #[serde(default = "legacy_append_index")]
3703        note_index: usize,
3704        note: Box<Note>,
3705    },
3706    RemoveNote {
3707        part: usize,
3708        staff: usize,
3709        measure: usize,
3710        voice: usize,
3711        note_index: usize,
3712    },
3713    /// Replace the note at `note_index` with `note`.
3714    ReplaceNote {
3715        part: usize,
3716        staff: usize,
3717        measure: usize,
3718        voice: usize,
3719        note_index: usize,
3720        note: Box<Note>,
3721    },
3722    SetMeasureTempo {
3723        part: usize,
3724        staff: usize,
3725        measure: usize,
3726        value: Option<u16>,
3727    },
3728    SetMeasureMetronomeMark {
3729        part: usize,
3730        staff: usize,
3731        measure: usize,
3732        value: Option<MetronomeMark>,
3733    },
3734    SetMeasureTempoRamp {
3735        part: usize,
3736        staff: usize,
3737        measure: usize,
3738        value: Option<u16>,
3739    },
3740    /// Replace the complete score when a change cannot be represented safely by
3741    /// positional operations (for example, a part or measure was added).
3742    ReplaceScore {
3743        score: Box<Score>,
3744    },
3745}
3746
3747fn legacy_append_index() -> usize {
3748    usize::MAX
3749}
3750
3751/// Return whether positional patches would lose score data. The patch format deliberately
3752/// keeps the common editing operations small; fields without a dedicated operation use the
3753/// complete-score fallback so an interchange round-trip never silently drops notation.
3754fn patch_requires_replace(a: &Score, b: &Score) -> bool {
3755    if a.settings.time_signature != b.settings.time_signature
3756        || a.settings.key_signature != b.settings.key_signature
3757        || a.parts.len() != b.parts.len()
3758        || a.part_groups.len() != b.part_groups.len()
3759    {
3760        return true;
3761    }
3762    if a.part_groups.iter().zip(&b.part_groups).any(|(x, y)| {
3763        x.first_part != y.first_part
3764            || x.last_part != y.last_part
3765            || x.symbol != y.symbol
3766            || x.barlines_connect != y.barlines_connect
3767    }) {
3768        return true;
3769    }
3770    for (ap, bp) in a.parts.iter().zip(&b.parts) {
3771        if ap.percussion_instruments != bp.percussion_instruments
3772            || ap.staff_groups != bp.staff_groups
3773            || ap.staves.len() != bp.staves.len()
3774        {
3775            return true;
3776        }
3777        for (as_, bs) in ap.staves.iter().zip(&bp.staves) {
3778            if as_.measures.len() != bs.measures.len() {
3779                return true;
3780            }
3781        }
3782    }
3783    false
3784}
3785
3786/// Compare two scores and return a list of [`ScorePatch`] operations.
3787///
3788/// Applying the patches to `a` via [`apply_patch`] produces a score structurally
3789/// equivalent to `b` (same parts, staves, measures, and note content).
3790pub fn score_patch(a: &Score, b: &Score) -> Vec<ScorePatch> {
3791    let mut patches: Vec<ScorePatch> = Vec::new();
3792
3793    if patch_requires_replace(a, b) {
3794        return vec![ScorePatch::ReplaceScore {
3795            score: Box::new(b.clone()),
3796        }];
3797    }
3798
3799    if a.views != b.views {
3800        patches.push(ScorePatch::SetScoreViews {
3801            value: b.views.clone(),
3802        });
3803    }
3804
3805    if a.texts != b.texts {
3806        patches.push(ScorePatch::SetScoreTexts {
3807            value: b.texts.clone(),
3808        });
3809    }
3810    if a.chord_definitions != b.chord_definitions {
3811        patches.push(ScorePatch::SetChordDefinitions {
3812            value: b.chord_definitions.clone(),
3813        });
3814    }
3815
3816    macro_rules! meta {
3817        ($field:ident, $name:literal) => {
3818            if a.metadata.$field != b.metadata.$field {
3819                patches.push(ScorePatch::SetMetadata {
3820                    field: $name.to_string(),
3821                    value: b.metadata.$field.clone(),
3822                });
3823            }
3824        };
3825    }
3826    meta!(title, "title");
3827    meta!(composer, "composer");
3828    meta!(lyricist, "lyricist");
3829    meta!(copyright, "copyright");
3830    meta!(work_number, "work_number");
3831    meta!(movement_title, "movement_title");
3832
3833    if a.settings.tempo_bpm != b.settings.tempo_bpm {
3834        patches.push(ScorePatch::SetTempo {
3835            value: b.settings.tempo_bpm,
3836        });
3837    }
3838
3839    for pi in 0..a.parts.len().min(b.parts.len()) {
3840        let ap = &a.parts[pi];
3841        let bp = &b.parts[pi];
3842        if ap.name != bp.name || ap.short_name != bp.short_name {
3843            patches.push(ScorePatch::SetPartNames {
3844                part: pi,
3845                name: bp.name.clone(),
3846                short_name: bp.short_name.clone(),
3847            });
3848        }
3849        if ap.midi_channel != bp.midi_channel || ap.midi_program != bp.midi_program {
3850            patches.push(ScorePatch::SetPartMidi {
3851                part: pi,
3852                channel: bp.midi_channel,
3853                program: bp.midi_program,
3854            });
3855        }
3856        if ap.instrument != bp.instrument {
3857            patches.push(ScorePatch::SetInstrumentDefinition {
3858                part: pi,
3859                value: bp.instrument.clone(),
3860            });
3861        }
3862        if ap.midi_pitch_bends != bp.midi_pitch_bends
3863            || ap.midi_control_changes != bp.midi_control_changes
3864            || ap.midi_program_changes != bp.midi_program_changes
3865            || ap.midi_aftertouch != bp.midi_aftertouch
3866        {
3867            patches.push(ScorePatch::SetPartMidiAutomation {
3868                part: pi,
3869                pitch_bends: bp.midi_pitch_bends.clone(),
3870                control_changes: bp.midi_control_changes.clone(),
3871                program_changes: bp.midi_program_changes.clone(),
3872                aftertouch: bp.midi_aftertouch.clone(),
3873            });
3874        }
3875        for si in 0..ap.staves.len().min(bp.staves.len()) {
3876            let a_staff = &ap.staves[si];
3877            let b_staff = &bp.staves[si];
3878            if a_staff.clef != b_staff.clef
3879                || a_staff.transpose_semitones != b_staff.transpose_semitones
3880            {
3881                patches.push(ScorePatch::SetStaffConfiguration {
3882                    part: pi,
3883                    staff: si,
3884                    clef: b_staff.clef.clone(),
3885                    transpose_semitones: b_staff.transpose_semitones,
3886                });
3887            }
3888            if a_staff.tablature != b_staff.tablature {
3889                patches.push(ScorePatch::SetTablatureConfig {
3890                    part: pi,
3891                    staff: si,
3892                    value: b_staff.tablature.clone(),
3893                });
3894            }
3895            if a_staff.presentation != b_staff.presentation {
3896                patches.push(ScorePatch::SetStaffPresentation {
3897                    part: pi,
3898                    staff: si,
3899                    value: b_staff.presentation.clone(),
3900                });
3901            }
3902            for mi in 0..a_staff.measures.len().min(b_staff.measures.len()) {
3903                let am = &a_staff.measures[mi];
3904                let bm = &b_staff.measures[mi];
3905
3906                if am.tablature_change != bm.tablature_change {
3907                    patches.push(ScorePatch::SetMeasureTablatureChange {
3908                        part: pi,
3909                        staff: si,
3910                        measure: mi,
3911                        value: bm.tablature_change.clone(),
3912                    });
3913                }
3914
3915                if am.mid_clefs != bm.mid_clefs {
3916                    patches.push(ScorePatch::SetMeasureMidClefs {
3917                        part: pi,
3918                        staff: si,
3919                        measure: mi,
3920                        value: bm.mid_clefs.clone(),
3921                    });
3922                }
3923
3924                if am.number != bm.number
3925                    || am.clef != bm.clef
3926                    || am.tempo_text != bm.tempo_text
3927                    || am.navigation != bm.navigation
3928                    || am.expression_text != bm.expression_text
3929                    || am.multi_rest_count != bm.multi_rest_count
3930                    || am.system_break != bm.system_break
3931                    || am.page_break != bm.page_break
3932                    || am.section_break != bm.section_break
3933                {
3934                    patches.push(ScorePatch::SetMeasurePresentation {
3935                        part: pi,
3936                        staff: si,
3937                        measure: mi,
3938                        number: bm.number,
3939                        clef: bm.clef.clone(),
3940                        tempo_text: bm.tempo_text.clone(),
3941                        navigation: bm.navigation.clone(),
3942                        expression_text: bm.expression_text.clone(),
3943                        multi_rest_count: bm.multi_rest_count,
3944                        system_break: bm.system_break,
3945                        page_break: bm.page_break,
3946                        section_break: bm.section_break,
3947                    });
3948                }
3949                if am.key_sig != bm.key_sig {
3950                    patches.push(ScorePatch::SetKeySignature {
3951                        part: pi,
3952                        staff: si,
3953                        measure: mi,
3954                        value: bm.key_sig.clone(),
3955                    });
3956                }
3957                if am.time_sig != bm.time_sig {
3958                    patches.push(ScorePatch::SetTimeSignature {
3959                        part: pi,
3960                        staff: si,
3961                        measure: mi,
3962                        value: bm.time_sig.clone(),
3963                    });
3964                }
3965                if am.time_symbol != bm.time_symbol {
3966                    patches.push(ScorePatch::SetTimeSymbol {
3967                        part: pi,
3968                        staff: si,
3969                        measure: mi,
3970                        value: bm.time_symbol,
3971                    });
3972                }
3973                if am.barline_left != bm.barline_left || am.barline_right != bm.barline_right {
3974                    patches.push(ScorePatch::SetBarlines {
3975                        part: pi,
3976                        staff: si,
3977                        measure: mi,
3978                        left: bm.barline_left.clone(),
3979                        right: bm.barline_right.clone(),
3980                    });
3981                }
3982                if am.rehearsal != bm.rehearsal {
3983                    patches.push(ScorePatch::SetRehearsal {
3984                        part: pi,
3985                        staff: si,
3986                        measure: mi,
3987                        value: bm.rehearsal.clone(),
3988                    });
3989                }
3990                if am.volta != bm.volta {
3991                    patches.push(ScorePatch::SetVolta {
3992                        part: pi,
3993                        staff: si,
3994                        measure: mi,
3995                        value: bm.volta.clone(),
3996                    });
3997                }
3998                if am.tempo != bm.tempo {
3999                    patches.push(ScorePatch::SetMeasureTempo {
4000                        part: pi,
4001                        staff: si,
4002                        measure: mi,
4003                        value: bm.tempo,
4004                    });
4005                }
4006                if am.metronome_mark != bm.metronome_mark {
4007                    patches.push(ScorePatch::SetMeasureMetronomeMark {
4008                        part: pi,
4009                        staff: si,
4010                        measure: mi,
4011                        value: bm.metronome_mark.clone(),
4012                    });
4013                }
4014                if am.tempo_ramp_to != bm.tempo_ramp_to {
4015                    patches.push(ScorePatch::SetMeasureTempoRamp {
4016                        part: pi,
4017                        staff: si,
4018                        measure: mi,
4019                        value: bm.tempo_ramp_to,
4020                    });
4021                }
4022                if am.texts != bm.texts {
4023                    patches.push(ScorePatch::SetMeasureTexts {
4024                        part: pi,
4025                        staff: si,
4026                        measure: mi,
4027                        value: bm.texts.clone(),
4028                    });
4029                }
4030                if am.figured_bass != bm.figured_bass {
4031                    patches.push(ScorePatch::SetFiguredBass {
4032                        part: pi,
4033                        staff: si,
4034                        measure: mi,
4035                        value: bm.figured_bass.clone(),
4036                    });
4037                }
4038                if am.harp_pedal_diagrams != bm.harp_pedal_diagrams {
4039                    patches.push(ScorePatch::SetHarpPedalDiagrams {
4040                        part: pi,
4041                        staff: si,
4042                        measure: mi,
4043                        value: bm.harp_pedal_diagrams.clone(),
4044                    });
4045                }
4046
4047                for vi in 0..4usize {
4048                    let av = &am.voices[vi];
4049                    let bv = &bm.voices[vi];
4050                    for (ni, (a_note, b_note)) in av.iter().zip(bv.iter()).enumerate() {
4051                        if !note_content_eq(a_note, b_note) {
4052                            patches.push(ScorePatch::ReplaceNote {
4053                                part: pi,
4054                                staff: si,
4055                                measure: mi,
4056                                voice: vi,
4057                                note_index: ni,
4058                                note: Box::new(b_note.clone()),
4059                            });
4060                        }
4061                    }
4062                    // Notes in `a` beyond `b` — remove in reverse order to preserve indices.
4063                    for ni in (bv.len()..av.len()).rev() {
4064                        patches.push(ScorePatch::RemoveNote {
4065                            part: pi,
4066                            staff: si,
4067                            measure: mi,
4068                            voice: vi,
4069                            note_index: ni,
4070                        });
4071                    }
4072                    // Notes in `b` beyond `a` — append.
4073                    for (offset, note) in bv.iter().skip(av.len()).enumerate() {
4074                        patches.push(ScorePatch::AddNote {
4075                            part: pi,
4076                            staff: si,
4077                            measure: mi,
4078                            voice: vi,
4079                            note_index: av.len() + offset,
4080                            note: Box::new(note.clone()),
4081                        });
4082                    }
4083                }
4084            }
4085        }
4086    }
4087
4088    patches
4089}
4090
4091/// Apply a list of [`ScorePatch`] operations to a cloned copy of `score`.
4092///
4093/// Returns `Err(Error::InvalidPatch)` if any patch references an out-of-bounds index.
4094/// The returned score is an independent clone — `score` is not modified.
4095pub fn apply_patch(score: &Score, patches: &[ScorePatch]) -> Result<Score, Error> {
4096    let mut s = score.clone();
4097    for patch in patches {
4098        match patch {
4099            ScorePatch::ReplaceScore { score } => {
4100                s = (**score).clone();
4101            }
4102            ScorePatch::SetScoreViews { value } => {
4103                s.views = value.clone();
4104            }
4105            ScorePatch::SetScoreTexts { value } => {
4106                s.texts = value.clone();
4107            }
4108            ScorePatch::SetMeasureTexts {
4109                part,
4110                staff,
4111                measure,
4112                value,
4113            } => {
4114                s.parts
4115                    .get_mut(*part)
4116                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4117                    .staves
4118                    .get_mut(*staff)
4119                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4120                    .measures
4121                    .get_mut(*measure)
4122                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4123                    .texts = value.clone();
4124            }
4125            ScorePatch::SetFiguredBass {
4126                part,
4127                staff,
4128                measure,
4129                value,
4130            } => {
4131                s.parts
4132                    .get_mut(*part)
4133                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4134                    .staves
4135                    .get_mut(*staff)
4136                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4137                    .measures
4138                    .get_mut(*measure)
4139                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4140                    .figured_bass = value.clone();
4141            }
4142            ScorePatch::SetHarpPedalDiagrams {
4143                part,
4144                staff,
4145                measure,
4146                value,
4147            } => {
4148                s.parts
4149                    .get_mut(*part)
4150                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4151                    .staves
4152                    .get_mut(*staff)
4153                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4154                    .measures
4155                    .get_mut(*measure)
4156                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4157                    .harp_pedal_diagrams = value.clone();
4158            }
4159            ScorePatch::SetChordDefinitions { value } => {
4160                s.chord_definitions = value.clone();
4161            }
4162            ScorePatch::SetPartNames {
4163                part,
4164                name,
4165                short_name,
4166            } => {
4167                let target = s
4168                    .parts
4169                    .get_mut(*part)
4170                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?;
4171                target.name = name.clone();
4172                target.short_name = short_name.clone();
4173            }
4174            ScorePatch::SetPartMidi {
4175                part,
4176                channel,
4177                program,
4178            } => {
4179                let target = s
4180                    .parts
4181                    .get_mut(*part)
4182                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?;
4183                target.midi_channel = *channel;
4184                target.midi_program = *program;
4185            }
4186            ScorePatch::SetInstrumentDefinition { part, value } => {
4187                s.parts
4188                    .get_mut(*part)
4189                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4190                    .instrument = value.clone();
4191            }
4192            ScorePatch::SetPartMidiAutomation {
4193                part,
4194                pitch_bends,
4195                control_changes,
4196                program_changes,
4197                aftertouch,
4198            } => {
4199                let target = s
4200                    .parts
4201                    .get_mut(*part)
4202                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?;
4203                target.midi_pitch_bends = pitch_bends.clone();
4204                target.midi_control_changes = control_changes.clone();
4205                target.midi_program_changes = program_changes.clone();
4206                target.midi_aftertouch = aftertouch.clone();
4207            }
4208            ScorePatch::SetStaffConfiguration {
4209                part,
4210                staff,
4211                clef,
4212                transpose_semitones,
4213            } => {
4214                let target = s
4215                    .parts
4216                    .get_mut(*part)
4217                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4218                    .staves
4219                    .get_mut(*staff)
4220                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?;
4221                target.clef = clef.clone();
4222                target.transpose_semitones = *transpose_semitones;
4223            }
4224            ScorePatch::SetStaffPresentation { part, staff, value } => {
4225                let target = s
4226                    .parts
4227                    .get_mut(*part)
4228                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4229                    .staves
4230                    .get_mut(*staff)
4231                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?;
4232                target.presentation = value.clone();
4233            }
4234            ScorePatch::SetMeasurePresentation {
4235                part,
4236                staff,
4237                measure,
4238                number,
4239                clef,
4240                tempo_text,
4241                navigation,
4242                expression_text,
4243                multi_rest_count,
4244                system_break,
4245                page_break,
4246                section_break,
4247            } => {
4248                let target = s
4249                    .parts
4250                    .get_mut(*part)
4251                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4252                    .staves
4253                    .get_mut(*staff)
4254                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4255                    .measures
4256                    .get_mut(*measure)
4257                    .ok_or_else(|| {
4258                        Error::InvalidPatch(format!("measure {measure} out of range"))
4259                    })?;
4260                target.number = *number;
4261                target.clef = clef.clone();
4262                target.tempo_text = tempo_text.clone();
4263                target.navigation = navigation.clone();
4264                target.expression_text = expression_text.clone();
4265                target.multi_rest_count = *multi_rest_count;
4266                target.system_break = *system_break;
4267                target.page_break = *page_break;
4268                target.section_break = *section_break;
4269            }
4270            ScorePatch::SetTablatureConfig { part, staff, value } => {
4271                s.parts
4272                    .get_mut(*part)
4273                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4274                    .staves
4275                    .get_mut(*staff)
4276                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4277                    .tablature = value.clone();
4278            }
4279            ScorePatch::SetMeasureTablatureChange {
4280                part,
4281                staff,
4282                measure,
4283                value,
4284            } => {
4285                s.parts
4286                    .get_mut(*part)
4287                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4288                    .staves
4289                    .get_mut(*staff)
4290                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4291                    .measures
4292                    .get_mut(*measure)
4293                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4294                    .tablature_change = value.clone();
4295            }
4296            ScorePatch::SetMeasureMidClefs {
4297                part,
4298                staff,
4299                measure,
4300                value,
4301            } => {
4302                s.parts
4303                    .get_mut(*part)
4304                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4305                    .staves
4306                    .get_mut(*staff)
4307                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4308                    .measures
4309                    .get_mut(*measure)
4310                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4311                    .mid_clefs = value.clone();
4312            }
4313            ScorePatch::SetMetadata { field, value } => match field.as_str() {
4314                "title" => s.metadata.title = value.clone(),
4315                "composer" => s.metadata.composer = value.clone(),
4316                "lyricist" => s.metadata.lyricist = value.clone(),
4317                "copyright" => s.metadata.copyright = value.clone(),
4318                "work_number" => s.metadata.work_number = value.clone(),
4319                "movement_title" => s.metadata.movement_title = value.clone(),
4320                other => {
4321                    return Err(Error::InvalidPatch(format!(
4322                        "unknown metadata field: {other}"
4323                    )));
4324                }
4325            },
4326            ScorePatch::SetTempo { value } => {
4327                s.settings.tempo_bpm = *value;
4328            }
4329            ScorePatch::SetKeySignature {
4330                part,
4331                staff,
4332                measure,
4333                value,
4334            } => {
4335                s.parts
4336                    .get_mut(*part)
4337                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4338                    .staves
4339                    .get_mut(*staff)
4340                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4341                    .measures
4342                    .get_mut(*measure)
4343                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4344                    .key_sig = value.clone();
4345            }
4346            ScorePatch::SetTimeSignature {
4347                part,
4348                staff,
4349                measure,
4350                value,
4351            } => {
4352                s.parts
4353                    .get_mut(*part)
4354                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4355                    .staves
4356                    .get_mut(*staff)
4357                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4358                    .measures
4359                    .get_mut(*measure)
4360                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4361                    .time_sig = value.clone();
4362            }
4363            ScorePatch::SetTimeSymbol {
4364                part,
4365                staff,
4366                measure,
4367                value,
4368            } => {
4369                s.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                    .measures
4376                    .get_mut(*measure)
4377                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4378                    .time_symbol = *value;
4379            }
4380            ScorePatch::SetBarlines {
4381                part,
4382                staff,
4383                measure,
4384                left,
4385                right,
4386            } => {
4387                let m = s
4388                    .parts
4389                    .get_mut(*part)
4390                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4391                    .staves
4392                    .get_mut(*staff)
4393                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4394                    .measures
4395                    .get_mut(*measure)
4396                    .ok_or_else(|| {
4397                        Error::InvalidPatch(format!("measure {measure} out of range"))
4398                    })?;
4399                m.barline_left = left.clone();
4400                m.barline_right = right.clone();
4401            }
4402            ScorePatch::SetRehearsal {
4403                part,
4404                staff,
4405                measure,
4406                value,
4407            } => {
4408                s.parts
4409                    .get_mut(*part)
4410                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4411                    .staves
4412                    .get_mut(*staff)
4413                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4414                    .measures
4415                    .get_mut(*measure)
4416                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4417                    .rehearsal = value.clone();
4418            }
4419            ScorePatch::SetVolta {
4420                part,
4421                staff,
4422                measure,
4423                value,
4424            } => {
4425                s.parts
4426                    .get_mut(*part)
4427                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4428                    .staves
4429                    .get_mut(*staff)
4430                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4431                    .measures
4432                    .get_mut(*measure)
4433                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4434                    .volta = value.clone();
4435            }
4436            ScorePatch::AddNote {
4437                part,
4438                staff,
4439                measure,
4440                voice,
4441                note_index,
4442                note,
4443            } => {
4444                let v = s
4445                    .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                    .voices
4455                    .get_mut(*voice)
4456                    .ok_or_else(|| Error::InvalidPatch(format!("voice {voice} out of range")))?;
4457                let insert_at = if *note_index == usize::MAX {
4458                    v.len()
4459                } else {
4460                    *note_index
4461                };
4462                if insert_at > v.len() {
4463                    return Err(Error::InvalidPatch(format!(
4464                        "note_index {note_index} out of range"
4465                    )));
4466                }
4467                v.insert(insert_at, *note.clone());
4468            }
4469            ScorePatch::RemoveNote {
4470                part,
4471                staff,
4472                measure,
4473                voice,
4474                note_index,
4475            } => {
4476                let v = s
4477                    .parts
4478                    .get_mut(*part)
4479                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4480                    .staves
4481                    .get_mut(*staff)
4482                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4483                    .measures
4484                    .get_mut(*measure)
4485                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4486                    .voices
4487                    .get_mut(*voice)
4488                    .ok_or_else(|| Error::InvalidPatch(format!("voice {voice} out of range")))?;
4489                if *note_index >= v.len() {
4490                    return Err(Error::InvalidPatch(format!(
4491                        "note_index {note_index} out of range"
4492                    )));
4493                }
4494                v.remove(*note_index);
4495            }
4496            ScorePatch::ReplaceNote {
4497                part,
4498                staff,
4499                measure,
4500                voice,
4501                note_index,
4502                note,
4503            } => {
4504                let v = s
4505                    .parts
4506                    .get_mut(*part)
4507                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4508                    .staves
4509                    .get_mut(*staff)
4510                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4511                    .measures
4512                    .get_mut(*measure)
4513                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4514                    .voices
4515                    .get_mut(*voice)
4516                    .ok_or_else(|| Error::InvalidPatch(format!("voice {voice} out of range")))?;
4517                if *note_index >= v.len() {
4518                    return Err(Error::InvalidPatch(format!(
4519                        "note_index {note_index} out of range"
4520                    )));
4521                }
4522                v[*note_index] = *note.clone();
4523            }
4524            ScorePatch::SetMeasureTempo {
4525                part,
4526                staff,
4527                measure,
4528                value,
4529            } => {
4530                s.parts
4531                    .get_mut(*part)
4532                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4533                    .staves
4534                    .get_mut(*staff)
4535                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4536                    .measures
4537                    .get_mut(*measure)
4538                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4539                    .tempo = *value;
4540            }
4541            ScorePatch::SetMeasureTempoRamp {
4542                part,
4543                staff,
4544                measure,
4545                value,
4546            } => {
4547                s.parts
4548                    .get_mut(*part)
4549                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4550                    .staves
4551                    .get_mut(*staff)
4552                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4553                    .measures
4554                    .get_mut(*measure)
4555                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4556                    .tempo_ramp_to = *value;
4557            }
4558            ScorePatch::SetMeasureMetronomeMark {
4559                part,
4560                staff,
4561                measure,
4562                value,
4563            } => {
4564                s.parts
4565                    .get_mut(*part)
4566                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
4567                    .staves
4568                    .get_mut(*staff)
4569                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
4570                    .measures
4571                    .get_mut(*measure)
4572                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
4573                    .metronome_mark = value.clone();
4574            }
4575        }
4576    }
4577    if !super::validate::validate(&s).is_valid() {
4578        return Err(Error::InvalidScore);
4579    }
4580    Ok(s)
4581}
4582
4583/// Beats a voice occupies in a measure lasting `measure_beats`. A voice holding only one plain
4584/// whole rest is a measure rest and lasts exactly the measure in any time signature (as in
4585/// MusicXML and MuseScore); otherwise the voice lasts the sum of its notes.
4586pub fn voice_duration_beats(voice: &[Note], measure_beats: f64) -> f64 {
4587    match voice {
4588        [only] if only.is_plain_whole_rest() => measure_beats,
4589        _ => voice.iter().map(Note::beats).sum(),
4590    }
4591}
4592
4593/// Respell all pitches in the score to prefer flats or sharps.
4594///
4595/// Applies [`Pitch::respell`] to every note in every part, staff, measure, and voice.
4596pub fn respell_score(score: &mut Score, prefer_flat: bool) {
4597    for part in &mut score.parts {
4598        for staff in &mut part.staves {
4599            for measure in &mut staff.measures {
4600                for voice in &mut measure.voices {
4601                    for note in voice.iter_mut() {
4602                        for pitch in &mut note.pitches {
4603                            *pitch = pitch.respell(prefer_flat);
4604                        }
4605                    }
4606                }
4607            }
4608        }
4609    }
4610}
4611
4612/// Spelling policy for [`respell_staff_region`].
4613#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4614#[serde(rename_all = "snake_case")]
4615pub enum RespellPolicy {
4616    /// Spell black-key pitches with flats.
4617    Flat,
4618    /// Spell black-key pitches with sharps.
4619    Sharp,
4620    /// Follow the key signature in effect at each measure: flat keys use flats, others sharps.
4621    Key,
4622}
4623
4624/// Respell the pitched notes of one staff in the measure range `start_measure..end_measure`
4625/// (end exclusive), the selection form of [`respell_score`].
4626///
4627/// With [`RespellPolicy::Key`], each measure uses its own key change, then the part's first-staff
4628/// key change, then the score key. Rests and unpitched notes are left unchanged, because an
4629/// unpitched note's pitch is its staff position. Sounding pitch and microtones never change.
4630///
4631/// A tie never joins two spellings: a tied chain takes the spelling of its first note inside the
4632/// range, including chain members before or after the range. Returns the inclusive measure span
4633/// that changed, which is wider than the range when a tie crosses its boundary.
4634pub fn respell_staff_region(
4635    score: &mut Score,
4636    part_index: usize,
4637    staff_index: usize,
4638    start_measure: usize,
4639    end_measure: usize,
4640    policy: RespellPolicy,
4641) -> Result<(usize, usize), Error> {
4642    let score_key_flat = score.settings.key_signature.fifths < 0;
4643    let part = score
4644        .parts
4645        .get_mut(part_index)
4646        .ok_or(Error::PartNotFound(part_index))?;
4647    let staff_count = part.staves.len();
4648    if staff_index >= staff_count {
4649        return Err(Error::StaffNotFound(staff_index));
4650    }
4651    let measure_count = part.staves[staff_index].measures.len();
4652    if start_measure >= end_measure || end_measure > measure_count {
4653        return Err(Error::InvalidCommand(format!(
4654            "invalid measure range {start_measure}..{end_measure}"
4655        )));
4656    }
4657    let mut prefer_flat = Vec::with_capacity(end_measure);
4658    let mut running_flat = score_key_flat;
4659    for index in 0..end_measure {
4660        let key = part.staves[staff_index].measures[index]
4661            .key_sig
4662            .as_ref()
4663            .or_else(|| {
4664                part.staves[0]
4665                    .measures
4666                    .get(index)
4667                    .and_then(|measure| measure.key_sig.as_ref())
4668            });
4669        if let Some(key) = key {
4670            running_flat = key.fifths < 0;
4671        }
4672        prefer_flat.push(match policy {
4673            RespellPolicy::Flat => true,
4674            RespellPolicy::Sharp => false,
4675            RespellPolicy::Key => running_flat,
4676        });
4677    }
4678
4679    let measures = &mut part.staves[staff_index].measures;
4680    for (index, measure) in measures
4681        .iter_mut()
4682        .enumerate()
4683        .take(end_measure)
4684        .skip(start_measure)
4685    {
4686        for voice in &mut measure.voices {
4687            for note in voice
4688                .iter_mut()
4689                .filter(|note| !note.is_rest && !note.is_unpitched)
4690            {
4691                for pitch in &mut note.pitches {
4692                    *pitch = pitch.respell(prefer_flat[index]);
4693                }
4694            }
4695        }
4696    }
4697
4698    let mut changed = (start_measure, end_measure - 1);
4699    for voice in 0..4 {
4700        for measure in start_measure..end_measure {
4701            for note in 0..measures[measure].voices[voice].len() {
4702                if measures[measure].voices[voice][note].tie_start {
4703                    let last = propagate_tied_spelling(measures, voice, (measure, note), true);
4704                    changed.1 = changed.1.max(last);
4705                }
4706            }
4707        }
4708        if !measures[start_measure].voices[voice].is_empty() {
4709            let first = propagate_tied_spelling(measures, voice, (start_measure, 0), false);
4710            changed.0 = changed.0.min(first);
4711        }
4712    }
4713    Ok(changed)
4714}
4715
4716/// Copy one note's spelling along its tie chain (forward through `tie_start`, or backward through
4717/// `tie_end`), matching chord members by sounding pitch. Returns the last measure reached.
4718fn propagate_tied_spelling(
4719    measures: &mut [Measure],
4720    voice: usize,
4721    from: (usize, usize),
4722    forward: bool,
4723) -> usize {
4724    let mut current = from;
4725    loop {
4726        let note = &measures[current.0].voices[voice][current.1];
4727        if !(if forward {
4728            note.tie_start
4729        } else {
4730            note.tie_end
4731        }) {
4732            return current.0;
4733        }
4734        let neighbor = if forward {
4735            if current.1 + 1 < measures[current.0].voices[voice].len() {
4736                Some((current.0, current.1 + 1))
4737            } else {
4738                measures
4739                    .get(current.0 + 1)
4740                    .filter(|measure| !measure.voices[voice].is_empty())
4741                    .map(|_| (current.0 + 1, 0))
4742            }
4743        } else if current.1 > 0 {
4744            Some((current.0, current.1 - 1))
4745        } else {
4746            current.0.checked_sub(1).and_then(|previous| {
4747                measures[previous].voices[voice]
4748                    .len()
4749                    .checked_sub(1)
4750                    .map(|last| (previous, last))
4751            })
4752        };
4753        let Some(next) = neighbor else {
4754            return current.0;
4755        };
4756        let next_note = &measures[next.0].voices[voice][next.1];
4757        if !(if forward {
4758            next_note.tie_end
4759        } else {
4760            next_note.tie_start
4761        }) {
4762            return current.0;
4763        }
4764        let source = measures[current.0].voices[voice][current.1].pitches.clone();
4765        for pitch in &mut measures[next.0].voices[voice][next.1].pitches {
4766            if let Some(spelled) = source.iter().find(|candidate| {
4767                candidate.to_midi() == pitch.to_midi()
4768                    && candidate.microtone_cents == pitch.microtone_cents
4769            }) {
4770                *pitch = spelled.clone();
4771            }
4772        }
4773        current = next;
4774    }
4775}
4776
4777/// Respell all pitches to match the score's key signature spelling convention.
4778///
4779/// Flat-key signatures (fifths < 0) use flat spellings; sharp-key and C major use sharps.
4780pub fn respell_score_to_key(score: &mut Score) {
4781    let prefer_flat = score.settings.key_signature.fifths < 0;
4782    respell_score(score, prefer_flat);
4783}
4784
4785/// Compute total playback duration in seconds.
4786///
4787/// Uses `measure_sequence` for correct repeat handling. Lighter than generating
4788/// full playback events — suitable for progress bars and UI display.
4789pub fn score_duration_secs(score: &Score) -> f64 {
4790    if score.settings.tempo_bpm == 0 {
4791        return 0.0;
4792    }
4793    let seq = measure_sequence(score);
4794    let mut total_secs = 0.0f64;
4795    let mut current_bpm = score.settings.tempo_bpm as f64;
4796    if let Some(staff) = score.parts.first().and_then(|p| p.staves.first()) {
4797        for &idx in &seq {
4798            if let Some(m) = staff.measures.get(idx) {
4799                if let Some(b) = m.tempo {
4800                    current_bpm = b as f64;
4801                }
4802                if current_bpm == 0.0 {
4803                    continue;
4804                }
4805                let beats = voice_duration_beats(
4806                    &m.voices[0],
4807                    staff.measure_beats(idx, &score.settings.time_signature),
4808                );
4809                total_secs += tempo_ramp_duration_secs(current_bpm, m.tempo_ramp_to, beats);
4810                if let Some(target) = m.tempo_ramp_to.filter(|target| *target > 0) {
4811                    current_bpm = f64::from(target);
4812                }
4813            }
4814        }
4815    }
4816    total_secs
4817}
4818
4819/// Compute playback duration in seconds for a specific measure range (inclusive).
4820///
4821/// `region` is `(start_measure, end_measure)`, both 0-based. Measures outside the range
4822/// are excluded. Uses `measure_sequence` for correct repeat handling.
4823pub fn score_duration_secs_region(score: &Score, region: (usize, usize)) -> f64 {
4824    if score.settings.tempo_bpm == 0 {
4825        return 0.0;
4826    }
4827    let seq: Vec<usize> = measure_sequence(score)
4828        .into_iter()
4829        .filter(|&idx| idx >= region.0 && idx <= region.1)
4830        .collect();
4831    let mut total_secs = 0.0f64;
4832    let mut current_bpm = score.settings.tempo_bpm as f64;
4833    if let Some(staff) = score.parts.first().and_then(|p| p.staves.first()) {
4834        for &idx in &seq {
4835            if let Some(m) = staff.measures.get(idx) {
4836                if let Some(b) = m.tempo {
4837                    current_bpm = b as f64;
4838                }
4839                if current_bpm == 0.0 {
4840                    continue;
4841                }
4842                let beats = voice_duration_beats(
4843                    &m.voices[0],
4844                    staff.measure_beats(idx, &score.settings.time_signature),
4845                );
4846                total_secs += tempo_ramp_duration_secs(current_bpm, m.tempo_ramp_to, beats);
4847                if let Some(target) = m.tempo_ramp_to.filter(|target| *target > 0) {
4848                    current_bpm = f64::from(target);
4849                }
4850            }
4851        }
4852    }
4853    total_secs
4854}
4855
4856fn tempo_ramp_duration_secs(start_bpm: f64, target_bpm: Option<u16>, beats: f64) -> f64 {
4857    let Some(target_bpm) = target_bpm.filter(|target| *target > 0) else {
4858        return beats / start_bpm * 60.0;
4859    };
4860    let end_bpm = f64::from(target_bpm);
4861    let delta = end_bpm - start_bpm;
4862    if delta.abs() < f64::EPSILON {
4863        return beats / start_bpm * 60.0;
4864    }
4865    60.0 * beats / delta * (end_bpm / start_bpm).ln()
4866}
4867
4868/// Return the number of beats available in a voice before it is full.
4869///
4870/// Uses [`Note::beats`] which correctly handles tuplet scaling.
4871/// Returns `Ok(0.0)` when the voice is already full or over-full.
4872pub fn measure_beats_remaining(
4873    score: &Score,
4874    part_index: usize,
4875    staff_index: usize,
4876    measure_index: usize,
4877    voice_index: usize,
4878) -> Result<f64, Error> {
4879    let part = score
4880        .parts
4881        .get(part_index)
4882        .ok_or(Error::PartNotFound(part_index))?;
4883    let staff = part
4884        .staves
4885        .get(staff_index)
4886        .ok_or(Error::StaffNotFound(staff_index))?;
4887    let measure = staff
4888        .measures
4889        .get(measure_index)
4890        .ok_or(Error::MeasureNotFound(measure_index))?;
4891    let voice = measure
4892        .voices
4893        .get(voice_index)
4894        .ok_or(Error::VoiceOutOfRange(voice_index))?;
4895    let capacity = staff.measure_beats(measure_index, &score.settings.time_signature);
4896    Ok((capacity - voice_duration_beats(voice, capacity)).max(0.0))
4897}
4898
4899/// Suggest whether the stem should point up for the given pitches and clef.
4900///
4901/// Conventional rule: if the average MIDI pitch of the chord is below the staff
4902/// middle line, the stem points up; at or above, it points down.
4903/// For empty pitch lists (rests), returns `true` by convention.
4904pub fn suggested_stem_up(pitches: &[Pitch], clef: &Clef) -> bool {
4905    if pitches.is_empty() {
4906        return true;
4907    }
4908    let avg = pitches.iter().map(|p| p.to_midi() as f64).sum::<f64>() / pitches.len() as f64;
4909    avg < clef.middle_line_midi() as f64
4910}
4911
4912fn beam_beat_size(ts: &TimeSignature) -> f64 {
4913    if ts.numerator.is_multiple_of(3) && ts.numerator >= 6 && ts.denominator >= 8 {
4914        3.0 * 4.0 / ts.denominator as f64
4915    } else {
4916        4.0 / ts.denominator as f64
4917    }
4918}
4919
4920/// Compute recommended [`BeamState`] values for a voice's notes.
4921///
4922/// Groups beamable notes (eighth or shorter, non-rest) within beat boundaries.
4923/// Returns a `Vec` the same length as `notes`.
4924pub fn compute_beams(notes: &[Note], time_sig: &TimeSignature) -> Vec<BeamState> {
4925    let beat_size = beam_beat_size(time_sig);
4926    let n = notes.len();
4927    let mut result = vec![BeamState::None; n];
4928
4929    let is_beamable = |note: &Note| -> bool {
4930        !note.is_rest
4931            && matches!(
4932                note.duration,
4933                Duration::Eighth
4934                    | Duration::Sixteenth
4935                    | Duration::ThirtySecond
4936                    | Duration::SixtyFourth
4937                    | Duration::HundredTwentyEighth
4938            )
4939    };
4940
4941    // Compute beat start positions
4942    let mut starts = Vec::with_capacity(n);
4943    let mut pos = 0.0f64;
4944    for note in notes {
4945        starts.push(pos);
4946        pos += note.beats();
4947    }
4948
4949    // A tuplet can end exactly on a beat while the accumulated f64 start is a
4950    // few ulps below it (15 copies of 1/15, for example). Snap only starts
4951    // within a much smaller interval than any supported note value.
4952    let group_id = |i: usize| -> i64 {
4953        let beat = starts[i] / beat_size;
4954        let nearest = beat.round();
4955        if (beat - nearest).abs() <= 1e-9 {
4956            nearest as i64
4957        } else {
4958            beat.floor() as i64
4959        }
4960    };
4961
4962    let mut i = 0;
4963    while i < n {
4964        if !is_beamable(&notes[i]) {
4965            i += 1;
4966            continue;
4967        }
4968        let g = group_id(i);
4969        // Find the run of beamable notes in the same beat group
4970        let mut j = i;
4971        while j < n && is_beamable(&notes[j]) && group_id(j) == g {
4972            j += 1;
4973        }
4974        let run = j - i;
4975        if run == 1 {
4976            result[i] = BeamState::None;
4977        } else {
4978            result[i] = BeamState::Begin;
4979            result[i + 1..j - 1].fill(BeamState::Continue);
4980            result[j - 1] = BeamState::End;
4981        }
4982        i = j;
4983    }
4984    result
4985}
4986
4987fn note_content_eq(a: &Note, b: &Note) -> bool {
4988    a.is_rest == b.is_rest
4989        && a.is_unpitched == b.is_unpitched
4990        && a.instrument_id == b.instrument_id
4991        && a.offset_x == b.offset_x
4992        && a.offset_y == b.offset_y
4993        && a.relative_x == b.relative_x
4994        && a.relative_y == b.relative_y
4995        && a.pitches == b.pitches
4996        && a.duration == b.duration
4997        && a.dot_count == b.dot_count
4998        && a.tie_start == b.tie_start
4999        && a.tie_end == b.tie_end
5000        && a.beam == b.beam
5001        && a.explicit_unbeamed == b.explicit_unbeamed
5002        && a.articulations == b.articulations
5003        && a.dynamic == b.dynamic
5004        && a.stem_up == b.stem_up
5005        && a.hairpin_start == b.hairpin_start
5006        && a.hairpin_end == b.hairpin_end
5007        && a.tuplet == b.tuplet
5008        && a.chord_symbol == b.chord_symbol
5009        && a.is_grace == b.is_grace
5010        && a.grace_slash == b.grace_slash
5011        && a.ottava_start == b.ottava_start
5012        && a.ottava_end == b.ottava_end
5013        && a.lyric == b.lyric
5014        && a.additional_lyrics == b.additional_lyrics
5015        && a.pedal_start == b.pedal_start
5016        && a.pedal_end == b.pedal_end
5017        && a.slur_start == b.slur_start
5018        && a.slur_end == b.slur_end
5019        && a.arpeggiate == b.arpeggiate
5020        && a.tab_position == b.tab_position
5021        && a.tab_positions == b.tab_positions
5022        && a.guitar_technique == b.guitar_technique
5023        && a.guitar_bend_alter_cents == b.guitar_bend_alter_cents
5024        && a.guitar_bend_curve == b.guitar_bend_curve
5025}
5026
5027fn per_pitch_flag(flags: &[bool], pitch_count: usize, index: usize, chord: bool) -> bool {
5028    if flags.len() == pitch_count {
5029        flags.get(index).copied().unwrap_or(chord)
5030    } else {
5031        chord
5032    }
5033}
5034
5035#[cfg(test)]
5036mod tests {
5037    use super::*;
5038    use crate::model::{
5039        notation::{FingeringSelectionPolicy, TextStyle},
5040        pitch::Step,
5041    };
5042
5043    #[test]
5044    fn fingering_selection_policy_is_deterministic_and_non_mutating() {
5045        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
5046        note.fingerings = vec![3, 1, 4];
5047        note.fingering = Some(3);
5048        assert_eq!(
5049            note.select_fingering(FingeringSelectionPolicy::SourceOrder),
5050            Some(3)
5051        );
5052        assert_eq!(
5053            note.select_fingering(FingeringSelectionPolicy::LowestNumber),
5054            Some(1)
5055        );
5056        assert_eq!(
5057            note.select_fingering(FingeringSelectionPolicy::HighestNumber),
5058            Some(4)
5059        );
5060        assert_eq!(note.fingerings, vec![3, 1, 4]);
5061        assert_eq!(note.fingering, Some(3));
5062    }
5063
5064    #[test]
5065    fn default_score_has_one_part_four_measures() {
5066        let score = Score::default();
5067        assert_eq!(score.parts.len(), 1);
5068        assert_eq!(score.parts[0].staves.len(), 1);
5069        assert_eq!(score.parts[0].staves[0].measures.len(), 4);
5070    }
5071
5072    #[test]
5073    fn assign_tablature_positions_is_capo_aware_and_preserves_explicit_positions() {
5074        let mut score = Score::new("Guitar", 120, 4, 4, 0, 1);
5075        score.parts[0].staves[0].tablature = Some(TablatureConfig {
5076            lines: 6,
5077            tuning_midi: vec![64, 59, 55, 50, 45, 40],
5078            capo: 2,
5079        });
5080        score.parts[0].staves[0].measures[0].voices[0].push(Note::new(
5081            Pitch::with_alter(Step::F, 4, 1),
5082            Duration::Quarter,
5083        ));
5084        score.parts[0].staves[0].measures[0].voices[0]
5085            .push(Note::new(Pitch::new(Step::G, 3), Duration::Quarter));
5086        score.parts[0].staves[0].measures[0].voices[0][2].tab_position =
5087            Some(TabPosition { string: 6, fret: 7 });
5088
5089        assert_eq!(assign_tablature_positions(&mut score), 1);
5090        let notes = &score.parts[0].staves[0].measures[0].voices[0];
5091        assert_eq!(
5092            notes[1].tab_position,
5093            Some(TabPosition { string: 1, fret: 0 })
5094        );
5095        assert_eq!(notes[1].string_number, Some(1));
5096        assert_eq!(
5097            notes[2].tab_position,
5098            Some(TabPosition { string: 6, fret: 7 })
5099        );
5100    }
5101
5102    #[test]
5103    fn assign_tablature_positions_uses_measure_local_capo_change() {
5104        let mut score = Score::new("Guitar", 120, 4, 4, 0, 2);
5105        score.parts[0].staves[0].tablature = Some(TablatureConfig {
5106            lines: 6,
5107            tuning_midi: vec![40, 45, 50, 55, 59, 64],
5108            capo: 0,
5109        });
5110        score.parts[0].staves[0].measures[1].tablature_change = Some(TablatureConfig {
5111            lines: 6,
5112            tuning_midi: vec![40, 45, 50, 55, 59, 64],
5113            capo: 2,
5114        });
5115        score.parts[0].staves[0].measures[1].voices[0]
5116            .push(Note::new(Pitch::new(Step::E, 4), Duration::Quarter));
5117
5118        assert_eq!(assign_tablature_positions(&mut score), 1);
5119        assert_eq!(
5120            score.parts[0].staves[0].measures[1].voices[0][1].tab_position,
5121            Some(TabPosition { string: 5, fret: 3 })
5122        );
5123    }
5124
5125    #[test]
5126    fn assign_tablature_positions_optimizes_chord_strings_and_fret_span() {
5127        let mut score = Score::new("Guitar", 120, 4, 4, 0, 1);
5128        score.parts[0].staves[0].tablature = Some(TablatureConfig {
5129            lines: 6,
5130            tuning_midi: vec![64, 59, 55, 50, 45, 40],
5131            capo: 0,
5132        });
5133        let mut chord = Note::new(Pitch::new(Step::E, 4), Duration::Quarter);
5134        chord.pitches.push(Pitch::new(Step::G, 4));
5135        score.parts[0].staves[0].measures[0].voices[0].push(chord);
5136
5137        assert_eq!(assign_tablature_positions(&mut score), 1);
5138        let positions = &score.parts[0].staves[0].measures[0].voices[0][1].tab_positions;
5139        assert_eq!(
5140            positions,
5141            &vec![
5142                TabPosition { string: 2, fret: 5 },
5143                TabPosition { string: 1, fret: 3 },
5144            ]
5145        );
5146    }
5147
5148    #[test]
5149    fn optimized_tablature_respects_an_authored_anchor_across_measures() {
5150        let mut score = Score::new("Two-string bass", 120, 4, 4, 0, 2);
5151        score.parts[0].staves[0].tablature = Some(TablatureConfig {
5152            lines: 2,
5153            tuning_midi: vec![40, 45],
5154            capo: 0,
5155        });
5156        let a2 = Pitch::new(Step::A, 2);
5157        let mut anchor = Note::new(a2.clone(), Duration::Quarter);
5158        anchor.tab_position = Some(TabPosition { string: 1, fret: 5 });
5159        score.parts[0].staves[0].measures[0].voices[0] = vec![anchor];
5160        score.parts[0].staves[0].measures[1].voices[0] = vec![
5161            Note::rest(Duration::Quarter),
5162            Note::new(a2, Duration::Quarter),
5163        ];
5164
5165        assert_eq!(optimize_tablature_positions(&mut score), 1);
5166        let anchor = &score.parts[0].staves[0].measures[0].voices[0][0];
5167        let assigned = &score.parts[0].staves[0].measures[1].voices[0][1];
5168        assert_eq!(
5169            anchor.tab_position,
5170            Some(TabPosition { string: 1, fret: 5 })
5171        );
5172        // The open second string has a lower fret but would require a larger hand move.
5173        assert_eq!(
5174            assigned.tab_position,
5175            Some(TabPosition { string: 1, fret: 5 })
5176        );
5177        assert_eq!(assigned.string_number, Some(1));
5178        assert_eq!(
5179            assigned.tab_positions,
5180            vec![TabPosition { string: 1, fret: 5 }]
5181        );
5182        assert_eq!(optimize_tablature_positions(&mut score), 0);
5183    }
5184
5185    #[test]
5186    fn optimized_tablature_assigns_distinct_strings_to_a_chord() {
5187        let mut score = Score::new("Guitar chord", 120, 4, 4, 0, 1);
5188        score.parts[0].staves[0].tablature = Some(TablatureConfig {
5189            lines: 6,
5190            tuning_midi: vec![64, 59, 55, 50, 45, 40],
5191            capo: 0,
5192        });
5193        let mut chord = Note::new(Pitch::new(Step::E, 4), Duration::Quarter);
5194        chord.pitches.push(Pitch::new(Step::G, 4));
5195        score.parts[0].staves[0].measures[0].voices[0] = vec![chord];
5196
5197        assert_eq!(optimize_tablature_positions(&mut score), 1);
5198        let assigned = &score.parts[0].staves[0].measures[0].voices[0][0];
5199        assert_eq!(assigned.tab_positions.len(), 2);
5200        assert_ne!(
5201            assigned.tab_positions[0].string,
5202            assigned.tab_positions[1].string
5203        );
5204        for (pitch, position) in assigned.pitches.iter().zip(&assigned.tab_positions) {
5205            let open = score.parts[0].staves[0]
5206                .tablature
5207                .as_ref()
5208                .unwrap()
5209                .tuning_midi[usize::from(position.string - 1)];
5210            assert_eq!(pitch.to_midi(), open + i16::from(position.fret));
5211        }
5212    }
5213
5214    #[test]
5215    fn new_score_measure_count() {
5216        let score = Score::new("Test", 120, 4, 4, 0, 8);
5217        assert_eq!(score.measure_count(), 8);
5218    }
5219
5220    #[test]
5221    fn note_beats_quarter() {
5222        let note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
5223        assert!((note.beats() - 1.0).abs() < 1e-9);
5224    }
5225
5226    #[test]
5227    fn note_beats_dotted_quarter() {
5228        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
5229        note.dot_count = 1;
5230        assert!((note.beats() - 1.5).abs() < 1e-9);
5231    }
5232
5233    #[test]
5234    fn grace_note_beats_zero() {
5235        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Eighth);
5236        note.is_grace = true;
5237        assert_eq!(note.beats(), 0.0);
5238    }
5239
5240    #[test]
5241    fn measure_empty_4_4_fills_four_beats() {
5242        let m = Measure::empty(4, 4);
5243        let total: f64 = m.voices[0].iter().map(|n| n.beats()).sum();
5244        assert!((total - 4.0).abs() < 1e-9);
5245    }
5246
5247    #[test]
5248    fn measure_empty_3_4_fills_three_beats() {
5249        let m = Measure::empty(3, 4);
5250        let total: f64 = m.voices[0].iter().map(|n| n.beats()).sum();
5251        assert!((total - 3.0).abs() < 1e-9);
5252    }
5253
5254    #[test]
5255    fn whole_filling_beats() {
5256        assert_eq!(Duration::whole_filling_beats(16.0), Duration::Longa);
5257        assert_eq!(Duration::whole_filling_beats(8.0), Duration::Breve);
5258        assert_eq!(Duration::whole_filling_beats(4.0), Duration::Whole);
5259        assert_eq!(Duration::whole_filling_beats(2.0), Duration::Half);
5260        assert_eq!(Duration::whole_filling_beats(1.0), Duration::Quarter);
5261    }
5262
5263    // ── ScoreStats ────────────────────────────────────────────────────────────
5264
5265    #[test]
5266    fn statistics_default_score_all_rests() {
5267        let score = Score::default();
5268        let s = score.statistics();
5269        assert_eq!(s.part_count, 1);
5270        assert_eq!(s.measure_count, 4);
5271        assert_eq!(s.note_count, 0);
5272        assert!(s.rest_count > 0);
5273    }
5274
5275    #[test]
5276    fn statistics_duration_estimate() {
5277        // 4/4, 120 BPM, 1 measure → 4 beats → 2.0 s
5278        let score = Score::new("T", 120, 4, 4, 0, 1);
5279        let s = score.statistics();
5280        assert!((s.estimated_duration_secs - 2.0).abs() < 0.01);
5281    }
5282
5283    #[test]
5284    fn score_duration_secs_matches_statistics() {
5285        use super::score_duration_secs;
5286        let score = Score::new("T", 120, 4, 4, 0, 4);
5287        let secs = score_duration_secs(&score);
5288        // 4/4, 120 BPM, 4 measures → 16 beats → 8.0 s
5289        assert!((secs - 8.0).abs() < 0.01, "expected ~8.0 s, got {secs}");
5290    }
5291
5292    #[test]
5293    fn score_duration_secs_integrates_measure_tempo_ramp() {
5294        use super::score_duration_secs;
5295        let mut score = Score::new("Ramp", 120, 4, 4, 0, 1);
5296        score.parts[0].staves[0].measures[0].tempo_ramp_to = Some(60);
5297        let expected = 4.0 * 60.0 / (60.0 - 120.0) * (60.0f64 / 120.0).ln();
5298        assert!((score_duration_secs(&score) - expected).abs() < 1e-9);
5299    }
5300
5301    #[test]
5302    fn score_duration_secs_zero_bpm_returns_zero() {
5303        use super::score_duration_secs;
5304        let mut score = Score::new("T", 120, 4, 4, 0, 1);
5305        score.settings.tempo_bpm = 0;
5306        assert_eq!(score_duration_secs(&score), 0.0);
5307    }
5308
5309    #[test]
5310    fn score_duration_secs_per_measure_tempo() {
5311        use super::score_duration_secs;
5312        // 2 measures: measure 0 at 120 BPM (2.0 s), measure 1 at 60 BPM (4.0 s)
5313        let mut score = Score::new("T", 120, 4, 4, 0, 2);
5314        score.parts[0].staves[0].measures[1].tempo = Some(60);
5315        let secs = score_duration_secs(&score);
5316        assert!((secs - 6.0).abs() < 0.01, "expected ~6.0 s, got {secs}");
5317    }
5318
5319    // ── extract_part ──────────────────────────────────────────────────────────
5320
5321    #[test]
5322    fn extract_part_returns_single_part_score() {
5323        let mut score = Score::default();
5324        let mut p2 = Part::new("Violin", "Vln.");
5325        p2.staves.push(Staff::new(Clef::Treble));
5326        score.parts.push(p2);
5327        let ex = score.extract_part(0).unwrap();
5328        assert_eq!(ex.parts.len(), 1);
5329        assert_ne!(ex.id, score.id);
5330        assert_eq!(ex.metadata.title, score.metadata.title);
5331    }
5332
5333    #[test]
5334    fn extract_part_out_of_range_is_none() {
5335        let score = Score::default();
5336        assert!(score.extract_part(99).is_none());
5337    }
5338
5339    #[test]
5340    fn extract_and_merge_remap_typed_spanner_part_addresses() {
5341        let mut left = Score::template(ScoreTemplate::StringQuartet);
5342        let address = NoteAddr {
5343            part: 1,
5344            staff: 0,
5345            measure: 0,
5346            voice: 0,
5347            note: 0,
5348        };
5349        left.spanners.push(NotationSpanner {
5350            id: "left-span".to_string(),
5351            kind: NotationSpannerKind::Slur,
5352            start: address.clone(),
5353            end: address,
5354            number: Some(1),
5355            line_type: None,
5356            text: None,
5357            placement: None,
5358            ottava_size: None,
5359            ottava_type: None,
5360        });
5361        let extracted = left.extract_part_checked(1).expect("valid extracted part");
5362        assert_eq!(extracted.spanners[0].start.part, 0);
5363        assert_eq!(extracted.spanners[0].end.part, 0);
5364
5365        let mut right = Score::new("R", 120, 4, 4, 0, 1);
5366        let right_address = NoteAddr {
5367            part: 0,
5368            staff: 0,
5369            measure: 0,
5370            voice: 0,
5371            note: 0,
5372        };
5373        right.spanners.push(NotationSpanner {
5374            id: "right-span".to_string(),
5375            kind: NotationSpannerKind::Pedal,
5376            start: right_address.clone(),
5377            end: right_address,
5378            number: Some(1),
5379            line_type: None,
5380            text: None,
5381            placement: None,
5382            ottava_size: None,
5383            ottava_type: None,
5384        });
5385        let merged = left.merge_checked(&right).expect("valid merged score");
5386        let right_span = merged
5387            .spanners
5388            .iter()
5389            .find(|spanner| spanner.id == "right-span")
5390            .expect("merged right span");
5391        assert_eq!(right_span.start.part, left.parts.len());
5392        assert_eq!(right_span.end.part, left.parts.len());
5393    }
5394
5395    // ── transpose ─────────────────────────────────────────────────────────────
5396
5397    #[test]
5398    fn transpose_zero_is_clone() {
5399        let score = Score::new("T", 120, 4, 4, 0, 1);
5400        let t = transpose(&score, 0);
5401        assert_eq!(t.settings.key_signature.fifths, 0);
5402    }
5403
5404    #[test]
5405    fn transpose_staff_region_rewrites_only_selected_written_measures() {
5406        let mut score = Score::new("T", 120, 4, 4, 0, 2);
5407        for measure in &mut score.parts[0].staves[0].measures {
5408            measure.voices[0] = vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
5409        }
5410        let transformed = transpose_staff_region_checked(
5411            &score,
5412            0,
5413            0,
5414            1,
5415            2,
5416            2,
5417            RegionalTranspositionTarget::Written,
5418        )
5419        .expect("region should transpose");
5420        assert_eq!(
5421            transformed.parts[0].staves[0].measures[0].voices[0][0].pitches[0],
5422            Pitch::new(Step::C, 4)
5423        );
5424        assert_eq!(
5425            transformed.parts[0].staves[0].measures[1].voices[0][0].pitches[0],
5426            Pitch::new(Step::D, 4)
5427        );
5428        assert_eq!(
5429            score.parts[0].staves[0].measures[1].voices[0][0].pitches[0],
5430            Pitch::new(Step::C, 4)
5431        );
5432    }
5433
5434    #[test]
5435    fn regional_concert_transposition_requires_full_staff() {
5436        let score = Score::new("T", 120, 4, 4, 0, 2);
5437        assert!(
5438            transpose_staff_region_checked(
5439                &score,
5440                0,
5441                0,
5442                1,
5443                2,
5444                -2,
5445                RegionalTranspositionTarget::Concert,
5446            )
5447            .is_err()
5448        );
5449    }
5450
5451    #[test]
5452    fn transpose_c_major_up_2_to_d_major() {
5453        let score = Score::new("T", 120, 4, 4, 0, 1);
5454        assert_eq!(transpose(&score, 2).settings.key_signature.fifths, 2);
5455    }
5456
5457    #[test]
5458    fn transpose_d_major_up_5_to_g_major() {
5459        let score = Score::new("T", 120, 4, 4, 2, 1);
5460        assert_eq!(transpose(&score, 5).settings.key_signature.fifths, 1);
5461    }
5462
5463    #[test]
5464    fn transpose_c4_up_1_to_csharp4() {
5465        let p = transpose_pitch(&Pitch::new(Step::C, 4), 1);
5466        assert_eq!(p.to_midi(), 61);
5467        assert_eq!(p.step, Step::C);
5468        assert_eq!(p.alter, 1);
5469    }
5470
5471    #[test]
5472    fn transpose_c4_down_1_to_b3() {
5473        let p = transpose_pitch(&Pitch::new(Step::C, 4), -1);
5474        assert_eq!(p.to_midi(), 59);
5475        assert_eq!(p.step, Step::B);
5476        assert_eq!(p.alter, 0);
5477    }
5478
5479    #[test]
5480    fn transpose_up_octave_keeps_step() {
5481        let p = transpose_pitch(&Pitch::new(Step::A, 4), 12);
5482        assert_eq!(p.to_midi(), 81);
5483        assert_eq!(p.step, Step::A);
5484        assert_eq!(p.octave, 5);
5485    }
5486
5487    #[test]
5488    fn statistics_with_repeat_doubles_duration() {
5489        // 4/4, 120 BPM, 2 measures with RepeatStart+RepeatEnd → plays twice → 4 measures worth
5490        let mut score = Score::new("T", 120, 4, 4, 0, 2);
5491        score.parts[0].staves[0].measures[0].barline_left =
5492            crate::model::notation::Barline::RepeatStart;
5493        score.parts[0].staves[0].measures[1].barline_right =
5494            crate::model::notation::Barline::RepeatEnd;
5495        let s = score.statistics();
5496        // 4 beats × 4 measures (2 physical × 2 passes) ÷ 120 BPM × 60 = 8.0 s
5497        assert!((s.estimated_duration_secs - 8.0).abs() < 0.01);
5498    }
5499
5500    #[test]
5501    fn transpose_octave_boundary_b4_to_c5() {
5502        // B4 (midi=71) + 1 semitone = C5 (midi=72)
5503        let p = transpose_pitch(&Pitch::new(Step::B, 4), 1);
5504        assert_eq!(p.to_midi(), 72);
5505        assert_eq!(p.step, Step::C);
5506        assert_eq!(p.octave, 5);
5507    }
5508
5509    #[test]
5510    fn transpose_clamp_at_midi_127() {
5511        // G9 (midi=127) + 3 semitones → clamped to 127
5512        let p = transpose_pitch(&Pitch::new(Step::G, 9), 3);
5513        assert_eq!(p.to_midi(), 127);
5514    }
5515
5516    // ── merge ─────────────────────────────────────────────────────────────────
5517
5518    #[test]
5519    fn merge_combines_parts() {
5520        let mut a = Score::new("A", 120, 4, 4, 0, 2);
5521        let b = Score::new("B", 120, 4, 4, 0, 2);
5522        // Add a second part to score a
5523        let mut p2 = Part::new("Violin", "Vln.");
5524        p2.staves.push(Staff::new(Clef::Treble));
5525        for i in 0..2usize {
5526            let mut m = Measure::empty(4, 4);
5527            m.number = i as u32 + 1;
5528            p2.staves[0].measures.push(m);
5529        }
5530        a.parts.push(p2);
5531        let merged = a.merge(&b);
5532        // a has 2 parts, b has 1 part → merged has 3 parts
5533        assert_eq!(merged.parts.len(), 3);
5534    }
5535
5536    #[test]
5537    fn merge_pads_shorter_score() {
5538        let a = Score::new("A", 120, 4, 4, 0, 4);
5539        let b = Score::new("B", 120, 4, 4, 0, 2);
5540        let merged = a.merge(&b);
5541        // Both parts should have 4 measures
5542        assert_eq!(merged.parts[0].staves[0].measures.len(), 4);
5543        assert_eq!(merged.parts[1].staves[0].measures.len(), 4);
5544    }
5545
5546    #[test]
5547    fn merge_uses_self_metadata() {
5548        let mut a = Score::new("Title A", 120, 4, 4, 0, 2);
5549        a.metadata.composer = "Composer A".to_string();
5550        let b = Score::new("Title B", 120, 4, 4, 0, 2);
5551        let merged = a.merge(&b);
5552        assert_eq!(merged.metadata.title, "Title A");
5553        assert_eq!(merged.metadata.composer, "Composer A");
5554    }
5555
5556    #[test]
5557    fn merge_new_id_differs_from_both() {
5558        let a = Score::new("A", 120, 4, 4, 0, 2);
5559        let b = Score::new("B", 120, 4, 4, 0, 2);
5560        let merged = a.merge(&b);
5561        assert_ne!(merged.id, a.id);
5562        assert_ne!(merged.id, b.id);
5563    }
5564
5565    // ── Staff.transpose_semitones ─────────────────────────────────────────────
5566
5567    #[test]
5568    fn staff_default_transpose_is_zero() {
5569        let s = Staff::new(Clef::Treble);
5570        assert_eq!(s.transpose_semitones, 0);
5571    }
5572
5573    #[test]
5574    fn staff_presentation_defaults_and_tracks_percussion_clef() {
5575        let standard = Staff::new(Clef::Treble);
5576        assert_eq!(standard.presentation, StaffPresentation::default());
5577
5578        let percussion = Staff::new(Clef::Percussion);
5579        assert_eq!(percussion.presentation.kind, StaffKind::Percussion);
5580        assert_eq!(percussion.presentation.lines, 5);
5581        assert!(percussion.presentation.visible);
5582    }
5583
5584    // ── schema_version ────────────────────────────────────────────────────────
5585
5586    #[test]
5587    fn score_default_has_schema_version_1() {
5588        let score = Score::default();
5589        assert_eq!(score.schema_version, 1);
5590    }
5591
5592    #[test]
5593    fn score_new_has_schema_version_1() {
5594        let score = Score::new("T", 120, 4, 4, 0, 4);
5595        assert_eq!(score.schema_version, 1);
5596    }
5597
5598    #[test]
5599    fn score_without_schema_version_deserializes_to_zero() {
5600        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":[]}"#;
5601        let score: Score = serde_json::from_str(json).unwrap();
5602        assert_eq!(score.schema_version, 0);
5603    }
5604
5605    #[test]
5606    fn legacy_score_json_defaults_typed_spanners() {
5607        let score = Score::new("Legacy", 120, 4, 4, 0, 1);
5608        let mut value = serde_json::to_value(score).expect("score serializes");
5609        value
5610            .as_object_mut()
5611            .expect("score is an object")
5612            .remove("spanners");
5613        let restored: Score = serde_json::from_value(value).expect("legacy score deserializes");
5614        assert!(restored.spanners.is_empty());
5615    }
5616
5617    #[test]
5618    fn legacy_staff_json_defaults_presentation() {
5619        let score = Score::new("Legacy", 120, 4, 4, 0, 1);
5620        let mut value = serde_json::to_value(score).expect("score serializes");
5621        value["parts"][0]["staves"][0]
5622            .as_object_mut()
5623            .expect("staff is an object")
5624            .remove("presentation");
5625
5626        let restored: Score = serde_json::from_value(value).expect("legacy score deserializes");
5627        assert_eq!(
5628            restored.parts[0].staves[0].presentation,
5629            StaffPresentation::default()
5630        );
5631    }
5632
5633    #[test]
5634    fn legacy_part_json_defaults_instrument_definition() {
5635        let score = Score::new("Legacy", 120, 4, 4, 0, 1);
5636        let mut value = serde_json::to_value(score).expect("score serializes");
5637        value["parts"][0]
5638            .as_object_mut()
5639            .expect("part is an object")
5640            .remove("instrument");
5641
5642        let restored: Score = serde_json::from_value(value).expect("legacy score deserializes");
5643        assert!(restored.parts[0].instrument.is_none());
5644    }
5645
5646    #[test]
5647    fn resolve_view_projects_linked_part_without_mutating_source_score() {
5648        let mut score = Score::template(ScoreTemplate::StringQuartet);
5649        let mut view = ScoreView::linked_part("violin-2", "Violin II", 1);
5650        view.layout.measures_per_row = Some(2);
5651        score.views.push(view);
5652
5653        let projected = score.resolve_view("violin-2").expect("view resolves");
5654        assert_eq!(projected.parts.len(), 1);
5655        assert_eq!(projected.parts[0].name, "Violin II");
5656        assert_eq!(projected.views.len(), 1);
5657        assert_eq!(projected.views[0].parts, vec![0]);
5658        assert_eq!(score.parts.len(), 4);
5659        assert_eq!(score.views[0].parts, vec![1]);
5660    }
5661
5662    #[test]
5663    fn resolve_view_applies_linked_standard_and_tablature_presentations_non_destructively() {
5664        let mut score = Score::new("Guitar", 120, 4, 4, 0, 1);
5665        score.parts[0].staves[0].tablature = Some(TablatureConfig {
5666            lines: 6,
5667            tuning_midi: vec![40, 45, 50, 55, 59, 64],
5668            capo: 0,
5669        });
5670        score.parts[0].staves[0].presentation.kind = StaffKind::Standard;
5671        score.views.push(ScoreView::linked_tablature_staff(
5672            "guitar-tab",
5673            "Guitar Tab",
5674            0,
5675            0,
5676        ));
5677
5678        let projected = score.resolve_view("guitar-tab").expect("view resolves");
5679        assert_eq!(
5680            projected.parts[0].staves[0].presentation.kind,
5681            StaffKind::Tablature
5682        );
5683        assert_eq!(
5684            projected.views[0].staff_kind_overrides[0].staff,
5685            ViewStaffRef { part: 0, staff: 0 }
5686        );
5687        assert_eq!(
5688            score.parts[0].staves[0].presentation.kind,
5689            StaffKind::Standard
5690        );
5691    }
5692
5693    #[test]
5694    fn legacy_score_view_json_defaults_staff_kind_overrides() {
5695        let mut score = Score::new("Legacy", 120, 4, 4, 0, 1);
5696        score.views.push(ScoreView::linked_part("part", "Part", 0));
5697        let mut value = serde_json::to_value(score).expect("score serializes");
5698        value["views"][0]
5699            .as_object_mut()
5700            .expect("view is an object")
5701            .remove("staff_kind_overrides");
5702
5703        let restored: Score = serde_json::from_value(value).expect("legacy score deserializes");
5704        assert!(restored.views[0].staff_kind_overrides.is_empty());
5705    }
5706
5707    #[test]
5708    fn typed_view_style_overrides_are_ordered_and_json_compatible() {
5709        let mut view = ScoreView::linked_part("part", "Part", 0);
5710        view.layout.typed_style_overrides = vec![
5711            ViewStyleOverride {
5712                property: ViewStyleProperty::TextScale,
5713                value: 0.9,
5714            },
5715            ViewStyleOverride {
5716                property: ViewStyleProperty::TextScale,
5717                value: 1.1,
5718            },
5719        ];
5720        assert_eq!(
5721            view.layout.style_value(ViewStyleProperty::TextScale),
5722            Some(1.1)
5723        );
5724        let restored: ScoreView =
5725            serde_json::from_str(&serde_json::to_string(&view).unwrap()).unwrap();
5726        assert_eq!(
5727            restored.layout.typed_style_overrides,
5728            view.layout.typed_style_overrides
5729        );
5730        assert_eq!(view.layout.resolved_style().text_scale, 1.1);
5731        assert_eq!(view.layout.resolved_style().system_gap, 2.0);
5732    }
5733
5734    #[test]
5735    fn score_style_defaults_are_inherited_then_overridden_by_view() {
5736        let mut score = Score::new("Style", 120, 4, 4, 0, 1);
5737        score.style_overrides = vec![
5738            ViewStyleOverride {
5739                property: ViewStyleProperty::StaffSpace,
5740                value: 1.2,
5741            },
5742            ViewStyleOverride {
5743                property: ViewStyleProperty::TextScale,
5744                value: 0.9,
5745            },
5746        ];
5747        let mut view = ScoreView::linked_part("part", "Part", 0);
5748        view.layout.typed_style_overrides.push(ViewStyleOverride {
5749            property: ViewStyleProperty::TextScale,
5750            value: 1.1,
5751        });
5752        let style = score.resolved_view_style(&view.layout);
5753        assert_eq!(style.staff_space, 1.2);
5754        assert_eq!(style.text_scale, 1.1);
5755        let restored: Score =
5756            serde_json::from_str(&serde_json::to_string(&score).unwrap()).unwrap();
5757        assert_eq!(restored.style_overrides, score.style_overrides);
5758    }
5759
5760    #[test]
5761    fn legacy_measure_json_defaults_source_voice_numbers() {
5762        let score = Score::new("Legacy", 120, 4, 4, 0, 1);
5763        let mut value = serde_json::to_value(&score).expect("score serializes");
5764        value["parts"][0]["staves"][0]["measures"][0]
5765            .as_object_mut()
5766            .expect("measure is an object")
5767            .remove("source_voice_numbers");
5768
5769        let restored: Score = serde_json::from_value(value).expect("legacy score deserializes");
5770        assert_eq!(
5771            restored.parts[0].staves[0].measures[0].source_voice_numbers,
5772            [None; 4]
5773        );
5774    }
5775
5776    #[test]
5777    fn legacy_measure_json_defaults_tempo_ramp() {
5778        let measure: Measure = serde_json::from_str(
5779            r#"{"number":1,"time_sig":null,"key_sig":null,"clef":null,"tempo":120,"barline_left":"Normal","barline_right":"Normal","voices":[[],[],[],[]]}"#,
5780        )
5781        .expect("legacy measure deserializes");
5782        assert_eq!(measure.tempo_ramp_to, None);
5783    }
5784
5785    #[test]
5786    fn note_without_new_percussion_fields_uses_serde_defaults() {
5787        let note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
5788        let mut value = serde_json::to_value(note).unwrap();
5789        let object = value.as_object_mut().unwrap();
5790        object.remove("is_unpitched");
5791        object.remove("instrument_id");
5792        let restored: Note = serde_json::from_value(value).unwrap();
5793        assert!(!restored.is_unpitched);
5794        assert_eq!(restored.instrument_id, None);
5795    }
5796
5797    #[test]
5798    fn percussion_instrument_resolution_prefers_id_then_display_key() {
5799        let mut part = Part::new("Drums", "Dr.");
5800        part.percussion_instruments = vec![
5801            PercussionInstrument {
5802                id: "snare".to_string(),
5803                name: Some("Acoustic Snare".to_string()),
5804                midi_unpitched: Some(38),
5805                staff_position: None,
5806                notehead: None,
5807                preferred_voice: None,
5808                techniques: Vec::new(),
5809            },
5810            PercussionInstrument {
5811                id: "rim".to_string(),
5812                name: Some("Side Stick".to_string()),
5813                midi_unpitched: Some(37),
5814                staff_position: None,
5815                notehead: None,
5816                preferred_voice: None,
5817                techniques: Vec::new(),
5818            },
5819        ];
5820        let mut note = Note::new(Pitch::from_midi(38, false), Duration::Quarter);
5821        note.is_unpitched = true;
5822        assert_eq!(
5823            part.percussion_instrument_for_note(&note)
5824                .map(|instrument| instrument.id.as_str()),
5825            Some("snare")
5826        );
5827        note.instrument_id = Some("rim".to_string());
5828        assert_eq!(
5829            part.percussion_instrument_for_note(&note)
5830                .map(|instrument| instrument.id.as_str()),
5831            Some("rim")
5832        );
5833        note.instrument_id = Some("missing".to_string());
5834        assert!(part.percussion_instrument_for_note(&note).is_none());
5835        note.instrument_id = None;
5836        note.is_unpitched = false;
5837        assert!(part.percussion_instrument_for_note(&note).is_none());
5838    }
5839
5840    #[test]
5841    fn percussion_kit_extensions_are_json_backward_compatible() {
5842        let mut instrument = PercussionInstrument {
5843            id: "snare".to_string(),
5844            name: Some("Acoustic Snare".to_string()),
5845            midi_unpitched: Some(38),
5846            staff_position: Some(0),
5847            notehead: Some(NoteHead::Cross),
5848            preferred_voice: Some(1),
5849            techniques: vec!["rim-shot".to_string()],
5850        };
5851        let mut value = serde_json::to_value(&instrument).expect("instrument serializes");
5852        let object = value
5853            .as_object_mut()
5854            .expect("percussion instrument is an object");
5855        object.remove("staff_position");
5856        object.remove("notehead");
5857        object.remove("preferred_voice");
5858        object.remove("techniques");
5859        instrument = serde_json::from_value(value).expect("legacy instrument deserializes");
5860        assert_eq!(instrument.staff_position, None);
5861        assert_eq!(instrument.notehead, None);
5862        assert_eq!(instrument.preferred_voice, None);
5863        assert!(instrument.techniques.is_empty());
5864    }
5865
5866    // ── ScoreTemplate ─────────────────────────────────────────────────────────
5867
5868    #[test]
5869    fn score_template_solo_has_one_part_treble() {
5870        let score = Score::template(ScoreTemplate::Solo);
5871        assert_eq!(score.parts.len(), 1);
5872        assert_eq!(score.parts[0].staves.len(), 1);
5873        assert_eq!(score.parts[0].staves[0].clef, Clef::Treble);
5874        assert_eq!(score.parts[0].midi_program, 0);
5875    }
5876
5877    #[test]
5878    fn score_template_piano_has_two_staves() {
5879        let score = Score::template(ScoreTemplate::Piano);
5880        assert_eq!(score.parts.len(), 1);
5881        assert_eq!(score.parts[0].staves.len(), 2);
5882        assert_eq!(score.parts[0].staves[0].clef, Clef::Treble);
5883        assert_eq!(score.parts[0].staves[1].clef, Clef::Bass);
5884    }
5885
5886    #[test]
5887    fn score_template_string_quartet_has_four_parts() {
5888        let score = Score::template(ScoreTemplate::StringQuartet);
5889        assert_eq!(score.parts.len(), 4);
5890        assert_eq!(score.parts[2].staves[0].clef, Clef::Alto); // Viola
5891        assert_eq!(score.parts[3].staves[0].clef, Clef::Bass); // Cello
5892        assert_eq!(score.parts[0].midi_program, 40);
5893        assert_eq!(score.parts[3].midi_program, 42);
5894    }
5895
5896    #[test]
5897    fn score_template_string_orchestra_has_five_parts() {
5898        let score = Score::template(ScoreTemplate::StringOrchestra);
5899        assert_eq!(score.parts.len(), 5);
5900        assert_eq!(score.parts[4].midi_program, 43); // Contrabass
5901    }
5902
5903    #[test]
5904    fn score_template_brass_quintet_has_five_parts() {
5905        let score = Score::template(ScoreTemplate::BrassQuintet);
5906        assert_eq!(score.parts.len(), 5);
5907        assert_eq!(score.parts[2].midi_program, 60); // French Horn
5908    }
5909
5910    #[test]
5911    fn score_template_default_measures_are_four() {
5912        let score = Score::template(ScoreTemplate::StringQuartet);
5913        for part in &score.parts {
5914            for staff in &part.staves {
5915                assert_eq!(staff.measures.len(), 4);
5916            }
5917        }
5918    }
5919
5920    // ── system_break / page_break ─────────────────────────────────────────────
5921
5922    #[test]
5923    fn measure_empty_has_no_breaks() {
5924        let m = Measure::empty(4, 4);
5925        assert!(!m.system_break);
5926        assert!(!m.page_break);
5927        assert!(!m.section_break);
5928    }
5929
5930    #[test]
5931    fn section_range_uses_semantic_boundaries_not_layout_breaks() {
5932        let mut score = Score::new("sections", 120, 4, 4, 0, 6);
5933        score.parts[0].staves[0].measures[0].section_break = true;
5934        score.parts[0].staves[0].measures[2].section_break = true;
5935        score.parts[0].staves[0].measures[4].section_break = true;
5936        score.parts[0].staves[0].measures[5].section_break = true;
5937        score.parts[0].staves[0].measures[1].system_break = true;
5938        score.parts[0].staves[0].measures[3].page_break = true;
5939        assert_eq!(score.section_range(0).unwrap(), 0..=1);
5940        assert_eq!(score.section_range(3).unwrap(), 2..=3);
5941        assert_eq!(score.section_range(4).unwrap(), 4..=4);
5942        assert_eq!(score.section_range(5).unwrap(), 5..=5);
5943        assert!(score.section_range(6).is_err());
5944    }
5945
5946    #[test]
5947    fn system_break_survives_json_roundtrip() {
5948        let mut m = Measure::empty(4, 4);
5949        m.system_break = true;
5950        let json = serde_json::to_string(&m).unwrap();
5951        let m2: Measure = serde_json::from_str(&json).unwrap();
5952        assert!(m2.system_break);
5953        assert!(!m2.page_break);
5954    }
5955
5956    // ── diff ──────────────────────────────────────────────────────────────────
5957
5958    #[test]
5959    fn diff_identical_scores_is_empty() {
5960        let s = Score::new("T", 120, 4, 4, 0, 2);
5961        assert!(diff(&s, &s).is_empty());
5962    }
5963
5964    #[test]
5965    fn score_patch_covers_measure_semantics_and_note_insert_index() {
5966        let mut a = Score::new("T", 120, 4, 4, 0, 1);
5967        a.parts[0].staves[0].measures[0].voices[0].clear();
5968        let mut b = a.clone();
5969        let measure = &mut b.parts[0].staves[0].measures[0];
5970        measure.key_sig = Some(KeySignature {
5971            fifths: -2,
5972            mode: "major".to_string(),
5973        });
5974        measure.time_sig = Some(TimeSignature {
5975            numerator: 3,
5976            denominator: 4,
5977        });
5978        measure.barline_left = Barline::RepeatStart;
5979        measure.barline_right = Barline::RepeatEnd;
5980        measure.rehearsal = Some("A".to_string());
5981        measure.volta = Some(VoltaBracket {
5982            number: 1,
5983            kind: "begin_end".to_string(),
5984            numbers: Vec::new(),
5985        });
5986        measure.texts.push(StyledText {
5987            position_beats: None,
5988            style: TextStyle::RehearsalMark,
5989            text: "A".to_string(),
5990            placement: None,
5991            offset_x: None,
5992            offset_y: None,
5993            relative_x: None,
5994            relative_y: None,
5995        });
5996        measure.figured_bass.push(FiguredBassFigure {
5997            number: "6".to_string(),
5998            alter: None,
5999            prefix: None,
6000            suffix: None,
6001            extender: false,
6002        });
6003        measure.harp_pedal_diagrams.push(HarpPedalDiagram {
6004            positions: [
6005                HarpPedalPosition::Flat,
6006                HarpPedalPosition::Natural,
6007                HarpPedalPosition::Sharp,
6008                HarpPedalPosition::Natural,
6009                HarpPedalPosition::Flat,
6010                HarpPedalPosition::Sharp,
6011                HarpPedalPosition::Natural,
6012            ],
6013            placement: Some("above".to_string()),
6014        });
6015        measure.voices[0].insert(0, Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
6016        let expected = b.parts[0].staves[0].measures[0].clone();
6017
6018        let patches = score_patch(&a, &b);
6019        assert!(
6020            patches
6021                .iter()
6022                .any(|p| matches!(p, ScorePatch::SetTimeSignature { .. }))
6023        );
6024        assert!(
6025            patches
6026                .iter()
6027                .any(|p| matches!(p, ScorePatch::SetBarlines { .. }))
6028        );
6029        assert!(
6030            patches
6031                .iter()
6032                .any(|p| matches!(p, ScorePatch::SetRehearsal { .. }))
6033        );
6034        assert!(
6035            patches
6036                .iter()
6037                .any(|p| matches!(p, ScorePatch::SetVolta { .. }))
6038        );
6039        assert!(
6040            patches
6041                .iter()
6042                .any(|p| matches!(p, ScorePatch::SetMeasureTexts { .. }))
6043        );
6044        assert!(
6045            patches
6046                .iter()
6047                .any(|p| matches!(p, ScorePatch::SetFiguredBass { .. }))
6048        );
6049        assert!(
6050            patches
6051                .iter()
6052                .any(|p| matches!(p, ScorePatch::SetHarpPedalDiagrams { .. }))
6053        );
6054        let result = apply_patch(&a, &patches).expect("patch application failed");
6055        let result_measure = &result.parts[0].staves[0].measures[0];
6056        assert_eq!(result_measure.key_sig, expected.key_sig);
6057        assert_eq!(result_measure.time_sig, expected.time_sig);
6058        assert_eq!(result_measure.barline_left, expected.barline_left);
6059        assert_eq!(result_measure.barline_right, expected.barline_right);
6060        assert_eq!(result_measure.rehearsal, expected.rehearsal);
6061        assert_eq!(result_measure.volta, expected.volta);
6062        assert_eq!(result_measure.texts, expected.texts);
6063        assert_eq!(result_measure.figured_bass, expected.figured_bass);
6064        assert_eq!(
6065            result_measure.harp_pedal_diagrams,
6066            expected.harp_pedal_diagrams
6067        );
6068        assert_eq!(result_measure.voices[0].len(), expected.voices[0].len());
6069    }
6070
6071    #[test]
6072    fn diff_and_patch_preserve_score_level_texts() {
6073        let a = Score::new("T", 120, 4, 4, 0, 1);
6074        let mut b = a.clone();
6075        b.texts.push(StyledText {
6076            position_beats: None,
6077            style: TextStyle::Expression,
6078            text: "Prelude".to_string(),
6079            placement: Some("above".to_string()),
6080            offset_x: Some(12.0),
6081            offset_y: Some(-8.0),
6082            relative_x: None,
6083            relative_y: None,
6084        });
6085
6086        let changes = diff(&a, &b);
6087        assert!(changes.iter().any(|change| matches!(
6088            change,
6089            ScoreChange::ScoreTextChanged { old, new }
6090                if old.is_empty() && new == &b.texts
6091        )));
6092
6093        let patches = score_patch(&a, &b);
6094        assert!(patches.iter().any(|patch| matches!(
6095            patch,
6096            ScorePatch::SetScoreTexts { value } if value == &b.texts
6097        )));
6098        let result = apply_patch(&a, &patches).expect("score text patch failed");
6099        assert_eq!(result.texts, b.texts);
6100    }
6101
6102    #[test]
6103    fn mixed_editor_changes_round_trip_as_local_patches() {
6104        let mut before = Score::new("Before", 120, 4, 4, 0, 2);
6105        before.parts[0].staves[0].measures[0].voices[0] = vec![
6106            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
6107            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
6108        ];
6109        before.parts[0].staves[0].measures[1].voices[0] = vec![
6110            Note::new(Pitch::new(Step::E, 4), Duration::Quarter),
6111            Note::new(Pitch::new(Step::F, 4), Duration::Quarter),
6112        ];
6113        let mut after = before.clone();
6114        after.metadata.title = "After".into();
6115        after.metadata.composer = "Composer".into();
6116        after.metadata.lyricist = "Lyricist".into();
6117        after.metadata.copyright = "Copyright".into();
6118        after.metadata.work_number = "Op. 1".into();
6119        after.metadata.movement_title = "Prelude".into();
6120        after.settings.tempo_bpm = 96;
6121        after.parts[0].name = "Piano".into();
6122        after.parts[0].short_name = "Pno.".into();
6123        after.parts[0].midi_channel = 1;
6124        after.parts[0].midi_program = 41;
6125        after.parts[0].midi_pitch_bends.push(MidiPitchBend {
6126            tick: 240,
6127            channel: 1,
6128            value: 512,
6129        });
6130        after.parts[0].staves[0].clef = Clef::Bass;
6131        after.parts[0].staves[0].measures[0].voices[0][0] =
6132            Note::new(Pitch::new(Step::G, 3), Duration::Quarter);
6133        after.parts[0].staves[0].measures[0].voices[0]
6134            .push(Note::new(Pitch::new(Step::A, 3), Duration::Quarter));
6135        after.parts[0].staves[0].measures[1].voices[0].pop();
6136
6137        let changes = diff(&before, &after);
6138        assert!(
6139            changes
6140                .iter()
6141                .any(|change| matches!(change, ScoreChange::PartMidiAutomationChanged { .. }))
6142        );
6143        assert!(
6144            changes
6145                .iter()
6146                .any(|change| matches!(change, ScoreChange::NoteModified { .. }))
6147        );
6148        assert!(
6149            changes
6150                .iter()
6151                .any(|change| matches!(change, ScoreChange::NoteAdded { .. }))
6152        );
6153        assert!(
6154            changes
6155                .iter()
6156                .any(|change| matches!(change, ScoreChange::NoteRemoved { .. }))
6157        );
6158        let patches = score_patch(&before, &after);
6159        assert!(
6160            !patches
6161                .iter()
6162                .any(|patch| matches!(patch, ScorePatch::ReplaceScore { .. }))
6163        );
6164        let result = apply_patch(&before, &patches).expect("local patches apply");
6165        assert_eq!(
6166            serde_json::to_value(result).expect("patched score"),
6167            serde_json::to_value(after).expect("expected score")
6168        );
6169    }
6170
6171    #[test]
6172    fn measure_presentation_changes_use_typed_diff_and_local_patch() {
6173        let a = Score::new("T", 120, 4, 4, 0, 1);
6174        let mut b = a.clone();
6175        let measure = &mut b.parts[0].staves[0].measures[0];
6176        measure.number = 8;
6177        measure.clef = Some(Clef::Bass);
6178        measure.tempo_text = Some("Allegro".to_string());
6179        measure.navigation = Some("D.S.".to_string());
6180        measure.expression_text = Some("espressivo".to_string());
6181        measure.multi_rest_count = Some(3);
6182        measure.system_break = true;
6183        measure.page_break = true;
6184
6185        let changes = diff(&a, &b);
6186        assert!(changes.iter().any(|change| matches!(
6187            change,
6188            ScoreChange::MeasurePresentationChanged {
6189                part: 0,
6190                staff: 0,
6191                measure: 0,
6192                old_number: 1,
6193                new_number: 8,
6194                old_clef: None,
6195                new_clef: Some(Clef::Bass),
6196                new_tempo_text: Some(text),
6197                new_navigation: Some(navigation),
6198                new_expression_text: Some(expression),
6199                new_multi_rest_count: Some(3),
6200                old_system_break: false,
6201                new_system_break: true,
6202                old_page_break: false,
6203                new_page_break: true,
6204                ..
6205            } if text == "Allegro" && navigation == "D.S." && expression == "espressivo"
6206        )));
6207        assert!(!changes.iter().any(|change| matches!(
6208            change,
6209            ScoreChange::UnrepresentedFieldChanged { path }
6210                if path == "parts[0].staves[0].measures[0].number"
6211        )));
6212
6213        let patches = score_patch(&a, &b);
6214        assert!(patches.iter().any(|patch| matches!(
6215            patch,
6216            ScorePatch::SetMeasurePresentation {
6217                part: 0,
6218                staff: 0,
6219                measure: 0,
6220                number: 8,
6221                clef: Some(Clef::Bass),
6222                tempo_text: Some(text),
6223                navigation: Some(navigation),
6224                expression_text: Some(expression),
6225                multi_rest_count: Some(3),
6226                system_break: true,
6227                page_break: true,
6228                section_break: false,
6229            } if text == "Allegro" && navigation == "D.S." && expression == "espressivo"
6230        )));
6231        assert!(
6232            !patches
6233                .iter()
6234                .any(|patch| matches!(patch, ScorePatch::ReplaceScore { .. }))
6235        );
6236        let json = serde_json::to_string(&patches).expect("measure presentation patch JSON");
6237        let decoded: Vec<ScorePatch> =
6238            serde_json::from_str(&json).expect("measure presentation patch should decode");
6239        let mut legacy_json: serde_json::Value =
6240            serde_json::from_str(&json).expect("patch JSON value");
6241        for patch in legacy_json.as_array_mut().expect("patch JSON array") {
6242            patch
6243                .as_object_mut()
6244                .expect("patch JSON object")
6245                .remove("section_break");
6246        }
6247        let legacy: Vec<ScorePatch> =
6248            serde_json::from_value(legacy_json).expect("legacy patch should decode");
6249        assert!(matches!(
6250            legacy.first(),
6251            Some(ScorePatch::SetMeasurePresentation {
6252                section_break: false,
6253                ..
6254            })
6255        ));
6256        let result = apply_patch(&a, &decoded).expect("measure presentation patch failed");
6257        assert_eq!(result.parts[0].staves[0].measures[0].number, 8);
6258        assert_eq!(result.parts[0].staves[0].measures[0].clef, Some(Clef::Bass));
6259        assert_eq!(
6260            serde_json::to_value(&result).expect("patched score JSON"),
6261            serde_json::to_value(&b).expect("expected score JSON")
6262        );
6263    }
6264
6265    #[test]
6266    fn chord_definition_changes_use_typed_diff_and_local_patch() {
6267        let a = Score::new("T", 120, 4, 4, 0, 1);
6268        let mut b = a.clone();
6269        b.chord_definitions.push(ChordDefinition {
6270            id: Some("c-major".to_string()),
6271            label: Some("C".to_string()),
6272            kind: Some("major".to_string()),
6273            fret_position: Some(0),
6274            tab_strings: Some("x32010".to_string()),
6275            tab_courses: None,
6276            members: Vec::new(),
6277            barres: Vec::new(),
6278        });
6279
6280        let changes = diff(&a, &b);
6281        assert!(changes.iter().any(|change| matches!(
6282            change,
6283            ScoreChange::ChordDefinitionsChanged { old, new }
6284                if old.is_empty() && new == &b.chord_definitions
6285        )));
6286        assert!(!changes.iter().any(|change| matches!(
6287            change,
6288            ScoreChange::UnrepresentedFieldChanged { path }
6289                if path == "chord_definitions"
6290        )));
6291
6292        let patches = score_patch(&a, &b);
6293        assert!(patches.iter().any(|patch| matches!(
6294            patch,
6295            ScorePatch::SetChordDefinitions { value } if value == &b.chord_definitions
6296        )));
6297        assert!(
6298            !patches
6299                .iter()
6300                .any(|patch| matches!(patch, ScorePatch::ReplaceScore { .. }))
6301        );
6302        let json = serde_json::to_string(&patches).expect("chord definition patch JSON");
6303        let decoded: Vec<ScorePatch> =
6304            serde_json::from_str(&json).expect("chord definition patch should decode");
6305        let result = apply_patch(&a, &decoded).expect("chord definition patch failed");
6306        assert_eq!(result.chord_definitions, b.chord_definitions);
6307    }
6308
6309    #[test]
6310    fn part_name_changes_use_typed_diff_and_local_patch() {
6311        let a = Score::new("T", 120, 4, 4, 0, 1);
6312        let mut b = a.clone();
6313        b.parts[0].name = "Violin".to_string();
6314        b.parts[0].short_name = "Vln.".to_string();
6315        b.parts[0].midi_channel = 4;
6316        b.parts[0].midi_program = 40;
6317        b.parts[0].midi_pitch_bends.push(MidiPitchBend {
6318            tick: 120,
6319            channel: 4,
6320            value: 2048,
6321        });
6322        b.parts[0].midi_control_changes.push(MidiControlChange {
6323            tick: 240,
6324            channel: 4,
6325            controller: 64,
6326            value: 127,
6327        });
6328        b.parts[0].midi_program_changes.push(MidiProgramChange {
6329            tick: 0,
6330            channel: 4,
6331            program: 40,
6332        });
6333        b.parts[0].midi_aftertouch.push(MidiAftertouch {
6334            tick: 360,
6335            channel: 4,
6336            key: Some(64),
6337            value: 80,
6338        });
6339        b.parts[0].staves[0].clef = Clef::Bass;
6340        b.parts[0].staves[0].transpose_semitones = -2;
6341
6342        let changes = diff(&a, &b);
6343        assert!(changes.iter().any(|change| matches!(
6344            change,
6345            ScoreChange::PartNamesChanged {
6346                part: 0,
6347                old_name,
6348                new_name,
6349                old_short_name,
6350                new_short_name,
6351            } if old_name == "Piano"
6352                && new_name == "Violin"
6353                && old_short_name == "Pno."
6354                && new_short_name == "Vln."
6355        )));
6356        assert!(changes.iter().any(|change| matches!(
6357            change,
6358            ScoreChange::PartMidiChanged {
6359                part: 0,
6360                old_channel: 0,
6361                new_channel: 4,
6362                old_program: 0,
6363                new_program: 40,
6364            }
6365        )));
6366        assert!(changes.iter().any(|change| matches!(
6367            change,
6368            ScoreChange::PartMidiAutomationChanged {
6369                part: 0,
6370                new_pitch_bends,
6371                new_control_changes,
6372                new_program_changes,
6373                new_aftertouch,
6374                ..
6375            } if new_pitch_bends == &b.parts[0].midi_pitch_bends
6376                && new_control_changes == &b.parts[0].midi_control_changes
6377                && new_program_changes == &b.parts[0].midi_program_changes
6378                && new_aftertouch == &b.parts[0].midi_aftertouch
6379        )));
6380        assert!(changes.iter().any(|change| matches!(
6381            change,
6382            ScoreChange::StaffConfigurationChanged {
6383                part: 0,
6384                staff: 0,
6385                old_clef: Clef::Treble,
6386                new_clef: Clef::Bass,
6387                old_transpose_semitones: 0,
6388                new_transpose_semitones: -2,
6389            }
6390        )));
6391        assert!(!changes.iter().any(|change| matches!(
6392            change,
6393            ScoreChange::UnrepresentedFieldChanged { path }
6394                if path == "parts[0].name"
6395        )));
6396
6397        let patches = score_patch(&a, &b);
6398        assert!(patches.iter().any(|patch| matches!(
6399            patch,
6400            ScorePatch::SetPartNames {
6401                part: 0,
6402                name,
6403                short_name,
6404            } if name == "Violin" && short_name == "Vln."
6405        )));
6406        assert!(
6407            !patches
6408                .iter()
6409                .any(|patch| matches!(patch, ScorePatch::ReplaceScore { .. }))
6410        );
6411        assert!(patches.iter().any(|patch| matches!(
6412            patch,
6413            ScorePatch::SetPartMidi {
6414                part: 0,
6415                channel: 4,
6416                program: 40,
6417            }
6418        )));
6419        assert!(patches.iter().any(|patch| matches!(
6420            patch,
6421            ScorePatch::SetPartMidiAutomation {
6422                part: 0,
6423                pitch_bends,
6424                control_changes,
6425                program_changes,
6426                aftertouch,
6427            } if pitch_bends == &b.parts[0].midi_pitch_bends
6428                && control_changes == &b.parts[0].midi_control_changes
6429                && program_changes == &b.parts[0].midi_program_changes
6430                && aftertouch == &b.parts[0].midi_aftertouch
6431        )));
6432        assert!(patches.iter().any(|patch| matches!(
6433            patch,
6434            ScorePatch::SetStaffConfiguration {
6435                part: 0,
6436                staff: 0,
6437                clef: Clef::Bass,
6438                transpose_semitones: -2,
6439            }
6440        )));
6441        let result = apply_patch(&a, &patches).expect("part name patch failed");
6442        assert_eq!(result.parts[0].name, b.parts[0].name);
6443        assert_eq!(result.parts[0].short_name, b.parts[0].short_name);
6444        assert_eq!(result.parts[0].midi_channel, b.parts[0].midi_channel);
6445        assert_eq!(result.parts[0].midi_program, b.parts[0].midi_program);
6446        assert_eq!(
6447            result.parts[0].midi_pitch_bends,
6448            b.parts[0].midi_pitch_bends
6449        );
6450        assert_eq!(
6451            result.parts[0].midi_control_changes,
6452            b.parts[0].midi_control_changes
6453        );
6454        assert_eq!(
6455            result.parts[0].midi_program_changes,
6456            b.parts[0].midi_program_changes
6457        );
6458        assert_eq!(result.parts[0].midi_aftertouch, b.parts[0].midi_aftertouch);
6459        assert_eq!(result.parts[0].staves[0].clef, b.parts[0].staves[0].clef);
6460        assert_eq!(
6461            result.parts[0].staves[0].transpose_semitones,
6462            b.parts[0].staves[0].transpose_semitones
6463        );
6464    }
6465
6466    #[test]
6467    fn diff_reports_measure_text_and_figured_bass_changes() {
6468        let a = Score::new("T", 120, 4, 4, 0, 1);
6469        let mut b = a.clone();
6470        let measure = &mut b.parts[0].staves[0].measures[0];
6471        measure.texts.push(StyledText {
6472            position_beats: None,
6473            style: TextStyle::Lyrics,
6474            text: "la".to_string(),
6475            placement: None,
6476            offset_x: None,
6477            offset_y: None,
6478            relative_x: None,
6479            relative_y: None,
6480        });
6481        measure.figured_bass.push(FiguredBassFigure {
6482            number: "6".to_string(),
6483            alter: None,
6484            prefix: None,
6485            suffix: None,
6486            extender: false,
6487        });
6488
6489        let changes = diff(&a, &b);
6490        assert!(changes.iter().any(|change| matches!(
6491            change,
6492            ScoreChange::MeasureTextChanged { part: 0, staff: 0, measure: 0, old, new }
6493                if old.is_empty() && new.len() == 1
6494        )));
6495        assert!(changes.iter().any(|change| matches!(
6496            change,
6497            ScoreChange::FiguredBassChanged { part: 0, staff: 0, measure: 0, old, new }
6498                if old.is_empty() && new.len() == 1
6499        )));
6500    }
6501
6502    #[test]
6503    fn diff_reports_tablature_changes_and_patches_them_locally() {
6504        let a = Score::new("T", 120, 4, 4, 0, 1);
6505        let mut b = a.clone();
6506        b.parts[0].staves[0].tablature = Some(TablatureConfig {
6507            lines: 6,
6508            tuning_midi: vec![40, 45, 50, 55, 59, 64],
6509            capo: 2,
6510        });
6511
6512        let changes = diff(&a, &b);
6513        assert!(!changes.iter().any(|change| matches!(
6514            change,
6515            ScoreChange::UnrepresentedFieldChanged { path }
6516                if path == "parts[0].staves[0].tablature"
6517        )));
6518        assert!(changes.iter().any(|change| matches!(
6519            change,
6520            ScoreChange::TablatureConfigChanged { part: 0, staff: 0, old: None, new: Some(config) }
6521                if config.lines == 6 && config.capo == 2
6522        )));
6523        let patches = score_patch(&a, &b);
6524        assert!(patches.iter().any(|patch| matches!(
6525            patch,
6526            ScorePatch::SetTablatureConfig { part: 0, staff: 0, value: Some(config) }
6527                if config.lines == 6 && config.capo == 2
6528        )));
6529        assert_eq!(
6530            apply_patch(&a, &patches).unwrap().parts[0].staves[0].tablature,
6531            b.parts[0].staves[0].tablature
6532        );
6533    }
6534
6535    #[test]
6536    fn diff_and_patch_preserve_common_time_symbol() {
6537        let a = Score::new("Meter", 120, 4, 4, 0, 1);
6538        let mut b = a.clone();
6539        b.parts[0].staves[0].measures[0].time_symbol = Some(TimeSymbol::Common);
6540        assert!(diff(&a, &b).iter().any(|change| matches!(
6541            change,
6542            ScoreChange::TimeSymbolChanged {
6543                part: 0,
6544                staff: 0,
6545                measure: 0,
6546                new: Some(TimeSymbol::Common),
6547                ..
6548            }
6549        )));
6550        let patches = score_patch(&a, &b);
6551        assert!(patches.iter().any(|patch| matches!(
6552            patch,
6553            ScorePatch::SetTimeSymbol {
6554                part: 0,
6555                staff: 0,
6556                measure: 0,
6557                value: Some(TimeSymbol::Common)
6558            }
6559        )));
6560        assert_eq!(
6561            apply_patch(&a, &patches).unwrap().parts[0].staves[0].measures[0].time_symbol,
6562            Some(TimeSymbol::Common)
6563        );
6564    }
6565
6566    #[test]
6567    fn diff_and_patch_preserve_written_metronome_marks() {
6568        let a = Score::new("T", 120, 4, 4, 0, 1);
6569        let mut b = a.clone();
6570        b.parts[0].staves[0].measures[0].metronome_mark = Some(MetronomeMark {
6571            beat_unit: Duration::Quarter,
6572            beat_unit_dots: 0,
6573            per_minute: 69,
6574            parentheses: true,
6575        });
6576        let changes = diff(&a, &b);
6577        assert!(changes.iter().any(|change| matches!(
6578            change,
6579            ScoreChange::MeasureMetronomeMarkChanged {
6580                part: 0,
6581                staff: 0,
6582                measure: 0,
6583                ..
6584            }
6585        )));
6586        let patches = score_patch(&a, &b);
6587        assert!(patches.iter().any(|patch| matches!(
6588            patch,
6589            ScorePatch::SetMeasureMetronomeMark {
6590                part: 0,
6591                staff: 0,
6592                measure: 0,
6593                ..
6594            }
6595        )));
6596        assert_eq!(
6597            apply_patch(&a, &patches).unwrap().parts[0].staves[0].measures[0].metronome_mark,
6598            b.parts[0].staves[0].measures[0].metronome_mark
6599        );
6600    }
6601
6602    #[test]
6603    fn diff_reports_measure_tablature_changes_and_patches_them_locally() {
6604        let mut a = Score::new("T", 120, 4, 4, 0, 2);
6605        a.parts[0].staves[0].tablature = Some(TablatureConfig {
6606            lines: 6,
6607            tuning_midi: vec![40, 45, 50, 55, 59, 64],
6608            capo: 0,
6609        });
6610        let mut b = a.clone();
6611        b.parts[0].staves[0].measures[1].tablature_change = Some(TablatureConfig {
6612            lines: 6,
6613            tuning_midi: vec![40, 45, 50, 55, 59, 64],
6614            capo: 3,
6615        });
6616
6617        let changes = diff(&a, &b);
6618        assert!(changes.iter().any(|change| matches!(
6619            change,
6620            ScoreChange::TablatureChangeChanged {
6621                part: 0,
6622                staff: 0,
6623                measure: 1,
6624                old: None,
6625                new: Some(config),
6626            } if config.capo == 3
6627        )));
6628        let patches = score_patch(&a, &b);
6629        assert!(patches.iter().any(|patch| matches!(
6630            patch,
6631            ScorePatch::SetMeasureTablatureChange {
6632                part: 0,
6633                staff: 0,
6634                measure: 1,
6635                value: Some(config),
6636            } if config.capo == 3
6637        )));
6638        assert_eq!(
6639            apply_patch(&a, &patches).unwrap().parts[0].staves[0].measures[1].tablature_change,
6640            b.parts[0].staves[0].measures[1].tablature_change
6641        );
6642    }
6643
6644    #[test]
6645    fn diff_reports_measure_tempo_ramps_and_patches_them_locally() {
6646        let a = Score::new("Ramp", 120, 4, 4, 0, 2);
6647        let mut b = a.clone();
6648        b.parts[0].staves[0].measures[1].tempo_ramp_to = Some(72);
6649
6650        assert!(diff(&a, &b).iter().any(|change| matches!(
6651            change,
6652            ScoreChange::MeasureTempoRampChanged {
6653                part: 0,
6654                staff: 0,
6655                measure: 1,
6656                old: None,
6657                new: Some(72),
6658            }
6659        )));
6660        let patches = score_patch(&a, &b);
6661        assert!(patches.iter().any(|patch| matches!(
6662            patch,
6663            ScorePatch::SetMeasureTempoRamp {
6664                part: 0,
6665                staff: 0,
6666                measure: 1,
6667                value: Some(72),
6668            }
6669        )));
6670        assert_eq!(
6671            apply_patch(&a, &patches).expect("patch applies").parts[0].staves[0].measures[1]
6672                .tempo_ramp_to,
6673            b.parts[0].staves[0].measures[1].tempo_ramp_to
6674        );
6675    }
6676
6677    #[test]
6678    fn diff_reports_staff_presentation_changes_and_patches_them_locally() {
6679        let a = Score::new("T", 120, 4, 4, 0, 1);
6680        let mut b = a.clone();
6681        b.parts[0].staves[0].presentation = StaffPresentation {
6682            kind: StaffKind::Percussion,
6683            lines: 1,
6684            line_distance: 1.5,
6685            small: true,
6686            cutaway: true,
6687            visible: false,
6688            notehead_scheme: StaffNoteheadScheme::PitchNames,
6689            tablature_rhythm_display: TablatureRhythmDisplay::FretOnly,
6690            tablature_fret_mark_style: TablatureFretMarkStyle::Arabic,
6691        };
6692
6693        let changes = diff(&a, &b);
6694        assert!(changes.iter().any(|change| matches!(
6695            change,
6696            ScoreChange::StaffPresentationChanged { part: 0, staff: 0, new, .. }
6697                if new == &b.parts[0].staves[0].presentation
6698        )));
6699        let patches = score_patch(&a, &b);
6700        assert!(patches.iter().any(|patch| matches!(
6701            patch,
6702            ScorePatch::SetStaffPresentation { part: 0, staff: 0, value }
6703                if value == &b.parts[0].staves[0].presentation
6704        )));
6705        assert!(
6706            !patches
6707                .iter()
6708                .any(|patch| matches!(patch, ScorePatch::ReplaceScore { .. }))
6709        );
6710        assert_eq!(
6711            apply_patch(&a, &patches).unwrap().parts[0].staves[0].presentation,
6712            b.parts[0].staves[0].presentation
6713        );
6714    }
6715
6716    #[test]
6717    fn score_patch_uses_local_presentation_patch_for_display_fields() {
6718        let a = Score::new("T", 120, 4, 4, 0, 1);
6719        let mut b = a.clone();
6720        b.parts[0].name = "Piano".to_string();
6721        b.parts[0].staves[0].measures[0].expression_text = Some("dolce".to_string());
6722        let patches = score_patch(&a, &b);
6723        assert!(patches.iter().any(|patch| matches!(
6724            patch,
6725            ScorePatch::SetMeasurePresentation {
6726                part: 0,
6727                staff: 0,
6728                measure: 0,
6729                expression_text: Some(text),
6730                ..
6731            } if text == "dolce"
6732        )));
6733        assert!(
6734            !patches
6735                .iter()
6736                .any(|patch| matches!(patch, ScorePatch::ReplaceScore { .. }))
6737        );
6738        let result = apply_patch(&a, &patches).expect("measure presentation patch failed");
6739        assert_eq!(result.parts[0].name, "Piano");
6740        assert_eq!(
6741            result.parts[0].staves[0].measures[0].expression_text,
6742            Some("dolce".to_string())
6743        );
6744    }
6745
6746    #[test]
6747    fn diff_detects_tempo_change() {
6748        let a = Score::new("T", 120, 4, 4, 0, 1);
6749        let mut b = a.clone();
6750        b.settings.tempo_bpm = 90;
6751        let changes = diff(&a, &b);
6752        assert_eq!(changes.len(), 1);
6753        assert!(matches!(
6754            changes[0],
6755            ScoreChange::TempoChanged { old: 120, new: 90 }
6756        ));
6757    }
6758
6759    #[test]
6760    fn diff_detects_title_change() {
6761        let a = Score::new("Old Title", 120, 4, 4, 0, 1);
6762        let mut b = a.clone();
6763        b.metadata.title = "New Title".to_string();
6764        let changes = diff(&a, &b);
6765        assert!(
6766            changes.iter().any(
6767                |c| matches!(c, ScoreChange::MetadataChanged { field, .. } if field == "title")
6768            )
6769        );
6770    }
6771
6772    #[test]
6773    fn diff_detects_note_modification() {
6774        let mut a = Score::new("T", 120, 4, 4, 0, 1);
6775        a.parts[0].staves[0].measures[0].voices[0] =
6776            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
6777        let mut b = a.clone();
6778        b.parts[0].staves[0].measures[0].voices[0][0] =
6779            Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
6780        let changes = diff(&a, &b);
6781        assert!(
6782            changes
6783                .iter()
6784                .any(|c| matches!(c, ScoreChange::NoteModified { .. }))
6785        );
6786    }
6787
6788    #[test]
6789    fn diff_detects_part_added() {
6790        let a = Score::new("T", 120, 4, 4, 0, 1);
6791        let mut b = a.clone();
6792        let mut p = Part::new("Violin", "Vln.");
6793        p.staves.push(Staff::new(Clef::Treble));
6794        b.parts.push(p);
6795        let changes = diff(&a, &b);
6796        assert!(
6797            changes
6798                .iter()
6799                .any(|c| matches!(c, ScoreChange::PartAdded { part_index: 1 }))
6800        );
6801    }
6802
6803    #[test]
6804    fn diff_detects_measure_tempo_change() {
6805        let a = Score::new("T", 120, 4, 4, 0, 2);
6806        let mut b = a.clone();
6807        b.parts[0].staves[0].measures[1].tempo = Some(60);
6808        let changes = diff(&a, &b);
6809        assert!(changes.iter().any(|c| matches!(
6810            c,
6811            ScoreChange::MeasureTempoChanged {
6812                measure: 1,
6813                old: None,
6814                new: Some(60),
6815                ..
6816            }
6817        )));
6818    }
6819
6820    #[test]
6821    fn diff_detects_barline_change() {
6822        use crate::model::notation::Barline;
6823        let a = Score::new("T", 120, 4, 4, 0, 2);
6824        let mut b = a.clone();
6825        b.parts[0].staves[0].measures[0].barline_left = Barline::RepeatStart;
6826        let changes = diff(&a, &b);
6827        assert!(
6828            changes
6829                .iter()
6830                .any(|c| matches!(c, ScoreChange::BarlineChanged { measure: 0, .. }))
6831        );
6832    }
6833
6834    #[test]
6835    fn diff_detects_rehearsal_change() {
6836        let a = Score::new("T", 120, 4, 4, 0, 2);
6837        let mut b = a.clone();
6838        b.parts[0].staves[0].measures[0].rehearsal = Some("A".to_string());
6839        let changes = diff(&a, &b);
6840        assert!(
6841            changes
6842                .iter()
6843                .any(|c| matches!(c, ScoreChange::RehearsalMarkChanged { measure: 0, .. }))
6844        );
6845    }
6846
6847    #[test]
6848    fn diff_detects_volta_change() {
6849        use super::VoltaBracket;
6850        let a = Score::new("T", 120, 4, 4, 0, 2);
6851        let mut b = a.clone();
6852        b.parts[0].staves[0].measures[0].volta = Some(VoltaBracket {
6853            number: 1,
6854            kind: "begin_end".into(),
6855            numbers: Vec::new(),
6856        });
6857        let changes = diff(&a, &b);
6858        assert!(
6859            changes
6860                .iter()
6861                .any(|c| matches!(c, ScoreChange::VoltaChanged { measure: 0, .. }))
6862        );
6863    }
6864
6865    #[test]
6866    fn diff_detects_key_signature_change() {
6867        let a = Score::new("T", 120, 4, 4, 0, 1);
6868        let mut b = a.clone();
6869        b.settings.key_signature.fifths = 2; // C major → D major
6870        let changes = diff(&a, &b);
6871        assert!(
6872            changes
6873                .iter()
6874                .any(|c| matches!(c, ScoreChange::KeySignatureChanged { .. }))
6875        );
6876    }
6877
6878    #[test]
6879    fn measure_key_signature_uses_typed_local_patch() {
6880        let a = Score::new("T", 120, 4, 4, 0, 1);
6881        let mut b = a.clone();
6882        b.parts[0].staves[0].measures[0].key_sig = Some(KeySignature {
6883            fifths: 2,
6884            mode: "major".to_string(),
6885        });
6886        let changes = diff(&a, &b);
6887        assert!(!changes.iter().any(|change| matches!(
6888            change,
6889            ScoreChange::UnrepresentedFieldChanged { path }
6890                if path == "parts[0].staves[0].measures[0].key_sig"
6891        )));
6892        let patches = score_patch(&a, &b);
6893        assert!(patches.iter().any(|patch| matches!(
6894            patch,
6895            ScorePatch::SetKeySignature {
6896                part: 0,
6897                staff: 0,
6898                measure: 0,
6899                value: Some(KeySignature { fifths: 2, .. }),
6900            }
6901        )));
6902        assert!(
6903            !patches
6904                .iter()
6905                .any(|patch| matches!(patch, ScorePatch::ReplaceScore { .. }))
6906        );
6907    }
6908
6909    #[test]
6910    fn diff_same_key_signature_no_change() {
6911        let a = Score::new("T", 120, 4, 4, 2, 1);
6912        let changes = diff(&a, &a);
6913        assert!(changes.is_empty());
6914    }
6915
6916    #[test]
6917    fn score_duration_secs_region_partial() {
6918        use super::score_duration_secs_region;
6919        // 4/4, 120 BPM, 4 measures → each measure = 2.0 s; region [1,2] = 4.0 s
6920        let score = Score::new("T", 120, 4, 4, 0, 4);
6921        let secs = score_duration_secs_region(&score, (1, 2));
6922        assert!((secs - 4.0).abs() < 0.01, "expected ~4.0 s, got {secs}");
6923    }
6924
6925    #[test]
6926    fn score_duration_secs_region_single_measure() {
6927        use super::score_duration_secs_region;
6928        // 4/4, 120 BPM → 1 measure = 2.0 s
6929        let score = Score::new("T", 120, 4, 4, 0, 4);
6930        let secs = score_duration_secs_region(&score, (0, 0));
6931        assert!((secs - 2.0).abs() < 0.01, "expected ~2.0 s, got {secs}");
6932    }
6933
6934    // ── measure_beats_remaining ───────────────────────────────────────────────
6935
6936    #[test]
6937    fn measure_beats_remaining_empty_voice_returns_full() {
6938        use super::measure_beats_remaining;
6939        let mut score = Score::new("T", 120, 4, 4, 0, 1);
6940        score.parts[0].staves[0].measures[0].voices[0].clear();
6941        let rem = measure_beats_remaining(&score, 0, 0, 0, 0).unwrap();
6942        assert!(
6943            (rem - 4.0).abs() < 1e-9,
6944            "expected 4.0 remaining, got {rem}"
6945        );
6946    }
6947
6948    #[test]
6949    fn measure_beats_remaining_half_full_returns_half() {
6950        use super::measure_beats_remaining;
6951        use crate::model::pitch::Step;
6952        let mut score = Score::new("T", 120, 4, 4, 0, 1);
6953        score.parts[0].staves[0].measures[0].voices[0] = vec![
6954            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
6955            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
6956        ];
6957        let rem = measure_beats_remaining(&score, 0, 0, 0, 0).unwrap();
6958        assert!(
6959            (rem - 2.0).abs() < 1e-9,
6960            "expected 2.0 remaining, got {rem}"
6961        );
6962    }
6963
6964    #[test]
6965    fn measure_beats_remaining_full_voice_returns_zero() {
6966        use super::measure_beats_remaining;
6967        use crate::model::pitch::Step;
6968        let mut score = Score::new("T", 120, 4, 4, 0, 1);
6969        score.parts[0].staves[0].measures[0].voices[0] =
6970            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
6971        let rem = measure_beats_remaining(&score, 0, 0, 0, 0).unwrap();
6972        assert!((rem).abs() < 1e-9, "expected 0.0 remaining, got {rem}");
6973    }
6974
6975    #[test]
6976    fn measure_beats_remaining_tuplet_accounting() {
6977        use super::measure_beats_remaining;
6978        use crate::model::notation::TupletInfo;
6979        use crate::model::pitch::Step;
6980        // 3 quarter-note triplets each take 2/3 of a beat → total 2.0 beats used → 2.0 remaining
6981        let mut score = Score::new("T", 120, 4, 4, 0, 1);
6982        let tuplet = TupletInfo {
6983            actual_notes: 3,
6984            normal_notes: 2,
6985        };
6986        let mk = |step| {
6987            let mut n = Note::new(Pitch::new(step, 4), Duration::Quarter);
6988            n.tuplet = Some(tuplet.clone());
6989            n
6990        };
6991        score.parts[0].staves[0].measures[0].voices[0] =
6992            vec![mk(Step::C), mk(Step::D), mk(Step::E)];
6993        let rem = measure_beats_remaining(&score, 0, 0, 0, 0).unwrap();
6994        assert!(
6995            (rem - 2.0).abs() < 1e-9,
6996            "expected 2.0 remaining (triplets used 2.0), got {rem}"
6997        );
6998    }
6999
7000    #[test]
7001    fn measure_beats_remaining_out_of_range_returns_err() {
7002        use super::measure_beats_remaining;
7003        let score = Score::new("T", 120, 4, 4, 0, 1);
7004        assert!(measure_beats_remaining(&score, 99, 0, 0, 0).is_err());
7005        assert!(measure_beats_remaining(&score, 0, 99, 0, 0).is_err());
7006        assert!(measure_beats_remaining(&score, 0, 0, 99, 0).is_err());
7007        assert!(measure_beats_remaining(&score, 0, 0, 0, 4).is_err());
7008    }
7009
7010    #[test]
7011    fn note_content_eq_ignores_id() {
7012        use crate::model::pitch::Step;
7013        let mut a = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
7014        let mut b = a.clone();
7015        b.id = "different-id".to_string();
7016        assert!(note_content_eq(&a, &b));
7017        // Actual pitch change should differ
7018        b.pitches[0] = Pitch::new(Step::D, 4);
7019        assert!(!note_content_eq(&a, &b));
7020        // stem_up difference
7021        let mut c = a.clone();
7022        a.stem_up = Some(true);
7023        c.stem_up = Some(false);
7024        assert!(!note_content_eq(&a, &c));
7025    }
7026
7027    #[test]
7028    fn suggested_stem_up_below_middle() {
7029        use crate::model::notation::Clef;
7030        // C4 = MIDI 60, Treble middle = B4 = 71 → stem up
7031        let pitches = vec![Pitch::new(Step::C, 4)];
7032        assert!(suggested_stem_up(&pitches, &Clef::Treble));
7033    }
7034
7035    #[test]
7036    fn suggested_stem_up_above_middle() {
7037        use crate::model::notation::Clef;
7038        // G5 = MIDI 79, Treble middle = 71 → stem down
7039        let pitches = vec![Pitch::new(Step::G, 5)];
7040        assert!(!suggested_stem_up(&pitches, &Clef::Treble));
7041    }
7042
7043    #[test]
7044    fn suggested_stem_up_at_middle_line() {
7045        use crate::model::notation::Clef;
7046        // B4 = MIDI 71, Treble middle = 71 → stem down (avg >= middle)
7047        let pitches = vec![Pitch::new(Step::B, 4)];
7048        assert!(!suggested_stem_up(&pitches, &Clef::Treble));
7049    }
7050
7051    #[test]
7052    fn suggested_stem_up_chord() {
7053        use crate::model::notation::Clef;
7054        // [C4=60, G4=67] avg=63.5 < 71 → stem up
7055        let pitches = vec![Pitch::new(Step::C, 4), Pitch::new(Step::G, 4)];
7056        assert!(suggested_stem_up(&pitches, &Clef::Treble));
7057    }
7058
7059    #[test]
7060    fn suggested_stem_up_bass_clef() {
7061        use crate::model::notation::Clef;
7062        // D3=50 is exactly at Bass middle line → stem down
7063        let pitches = vec![Pitch::new(Step::D, 3)];
7064        assert!(!suggested_stem_up(&pitches, &Clef::Bass));
7065        // C3=48 < 50 → stem up
7066        let pitches2 = vec![Pitch::new(Step::C, 3)];
7067        assert!(suggested_stem_up(&pitches2, &Clef::Bass));
7068    }
7069
7070    #[test]
7071    fn suggested_stem_up_empty_pitches() {
7072        use crate::model::notation::Clef;
7073        assert!(suggested_stem_up(&[], &Clef::Treble));
7074    }
7075
7076    fn eighth(pitch: Pitch) -> Note {
7077        Note::new(pitch, Duration::Eighth)
7078    }
7079    fn quarter(pitch: Pitch) -> Note {
7080        Note::new(pitch, Duration::Quarter)
7081    }
7082    fn rest_eighth() -> Note {
7083        Note::rest(Duration::Eighth)
7084    }
7085
7086    #[test]
7087    fn compute_beams_4_4_four_eighths() {
7088        use crate::model::notation::{Clef, TimeSignature};
7089        let _ = Clef::Treble; // suppress unused import warning
7090        let ts = TimeSignature {
7091            numerator: 4,
7092            denominator: 4,
7093        };
7094        let c4 = Pitch::new(Step::C, 4);
7095        let notes = vec![
7096            eighth(c4.clone()),
7097            eighth(c4.clone()),
7098            eighth(c4.clone()),
7099            eighth(c4.clone()),
7100        ];
7101        let beams = compute_beams(&notes, &ts);
7102        // 4 eighths in 4/4: beat size=1.0, two groups of 2 each
7103        assert_eq!(beams[0], BeamState::Begin);
7104        assert_eq!(beams[1], BeamState::End);
7105        assert_eq!(beams[2], BeamState::Begin);
7106        assert_eq!(beams[3], BeamState::End);
7107    }
7108
7109    #[test]
7110    fn compute_beams_4_4_all_eighth_one_group() {
7111        use crate::model::notation::TimeSignature;
7112        let ts = TimeSignature {
7113            numerator: 4,
7114            denominator: 4,
7115        };
7116        let c4 = Pitch::new(Step::C, 4);
7117        // 2 eighths in a beat → group of 2
7118        let notes = vec![eighth(c4.clone()), eighth(c4.clone())];
7119        let beams = compute_beams(&notes, &ts);
7120        assert_eq!(beams[0], BeamState::Begin);
7121        assert_eq!(beams[1], BeamState::End);
7122    }
7123
7124    #[test]
7125    fn compute_beams_quarter_not_beamed() {
7126        use crate::model::notation::TimeSignature;
7127        let ts = TimeSignature {
7128            numerator: 4,
7129            denominator: 4,
7130        };
7131        let c4 = Pitch::new(Step::C, 4);
7132        let notes = vec![quarter(c4.clone()), quarter(c4.clone())];
7133        let beams = compute_beams(&notes, &ts);
7134        assert_eq!(beams[0], BeamState::None);
7135        assert_eq!(beams[1], BeamState::None);
7136    }
7137
7138    #[test]
7139    fn compute_beams_rest_breaks_beam() {
7140        use crate::model::notation::TimeSignature;
7141        let ts = TimeSignature {
7142            numerator: 4,
7143            denominator: 4,
7144        };
7145        let c4 = Pitch::new(Step::C, 4);
7146        let notes = vec![eighth(c4.clone()), rest_eighth(), eighth(c4.clone())];
7147        let beams = compute_beams(&notes, &ts);
7148        // rest breaks beam group
7149        assert_eq!(beams[0], BeamState::None);
7150        assert_eq!(beams[1], BeamState::None);
7151        assert_eq!(beams[2], BeamState::None);
7152    }
7153
7154    #[test]
7155    fn compute_beams_6_8_compound() {
7156        use crate::model::notation::TimeSignature;
7157        let ts = TimeSignature {
7158            numerator: 6,
7159            denominator: 8,
7160        };
7161        let c4 = Pitch::new(Step::C, 4);
7162        // 6 eighths in 6/8 compound → two groups of 3 (beam size=1.5 beats)
7163        let notes: Vec<Note> = (0..6).map(|_| eighth(c4.clone())).collect();
7164        let beams = compute_beams(&notes, &ts);
7165        assert_eq!(beams[0], BeamState::Begin);
7166        assert_eq!(beams[1], BeamState::Continue);
7167        assert_eq!(beams[2], BeamState::End);
7168        assert_eq!(beams[3], BeamState::Begin);
7169        assert_eq!(beams[4], BeamState::Continue);
7170        assert_eq!(beams[5], BeamState::End);
7171    }
7172
7173    #[test]
7174    fn compute_beams_single_eighth() {
7175        use crate::model::notation::TimeSignature;
7176        let ts = TimeSignature {
7177            numerator: 4,
7178            denominator: 4,
7179        };
7180        let c4 = Pitch::new(Step::C, 4);
7181        let notes = vec![eighth(c4.clone())];
7182        let beams = compute_beams(&notes, &ts);
7183        assert_eq!(beams[0], BeamState::None);
7184    }
7185
7186    #[test]
7187    fn compute_beams_stops_fifteen_in_eight_tuplet_at_the_beat_boundary() {
7188        let ts = TimeSignature {
7189            numerator: 2,
7190            denominator: 4,
7191        };
7192        let pitch = Pitch::new(Step::C, 4);
7193        let mut tuplet_note = Note::new(pitch.clone(), Duration::ThirtySecond);
7194        tuplet_note.tuplet = Some(TupletInfo {
7195            actual_notes: 15,
7196            normal_notes: 8,
7197        });
7198        let mut notes = vec![tuplet_note; 15];
7199        notes.extend([eighth(pitch.clone()), eighth(pitch)]);
7200        let original = serde_json::to_value(&notes).unwrap();
7201
7202        let beams = compute_beams(&notes, &ts);
7203        let mut expected = vec![BeamState::Begin];
7204        expected.extend([BeamState::Continue; 13]);
7205        expected.extend([BeamState::End, BeamState::Begin, BeamState::End]);
7206        assert_eq!(beams, expected);
7207        assert_eq!(
7208            serde_json::to_value(&notes).unwrap(),
7209            original,
7210            "beam inference must not change notation or timing"
7211        );
7212        assert!((notes[..15].iter().map(Note::beats).sum::<f64>() - 1.0).abs() < 1e-12);
7213    }
7214}
7215
7216#[cfg(test)]
7217mod mid_clef_tests {
7218    use super::*;
7219    use crate::{Command, JoinMeasuresCmd, ScoreEngine, SetMidMeasureClefsCmd, SplitMeasureCmd};
7220
7221    fn quarter_notes() -> Vec<Note> {
7222        [Step::C, Step::D, Step::E, Step::F]
7223            .into_iter()
7224            .map(|step| Note::new(Pitch::new(step, 4), Duration::Quarter))
7225            .collect()
7226    }
7227
7228    fn change(beats: f64, clef: Clef) -> MidMeasureClef {
7229        MidMeasureClef {
7230            offset: MeasureLength::from_beats(beats).unwrap(),
7231            clef,
7232        }
7233    }
7234
7235    #[test]
7236    fn measure_length_from_beats_is_exact_for_tuplet_subdivisions() {
7237        let third = MeasureLength::from_beats(1.0 / 3.0).unwrap();
7238        assert_eq!((third.numerator, third.denominator), (1, 12));
7239        let two = MeasureLength::from_beats(2.0).unwrap();
7240        assert_eq!((two.numerator, two.denominator), (1, 2));
7241        assert!(MeasureLength::from_beats(0.0).is_none());
7242    }
7243
7244    #[test]
7245    fn clef_at_follows_mid_bar_changes_and_json_omits_an_empty_list() {
7246        let mut measure = Measure::empty(4, 4);
7247        let json = serde_json::to_string(&measure).unwrap();
7248        assert!(!json.contains("mid_clefs"));
7249        measure.mid_clefs = vec![change(2.0, Clef::Bass)];
7250        assert_eq!(measure.clef_at(&Clef::Treble, 1.0), Clef::Treble);
7251        assert_eq!(measure.clef_at(&Clef::Treble, 2.0), Clef::Bass);
7252        let restored: Measure =
7253            serde_json::from_str(&serde_json::to_string(&measure).unwrap()).unwrap();
7254        assert_eq!(restored.mid_clefs, measure.mid_clefs);
7255    }
7256
7257    #[test]
7258    fn validation_rejects_mid_bar_clefs_outside_the_bar_or_out_of_order() {
7259        let mut score = Score::new("clefs", 120, 4, 4, 0, 1);
7260        score.parts[0].staves[0].measures[0].voices[0] = quarter_notes();
7261        score.parts[0].staves[0].measures[0].mid_clefs =
7262            vec![change(2.0, Clef::Bass), change(1.0, Clef::Treble)];
7263        let errors = crate::validate(&score).errors;
7264        assert!(errors.iter().any(|error| matches!(
7265            error,
7266            crate::ValidationError::InvalidMidMeasureClef { index: 1, .. }
7267        )));
7268        score.parts[0].staves[0].measures[0].mid_clefs = vec![change(4.0, Clef::Bass)];
7269        assert!(!crate::validate(&score).errors.is_empty());
7270    }
7271
7272    #[test]
7273    fn command_split_join_diff_and_patch_keep_mid_bar_clefs() {
7274        let mut engine = ScoreEngine::new();
7275        engine.score.parts[0].staves[0].measures[0].voices[0] = quarter_notes();
7276        engine
7277            .apply(Command::SetMidMeasureClefs(SetMidMeasureClefsCmd {
7278                part_index: 0,
7279                staff_index: 0,
7280                measure_index: 0,
7281                clefs: vec![change(1.0, Clef::Bass), change(3.0, Clef::Alto)],
7282            }))
7283            .unwrap();
7284        let with_clefs = engine.score.clone();
7285        let plain = {
7286            let mut score = with_clefs.clone();
7287            score.parts[0].staves[0].measures[0].mid_clefs.clear();
7288            score
7289        };
7290        assert!(diff(&plain, &with_clefs).iter().any(|change| matches!(
7291            change,
7292            ScoreChange::MidMeasureClefsChanged { measure: 0, .. }
7293        )));
7294        let patched = apply_patch(&plain, &score_patch(&plain, &with_clefs)).unwrap();
7295        assert_eq!(
7296            patched.parts[0].staves[0].measures[0].mid_clefs,
7297            with_clefs.parts[0].staves[0].measures[0].mid_clefs
7298        );
7299
7300        engine
7301            .apply(Command::SplitMeasure(SplitMeasureCmd {
7302                measure_index: 0,
7303                split_at_beats: 1.0,
7304            }))
7305            .unwrap();
7306        let measures = &engine.score.parts[0].staves[0].measures;
7307        assert!(measures[0].mid_clefs.is_empty());
7308        assert_eq!(measures[1].clef, Some(Clef::Bass));
7309        assert_eq!(measures[1].mid_clefs, vec![change(2.0, Clef::Alto)]);
7310
7311        engine
7312            .apply(Command::JoinMeasures(JoinMeasuresCmd { measure_index: 0 }))
7313            .unwrap();
7314        let measure = &engine.score.parts[0].staves[0].measures[0];
7315        assert_eq!(
7316            measure.mid_clefs,
7317            vec![change(1.0, Clef::Bass), change(3.0, Clef::Alto)]
7318        );
7319
7320        let invalid = engine.apply(Command::SetMidMeasureClefs(SetMidMeasureClefsCmd {
7321            part_index: 0,
7322            staff_index: 0,
7323            measure_index: 0,
7324            clefs: vec![change(0.0 + 5.0, Clef::Bass)],
7325        }));
7326        assert!(invalid.is_err());
7327    }
7328}
7329
7330#[cfg(test)]
7331mod percussion_key_tests {
7332    use super::*;
7333
7334    #[test]
7335    fn unpitched_chord_members_sound_their_kit_keys_by_display_position() {
7336        let mut part = Part::new("Drums", "Dr.");
7337        part.midi_channel = 9;
7338        let kit = |id: &str, key: u8, display: Pitch| PercussionInstrument {
7339            id: id.into(),
7340            name: None,
7341            midi_unpitched: Some(key),
7342            staff_position: Some(PercussionInstrument::display_position(&display)),
7343            notehead: None,
7344            preferred_voice: None,
7345            techniques: Vec::new(),
7346        };
7347        part.percussion_instruments = vec![
7348            kit("kick", 35, Pitch::new(Step::F, 4)),
7349            kit("snare", 38, Pitch::new(Step::C, 5)),
7350        ];
7351        let mut note = Note::new(Pitch::new(Step::C, 5), Duration::Quarter);
7352        note.pitches.push(Pitch::new(Step::F, 4));
7353        note.is_unpitched = true;
7354        note.instrument_id = Some("snare".into());
7355        assert_eq!(part.percussion_key(&note, 0), Some(38));
7356        assert_eq!(part.percussion_key(&note, 1), Some(35));
7357        note.is_unpitched = false;
7358        assert_eq!(part.percussion_key(&note, 0), None);
7359        assert_eq!(
7360            PercussionInstrument::display_position(&Pitch::new(Step::B, 4)),
7361            0
7362        );
7363    }
7364}