Skip to main content

acorde_core/model/
notation.rs

1use super::score::NoteAddr;
2use serde::{Deserialize, Serialize};
3
4use super::pitch::{Pitch, Step};
5
6#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
7pub enum Clef {
8    Treble,
9    Bass,
10    Alto,
11    Tenor,
12    Percussion,
13}
14
15impl Clef {
16    pub fn to_musicxml_sign(&self) -> &'static str {
17        match self {
18            Clef::Treble => "G",
19            Clef::Bass => "F",
20            Clef::Alto => "C",
21            Clef::Tenor => "C",
22            Clef::Percussion => "percussion",
23        }
24    }
25
26    pub fn musicxml_line(&self) -> u8 {
27        match self {
28            Clef::Treble => 2,
29            Clef::Bass => 4,
30            Clef::Alto => 3,
31            Clef::Tenor => 4,
32            Clef::Percussion => 2,
33        }
34    }
35
36    /// MIDI note number of the middle staff line (used for stem direction heuristics).
37    pub fn middle_line_midi(&self) -> u8 {
38        match self {
39            Clef::Treble => 71,     // B4
40            Clef::Bass => 50,       // D3
41            Clef::Alto => 60,       // C4
42            Clef::Tenor => 57,      // A3
43            Clef::Percussion => 71, // B4 (same as Treble)
44        }
45    }
46}
47
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49pub struct KeySignature {
50    /// -7 (7 flats) to +7 (7 sharps), 0 = C major / A minor.
51    pub fifths: i8,
52    /// "major" or "minor"
53    pub mode: String,
54}
55
56impl Default for KeySignature {
57    fn default() -> Self {
58        Self {
59            fifths: 0,
60            mode: "major".to_string(),
61        }
62    }
63}
64
65impl KeySignature {
66    // Order of sharps added one by one as fifths increases: F C G D A E B
67    const SHARP_ORDER: [Step; 7] = [
68        Step::F,
69        Step::C,
70        Step::G,
71        Step::D,
72        Step::A,
73        Step::E,
74        Step::B,
75    ];
76    // Order of flats: B E A D G C F
77    const FLAT_ORDER: [Step; 7] = [
78        Step::B,
79        Step::E,
80        Step::A,
81        Step::D,
82        Step::G,
83        Step::C,
84        Step::F,
85    ];
86
87    /// Accidental alter (`-1`, `0`, or `+1`) applied to `step` in this key signature.
88    pub fn alter_for_step(&self, step: &Step) -> i8 {
89        if self.fifths > 0 {
90            let count = self.fifths.min(7) as usize;
91            if Self::SHARP_ORDER[..count].contains(step) {
92                1
93            } else {
94                0
95            }
96        } else if self.fifths < 0 {
97            let count = (-self.fifths).min(7) as usize;
98            if Self::FLAT_ORDER[..count].contains(step) {
99                -1
100            } else {
101                0
102            }
103        } else {
104            0
105        }
106    }
107
108    /// True if `pitch` is diatonic to this key (octave-independent, checks step + alter).
109    pub fn contains_pitch(&self, pitch: &Pitch) -> bool {
110        pitch.alter == self.alter_for_step(&pitch.step)
111    }
112
113    /// Tonic step and alter for this key.
114    ///
115    /// Examples: G major → `(Step::G, 0)`, Bb major → `(Step::B, -1)`, F# minor → `(Step::F, 1)`.
116    pub fn tonic(&self) -> (Step, i8) {
117        if self.mode == "minor" {
118            let (maj_step, maj_alter) = Self::major_tonic_from_fifths(self.fifths);
119            // Relative minor tonic = major tonic - 3 semitones.
120            let major_midi = Pitch::with_alter(maj_step, 4, maj_alter).to_midi();
121            let minor_midi = (major_midi - 3).clamp(0, 127) as u8;
122            let p = Pitch::from_midi(minor_midi, self.fifths < 0);
123            (p.step, p.alter)
124        } else {
125            Self::major_tonic_from_fifths(self.fifths)
126        }
127    }
128
129    /// Human-readable key name: `"C major"`, `"G major"`, `"F# minor"`, `"Bb major"` etc.
130    pub fn display_name(&self) -> String {
131        let (step, alter) = self.tonic();
132        let acc = match alter {
133            1 => "#",
134            -1 => "b",
135            _ => "",
136        };
137        format!("{}{} {}", step.to_char(), acc, self.mode)
138    }
139
140    fn major_tonic_from_fifths(fifths: i8) -> (Step, i8) {
141        // Index = fifths + 7 (range 0..=14).
142        // -7=Cb, -6=Gb, -5=Db, -4=Ab, -3=Eb, -2=Bb, -1=F, 0=C, +1=G, +2=D, +3=A, +4=E, +5=B, +6=F#, +7=C#
143        const TONICS: [(Step, i8); 15] = [
144            (Step::C, -1), // -7: Cb
145            (Step::G, -1), // -6: Gb
146            (Step::D, -1), // -5: Db
147            (Step::A, -1), // -4: Ab
148            (Step::E, -1), // -3: Eb
149            (Step::B, -1), // -2: Bb
150            (Step::F, 0),  // -1: F
151            (Step::C, 0),  //  0: C
152            (Step::G, 0),  // +1: G
153            (Step::D, 0),  // +2: D
154            (Step::A, 0),  // +3: A
155            (Step::E, 0),  // +4: E
156            (Step::B, 0),  // +5: B
157            (Step::F, 1),  // +6: F#
158            (Step::C, 1),  // +7: C#
159        ];
160        let idx = (fifths.clamp(-7, 7) + 7) as usize;
161        TONICS[idx].clone()
162    }
163}
164
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
166pub struct TimeSignature {
167    pub numerator: u8,
168    pub denominator: u8,
169}
170
171impl Default for TimeSignature {
172    fn default() -> Self {
173        Self {
174            numerator: 4,
175            denominator: 4,
176        }
177    }
178}
179
180impl TimeSignature {
181    pub fn beats_per_measure(&self) -> f64 {
182        self.numerator as f64
183    }
184
185    pub fn beat_unit_beats(&self) -> f64 {
186        4.0 / self.denominator as f64
187    }
188
189    pub fn total_beats(&self) -> f64 {
190        self.beats_per_measure() * self.beat_unit_beats()
191    }
192}
193
194#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
195pub enum Dynamic {
196    Pppp,
197    Ppp,
198    Pp,
199    P,
200    Mp,
201    Mf,
202    F,
203    Ff,
204    Fff,
205    Ffff,
206    Sfz,
207    Rfz,
208    Fz,
209    Sf,
210}
211
212impl Dynamic {
213    pub fn to_musicxml_str(&self) -> &'static str {
214        match self {
215            Dynamic::Pppp => "pppp",
216            Dynamic::Ppp => "ppp",
217            Dynamic::Pp => "pp",
218            Dynamic::P => "p",
219            Dynamic::Mp => "mp",
220            Dynamic::Mf => "mf",
221            Dynamic::F => "f",
222            Dynamic::Ff => "ff",
223            Dynamic::Fff => "fff",
224            Dynamic::Ffff => "ffff",
225            Dynamic::Sfz => "sfz",
226            Dynamic::Rfz => "rfz",
227            Dynamic::Fz => "fz",
228            Dynamic::Sf => "sf",
229        }
230    }
231
232    pub fn to_velocity(&self) -> u8 {
233        match self {
234            Dynamic::Pppp => 16,
235            Dynamic::Ppp => 24,
236            Dynamic::Pp => 36,
237            Dynamic::P => 48,
238            Dynamic::Mp => 60,
239            Dynamic::Mf => 72,
240            Dynamic::F => 84,
241            Dynamic::Ff => 96,
242            Dynamic::Fff => 108,
243            Dynamic::Ffff => 120,
244            Dynamic::Sfz => 112,
245            Dynamic::Rfz => 104,
246            Dynamic::Fz => 100,
247            Dynamic::Sf => 96,
248        }
249    }
250}
251
252#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
253pub enum Articulation {
254    Staccato,
255    Staccatissimo,
256    Accent,
257    Tenuto,
258    Marcato,
259    Fermata,
260    Trill,
261    Mordent,
262    InvertedMordent,
263    Turn,
264    InvertedTurn,
265    Shake,
266    Tremolo(u8),
267    BreathMark,
268    Caesura,
269}
270
271/// Guitar-specific playing technique attached to a note.
272#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
273#[serde(rename_all = "kebab-case")]
274pub enum GuitarTechnique {
275    Bend,
276    Slide,
277    HammerOn,
278    PullOff,
279}
280
281/// Deterministic policy for selecting one candidate from an ordered fingering list.
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
283pub enum FingeringSelectionPolicy {
284    /// Preserve the source/application's first candidate.
285    #[default]
286    SourceOrder,
287    /// Select the numerically smallest candidate.
288    LowestNumber,
289    /// Select the numerically largest candidate.
290    HighestNumber,
291}
292
293#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
294pub enum Barline {
295    #[default]
296    Normal,
297    Double,
298    Final,
299    RepeatStart,
300    RepeatEnd,
301    RepeatBoth,
302    Dashed,
303    Dotted,
304    Invisible,
305}
306
307#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
308pub enum HairpinKind {
309    Crescendo,
310    Decrescendo,
311}
312
313#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
314pub struct TupletInfo {
315    /// Notes in the tuplet group (e.g. 3 for triplet).
316    pub actual_notes: u8,
317    /// Normal notes displaced (e.g. 2 for triplet = 3-in-2).
318    pub normal_notes: u8,
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
322pub enum BeamState {
323    #[default]
324    None,
325    Begin,
326    Continue,
327    End,
328    BeginEnd,
329    BackwardHook,
330    ForwardHook,
331}
332
333/// Ottava (octave transposition bracket).
334#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
335pub enum OttavaKind {
336    /// 8va — sounds one octave higher than written.
337    Va8,
338    /// 8vb — sounds one octave lower than written.
339    Vb8,
340    /// 15ma — sounds two octaves higher than written.
341    Ma15,
342    /// 15mb — sounds two octaves lower than written.
343    Mb15,
344}
345
346impl OttavaKind {
347    pub fn musicxml_type(&self) -> &'static str {
348        match self {
349            OttavaKind::Va8 | OttavaKind::Ma15 => "up",
350            OttavaKind::Vb8 | OttavaKind::Mb15 => "down",
351        }
352    }
353
354    pub fn musicxml_size(&self) -> u8 {
355        match self {
356            OttavaKind::Va8 | OttavaKind::Vb8 => 8,
357            OttavaKind::Ma15 | OttavaKind::Mb15 => 15,
358        }
359    }
360}
361
362/// Note head shape.
363#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
364pub enum NoteHead {
365    #[default]
366    Normal,
367    Diamond,  // natural harmonics
368    X,        // muted / dead note
369    Slash,    // ghost note
370    Cross,    // percussion special
371    Triangle, // tap harmonics
372}
373
374/// Lyric syllable attached to a note.
375#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
376pub struct Lyric {
377    /// The syllable text.
378    pub text: String,
379    /// Syllabic position: "single" | "begin" | "middle" | "end"
380    pub syllabic: String,
381}
382
383/// A typed score text annotation. The text itself is kept separate from its
384/// presentation role so consumers do not need to infer semantics from prose.
385#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
386pub struct StyledText {
387    pub style: TextStyle,
388    pub text: String,
389    /// Optional vertical placement hint from interchange formats.
390    #[serde(default)]
391    pub placement: Option<String>,
392    /// MusicXML default horizontal offset in tenths, when attached to a direction text.
393    #[serde(default)]
394    pub offset_x: Option<f64>,
395    /// MusicXML default vertical offset in tenths, when attached to a direction text.
396    #[serde(default)]
397    pub offset_y: Option<f64>,
398    /// MusicXML relative horizontal offset in tenths, retaining its relative-coordinate meaning.
399    #[serde(default)]
400    pub relative_x: Option<f64>,
401    /// MusicXML relative vertical offset in tenths, retaining its relative-coordinate meaning.
402    #[serde(default)]
403    pub relative_y: Option<f64>,
404}
405
406#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
407pub enum TextStyle {
408    Expression,
409    Technique,
410    Lyrics,
411    ChordSymbol,
412    FiguredBass,
413    RehearsalMark,
414    Generic,
415}
416
417/// Cross-staff placement metadata. The note remains in its source staff, so
418/// its stable playback address continues to identify the original note.
419#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
420pub struct CrossStaff {
421    pub target_staff: usize,
422    #[serde(default)]
423    pub target_voice: Option<usize>,
424}
425
426/// A tablature string/fret position attached to a pitched note.
427#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
428pub struct TabPosition {
429    /// One-based string number, matching MusicXML `<string>`.
430    pub string: u8,
431    pub fret: u8,
432}
433
434/// Tablature staff metadata. Tuning MIDI values are ordered by string number.
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
436pub struct TablatureConfig {
437    pub lines: u8,
438    pub tuning_midi: Vec<i16>,
439    #[serde(default)]
440    pub capo: u8,
441}
442
443/// Structured chord symbol attached to a note.
444#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
445pub struct ChordSymbol {
446    /// Root note name: "C", "F#", "Bb", etc.
447    pub root: String,
448    /// MusicXML harmony kind: "major", "minor", "dominant", "major-seventh", etc.
449    pub kind: String,
450    /// Slash-chord bass note.
451    pub bass: Option<String>,
452    /// Optional vertical placement hint from interchange formats (for example `above`/`below`).
453    #[serde(default)]
454    pub placement: Option<String>,
455    /// Whether the source harmony carries a continuation/extender line.
456    #[serde(default)]
457    pub extender: bool,
458    /// MEI harmonic-analysis scale degree (Humdrum **deg syntax), when supplied.
459    #[serde(default)]
460    pub harmonic_degree: Option<String>,
461    /// MEI harmonic function token (for example `T`, `PD`, or `D`), when supplied.
462    #[serde(default)]
463    pub harmony_function: Option<String>,
464    /// MEI `harm@type` classification token(s), when supplied.
465    #[serde(default)]
466    pub harmony_type: Option<String>,
467    /// MEI `harm@chordref` URI, retained without resolving an external chord definition.
468    #[serde(default)]
469    pub chord_ref: Option<String>,
470    /// Optional note address where a harmony range ends.
471    ///
472    /// This is primarily used by MEI `harm@tstamp2`/`endid`.  The field is
473    /// optional so older score JSON and formats without harmony ranges remain
474    /// fully compatible.
475    #[serde(default)]
476    pub range_end: Option<NoteAddr>,
477    /// Structured chord extensions such as add9, alter5, or omit3.
478    #[serde(default)]
479    pub degrees: Vec<ChordDegree>,
480}
481
482/// A reusable MEI chord/tablature definition referenced by `harm@chordref`.
483///
484/// The fields intentionally retain the source spelling for deprecated MEI tuning
485/// attributes.  Consumers may interpret the member positions without requiring
486/// the canonical score to invent an instrument catalog.
487#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
488pub struct ChordDefinition {
489    #[serde(default)]
490    pub id: Option<String>,
491    #[serde(default)]
492    pub label: Option<String>,
493    #[serde(default)]
494    pub kind: Option<String>,
495    #[serde(default)]
496    pub fret_position: Option<u32>,
497    #[serde(default)]
498    pub tab_strings: Option<String>,
499    #[serde(default)]
500    pub tab_courses: Option<String>,
501    #[serde(default)]
502    pub members: Vec<ChordDefinitionMember>,
503    #[serde(default)]
504    pub barres: Vec<ChordBarre>,
505}
506
507/// One pitch and/or tablature position in a [`ChordDefinition`].
508#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
509pub struct ChordDefinitionMember {
510    #[serde(default)]
511    pub id: Option<String>,
512    #[serde(default)]
513    pub pitch: Option<Pitch>,
514    #[serde(default)]
515    pub tab_string: Option<u8>,
516    #[serde(default)]
517    pub tab_course: Option<u8>,
518    #[serde(default)]
519    pub tab_fret: Option<u16>,
520    #[serde(default)]
521    pub fingering: Option<u8>,
522}
523
524/// A barre range inside a [`ChordDefinition`] fretboard diagram.
525#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
526pub struct ChordBarre {
527    #[serde(default)]
528    pub start_member: Option<String>,
529    #[serde(default)]
530    pub end_member: Option<String>,
531    #[serde(default)]
532    pub fret: Option<u16>,
533    #[serde(default)]
534    pub label: Option<String>,
535    #[serde(default)]
536    pub kind: Option<String>,
537}
538
539/// One structured MusicXML chord degree.
540#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
541pub struct ChordDegree {
542    /// Scale degree number (normally 1 through 13).
543    pub value: u8,
544    /// Semitone alteration relative to the diatonic degree.
545    pub alter: i8,
546    /// MusicXML degree type, such as `add`, `alter`, or `subtract`.
547    pub kind: String,
548}
549
550/// One structured figure in a figured-bass annotation.
551#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
552pub struct FiguredBassFigure {
553    /// Source figure number, retained as text because MusicXML permits non-numeric figures.
554    pub number: String,
555    /// Raw source alteration value, such as `-1`, `0`, or `1`.
556    #[serde(default)]
557    pub alter: Option<String>,
558    /// Optional source prefix decoration.
559    #[serde(default)]
560    pub prefix: Option<String>,
561    /// Optional source suffix decoration.
562    #[serde(default)]
563    pub suffix: Option<String>,
564    /// Whether MEI marks this figure with a horizontal extender.
565    #[serde(default)]
566    pub extender: bool,
567}
568
569impl ChordDegree {
570    /// Render the degree using the compact chord-label convention.
571    pub fn display_text(&self) -> String {
572        let accidental = match self.alter {
573            -2 => "bb",
574            -1 => "b",
575            1 => "#",
576            2 => "##",
577            _ => "",
578        };
579        match self.kind.as_str() {
580            "subtract" => format!("no{}{}", accidental, self.value),
581            "alter" => format!("{}{}", accidental, self.value),
582            _ => format!("add{}{}", accidental, self.value),
583        }
584    }
585}
586
587impl ChordSymbol {
588    pub fn display_text(&self) -> String {
589        let kind_str = match self.kind.as_str() {
590            "major" | "" => "",
591            "minor" => "m",
592            "dominant" => "7",
593            "major-seventh" => "maj7",
594            "minor-seventh" => "m7",
595            "diminished" => "dim",
596            "diminished-seventh" => "dim7",
597            "augmented" => "aug",
598            "suspended-second" => "sus2",
599            "suspended-fourth" => "sus4",
600            "half-diminished" => "m7b5",
601            "major-sixth" => "6",
602            "minor-sixth" => "m6",
603            "power" => "5",
604            "major-add9" => "add9",
605            "minor-add9" => "madd9",
606            "minor-major-seventh" => "mMaj7",
607            "dominant-flat-five" => "7b5",
608            "dominant-sharp-five" => "7#5",
609            "dominant-ninth" => "9",
610            "major-ninth" => "maj9",
611            "minor-ninth" => "m9",
612            other => other,
613        };
614        let bass_str = match &self.bass {
615            Some(b) => format!("/{}", b),
616            None => String::new(),
617        };
618        let degree_str = self
619            .degrees
620            .iter()
621            .map(ChordDegree::display_text)
622            .collect::<String>();
623        format!("{}{}{}{}", self.root, kind_str, degree_str, bass_str)
624    }
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    #[test]
632    fn key_alter_c_major_all_natural() {
633        let key = KeySignature {
634            fifths: 0,
635            mode: "major".into(),
636        };
637        for step in [
638            Step::C,
639            Step::D,
640            Step::E,
641            Step::F,
642            Step::G,
643            Step::A,
644            Step::B,
645        ] {
646            assert_eq!(key.alter_for_step(&step), 0, "step {:?}", step);
647        }
648    }
649
650    #[test]
651    fn key_alter_g_major_fsharp() {
652        let key = KeySignature {
653            fifths: 1,
654            mode: "major".into(),
655        };
656        assert_eq!(key.alter_for_step(&Step::F), 1);
657        assert_eq!(key.alter_for_step(&Step::G), 0);
658    }
659
660    #[test]
661    fn key_alter_f_major_bflat() {
662        let key = KeySignature {
663            fifths: -1,
664            mode: "major".into(),
665        };
666        assert_eq!(key.alter_for_step(&Step::B), -1);
667        assert_eq!(key.alter_for_step(&Step::C), 0);
668    }
669
670    #[test]
671    fn key_alter_bb_major() {
672        let key = KeySignature {
673            fifths: -2,
674            mode: "major".into(),
675        };
676        assert_eq!(key.alter_for_step(&Step::B), -1);
677        assert_eq!(key.alter_for_step(&Step::E), -1);
678        assert_eq!(key.alter_for_step(&Step::A), 0);
679    }
680
681    #[test]
682    fn key_contains_pitch_g_major() {
683        let key = KeySignature {
684            fifths: 1,
685            mode: "major".into(),
686        };
687        // In-key: G, A, B, C, D, E, F# (alter=1)
688        assert!(key.contains_pitch(&Pitch::new(Step::G, 4)));
689        assert!(key.contains_pitch(&Pitch::new(Step::D, 4)));
690        assert!(key.contains_pitch(&Pitch::with_alter(Step::F, 4, 1))); // F#
691        // Out-of-key: F natural
692        assert!(!key.contains_pitch(&Pitch::new(Step::F, 4)));
693    }
694
695    #[test]
696    fn key_display_name_c_major() {
697        let key = KeySignature {
698            fifths: 0,
699            mode: "major".into(),
700        };
701        assert_eq!(key.display_name(), "C major");
702    }
703
704    #[test]
705    fn key_display_name_bb_major() {
706        let key = KeySignature {
707            fifths: -2,
708            mode: "major".into(),
709        };
710        assert_eq!(key.display_name(), "Bb major");
711    }
712
713    #[test]
714    fn key_display_name_fsharp_minor() {
715        let key = KeySignature {
716            fifths: 3,
717            mode: "minor".into(),
718        };
719        assert_eq!(key.display_name(), "F# minor");
720    }
721
722    #[test]
723    fn key_tonic_d_major() {
724        let key = KeySignature {
725            fifths: 2,
726            mode: "major".into(),
727        };
728        let (step, alter) = key.tonic();
729        assert_eq!(step, Step::D);
730        assert_eq!(alter, 0);
731    }
732
733    #[test]
734    fn key_tonic_a_minor() {
735        // A minor = relative minor of C major (fifths=0)
736        let key = KeySignature {
737            fifths: 0,
738            mode: "minor".into(),
739        };
740        let (step, alter) = key.tonic();
741        assert_eq!(step, Step::A);
742        assert_eq!(alter, 0);
743    }
744
745    #[test]
746    fn chord_display_major() {
747        let c = ChordSymbol {
748            root: "C".into(),
749            kind: "major".into(),
750            bass: None,
751            placement: None,
752            extender: false,
753            harmonic_degree: None,
754            harmony_function: None,
755            harmony_type: None,
756            chord_ref: None,
757            range_end: None,
758            degrees: Vec::new(),
759        };
760        assert_eq!(c.display_text(), "C");
761    }
762
763    #[test]
764    fn chord_display_minor_seventh_slash() {
765        let c = ChordSymbol {
766            root: "D".into(),
767            kind: "minor-seventh".into(),
768            bass: Some("F".into()),
769            placement: None,
770            extender: false,
771            harmonic_degree: None,
772            harmony_function: None,
773            harmony_type: None,
774            chord_ref: None,
775            range_end: None,
776            degrees: Vec::new(),
777        };
778        assert_eq!(c.display_text(), "Dm7/F");
779    }
780
781    #[test]
782    fn chord_display_structured_degrees() {
783        let c = ChordSymbol {
784            root: "C".into(),
785            kind: "dominant".into(),
786            bass: None,
787            placement: None,
788            extender: false,
789            harmonic_degree: None,
790            harmony_function: None,
791            harmony_type: None,
792            chord_ref: None,
793            range_end: None,
794            degrees: vec![
795                ChordDegree {
796                    value: 9,
797                    alter: 1,
798                    kind: "add".into(),
799                },
800                ChordDegree {
801                    value: 5,
802                    alter: -1,
803                    kind: "alter".into(),
804                },
805                ChordDegree {
806                    value: 3,
807                    alter: 0,
808                    kind: "subtract".into(),
809                },
810            ],
811        };
812        assert_eq!(c.display_text(), "C7add#9b5no3");
813    }
814
815    #[test]
816    fn chord_symbol_legacy_json_defaults_degrees() {
817        let chord: ChordSymbol =
818            serde_json::from_str(r#"{"root":"C","kind":"major","bass":null,"placement":null}"#)
819                .expect("legacy chord symbol JSON deserializes");
820        assert!(chord.degrees.is_empty());
821        assert!(!chord.extender);
822        assert!(chord.harmonic_degree.is_none());
823        assert!(chord.harmony_function.is_none());
824        assert!(chord.harmony_type.is_none());
825    }
826
827    #[test]
828    fn time_sig_total_beats_three_four() {
829        let ts = TimeSignature {
830            numerator: 3,
831            denominator: 4,
832        };
833        assert!((ts.total_beats() - 3.0).abs() < 1e-9);
834    }
835
836    #[test]
837    fn time_sig_total_beats_six_eight() {
838        let ts = TimeSignature {
839            numerator: 6,
840            denominator: 8,
841        };
842        assert!((ts.total_beats() - 3.0).abs() < 1e-9);
843    }
844
845    #[test]
846    fn clef_treble_middle_b4() {
847        assert_eq!(Clef::Treble.middle_line_midi(), 71);
848    }
849
850    #[test]
851    fn clef_bass_middle_d3() {
852        assert_eq!(Clef::Bass.middle_line_midi(), 50);
853    }
854
855    #[test]
856    fn clef_alto_middle_c4() {
857        assert_eq!(Clef::Alto.middle_line_midi(), 60);
858    }
859
860    #[test]
861    fn clef_tenor_middle_a3() {
862        assert_eq!(Clef::Tenor.middle_line_midi(), 57);
863    }
864}