Skip to main content

acorde_core/model/
score.rs

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