Skip to main content

acorde_core/model/
score.rs

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