Skip to main content

acorde_core/model/
score.rs

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