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