Skip to main content

acorde_core/model/
score.rs

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