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