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 lyric syllable for verse 2 or later. Verse 1 stays in `Note.lyric`.
384#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
385pub struct VerseLyric {
386    /// Verse number, from 2 to [`VerseLyric::MAX_VERSE`].
387    pub verse: u8,
388    pub lyric: Lyric,
389}
390
391impl VerseLyric {
392    /// Highest supported verse number.
393    pub const MAX_VERSE: u8 = 32;
394}
395
396/// A typed score text annotation. The text itself is kept separate from its
397/// presentation role so consumers do not need to infer semantics from prose.
398#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
399pub struct StyledText {
400    pub style: TextStyle,
401    pub text: String,
402    /// Optional vertical placement hint from interchange formats.
403    #[serde(default)]
404    pub placement: Option<String>,
405    /// MusicXML default horizontal offset in tenths, when attached to a direction text.
406    #[serde(default)]
407    pub offset_x: Option<f64>,
408    /// MusicXML default vertical offset in tenths, when attached to a direction text.
409    #[serde(default)]
410    pub offset_y: Option<f64>,
411    /// MusicXML relative horizontal offset in tenths, retaining its relative-coordinate meaning.
412    #[serde(default)]
413    pub relative_x: Option<f64>,
414    /// MusicXML relative vertical offset in tenths, retaining its relative-coordinate meaning.
415    #[serde(default)]
416    pub relative_y: Option<f64>,
417}
418
419#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
420pub enum TextStyle {
421    Expression,
422    Technique,
423    Lyrics,
424    ChordSymbol,
425    FiguredBass,
426    RehearsalMark,
427    Generic,
428}
429
430/// Cross-staff placement metadata. The note remains in its source staff, so
431/// its stable playback address continues to identify the original note.
432#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
433pub struct CrossStaff {
434    pub target_staff: usize,
435    #[serde(default)]
436    pub target_voice: Option<usize>,
437}
438
439/// A tablature string/fret position attached to a pitched note.
440#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
441pub struct TabPosition {
442    /// One-based string number, matching MusicXML `<string>`.
443    pub string: u8,
444    pub fret: u8,
445}
446
447/// Tablature staff metadata. Tuning MIDI values are ordered by string number.
448#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
449pub struct TablatureConfig {
450    pub lines: u8,
451    pub tuning_midi: Vec<i16>,
452    #[serde(default)]
453    pub capo: u8,
454}
455
456/// Structured chord symbol attached to a note.
457#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
458pub struct ChordSymbol {
459    /// Root note name: "C", "F#", "Bb", etc.
460    pub root: String,
461    /// MusicXML harmony kind: "major", "minor", "dominant", "major-seventh", etc.
462    pub kind: String,
463    /// Slash-chord bass note.
464    pub bass: Option<String>,
465    /// Optional vertical placement hint from interchange formats (for example `above`/`below`).
466    #[serde(default)]
467    pub placement: Option<String>,
468    /// Whether the source harmony carries a continuation/extender line.
469    #[serde(default)]
470    pub extender: bool,
471    /// MEI harmonic-analysis scale degree (Humdrum **deg syntax), when supplied.
472    #[serde(default)]
473    pub harmonic_degree: Option<String>,
474    /// MEI harmonic function token (for example `T`, `PD`, or `D`), when supplied.
475    #[serde(default)]
476    pub harmony_function: Option<String>,
477    /// MEI `harm@type` classification token(s), when supplied.
478    #[serde(default)]
479    pub harmony_type: Option<String>,
480    /// MEI `harm@chordref` URI, retained without resolving an external chord definition.
481    #[serde(default)]
482    pub chord_ref: Option<String>,
483    /// Optional note address where a harmony range ends.
484    ///
485    /// This is primarily used by MEI `harm@tstamp2`/`endid`.  The field is
486    /// optional so older score JSON and formats without harmony ranges remain
487    /// fully compatible.
488    #[serde(default)]
489    pub range_end: Option<NoteAddr>,
490    /// Structured chord extensions such as add9, alter5, or omit3.
491    #[serde(default)]
492    pub degrees: Vec<ChordDegree>,
493}
494
495/// A reusable MEI chord/tablature definition referenced by `harm@chordref`.
496///
497/// The fields intentionally retain the source spelling for deprecated MEI tuning
498/// attributes.  Consumers may interpret the member positions without requiring
499/// the canonical score to invent an instrument catalog.
500#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
501pub struct ChordDefinition {
502    #[serde(default)]
503    pub id: Option<String>,
504    #[serde(default)]
505    pub label: Option<String>,
506    #[serde(default)]
507    pub kind: Option<String>,
508    #[serde(default)]
509    pub fret_position: Option<u32>,
510    #[serde(default)]
511    pub tab_strings: Option<String>,
512    #[serde(default)]
513    pub tab_courses: Option<String>,
514    #[serde(default)]
515    pub members: Vec<ChordDefinitionMember>,
516    #[serde(default)]
517    pub barres: Vec<ChordBarre>,
518}
519
520/// One pitch and/or tablature position in a [`ChordDefinition`].
521#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
522pub struct ChordDefinitionMember {
523    #[serde(default)]
524    pub id: Option<String>,
525    #[serde(default)]
526    pub pitch: Option<Pitch>,
527    #[serde(default)]
528    pub tab_string: Option<u8>,
529    #[serde(default)]
530    pub tab_course: Option<u8>,
531    #[serde(default)]
532    pub tab_fret: Option<u16>,
533    #[serde(default)]
534    pub fingering: Option<u8>,
535}
536
537/// A barre range inside a [`ChordDefinition`] fretboard diagram.
538#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
539pub struct ChordBarre {
540    #[serde(default)]
541    pub start_member: Option<String>,
542    #[serde(default)]
543    pub end_member: Option<String>,
544    #[serde(default)]
545    pub fret: Option<u16>,
546    #[serde(default)]
547    pub label: Option<String>,
548    #[serde(default)]
549    pub kind: Option<String>,
550}
551
552/// One structured MusicXML chord degree.
553#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
554pub struct ChordDegree {
555    /// Scale degree number (normally 1 through 13).
556    pub value: u8,
557    /// Semitone alteration relative to the diatonic degree.
558    pub alter: i8,
559    /// MusicXML degree type, such as `add`, `alter`, or `subtract`.
560    pub kind: String,
561}
562
563/// One structured figure in a figured-bass annotation.
564#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
565pub struct FiguredBassFigure {
566    /// Source figure number, retained as text because MusicXML permits non-numeric figures.
567    pub number: String,
568    /// Raw source alteration value, such as `-1`, `0`, or `1`.
569    #[serde(default)]
570    pub alter: Option<String>,
571    /// Optional source prefix decoration.
572    #[serde(default)]
573    pub prefix: Option<String>,
574    /// Optional source suffix decoration.
575    #[serde(default)]
576    pub suffix: Option<String>,
577    /// Whether MEI marks this figure with a horizontal extender.
578    #[serde(default)]
579    pub extender: bool,
580}
581
582impl ChordDegree {
583    /// Render the degree using the compact chord-label convention.
584    pub fn display_text(&self) -> String {
585        let accidental = match self.alter {
586            -2 => "bb",
587            -1 => "b",
588            1 => "#",
589            2 => "##",
590            _ => "",
591        };
592        match self.kind.as_str() {
593            "subtract" => format!("no{}{}", accidental, self.value),
594            "alter" => format!("{}{}", accidental, self.value),
595            _ => format!("add{}{}", accidental, self.value),
596        }
597    }
598}
599
600impl ChordSymbol {
601    pub fn display_text(&self) -> String {
602        let kind_str = match self.kind.as_str() {
603            "major" | "" => "",
604            "minor" => "m",
605            "dominant" => "7",
606            "major-seventh" => "maj7",
607            "minor-seventh" => "m7",
608            "diminished" => "dim",
609            "diminished-seventh" => "dim7",
610            "augmented" => "aug",
611            "suspended-second" => "sus2",
612            "suspended-fourth" => "sus4",
613            "half-diminished" => "m7b5",
614            "major-sixth" => "6",
615            "minor-sixth" => "m6",
616            "power" => "5",
617            "major-add9" => "add9",
618            "minor-add9" => "madd9",
619            "minor-major-seventh" => "mMaj7",
620            "dominant-flat-five" => "7b5",
621            "dominant-sharp-five" => "7#5",
622            "dominant-ninth" => "9",
623            "major-ninth" => "maj9",
624            "minor-ninth" => "m9",
625            other => other,
626        };
627        let bass_str = match &self.bass {
628            Some(b) => format!("/{}", b),
629            None => String::new(),
630        };
631        let degree_str = self
632            .degrees
633            .iter()
634            .map(ChordDegree::display_text)
635            .collect::<String>();
636        format!("{}{}{}{}", self.root, kind_str, degree_str, bass_str)
637    }
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643
644    #[test]
645    fn key_alter_c_major_all_natural() {
646        let key = KeySignature {
647            fifths: 0,
648            mode: "major".into(),
649        };
650        for step in [
651            Step::C,
652            Step::D,
653            Step::E,
654            Step::F,
655            Step::G,
656            Step::A,
657            Step::B,
658        ] {
659            assert_eq!(key.alter_for_step(&step), 0, "step {:?}", step);
660        }
661    }
662
663    #[test]
664    fn key_alter_g_major_fsharp() {
665        let key = KeySignature {
666            fifths: 1,
667            mode: "major".into(),
668        };
669        assert_eq!(key.alter_for_step(&Step::F), 1);
670        assert_eq!(key.alter_for_step(&Step::G), 0);
671    }
672
673    #[test]
674    fn key_alter_f_major_bflat() {
675        let key = KeySignature {
676            fifths: -1,
677            mode: "major".into(),
678        };
679        assert_eq!(key.alter_for_step(&Step::B), -1);
680        assert_eq!(key.alter_for_step(&Step::C), 0);
681    }
682
683    #[test]
684    fn key_alter_bb_major() {
685        let key = KeySignature {
686            fifths: -2,
687            mode: "major".into(),
688        };
689        assert_eq!(key.alter_for_step(&Step::B), -1);
690        assert_eq!(key.alter_for_step(&Step::E), -1);
691        assert_eq!(key.alter_for_step(&Step::A), 0);
692    }
693
694    #[test]
695    fn key_contains_pitch_g_major() {
696        let key = KeySignature {
697            fifths: 1,
698            mode: "major".into(),
699        };
700        // In-key: G, A, B, C, D, E, F# (alter=1)
701        assert!(key.contains_pitch(&Pitch::new(Step::G, 4)));
702        assert!(key.contains_pitch(&Pitch::new(Step::D, 4)));
703        assert!(key.contains_pitch(&Pitch::with_alter(Step::F, 4, 1))); // F#
704        // Out-of-key: F natural
705        assert!(!key.contains_pitch(&Pitch::new(Step::F, 4)));
706    }
707
708    #[test]
709    fn key_display_name_c_major() {
710        let key = KeySignature {
711            fifths: 0,
712            mode: "major".into(),
713        };
714        assert_eq!(key.display_name(), "C major");
715    }
716
717    #[test]
718    fn key_display_name_bb_major() {
719        let key = KeySignature {
720            fifths: -2,
721            mode: "major".into(),
722        };
723        assert_eq!(key.display_name(), "Bb major");
724    }
725
726    #[test]
727    fn key_display_name_fsharp_minor() {
728        let key = KeySignature {
729            fifths: 3,
730            mode: "minor".into(),
731        };
732        assert_eq!(key.display_name(), "F# minor");
733    }
734
735    #[test]
736    fn key_tonic_d_major() {
737        let key = KeySignature {
738            fifths: 2,
739            mode: "major".into(),
740        };
741        let (step, alter) = key.tonic();
742        assert_eq!(step, Step::D);
743        assert_eq!(alter, 0);
744    }
745
746    #[test]
747    fn key_tonic_a_minor() {
748        // A minor = relative minor of C major (fifths=0)
749        let key = KeySignature {
750            fifths: 0,
751            mode: "minor".into(),
752        };
753        let (step, alter) = key.tonic();
754        assert_eq!(step, Step::A);
755        assert_eq!(alter, 0);
756    }
757
758    #[test]
759    fn chord_display_major() {
760        let c = ChordSymbol {
761            root: "C".into(),
762            kind: "major".into(),
763            bass: None,
764            placement: None,
765            extender: false,
766            harmonic_degree: None,
767            harmony_function: None,
768            harmony_type: None,
769            chord_ref: None,
770            range_end: None,
771            degrees: Vec::new(),
772        };
773        assert_eq!(c.display_text(), "C");
774    }
775
776    #[test]
777    fn chord_display_minor_seventh_slash() {
778        let c = ChordSymbol {
779            root: "D".into(),
780            kind: "minor-seventh".into(),
781            bass: Some("F".into()),
782            placement: None,
783            extender: false,
784            harmonic_degree: None,
785            harmony_function: None,
786            harmony_type: None,
787            chord_ref: None,
788            range_end: None,
789            degrees: Vec::new(),
790        };
791        assert_eq!(c.display_text(), "Dm7/F");
792    }
793
794    #[test]
795    fn chord_display_structured_degrees() {
796        let c = ChordSymbol {
797            root: "C".into(),
798            kind: "dominant".into(),
799            bass: None,
800            placement: None,
801            extender: false,
802            harmonic_degree: None,
803            harmony_function: None,
804            harmony_type: None,
805            chord_ref: None,
806            range_end: None,
807            degrees: vec![
808                ChordDegree {
809                    value: 9,
810                    alter: 1,
811                    kind: "add".into(),
812                },
813                ChordDegree {
814                    value: 5,
815                    alter: -1,
816                    kind: "alter".into(),
817                },
818                ChordDegree {
819                    value: 3,
820                    alter: 0,
821                    kind: "subtract".into(),
822                },
823            ],
824        };
825        assert_eq!(c.display_text(), "C7add#9b5no3");
826    }
827
828    #[test]
829    fn chord_symbol_legacy_json_defaults_degrees() {
830        let chord: ChordSymbol =
831            serde_json::from_str(r#"{"root":"C","kind":"major","bass":null,"placement":null}"#)
832                .expect("legacy chord symbol JSON deserializes");
833        assert!(chord.degrees.is_empty());
834        assert!(!chord.extender);
835        assert!(chord.harmonic_degree.is_none());
836        assert!(chord.harmony_function.is_none());
837        assert!(chord.harmony_type.is_none());
838    }
839
840    #[test]
841    fn time_sig_total_beats_three_four() {
842        let ts = TimeSignature {
843            numerator: 3,
844            denominator: 4,
845        };
846        assert!((ts.total_beats() - 3.0).abs() < 1e-9);
847    }
848
849    #[test]
850    fn time_sig_total_beats_six_eight() {
851        let ts = TimeSignature {
852            numerator: 6,
853            denominator: 8,
854        };
855        assert!((ts.total_beats() - 3.0).abs() < 1e-9);
856    }
857
858    #[test]
859    fn clef_treble_middle_b4() {
860        assert_eq!(Clef::Treble.middle_line_midi(), 71);
861    }
862
863    #[test]
864    fn clef_bass_middle_d3() {
865        assert_eq!(Clef::Bass.middle_line_midi(), 50);
866    }
867
868    #[test]
869    fn clef_alto_middle_c4() {
870        assert_eq!(Clef::Alto.middle_line_midi(), 60);
871    }
872
873    #[test]
874    fn clef_tenor_middle_a3() {
875        assert_eq!(Clef::Tenor.middle_line_midi(), 57);
876    }
877}