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