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