Skip to main content

acorde_core/model/
score.rs

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