Skip to main content

acorde_core/model/
score.rs

1use super::{
2    duration::Duration,
3    notation::{
4        Articulation, Barline, BeamState, ChordSymbol, Clef, CrossStaff, Dynamic, GuitarTechnique,
5        HairpinKind, KeySignature, Lyric, NoteHead, OttavaKind, StyledText, TabPosition,
6        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#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct Score {
77    pub id: String,
78    /// JSON schema version. 0 when deserialized from files that predate this field.
79    #[serde(default)]
80    pub schema_version: u32,
81    pub metadata: ScoreMetadata,
82    pub settings: ScoreSettings,
83    pub parts: Vec<Part>,
84    #[serde(default)]
85    pub part_groups: Vec<PartGroup>,
86    /// Typed score-level text annotations retained independently of legacy text fields.
87    #[serde(default)]
88    pub texts: Vec<StyledText>,
89}
90
91impl Default for Score {
92    fn default() -> Self {
93        let mut part = Part::new("Piano", "Pno.");
94        part.staves.push(Staff::new(Clef::Treble));
95        for _ in 0..4 {
96            part.staves[0].measures.push(Measure::empty(4, 4));
97        }
98        Self {
99            id: Uuid::new_v4().to_string(),
100            schema_version: 1,
101            metadata: ScoreMetadata::default(),
102            settings: ScoreSettings::default(),
103            parts: vec![part],
104            part_groups: Vec::new(),
105            texts: Vec::new(),
106        }
107    }
108}
109
110/// Score template presets for common ensemble configurations.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
112pub enum ScoreTemplate {
113    /// Single treble-clef part (piano by default).
114    Solo,
115    /// One piano part with treble + bass grand staff.
116    Piano,
117    /// Violin I, Violin II, Viola, Cello.
118    StringQuartet,
119    /// Violin I, Violin II, Viola, Cello, Contrabass.
120    StringOrchestra,
121    /// Two trumpets, French horn, trombone, tuba.
122    BrassQuintet,
123}
124
125impl Score {
126    pub fn new(
127        title: &str,
128        tempo_bpm: u16,
129        numerator: u8,
130        denominator: u8,
131        fifths: i8,
132        measure_count: u32,
133    ) -> Self {
134        let mut score = Score::default();
135        score.metadata.title = title.to_string();
136        score.settings.tempo_bpm = tempo_bpm;
137        score.settings.time_signature = TimeSignature {
138            numerator,
139            denominator,
140        };
141        score.settings.key_signature = KeySignature {
142            fifths,
143            mode: "major".to_string(),
144        };
145
146        score.parts[0].staves[0].measures.clear();
147        for i in 0..measure_count {
148            let mut m = Measure::empty(numerator, denominator);
149            m.number = i + 1;
150            score.parts[0].staves[0].measures.push(m);
151        }
152        score
153    }
154
155    /// Create a score pre-populated with parts for the given ensemble template.
156    ///
157    /// Defaults: 120 BPM, 4/4, C major, 4 empty measures.
158    /// Use [`NewScoreCmd`](crate::model::commands::NewScoreCmd) to override those after creation.
159    pub fn template(kind: ScoreTemplate) -> Self {
160        fn measures(num: u8, den: u8, count: u32) -> Vec<Measure> {
161            (0..count)
162                .map(|i| {
163                    let mut m = Measure::empty(num, den);
164                    m.number = i + 1;
165                    m
166                })
167                .collect()
168        }
169        fn part(name: &str, short: &str, clef: Clef, program: u8) -> Part {
170            let mut p = Part::new(name, short);
171            p.midi_program = program;
172            let mut s = Staff::new(clef);
173            s.measures = measures(4, 4, 4);
174            p.staves.push(s);
175            p
176        }
177
178        let mut score = Score {
179            id: uuid::Uuid::new_v4().to_string(),
180            schema_version: 1,
181            metadata: ScoreMetadata::default(),
182            settings: ScoreSettings::default(),
183            parts: Vec::new(),
184            part_groups: Vec::new(),
185            texts: Vec::new(),
186        };
187
188        match kind {
189            ScoreTemplate::Solo => {
190                score.parts.push(part("Piano", "Pno.", Clef::Treble, 0));
191            }
192            ScoreTemplate::Piano => {
193                let mut p = Part::new("Piano", "Pno.");
194                p.midi_program = 0;
195                let mut treble = Staff::new(Clef::Treble);
196                treble.measures = measures(4, 4, 4);
197                let mut bass = Staff::new(Clef::Bass);
198                bass.measures = measures(4, 4, 4);
199                p.staves.push(treble);
200                p.staves.push(bass);
201                score.parts.push(p);
202            }
203            ScoreTemplate::StringQuartet => {
204                score
205                    .parts
206                    .push(part("Violin I", "Vn. I", Clef::Treble, 40));
207                score
208                    .parts
209                    .push(part("Violin II", "Vn. II", Clef::Treble, 40));
210                score.parts.push(part("Viola", "Va.", Clef::Alto, 41));
211                score.parts.push(part("Cello", "Vc.", Clef::Bass, 42));
212            }
213            ScoreTemplate::StringOrchestra => {
214                score
215                    .parts
216                    .push(part("Violin I", "Vn. I", Clef::Treble, 40));
217                score
218                    .parts
219                    .push(part("Violin II", "Vn. II", Clef::Treble, 40));
220                score.parts.push(part("Viola", "Va.", Clef::Alto, 41));
221                score.parts.push(part("Cello", "Vc.", Clef::Bass, 42));
222                score.parts.push(part("Contrabass", "Cb.", Clef::Bass, 43));
223            }
224            ScoreTemplate::BrassQuintet => {
225                score
226                    .parts
227                    .push(part("Trumpet I", "Tpt. I", Clef::Treble, 56));
228                score
229                    .parts
230                    .push(part("Trumpet II", "Tpt. II", Clef::Treble, 56));
231                score
232                    .parts
233                    .push(part("French Horn", "Hn.", Clef::Treble, 60));
234                score.parts.push(part("Trombone", "Tbn.", Clef::Bass, 57));
235                score.parts.push(part("Tuba", "Tba.", Clef::Bass, 58));
236            }
237        }
238        score
239    }
240
241    pub fn measure_count(&self) -> usize {
242        self.parts
243            .first()
244            .and_then(|p| p.staves.first())
245            .map(|s| s.measures.len())
246            .unwrap_or(0)
247    }
248
249    /// Aggregate statistics about the score.
250    pub fn statistics(&self) -> ScoreStats {
251        let measure_count = self.measure_count();
252        let part_count = self.parts.len();
253
254        // Beat accumulation via measure_sequence so repeats are counted correctly.
255        let seq = measure_sequence(self);
256        let total_beats: f64 = self
257            .parts
258            .first()
259            .and_then(|p| p.staves.first())
260            .map(|s| {
261                seq.iter()
262                    .filter_map(|&idx| s.measures.get(idx))
263                    .flat_map(|m| m.voices.iter().flat_map(|v| v.iter()))
264                    .map(|n| n.beats())
265                    .sum()
266            })
267            .unwrap_or(0.0);
268
269        let mut note_count = 0usize;
270        let mut rest_count = 0usize;
271        for part in &self.parts {
272            for staff in &part.staves {
273                for measure in &staff.measures {
274                    for voice in &measure.voices {
275                        for note in voice {
276                            if note.is_rest {
277                                rest_count += 1;
278                            } else {
279                                note_count += 1;
280                            }
281                        }
282                    }
283                }
284            }
285        }
286
287        let bpm = self.settings.tempo_bpm as f64;
288        let estimated_duration_secs = if bpm > 0.0 {
289            total_beats / bpm * 60.0
290        } else {
291            0.0
292        };
293
294        ScoreStats {
295            measure_count,
296            note_count,
297            rest_count,
298            part_count,
299            estimated_duration_secs,
300        }
301    }
302
303    /// Return a new `Score` containing only the given part.
304    /// Returns `None` if `part_index` is out of range.
305    pub fn extract_part(&self, part_index: usize) -> Option<Score> {
306        let part = self.parts.get(part_index)?.clone();
307        Some(Score {
308            id: Uuid::new_v4().to_string(),
309            schema_version: 1,
310            metadata: self.metadata.clone(),
311            settings: self.settings.clone(),
312            parts: vec![part],
313            part_groups: Vec::new(),
314            texts: self.texts.clone(),
315        })
316    }
317
318    /// Merge two scores by appending `other`'s parts to `self`'s parts.
319    /// Shorter scores are padded with empty measures to match the longer one.
320    /// Metadata and settings are taken from `self`.
321    pub fn merge(&self, other: &Score) -> Score {
322        let self_count = self.measure_count();
323        let other_count = other.measure_count();
324        let max_count = self_count.max(other_count);
325        let ts = self.settings.time_signature.clone();
326
327        let pad = |mut part: Part, from: usize| -> Part {
328            for staff in &mut part.staves {
329                for i in from..max_count {
330                    let mut m = Measure::empty(ts.numerator, ts.denominator);
331                    m.number = i as u32 + 1;
332                    staff.measures.push(m);
333                }
334            }
335            part
336        };
337
338        let mut parts: Vec<Part> = self
339            .parts
340            .iter()
341            .cloned()
342            .map(|p| pad(p, self_count))
343            .collect();
344        for p in &other.parts {
345            parts.push(pad(p.clone(), other_count));
346        }
347
348        Score {
349            id: Uuid::new_v4().to_string(),
350            schema_version: 1,
351            metadata: self.metadata.clone(),
352            settings: self.settings.clone(),
353            parts,
354            part_groups: Vec::new(),
355            texts: self.texts.clone(),
356        }
357    }
358}
359
360/// Aggregate statistics returned by [`Score::statistics`].
361#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
362pub struct ScoreStats {
363    pub measure_count: usize,
364    /// Number of non-rest notes across all parts.
365    pub note_count: usize,
366    pub rest_count: usize,
367    pub part_count: usize,
368    /// Rough estimate: `total_beats(first part) / tempo_bpm * 60`.
369    pub estimated_duration_secs: f64,
370}
371
372// ── transpose ─────────────────────────────────────────────────────────────────
373
374use super::pitch::Step;
375use super::repeat::measure_sequence;
376
377/// Assign deterministic guitar tablature positions to eligible notes and chords.
378///
379/// Existing positions are preserved. For each note, the lowest reachable fret is
380/// selected, breaking ties toward the highest string (the lowest string number). For
381/// chords, strings are unique and the assignment minimizes total fret, then fret
382/// span, then highest fret. This is a bounded deterministic fingering heuristic,
383/// not a claim of instrument-specific playability.
384/// The staff tuning is interpreted as open-string MIDI pitches before capo, and
385/// assignments are limited to frets 0 through 24. Rests, chords, and notes with
386/// no reachable position are left unchanged. Returns the number of notes/chords
387/// assigned.
388pub fn assign_tablature_positions(score: &mut Score) -> usize {
389    const MAX_FRET: i16 = 24;
390    let mut assigned = 0;
391
392    for part in &mut score.parts {
393        for staff in &mut part.staves {
394            let Some(tab) = &staff.tablature else {
395                continue;
396            };
397            let tuning = tab.tuning_midi.clone();
398            let lines = tab.lines as usize;
399            let capo = i16::from(tab.capo);
400
401            for measure in &mut staff.measures {
402                for voice in &mut measure.voices {
403                    for note in voice.iter_mut() {
404                        if note.is_rest
405                            || note.tab_position.is_some()
406                            || !note.tab_positions.is_empty()
407                            || note.pitches.is_empty()
408                        {
409                            continue;
410                        }
411                        let pitches: Vec<i16> = note.pitches.iter().map(Pitch::to_midi).collect();
412                        let Some(positions) =
413                            best_tablature_assignment(&pitches, &tuning, lines, capo, MAX_FRET)
414                        else {
415                            continue;
416                        };
417
418                        note.tab_position = positions.first().cloned();
419                        note.tab_positions = positions;
420                        note.string_number = note.tab_position.as_ref().map(|p| p.string);
421                        assigned += 1;
422                    }
423                }
424            }
425        }
426    }
427
428    assigned
429}
430
431/// Optimize tablature positions across each voice using bounded position movement.
432///
433/// Explicit positions are treated as fixed anchors. Unassigned notes and chords
434/// receive positions that minimize fret load plus movement from the preceding
435/// event; ties are resolved deterministically. Returns the number of notes/chords
436/// newly assigned.
437pub fn optimize_tablature_positions(score: &mut Score) -> usize {
438    const MAX_FRET: i16 = 24;
439    let mut assigned = 0;
440
441    for part in &mut score.parts {
442        for staff in &mut part.staves {
443            let Some(tab) = &staff.tablature else {
444                continue;
445            };
446            let tuning = tab.tuning_midi.clone();
447            let lines = tab.lines as usize;
448            let capo = i16::from(tab.capo);
449            for voice in 0..4 {
450                // Work per measure/voice location so repeated note indices remain distinct.
451                let mut locations = Vec::new();
452                for (measure_index, measure) in staff.measures.iter().enumerate() {
453                    for (note_index, note) in measure.voices[voice].iter().enumerate() {
454                        if !note.is_rest && !note.pitches.is_empty() {
455                            locations.push((measure_index, note_index, note.clone()));
456                        }
457                    }
458                }
459                let candidates: Vec<Vec<Vec<TabPosition>>> = locations
460                    .iter()
461                    .map(|(_, _, note)| {
462                        if let Some(positions) = if !note.tab_positions.is_empty() {
463                            Some(note.tab_positions.clone())
464                        } else {
465                            note.tab_position.clone().map(|position| vec![position])
466                        } {
467                            vec![positions]
468                        } else {
469                            tablature_assignments(
470                                &note.pitches.iter().map(Pitch::to_midi).collect::<Vec<_>>(),
471                                &tuning,
472                                lines,
473                                capo,
474                                MAX_FRET,
475                            )
476                        }
477                    })
478                    .collect();
479                if candidates.iter().any(Vec::is_empty) {
480                    continue;
481                }
482
483                let mut costs: Vec<Vec<(u32, Option<usize>)>> = candidates
484                    .iter()
485                    .map(|events| vec![(u32::MAX, None); events.len()])
486                    .collect();
487                for (candidate_index, candidate) in candidates[0].iter().enumerate() {
488                    costs[0][candidate_index] = (tablature_load(candidate), None);
489                }
490                for event_index in 1..candidates.len() {
491                    for (candidate_index, candidate) in candidates[event_index].iter().enumerate() {
492                        let load = tablature_load(candidate);
493                        for (previous_index, previous) in
494                            candidates[event_index - 1].iter().enumerate()
495                        {
496                            let previous_cost = costs[event_index - 1][previous_index].0;
497                            let cost = previous_cost
498                                .saturating_add(load)
499                                .saturating_add(tablature_movement(previous, candidate));
500                            if cost < costs[event_index][candidate_index].0 {
501                                costs[event_index][candidate_index] = (cost, Some(previous_index));
502                            }
503                        }
504                    }
505                }
506                let mut selected = vec![0; candidates.len()];
507                if let Some((last, _)) = costs.last().and_then(|row| {
508                    row.iter()
509                        .enumerate()
510                        .min_by_key(|(index, (cost, _))| (*cost, *index))
511                }) {
512                    selected[candidates.len() - 1] = last;
513                    for event_index in (1..candidates.len()).rev() {
514                        selected[event_index - 1] =
515                            costs[event_index][selected[event_index]].1.unwrap_or(0);
516                    }
517                }
518
519                for (((measure_index, note_index, original), event_candidates), selected_index) in
520                    locations.into_iter().zip(candidates).zip(selected)
521                {
522                    if original.tab_position.is_none() && original.tab_positions.is_empty() {
523                        let note = &mut staff.measures[measure_index].voices[voice][note_index];
524                        let positions = event_candidates[selected_index].clone();
525                        note.tab_position = positions.first().cloned();
526                        note.tab_positions = positions;
527                        note.string_number = note.tab_position.as_ref().map(|p| p.string);
528                        assigned += 1;
529                    }
530                }
531            }
532        }
533    }
534
535    assigned
536}
537
538fn tablature_load(positions: &[TabPosition]) -> u32 {
539    let sum: u32 = positions
540        .iter()
541        .map(|position| u32::from(position.fret))
542        .sum();
543    let min = positions
544        .iter()
545        .map(|position| position.fret)
546        .min()
547        .unwrap_or(0);
548    let max = positions
549        .iter()
550        .map(|position| position.fret)
551        .max()
552        .unwrap_or(0);
553    let span = max - min;
554    // A four-fret hand position is a practical baseline; wide chords receive
555    // a strong penalty so a higher but compact voicing wins when available.
556    let stretch_penalty = span.saturating_sub(4) as u32 * 12;
557    sum + u32::from(span) * 2 + stretch_penalty
558}
559
560fn tablature_movement(previous: &[TabPosition], current: &[TabPosition]) -> u32 {
561    previous
562        .iter()
563        .zip(current)
564        .map(|(a, b)| u32::from(a.fret.abs_diff(b.fret)) + u32::from(a.string.abs_diff(b.string)))
565        .sum()
566}
567
568fn tablature_assignments(
569    pitches: &[i16],
570    tuning: &[i16],
571    lines: usize,
572    capo: i16,
573    max_fret: i16,
574) -> Vec<Vec<TabPosition>> {
575    #[allow(clippy::too_many_arguments)]
576    fn visit(
577        pitches: &[i16],
578        tuning: &[i16],
579        lines: usize,
580        capo: i16,
581        max_fret: i16,
582        index: usize,
583        used: &mut [bool],
584        current: &mut Vec<TabPosition>,
585        output: &mut Vec<Vec<TabPosition>>,
586    ) {
587        if index == pitches.len() {
588            output.push(current.clone());
589            return;
590        }
591        for (string, open) in tuning.iter().enumerate().take(lines) {
592            if used[string] {
593                continue;
594            }
595            let fret = pitches[index] - *open - capo;
596            if !(0..=max_fret).contains(&fret) {
597                continue;
598            }
599            used[string] = true;
600            current.push(TabPosition {
601                string: (string + 1) as u8,
602                fret: fret as u8,
603            });
604            visit(
605                pitches,
606                tuning,
607                lines,
608                capo,
609                max_fret,
610                index + 1,
611                used,
612                current,
613                output,
614            );
615            current.pop();
616            used[string] = false;
617        }
618    }
619
620    if pitches.is_empty() || pitches.len() > lines {
621        return Vec::new();
622    }
623    let mut output = Vec::new();
624    visit(
625        pitches,
626        tuning,
627        lines,
628        capo,
629        max_fret,
630        0,
631        &mut vec![false; lines],
632        &mut Vec::new(),
633        &mut output,
634    );
635    output
636}
637
638fn best_tablature_assignment(
639    pitches: &[i16],
640    tuning: &[i16],
641    lines: usize,
642    capo: i16,
643    max_fret: i16,
644) -> Option<Vec<TabPosition>> {
645    type Assignment = (i16, i16, i16, Vec<u8>, Vec<TabPosition>);
646
647    #[allow(clippy::too_many_arguments)]
648    fn search(
649        pitches: &[i16],
650        tuning: &[i16],
651        lines: usize,
652        capo: i16,
653        max_fret: i16,
654        index: usize,
655        used: &mut [bool],
656        current: &mut Vec<TabPosition>,
657        best: &mut Option<Assignment>,
658    ) {
659        if index == pitches.len() {
660            let sum: i16 = current.iter().map(|p| i16::from(p.fret)).sum();
661            let min = current.iter().map(|p| p.fret).min().unwrap_or(0);
662            let max = current.iter().map(|p| p.fret).max().unwrap_or(0);
663            let strings: Vec<u8> = current.iter().map(|p| p.string).collect();
664            let candidate = (
665                sum,
666                i16::from(max) - i16::from(min),
667                i16::from(max),
668                strings,
669                current.clone(),
670            );
671            if best.as_ref().is_none_or(|existing| {
672                (candidate.0, candidate.1, candidate.2, &candidate.3)
673                    < (existing.0, existing.1, existing.2, &existing.3)
674            }) {
675                *best = Some(candidate);
676            }
677            return;
678        }
679
680        for (string, open) in tuning.iter().enumerate().take(lines) {
681            if used[string] {
682                continue;
683            }
684            let fret = pitches[index] - *open - capo;
685            if !(0..=max_fret).contains(&fret) {
686                continue;
687            }
688            used[string] = true;
689            current.push(TabPosition {
690                string: (string + 1) as u8,
691                fret: fret as u8,
692            });
693            search(
694                pitches,
695                tuning,
696                lines,
697                capo,
698                max_fret,
699                index + 1,
700                used,
701                current,
702                best,
703            );
704            current.pop();
705            used[string] = false;
706        }
707    }
708
709    if pitches.len() > lines {
710        return None;
711    }
712    let mut used = vec![false; lines];
713    let mut current = Vec::with_capacity(pitches.len());
714    let mut best = None;
715    search(
716        pitches,
717        tuning,
718        lines,
719        capo,
720        max_fret,
721        0,
722        &mut used,
723        &mut current,
724        &mut best,
725    );
726    best.map(|(_, _, _, _, positions)| positions)
727}
728
729/// Return a new `Score` with all pitches shifted by `semitones`.
730/// Key signatures (global and per-measure) are updated accordingly.
731/// If `semitones == 0` the score is cloned unchanged.
732pub fn transpose(score: &Score, semitones: i8) -> Score {
733    if semitones == 0 {
734        return score.clone();
735    }
736    let mut out = score.clone();
737    out.settings.key_signature.fifths = transpose_fifths(
738        score.settings.key_signature.fifths,
739        &score.settings.key_signature.mode,
740        semitones,
741    );
742    for part in &mut out.parts {
743        for staff in &mut part.staves {
744            for measure in &mut staff.measures {
745                if let Some(ref mut ks) = measure.key_sig {
746                    ks.fifths = transpose_fifths(ks.fifths, &ks.mode, semitones);
747                }
748                for voice in &mut measure.voices {
749                    for note in voice.iter_mut() {
750                        for pitch in note.pitches.iter_mut() {
751                            *pitch = transpose_pitch(pitch, semitones);
752                        }
753                    }
754                }
755            }
756        }
757    }
758    out
759}
760
761fn transpose_pitch(pitch: &Pitch, semitones: i8) -> Pitch {
762    let new_midi = (pitch.to_midi() + semitones as i16).clamp(0, 127) as u8;
763    let pc = new_midi % 12;
764    let oct = (new_midi / 12) as i8 - 1;
765    let (step, alter): (Step, i8) = if semitones >= 0 {
766        match pc {
767            0 => (Step::C, 0),
768            1 => (Step::C, 1),
769            2 => (Step::D, 0),
770            3 => (Step::D, 1),
771            4 => (Step::E, 0),
772            5 => (Step::F, 0),
773            6 => (Step::F, 1),
774            7 => (Step::G, 0),
775            8 => (Step::G, 1),
776            9 => (Step::A, 0),
777            10 => (Step::A, 1),
778            11 => (Step::B, 0),
779            _ => (Step::C, 0),
780        }
781    } else {
782        match pc {
783            0 => (Step::C, 0),
784            1 => (Step::D, -1),
785            2 => (Step::D, 0),
786            3 => (Step::E, -1),
787            4 => (Step::E, 0),
788            5 => (Step::F, 0),
789            6 => (Step::G, -1),
790            7 => (Step::G, 0),
791            8 => (Step::A, -1),
792            9 => (Step::A, 0),
793            10 => (Step::B, -1),
794            11 => (Step::B, 0),
795            _ => (Step::C, 0),
796        }
797    };
798    Pitch::with_microtone(step, oct, alter, pitch.microtone_cents)
799}
800
801/// Shift a key signature's fifths value by `semitones`.
802///
803/// Uses the circle-of-fifths arithmetic:
804/// - `tonic_pc = (fifths * 7) mod 12`  (for major; minor adds 9 to get relative major tonic)
805/// - `new_fifths = (new_tonic_pc * 7) mod 12`, adjusted to `[-7, 7]`
806fn transpose_fifths(fifths: i8, mode: &str, semitones: i8) -> i8 {
807    let tonic_major_pc = ((fifths as i32 * 7).rem_euclid(12)) as u8;
808    let tonic_pc = if mode == "minor" {
809        ((tonic_major_pc as i32 + 9).rem_euclid(12)) as u8
810    } else {
811        tonic_major_pc
812    };
813    let new_tonic = ((tonic_pc as i32 + semitones as i32).rem_euclid(12)) as u8;
814    let major_tonic = if mode == "minor" {
815        ((new_tonic as i32 + 3).rem_euclid(12)) as u8
816    } else {
817        new_tonic
818    };
819    let raw = ((major_tonic as i32 * 7).rem_euclid(12)) as i8;
820    if raw > 6 { raw - 12 } else { raw }
821}
822
823#[derive(Debug, Clone, Serialize, Deserialize)]
824pub struct Part {
825    pub id: String,
826    pub name: String,
827    pub short_name: String,
828    pub staves: Vec<Staff>,
829    /// MIDI channel (0–15). Channel 9 is conventionally used for percussion.
830    #[serde(default)]
831    pub midi_channel: u8,
832    /// General MIDI program number (0–127). Default 0 = Acoustic Grand Piano.
833    #[serde(default)]
834    pub midi_program: u8,
835    /// MIDI pitch-bend events preserved from interchange input, in PPQ ticks.
836    #[serde(default)]
837    pub midi_pitch_bends: Vec<MidiPitchBend>,
838}
839
840#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
841pub struct MidiPitchBend {
842    pub tick: u64,
843    pub channel: u8,
844    /// Signed 14-bit MIDI bend value in the range -8192..=8191.
845    pub value: i16,
846}
847
848impl Part {
849    pub fn new(name: &str, short_name: &str) -> Self {
850        Self {
851            id: Uuid::new_v4().to_string(),
852            name: name.to_string(),
853            short_name: short_name.to_string(),
854            staves: Vec::new(),
855            midi_channel: 0,
856            midi_program: 0,
857            midi_pitch_bends: Vec::new(),
858        }
859    }
860}
861
862#[derive(Debug, Clone, Serialize, Deserialize)]
863pub struct Staff {
864    pub clef: Clef,
865    pub measures: Vec<Measure>,
866    /// Semitones to add to written pitch for concert pitch / MIDI output.
867    /// -2 = Bb instrument (clarinet, trumpet), -9 = Eb instrument (alto sax), etc.
868    #[serde(default)]
869    pub transpose_semitones: i8,
870    #[serde(default)]
871    pub tablature: Option<TablatureConfig>,
872}
873
874impl Staff {
875    pub fn new(clef: Clef) -> Self {
876        Self {
877            clef,
878            measures: Vec::new(),
879            transpose_semitones: 0,
880            tablature: None,
881        }
882    }
883}
884
885#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
886pub struct VoltaBracket {
887    /// Ending number (1, 2, …)
888    pub number: u8,
889    /// "begin" | "mid" | "end" | "begin_end"
890    pub kind: String,
891}
892
893#[derive(Debug, Clone, Serialize, Deserialize)]
894pub struct Measure {
895    pub number: u32,
896    pub time_sig: Option<TimeSignature>,
897    pub key_sig: Option<KeySignature>,
898    pub clef: Option<Clef>,
899    pub tempo: Option<u16>,
900    pub barline_left: Barline,
901    pub barline_right: Barline,
902    #[serde(default)]
903    pub volta: Option<VoltaBracket>,
904    #[serde(default)]
905    pub tempo_text: Option<String>,
906    #[serde(default)]
907    pub rehearsal: Option<String>,
908    /// Navigation mark: "Segno" | "Coda" | "Fine" | "DaCapo" | "DaCapoAlFine" |
909    /// "DaCapoAlCoda" | "DalSegno" | "DalSegnoAlFine" | "DalSegnoAlCoda" | "ToCoda"
910    #[serde(default)]
911    pub navigation: Option<String>,
912    /// Expression / performance text ("dolce", "espressivo", "con fuoco", etc.).
913    #[serde(default)]
914    pub expression_text: Option<String>,
915    #[serde(default)]
916    pub texts: Vec<StyledText>,
917    /// When ≥ 2, this measure is displayed as a multi-measure rest spanning N measures.
918    #[serde(default)]
919    pub multi_rest_count: Option<u8>,
920    /// Force a new system (row) after this measure.
921    #[serde(default)]
922    pub system_break: bool,
923    /// Force a new page after this measure.
924    #[serde(default)]
925    pub page_break: bool,
926    /// Up to 4 voices; voice 0 is the primary voice.
927    pub voices: [Vec<Note>; 4],
928}
929
930impl Measure {
931    pub fn empty(numerator: u8, denominator: u8) -> Self {
932        let total_beats = TimeSignature {
933            numerator,
934            denominator,
935        }
936        .total_beats();
937        let mut voice0: Vec<Note> = Vec::new();
938        let mut remaining = total_beats;
939        while remaining > 1e-9 {
940            let dur = Duration::whole_filling_beats(remaining);
941            remaining -= dur.beats(0);
942            voice0.push(Note::rest(dur));
943        }
944        Self {
945            number: 0,
946            time_sig: None,
947            key_sig: None,
948            clef: None,
949            tempo: None,
950            barline_left: Barline::Normal,
951            barline_right: Barline::Normal,
952            volta: None,
953            tempo_text: None,
954            rehearsal: None,
955            navigation: None,
956            expression_text: None,
957            texts: Vec::new(),
958            multi_rest_count: None,
959            system_break: false,
960            page_break: false,
961            voices: [voice0, vec![], vec![], vec![]],
962        }
963    }
964
965    pub fn renumber(&mut self, n: u32) {
966        self.number = n;
967    }
968}
969
970#[derive(Debug, Clone, Serialize, Deserialize)]
971pub struct Note {
972    pub id: String,
973    pub is_rest: bool,
974    /// Single note: one pitch. Chord: multiple pitches (same duration).
975    pub pitches: Vec<Pitch>,
976    #[serde(default)]
977    pub tab_position: Option<super::notation::TabPosition>,
978    /// One tablature position per pitch; the first entry mirrors `tab_position`.
979    /// This is populated for chords so each pitch can occupy a distinct string.
980    #[serde(default)]
981    pub tab_positions: Vec<super::notation::TabPosition>,
982    pub duration: Duration,
983    pub dot_count: u8,
984    pub tie_start: bool,
985    pub tie_end: bool,
986    pub beam: BeamState,
987    pub articulations: Vec<Articulation>,
988    pub dynamic: Option<Dynamic>,
989    pub stem_up: Option<bool>,
990    #[serde(default)]
991    pub hairpin_start: Option<HairpinKind>,
992    #[serde(default)]
993    pub hairpin_end: bool,
994    #[serde(default)]
995    pub tuplet: Option<TupletInfo>,
996    #[serde(default)]
997    pub chord_symbol: Option<ChordSymbol>,
998    #[serde(default)]
999    pub is_grace: bool,
1000    /// Acciaccatura: true (slash through stem). Appoggiatura: false.
1001    #[serde(default)]
1002    pub grace_slash: bool,
1003    #[serde(default)]
1004    pub ottava_start: Option<OttavaKind>,
1005    #[serde(default)]
1006    pub ottava_end: bool,
1007    #[serde(default)]
1008    pub lyric: Option<Lyric>,
1009    #[serde(default)]
1010    pub pedal_start: bool,
1011    #[serde(default)]
1012    pub pedal_end: bool,
1013    #[serde(default)]
1014    pub slur_start: bool,
1015    #[serde(default)]
1016    pub slur_end: bool,
1017    /// Arpeggiate direction: `Some(true)` = up, `Some(false)` = down, `None` = none.
1018    #[serde(default)]
1019    pub arpeggiate: Option<bool>,
1020    /// Technique/style instruction attached to this note ("pizz.", "arco", "con sord.", etc.).
1021    #[serde(default)]
1022    pub technique_text: Option<String>,
1023    #[serde(default)]
1024    pub glissando_start: bool,
1025    #[serde(default)]
1026    pub glissando_end: bool,
1027    #[serde(default)]
1028    pub cross_staff: Option<CrossStaff>,
1029    /// Left-hand fingering number (0 = open / thumb, 1–5 = fingers).
1030    #[serde(default)]
1031    pub fingering: Option<u8>,
1032    /// String number for plucked/bowed string instruments (1 = highest string).
1033    #[serde(default)]
1034    pub string_number: Option<u8>,
1035    #[serde(default)]
1036    pub note_head: NoteHead,
1037    /// Cue note (small-sized, does not count toward beat total).
1038    #[serde(default)]
1039    pub is_cue: bool,
1040    /// Start of a multi-note trill line span.
1041    #[serde(default)]
1042    pub trill_line_start: bool,
1043    /// End of a multi-note trill line span.
1044    #[serde(default)]
1045    pub trill_line_end: bool,
1046    /// Guitar-specific playing technique (bend, slide, hammer-on, pull-off).
1047    #[serde(default)]
1048    pub guitar_technique: Option<GuitarTechnique>,
1049}
1050
1051impl Note {
1052    pub fn new(pitch: Pitch, duration: Duration) -> Self {
1053        Self {
1054            id: Uuid::new_v4().to_string(),
1055            is_rest: false,
1056            pitches: vec![pitch],
1057            tab_position: None,
1058            tab_positions: Vec::new(),
1059            duration,
1060            dot_count: 0,
1061            tie_start: false,
1062            tie_end: false,
1063            beam: BeamState::None,
1064            articulations: Vec::new(),
1065            dynamic: None,
1066            stem_up: None,
1067            hairpin_start: None,
1068            hairpin_end: false,
1069            tuplet: None,
1070            chord_symbol: None,
1071            is_grace: false,
1072            grace_slash: false,
1073            ottava_start: None,
1074            ottava_end: false,
1075            lyric: None,
1076            pedal_start: false,
1077            pedal_end: false,
1078            slur_start: false,
1079            slur_end: false,
1080            arpeggiate: None,
1081            technique_text: None,
1082            glissando_start: false,
1083            glissando_end: false,
1084            cross_staff: None,
1085            fingering: None,
1086            string_number: None,
1087            note_head: NoteHead::Normal,
1088            is_cue: false,
1089            trill_line_start: false,
1090            trill_line_end: false,
1091            guitar_technique: None,
1092        }
1093    }
1094
1095    pub fn rest(duration: Duration) -> Self {
1096        Self {
1097            id: Uuid::new_v4().to_string(),
1098            is_rest: true,
1099            pitches: Vec::new(),
1100            tab_position: None,
1101            tab_positions: Vec::new(),
1102            duration,
1103            dot_count: 0,
1104            tie_start: false,
1105            tie_end: false,
1106            beam: BeamState::None,
1107            articulations: Vec::new(),
1108            dynamic: None,
1109            stem_up: None,
1110            hairpin_start: None,
1111            hairpin_end: false,
1112            tuplet: None,
1113            chord_symbol: None,
1114            is_grace: false,
1115            grace_slash: false,
1116            ottava_start: None,
1117            ottava_end: false,
1118            lyric: None,
1119            pedal_start: false,
1120            pedal_end: false,
1121            slur_start: false,
1122            slur_end: false,
1123            arpeggiate: None,
1124            technique_text: None,
1125            glissando_start: false,
1126            glissando_end: false,
1127            cross_staff: None,
1128            fingering: None,
1129            string_number: None,
1130            note_head: NoteHead::Normal,
1131            is_cue: false,
1132            trill_line_start: false,
1133            trill_line_end: false,
1134            guitar_technique: None,
1135        }
1136    }
1137
1138    pub fn beats(&self) -> f64 {
1139        if self.is_grace || self.is_cue {
1140            return 0.0;
1141        }
1142        let base = self.duration.beats(self.dot_count);
1143        if let Some(ref t) = self.tuplet {
1144            base * (t.normal_notes as f64) / (t.actual_notes as f64)
1145        } else {
1146            base
1147        }
1148    }
1149}
1150
1151impl Duration {
1152    /// Returns the largest single duration that fills the given number of beats.
1153    pub fn whole_filling_beats(beats: f64) -> Duration {
1154        if beats >= 4.0 {
1155            Duration::Whole
1156        } else if beats >= 2.0 {
1157            Duration::Half
1158        } else if beats >= 1.0 {
1159            Duration::Quarter
1160        } else if beats >= 0.5 {
1161            Duration::Eighth
1162        } else if beats >= 0.25 {
1163            Duration::Sixteenth
1164        } else if beats >= 0.125 {
1165            Duration::ThirtySecond
1166        } else {
1167            Duration::SixtyFourth
1168        }
1169    }
1170}
1171
1172// ── NoteAddr ──────────────────────────────────────────────────────────────────
1173
1174/// Physical address of a note within a score.
1175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1176pub struct NoteAddr {
1177    pub part: usize,
1178    pub staff: usize,
1179    pub measure: usize,
1180    pub voice: usize,
1181    pub note: usize,
1182}
1183
1184// ── diff ──────────────────────────────────────────────────────────────────────
1185
1186/// A single change between two [`Score`] values as reported by [`diff`].
1187#[derive(Debug, Clone, Serialize, Deserialize)]
1188pub enum ScoreChange {
1189    MetadataChanged {
1190        field: String,
1191        old: String,
1192        new: String,
1193    },
1194    TempoChanged {
1195        old: u16,
1196        new: u16,
1197    },
1198    KeySignatureChanged {
1199        old: KeySignature,
1200        new: KeySignature,
1201    },
1202    PartAdded {
1203        part_index: usize,
1204    },
1205    PartRemoved {
1206        part_index: usize,
1207        name: String,
1208    },
1209    NoteAdded {
1210        part: usize,
1211        staff: usize,
1212        measure: usize,
1213        voice: usize,
1214        note_index: usize,
1215    },
1216    NoteRemoved {
1217        part: usize,
1218        staff: usize,
1219        measure: usize,
1220        voice: usize,
1221        note: Box<Note>,
1222    },
1223    NoteModified {
1224        part: usize,
1225        staff: usize,
1226        measure: usize,
1227        voice: usize,
1228        note_index: usize,
1229        old: Box<Note>,
1230        new: Box<Note>,
1231    },
1232    TimeSigChanged {
1233        part: usize,
1234        staff: usize,
1235        measure: usize,
1236        old: Option<TimeSignature>,
1237        new: Option<TimeSignature>,
1238    },
1239    MeasureTempoChanged {
1240        part: usize,
1241        staff: usize,
1242        measure: usize,
1243        old: Option<u16>,
1244        new: Option<u16>,
1245    },
1246    BarlineChanged {
1247        part: usize,
1248        staff: usize,
1249        measure: usize,
1250    },
1251    RehearsalMarkChanged {
1252        part: usize,
1253        staff: usize,
1254        measure: usize,
1255        old: Option<String>,
1256        new: Option<String>,
1257    },
1258    VoltaChanged {
1259        part: usize,
1260        staff: usize,
1261        measure: usize,
1262    },
1263}
1264
1265/// Compare two scores and return a list of differences.
1266///
1267/// Parts, staves, measures, and voices are compared by position. Notes are compared by
1268/// position within each voice, ignoring their `id` field. Metadata fields are compared
1269/// individually.
1270pub fn diff(a: &Score, b: &Score) -> Vec<ScoreChange> {
1271    let mut changes: Vec<ScoreChange> = Vec::new();
1272
1273    macro_rules! meta {
1274        ($field:ident, $name:literal) => {
1275            if a.metadata.$field != b.metadata.$field {
1276                changes.push(ScoreChange::MetadataChanged {
1277                    field: $name.to_string(),
1278                    old: a.metadata.$field.clone(),
1279                    new: b.metadata.$field.clone(),
1280                });
1281            }
1282        };
1283    }
1284    meta!(title, "title");
1285    meta!(composer, "composer");
1286    meta!(lyricist, "lyricist");
1287    meta!(copyright, "copyright");
1288    meta!(work_number, "work_number");
1289    meta!(movement_title, "movement_title");
1290
1291    if a.settings.tempo_bpm != b.settings.tempo_bpm {
1292        changes.push(ScoreChange::TempoChanged {
1293            old: a.settings.tempo_bpm,
1294            new: b.settings.tempo_bpm,
1295        });
1296    }
1297    if a.settings.key_signature != b.settings.key_signature {
1298        changes.push(ScoreChange::KeySignatureChanged {
1299            old: a.settings.key_signature.clone(),
1300            new: b.settings.key_signature.clone(),
1301        });
1302    }
1303
1304    let a_len = a.parts.len();
1305    let b_len = b.parts.len();
1306    for i in b_len..a_len {
1307        changes.push(ScoreChange::PartRemoved {
1308            part_index: i,
1309            name: a.parts[i].name.clone(),
1310        });
1311    }
1312    for i in a_len..b_len {
1313        changes.push(ScoreChange::PartAdded { part_index: i });
1314    }
1315
1316    for pi in 0..a_len.min(b_len) {
1317        let ap = &a.parts[pi];
1318        let bp = &b.parts[pi];
1319        for si in 0..ap.staves.len().min(bp.staves.len()) {
1320            let a_staff = &ap.staves[si];
1321            let b_staff = &bp.staves[si];
1322            for mi in 0..a_staff.measures.len().min(b_staff.measures.len()) {
1323                let am = &a_staff.measures[mi];
1324                let bm = &b_staff.measures[mi];
1325                for vi in 0..4usize {
1326                    let av = &am.voices[vi];
1327                    let bv = &bm.voices[vi];
1328                    for (ni, (a_note, b_note)) in av.iter().zip(bv.iter()).enumerate() {
1329                        if !note_content_eq(a_note, b_note) {
1330                            changes.push(ScoreChange::NoteModified {
1331                                part: pi,
1332                                staff: si,
1333                                measure: mi,
1334                                voice: vi,
1335                                note_index: ni,
1336                                old: Box::new(a_note.clone()),
1337                                new: Box::new(b_note.clone()),
1338                            });
1339                        }
1340                    }
1341                    for note in av.iter().skip(bv.len()) {
1342                        changes.push(ScoreChange::NoteRemoved {
1343                            part: pi,
1344                            staff: si,
1345                            measure: mi,
1346                            voice: vi,
1347                            note: Box::new(note.clone()),
1348                        });
1349                    }
1350                    for ni in av.len()..bv.len() {
1351                        changes.push(ScoreChange::NoteAdded {
1352                            part: pi,
1353                            staff: si,
1354                            measure: mi,
1355                            voice: vi,
1356                            note_index: ni,
1357                        });
1358                    }
1359                }
1360                if am.time_sig != bm.time_sig {
1361                    changes.push(ScoreChange::TimeSigChanged {
1362                        part: pi,
1363                        staff: si,
1364                        measure: mi,
1365                        old: am.time_sig.clone(),
1366                        new: bm.time_sig.clone(),
1367                    });
1368                }
1369                if am.tempo != bm.tempo {
1370                    changes.push(ScoreChange::MeasureTempoChanged {
1371                        part: pi,
1372                        staff: si,
1373                        measure: mi,
1374                        old: am.tempo,
1375                        new: bm.tempo,
1376                    });
1377                }
1378                if am.barline_left != bm.barline_left || am.barline_right != bm.barline_right {
1379                    changes.push(ScoreChange::BarlineChanged {
1380                        part: pi,
1381                        staff: si,
1382                        measure: mi,
1383                    });
1384                }
1385                if am.rehearsal != bm.rehearsal {
1386                    changes.push(ScoreChange::RehearsalMarkChanged {
1387                        part: pi,
1388                        staff: si,
1389                        measure: mi,
1390                        old: am.rehearsal.clone(),
1391                        new: bm.rehearsal.clone(),
1392                    });
1393                }
1394                if am.volta != bm.volta {
1395                    changes.push(ScoreChange::VoltaChanged {
1396                        part: pi,
1397                        staff: si,
1398                        measure: mi,
1399                    });
1400                }
1401            }
1402        }
1403    }
1404
1405    changes
1406}
1407
1408// ── ScorePatch ────────────────────────────────────────────────────────────────
1409
1410/// An individually applicable patch operation produced by [`score_patch`].
1411///
1412/// Unlike [`ScoreChange`], every variant carries enough data to apply the change to a
1413/// [`Score`] without needing the original score. Use [`apply_patch`] to apply a list.
1414#[derive(Debug, Clone, Serialize, Deserialize)]
1415pub enum ScorePatch {
1416    SetMetadata {
1417        field: String,
1418        value: String,
1419    },
1420    SetTempo {
1421        value: u16,
1422    },
1423    SetKeySignature {
1424        part: usize,
1425        staff: usize,
1426        measure: usize,
1427        value: Option<KeySignature>,
1428    },
1429    SetTimeSignature {
1430        part: usize,
1431        staff: usize,
1432        measure: usize,
1433        value: Option<TimeSignature>,
1434    },
1435    SetBarlines {
1436        part: usize,
1437        staff: usize,
1438        measure: usize,
1439        left: Barline,
1440        right: Barline,
1441    },
1442    SetRehearsal {
1443        part: usize,
1444        staff: usize,
1445        measure: usize,
1446        value: Option<String>,
1447    },
1448    SetVolta {
1449        part: usize,
1450        staff: usize,
1451        measure: usize,
1452        value: Option<VoltaBracket>,
1453    },
1454    /// Insert `note` at `note_index` in the given voice (existing notes shift right).
1455    AddNote {
1456        part: usize,
1457        staff: usize,
1458        measure: usize,
1459        voice: usize,
1460        /// Position for insertion. `usize::MAX` is the legacy append sentinel.
1461        #[serde(default = "legacy_append_index")]
1462        note_index: usize,
1463        note: Box<Note>,
1464    },
1465    RemoveNote {
1466        part: usize,
1467        staff: usize,
1468        measure: usize,
1469        voice: usize,
1470        note_index: usize,
1471    },
1472    /// Replace the note at `note_index` with `note`.
1473    ReplaceNote {
1474        part: usize,
1475        staff: usize,
1476        measure: usize,
1477        voice: usize,
1478        note_index: usize,
1479        note: Box<Note>,
1480    },
1481    SetMeasureTempo {
1482        part: usize,
1483        staff: usize,
1484        measure: usize,
1485        value: Option<u16>,
1486    },
1487    /// Replace the complete score when a change cannot be represented safely by
1488    /// positional operations (for example, a part or measure was added).
1489    ReplaceScore {
1490        score: Box<Score>,
1491    },
1492}
1493
1494fn legacy_append_index() -> usize {
1495    usize::MAX
1496}
1497
1498/// Return whether positional patches would lose score data. The patch format deliberately
1499/// keeps the common editing operations small; fields without a dedicated operation use the
1500/// complete-score fallback so an interchange round-trip never silently drops notation.
1501fn patch_requires_replace(a: &Score, b: &Score) -> bool {
1502    if a.settings.time_signature != b.settings.time_signature
1503        || a.settings.key_signature != b.settings.key_signature
1504        || a.parts.len() != b.parts.len()
1505        || a.part_groups.len() != b.part_groups.len()
1506    {
1507        return true;
1508    }
1509    if a.part_groups.iter().zip(&b.part_groups).any(|(x, y)| {
1510        x.first_part != y.first_part
1511            || x.last_part != y.last_part
1512            || x.symbol != y.symbol
1513            || x.barlines_connect != y.barlines_connect
1514    }) {
1515        return true;
1516    }
1517    for (ap, bp) in a.parts.iter().zip(&b.parts) {
1518        if ap.name != bp.name
1519            || ap.short_name != bp.short_name
1520            || ap.midi_channel != bp.midi_channel
1521            || ap.midi_program != bp.midi_program
1522            || ap.midi_pitch_bends != bp.midi_pitch_bends
1523            || ap.staves.len() != bp.staves.len()
1524        {
1525            return true;
1526        }
1527        for (as_, bs) in ap.staves.iter().zip(&bp.staves) {
1528            if as_.clef != bs.clef
1529                || as_.transpose_semitones != bs.transpose_semitones
1530                || as_.measures.len() != bs.measures.len()
1531            {
1532                return true;
1533            }
1534            for (am, bm) in as_.measures.iter().zip(&bs.measures) {
1535                if am.number != bm.number
1536                    || am.clef != bm.clef
1537                    || am.tempo_text != bm.tempo_text
1538                    || am.navigation != bm.navigation
1539                    || am.expression_text != bm.expression_text
1540                    || am.multi_rest_count != bm.multi_rest_count
1541                    || am.system_break != bm.system_break
1542                    || am.page_break != bm.page_break
1543                {
1544                    return true;
1545                }
1546            }
1547        }
1548    }
1549    false
1550}
1551
1552/// Compare two scores and return a list of [`ScorePatch`] operations.
1553///
1554/// Applying the patches to `a` via [`apply_patch`] produces a score structurally
1555/// equivalent to `b` (same parts, staves, measures, and note content).
1556pub fn score_patch(a: &Score, b: &Score) -> Vec<ScorePatch> {
1557    let mut patches: Vec<ScorePatch> = Vec::new();
1558
1559    if patch_requires_replace(a, b) {
1560        return vec![ScorePatch::ReplaceScore {
1561            score: Box::new(b.clone()),
1562        }];
1563    }
1564
1565    macro_rules! meta {
1566        ($field:ident, $name:literal) => {
1567            if a.metadata.$field != b.metadata.$field {
1568                patches.push(ScorePatch::SetMetadata {
1569                    field: $name.to_string(),
1570                    value: b.metadata.$field.clone(),
1571                });
1572            }
1573        };
1574    }
1575    meta!(title, "title");
1576    meta!(composer, "composer");
1577    meta!(lyricist, "lyricist");
1578    meta!(copyright, "copyright");
1579    meta!(work_number, "work_number");
1580    meta!(movement_title, "movement_title");
1581
1582    if a.settings.tempo_bpm != b.settings.tempo_bpm {
1583        patches.push(ScorePatch::SetTempo {
1584            value: b.settings.tempo_bpm,
1585        });
1586    }
1587
1588    for pi in 0..a.parts.len().min(b.parts.len()) {
1589        let ap = &a.parts[pi];
1590        let bp = &b.parts[pi];
1591        for si in 0..ap.staves.len().min(bp.staves.len()) {
1592            let a_staff = &ap.staves[si];
1593            let b_staff = &bp.staves[si];
1594            for mi in 0..a_staff.measures.len().min(b_staff.measures.len()) {
1595                let am = &a_staff.measures[mi];
1596                let bm = &b_staff.measures[mi];
1597
1598                if am.key_sig != bm.key_sig {
1599                    patches.push(ScorePatch::SetKeySignature {
1600                        part: pi,
1601                        staff: si,
1602                        measure: mi,
1603                        value: bm.key_sig.clone(),
1604                    });
1605                }
1606                if am.time_sig != bm.time_sig {
1607                    patches.push(ScorePatch::SetTimeSignature {
1608                        part: pi,
1609                        staff: si,
1610                        measure: mi,
1611                        value: bm.time_sig.clone(),
1612                    });
1613                }
1614                if am.barline_left != bm.barline_left || am.barline_right != bm.barline_right {
1615                    patches.push(ScorePatch::SetBarlines {
1616                        part: pi,
1617                        staff: si,
1618                        measure: mi,
1619                        left: bm.barline_left.clone(),
1620                        right: bm.barline_right.clone(),
1621                    });
1622                }
1623                if am.rehearsal != bm.rehearsal {
1624                    patches.push(ScorePatch::SetRehearsal {
1625                        part: pi,
1626                        staff: si,
1627                        measure: mi,
1628                        value: bm.rehearsal.clone(),
1629                    });
1630                }
1631                if am.volta != bm.volta {
1632                    patches.push(ScorePatch::SetVolta {
1633                        part: pi,
1634                        staff: si,
1635                        measure: mi,
1636                        value: bm.volta.clone(),
1637                    });
1638                }
1639                if am.tempo != bm.tempo {
1640                    patches.push(ScorePatch::SetMeasureTempo {
1641                        part: pi,
1642                        staff: si,
1643                        measure: mi,
1644                        value: bm.tempo,
1645                    });
1646                }
1647
1648                for vi in 0..4usize {
1649                    let av = &am.voices[vi];
1650                    let bv = &bm.voices[vi];
1651                    for (ni, (a_note, b_note)) in av.iter().zip(bv.iter()).enumerate() {
1652                        if !note_content_eq(a_note, b_note) {
1653                            patches.push(ScorePatch::ReplaceNote {
1654                                part: pi,
1655                                staff: si,
1656                                measure: mi,
1657                                voice: vi,
1658                                note_index: ni,
1659                                note: Box::new(b_note.clone()),
1660                            });
1661                        }
1662                    }
1663                    // Notes in `a` beyond `b` — remove in reverse order to preserve indices.
1664                    for ni in (bv.len()..av.len()).rev() {
1665                        patches.push(ScorePatch::RemoveNote {
1666                            part: pi,
1667                            staff: si,
1668                            measure: mi,
1669                            voice: vi,
1670                            note_index: ni,
1671                        });
1672                    }
1673                    // Notes in `b` beyond `a` — append.
1674                    for (offset, note) in bv.iter().skip(av.len()).enumerate() {
1675                        patches.push(ScorePatch::AddNote {
1676                            part: pi,
1677                            staff: si,
1678                            measure: mi,
1679                            voice: vi,
1680                            note_index: av.len() + offset,
1681                            note: Box::new(note.clone()),
1682                        });
1683                    }
1684                }
1685            }
1686        }
1687    }
1688
1689    patches
1690}
1691
1692/// Apply a list of [`ScorePatch`] operations to a cloned copy of `score`.
1693///
1694/// Returns `Err(Error::InvalidPatch)` if any patch references an out-of-bounds index.
1695/// The returned score is an independent clone — `score` is not modified.
1696pub fn apply_patch(score: &Score, patches: &[ScorePatch]) -> Result<Score, Error> {
1697    let mut s = score.clone();
1698    for patch in patches {
1699        match patch {
1700            ScorePatch::ReplaceScore { score } => {
1701                s = (**score).clone();
1702            }
1703            ScorePatch::SetMetadata { field, value } => match field.as_str() {
1704                "title" => s.metadata.title = value.clone(),
1705                "composer" => s.metadata.composer = value.clone(),
1706                "lyricist" => s.metadata.lyricist = value.clone(),
1707                "copyright" => s.metadata.copyright = value.clone(),
1708                "work_number" => s.metadata.work_number = value.clone(),
1709                "movement_title" => s.metadata.movement_title = value.clone(),
1710                other => {
1711                    return Err(Error::InvalidPatch(format!(
1712                        "unknown metadata field: {other}"
1713                    )));
1714                }
1715            },
1716            ScorePatch::SetTempo { value } => {
1717                s.settings.tempo_bpm = *value;
1718            }
1719            ScorePatch::SetKeySignature {
1720                part,
1721                staff,
1722                measure,
1723                value,
1724            } => {
1725                s.parts
1726                    .get_mut(*part)
1727                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
1728                    .staves
1729                    .get_mut(*staff)
1730                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
1731                    .measures
1732                    .get_mut(*measure)
1733                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
1734                    .key_sig = value.clone();
1735            }
1736            ScorePatch::SetTimeSignature {
1737                part,
1738                staff,
1739                measure,
1740                value,
1741            } => {
1742                s.parts
1743                    .get_mut(*part)
1744                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
1745                    .staves
1746                    .get_mut(*staff)
1747                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
1748                    .measures
1749                    .get_mut(*measure)
1750                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
1751                    .time_sig = value.clone();
1752            }
1753            ScorePatch::SetBarlines {
1754                part,
1755                staff,
1756                measure,
1757                left,
1758                right,
1759            } => {
1760                let m = s
1761                    .parts
1762                    .get_mut(*part)
1763                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
1764                    .staves
1765                    .get_mut(*staff)
1766                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
1767                    .measures
1768                    .get_mut(*measure)
1769                    .ok_or_else(|| {
1770                        Error::InvalidPatch(format!("measure {measure} out of range"))
1771                    })?;
1772                m.barline_left = left.clone();
1773                m.barline_right = right.clone();
1774            }
1775            ScorePatch::SetRehearsal {
1776                part,
1777                staff,
1778                measure,
1779                value,
1780            } => {
1781                s.parts
1782                    .get_mut(*part)
1783                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
1784                    .staves
1785                    .get_mut(*staff)
1786                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
1787                    .measures
1788                    .get_mut(*measure)
1789                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
1790                    .rehearsal = value.clone();
1791            }
1792            ScorePatch::SetVolta {
1793                part,
1794                staff,
1795                measure,
1796                value,
1797            } => {
1798                s.parts
1799                    .get_mut(*part)
1800                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
1801                    .staves
1802                    .get_mut(*staff)
1803                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
1804                    .measures
1805                    .get_mut(*measure)
1806                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
1807                    .volta = value.clone();
1808            }
1809            ScorePatch::AddNote {
1810                part,
1811                staff,
1812                measure,
1813                voice,
1814                note_index,
1815                note,
1816            } => {
1817                let v = s
1818                    .parts
1819                    .get_mut(*part)
1820                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
1821                    .staves
1822                    .get_mut(*staff)
1823                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
1824                    .measures
1825                    .get_mut(*measure)
1826                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
1827                    .voices
1828                    .get_mut(*voice)
1829                    .ok_or_else(|| Error::InvalidPatch(format!("voice {voice} out of range")))?;
1830                let insert_at = if *note_index == usize::MAX {
1831                    v.len()
1832                } else {
1833                    *note_index
1834                };
1835                if insert_at > v.len() {
1836                    return Err(Error::InvalidPatch(format!(
1837                        "note_index {note_index} out of range"
1838                    )));
1839                }
1840                v.insert(insert_at, *note.clone());
1841            }
1842            ScorePatch::RemoveNote {
1843                part,
1844                staff,
1845                measure,
1846                voice,
1847                note_index,
1848            } => {
1849                let v = s
1850                    .parts
1851                    .get_mut(*part)
1852                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
1853                    .staves
1854                    .get_mut(*staff)
1855                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
1856                    .measures
1857                    .get_mut(*measure)
1858                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
1859                    .voices
1860                    .get_mut(*voice)
1861                    .ok_or_else(|| Error::InvalidPatch(format!("voice {voice} out of range")))?;
1862                if *note_index >= v.len() {
1863                    return Err(Error::InvalidPatch(format!(
1864                        "note_index {note_index} out of range"
1865                    )));
1866                }
1867                v.remove(*note_index);
1868            }
1869            ScorePatch::ReplaceNote {
1870                part,
1871                staff,
1872                measure,
1873                voice,
1874                note_index,
1875                note,
1876            } => {
1877                let v = s
1878                    .parts
1879                    .get_mut(*part)
1880                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
1881                    .staves
1882                    .get_mut(*staff)
1883                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
1884                    .measures
1885                    .get_mut(*measure)
1886                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
1887                    .voices
1888                    .get_mut(*voice)
1889                    .ok_or_else(|| Error::InvalidPatch(format!("voice {voice} out of range")))?;
1890                if *note_index >= v.len() {
1891                    return Err(Error::InvalidPatch(format!(
1892                        "note_index {note_index} out of range"
1893                    )));
1894                }
1895                v[*note_index] = *note.clone();
1896            }
1897            ScorePatch::SetMeasureTempo {
1898                part,
1899                staff,
1900                measure,
1901                value,
1902            } => {
1903                s.parts
1904                    .get_mut(*part)
1905                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
1906                    .staves
1907                    .get_mut(*staff)
1908                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
1909                    .measures
1910                    .get_mut(*measure)
1911                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
1912                    .tempo = *value;
1913            }
1914        }
1915    }
1916    Ok(s)
1917}
1918
1919/// Respell all pitches in the score to prefer flats or sharps.
1920///
1921/// Applies [`Pitch::respell`] to every note in every part, staff, measure, and voice.
1922pub fn respell_score(score: &mut Score, prefer_flat: bool) {
1923    for part in &mut score.parts {
1924        for staff in &mut part.staves {
1925            for measure in &mut staff.measures {
1926                for voice in &mut measure.voices {
1927                    for note in voice.iter_mut() {
1928                        for pitch in &mut note.pitches {
1929                            *pitch = pitch.respell(prefer_flat);
1930                        }
1931                    }
1932                }
1933            }
1934        }
1935    }
1936}
1937
1938/// Respell all pitches to match the score's key signature spelling convention.
1939///
1940/// Flat-key signatures (fifths < 0) use flat spellings; sharp-key and C major use sharps.
1941pub fn respell_score_to_key(score: &mut Score) {
1942    let prefer_flat = score.settings.key_signature.fifths < 0;
1943    respell_score(score, prefer_flat);
1944}
1945
1946/// Compute total playback duration in seconds.
1947///
1948/// Uses `measure_sequence` for correct repeat handling. Lighter than generating
1949/// full playback events — suitable for progress bars and UI display.
1950pub fn score_duration_secs(score: &Score) -> f64 {
1951    if score.settings.tempo_bpm == 0 {
1952        return 0.0;
1953    }
1954    let seq = measure_sequence(score);
1955    let mut total_secs = 0.0f64;
1956    let mut current_bpm = score.settings.tempo_bpm as f64;
1957    if let Some(staff) = score.parts.first().and_then(|p| p.staves.first()) {
1958        for &idx in &seq {
1959            if let Some(m) = staff.measures.get(idx) {
1960                if let Some(b) = m.tempo {
1961                    current_bpm = b as f64;
1962                }
1963                if current_bpm == 0.0 {
1964                    continue;
1965                }
1966                let beats: f64 = m.voices[0].iter().map(|n| n.beats()).sum();
1967                total_secs += beats / current_bpm * 60.0;
1968            }
1969        }
1970    }
1971    total_secs
1972}
1973
1974/// Compute playback duration in seconds for a specific measure range (inclusive).
1975///
1976/// `region` is `(start_measure, end_measure)`, both 0-based. Measures outside the range
1977/// are excluded. Uses `measure_sequence` for correct repeat handling.
1978pub fn score_duration_secs_region(score: &Score, region: (usize, usize)) -> f64 {
1979    if score.settings.tempo_bpm == 0 {
1980        return 0.0;
1981    }
1982    let seq: Vec<usize> = measure_sequence(score)
1983        .into_iter()
1984        .filter(|&idx| idx >= region.0 && idx <= region.1)
1985        .collect();
1986    let mut total_secs = 0.0f64;
1987    let mut current_bpm = score.settings.tempo_bpm as f64;
1988    if let Some(staff) = score.parts.first().and_then(|p| p.staves.first()) {
1989        for &idx in &seq {
1990            if let Some(m) = staff.measures.get(idx) {
1991                if let Some(b) = m.tempo {
1992                    current_bpm = b as f64;
1993                }
1994                if current_bpm == 0.0 {
1995                    continue;
1996                }
1997                let beats: f64 = m.voices[0].iter().map(|n| n.beats()).sum();
1998                total_secs += beats / current_bpm * 60.0;
1999            }
2000        }
2001    }
2002    total_secs
2003}
2004
2005/// Return the number of beats available in a voice before it is full.
2006///
2007/// Uses [`Note::beats`] which correctly handles tuplet scaling.
2008/// Returns `Ok(0.0)` when the voice is already full or over-full.
2009pub fn measure_beats_remaining(
2010    score: &Score,
2011    part_index: usize,
2012    staff_index: usize,
2013    measure_index: usize,
2014    voice_index: usize,
2015) -> Result<f64, Error> {
2016    let part = score
2017        .parts
2018        .get(part_index)
2019        .ok_or(Error::PartNotFound(part_index))?;
2020    let staff = part
2021        .staves
2022        .get(staff_index)
2023        .ok_or(Error::StaffNotFound(staff_index))?;
2024    let measure = staff
2025        .measures
2026        .get(measure_index)
2027        .ok_or(Error::MeasureNotFound(measure_index))?;
2028    let voice = measure
2029        .voices
2030        .get(voice_index)
2031        .ok_or(Error::VoiceOutOfRange(voice_index))?;
2032    let ts = measure
2033        .time_sig
2034        .as_ref()
2035        .unwrap_or(&score.settings.time_signature);
2036    let used: f64 = voice.iter().map(|n| n.beats()).sum();
2037    Ok((ts.total_beats() - used).max(0.0))
2038}
2039
2040/// Suggest whether the stem should point up for the given pitches and clef.
2041///
2042/// Conventional rule: if the average MIDI pitch of the chord is below the staff
2043/// middle line, the stem points up; at or above, it points down.
2044/// For empty pitch lists (rests), returns `true` by convention.
2045pub fn suggested_stem_up(pitches: &[Pitch], clef: &Clef) -> bool {
2046    if pitches.is_empty() {
2047        return true;
2048    }
2049    let avg = pitches.iter().map(|p| p.to_midi() as f64).sum::<f64>() / pitches.len() as f64;
2050    avg < clef.middle_line_midi() as f64
2051}
2052
2053fn beam_beat_size(ts: &TimeSignature) -> f64 {
2054    if ts.numerator.is_multiple_of(3) && ts.numerator >= 6 && ts.denominator >= 8 {
2055        3.0 * 4.0 / ts.denominator as f64
2056    } else {
2057        4.0 / ts.denominator as f64
2058    }
2059}
2060
2061/// Compute recommended [`BeamState`] values for a voice's notes.
2062///
2063/// Groups beamable notes (eighth or shorter, non-rest) within beat boundaries.
2064/// Returns a `Vec` the same length as `notes`.
2065pub fn compute_beams(notes: &[Note], time_sig: &TimeSignature) -> Vec<BeamState> {
2066    let beat_size = beam_beat_size(time_sig);
2067    let n = notes.len();
2068    let mut result = vec![BeamState::None; n];
2069
2070    let is_beamable = |note: &Note| -> bool {
2071        !note.is_rest
2072            && matches!(
2073                note.duration,
2074                Duration::Eighth
2075                    | Duration::Sixteenth
2076                    | Duration::ThirtySecond
2077                    | Duration::SixtyFourth
2078            )
2079    };
2080
2081    // Compute beat start positions
2082    let mut starts = Vec::with_capacity(n);
2083    let mut pos = 0.0f64;
2084    for note in notes {
2085        starts.push(pos);
2086        pos += note.beats();
2087    }
2088
2089    // Assign beam group ids based on beat boundary
2090    let group_id = |i: usize| -> i64 { (starts[i] / beat_size).floor() as i64 };
2091
2092    let mut i = 0;
2093    while i < n {
2094        if !is_beamable(&notes[i]) {
2095            i += 1;
2096            continue;
2097        }
2098        let g = group_id(i);
2099        // Find the run of beamable notes in the same beat group
2100        let mut j = i;
2101        while j < n && is_beamable(&notes[j]) && group_id(j) == g {
2102            j += 1;
2103        }
2104        let run = j - i;
2105        if run == 1 {
2106            result[i] = BeamState::None;
2107        } else {
2108            result[i] = BeamState::Begin;
2109            result[i + 1..j - 1].fill(BeamState::Continue);
2110            result[j - 1] = BeamState::End;
2111        }
2112        i = j;
2113    }
2114    result
2115}
2116
2117fn note_content_eq(a: &Note, b: &Note) -> bool {
2118    a.is_rest == b.is_rest
2119        && a.pitches == b.pitches
2120        && a.duration == b.duration
2121        && a.dot_count == b.dot_count
2122        && a.tie_start == b.tie_start
2123        && a.tie_end == b.tie_end
2124        && a.beam == b.beam
2125        && a.articulations == b.articulations
2126        && a.dynamic == b.dynamic
2127        && a.stem_up == b.stem_up
2128        && a.hairpin_start == b.hairpin_start
2129        && a.hairpin_end == b.hairpin_end
2130        && a.tuplet == b.tuplet
2131        && a.chord_symbol == b.chord_symbol
2132        && a.is_grace == b.is_grace
2133        && a.grace_slash == b.grace_slash
2134        && a.ottava_start == b.ottava_start
2135        && a.ottava_end == b.ottava_end
2136        && a.lyric == b.lyric
2137        && a.pedal_start == b.pedal_start
2138        && a.pedal_end == b.pedal_end
2139        && a.slur_start == b.slur_start
2140        && a.slur_end == b.slur_end
2141        && a.arpeggiate == b.arpeggiate
2142        && a.tab_position == b.tab_position
2143        && a.tab_positions == b.tab_positions
2144}
2145
2146#[cfg(test)]
2147mod tests {
2148    use super::*;
2149    use crate::model::pitch::Step;
2150
2151    #[test]
2152    fn default_score_has_one_part_four_measures() {
2153        let score = Score::default();
2154        assert_eq!(score.parts.len(), 1);
2155        assert_eq!(score.parts[0].staves.len(), 1);
2156        assert_eq!(score.parts[0].staves[0].measures.len(), 4);
2157    }
2158
2159    #[test]
2160    fn assign_tablature_positions_is_capo_aware_and_preserves_explicit_positions() {
2161        let mut score = Score::new("Guitar", 120, 4, 4, 0, 1);
2162        score.parts[0].staves[0].tablature = Some(TablatureConfig {
2163            lines: 6,
2164            tuning_midi: vec![64, 59, 55, 50, 45, 40],
2165            capo: 2,
2166        });
2167        score.parts[0].staves[0].measures[0].voices[0].push(Note::new(
2168            Pitch::with_alter(Step::F, 4, 1),
2169            Duration::Quarter,
2170        ));
2171        score.parts[0].staves[0].measures[0].voices[0]
2172            .push(Note::new(Pitch::new(Step::G, 3), Duration::Quarter));
2173        score.parts[0].staves[0].measures[0].voices[0][2].tab_position =
2174            Some(TabPosition { string: 6, fret: 7 });
2175
2176        assert_eq!(assign_tablature_positions(&mut score), 1);
2177        let notes = &score.parts[0].staves[0].measures[0].voices[0];
2178        assert_eq!(
2179            notes[1].tab_position,
2180            Some(TabPosition { string: 1, fret: 0 })
2181        );
2182        assert_eq!(notes[1].string_number, Some(1));
2183        assert_eq!(
2184            notes[2].tab_position,
2185            Some(TabPosition { string: 6, fret: 7 })
2186        );
2187    }
2188
2189    #[test]
2190    fn assign_tablature_positions_optimizes_chord_strings_and_fret_span() {
2191        let mut score = Score::new("Guitar", 120, 4, 4, 0, 1);
2192        score.parts[0].staves[0].tablature = Some(TablatureConfig {
2193            lines: 6,
2194            tuning_midi: vec![64, 59, 55, 50, 45, 40],
2195            capo: 0,
2196        });
2197        let mut chord = Note::new(Pitch::new(Step::E, 4), Duration::Quarter);
2198        chord.pitches.push(Pitch::new(Step::G, 4));
2199        score.parts[0].staves[0].measures[0].voices[0].push(chord);
2200
2201        assert_eq!(assign_tablature_positions(&mut score), 1);
2202        let positions = &score.parts[0].staves[0].measures[0].voices[0][1].tab_positions;
2203        assert_eq!(
2204            positions,
2205            &vec![
2206                TabPosition { string: 2, fret: 5 },
2207                TabPosition { string: 1, fret: 3 },
2208            ]
2209        );
2210    }
2211
2212    #[test]
2213    fn new_score_measure_count() {
2214        let score = Score::new("Test", 120, 4, 4, 0, 8);
2215        assert_eq!(score.measure_count(), 8);
2216    }
2217
2218    #[test]
2219    fn note_beats_quarter() {
2220        let note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2221        assert!((note.beats() - 1.0).abs() < 1e-9);
2222    }
2223
2224    #[test]
2225    fn note_beats_dotted_quarter() {
2226        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2227        note.dot_count = 1;
2228        assert!((note.beats() - 1.5).abs() < 1e-9);
2229    }
2230
2231    #[test]
2232    fn grace_note_beats_zero() {
2233        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Eighth);
2234        note.is_grace = true;
2235        assert_eq!(note.beats(), 0.0);
2236    }
2237
2238    #[test]
2239    fn measure_empty_4_4_fills_four_beats() {
2240        let m = Measure::empty(4, 4);
2241        let total: f64 = m.voices[0].iter().map(|n| n.beats()).sum();
2242        assert!((total - 4.0).abs() < 1e-9);
2243    }
2244
2245    #[test]
2246    fn measure_empty_3_4_fills_three_beats() {
2247        let m = Measure::empty(3, 4);
2248        let total: f64 = m.voices[0].iter().map(|n| n.beats()).sum();
2249        assert!((total - 3.0).abs() < 1e-9);
2250    }
2251
2252    #[test]
2253    fn whole_filling_beats() {
2254        assert_eq!(Duration::whole_filling_beats(4.0), Duration::Whole);
2255        assert_eq!(Duration::whole_filling_beats(2.0), Duration::Half);
2256        assert_eq!(Duration::whole_filling_beats(1.0), Duration::Quarter);
2257    }
2258
2259    // ── ScoreStats ────────────────────────────────────────────────────────────
2260
2261    #[test]
2262    fn statistics_default_score_all_rests() {
2263        let score = Score::default();
2264        let s = score.statistics();
2265        assert_eq!(s.part_count, 1);
2266        assert_eq!(s.measure_count, 4);
2267        assert_eq!(s.note_count, 0);
2268        assert!(s.rest_count > 0);
2269    }
2270
2271    #[test]
2272    fn statistics_duration_estimate() {
2273        // 4/4, 120 BPM, 1 measure → 4 beats → 2.0 s
2274        let score = Score::new("T", 120, 4, 4, 0, 1);
2275        let s = score.statistics();
2276        assert!((s.estimated_duration_secs - 2.0).abs() < 0.01);
2277    }
2278
2279    #[test]
2280    fn score_duration_secs_matches_statistics() {
2281        use super::score_duration_secs;
2282        let score = Score::new("T", 120, 4, 4, 0, 4);
2283        let secs = score_duration_secs(&score);
2284        // 4/4, 120 BPM, 4 measures → 16 beats → 8.0 s
2285        assert!((secs - 8.0).abs() < 0.01, "expected ~8.0 s, got {secs}");
2286    }
2287
2288    #[test]
2289    fn score_duration_secs_zero_bpm_returns_zero() {
2290        use super::score_duration_secs;
2291        let mut score = Score::new("T", 120, 4, 4, 0, 1);
2292        score.settings.tempo_bpm = 0;
2293        assert_eq!(score_duration_secs(&score), 0.0);
2294    }
2295
2296    #[test]
2297    fn score_duration_secs_per_measure_tempo() {
2298        use super::score_duration_secs;
2299        // 2 measures: measure 0 at 120 BPM (2.0 s), measure 1 at 60 BPM (4.0 s)
2300        let mut score = Score::new("T", 120, 4, 4, 0, 2);
2301        score.parts[0].staves[0].measures[1].tempo = Some(60);
2302        let secs = score_duration_secs(&score);
2303        assert!((secs - 6.0).abs() < 0.01, "expected ~6.0 s, got {secs}");
2304    }
2305
2306    // ── extract_part ──────────────────────────────────────────────────────────
2307
2308    #[test]
2309    fn extract_part_returns_single_part_score() {
2310        let mut score = Score::default();
2311        let mut p2 = Part::new("Violin", "Vln.");
2312        p2.staves.push(Staff::new(Clef::Treble));
2313        score.parts.push(p2);
2314        let ex = score.extract_part(0).unwrap();
2315        assert_eq!(ex.parts.len(), 1);
2316        assert_ne!(ex.id, score.id);
2317        assert_eq!(ex.metadata.title, score.metadata.title);
2318    }
2319
2320    #[test]
2321    fn extract_part_out_of_range_is_none() {
2322        let score = Score::default();
2323        assert!(score.extract_part(99).is_none());
2324    }
2325
2326    // ── transpose ─────────────────────────────────────────────────────────────
2327
2328    #[test]
2329    fn transpose_zero_is_clone() {
2330        let score = Score::new("T", 120, 4, 4, 0, 1);
2331        let t = transpose(&score, 0);
2332        assert_eq!(t.settings.key_signature.fifths, 0);
2333    }
2334
2335    #[test]
2336    fn transpose_c_major_up_2_to_d_major() {
2337        let score = Score::new("T", 120, 4, 4, 0, 1);
2338        assert_eq!(transpose(&score, 2).settings.key_signature.fifths, 2);
2339    }
2340
2341    #[test]
2342    fn transpose_d_major_up_5_to_g_major() {
2343        let score = Score::new("T", 120, 4, 4, 2, 1);
2344        assert_eq!(transpose(&score, 5).settings.key_signature.fifths, 1);
2345    }
2346
2347    #[test]
2348    fn transpose_c4_up_1_to_csharp4() {
2349        let p = transpose_pitch(&Pitch::new(Step::C, 4), 1);
2350        assert_eq!(p.to_midi(), 61);
2351        assert_eq!(p.step, Step::C);
2352        assert_eq!(p.alter, 1);
2353    }
2354
2355    #[test]
2356    fn transpose_c4_down_1_to_b3() {
2357        let p = transpose_pitch(&Pitch::new(Step::C, 4), -1);
2358        assert_eq!(p.to_midi(), 59);
2359        assert_eq!(p.step, Step::B);
2360        assert_eq!(p.alter, 0);
2361    }
2362
2363    #[test]
2364    fn transpose_up_octave_keeps_step() {
2365        let p = transpose_pitch(&Pitch::new(Step::A, 4), 12);
2366        assert_eq!(p.to_midi(), 81);
2367        assert_eq!(p.step, Step::A);
2368        assert_eq!(p.octave, 5);
2369    }
2370
2371    #[test]
2372    fn statistics_with_repeat_doubles_duration() {
2373        // 4/4, 120 BPM, 2 measures with RepeatStart+RepeatEnd → plays twice → 4 measures worth
2374        let mut score = Score::new("T", 120, 4, 4, 0, 2);
2375        score.parts[0].staves[0].measures[0].barline_left =
2376            crate::model::notation::Barline::RepeatStart;
2377        score.parts[0].staves[0].measures[1].barline_right =
2378            crate::model::notation::Barline::RepeatEnd;
2379        let s = score.statistics();
2380        // 4 beats × 4 measures (2 physical × 2 passes) ÷ 120 BPM × 60 = 8.0 s
2381        assert!((s.estimated_duration_secs - 8.0).abs() < 0.01);
2382    }
2383
2384    #[test]
2385    fn transpose_octave_boundary_b4_to_c5() {
2386        // B4 (midi=71) + 1 semitone = C5 (midi=72)
2387        let p = transpose_pitch(&Pitch::new(Step::B, 4), 1);
2388        assert_eq!(p.to_midi(), 72);
2389        assert_eq!(p.step, Step::C);
2390        assert_eq!(p.octave, 5);
2391    }
2392
2393    #[test]
2394    fn transpose_clamp_at_midi_127() {
2395        // G9 (midi=127) + 3 semitones → clamped to 127
2396        let p = transpose_pitch(&Pitch::new(Step::G, 9), 3);
2397        assert_eq!(p.to_midi(), 127);
2398    }
2399
2400    // ── merge ─────────────────────────────────────────────────────────────────
2401
2402    #[test]
2403    fn merge_combines_parts() {
2404        let mut a = Score::new("A", 120, 4, 4, 0, 2);
2405        let b = Score::new("B", 120, 4, 4, 0, 2);
2406        // Add a second part to score a
2407        let mut p2 = Part::new("Violin", "Vln.");
2408        p2.staves.push(Staff::new(Clef::Treble));
2409        for i in 0..2usize {
2410            let mut m = Measure::empty(4, 4);
2411            m.number = i as u32 + 1;
2412            p2.staves[0].measures.push(m);
2413        }
2414        a.parts.push(p2);
2415        let merged = a.merge(&b);
2416        // a has 2 parts, b has 1 part → merged has 3 parts
2417        assert_eq!(merged.parts.len(), 3);
2418    }
2419
2420    #[test]
2421    fn merge_pads_shorter_score() {
2422        let a = Score::new("A", 120, 4, 4, 0, 4);
2423        let b = Score::new("B", 120, 4, 4, 0, 2);
2424        let merged = a.merge(&b);
2425        // Both parts should have 4 measures
2426        assert_eq!(merged.parts[0].staves[0].measures.len(), 4);
2427        assert_eq!(merged.parts[1].staves[0].measures.len(), 4);
2428    }
2429
2430    #[test]
2431    fn merge_uses_self_metadata() {
2432        let mut a = Score::new("Title A", 120, 4, 4, 0, 2);
2433        a.metadata.composer = "Composer A".to_string();
2434        let b = Score::new("Title B", 120, 4, 4, 0, 2);
2435        let merged = a.merge(&b);
2436        assert_eq!(merged.metadata.title, "Title A");
2437        assert_eq!(merged.metadata.composer, "Composer A");
2438    }
2439
2440    #[test]
2441    fn merge_new_id_differs_from_both() {
2442        let a = Score::new("A", 120, 4, 4, 0, 2);
2443        let b = Score::new("B", 120, 4, 4, 0, 2);
2444        let merged = a.merge(&b);
2445        assert_ne!(merged.id, a.id);
2446        assert_ne!(merged.id, b.id);
2447    }
2448
2449    // ── Staff.transpose_semitones ─────────────────────────────────────────────
2450
2451    #[test]
2452    fn staff_default_transpose_is_zero() {
2453        let s = Staff::new(Clef::Treble);
2454        assert_eq!(s.transpose_semitones, 0);
2455    }
2456
2457    // ── schema_version ────────────────────────────────────────────────────────
2458
2459    #[test]
2460    fn score_default_has_schema_version_1() {
2461        let score = Score::default();
2462        assert_eq!(score.schema_version, 1);
2463    }
2464
2465    #[test]
2466    fn score_new_has_schema_version_1() {
2467        let score = Score::new("T", 120, 4, 4, 0, 4);
2468        assert_eq!(score.schema_version, 1);
2469    }
2470
2471    #[test]
2472    fn score_without_schema_version_deserializes_to_zero() {
2473        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":[]}"#;
2474        let score: Score = serde_json::from_str(json).unwrap();
2475        assert_eq!(score.schema_version, 0);
2476    }
2477
2478    // ── ScoreTemplate ─────────────────────────────────────────────────────────
2479
2480    #[test]
2481    fn score_template_solo_has_one_part_treble() {
2482        let score = Score::template(ScoreTemplate::Solo);
2483        assert_eq!(score.parts.len(), 1);
2484        assert_eq!(score.parts[0].staves.len(), 1);
2485        assert_eq!(score.parts[0].staves[0].clef, Clef::Treble);
2486        assert_eq!(score.parts[0].midi_program, 0);
2487    }
2488
2489    #[test]
2490    fn score_template_piano_has_two_staves() {
2491        let score = Score::template(ScoreTemplate::Piano);
2492        assert_eq!(score.parts.len(), 1);
2493        assert_eq!(score.parts[0].staves.len(), 2);
2494        assert_eq!(score.parts[0].staves[0].clef, Clef::Treble);
2495        assert_eq!(score.parts[0].staves[1].clef, Clef::Bass);
2496    }
2497
2498    #[test]
2499    fn score_template_string_quartet_has_four_parts() {
2500        let score = Score::template(ScoreTemplate::StringQuartet);
2501        assert_eq!(score.parts.len(), 4);
2502        assert_eq!(score.parts[2].staves[0].clef, Clef::Alto); // Viola
2503        assert_eq!(score.parts[3].staves[0].clef, Clef::Bass); // Cello
2504        assert_eq!(score.parts[0].midi_program, 40);
2505        assert_eq!(score.parts[3].midi_program, 42);
2506    }
2507
2508    #[test]
2509    fn score_template_string_orchestra_has_five_parts() {
2510        let score = Score::template(ScoreTemplate::StringOrchestra);
2511        assert_eq!(score.parts.len(), 5);
2512        assert_eq!(score.parts[4].midi_program, 43); // Contrabass
2513    }
2514
2515    #[test]
2516    fn score_template_brass_quintet_has_five_parts() {
2517        let score = Score::template(ScoreTemplate::BrassQuintet);
2518        assert_eq!(score.parts.len(), 5);
2519        assert_eq!(score.parts[2].midi_program, 60); // French Horn
2520    }
2521
2522    #[test]
2523    fn score_template_default_measures_are_four() {
2524        let score = Score::template(ScoreTemplate::StringQuartet);
2525        for part in &score.parts {
2526            for staff in &part.staves {
2527                assert_eq!(staff.measures.len(), 4);
2528            }
2529        }
2530    }
2531
2532    // ── system_break / page_break ─────────────────────────────────────────────
2533
2534    #[test]
2535    fn measure_empty_has_no_breaks() {
2536        let m = Measure::empty(4, 4);
2537        assert!(!m.system_break);
2538        assert!(!m.page_break);
2539    }
2540
2541    #[test]
2542    fn system_break_survives_json_roundtrip() {
2543        let mut m = Measure::empty(4, 4);
2544        m.system_break = true;
2545        let json = serde_json::to_string(&m).unwrap();
2546        let m2: Measure = serde_json::from_str(&json).unwrap();
2547        assert!(m2.system_break);
2548        assert!(!m2.page_break);
2549    }
2550
2551    // ── diff ──────────────────────────────────────────────────────────────────
2552
2553    #[test]
2554    fn diff_identical_scores_is_empty() {
2555        let s = Score::new("T", 120, 4, 4, 0, 2);
2556        assert!(diff(&s, &s).is_empty());
2557    }
2558
2559    #[test]
2560    fn score_patch_covers_measure_semantics_and_note_insert_index() {
2561        let a = Score::new("T", 120, 4, 4, 0, 1);
2562        let mut b = a.clone();
2563        let measure = &mut b.parts[0].staves[0].measures[0];
2564        measure.key_sig = Some(KeySignature {
2565            fifths: -2,
2566            mode: "major".to_string(),
2567        });
2568        measure.time_sig = Some(TimeSignature {
2569            numerator: 3,
2570            denominator: 4,
2571        });
2572        measure.barline_left = Barline::RepeatStart;
2573        measure.barline_right = Barline::RepeatEnd;
2574        measure.rehearsal = Some("A".to_string());
2575        measure.volta = Some(VoltaBracket {
2576            number: 1,
2577            kind: "begin_end".to_string(),
2578        });
2579        measure.voices[0].insert(0, Note::new(Pitch::new(Step::C, 4), Duration::Quarter));
2580        let expected = b.parts[0].staves[0].measures[0].clone();
2581
2582        let patches = score_patch(&a, &b);
2583        assert!(
2584            patches
2585                .iter()
2586                .any(|p| matches!(p, ScorePatch::SetTimeSignature { .. }))
2587        );
2588        assert!(
2589            patches
2590                .iter()
2591                .any(|p| matches!(p, ScorePatch::SetBarlines { .. }))
2592        );
2593        assert!(
2594            patches
2595                .iter()
2596                .any(|p| matches!(p, ScorePatch::SetRehearsal { .. }))
2597        );
2598        assert!(
2599            patches
2600                .iter()
2601                .any(|p| matches!(p, ScorePatch::SetVolta { .. }))
2602        );
2603        let result = apply_patch(&a, &patches).expect("patch application failed");
2604        let result_measure = &result.parts[0].staves[0].measures[0];
2605        assert_eq!(result_measure.key_sig, expected.key_sig);
2606        assert_eq!(result_measure.time_sig, expected.time_sig);
2607        assert_eq!(result_measure.barline_left, expected.barline_left);
2608        assert_eq!(result_measure.barline_right, expected.barline_right);
2609        assert_eq!(result_measure.rehearsal, expected.rehearsal);
2610        assert_eq!(result_measure.volta, expected.volta);
2611        assert_eq!(result_measure.voices[0].len(), expected.voices[0].len());
2612    }
2613
2614    #[test]
2615    fn score_patch_replaces_when_structure_or_uncovered_fields_change() {
2616        let a = Score::new("T", 120, 4, 4, 0, 1);
2617        let mut b = a.clone();
2618        b.parts[0].name = "Piano".to_string();
2619        b.parts[0].staves[0].measures[0].expression_text = Some("dolce".to_string());
2620        let patches = score_patch(&a, &b);
2621        assert!(matches!(
2622            patches.as_slice(),
2623            [ScorePatch::ReplaceScore { .. }]
2624        ));
2625        let result = apply_patch(&a, &patches).expect("replacement failed");
2626        assert_eq!(result.parts[0].name, "Piano");
2627        assert_eq!(
2628            result.parts[0].staves[0].measures[0].expression_text,
2629            Some("dolce".to_string())
2630        );
2631    }
2632
2633    #[test]
2634    fn diff_detects_tempo_change() {
2635        let a = Score::new("T", 120, 4, 4, 0, 1);
2636        let mut b = a.clone();
2637        b.settings.tempo_bpm = 90;
2638        let changes = diff(&a, &b);
2639        assert_eq!(changes.len(), 1);
2640        assert!(matches!(
2641            changes[0],
2642            ScoreChange::TempoChanged { old: 120, new: 90 }
2643        ));
2644    }
2645
2646    #[test]
2647    fn diff_detects_title_change() {
2648        let a = Score::new("Old Title", 120, 4, 4, 0, 1);
2649        let mut b = a.clone();
2650        b.metadata.title = "New Title".to_string();
2651        let changes = diff(&a, &b);
2652        assert!(
2653            changes.iter().any(
2654                |c| matches!(c, ScoreChange::MetadataChanged { field, .. } if field == "title")
2655            )
2656        );
2657    }
2658
2659    #[test]
2660    fn diff_detects_note_modification() {
2661        let mut a = Score::new("T", 120, 4, 4, 0, 1);
2662        a.parts[0].staves[0].measures[0].voices[0] =
2663            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
2664        let mut b = a.clone();
2665        b.parts[0].staves[0].measures[0].voices[0][0] =
2666            Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
2667        let changes = diff(&a, &b);
2668        assert!(
2669            changes
2670                .iter()
2671                .any(|c| matches!(c, ScoreChange::NoteModified { .. }))
2672        );
2673    }
2674
2675    #[test]
2676    fn diff_detects_part_added() {
2677        let a = Score::new("T", 120, 4, 4, 0, 1);
2678        let mut b = a.clone();
2679        let mut p = Part::new("Violin", "Vln.");
2680        p.staves.push(Staff::new(Clef::Treble));
2681        b.parts.push(p);
2682        let changes = diff(&a, &b);
2683        assert!(
2684            changes
2685                .iter()
2686                .any(|c| matches!(c, ScoreChange::PartAdded { part_index: 1 }))
2687        );
2688    }
2689
2690    #[test]
2691    fn diff_detects_measure_tempo_change() {
2692        let a = Score::new("T", 120, 4, 4, 0, 2);
2693        let mut b = a.clone();
2694        b.parts[0].staves[0].measures[1].tempo = Some(60);
2695        let changes = diff(&a, &b);
2696        assert!(changes.iter().any(|c| matches!(
2697            c,
2698            ScoreChange::MeasureTempoChanged {
2699                measure: 1,
2700                old: None,
2701                new: Some(60),
2702                ..
2703            }
2704        )));
2705    }
2706
2707    #[test]
2708    fn diff_detects_barline_change() {
2709        use crate::model::notation::Barline;
2710        let a = Score::new("T", 120, 4, 4, 0, 2);
2711        let mut b = a.clone();
2712        b.parts[0].staves[0].measures[0].barline_left = Barline::RepeatStart;
2713        let changes = diff(&a, &b);
2714        assert!(
2715            changes
2716                .iter()
2717                .any(|c| matches!(c, ScoreChange::BarlineChanged { measure: 0, .. }))
2718        );
2719    }
2720
2721    #[test]
2722    fn diff_detects_rehearsal_change() {
2723        let a = Score::new("T", 120, 4, 4, 0, 2);
2724        let mut b = a.clone();
2725        b.parts[0].staves[0].measures[0].rehearsal = Some("A".to_string());
2726        let changes = diff(&a, &b);
2727        assert!(
2728            changes
2729                .iter()
2730                .any(|c| matches!(c, ScoreChange::RehearsalMarkChanged { measure: 0, .. }))
2731        );
2732    }
2733
2734    #[test]
2735    fn diff_detects_volta_change() {
2736        use super::VoltaBracket;
2737        let a = Score::new("T", 120, 4, 4, 0, 2);
2738        let mut b = a.clone();
2739        b.parts[0].staves[0].measures[0].volta = Some(VoltaBracket {
2740            number: 1,
2741            kind: "begin_end".into(),
2742        });
2743        let changes = diff(&a, &b);
2744        assert!(
2745            changes
2746                .iter()
2747                .any(|c| matches!(c, ScoreChange::VoltaChanged { measure: 0, .. }))
2748        );
2749    }
2750
2751    #[test]
2752    fn diff_detects_key_signature_change() {
2753        let a = Score::new("T", 120, 4, 4, 0, 1);
2754        let mut b = a.clone();
2755        b.settings.key_signature.fifths = 2; // C major → D major
2756        let changes = diff(&a, &b);
2757        assert!(
2758            changes
2759                .iter()
2760                .any(|c| matches!(c, ScoreChange::KeySignatureChanged { .. }))
2761        );
2762    }
2763
2764    #[test]
2765    fn diff_same_key_signature_no_change() {
2766        let a = Score::new("T", 120, 4, 4, 2, 1);
2767        let changes = diff(&a, &a);
2768        assert!(changes.is_empty());
2769    }
2770
2771    #[test]
2772    fn score_duration_secs_region_partial() {
2773        use super::score_duration_secs_region;
2774        // 4/4, 120 BPM, 4 measures → each measure = 2.0 s; region [1,2] = 4.0 s
2775        let score = Score::new("T", 120, 4, 4, 0, 4);
2776        let secs = score_duration_secs_region(&score, (1, 2));
2777        assert!((secs - 4.0).abs() < 0.01, "expected ~4.0 s, got {secs}");
2778    }
2779
2780    #[test]
2781    fn score_duration_secs_region_single_measure() {
2782        use super::score_duration_secs_region;
2783        // 4/4, 120 BPM → 1 measure = 2.0 s
2784        let score = Score::new("T", 120, 4, 4, 0, 4);
2785        let secs = score_duration_secs_region(&score, (0, 0));
2786        assert!((secs - 2.0).abs() < 0.01, "expected ~2.0 s, got {secs}");
2787    }
2788
2789    // ── measure_beats_remaining ───────────────────────────────────────────────
2790
2791    #[test]
2792    fn measure_beats_remaining_empty_voice_returns_full() {
2793        use super::measure_beats_remaining;
2794        let mut score = Score::new("T", 120, 4, 4, 0, 1);
2795        score.parts[0].staves[0].measures[0].voices[0].clear();
2796        let rem = measure_beats_remaining(&score, 0, 0, 0, 0).unwrap();
2797        assert!(
2798            (rem - 4.0).abs() < 1e-9,
2799            "expected 4.0 remaining, got {rem}"
2800        );
2801    }
2802
2803    #[test]
2804    fn measure_beats_remaining_half_full_returns_half() {
2805        use super::measure_beats_remaining;
2806        use crate::model::pitch::Step;
2807        let mut score = Score::new("T", 120, 4, 4, 0, 1);
2808        score.parts[0].staves[0].measures[0].voices[0] = vec![
2809            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
2810            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
2811        ];
2812        let rem = measure_beats_remaining(&score, 0, 0, 0, 0).unwrap();
2813        assert!(
2814            (rem - 2.0).abs() < 1e-9,
2815            "expected 2.0 remaining, got {rem}"
2816        );
2817    }
2818
2819    #[test]
2820    fn measure_beats_remaining_full_voice_returns_zero() {
2821        use super::measure_beats_remaining;
2822        use crate::model::pitch::Step;
2823        let mut score = Score::new("T", 120, 4, 4, 0, 1);
2824        score.parts[0].staves[0].measures[0].voices[0] =
2825            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
2826        let rem = measure_beats_remaining(&score, 0, 0, 0, 0).unwrap();
2827        assert!((rem).abs() < 1e-9, "expected 0.0 remaining, got {rem}");
2828    }
2829
2830    #[test]
2831    fn measure_beats_remaining_tuplet_accounting() {
2832        use super::measure_beats_remaining;
2833        use crate::model::notation::TupletInfo;
2834        use crate::model::pitch::Step;
2835        // 3 quarter-note triplets each take 2/3 of a beat → total 2.0 beats used → 2.0 remaining
2836        let mut score = Score::new("T", 120, 4, 4, 0, 1);
2837        let tuplet = TupletInfo {
2838            actual_notes: 3,
2839            normal_notes: 2,
2840        };
2841        let mk = |step| {
2842            let mut n = Note::new(Pitch::new(step, 4), Duration::Quarter);
2843            n.tuplet = Some(tuplet.clone());
2844            n
2845        };
2846        score.parts[0].staves[0].measures[0].voices[0] =
2847            vec![mk(Step::C), mk(Step::D), mk(Step::E)];
2848        let rem = measure_beats_remaining(&score, 0, 0, 0, 0).unwrap();
2849        assert!(
2850            (rem - 2.0).abs() < 1e-9,
2851            "expected 2.0 remaining (triplets used 2.0), got {rem}"
2852        );
2853    }
2854
2855    #[test]
2856    fn measure_beats_remaining_out_of_range_returns_err() {
2857        use super::measure_beats_remaining;
2858        let score = Score::new("T", 120, 4, 4, 0, 1);
2859        assert!(measure_beats_remaining(&score, 99, 0, 0, 0).is_err());
2860        assert!(measure_beats_remaining(&score, 0, 99, 0, 0).is_err());
2861        assert!(measure_beats_remaining(&score, 0, 0, 99, 0).is_err());
2862        assert!(measure_beats_remaining(&score, 0, 0, 0, 4).is_err());
2863    }
2864
2865    #[test]
2866    fn note_content_eq_ignores_id() {
2867        use crate::model::pitch::Step;
2868        let mut a = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2869        let mut b = a.clone();
2870        b.id = "different-id".to_string();
2871        assert!(note_content_eq(&a, &b));
2872        // Actual pitch change should differ
2873        b.pitches[0] = Pitch::new(Step::D, 4);
2874        assert!(!note_content_eq(&a, &b));
2875        // stem_up difference
2876        let mut c = a.clone();
2877        a.stem_up = Some(true);
2878        c.stem_up = Some(false);
2879        assert!(!note_content_eq(&a, &c));
2880    }
2881
2882    #[test]
2883    fn suggested_stem_up_below_middle() {
2884        use crate::model::notation::Clef;
2885        // C4 = MIDI 60, Treble middle = B4 = 71 → stem up
2886        let pitches = vec![Pitch::new(Step::C, 4)];
2887        assert!(suggested_stem_up(&pitches, &Clef::Treble));
2888    }
2889
2890    #[test]
2891    fn suggested_stem_up_above_middle() {
2892        use crate::model::notation::Clef;
2893        // G5 = MIDI 79, Treble middle = 71 → stem down
2894        let pitches = vec![Pitch::new(Step::G, 5)];
2895        assert!(!suggested_stem_up(&pitches, &Clef::Treble));
2896    }
2897
2898    #[test]
2899    fn suggested_stem_up_at_middle_line() {
2900        use crate::model::notation::Clef;
2901        // B4 = MIDI 71, Treble middle = 71 → stem down (avg >= middle)
2902        let pitches = vec![Pitch::new(Step::B, 4)];
2903        assert!(!suggested_stem_up(&pitches, &Clef::Treble));
2904    }
2905
2906    #[test]
2907    fn suggested_stem_up_chord() {
2908        use crate::model::notation::Clef;
2909        // [C4=60, G4=67] avg=63.5 < 71 → stem up
2910        let pitches = vec![Pitch::new(Step::C, 4), Pitch::new(Step::G, 4)];
2911        assert!(suggested_stem_up(&pitches, &Clef::Treble));
2912    }
2913
2914    #[test]
2915    fn suggested_stem_up_bass_clef() {
2916        use crate::model::notation::Clef;
2917        // D3=50 is exactly at Bass middle line → stem down
2918        let pitches = vec![Pitch::new(Step::D, 3)];
2919        assert!(!suggested_stem_up(&pitches, &Clef::Bass));
2920        // C3=48 < 50 → stem up
2921        let pitches2 = vec![Pitch::new(Step::C, 3)];
2922        assert!(suggested_stem_up(&pitches2, &Clef::Bass));
2923    }
2924
2925    #[test]
2926    fn suggested_stem_up_empty_pitches() {
2927        use crate::model::notation::Clef;
2928        assert!(suggested_stem_up(&[], &Clef::Treble));
2929    }
2930
2931    fn eighth(pitch: Pitch) -> Note {
2932        Note::new(pitch, Duration::Eighth)
2933    }
2934    fn quarter(pitch: Pitch) -> Note {
2935        Note::new(pitch, Duration::Quarter)
2936    }
2937    fn rest_eighth() -> Note {
2938        Note::rest(Duration::Eighth)
2939    }
2940
2941    #[test]
2942    fn compute_beams_4_4_four_eighths() {
2943        use crate::model::notation::{Clef, TimeSignature};
2944        let _ = Clef::Treble; // suppress unused import warning
2945        let ts = TimeSignature {
2946            numerator: 4,
2947            denominator: 4,
2948        };
2949        let c4 = Pitch::new(Step::C, 4);
2950        let notes = vec![
2951            eighth(c4.clone()),
2952            eighth(c4.clone()),
2953            eighth(c4.clone()),
2954            eighth(c4.clone()),
2955        ];
2956        let beams = compute_beams(&notes, &ts);
2957        // 4 eighths in 4/4: beat size=1.0, two groups of 2 each
2958        assert_eq!(beams[0], BeamState::Begin);
2959        assert_eq!(beams[1], BeamState::End);
2960        assert_eq!(beams[2], BeamState::Begin);
2961        assert_eq!(beams[3], BeamState::End);
2962    }
2963
2964    #[test]
2965    fn compute_beams_4_4_all_eighth_one_group() {
2966        use crate::model::notation::TimeSignature;
2967        let ts = TimeSignature {
2968            numerator: 4,
2969            denominator: 4,
2970        };
2971        let c4 = Pitch::new(Step::C, 4);
2972        // 2 eighths in a beat → group of 2
2973        let notes = vec![eighth(c4.clone()), eighth(c4.clone())];
2974        let beams = compute_beams(&notes, &ts);
2975        assert_eq!(beams[0], BeamState::Begin);
2976        assert_eq!(beams[1], BeamState::End);
2977    }
2978
2979    #[test]
2980    fn compute_beams_quarter_not_beamed() {
2981        use crate::model::notation::TimeSignature;
2982        let ts = TimeSignature {
2983            numerator: 4,
2984            denominator: 4,
2985        };
2986        let c4 = Pitch::new(Step::C, 4);
2987        let notes = vec![quarter(c4.clone()), quarter(c4.clone())];
2988        let beams = compute_beams(&notes, &ts);
2989        assert_eq!(beams[0], BeamState::None);
2990        assert_eq!(beams[1], BeamState::None);
2991    }
2992
2993    #[test]
2994    fn compute_beams_rest_breaks_beam() {
2995        use crate::model::notation::TimeSignature;
2996        let ts = TimeSignature {
2997            numerator: 4,
2998            denominator: 4,
2999        };
3000        let c4 = Pitch::new(Step::C, 4);
3001        let notes = vec![eighth(c4.clone()), rest_eighth(), eighth(c4.clone())];
3002        let beams = compute_beams(&notes, &ts);
3003        // rest breaks beam group
3004        assert_eq!(beams[0], BeamState::None);
3005        assert_eq!(beams[1], BeamState::None);
3006        assert_eq!(beams[2], BeamState::None);
3007    }
3008
3009    #[test]
3010    fn compute_beams_6_8_compound() {
3011        use crate::model::notation::TimeSignature;
3012        let ts = TimeSignature {
3013            numerator: 6,
3014            denominator: 8,
3015        };
3016        let c4 = Pitch::new(Step::C, 4);
3017        // 6 eighths in 6/8 compound → two groups of 3 (beam size=1.5 beats)
3018        let notes: Vec<Note> = (0..6).map(|_| eighth(c4.clone())).collect();
3019        let beams = compute_beams(&notes, &ts);
3020        assert_eq!(beams[0], BeamState::Begin);
3021        assert_eq!(beams[1], BeamState::Continue);
3022        assert_eq!(beams[2], BeamState::End);
3023        assert_eq!(beams[3], BeamState::Begin);
3024        assert_eq!(beams[4], BeamState::Continue);
3025        assert_eq!(beams[5], BeamState::End);
3026    }
3027
3028    #[test]
3029    fn compute_beams_single_eighth() {
3030        use crate::model::notation::TimeSignature;
3031        let ts = TimeSignature {
3032            numerator: 4,
3033            denominator: 4,
3034        };
3035        let c4 = Pitch::new(Step::C, 4);
3036        let notes = vec![eighth(c4.clone())];
3037        let beams = compute_beams(&notes, &ts);
3038        assert_eq!(beams[0], BeamState::None);
3039    }
3040}