Skip to main content

acorde_core/model/
score.rs

1use super::{
2    duration::Duration,
3    notation::{
4        Articulation, Barline, BeamState, ChordSymbol, Clef, Dynamic, GuitarTechnique, HairpinKind,
5        KeySignature, Lyric, NoteHead, OttavaKind, TimeSignature, TupletInfo,
6    },
7    pitch::Pitch,
8};
9use crate::Error;
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ScoreMetadata {
15    pub title: String,
16    pub composer: String,
17    pub lyricist: String,
18    pub copyright: String,
19    pub work_number: String,
20    pub movement_title: String,
21}
22
23impl Default for ScoreMetadata {
24    fn default() -> Self {
25        Self {
26            title: "Untitled Score".to_string(),
27            composer: String::new(),
28            lyricist: String::new(),
29            copyright: String::new(),
30            work_number: String::new(),
31            movement_title: String::new(),
32        }
33    }
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ScoreSettings {
38    pub tempo_bpm: u16,
39    pub time_signature: TimeSignature,
40    pub key_signature: KeySignature,
41}
42
43impl Default for ScoreSettings {
44    fn default() -> Self {
45        Self {
46            tempo_bpm: 120,
47            time_signature: TimeSignature::default(),
48            key_signature: KeySignature::default(),
49        }
50    }
51}
52
53/// Visual connector symbol for a group of adjacent parts.
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55pub enum PartGroupSymbol {
56    Bracket, // square bracket — orchestral strings, woodwinds
57    Brace,   // curly brace — piano grand staff
58    Line,    // thin vertical line
59}
60
61/// Groups a range of adjacent parts with a bracket or brace for rendering.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct PartGroup {
64    /// Index of the first part in the group (inclusive).
65    pub first_part: usize,
66    /// Index of the last part in the group (inclusive).
67    pub last_part: usize,
68    pub symbol: PartGroupSymbol,
69    /// Whether barlines are connected across all staves in the group.
70    #[serde(default)]
71    pub barlines_connect: bool,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct Score {
76    pub id: String,
77    /// JSON schema version. 0 when deserialized from files that predate this field.
78    #[serde(default)]
79    pub schema_version: u32,
80    pub metadata: ScoreMetadata,
81    pub settings: ScoreSettings,
82    pub parts: Vec<Part>,
83    #[serde(default)]
84    pub part_groups: Vec<PartGroup>,
85}
86
87impl Default for Score {
88    fn default() -> Self {
89        let mut part = Part::new("Piano", "Pno.");
90        part.staves.push(Staff::new(Clef::Treble));
91        for _ in 0..4 {
92            part.staves[0].measures.push(Measure::empty(4, 4));
93        }
94        Self {
95            id: Uuid::new_v4().to_string(),
96            schema_version: 1,
97            metadata: ScoreMetadata::default(),
98            settings: ScoreSettings::default(),
99            parts: vec![part],
100            part_groups: Vec::new(),
101        }
102    }
103}
104
105/// Score template presets for common ensemble configurations.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107pub enum ScoreTemplate {
108    /// Single treble-clef part (piano by default).
109    Solo,
110    /// One piano part with treble + bass grand staff.
111    Piano,
112    /// Violin I, Violin II, Viola, Cello.
113    StringQuartet,
114    /// Violin I, Violin II, Viola, Cello, Contrabass.
115    StringOrchestra,
116    /// Two trumpets, French horn, trombone, tuba.
117    BrassQuintet,
118}
119
120impl Score {
121    pub fn new(
122        title: &str,
123        tempo_bpm: u16,
124        numerator: u8,
125        denominator: u8,
126        fifths: i8,
127        measure_count: u32,
128    ) -> Self {
129        let mut score = Score::default();
130        score.metadata.title = title.to_string();
131        score.settings.tempo_bpm = tempo_bpm;
132        score.settings.time_signature = TimeSignature {
133            numerator,
134            denominator,
135        };
136        score.settings.key_signature = KeySignature {
137            fifths,
138            mode: "major".to_string(),
139        };
140
141        score.parts[0].staves[0].measures.clear();
142        for i in 0..measure_count {
143            let mut m = Measure::empty(numerator, denominator);
144            m.number = i + 1;
145            score.parts[0].staves[0].measures.push(m);
146        }
147        score
148    }
149
150    /// Create a score pre-populated with parts for the given ensemble template.
151    ///
152    /// Defaults: 120 BPM, 4/4, C major, 4 empty measures.
153    /// Use [`NewScoreCmd`](crate::model::commands::NewScoreCmd) to override those after creation.
154    pub fn template(kind: ScoreTemplate) -> Self {
155        fn measures(num: u8, den: u8, count: u32) -> Vec<Measure> {
156            (0..count)
157                .map(|i| {
158                    let mut m = Measure::empty(num, den);
159                    m.number = i + 1;
160                    m
161                })
162                .collect()
163        }
164        fn part(name: &str, short: &str, clef: Clef, program: u8) -> Part {
165            let mut p = Part::new(name, short);
166            p.midi_program = program;
167            let mut s = Staff::new(clef);
168            s.measures = measures(4, 4, 4);
169            p.staves.push(s);
170            p
171        }
172
173        let mut score = Score {
174            id: uuid::Uuid::new_v4().to_string(),
175            schema_version: 1,
176            metadata: ScoreMetadata::default(),
177            settings: ScoreSettings::default(),
178            parts: Vec::new(),
179            part_groups: Vec::new(),
180        };
181
182        match kind {
183            ScoreTemplate::Solo => {
184                score.parts.push(part("Piano", "Pno.", Clef::Treble, 0));
185            }
186            ScoreTemplate::Piano => {
187                let mut p = Part::new("Piano", "Pno.");
188                p.midi_program = 0;
189                let mut treble = Staff::new(Clef::Treble);
190                treble.measures = measures(4, 4, 4);
191                let mut bass = Staff::new(Clef::Bass);
192                bass.measures = measures(4, 4, 4);
193                p.staves.push(treble);
194                p.staves.push(bass);
195                score.parts.push(p);
196            }
197            ScoreTemplate::StringQuartet => {
198                score
199                    .parts
200                    .push(part("Violin I", "Vn. I", Clef::Treble, 40));
201                score
202                    .parts
203                    .push(part("Violin II", "Vn. II", Clef::Treble, 40));
204                score.parts.push(part("Viola", "Va.", Clef::Alto, 41));
205                score.parts.push(part("Cello", "Vc.", Clef::Bass, 42));
206            }
207            ScoreTemplate::StringOrchestra => {
208                score
209                    .parts
210                    .push(part("Violin I", "Vn. I", Clef::Treble, 40));
211                score
212                    .parts
213                    .push(part("Violin II", "Vn. II", Clef::Treble, 40));
214                score.parts.push(part("Viola", "Va.", Clef::Alto, 41));
215                score.parts.push(part("Cello", "Vc.", Clef::Bass, 42));
216                score.parts.push(part("Contrabass", "Cb.", Clef::Bass, 43));
217            }
218            ScoreTemplate::BrassQuintet => {
219                score
220                    .parts
221                    .push(part("Trumpet I", "Tpt. I", Clef::Treble, 56));
222                score
223                    .parts
224                    .push(part("Trumpet II", "Tpt. II", Clef::Treble, 56));
225                score
226                    .parts
227                    .push(part("French Horn", "Hn.", Clef::Treble, 60));
228                score.parts.push(part("Trombone", "Tbn.", Clef::Bass, 57));
229                score.parts.push(part("Tuba", "Tba.", Clef::Bass, 58));
230            }
231        }
232        score
233    }
234
235    pub fn measure_count(&self) -> usize {
236        self.parts
237            .first()
238            .and_then(|p| p.staves.first())
239            .map(|s| s.measures.len())
240            .unwrap_or(0)
241    }
242
243    /// Aggregate statistics about the score.
244    pub fn statistics(&self) -> ScoreStats {
245        let measure_count = self.measure_count();
246        let part_count = self.parts.len();
247
248        // Beat accumulation via measure_sequence so repeats are counted correctly.
249        let seq = measure_sequence(self);
250        let total_beats: f64 = self
251            .parts
252            .first()
253            .and_then(|p| p.staves.first())
254            .map(|s| {
255                seq.iter()
256                    .filter_map(|&idx| s.measures.get(idx))
257                    .flat_map(|m| m.voices.iter().flat_map(|v| v.iter()))
258                    .map(|n| n.beats())
259                    .sum()
260            })
261            .unwrap_or(0.0);
262
263        let mut note_count = 0usize;
264        let mut rest_count = 0usize;
265        for part in &self.parts {
266            for staff in &part.staves {
267                for measure in &staff.measures {
268                    for voice in &measure.voices {
269                        for note in voice {
270                            if note.is_rest {
271                                rest_count += 1;
272                            } else {
273                                note_count += 1;
274                            }
275                        }
276                    }
277                }
278            }
279        }
280
281        let bpm = self.settings.tempo_bpm as f64;
282        let estimated_duration_secs = if bpm > 0.0 {
283            total_beats / bpm * 60.0
284        } else {
285            0.0
286        };
287
288        ScoreStats {
289            measure_count,
290            note_count,
291            rest_count,
292            part_count,
293            estimated_duration_secs,
294        }
295    }
296
297    /// Return a new `Score` containing only the given part.
298    /// Returns `None` if `part_index` is out of range.
299    pub fn extract_part(&self, part_index: usize) -> Option<Score> {
300        let part = self.parts.get(part_index)?.clone();
301        Some(Score {
302            id: Uuid::new_v4().to_string(),
303            schema_version: 1,
304            metadata: self.metadata.clone(),
305            settings: self.settings.clone(),
306            parts: vec![part],
307            part_groups: Vec::new(),
308        })
309    }
310
311    /// Merge two scores by appending `other`'s parts to `self`'s parts.
312    /// Shorter scores are padded with empty measures to match the longer one.
313    /// Metadata and settings are taken from `self`.
314    pub fn merge(&self, other: &Score) -> Score {
315        let self_count = self.measure_count();
316        let other_count = other.measure_count();
317        let max_count = self_count.max(other_count);
318        let ts = self.settings.time_signature.clone();
319
320        let pad = |mut part: Part, from: usize| -> Part {
321            for staff in &mut part.staves {
322                for i in from..max_count {
323                    let mut m = Measure::empty(ts.numerator, ts.denominator);
324                    m.number = i as u32 + 1;
325                    staff.measures.push(m);
326                }
327            }
328            part
329        };
330
331        let mut parts: Vec<Part> = self
332            .parts
333            .iter()
334            .cloned()
335            .map(|p| pad(p, self_count))
336            .collect();
337        for p in &other.parts {
338            parts.push(pad(p.clone(), other_count));
339        }
340
341        Score {
342            id: Uuid::new_v4().to_string(),
343            schema_version: 1,
344            metadata: self.metadata.clone(),
345            settings: self.settings.clone(),
346            parts,
347            part_groups: Vec::new(),
348        }
349    }
350}
351
352/// Aggregate statistics returned by [`Score::statistics`].
353#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
354pub struct ScoreStats {
355    pub measure_count: usize,
356    /// Number of non-rest notes across all parts.
357    pub note_count: usize,
358    pub rest_count: usize,
359    pub part_count: usize,
360    /// Rough estimate: `total_beats(first part) / tempo_bpm * 60`.
361    pub estimated_duration_secs: f64,
362}
363
364// ── transpose ─────────────────────────────────────────────────────────────────
365
366use super::pitch::Step;
367use super::repeat::measure_sequence;
368
369/// Return a new `Score` with all pitches shifted by `semitones`.
370/// Key signatures (global and per-measure) are updated accordingly.
371/// If `semitones == 0` the score is cloned unchanged.
372pub fn transpose(score: &Score, semitones: i8) -> Score {
373    if semitones == 0 {
374        return score.clone();
375    }
376    let mut out = score.clone();
377    out.settings.key_signature.fifths = transpose_fifths(
378        score.settings.key_signature.fifths,
379        &score.settings.key_signature.mode,
380        semitones,
381    );
382    for part in &mut out.parts {
383        for staff in &mut part.staves {
384            for measure in &mut staff.measures {
385                if let Some(ref mut ks) = measure.key_sig {
386                    ks.fifths = transpose_fifths(ks.fifths, &ks.mode, semitones);
387                }
388                for voice in &mut measure.voices {
389                    for note in voice.iter_mut() {
390                        for pitch in note.pitches.iter_mut() {
391                            *pitch = transpose_pitch(pitch, semitones);
392                        }
393                    }
394                }
395            }
396        }
397    }
398    out
399}
400
401fn transpose_pitch(pitch: &Pitch, semitones: i8) -> Pitch {
402    let new_midi = (pitch.to_midi() + semitones as i16).clamp(0, 127) as u8;
403    let pc = new_midi % 12;
404    let oct = (new_midi / 12) as i8 - 1;
405    let (step, alter): (Step, i8) = if semitones >= 0 {
406        match pc {
407            0 => (Step::C, 0),
408            1 => (Step::C, 1),
409            2 => (Step::D, 0),
410            3 => (Step::D, 1),
411            4 => (Step::E, 0),
412            5 => (Step::F, 0),
413            6 => (Step::F, 1),
414            7 => (Step::G, 0),
415            8 => (Step::G, 1),
416            9 => (Step::A, 0),
417            10 => (Step::A, 1),
418            11 => (Step::B, 0),
419            _ => (Step::C, 0),
420        }
421    } else {
422        match pc {
423            0 => (Step::C, 0),
424            1 => (Step::D, -1),
425            2 => (Step::D, 0),
426            3 => (Step::E, -1),
427            4 => (Step::E, 0),
428            5 => (Step::F, 0),
429            6 => (Step::G, -1),
430            7 => (Step::G, 0),
431            8 => (Step::A, -1),
432            9 => (Step::A, 0),
433            10 => (Step::B, -1),
434            11 => (Step::B, 0),
435            _ => (Step::C, 0),
436        }
437    };
438    Pitch::with_alter(step, oct, alter)
439}
440
441/// Shift a key signature's fifths value by `semitones`.
442///
443/// Uses the circle-of-fifths arithmetic:
444/// - `tonic_pc = (fifths * 7) mod 12`  (for major; minor adds 9 to get relative major tonic)
445/// - `new_fifths = (new_tonic_pc * 7) mod 12`, adjusted to `[-7, 7]`
446fn transpose_fifths(fifths: i8, mode: &str, semitones: i8) -> i8 {
447    let tonic_major_pc = ((fifths as i32 * 7).rem_euclid(12)) as u8;
448    let tonic_pc = if mode == "minor" {
449        ((tonic_major_pc as i32 + 9).rem_euclid(12)) as u8
450    } else {
451        tonic_major_pc
452    };
453    let new_tonic = ((tonic_pc as i32 + semitones as i32).rem_euclid(12)) as u8;
454    let major_tonic = if mode == "minor" {
455        ((new_tonic as i32 + 3).rem_euclid(12)) as u8
456    } else {
457        new_tonic
458    };
459    let raw = ((major_tonic as i32 * 7).rem_euclid(12)) as i8;
460    if raw > 6 { raw - 12 } else { raw }
461}
462
463#[derive(Debug, Clone, Serialize, Deserialize)]
464pub struct Part {
465    pub id: String,
466    pub name: String,
467    pub short_name: String,
468    pub staves: Vec<Staff>,
469    /// MIDI channel (0–15). Channel 9 is conventionally used for percussion.
470    #[serde(default)]
471    pub midi_channel: u8,
472    /// General MIDI program number (0–127). Default 0 = Acoustic Grand Piano.
473    #[serde(default)]
474    pub midi_program: u8,
475}
476
477impl Part {
478    pub fn new(name: &str, short_name: &str) -> Self {
479        Self {
480            id: Uuid::new_v4().to_string(),
481            name: name.to_string(),
482            short_name: short_name.to_string(),
483            staves: Vec::new(),
484            midi_channel: 0,
485            midi_program: 0,
486        }
487    }
488}
489
490#[derive(Debug, Clone, Serialize, Deserialize)]
491pub struct Staff {
492    pub clef: Clef,
493    pub measures: Vec<Measure>,
494    /// Semitones to add to written pitch for concert pitch / MIDI output.
495    /// -2 = Bb instrument (clarinet, trumpet), -9 = Eb instrument (alto sax), etc.
496    #[serde(default)]
497    pub transpose_semitones: i8,
498}
499
500impl Staff {
501    pub fn new(clef: Clef) -> Self {
502        Self {
503            clef,
504            measures: Vec::new(),
505            transpose_semitones: 0,
506        }
507    }
508}
509
510#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
511pub struct VoltaBracket {
512    /// Ending number (1, 2, …)
513    pub number: u8,
514    /// "begin" | "mid" | "end" | "begin_end"
515    pub kind: String,
516}
517
518#[derive(Debug, Clone, Serialize, Deserialize)]
519pub struct Measure {
520    pub number: u32,
521    pub time_sig: Option<TimeSignature>,
522    pub key_sig: Option<KeySignature>,
523    pub clef: Option<Clef>,
524    pub tempo: Option<u16>,
525    pub barline_left: Barline,
526    pub barline_right: Barline,
527    #[serde(default)]
528    pub volta: Option<VoltaBracket>,
529    #[serde(default)]
530    pub tempo_text: Option<String>,
531    #[serde(default)]
532    pub rehearsal: Option<String>,
533    /// Navigation mark: "Segno" | "Coda" | "Fine" | "DaCapo" | "DaCapoAlFine" |
534    /// "DaCapoAlCoda" | "DalSegno" | "DalSegnoAlFine" | "DalSegnoAlCoda" | "ToCoda"
535    #[serde(default)]
536    pub navigation: Option<String>,
537    /// Expression / performance text ("dolce", "espressivo", "con fuoco", etc.).
538    #[serde(default)]
539    pub expression_text: Option<String>,
540    /// When ≥ 2, this measure is displayed as a multi-measure rest spanning N measures.
541    #[serde(default)]
542    pub multi_rest_count: Option<u8>,
543    /// Force a new system (row) after this measure.
544    #[serde(default)]
545    pub system_break: bool,
546    /// Force a new page after this measure.
547    #[serde(default)]
548    pub page_break: bool,
549    /// Up to 4 voices; voice 0 is the primary voice.
550    pub voices: [Vec<Note>; 4],
551}
552
553impl Measure {
554    pub fn empty(numerator: u8, denominator: u8) -> Self {
555        let total_beats = TimeSignature {
556            numerator,
557            denominator,
558        }
559        .total_beats();
560        let mut voice0: Vec<Note> = Vec::new();
561        let mut remaining = total_beats;
562        while remaining > 1e-9 {
563            let dur = Duration::whole_filling_beats(remaining);
564            remaining -= dur.beats(0);
565            voice0.push(Note::rest(dur));
566        }
567        Self {
568            number: 0,
569            time_sig: None,
570            key_sig: None,
571            clef: None,
572            tempo: None,
573            barline_left: Barline::Normal,
574            barline_right: Barline::Normal,
575            volta: None,
576            tempo_text: None,
577            rehearsal: None,
578            navigation: None,
579            expression_text: None,
580            multi_rest_count: None,
581            system_break: false,
582            page_break: false,
583            voices: [voice0, vec![], vec![], vec![]],
584        }
585    }
586
587    pub fn renumber(&mut self, n: u32) {
588        self.number = n;
589    }
590}
591
592#[derive(Debug, Clone, Serialize, Deserialize)]
593pub struct Note {
594    pub id: String,
595    pub is_rest: bool,
596    /// Single note: one pitch. Chord: multiple pitches (same duration).
597    pub pitches: Vec<Pitch>,
598    pub duration: Duration,
599    pub dot_count: u8,
600    pub tie_start: bool,
601    pub tie_end: bool,
602    pub beam: BeamState,
603    pub articulations: Vec<Articulation>,
604    pub dynamic: Option<Dynamic>,
605    pub stem_up: Option<bool>,
606    #[serde(default)]
607    pub hairpin_start: Option<HairpinKind>,
608    #[serde(default)]
609    pub hairpin_end: bool,
610    #[serde(default)]
611    pub tuplet: Option<TupletInfo>,
612    #[serde(default)]
613    pub chord_symbol: Option<ChordSymbol>,
614    #[serde(default)]
615    pub is_grace: bool,
616    /// Acciaccatura: true (slash through stem). Appoggiatura: false.
617    #[serde(default)]
618    pub grace_slash: bool,
619    #[serde(default)]
620    pub ottava_start: Option<OttavaKind>,
621    #[serde(default)]
622    pub ottava_end: bool,
623    #[serde(default)]
624    pub lyric: Option<Lyric>,
625    #[serde(default)]
626    pub pedal_start: bool,
627    #[serde(default)]
628    pub pedal_end: bool,
629    #[serde(default)]
630    pub slur_start: bool,
631    #[serde(default)]
632    pub slur_end: bool,
633    /// Arpeggiate direction: `Some(true)` = up, `Some(false)` = down, `None` = none.
634    #[serde(default)]
635    pub arpeggiate: Option<bool>,
636    /// Technique/style instruction attached to this note ("pizz.", "arco", "con sord.", etc.).
637    #[serde(default)]
638    pub technique_text: Option<String>,
639    /// Left-hand fingering number (0 = open / thumb, 1–5 = fingers).
640    #[serde(default)]
641    pub fingering: Option<u8>,
642    /// String number for plucked/bowed string instruments (1 = highest string).
643    #[serde(default)]
644    pub string_number: Option<u8>,
645    #[serde(default)]
646    pub note_head: NoteHead,
647    /// Cue note (small-sized, does not count toward beat total).
648    #[serde(default)]
649    pub is_cue: bool,
650    /// Start of a multi-note trill line span.
651    #[serde(default)]
652    pub trill_line_start: bool,
653    /// End of a multi-note trill line span.
654    #[serde(default)]
655    pub trill_line_end: bool,
656    /// Guitar-specific playing technique (bend, slide, hammer-on, pull-off).
657    #[serde(default)]
658    pub guitar_technique: Option<GuitarTechnique>,
659}
660
661impl Note {
662    pub fn new(pitch: Pitch, duration: Duration) -> Self {
663        Self {
664            id: Uuid::new_v4().to_string(),
665            is_rest: false,
666            pitches: vec![pitch],
667            duration,
668            dot_count: 0,
669            tie_start: false,
670            tie_end: false,
671            beam: BeamState::None,
672            articulations: Vec::new(),
673            dynamic: None,
674            stem_up: None,
675            hairpin_start: None,
676            hairpin_end: false,
677            tuplet: None,
678            chord_symbol: None,
679            is_grace: false,
680            grace_slash: false,
681            ottava_start: None,
682            ottava_end: false,
683            lyric: None,
684            pedal_start: false,
685            pedal_end: false,
686            slur_start: false,
687            slur_end: false,
688            arpeggiate: None,
689            technique_text: None,
690            fingering: None,
691            string_number: None,
692            note_head: NoteHead::Normal,
693            is_cue: false,
694            trill_line_start: false,
695            trill_line_end: false,
696            guitar_technique: None,
697        }
698    }
699
700    pub fn rest(duration: Duration) -> Self {
701        Self {
702            id: Uuid::new_v4().to_string(),
703            is_rest: true,
704            pitches: Vec::new(),
705            duration,
706            dot_count: 0,
707            tie_start: false,
708            tie_end: false,
709            beam: BeamState::None,
710            articulations: Vec::new(),
711            dynamic: None,
712            stem_up: None,
713            hairpin_start: None,
714            hairpin_end: false,
715            tuplet: None,
716            chord_symbol: None,
717            is_grace: false,
718            grace_slash: false,
719            ottava_start: None,
720            ottava_end: false,
721            lyric: None,
722            pedal_start: false,
723            pedal_end: false,
724            slur_start: false,
725            slur_end: false,
726            arpeggiate: None,
727            technique_text: None,
728            fingering: None,
729            string_number: None,
730            note_head: NoteHead::Normal,
731            is_cue: false,
732            trill_line_start: false,
733            trill_line_end: false,
734            guitar_technique: None,
735        }
736    }
737
738    pub fn beats(&self) -> f64 {
739        if self.is_grace || self.is_cue {
740            return 0.0;
741        }
742        let base = self.duration.beats(self.dot_count);
743        if let Some(ref t) = self.tuplet {
744            base * (t.normal_notes as f64) / (t.actual_notes as f64)
745        } else {
746            base
747        }
748    }
749}
750
751impl Duration {
752    /// Returns the largest single duration that fills the given number of beats.
753    pub fn whole_filling_beats(beats: f64) -> Duration {
754        if beats >= 4.0 {
755            Duration::Whole
756        } else if beats >= 2.0 {
757            Duration::Half
758        } else if beats >= 1.0 {
759            Duration::Quarter
760        } else if beats >= 0.5 {
761            Duration::Eighth
762        } else if beats >= 0.25 {
763            Duration::Sixteenth
764        } else if beats >= 0.125 {
765            Duration::ThirtySecond
766        } else {
767            Duration::SixtyFourth
768        }
769    }
770}
771
772// ── NoteAddr ──────────────────────────────────────────────────────────────────
773
774/// Physical address of a note within a score.
775#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
776pub struct NoteAddr {
777    pub part: usize,
778    pub staff: usize,
779    pub measure: usize,
780    pub voice: usize,
781    pub note: usize,
782}
783
784// ── diff ──────────────────────────────────────────────────────────────────────
785
786/// A single change between two [`Score`] values as reported by [`diff`].
787#[derive(Debug, Clone, Serialize, Deserialize)]
788pub enum ScoreChange {
789    MetadataChanged {
790        field: String,
791        old: String,
792        new: String,
793    },
794    TempoChanged {
795        old: u16,
796        new: u16,
797    },
798    KeySignatureChanged {
799        old: KeySignature,
800        new: KeySignature,
801    },
802    PartAdded {
803        part_index: usize,
804    },
805    PartRemoved {
806        part_index: usize,
807        name: String,
808    },
809    NoteAdded {
810        part: usize,
811        staff: usize,
812        measure: usize,
813        voice: usize,
814        note_index: usize,
815    },
816    NoteRemoved {
817        part: usize,
818        staff: usize,
819        measure: usize,
820        voice: usize,
821        note: Box<Note>,
822    },
823    NoteModified {
824        part: usize,
825        staff: usize,
826        measure: usize,
827        voice: usize,
828        note_index: usize,
829        old: Box<Note>,
830        new: Box<Note>,
831    },
832    TimeSigChanged {
833        part: usize,
834        staff: usize,
835        measure: usize,
836        old: Option<TimeSignature>,
837        new: Option<TimeSignature>,
838    },
839    MeasureTempoChanged {
840        part: usize,
841        staff: usize,
842        measure: usize,
843        old: Option<u16>,
844        new: Option<u16>,
845    },
846    BarlineChanged {
847        part: usize,
848        staff: usize,
849        measure: usize,
850    },
851    RehearsalMarkChanged {
852        part: usize,
853        staff: usize,
854        measure: usize,
855        old: Option<String>,
856        new: Option<String>,
857    },
858    VoltaChanged {
859        part: usize,
860        staff: usize,
861        measure: usize,
862    },
863}
864
865/// Compare two scores and return a list of differences.
866///
867/// Parts, staves, measures, and voices are compared by position. Notes are compared by
868/// position within each voice, ignoring their `id` field. Metadata fields are compared
869/// individually.
870pub fn diff(a: &Score, b: &Score) -> Vec<ScoreChange> {
871    let mut changes: Vec<ScoreChange> = Vec::new();
872
873    macro_rules! meta {
874        ($field:ident, $name:literal) => {
875            if a.metadata.$field != b.metadata.$field {
876                changes.push(ScoreChange::MetadataChanged {
877                    field: $name.to_string(),
878                    old: a.metadata.$field.clone(),
879                    new: b.metadata.$field.clone(),
880                });
881            }
882        };
883    }
884    meta!(title, "title");
885    meta!(composer, "composer");
886    meta!(lyricist, "lyricist");
887    meta!(copyright, "copyright");
888    meta!(work_number, "work_number");
889    meta!(movement_title, "movement_title");
890
891    if a.settings.tempo_bpm != b.settings.tempo_bpm {
892        changes.push(ScoreChange::TempoChanged {
893            old: a.settings.tempo_bpm,
894            new: b.settings.tempo_bpm,
895        });
896    }
897    if a.settings.key_signature != b.settings.key_signature {
898        changes.push(ScoreChange::KeySignatureChanged {
899            old: a.settings.key_signature.clone(),
900            new: b.settings.key_signature.clone(),
901        });
902    }
903
904    let a_len = a.parts.len();
905    let b_len = b.parts.len();
906    for i in b_len..a_len {
907        changes.push(ScoreChange::PartRemoved {
908            part_index: i,
909            name: a.parts[i].name.clone(),
910        });
911    }
912    for i in a_len..b_len {
913        changes.push(ScoreChange::PartAdded { part_index: i });
914    }
915
916    for pi in 0..a_len.min(b_len) {
917        let ap = &a.parts[pi];
918        let bp = &b.parts[pi];
919        for si in 0..ap.staves.len().min(bp.staves.len()) {
920            let a_staff = &ap.staves[si];
921            let b_staff = &bp.staves[si];
922            for mi in 0..a_staff.measures.len().min(b_staff.measures.len()) {
923                let am = &a_staff.measures[mi];
924                let bm = &b_staff.measures[mi];
925                for vi in 0..4usize {
926                    let av = &am.voices[vi];
927                    let bv = &bm.voices[vi];
928                    for (ni, (a_note, b_note)) in av.iter().zip(bv.iter()).enumerate() {
929                        if !note_content_eq(a_note, b_note) {
930                            changes.push(ScoreChange::NoteModified {
931                                part: pi,
932                                staff: si,
933                                measure: mi,
934                                voice: vi,
935                                note_index: ni,
936                                old: Box::new(a_note.clone()),
937                                new: Box::new(b_note.clone()),
938                            });
939                        }
940                    }
941                    for note in av.iter().skip(bv.len()) {
942                        changes.push(ScoreChange::NoteRemoved {
943                            part: pi,
944                            staff: si,
945                            measure: mi,
946                            voice: vi,
947                            note: Box::new(note.clone()),
948                        });
949                    }
950                    for ni in av.len()..bv.len() {
951                        changes.push(ScoreChange::NoteAdded {
952                            part: pi,
953                            staff: si,
954                            measure: mi,
955                            voice: vi,
956                            note_index: ni,
957                        });
958                    }
959                }
960                if am.time_sig != bm.time_sig {
961                    changes.push(ScoreChange::TimeSigChanged {
962                        part: pi,
963                        staff: si,
964                        measure: mi,
965                        old: am.time_sig.clone(),
966                        new: bm.time_sig.clone(),
967                    });
968                }
969                if am.tempo != bm.tempo {
970                    changes.push(ScoreChange::MeasureTempoChanged {
971                        part: pi,
972                        staff: si,
973                        measure: mi,
974                        old: am.tempo,
975                        new: bm.tempo,
976                    });
977                }
978                if am.barline_left != bm.barline_left || am.barline_right != bm.barline_right {
979                    changes.push(ScoreChange::BarlineChanged {
980                        part: pi,
981                        staff: si,
982                        measure: mi,
983                    });
984                }
985                if am.rehearsal != bm.rehearsal {
986                    changes.push(ScoreChange::RehearsalMarkChanged {
987                        part: pi,
988                        staff: si,
989                        measure: mi,
990                        old: am.rehearsal.clone(),
991                        new: bm.rehearsal.clone(),
992                    });
993                }
994                if am.volta != bm.volta {
995                    changes.push(ScoreChange::VoltaChanged {
996                        part: pi,
997                        staff: si,
998                        measure: mi,
999                    });
1000                }
1001            }
1002        }
1003    }
1004
1005    changes
1006}
1007
1008// ── ScorePatch ────────────────────────────────────────────────────────────────
1009
1010/// An individually applicable patch operation produced by [`score_patch`].
1011///
1012/// Unlike [`ScoreChange`], every variant carries enough data to apply the change to a
1013/// [`Score`] without needing the original score. Use [`apply_patch`] to apply a list.
1014#[derive(Debug, Clone, Serialize, Deserialize)]
1015pub enum ScorePatch {
1016    SetMetadata {
1017        field: String,
1018        value: String,
1019    },
1020    SetTempo {
1021        value: u16,
1022    },
1023    SetKeySignature {
1024        part: usize,
1025        staff: usize,
1026        measure: usize,
1027        value: KeySignature,
1028    },
1029    /// Insert `note` at `note_index` in the given voice (existing notes shift right).
1030    AddNote {
1031        part: usize,
1032        staff: usize,
1033        measure: usize,
1034        voice: usize,
1035        note: Box<Note>,
1036    },
1037    RemoveNote {
1038        part: usize,
1039        staff: usize,
1040        measure: usize,
1041        voice: usize,
1042        note_index: usize,
1043    },
1044    /// Replace the note at `note_index` with `note`.
1045    ReplaceNote {
1046        part: usize,
1047        staff: usize,
1048        measure: usize,
1049        voice: usize,
1050        note_index: usize,
1051        note: Box<Note>,
1052    },
1053    SetMeasureTempo {
1054        part: usize,
1055        staff: usize,
1056        measure: usize,
1057        value: Option<u16>,
1058    },
1059}
1060
1061/// Compare two scores and return a list of [`ScorePatch`] operations.
1062///
1063/// Applying the patches to `a` via [`apply_patch`] produces a score structurally
1064/// equivalent to `b` (same parts, staves, measures, and note content).
1065pub fn score_patch(a: &Score, b: &Score) -> Vec<ScorePatch> {
1066    let mut patches: Vec<ScorePatch> = Vec::new();
1067
1068    macro_rules! meta {
1069        ($field:ident, $name:literal) => {
1070            if a.metadata.$field != b.metadata.$field {
1071                patches.push(ScorePatch::SetMetadata {
1072                    field: $name.to_string(),
1073                    value: b.metadata.$field.clone(),
1074                });
1075            }
1076        };
1077    }
1078    meta!(title, "title");
1079    meta!(composer, "composer");
1080    meta!(lyricist, "lyricist");
1081    meta!(copyright, "copyright");
1082    meta!(work_number, "work_number");
1083    meta!(movement_title, "movement_title");
1084
1085    if a.settings.tempo_bpm != b.settings.tempo_bpm {
1086        patches.push(ScorePatch::SetTempo {
1087            value: b.settings.tempo_bpm,
1088        });
1089    }
1090
1091    for pi in 0..a.parts.len().min(b.parts.len()) {
1092        let ap = &a.parts[pi];
1093        let bp = &b.parts[pi];
1094        for si in 0..ap.staves.len().min(bp.staves.len()) {
1095            let a_staff = &ap.staves[si];
1096            let b_staff = &bp.staves[si];
1097            for mi in 0..a_staff.measures.len().min(b_staff.measures.len()) {
1098                let am = &a_staff.measures[mi];
1099                let bm = &b_staff.measures[mi];
1100
1101                if am.key_sig != bm.key_sig
1102                    && let Some(ref ks) = bm.key_sig
1103                {
1104                    patches.push(ScorePatch::SetKeySignature {
1105                        part: pi,
1106                        staff: si,
1107                        measure: mi,
1108                        value: ks.clone(),
1109                    });
1110                }
1111                if am.tempo != bm.tempo {
1112                    patches.push(ScorePatch::SetMeasureTempo {
1113                        part: pi,
1114                        staff: si,
1115                        measure: mi,
1116                        value: bm.tempo,
1117                    });
1118                }
1119
1120                for vi in 0..4usize {
1121                    let av = &am.voices[vi];
1122                    let bv = &bm.voices[vi];
1123                    for (ni, (a_note, b_note)) in av.iter().zip(bv.iter()).enumerate() {
1124                        if !note_content_eq(a_note, b_note) {
1125                            patches.push(ScorePatch::ReplaceNote {
1126                                part: pi,
1127                                staff: si,
1128                                measure: mi,
1129                                voice: vi,
1130                                note_index: ni,
1131                                note: Box::new(b_note.clone()),
1132                            });
1133                        }
1134                    }
1135                    // Notes in `a` beyond `b` — remove in reverse order to preserve indices.
1136                    for ni in (bv.len()..av.len()).rev() {
1137                        patches.push(ScorePatch::RemoveNote {
1138                            part: pi,
1139                            staff: si,
1140                            measure: mi,
1141                            voice: vi,
1142                            note_index: ni,
1143                        });
1144                    }
1145                    // Notes in `b` beyond `a` — append.
1146                    for note in bv.iter().skip(av.len()) {
1147                        patches.push(ScorePatch::AddNote {
1148                            part: pi,
1149                            staff: si,
1150                            measure: mi,
1151                            voice: vi,
1152                            note: Box::new(note.clone()),
1153                        });
1154                    }
1155                }
1156            }
1157        }
1158    }
1159
1160    patches
1161}
1162
1163/// Apply a list of [`ScorePatch`] operations to a cloned copy of `score`.
1164///
1165/// Returns `Err(Error::InvalidPatch)` if any patch references an out-of-bounds index.
1166/// The returned score is an independent clone — `score` is not modified.
1167pub fn apply_patch(score: &Score, patches: &[ScorePatch]) -> Result<Score, Error> {
1168    let mut s = score.clone();
1169    for patch in patches {
1170        match patch {
1171            ScorePatch::SetMetadata { field, value } => match field.as_str() {
1172                "title" => s.metadata.title = value.clone(),
1173                "composer" => s.metadata.composer = value.clone(),
1174                "lyricist" => s.metadata.lyricist = value.clone(),
1175                "copyright" => s.metadata.copyright = value.clone(),
1176                "work_number" => s.metadata.work_number = value.clone(),
1177                "movement_title" => s.metadata.movement_title = value.clone(),
1178                other => {
1179                    return Err(Error::InvalidPatch(format!(
1180                        "unknown metadata field: {other}"
1181                    )));
1182                }
1183            },
1184            ScorePatch::SetTempo { value } => {
1185                s.settings.tempo_bpm = *value;
1186            }
1187            ScorePatch::SetKeySignature {
1188                part,
1189                staff,
1190                measure,
1191                value,
1192            } => {
1193                s.parts
1194                    .get_mut(*part)
1195                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
1196                    .staves
1197                    .get_mut(*staff)
1198                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
1199                    .measures
1200                    .get_mut(*measure)
1201                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
1202                    .key_sig = Some(value.clone());
1203            }
1204            ScorePatch::AddNote {
1205                part,
1206                staff,
1207                measure,
1208                voice,
1209                note,
1210            } => {
1211                let v = s
1212                    .parts
1213                    .get_mut(*part)
1214                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
1215                    .staves
1216                    .get_mut(*staff)
1217                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
1218                    .measures
1219                    .get_mut(*measure)
1220                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
1221                    .voices
1222                    .get_mut(*voice)
1223                    .ok_or_else(|| Error::InvalidPatch(format!("voice {voice} out of range")))?;
1224                v.push(*note.clone());
1225            }
1226            ScorePatch::RemoveNote {
1227                part,
1228                staff,
1229                measure,
1230                voice,
1231                note_index,
1232            } => {
1233                let v = s
1234                    .parts
1235                    .get_mut(*part)
1236                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
1237                    .staves
1238                    .get_mut(*staff)
1239                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
1240                    .measures
1241                    .get_mut(*measure)
1242                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
1243                    .voices
1244                    .get_mut(*voice)
1245                    .ok_or_else(|| Error::InvalidPatch(format!("voice {voice} out of range")))?;
1246                if *note_index >= v.len() {
1247                    return Err(Error::InvalidPatch(format!(
1248                        "note_index {note_index} out of range"
1249                    )));
1250                }
1251                v.remove(*note_index);
1252            }
1253            ScorePatch::ReplaceNote {
1254                part,
1255                staff,
1256                measure,
1257                voice,
1258                note_index,
1259                note,
1260            } => {
1261                let v = s
1262                    .parts
1263                    .get_mut(*part)
1264                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
1265                    .staves
1266                    .get_mut(*staff)
1267                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
1268                    .measures
1269                    .get_mut(*measure)
1270                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
1271                    .voices
1272                    .get_mut(*voice)
1273                    .ok_or_else(|| Error::InvalidPatch(format!("voice {voice} out of range")))?;
1274                if *note_index >= v.len() {
1275                    return Err(Error::InvalidPatch(format!(
1276                        "note_index {note_index} out of range"
1277                    )));
1278                }
1279                v[*note_index] = *note.clone();
1280            }
1281            ScorePatch::SetMeasureTempo {
1282                part,
1283                staff,
1284                measure,
1285                value,
1286            } => {
1287                s.parts
1288                    .get_mut(*part)
1289                    .ok_or_else(|| Error::InvalidPatch(format!("part {part} out of range")))?
1290                    .staves
1291                    .get_mut(*staff)
1292                    .ok_or_else(|| Error::InvalidPatch(format!("staff {staff} out of range")))?
1293                    .measures
1294                    .get_mut(*measure)
1295                    .ok_or_else(|| Error::InvalidPatch(format!("measure {measure} out of range")))?
1296                    .tempo = *value;
1297            }
1298        }
1299    }
1300    Ok(s)
1301}
1302
1303/// Respell all pitches in the score to prefer flats or sharps.
1304///
1305/// Applies [`Pitch::respell`] to every note in every part, staff, measure, and voice.
1306pub fn respell_score(score: &mut Score, prefer_flat: bool) {
1307    for part in &mut score.parts {
1308        for staff in &mut part.staves {
1309            for measure in &mut staff.measures {
1310                for voice in &mut measure.voices {
1311                    for note in voice.iter_mut() {
1312                        for pitch in &mut note.pitches {
1313                            *pitch = pitch.respell(prefer_flat);
1314                        }
1315                    }
1316                }
1317            }
1318        }
1319    }
1320}
1321
1322/// Respell all pitches to match the score's key signature spelling convention.
1323///
1324/// Flat-key signatures (fifths < 0) use flat spellings; sharp-key and C major use sharps.
1325pub fn respell_score_to_key(score: &mut Score) {
1326    let prefer_flat = score.settings.key_signature.fifths < 0;
1327    respell_score(score, prefer_flat);
1328}
1329
1330/// Compute total playback duration in seconds.
1331///
1332/// Uses `measure_sequence` for correct repeat handling. Lighter than generating
1333/// full playback events — suitable for progress bars and UI display.
1334pub fn score_duration_secs(score: &Score) -> f64 {
1335    if score.settings.tempo_bpm == 0 {
1336        return 0.0;
1337    }
1338    let seq = measure_sequence(score);
1339    let mut total_secs = 0.0f64;
1340    let mut current_bpm = score.settings.tempo_bpm as f64;
1341    if let Some(staff) = score.parts.first().and_then(|p| p.staves.first()) {
1342        for &idx in &seq {
1343            if let Some(m) = staff.measures.get(idx) {
1344                if let Some(b) = m.tempo {
1345                    current_bpm = b as f64;
1346                }
1347                if current_bpm == 0.0 {
1348                    continue;
1349                }
1350                let beats: f64 = m.voices[0].iter().map(|n| n.beats()).sum();
1351                total_secs += beats / current_bpm * 60.0;
1352            }
1353        }
1354    }
1355    total_secs
1356}
1357
1358/// Compute playback duration in seconds for a specific measure range (inclusive).
1359///
1360/// `region` is `(start_measure, end_measure)`, both 0-based. Measures outside the range
1361/// are excluded. Uses `measure_sequence` for correct repeat handling.
1362pub fn score_duration_secs_region(score: &Score, region: (usize, usize)) -> f64 {
1363    if score.settings.tempo_bpm == 0 {
1364        return 0.0;
1365    }
1366    let seq: Vec<usize> = measure_sequence(score)
1367        .into_iter()
1368        .filter(|&idx| idx >= region.0 && idx <= region.1)
1369        .collect();
1370    let mut total_secs = 0.0f64;
1371    let mut current_bpm = score.settings.tempo_bpm as f64;
1372    if let Some(staff) = score.parts.first().and_then(|p| p.staves.first()) {
1373        for &idx in &seq {
1374            if let Some(m) = staff.measures.get(idx) {
1375                if let Some(b) = m.tempo {
1376                    current_bpm = b as f64;
1377                }
1378                if current_bpm == 0.0 {
1379                    continue;
1380                }
1381                let beats: f64 = m.voices[0].iter().map(|n| n.beats()).sum();
1382                total_secs += beats / current_bpm * 60.0;
1383            }
1384        }
1385    }
1386    total_secs
1387}
1388
1389/// Return the number of beats available in a voice before it is full.
1390///
1391/// Uses [`Note::beats`] which correctly handles tuplet scaling.
1392/// Returns `Ok(0.0)` when the voice is already full or over-full.
1393pub fn measure_beats_remaining(
1394    score: &Score,
1395    part_index: usize,
1396    staff_index: usize,
1397    measure_index: usize,
1398    voice_index: usize,
1399) -> Result<f64, Error> {
1400    let part = score
1401        .parts
1402        .get(part_index)
1403        .ok_or(Error::PartNotFound(part_index))?;
1404    let staff = part
1405        .staves
1406        .get(staff_index)
1407        .ok_or(Error::StaffNotFound(staff_index))?;
1408    let measure = staff
1409        .measures
1410        .get(measure_index)
1411        .ok_or(Error::MeasureNotFound(measure_index))?;
1412    let voice = measure
1413        .voices
1414        .get(voice_index)
1415        .ok_or(Error::VoiceOutOfRange(voice_index))?;
1416    let ts = measure
1417        .time_sig
1418        .as_ref()
1419        .unwrap_or(&score.settings.time_signature);
1420    let used: f64 = voice.iter().map(|n| n.beats()).sum();
1421    Ok((ts.total_beats() - used).max(0.0))
1422}
1423
1424/// Suggest whether the stem should point up for the given pitches and clef.
1425///
1426/// Conventional rule: if the average MIDI pitch of the chord is below the staff
1427/// middle line, the stem points up; at or above, it points down.
1428/// For empty pitch lists (rests), returns `true` by convention.
1429pub fn suggested_stem_up(pitches: &[Pitch], clef: &Clef) -> bool {
1430    if pitches.is_empty() {
1431        return true;
1432    }
1433    let avg = pitches.iter().map(|p| p.to_midi() as f64).sum::<f64>() / pitches.len() as f64;
1434    avg < clef.middle_line_midi() as f64
1435}
1436
1437fn beam_beat_size(ts: &TimeSignature) -> f64 {
1438    if ts.numerator.is_multiple_of(3) && ts.numerator >= 6 && ts.denominator >= 8 {
1439        3.0 * 4.0 / ts.denominator as f64
1440    } else {
1441        4.0 / ts.denominator as f64
1442    }
1443}
1444
1445/// Compute recommended [`BeamState`] values for a voice's notes.
1446///
1447/// Groups beamable notes (eighth or shorter, non-rest) within beat boundaries.
1448/// Returns a `Vec` the same length as `notes`.
1449pub fn compute_beams(notes: &[Note], time_sig: &TimeSignature) -> Vec<BeamState> {
1450    let beat_size = beam_beat_size(time_sig);
1451    let n = notes.len();
1452    let mut result = vec![BeamState::None; n];
1453
1454    let is_beamable = |note: &Note| -> bool {
1455        !note.is_rest
1456            && matches!(
1457                note.duration,
1458                Duration::Eighth
1459                    | Duration::Sixteenth
1460                    | Duration::ThirtySecond
1461                    | Duration::SixtyFourth
1462            )
1463    };
1464
1465    // Compute beat start positions
1466    let mut starts = Vec::with_capacity(n);
1467    let mut pos = 0.0f64;
1468    for note in notes {
1469        starts.push(pos);
1470        pos += note.beats();
1471    }
1472
1473    // Assign beam group ids based on beat boundary
1474    let group_id = |i: usize| -> i64 { (starts[i] / beat_size).floor() as i64 };
1475
1476    let mut i = 0;
1477    while i < n {
1478        if !is_beamable(&notes[i]) {
1479            i += 1;
1480            continue;
1481        }
1482        let g = group_id(i);
1483        // Find the run of beamable notes in the same beat group
1484        let mut j = i;
1485        while j < n && is_beamable(&notes[j]) && group_id(j) == g {
1486            j += 1;
1487        }
1488        let run = j - i;
1489        if run == 1 {
1490            result[i] = BeamState::None;
1491        } else {
1492            result[i] = BeamState::Begin;
1493            result[i + 1..j - 1].fill(BeamState::Continue);
1494            result[j - 1] = BeamState::End;
1495        }
1496        i = j;
1497    }
1498    result
1499}
1500
1501fn note_content_eq(a: &Note, b: &Note) -> bool {
1502    a.is_rest == b.is_rest
1503        && a.pitches == b.pitches
1504        && a.duration == b.duration
1505        && a.dot_count == b.dot_count
1506        && a.tie_start == b.tie_start
1507        && a.tie_end == b.tie_end
1508        && a.beam == b.beam
1509        && a.articulations == b.articulations
1510        && a.dynamic == b.dynamic
1511        && a.stem_up == b.stem_up
1512        && a.hairpin_start == b.hairpin_start
1513        && a.hairpin_end == b.hairpin_end
1514        && a.tuplet == b.tuplet
1515        && a.chord_symbol == b.chord_symbol
1516        && a.is_grace == b.is_grace
1517        && a.grace_slash == b.grace_slash
1518        && a.ottava_start == b.ottava_start
1519        && a.ottava_end == b.ottava_end
1520        && a.lyric == b.lyric
1521        && a.pedal_start == b.pedal_start
1522        && a.pedal_end == b.pedal_end
1523        && a.slur_start == b.slur_start
1524        && a.slur_end == b.slur_end
1525        && a.arpeggiate == b.arpeggiate
1526}
1527
1528#[cfg(test)]
1529mod tests {
1530    use super::*;
1531    use crate::model::pitch::Step;
1532
1533    #[test]
1534    fn default_score_has_one_part_four_measures() {
1535        let score = Score::default();
1536        assert_eq!(score.parts.len(), 1);
1537        assert_eq!(score.parts[0].staves.len(), 1);
1538        assert_eq!(score.parts[0].staves[0].measures.len(), 4);
1539    }
1540
1541    #[test]
1542    fn new_score_measure_count() {
1543        let score = Score::new("Test", 120, 4, 4, 0, 8);
1544        assert_eq!(score.measure_count(), 8);
1545    }
1546
1547    #[test]
1548    fn note_beats_quarter() {
1549        let note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1550        assert!((note.beats() - 1.0).abs() < 1e-9);
1551    }
1552
1553    #[test]
1554    fn note_beats_dotted_quarter() {
1555        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1556        note.dot_count = 1;
1557        assert!((note.beats() - 1.5).abs() < 1e-9);
1558    }
1559
1560    #[test]
1561    fn grace_note_beats_zero() {
1562        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Eighth);
1563        note.is_grace = true;
1564        assert_eq!(note.beats(), 0.0);
1565    }
1566
1567    #[test]
1568    fn measure_empty_4_4_fills_four_beats() {
1569        let m = Measure::empty(4, 4);
1570        let total: f64 = m.voices[0].iter().map(|n| n.beats()).sum();
1571        assert!((total - 4.0).abs() < 1e-9);
1572    }
1573
1574    #[test]
1575    fn measure_empty_3_4_fills_three_beats() {
1576        let m = Measure::empty(3, 4);
1577        let total: f64 = m.voices[0].iter().map(|n| n.beats()).sum();
1578        assert!((total - 3.0).abs() < 1e-9);
1579    }
1580
1581    #[test]
1582    fn whole_filling_beats() {
1583        assert_eq!(Duration::whole_filling_beats(4.0), Duration::Whole);
1584        assert_eq!(Duration::whole_filling_beats(2.0), Duration::Half);
1585        assert_eq!(Duration::whole_filling_beats(1.0), Duration::Quarter);
1586    }
1587
1588    // ── ScoreStats ────────────────────────────────────────────────────────────
1589
1590    #[test]
1591    fn statistics_default_score_all_rests() {
1592        let score = Score::default();
1593        let s = score.statistics();
1594        assert_eq!(s.part_count, 1);
1595        assert_eq!(s.measure_count, 4);
1596        assert_eq!(s.note_count, 0);
1597        assert!(s.rest_count > 0);
1598    }
1599
1600    #[test]
1601    fn statistics_duration_estimate() {
1602        // 4/4, 120 BPM, 1 measure → 4 beats → 2.0 s
1603        let score = Score::new("T", 120, 4, 4, 0, 1);
1604        let s = score.statistics();
1605        assert!((s.estimated_duration_secs - 2.0).abs() < 0.01);
1606    }
1607
1608    #[test]
1609    fn score_duration_secs_matches_statistics() {
1610        use super::score_duration_secs;
1611        let score = Score::new("T", 120, 4, 4, 0, 4);
1612        let secs = score_duration_secs(&score);
1613        // 4/4, 120 BPM, 4 measures → 16 beats → 8.0 s
1614        assert!((secs - 8.0).abs() < 0.01, "expected ~8.0 s, got {secs}");
1615    }
1616
1617    #[test]
1618    fn score_duration_secs_zero_bpm_returns_zero() {
1619        use super::score_duration_secs;
1620        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1621        score.settings.tempo_bpm = 0;
1622        assert_eq!(score_duration_secs(&score), 0.0);
1623    }
1624
1625    #[test]
1626    fn score_duration_secs_per_measure_tempo() {
1627        use super::score_duration_secs;
1628        // 2 measures: measure 0 at 120 BPM (2.0 s), measure 1 at 60 BPM (4.0 s)
1629        let mut score = Score::new("T", 120, 4, 4, 0, 2);
1630        score.parts[0].staves[0].measures[1].tempo = Some(60);
1631        let secs = score_duration_secs(&score);
1632        assert!((secs - 6.0).abs() < 0.01, "expected ~6.0 s, got {secs}");
1633    }
1634
1635    // ── extract_part ──────────────────────────────────────────────────────────
1636
1637    #[test]
1638    fn extract_part_returns_single_part_score() {
1639        let mut score = Score::default();
1640        let mut p2 = Part::new("Violin", "Vln.");
1641        p2.staves.push(Staff::new(Clef::Treble));
1642        score.parts.push(p2);
1643        let ex = score.extract_part(0).unwrap();
1644        assert_eq!(ex.parts.len(), 1);
1645        assert_ne!(ex.id, score.id);
1646        assert_eq!(ex.metadata.title, score.metadata.title);
1647    }
1648
1649    #[test]
1650    fn extract_part_out_of_range_is_none() {
1651        let score = Score::default();
1652        assert!(score.extract_part(99).is_none());
1653    }
1654
1655    // ── transpose ─────────────────────────────────────────────────────────────
1656
1657    #[test]
1658    fn transpose_zero_is_clone() {
1659        let score = Score::new("T", 120, 4, 4, 0, 1);
1660        let t = transpose(&score, 0);
1661        assert_eq!(t.settings.key_signature.fifths, 0);
1662    }
1663
1664    #[test]
1665    fn transpose_c_major_up_2_to_d_major() {
1666        let score = Score::new("T", 120, 4, 4, 0, 1);
1667        assert_eq!(transpose(&score, 2).settings.key_signature.fifths, 2);
1668    }
1669
1670    #[test]
1671    fn transpose_d_major_up_5_to_g_major() {
1672        let score = Score::new("T", 120, 4, 4, 2, 1);
1673        assert_eq!(transpose(&score, 5).settings.key_signature.fifths, 1);
1674    }
1675
1676    #[test]
1677    fn transpose_c4_up_1_to_csharp4() {
1678        let p = transpose_pitch(&Pitch::new(Step::C, 4), 1);
1679        assert_eq!(p.to_midi(), 61);
1680        assert_eq!(p.step, Step::C);
1681        assert_eq!(p.alter, 1);
1682    }
1683
1684    #[test]
1685    fn transpose_c4_down_1_to_b3() {
1686        let p = transpose_pitch(&Pitch::new(Step::C, 4), -1);
1687        assert_eq!(p.to_midi(), 59);
1688        assert_eq!(p.step, Step::B);
1689        assert_eq!(p.alter, 0);
1690    }
1691
1692    #[test]
1693    fn transpose_up_octave_keeps_step() {
1694        let p = transpose_pitch(&Pitch::new(Step::A, 4), 12);
1695        assert_eq!(p.to_midi(), 81);
1696        assert_eq!(p.step, Step::A);
1697        assert_eq!(p.octave, 5);
1698    }
1699
1700    #[test]
1701    fn statistics_with_repeat_doubles_duration() {
1702        // 4/4, 120 BPM, 2 measures with RepeatStart+RepeatEnd → plays twice → 4 measures worth
1703        let mut score = Score::new("T", 120, 4, 4, 0, 2);
1704        score.parts[0].staves[0].measures[0].barline_left =
1705            crate::model::notation::Barline::RepeatStart;
1706        score.parts[0].staves[0].measures[1].barline_right =
1707            crate::model::notation::Barline::RepeatEnd;
1708        let s = score.statistics();
1709        // 4 beats × 4 measures (2 physical × 2 passes) ÷ 120 BPM × 60 = 8.0 s
1710        assert!((s.estimated_duration_secs - 8.0).abs() < 0.01);
1711    }
1712
1713    #[test]
1714    fn transpose_octave_boundary_b4_to_c5() {
1715        // B4 (midi=71) + 1 semitone = C5 (midi=72)
1716        let p = transpose_pitch(&Pitch::new(Step::B, 4), 1);
1717        assert_eq!(p.to_midi(), 72);
1718        assert_eq!(p.step, Step::C);
1719        assert_eq!(p.octave, 5);
1720    }
1721
1722    #[test]
1723    fn transpose_clamp_at_midi_127() {
1724        // G9 (midi=127) + 3 semitones → clamped to 127
1725        let p = transpose_pitch(&Pitch::new(Step::G, 9), 3);
1726        assert_eq!(p.to_midi(), 127);
1727    }
1728
1729    // ── merge ─────────────────────────────────────────────────────────────────
1730
1731    #[test]
1732    fn merge_combines_parts() {
1733        let mut a = Score::new("A", 120, 4, 4, 0, 2);
1734        let b = Score::new("B", 120, 4, 4, 0, 2);
1735        // Add a second part to score a
1736        let mut p2 = Part::new("Violin", "Vln.");
1737        p2.staves.push(Staff::new(Clef::Treble));
1738        for i in 0..2usize {
1739            let mut m = Measure::empty(4, 4);
1740            m.number = i as u32 + 1;
1741            p2.staves[0].measures.push(m);
1742        }
1743        a.parts.push(p2);
1744        let merged = a.merge(&b);
1745        // a has 2 parts, b has 1 part → merged has 3 parts
1746        assert_eq!(merged.parts.len(), 3);
1747    }
1748
1749    #[test]
1750    fn merge_pads_shorter_score() {
1751        let a = Score::new("A", 120, 4, 4, 0, 4);
1752        let b = Score::new("B", 120, 4, 4, 0, 2);
1753        let merged = a.merge(&b);
1754        // Both parts should have 4 measures
1755        assert_eq!(merged.parts[0].staves[0].measures.len(), 4);
1756        assert_eq!(merged.parts[1].staves[0].measures.len(), 4);
1757    }
1758
1759    #[test]
1760    fn merge_uses_self_metadata() {
1761        let mut a = Score::new("Title A", 120, 4, 4, 0, 2);
1762        a.metadata.composer = "Composer A".to_string();
1763        let b = Score::new("Title B", 120, 4, 4, 0, 2);
1764        let merged = a.merge(&b);
1765        assert_eq!(merged.metadata.title, "Title A");
1766        assert_eq!(merged.metadata.composer, "Composer A");
1767    }
1768
1769    #[test]
1770    fn merge_new_id_differs_from_both() {
1771        let a = Score::new("A", 120, 4, 4, 0, 2);
1772        let b = Score::new("B", 120, 4, 4, 0, 2);
1773        let merged = a.merge(&b);
1774        assert_ne!(merged.id, a.id);
1775        assert_ne!(merged.id, b.id);
1776    }
1777
1778    // ── Staff.transpose_semitones ─────────────────────────────────────────────
1779
1780    #[test]
1781    fn staff_default_transpose_is_zero() {
1782        let s = Staff::new(Clef::Treble);
1783        assert_eq!(s.transpose_semitones, 0);
1784    }
1785
1786    // ── schema_version ────────────────────────────────────────────────────────
1787
1788    #[test]
1789    fn score_default_has_schema_version_1() {
1790        let score = Score::default();
1791        assert_eq!(score.schema_version, 1);
1792    }
1793
1794    #[test]
1795    fn score_new_has_schema_version_1() {
1796        let score = Score::new("T", 120, 4, 4, 0, 4);
1797        assert_eq!(score.schema_version, 1);
1798    }
1799
1800    #[test]
1801    fn score_without_schema_version_deserializes_to_zero() {
1802        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":[]}"#;
1803        let score: Score = serde_json::from_str(json).unwrap();
1804        assert_eq!(score.schema_version, 0);
1805    }
1806
1807    // ── ScoreTemplate ─────────────────────────────────────────────────────────
1808
1809    #[test]
1810    fn score_template_solo_has_one_part_treble() {
1811        let score = Score::template(ScoreTemplate::Solo);
1812        assert_eq!(score.parts.len(), 1);
1813        assert_eq!(score.parts[0].staves.len(), 1);
1814        assert_eq!(score.parts[0].staves[0].clef, Clef::Treble);
1815        assert_eq!(score.parts[0].midi_program, 0);
1816    }
1817
1818    #[test]
1819    fn score_template_piano_has_two_staves() {
1820        let score = Score::template(ScoreTemplate::Piano);
1821        assert_eq!(score.parts.len(), 1);
1822        assert_eq!(score.parts[0].staves.len(), 2);
1823        assert_eq!(score.parts[0].staves[0].clef, Clef::Treble);
1824        assert_eq!(score.parts[0].staves[1].clef, Clef::Bass);
1825    }
1826
1827    #[test]
1828    fn score_template_string_quartet_has_four_parts() {
1829        let score = Score::template(ScoreTemplate::StringQuartet);
1830        assert_eq!(score.parts.len(), 4);
1831        assert_eq!(score.parts[2].staves[0].clef, Clef::Alto); // Viola
1832        assert_eq!(score.parts[3].staves[0].clef, Clef::Bass); // Cello
1833        assert_eq!(score.parts[0].midi_program, 40);
1834        assert_eq!(score.parts[3].midi_program, 42);
1835    }
1836
1837    #[test]
1838    fn score_template_string_orchestra_has_five_parts() {
1839        let score = Score::template(ScoreTemplate::StringOrchestra);
1840        assert_eq!(score.parts.len(), 5);
1841        assert_eq!(score.parts[4].midi_program, 43); // Contrabass
1842    }
1843
1844    #[test]
1845    fn score_template_brass_quintet_has_five_parts() {
1846        let score = Score::template(ScoreTemplate::BrassQuintet);
1847        assert_eq!(score.parts.len(), 5);
1848        assert_eq!(score.parts[2].midi_program, 60); // French Horn
1849    }
1850
1851    #[test]
1852    fn score_template_default_measures_are_four() {
1853        let score = Score::template(ScoreTemplate::StringQuartet);
1854        for part in &score.parts {
1855            for staff in &part.staves {
1856                assert_eq!(staff.measures.len(), 4);
1857            }
1858        }
1859    }
1860
1861    // ── system_break / page_break ─────────────────────────────────────────────
1862
1863    #[test]
1864    fn measure_empty_has_no_breaks() {
1865        let m = Measure::empty(4, 4);
1866        assert!(!m.system_break);
1867        assert!(!m.page_break);
1868    }
1869
1870    #[test]
1871    fn system_break_survives_json_roundtrip() {
1872        let mut m = Measure::empty(4, 4);
1873        m.system_break = true;
1874        let json = serde_json::to_string(&m).unwrap();
1875        let m2: Measure = serde_json::from_str(&json).unwrap();
1876        assert!(m2.system_break);
1877        assert!(!m2.page_break);
1878    }
1879
1880    // ── diff ──────────────────────────────────────────────────────────────────
1881
1882    #[test]
1883    fn diff_identical_scores_is_empty() {
1884        let s = Score::new("T", 120, 4, 4, 0, 2);
1885        assert!(diff(&s, &s).is_empty());
1886    }
1887
1888    #[test]
1889    fn diff_detects_tempo_change() {
1890        let a = Score::new("T", 120, 4, 4, 0, 1);
1891        let mut b = a.clone();
1892        b.settings.tempo_bpm = 90;
1893        let changes = diff(&a, &b);
1894        assert_eq!(changes.len(), 1);
1895        assert!(matches!(
1896            changes[0],
1897            ScoreChange::TempoChanged { old: 120, new: 90 }
1898        ));
1899    }
1900
1901    #[test]
1902    fn diff_detects_title_change() {
1903        let a = Score::new("Old Title", 120, 4, 4, 0, 1);
1904        let mut b = a.clone();
1905        b.metadata.title = "New Title".to_string();
1906        let changes = diff(&a, &b);
1907        assert!(
1908            changes.iter().any(
1909                |c| matches!(c, ScoreChange::MetadataChanged { field, .. } if field == "title")
1910            )
1911        );
1912    }
1913
1914    #[test]
1915    fn diff_detects_note_modification() {
1916        let mut a = Score::new("T", 120, 4, 4, 0, 1);
1917        a.parts[0].staves[0].measures[0].voices[0] =
1918            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
1919        let mut b = a.clone();
1920        b.parts[0].staves[0].measures[0].voices[0][0] =
1921            Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
1922        let changes = diff(&a, &b);
1923        assert!(
1924            changes
1925                .iter()
1926                .any(|c| matches!(c, ScoreChange::NoteModified { .. }))
1927        );
1928    }
1929
1930    #[test]
1931    fn diff_detects_part_added() {
1932        let a = Score::new("T", 120, 4, 4, 0, 1);
1933        let mut b = a.clone();
1934        let mut p = Part::new("Violin", "Vln.");
1935        p.staves.push(Staff::new(Clef::Treble));
1936        b.parts.push(p);
1937        let changes = diff(&a, &b);
1938        assert!(
1939            changes
1940                .iter()
1941                .any(|c| matches!(c, ScoreChange::PartAdded { part_index: 1 }))
1942        );
1943    }
1944
1945    #[test]
1946    fn diff_detects_measure_tempo_change() {
1947        let a = Score::new("T", 120, 4, 4, 0, 2);
1948        let mut b = a.clone();
1949        b.parts[0].staves[0].measures[1].tempo = Some(60);
1950        let changes = diff(&a, &b);
1951        assert!(changes.iter().any(|c| matches!(
1952            c,
1953            ScoreChange::MeasureTempoChanged {
1954                measure: 1,
1955                old: None,
1956                new: Some(60),
1957                ..
1958            }
1959        )));
1960    }
1961
1962    #[test]
1963    fn diff_detects_barline_change() {
1964        use crate::model::notation::Barline;
1965        let a = Score::new("T", 120, 4, 4, 0, 2);
1966        let mut b = a.clone();
1967        b.parts[0].staves[0].measures[0].barline_left = Barline::RepeatStart;
1968        let changes = diff(&a, &b);
1969        assert!(
1970            changes
1971                .iter()
1972                .any(|c| matches!(c, ScoreChange::BarlineChanged { measure: 0, .. }))
1973        );
1974    }
1975
1976    #[test]
1977    fn diff_detects_rehearsal_change() {
1978        let a = Score::new("T", 120, 4, 4, 0, 2);
1979        let mut b = a.clone();
1980        b.parts[0].staves[0].measures[0].rehearsal = Some("A".to_string());
1981        let changes = diff(&a, &b);
1982        assert!(
1983            changes
1984                .iter()
1985                .any(|c| matches!(c, ScoreChange::RehearsalMarkChanged { measure: 0, .. }))
1986        );
1987    }
1988
1989    #[test]
1990    fn diff_detects_volta_change() {
1991        use super::VoltaBracket;
1992        let a = Score::new("T", 120, 4, 4, 0, 2);
1993        let mut b = a.clone();
1994        b.parts[0].staves[0].measures[0].volta = Some(VoltaBracket {
1995            number: 1,
1996            kind: "begin_end".into(),
1997        });
1998        let changes = diff(&a, &b);
1999        assert!(
2000            changes
2001                .iter()
2002                .any(|c| matches!(c, ScoreChange::VoltaChanged { measure: 0, .. }))
2003        );
2004    }
2005
2006    #[test]
2007    fn diff_detects_key_signature_change() {
2008        let a = Score::new("T", 120, 4, 4, 0, 1);
2009        let mut b = a.clone();
2010        b.settings.key_signature.fifths = 2; // C major → D major
2011        let changes = diff(&a, &b);
2012        assert!(
2013            changes
2014                .iter()
2015                .any(|c| matches!(c, ScoreChange::KeySignatureChanged { .. }))
2016        );
2017    }
2018
2019    #[test]
2020    fn diff_same_key_signature_no_change() {
2021        let a = Score::new("T", 120, 4, 4, 2, 1);
2022        let changes = diff(&a, &a);
2023        assert!(changes.is_empty());
2024    }
2025
2026    #[test]
2027    fn score_duration_secs_region_partial() {
2028        use super::score_duration_secs_region;
2029        // 4/4, 120 BPM, 4 measures → each measure = 2.0 s; region [1,2] = 4.0 s
2030        let score = Score::new("T", 120, 4, 4, 0, 4);
2031        let secs = score_duration_secs_region(&score, (1, 2));
2032        assert!((secs - 4.0).abs() < 0.01, "expected ~4.0 s, got {secs}");
2033    }
2034
2035    #[test]
2036    fn score_duration_secs_region_single_measure() {
2037        use super::score_duration_secs_region;
2038        // 4/4, 120 BPM → 1 measure = 2.0 s
2039        let score = Score::new("T", 120, 4, 4, 0, 4);
2040        let secs = score_duration_secs_region(&score, (0, 0));
2041        assert!((secs - 2.0).abs() < 0.01, "expected ~2.0 s, got {secs}");
2042    }
2043
2044    // ── measure_beats_remaining ───────────────────────────────────────────────
2045
2046    #[test]
2047    fn measure_beats_remaining_empty_voice_returns_full() {
2048        use super::measure_beats_remaining;
2049        let mut score = Score::new("T", 120, 4, 4, 0, 1);
2050        score.parts[0].staves[0].measures[0].voices[0].clear();
2051        let rem = measure_beats_remaining(&score, 0, 0, 0, 0).unwrap();
2052        assert!(
2053            (rem - 4.0).abs() < 1e-9,
2054            "expected 4.0 remaining, got {rem}"
2055        );
2056    }
2057
2058    #[test]
2059    fn measure_beats_remaining_half_full_returns_half() {
2060        use super::measure_beats_remaining;
2061        use crate::model::pitch::Step;
2062        let mut score = Score::new("T", 120, 4, 4, 0, 1);
2063        score.parts[0].staves[0].measures[0].voices[0] = vec![
2064            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
2065            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
2066        ];
2067        let rem = measure_beats_remaining(&score, 0, 0, 0, 0).unwrap();
2068        assert!(
2069            (rem - 2.0).abs() < 1e-9,
2070            "expected 2.0 remaining, got {rem}"
2071        );
2072    }
2073
2074    #[test]
2075    fn measure_beats_remaining_full_voice_returns_zero() {
2076        use super::measure_beats_remaining;
2077        use crate::model::pitch::Step;
2078        let mut score = Score::new("T", 120, 4, 4, 0, 1);
2079        score.parts[0].staves[0].measures[0].voices[0] =
2080            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
2081        let rem = measure_beats_remaining(&score, 0, 0, 0, 0).unwrap();
2082        assert!((rem).abs() < 1e-9, "expected 0.0 remaining, got {rem}");
2083    }
2084
2085    #[test]
2086    fn measure_beats_remaining_tuplet_accounting() {
2087        use super::measure_beats_remaining;
2088        use crate::model::notation::TupletInfo;
2089        use crate::model::pitch::Step;
2090        // 3 quarter-note triplets each take 2/3 of a beat → total 2.0 beats used → 2.0 remaining
2091        let mut score = Score::new("T", 120, 4, 4, 0, 1);
2092        let tuplet = TupletInfo {
2093            actual_notes: 3,
2094            normal_notes: 2,
2095        };
2096        let mk = |step| {
2097            let mut n = Note::new(Pitch::new(step, 4), Duration::Quarter);
2098            n.tuplet = Some(tuplet.clone());
2099            n
2100        };
2101        score.parts[0].staves[0].measures[0].voices[0] =
2102            vec![mk(Step::C), mk(Step::D), mk(Step::E)];
2103        let rem = measure_beats_remaining(&score, 0, 0, 0, 0).unwrap();
2104        assert!(
2105            (rem - 2.0).abs() < 1e-9,
2106            "expected 2.0 remaining (triplets used 2.0), got {rem}"
2107        );
2108    }
2109
2110    #[test]
2111    fn measure_beats_remaining_out_of_range_returns_err() {
2112        use super::measure_beats_remaining;
2113        let score = Score::new("T", 120, 4, 4, 0, 1);
2114        assert!(measure_beats_remaining(&score, 99, 0, 0, 0).is_err());
2115        assert!(measure_beats_remaining(&score, 0, 99, 0, 0).is_err());
2116        assert!(measure_beats_remaining(&score, 0, 0, 99, 0).is_err());
2117        assert!(measure_beats_remaining(&score, 0, 0, 0, 4).is_err());
2118    }
2119
2120    #[test]
2121    fn note_content_eq_ignores_id() {
2122        use crate::model::pitch::Step;
2123        let mut a = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2124        let mut b = a.clone();
2125        b.id = "different-id".to_string();
2126        assert!(note_content_eq(&a, &b));
2127        // Actual pitch change should differ
2128        b.pitches[0] = Pitch::new(Step::D, 4);
2129        assert!(!note_content_eq(&a, &b));
2130        // stem_up difference
2131        let mut c = a.clone();
2132        a.stem_up = Some(true);
2133        c.stem_up = Some(false);
2134        assert!(!note_content_eq(&a, &c));
2135    }
2136
2137    #[test]
2138    fn suggested_stem_up_below_middle() {
2139        use crate::model::notation::Clef;
2140        // C4 = MIDI 60, Treble middle = B4 = 71 → stem up
2141        let pitches = vec![Pitch::new(Step::C, 4)];
2142        assert!(suggested_stem_up(&pitches, &Clef::Treble));
2143    }
2144
2145    #[test]
2146    fn suggested_stem_up_above_middle() {
2147        use crate::model::notation::Clef;
2148        // G5 = MIDI 79, Treble middle = 71 → stem down
2149        let pitches = vec![Pitch::new(Step::G, 5)];
2150        assert!(!suggested_stem_up(&pitches, &Clef::Treble));
2151    }
2152
2153    #[test]
2154    fn suggested_stem_up_at_middle_line() {
2155        use crate::model::notation::Clef;
2156        // B4 = MIDI 71, Treble middle = 71 → stem down (avg >= middle)
2157        let pitches = vec![Pitch::new(Step::B, 4)];
2158        assert!(!suggested_stem_up(&pitches, &Clef::Treble));
2159    }
2160
2161    #[test]
2162    fn suggested_stem_up_chord() {
2163        use crate::model::notation::Clef;
2164        // [C4=60, G4=67] avg=63.5 < 71 → stem up
2165        let pitches = vec![Pitch::new(Step::C, 4), Pitch::new(Step::G, 4)];
2166        assert!(suggested_stem_up(&pitches, &Clef::Treble));
2167    }
2168
2169    #[test]
2170    fn suggested_stem_up_bass_clef() {
2171        use crate::model::notation::Clef;
2172        // D3=50 is exactly at Bass middle line → stem down
2173        let pitches = vec![Pitch::new(Step::D, 3)];
2174        assert!(!suggested_stem_up(&pitches, &Clef::Bass));
2175        // C3=48 < 50 → stem up
2176        let pitches2 = vec![Pitch::new(Step::C, 3)];
2177        assert!(suggested_stem_up(&pitches2, &Clef::Bass));
2178    }
2179
2180    #[test]
2181    fn suggested_stem_up_empty_pitches() {
2182        use crate::model::notation::Clef;
2183        assert!(suggested_stem_up(&[], &Clef::Treble));
2184    }
2185
2186    fn eighth(pitch: Pitch) -> Note {
2187        Note::new(pitch, Duration::Eighth)
2188    }
2189    fn quarter(pitch: Pitch) -> Note {
2190        Note::new(pitch, Duration::Quarter)
2191    }
2192    fn rest_eighth() -> Note {
2193        Note::rest(Duration::Eighth)
2194    }
2195
2196    #[test]
2197    fn compute_beams_4_4_four_eighths() {
2198        use crate::model::notation::{Clef, TimeSignature};
2199        let _ = Clef::Treble; // suppress unused import warning
2200        let ts = TimeSignature {
2201            numerator: 4,
2202            denominator: 4,
2203        };
2204        let c4 = Pitch::new(Step::C, 4);
2205        let notes = vec![
2206            eighth(c4.clone()),
2207            eighth(c4.clone()),
2208            eighth(c4.clone()),
2209            eighth(c4.clone()),
2210        ];
2211        let beams = compute_beams(&notes, &ts);
2212        // 4 eighths in 4/4: beat size=1.0, two groups of 2 each
2213        assert_eq!(beams[0], BeamState::Begin);
2214        assert_eq!(beams[1], BeamState::End);
2215        assert_eq!(beams[2], BeamState::Begin);
2216        assert_eq!(beams[3], BeamState::End);
2217    }
2218
2219    #[test]
2220    fn compute_beams_4_4_all_eighth_one_group() {
2221        use crate::model::notation::TimeSignature;
2222        let ts = TimeSignature {
2223            numerator: 4,
2224            denominator: 4,
2225        };
2226        let c4 = Pitch::new(Step::C, 4);
2227        // 2 eighths in a beat → group of 2
2228        let notes = vec![eighth(c4.clone()), eighth(c4.clone())];
2229        let beams = compute_beams(&notes, &ts);
2230        assert_eq!(beams[0], BeamState::Begin);
2231        assert_eq!(beams[1], BeamState::End);
2232    }
2233
2234    #[test]
2235    fn compute_beams_quarter_not_beamed() {
2236        use crate::model::notation::TimeSignature;
2237        let ts = TimeSignature {
2238            numerator: 4,
2239            denominator: 4,
2240        };
2241        let c4 = Pitch::new(Step::C, 4);
2242        let notes = vec![quarter(c4.clone()), quarter(c4.clone())];
2243        let beams = compute_beams(&notes, &ts);
2244        assert_eq!(beams[0], BeamState::None);
2245        assert_eq!(beams[1], BeamState::None);
2246    }
2247
2248    #[test]
2249    fn compute_beams_rest_breaks_beam() {
2250        use crate::model::notation::TimeSignature;
2251        let ts = TimeSignature {
2252            numerator: 4,
2253            denominator: 4,
2254        };
2255        let c4 = Pitch::new(Step::C, 4);
2256        let notes = vec![eighth(c4.clone()), rest_eighth(), eighth(c4.clone())];
2257        let beams = compute_beams(&notes, &ts);
2258        // rest breaks beam group
2259        assert_eq!(beams[0], BeamState::None);
2260        assert_eq!(beams[1], BeamState::None);
2261        assert_eq!(beams[2], BeamState::None);
2262    }
2263
2264    #[test]
2265    fn compute_beams_6_8_compound() {
2266        use crate::model::notation::TimeSignature;
2267        let ts = TimeSignature {
2268            numerator: 6,
2269            denominator: 8,
2270        };
2271        let c4 = Pitch::new(Step::C, 4);
2272        // 6 eighths in 6/8 compound → two groups of 3 (beam size=1.5 beats)
2273        let notes: Vec<Note> = (0..6).map(|_| eighth(c4.clone())).collect();
2274        let beams = compute_beams(&notes, &ts);
2275        assert_eq!(beams[0], BeamState::Begin);
2276        assert_eq!(beams[1], BeamState::Continue);
2277        assert_eq!(beams[2], BeamState::End);
2278        assert_eq!(beams[3], BeamState::Begin);
2279        assert_eq!(beams[4], BeamState::Continue);
2280        assert_eq!(beams[5], BeamState::End);
2281    }
2282
2283    #[test]
2284    fn compute_beams_single_eighth() {
2285        use crate::model::notation::TimeSignature;
2286        let ts = TimeSignature {
2287            numerator: 4,
2288            denominator: 4,
2289        };
2290        let c4 = Pitch::new(Step::C, 4);
2291        let notes = vec![eighth(c4.clone())];
2292        let beams = compute_beams(&notes, &ts);
2293        assert_eq!(beams[0], BeamState::None);
2294    }
2295}