Skip to main content

acorde_core/model/
score.rs

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