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