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