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