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