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