Skip to main content

music21_rs/
chordsymbol.rs

1use std::str::FromStr;
2
3use crate::{
4    chord::Chord,
5    defaults::{FloatType, IntegerType},
6    error::{Error, Result},
7    interval::Interval,
8    pitch::Pitch,
9};
10use std::collections::{BTreeMap, BTreeSet};
11
12/// Tertian quality parsed from a chord symbol.
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15pub enum ChordQuality {
16    /// Major triad or major-family sonority.
17    Major,
18    /// Minor triad or minor-family sonority.
19    Minor,
20    /// Dominant seventh-family sonority.
21    Dominant,
22    /// Diminished triad or diminished-family sonority.
23    Diminished,
24    /// Augmented triad sonority.
25    Augmented,
26    /// Half-diminished seventh-family sonority.
27    HalfDiminished,
28    /// Suspended-second sonority.
29    Suspended2,
30    /// Suspended-fourth sonority.
31    Suspended4,
32    /// Power-chord sonority containing a root and fifth.
33    Power,
34}
35
36/// A chord-symbol alteration such as `b5` or `#11`.
37#[derive(Clone, Debug, Eq, PartialEq)]
38#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
39pub struct ChordAlteration {
40    degree: u8,
41    semitones: IntegerType,
42}
43
44impl ChordAlteration {
45    /// Creates an alteration for a scale degree and semitone displacement.
46    pub fn new(degree: u8, semitones: IntegerType) -> Self {
47        Self { degree, semitones }
48    }
49
50    /// Returns the altered or added chord degree.
51    pub fn degree(&self) -> u8 {
52        self.degree
53    }
54
55    /// Returns the semitone displacement from the unaltered degree.
56    pub fn semitones(&self) -> IntegerType {
57        self.semitones
58    }
59}
60
61/// Parsed chord symbol.
62#[derive(Clone, Debug, PartialEq)]
63#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
64pub struct ChordSymbol {
65    figure: String,
66    root: Pitch,
67    bass: Option<Pitch>,
68    quality: ChordQuality,
69    extensions: Vec<u8>,
70    alterations: Vec<ChordAlteration>,
71    #[cfg_attr(feature = "serde", serde(default))]
72    omissions: Vec<u8>,
73    #[cfg_attr(feature = "serde", serde(default))]
74    additions: Vec<ChordAlteration>,
75}
76
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78struct Music21ChordType {
79    kind: &'static str,
80    notation: &'static str,
81    abbreviation: &'static str,
82}
83
84#[derive(Clone, Copy, Debug, Eq, PartialEq)]
85struct Music21Degree {
86    degree: u8,
87    semitone: u8,
88}
89
90#[derive(Clone, Copy, Debug, Eq, PartialEq)]
91struct Music21FigureMatch {
92    kind: &'static str,
93    notation: &'static str,
94    abbreviation: &'static str,
95}
96
97#[derive(Clone, Copy, Debug, Eq, PartialEq)]
98struct Music21ChordAnalysis {
99    d3: Option<u8>,
100    d5: Option<u8>,
101    d7: Option<u8>,
102    d9: Option<u8>,
103    d11: Option<u8>,
104    d13: Option<u8>,
105    is_triad: bool,
106    is_seventh: bool,
107}
108
109const MUSIC21_CHORD_TYPES: &[Music21ChordType] = &[
110    Music21ChordType {
111        kind: "major",
112        notation: "1,3,5",
113        abbreviation: "",
114    },
115    Music21ChordType {
116        kind: "minor",
117        notation: "1,-3,5",
118        abbreviation: "m",
119    },
120    Music21ChordType {
121        kind: "augmented",
122        notation: "1,3,#5",
123        abbreviation: "+",
124    },
125    Music21ChordType {
126        kind: "diminished",
127        notation: "1,-3,-5",
128        abbreviation: "dim",
129    },
130    Music21ChordType {
131        kind: "dominant-seventh",
132        notation: "1,3,5,-7",
133        abbreviation: "7",
134    },
135    Music21ChordType {
136        kind: "major-seventh",
137        notation: "1,3,5,7",
138        abbreviation: "maj7",
139    },
140    Music21ChordType {
141        kind: "minor-major-seventh",
142        notation: "1,-3,5,7",
143        abbreviation: "mM7",
144    },
145    Music21ChordType {
146        kind: "minor-seventh",
147        notation: "1,-3,5,-7",
148        abbreviation: "m7",
149    },
150    Music21ChordType {
151        kind: "augmented-major-seventh",
152        notation: "1,3,#5,7",
153        abbreviation: "+M7",
154    },
155    Music21ChordType {
156        kind: "augmented-seventh",
157        notation: "1,3,#5,-7",
158        abbreviation: "7+",
159    },
160    Music21ChordType {
161        kind: "half-diminished-seventh",
162        notation: "1,-3,-5,-7",
163        abbreviation: "\u{00f8}7",
164    },
165    Music21ChordType {
166        kind: "diminished-seventh",
167        notation: "1,-3,-5,--7",
168        abbreviation: "o7",
169    },
170    Music21ChordType {
171        kind: "seventh-flat-five",
172        notation: "1,3,-5,-7",
173        abbreviation: "dom7dim5",
174    },
175    Music21ChordType {
176        kind: "major-sixth",
177        notation: "1,3,5,6",
178        abbreviation: "6",
179    },
180    Music21ChordType {
181        kind: "minor-sixth",
182        notation: "1,-3,5,6",
183        abbreviation: "m6",
184    },
185    Music21ChordType {
186        kind: "major-ninth",
187        notation: "1,3,5,7,9",
188        abbreviation: "M9",
189    },
190    Music21ChordType {
191        kind: "dominant-ninth",
192        notation: "1,3,5,-7,9",
193        abbreviation: "9",
194    },
195    Music21ChordType {
196        kind: "minor-major-ninth",
197        notation: "1,-3,5,7,9",
198        abbreviation: "mM9",
199    },
200    Music21ChordType {
201        kind: "minor-ninth",
202        notation: "1,-3,5,-7,9",
203        abbreviation: "m9",
204    },
205    Music21ChordType {
206        kind: "augmented-major-ninth",
207        notation: "1,3,#5,7,9",
208        abbreviation: "+M9",
209    },
210    Music21ChordType {
211        kind: "augmented-dominant-ninth",
212        notation: "1,3,#5,-7,9",
213        abbreviation: "9#5",
214    },
215    Music21ChordType {
216        kind: "half-diminished-ninth",
217        notation: "1,-3,-5,-7,9",
218        abbreviation: "\u{00f8}9",
219    },
220    Music21ChordType {
221        kind: "half-diminished-minor-ninth",
222        notation: "1,-3,-5,-7,-9",
223        abbreviation: "\u{00f8}b9",
224    },
225    Music21ChordType {
226        kind: "diminished-ninth",
227        notation: "1,-3,-5,--7,9",
228        abbreviation: "o9",
229    },
230    Music21ChordType {
231        kind: "diminished-minor-ninth",
232        notation: "1,-3,-5,--7,-9",
233        abbreviation: "ob9",
234    },
235    Music21ChordType {
236        kind: "dominant-11th",
237        notation: "1,3,5,-7,9,11",
238        abbreviation: "11",
239    },
240    Music21ChordType {
241        kind: "major-11th",
242        notation: "1,3,5,7,9,11",
243        abbreviation: "M11",
244    },
245    Music21ChordType {
246        kind: "minor-major-11th",
247        notation: "1,-3,5,7,9,11",
248        abbreviation: "mM11",
249    },
250    Music21ChordType {
251        kind: "minor-11th",
252        notation: "1,-3,5,-7,9,11",
253        abbreviation: "m11",
254    },
255    Music21ChordType {
256        kind: "augmented-major-11th",
257        notation: "1,3,#5,7,9,11",
258        abbreviation: "+M11",
259    },
260    Music21ChordType {
261        kind: "augmented-11th",
262        notation: "1,3,#5,-7,9,11",
263        abbreviation: "+11",
264    },
265    Music21ChordType {
266        kind: "half-diminished-11th",
267        notation: "1,-3,-5,-7,9,11",
268        abbreviation: "\u{00f8}11",
269    },
270    Music21ChordType {
271        kind: "diminished-11th",
272        notation: "1,-3,-5,--7,9,11",
273        abbreviation: "o11",
274    },
275    Music21ChordType {
276        kind: "major-13th",
277        notation: "1,3,5,7,9,11,13",
278        abbreviation: "M13",
279    },
280    Music21ChordType {
281        kind: "dominant-13th",
282        notation: "1,3,5,-7,9,11,13",
283        abbreviation: "13",
284    },
285    Music21ChordType {
286        kind: "minor-major-13th",
287        notation: "1,-3,5,7,9,11,13",
288        abbreviation: "mM13",
289    },
290    Music21ChordType {
291        kind: "minor-13th",
292        notation: "1,-3,5,-7,9,11,13",
293        abbreviation: "m13",
294    },
295    Music21ChordType {
296        kind: "augmented-major-13th",
297        notation: "1,3,#5,7,9,11,13",
298        abbreviation: "+M13",
299    },
300    Music21ChordType {
301        kind: "augmented-dominant-13th",
302        notation: "1,3,#5,-7,9,11,13",
303        abbreviation: "+13",
304    },
305    Music21ChordType {
306        kind: "half-diminished-13th",
307        notation: "1,-3,-5,-7,9,11,13",
308        abbreviation: "\u{00f8}13",
309    },
310    Music21ChordType {
311        kind: "suspended-second",
312        notation: "1,2,5",
313        abbreviation: "sus2",
314    },
315    Music21ChordType {
316        kind: "suspended-fourth",
317        notation: "1,4,5",
318        abbreviation: "sus",
319    },
320    Music21ChordType {
321        kind: "suspended-fourth-seventh",
322        notation: "1,4,5,-7",
323        abbreviation: "7sus",
324    },
325    Music21ChordType {
326        kind: "Neapolitan",
327        notation: "1,-2,3,-5",
328        abbreviation: "N6",
329    },
330    Music21ChordType {
331        kind: "Italian",
332        notation: "1,#4,-6",
333        abbreviation: "It+6",
334    },
335    Music21ChordType {
336        kind: "French",
337        notation: "1,2,#4,-6",
338        abbreviation: "Fr+6",
339    },
340    Music21ChordType {
341        kind: "German",
342        notation: "1,-3,#4,-6",
343        abbreviation: "Gr+6",
344    },
345    Music21ChordType {
346        kind: "pedal",
347        notation: "1",
348        abbreviation: "pedal",
349    },
350    Music21ChordType {
351        kind: "power",
352        notation: "1,5",
353        abbreviation: "power",
354    },
355    Music21ChordType {
356        kind: "Tristan",
357        notation: "1,#4,#6,#9",
358        abbreviation: "tristan",
359    },
360];
361
362impl ChordSymbol {
363    /// Parses a chord symbol such as `"Cmaj7"`, `"F#m7b5"`, or `"Bb7#11"`.
364    pub fn parse(figure: impl Into<String>) -> Result<Self> {
365        let figure = figure.into();
366        let trimmed = figure.trim();
367        if trimmed.is_empty() {
368            return Err(Error::Chord("chord symbol cannot be empty".to_string()));
369        }
370
371        let (body, bass_segment) = match trimmed.split_once('/') {
372            Some((body, bass)) => (body, Some(bass)),
373            None => (trimmed, None),
374        };
375        let body_parts = split_music21_pitch_modifiers(body);
376        let bass_parts = bass_segment.map(split_music21_pitch_modifiers);
377        let bass = bass_parts
378            .as_ref()
379            .map(|parts| parse_pitch_only(&parts.base))
380            .transpose()?;
381
382        let (root_name, suffix) = parse_pitch_prefix(&body_parts.base)?;
383        let root = Pitch::from_name(root_name)?;
384        let suffix_without_additions = strip_addition_groups(suffix);
385        let mut additions = parse_additions(suffix);
386        let mut omissions = parse_omissions(suffix);
387        for pitch_name in body_parts
388            .additions
389            .iter()
390            .chain(bass_parts.iter().flat_map(|parts| parts.additions.iter()))
391        {
392            if let Some(addition) = pitch_name_addition(&root, pitch_name) {
393                additions.push(addition);
394            }
395        }
396        for pitch_name in body_parts
397            .omissions
398            .iter()
399            .chain(bass_parts.iter().flat_map(|parts| parts.omissions.iter()))
400        {
401            if let Some(omission) = pitch_name_degree(&root, pitch_name)
402                && !omissions.contains(&omission)
403            {
404                omissions.push(omission);
405            }
406        }
407
408        let mut alterations = parse_alterations(&suffix_without_additions);
409        add_implicit_music21_alterations(&suffix_without_additions, &mut alterations);
410        let extensions = parse_extensions(&suffix_without_additions, &alterations);
411        let quality = parse_quality(&suffix_without_additions, &alterations);
412
413        Ok(Self {
414            figure: trimmed.to_string(),
415            root,
416            bass,
417            quality,
418            extensions,
419            alterations,
420            omissions,
421            additions,
422        })
423    }
424
425    /// Returns the original chord-symbol figure.
426    pub fn figure(&self) -> &str {
427        &self.figure
428    }
429
430    /// Returns the root pitch.
431    pub fn root(&self) -> &Pitch {
432        &self.root
433    }
434
435    /// Returns the slash bass pitch, if one was supplied.
436    pub fn bass(&self) -> Option<&Pitch> {
437        self.bass.as_ref()
438    }
439
440    /// Returns the parsed chord quality.
441    pub fn quality(&self) -> ChordQuality {
442        self.quality
443    }
444
445    /// Returns parsed extension degrees.
446    pub fn extensions(&self) -> &[u8] {
447        &self.extensions
448    }
449
450    /// Returns parsed alterations.
451    pub fn alterations(&self) -> &[ChordAlteration] {
452        &self.alterations
453    }
454
455    /// Returns degrees omitted with `no...` or `omit...` markers.
456    pub fn omissions(&self) -> &[u8] {
457        &self.omissions
458    }
459
460    /// Returns parsed added tones from `add(...)` groups.
461    pub fn additions(&self) -> &[ChordAlteration] {
462        &self.additions
463    }
464
465    /// Realizes the chord symbol as a [`Chord`].
466    pub fn to_chord(&self) -> Result<Chord> {
467        let mut interval_names = self.base_intervals();
468
469        for extension in [6, 9, 11, 13] {
470            if self.extensions.contains(&extension)
471                && !self.alterations.iter().any(|alt| alt.degree == extension)
472            {
473                interval_names.push(default_extension_interval(extension));
474            }
475        }
476
477        for alteration in &self.alterations {
478            if alteration.degree == 5 && matches!(self.quality, ChordQuality::HalfDiminished) {
479                continue;
480            }
481            if alteration.degree == 5 {
482                continue;
483            }
484            interval_names.push(altered_interval(alteration)?);
485        }
486
487        for addition in &self.additions {
488            interval_names.push(added_interval(addition)?);
489        }
490
491        interval_names.sort_unstable_by_key(|name| interval_sort_key(name));
492        interval_names.dedup();
493
494        let mut pitches = interval_names
495            .into_iter()
496            .map(|name| Interval::from_name(name)?.transpose_pitch(&self.root))
497            .collect::<Result<Vec<_>>>()?;
498
499        if let Some(bass) = &self.bass {
500            if let Some(index) = pitches.iter().position(|pitch| pitch.name() == bass.name()) {
501                let bass = pitches.remove(index);
502                pitches.insert(0, bass);
503            } else {
504                pitches.insert(0, bass.clone());
505            }
506        }
507
508        Chord::new(pitches.as_slice())
509    }
510
511    fn base_intervals(&self) -> Vec<&'static str> {
512        let altered_fifth = self
513            .alterations
514            .iter()
515            .find(|alteration| alteration.degree == 5)
516            .and_then(|alteration| match alteration.semitones {
517                -1 => Some("d5"),
518                1 => Some("a5"),
519                _ => None,
520            });
521
522        let fifth = altered_fifth.unwrap_or("P5");
523        let has_seventh = self
524            .extensions
525            .iter()
526            .any(|degree| matches!(degree, 7 | 9 | 11 | 13));
527
528        let intervals = match self.quality {
529            ChordQuality::Major => {
530                if has_seventh {
531                    vec![(1, "P1"), (3, "M3"), (5, fifth), (7, "M7")]
532                } else {
533                    vec![(1, "P1"), (3, "M3"), (5, fifth)]
534                }
535            }
536            ChordQuality::Minor => {
537                if has_seventh {
538                    vec![(1, "P1"), (3, "m3"), (5, fifth), (7, "m7")]
539                } else {
540                    vec![(1, "P1"), (3, "m3"), (5, fifth)]
541                }
542            }
543            ChordQuality::Dominant => vec![(1, "P1"), (3, "M3"), (5, fifth), (7, "m7")],
544            ChordQuality::Diminished => {
545                if has_seventh {
546                    vec![(1, "P1"), (3, "m3"), (5, "d5"), (7, "d7")]
547                } else {
548                    vec![(1, "P1"), (3, "m3"), (5, "d5")]
549                }
550            }
551            ChordQuality::Augmented => vec![(1, "P1"), (3, "M3"), (5, "a5")],
552            ChordQuality::HalfDiminished => vec![(1, "P1"), (3, "m3"), (5, "d5"), (7, "m7")],
553            ChordQuality::Suspended2 => vec![(1, "P1"), (2, "M2"), (5, fifth)],
554            ChordQuality::Suspended4 => vec![(1, "P1"), (4, "P4"), (5, fifth)],
555            ChordQuality::Power => vec![(1, "P1"), (5, fifth)],
556        };
557
558        intervals
559            .into_iter()
560            .filter_map(|(degree, interval)| {
561                (!self.omissions.contains(&degree)).then_some(interval)
562            })
563            .collect()
564    }
565}
566
567/// Returns the music21 chord-symbol figure for a chord, when identified.
568///
569/// This ports music21's `harmony.chordSymbolFigureFromChord` matching order and
570/// spelling conventions. Music21's "Chord Symbol Cannot Be Identified" result
571/// is represented by an empty list so callers can keep using `Option<String>`.
572pub(crate) fn chord_symbol_spellings(chord: &Chord) -> Vec<String> {
573    chord_symbol_spellings_for_root(chord, None)
574}
575
576pub(crate) fn chord_symbol_spellings_with_root(chord: &Chord, root: u8) -> Vec<String> {
577    chord_symbol_spellings_for_root(chord, Some(root % 12))
578}
579
580fn chord_symbol_spellings_for_root(chord: &Chord, explicit_root: Option<u8>) -> Vec<String> {
581    music21_chord_symbol_figure(chord, explicit_root)
582        .into_iter()
583        .collect()
584}
585
586fn music21_chord_symbol_figure(chord: &Chord, explicit_root: Option<u8>) -> Option<String> {
587    let pitches = chord.pitches();
588    if pitches.iter().any(|pitch| {
589        let ps = pitch.ps();
590        (ps - ps.round()).abs() > FloatType::EPSILON
591    }) {
592        return None;
593    }
594
595    if pitches.is_empty() {
596        return None;
597    }
598
599    let mut root_pitch = if let Some(root) = explicit_root {
600        pitches
601            .iter()
602            .find(|pitch| pitch_class(pitch) == root)
603            .cloned()?
604    } else {
605        find_root_pitch(&pitches).cloned()?
606    };
607
608    if pitches.len() == 1 {
609        return Some(format!("{}pedal", root_pitch.name()));
610    }
611
612    let analysis = Music21ChordAnalysis::new(&pitches, &root_pitch);
613    let matched = identify_music21_chord_type(&analysis)?;
614    let bass_pitch = bass_pitch(&pitches)?;
615    let mut notation = matched.notation;
616    let mut abbreviation = matched.abbreviation;
617
618    if pitch_class(bass_pitch) != pitch_class(&root_pitch)
619        && matched.kind == "suspended-second"
620        && matched.abbreviation == "sus2"
621    {
622        root_pitch = bass_pitch.clone();
623        notation = "1,4,5";
624        abbreviation = "sus";
625    }
626
627    let mut figure = format!("{}{}", root_pitch.name(), abbreviation);
628    if pitch_class(bass_pitch) != pitch_class(&root_pitch) {
629        figure.push('/');
630        figure.push_str(&bass_pitch.name());
631    }
632
633    let perfect = perfect_pitch_names(&root_pitch, notation)?;
634    let in_pitches = pitches
635        .iter()
636        .map(Pitch::name)
637        .collect::<BTreeSet<String>>();
638
639    if !perfect.is_superset(&in_pitches) {
640        let additions = in_pitches.difference(&perfect).cloned().collect::<Vec<_>>();
641        let subtractions = perfect.difference(&in_pitches).cloned().collect::<Vec<_>>();
642
643        if !additions.is_empty() {
644            figure.push_str("add");
645            figure.push_str(&additions.join(","));
646        }
647        if !subtractions.is_empty() {
648            figure.push_str("omit");
649            figure.push_str(&subtractions.join(","));
650        }
651    }
652
653    Some(figure)
654}
655
656impl Music21ChordAnalysis {
657    fn new(pitches: &[Pitch], root_pitch: &Pitch) -> Self {
658        let d3 = semitones_from_chord_step(pitches, root_pitch, 3);
659        let d5 = semitones_from_chord_step(pitches, root_pitch, 5);
660        let d7 = semitones_from_chord_step(pitches, root_pitch, 7);
661        let d9 = semitones_from_chord_step(pitches, root_pitch, 2);
662        let d11 = semitones_from_chord_step(pitches, root_pitch, 4);
663        let d13 = semitones_from_chord_step(pitches, root_pitch, 6);
664        let unique_pitch_names = pitches
665            .iter()
666            .map(Pitch::name)
667            .collect::<BTreeSet<String>>();
668
669        Self {
670            d3,
671            d5,
672            d7,
673            d9,
674            d11,
675            d13,
676            is_triad: unique_pitch_names.len() == 3 && d3.is_some() && d5.is_some(),
677            is_seventh: unique_pitch_names.len() == 4
678                && d3.is_some()
679                && d5.is_some()
680                && d7.is_some(),
681        }
682    }
683}
684
685fn identify_music21_chord_type(analysis: &Music21ChordAnalysis) -> Option<Music21FigureMatch> {
686    let mut matched = None;
687
688    for chord_type in MUSIC21_CHORD_TYPES {
689        let chord_degrees = chord_degrees_for_notation(chord_type.notation)?;
690        let is_match = match chord_degrees.len() {
691            2 if analysis.is_triad => {
692                compare_music21_degrees(&[analysis.d3, analysis.d5], &chord_degrees, &[])
693            }
694            3 if analysis.is_seventh => compare_music21_degrees(
695                &[analysis.d3, analysis.d5, analysis.d7],
696                &chord_degrees,
697                &[],
698            ),
699            4 if music21_truthy(analysis.d9)
700                && !music21_truthy(analysis.d11)
701                && !music21_truthy(analysis.d13) =>
702            {
703                compare_music21_degrees(
704                    &[analysis.d3, analysis.d5, analysis.d7, analysis.d9],
705                    &chord_degrees,
706                    &[5],
707                )
708            }
709            5 if music21_truthy(analysis.d11) && !music21_truthy(analysis.d13) => {
710                compare_music21_degrees(
711                    &[
712                        analysis.d3,
713                        analysis.d5,
714                        analysis.d7,
715                        analysis.d9,
716                        analysis.d11,
717                    ],
718                    &chord_degrees,
719                    &[3, 5],
720                )
721            }
722            6 if music21_truthy(analysis.d13) => compare_music21_degrees(
723                &[
724                    analysis.d3,
725                    analysis.d5,
726                    analysis.d7,
727                    analysis.d9,
728                    analysis.d11,
729                    analysis.d13,
730                ],
731                &chord_degrees,
732                &[5, 11, 9],
733            ),
734            _ => false,
735        };
736
737        if is_match {
738            matched = Some(Music21FigureMatch {
739                kind: chord_type.kind,
740                notation: chord_type.notation,
741                abbreviation: chord_type.abbreviation,
742            });
743        }
744    }
745
746    if matched.is_some() {
747        return matched;
748    }
749
750    let mut number_of_matched_degrees = 0;
751    for chord_type in MUSIC21_CHORD_TYPES {
752        let chord_degrees = chord_degrees_for_notation(chord_type.notation)?;
753        let mut degrees = degree_numbers_for_notation(chord_type.notation)?;
754        degrees.sort_unstable();
755        let to_compare = degrees
756            .into_iter()
757            .filter(|degree| *degree != 1)
758            .map(|degree| analysis_value_for_degree(analysis, degree))
759            .collect::<Vec<_>>();
760
761        if compare_music21_degrees(&to_compare, &chord_degrees, &[])
762            && number_of_matched_degrees < chord_degrees.len()
763        {
764            number_of_matched_degrees = chord_degrees.len();
765            matched = Some(Music21FigureMatch {
766                kind: chord_type.kind,
767                notation: chord_type.notation,
768                abbreviation: chord_type.abbreviation,
769            });
770        }
771    }
772
773    matched
774}
775
776fn compare_music21_degrees(
777    in_chord_nums: &[Option<u8>],
778    given_chord_nums: &[u8],
779    permitted_omissions: &[u8],
780) -> bool {
781    if given_chord_nums.len() > in_chord_nums.len() {
782        return false;
783    }
784
785    for (index, expected) in given_chord_nums.iter().enumerate() {
786        if in_chord_nums[index] == Some(*expected) {
787            continue;
788        }
789
790        let (degree, natural) = match index {
791            0 => (3, 4),
792            1 => (5, 7),
793            2 => (7, 11),
794            3 => (9, 2),
795            4 => (11, 5),
796            5 => (13, 9),
797            _ => return false,
798        };
799
800        if !(permitted_omissions.contains(&degree)
801            && *expected == natural
802            && in_chord_nums[index].is_none())
803        {
804            return false;
805        }
806    }
807
808    true
809}
810
811fn music21_truthy(value: Option<u8>) -> bool {
812    value.is_some_and(|value| value != 0)
813}
814
815fn chord_degrees_for_notation(notation: &str) -> Option<Vec<u8>> {
816    notation
817        .split(',')
818        .filter(|token| *token != "1")
819        .map(|token| parse_music21_degree(token).map(|degree| degree.semitone))
820        .collect()
821}
822
823fn degree_numbers_for_notation(notation: &str) -> Option<Vec<u8>> {
824    notation
825        .split(',')
826        .map(|token| parse_music21_degree(token).map(|degree| degree.degree))
827        .collect()
828}
829
830fn parse_music21_degree(token: &str) -> Option<Music21Degree> {
831    let alteration = token.chars().fold(0_i32, |sum, ch| match ch {
832        '#' => sum + 1,
833        '-' => sum - 1,
834        _ => sum,
835    });
836    let degree = token
837        .chars()
838        .filter(char::is_ascii_digit)
839        .collect::<String>()
840        .parse::<u8>()
841        .ok()?;
842    let semitone = (base_semitone_for_degree(degree)? + alteration).rem_euclid(12) as u8;
843
844    Some(Music21Degree { degree, semitone })
845}
846
847fn base_semitone_for_degree(degree: u8) -> Option<IntegerType> {
848    match degree {
849        1 => Some(0),
850        2 | 9 => Some(2),
851        3 => Some(4),
852        4 | 11 => Some(5),
853        5 => Some(7),
854        6 | 13 => Some(9),
855        7 => Some(11),
856        _ => None,
857    }
858}
859
860fn analysis_value_for_degree(analysis: &Music21ChordAnalysis, degree: u8) -> Option<u8> {
861    match degree {
862        2 | 9 => analysis.d9,
863        3 => analysis.d3,
864        4 | 11 => analysis.d11,
865        5 => analysis.d5,
866        6 | 13 => analysis.d13,
867        7 => analysis.d7,
868        _ => None,
869    }
870}
871
872fn semitones_from_chord_step(pitches: &[Pitch], root_pitch: &Pitch, chord_step: u8) -> Option<u8> {
873    let root_step = step_num(root_pitch);
874    let root_pc = pitch_class(root_pitch);
875
876    pitches.iter().find_map(|pitch| {
877        let generic_interval = (step_num(pitch) - root_step).rem_euclid(7) + 1;
878        if generic_interval == chord_step as IntegerType {
879            Some((pitch_class(pitch) + 12 - root_pc) % 12)
880        } else {
881            None
882        }
883    })
884}
885
886fn perfect_pitch_names(root_pitch: &Pitch, notation: &str) -> Option<BTreeSet<String>> {
887    let mut pitch_names = BTreeSet::new();
888    pitch_names.insert(root_pitch.name());
889    for token in notation.split(',').filter(|token| *token != "1") {
890        let degree = parse_music21_degree(token)?;
891        pitch_names.insert(pitch_name_for_music21_degree(
892            root_pitch,
893            degree.degree,
894            degree.semitone,
895        )?);
896    }
897    Some(pitch_names)
898}
899
900fn pitch_name_for_music21_degree(root_pitch: &Pitch, degree: u8, semitone: u8) -> Option<String> {
901    const LETTERS: [char; 7] = ['C', 'D', 'E', 'F', 'G', 'A', 'B'];
902    const NATURAL_PCS: [IntegerType; 7] = [0, 2, 4, 5, 7, 9, 11];
903
904    let root_letter = root_pitch.name().chars().next()?.to_ascii_uppercase();
905    let root_index = LETTERS.iter().position(|letter| *letter == root_letter)?;
906    let target_index = (root_index + (degree.saturating_sub(1) as usize % 7)) % 7;
907    let desired_pc =
908        ((pitch_class(root_pitch) as IntegerType) + semitone as IntegerType).rem_euclid(12);
909    let mut accidental = desired_pc - NATURAL_PCS[target_index];
910    while accidental > 6 {
911        accidental -= 12;
912    }
913    while accidental < -6 {
914        accidental += 12;
915    }
916
917    let mut name = LETTERS[target_index].to_string();
918    if accidental > 0 {
919        name.push_str(&"#".repeat(accidental as usize));
920    } else if accidental < 0 {
921        name.push_str(&"-".repeat((-accidental) as usize));
922    }
923    Some(name)
924}
925
926fn find_root_pitch(pitches: &[Pitch]) -> Option<&Pitch> {
927    let mut non_duplicating_pitches = Vec::new();
928    let mut seen_steps = BTreeSet::new();
929    for pitch in pitches {
930        if seen_steps.insert(step_num(pitch)) {
931            non_duplicating_pitches.push(pitch);
932        }
933    }
934
935    match non_duplicating_pitches.len() {
936        0 => return None,
937        1 => return pitches.first(),
938        7 => return bass_pitch(pitches),
939        _ => {}
940    }
941
942    let mut step_nums_to_pitches = BTreeMap::new();
943    for pitch in &non_duplicating_pitches {
944        step_nums_to_pitches.insert(step_num(pitch), *pitch);
945    }
946    let step_nums = step_nums_to_pitches.keys().copied().collect::<Vec<_>>();
947
948    for start_index in 0..step_nums.len() {
949        let mut all_are_thirds = true;
950        let this_step_num = step_nums[start_index];
951        let mut last_step_num = this_step_num;
952        for end_index in (start_index + 1)..(start_index + step_nums.len()) {
953            let end_step_num = step_nums[end_index % step_nums.len()];
954            if !matches!(end_step_num - last_step_num, 2 | -5) {
955                all_are_thirds = false;
956                break;
957            }
958            last_step_num = end_step_num;
959        }
960        if all_are_thirds {
961            return step_nums_to_pitches.get(&this_step_num).copied();
962        }
963    }
964
965    let ordered_chord_steps = [3, 5, 7, 2, 4, 6];
966    let mut best_pitch = non_duplicating_pitches[0];
967    let mut best_score = FloatType::NEG_INFINITY;
968
969    for pitch in non_duplicating_pitches {
970        let this_step_num = step_num(pitch);
971        let mut score = 0.0;
972        for (root_index, chord_step_test) in ordered_chord_steps.iter().enumerate() {
973            let target = (this_step_num + chord_step_test - 1).rem_euclid(7);
974            if step_nums_to_pitches.contains_key(&target) {
975                score += 1.0 / (root_index as FloatType + 6.0);
976            }
977        }
978        if score > best_score {
979            best_score = score;
980            best_pitch = pitch;
981        }
982    }
983
984    Some(best_pitch)
985}
986
987fn bass_pitch(pitches: &[Pitch]) -> Option<&Pitch> {
988    pitches.iter().min_by(|left, right| {
989        left.ps()
990            .partial_cmp(&right.ps())
991            .unwrap_or(std::cmp::Ordering::Equal)
992    })
993}
994
995fn step_num(pitch: &Pitch) -> IntegerType {
996    pitch.step().step_to_dnn_offset() - 1
997}
998
999fn pitch_class(pitch: &Pitch) -> u8 {
1000    (pitch.ps().round() as IntegerType).rem_euclid(12) as u8
1001}
1002
1003impl FromStr for ChordSymbol {
1004    type Err = Error;
1005
1006    fn from_str(value: &str) -> Result<Self> {
1007        Self::parse(value)
1008    }
1009}
1010
1011impl TryFrom<&str> for ChordSymbol {
1012    type Error = Error;
1013
1014    fn try_from(value: &str) -> Result<Self> {
1015        Self::parse(value)
1016    }
1017}
1018
1019impl TryFrom<String> for ChordSymbol {
1020    type Error = Error;
1021
1022    fn try_from(value: String) -> Result<Self> {
1023        Self::parse(value)
1024    }
1025}
1026
1027#[derive(Clone, Debug, Default, Eq, PartialEq)]
1028struct Music21PitchModifiers {
1029    base: String,
1030    additions: Vec<String>,
1031    omissions: Vec<String>,
1032}
1033
1034fn split_music21_pitch_modifiers(value: &str) -> Music21PitchModifiers {
1035    let Some(start) = find_music21_modifier_start(value) else {
1036        return Music21PitchModifiers {
1037            base: value.to_string(),
1038            ..Music21PitchModifiers::default()
1039        };
1040    };
1041
1042    let mut parts = Music21PitchModifiers {
1043        base: value[..start].trim_end().to_string(),
1044        ..Music21PitchModifiers::default()
1045    };
1046    let mut cursor = start;
1047    while cursor < value.len() {
1048        let Some(marker) = music21_modifier_at(value, cursor) else {
1049            cursor += value[cursor..]
1050                .chars()
1051                .next()
1052                .map(char::len_utf8)
1053                .unwrap_or(1);
1054            continue;
1055        };
1056        let content_start = cursor + marker.len();
1057        let content_end = find_music21_modifier_start(&value[content_start..])
1058            .map(|relative| content_start + relative)
1059            .unwrap_or(value.len());
1060        let tokens = value[content_start..content_end]
1061            .split(|ch: char| ch == ',' || ch.is_whitespace())
1062            .filter(|token| !token.trim().is_empty())
1063            .map(|token| token.trim().to_string());
1064
1065        match marker {
1066            "add" => parts.additions.extend(tokens),
1067            "omit" => parts.omissions.extend(tokens),
1068            _ => {}
1069        }
1070        cursor = content_end;
1071    }
1072
1073    parts
1074}
1075
1076fn find_music21_modifier_start(value: &str) -> Option<usize> {
1077    value
1078        .char_indices()
1079        .find_map(|(idx, _)| music21_modifier_at(value, idx).map(|_| idx))
1080}
1081
1082fn music21_modifier_at(value: &str, idx: usize) -> Option<&'static str> {
1083    let rest = value.get(idx..)?;
1084    let lower = rest.to_ascii_lowercase();
1085    if lower.starts_with("add") && !matches!(rest.as_bytes().get(3), Some(b'(')) {
1086        Some("add")
1087    } else if lower.starts_with("omit") && !matches!(rest.as_bytes().get(4), Some(b'(')) {
1088        Some("omit")
1089    } else {
1090        None
1091    }
1092}
1093
1094fn pitch_name_addition(root: &Pitch, pitch_name: &str) -> Option<ChordAlteration> {
1095    let pitch = Pitch::from_name(pitch_name).ok()?;
1096    let degree = pitch_name_degree(root, pitch_name)?;
1097    let actual = ((pitch_class(&pitch) + 12 - pitch_class(root)) % 12) as IntegerType;
1098    let base = base_semitone_for_degree(degree)?.rem_euclid(12);
1099    let mut semitones = actual - base;
1100    while semitones > 6 {
1101        semitones -= 12;
1102    }
1103    while semitones < -6 {
1104        semitones += 12;
1105    }
1106
1107    Some(ChordAlteration::new(degree, semitones))
1108}
1109
1110fn pitch_name_degree(root: &Pitch, pitch_name: &str) -> Option<u8> {
1111    let pitch = Pitch::from_name(pitch_name).ok()?;
1112    let generic = (step_num(&pitch) - step_num(root)).rem_euclid(7) + 1;
1113    Some(match generic as u8 {
1114        2 => 9,
1115        4 => 11,
1116        6 => 13,
1117        degree => degree,
1118    })
1119}
1120
1121fn add_implicit_music21_alterations(suffix: &str, alterations: &mut Vec<ChordAlteration>) {
1122    let lower = suffix.to_ascii_lowercase();
1123    if lower.contains("dim5")
1124        && !alterations
1125            .iter()
1126            .any(|alteration| alteration.degree == 5 && alteration.semitones == -1)
1127    {
1128        alterations.push(ChordAlteration::new(5, -1));
1129    }
1130    if lower.ends_with("7+")
1131        && !alterations
1132            .iter()
1133            .any(|alteration| alteration.degree == 5 && alteration.semitones == 1)
1134    {
1135        alterations.push(ChordAlteration::new(5, 1));
1136    }
1137}
1138
1139fn parse_quality(suffix: &str, alterations: &[ChordAlteration]) -> ChordQuality {
1140    let lower = suffix.to_ascii_lowercase();
1141    let has_flat_five = alterations
1142        .iter()
1143        .any(|alteration| alteration.degree == 5 && alteration.semitones == -1);
1144
1145    if suffix.starts_with('\u{00f8}') {
1146        ChordQuality::HalfDiminished
1147    } else if lower.contains("sus2") {
1148        ChordQuality::Suspended2
1149    } else if lower.contains("sus") {
1150        ChordQuality::Suspended4
1151    } else if lower.starts_with("maj") || suffix.starts_with('M') {
1152        ChordQuality::Major
1153    } else if lower.starts_with("min") || lower.starts_with('m') {
1154        if has_flat_five && lower.contains('7') {
1155            ChordQuality::HalfDiminished
1156        } else {
1157            ChordQuality::Minor
1158        }
1159    } else if lower.starts_with("dim") || lower.starts_with('o') {
1160        ChordQuality::Diminished
1161    } else if lower.starts_with("aug") || lower.starts_with('+') {
1162        ChordQuality::Augmented
1163    } else if lower.starts_with('5') {
1164        ChordQuality::Power
1165    } else if lower.starts_with("dom")
1166        || lower.starts_with('7')
1167        || lower.starts_with('9')
1168        || lower.starts_with("11")
1169        || lower.starts_with("13")
1170    {
1171        ChordQuality::Dominant
1172    } else {
1173        ChordQuality::Major
1174    }
1175}
1176
1177fn parse_extensions(suffix: &str, alterations: &[ChordAlteration]) -> Vec<u8> {
1178    let mut extensions = Vec::new();
1179    let bytes = suffix.as_bytes();
1180    let mut idx = 0;
1181    while idx < bytes.len() {
1182        let byte = bytes[idx];
1183        if byte.is_ascii_digit()
1184            && idx
1185                .checked_sub(1)
1186                .is_none_or(|prev| !matches!(bytes[prev] as char, '#' | 'b' | '-'))
1187        {
1188            let start = idx;
1189            while idx < bytes.len() && bytes[idx].is_ascii_digit() {
1190                idx += 1;
1191            }
1192            if let Ok(degree) = suffix[start..idx].parse::<u8>()
1193                && matches!(degree, 6 | 7 | 9 | 11 | 13)
1194                && !extensions.contains(&degree)
1195            {
1196                extensions.push(degree);
1197            }
1198        } else {
1199            idx += 1;
1200        }
1201    }
1202
1203    for alteration in alterations {
1204        if alteration.degree > 5 && !extensions.contains(&alteration.degree) {
1205            extensions.push(alteration.degree);
1206        }
1207    }
1208
1209    extensions.sort_unstable();
1210    extensions
1211}
1212
1213fn strip_addition_groups(suffix: &str) -> String {
1214    let lower = suffix.to_ascii_lowercase();
1215    let mut stripped = String::with_capacity(suffix.len());
1216    let mut cursor = 0;
1217
1218    while let Some(relative_start) = lower[cursor..].find("add(") {
1219        let start = cursor + relative_start;
1220        let content_start = start + "add(".len();
1221        let Some(relative_end) = suffix[content_start..].find(')') else {
1222            break;
1223        };
1224
1225        stripped.push_str(&suffix[cursor..start]);
1226        cursor = content_start + relative_end + 1;
1227    }
1228
1229    stripped.push_str(&suffix[cursor..]);
1230    stripped
1231}
1232
1233fn parse_additions(suffix: &str) -> Vec<ChordAlteration> {
1234    let lower = suffix.to_ascii_lowercase();
1235    let mut additions = Vec::new();
1236    let mut cursor = 0;
1237
1238    while let Some(relative_start) = lower[cursor..].find("add(") {
1239        let content_start = cursor + relative_start + "add(".len();
1240        let Some(relative_end) = suffix[content_start..].find(')') else {
1241            break;
1242        };
1243        let content_end = content_start + relative_end;
1244
1245        for token in
1246            suffix[content_start..content_end].split(|ch: char| ch == ',' || ch.is_whitespace())
1247        {
1248            if let Some(addition) = parse_addition_token(token) {
1249                additions.push(addition);
1250            }
1251        }
1252
1253        cursor = content_end + 1;
1254    }
1255
1256    additions
1257}
1258
1259fn parse_omissions(suffix: &str) -> Vec<u8> {
1260    let lower = suffix.to_ascii_lowercase();
1261    let bytes = lower.as_bytes();
1262    let mut omissions = Vec::new();
1263    let mut cursor = 0;
1264
1265    while cursor < bytes.len() {
1266        let marker_len = if bytes[cursor..].starts_with(b"omit") {
1267            4
1268        } else if bytes[cursor..].starts_with(b"no") {
1269            2
1270        } else {
1271            cursor += 1;
1272            continue;
1273        };
1274
1275        cursor += marker_len;
1276        while cursor < bytes.len() && (bytes[cursor].is_ascii_whitespace() || bytes[cursor] == b'(')
1277        {
1278            cursor += 1;
1279        }
1280
1281        let degree_start = cursor;
1282        while cursor < bytes.len() && bytes[cursor].is_ascii_digit() {
1283            cursor += 1;
1284        }
1285        if degree_start == cursor {
1286            continue;
1287        }
1288
1289        if let Ok(degree) = std::str::from_utf8(&bytes[degree_start..cursor])
1290            .unwrap_or_default()
1291            .parse::<u8>()
1292            && !omissions.contains(&degree)
1293        {
1294            omissions.push(degree);
1295        }
1296    }
1297
1298    omissions
1299}
1300
1301fn parse_addition_token(token: &str) -> Option<ChordAlteration> {
1302    let token = token.trim();
1303    if token.is_empty() {
1304        return None;
1305    }
1306
1307    let (semitones, degree) = match token.as_bytes()[0] as char {
1308        '#' => (1, &token[1..]),
1309        'b' | '-' => (-1, &token[1..]),
1310        _ => (0, token),
1311    };
1312
1313    degree
1314        .parse::<u8>()
1315        .ok()
1316        .map(|degree| ChordAlteration::new(degree, semitones))
1317}
1318
1319fn parse_alterations(suffix: &str) -> Vec<ChordAlteration> {
1320    let bytes = suffix.as_bytes();
1321    let mut alterations = Vec::new();
1322    let mut idx = 0;
1323    while idx < bytes.len() {
1324        let semitones = match bytes[idx] as char {
1325            '#' => 1,
1326            'b' | '-' => -1,
1327            _ => {
1328                idx += 1;
1329                continue;
1330            }
1331        };
1332        idx += 1;
1333        let start = idx;
1334        while idx < bytes.len() && bytes[idx].is_ascii_digit() {
1335            idx += 1;
1336        }
1337        if start == idx {
1338            continue;
1339        }
1340        if let Ok(degree) = suffix[start..idx].parse::<u8>() {
1341            alterations.push(ChordAlteration::new(degree, semitones));
1342        }
1343    }
1344    alterations
1345}
1346
1347fn parse_pitch_only(value: &str) -> Result<Pitch> {
1348    let (name, rest) = parse_pitch_prefix(value)?;
1349    if !rest.is_empty() {
1350        return Err(Error::Chord(format!("invalid slash bass {value:?}")));
1351    }
1352    Pitch::from_name(name)
1353}
1354
1355fn parse_pitch_prefix(value: &str) -> Result<(String, &str)> {
1356    let mut chars = value.char_indices();
1357    let Some((_, first)) = chars.next() else {
1358        return Err(Error::Chord("missing pitch name".to_string()));
1359    };
1360
1361    if !matches!(first.to_ascii_uppercase(), 'A'..='G') {
1362        return Err(Error::Chord(format!("invalid pitch name in {value:?}")));
1363    }
1364
1365    let mut end = first.len_utf8();
1366    let mut name = first.to_ascii_uppercase().to_string();
1367    for (idx, ch) in chars {
1368        match ch {
1369            '#' => {
1370                name.push('#');
1371                end = idx + ch.len_utf8();
1372            }
1373            'b' | '-' => {
1374                name.push('-');
1375                end = idx + ch.len_utf8();
1376            }
1377            _ => break,
1378        }
1379    }
1380
1381    Ok((name, &value[end..]))
1382}
1383
1384fn default_extension_interval(degree: u8) -> &'static str {
1385    match degree {
1386        6 => "M6",
1387        9 => "M9",
1388        11 => "P11",
1389        13 => "M13",
1390        _ => "P1",
1391    }
1392}
1393
1394fn altered_interval(alteration: &ChordAlteration) -> Result<&'static str> {
1395    match (alteration.degree, alteration.semitones) {
1396        (5, -1) => Ok("d5"),
1397        (5, 1) => Ok("a5"),
1398        (9, -1) => Ok("m9"),
1399        (9, 1) => Ok("a9"),
1400        (11, 1) => Ok("a11"),
1401        (13, -1) => Ok("m13"),
1402        (13, 1) => Ok("a13"),
1403        _ => Err(Error::Chord(format!(
1404            "unsupported chord-symbol alteration {alteration:?}"
1405        ))),
1406    }
1407}
1408
1409fn added_interval(addition: &ChordAlteration) -> Result<&'static str> {
1410    let degree = match addition.degree {
1411        2 => 9,
1412        4 => 11,
1413        6 => 13,
1414        degree => degree,
1415    };
1416
1417    match (degree, addition.semitones) {
1418        (3, -1) => Ok("m3"),
1419        (3, 0) => Ok("M3"),
1420        (3, 1) => Ok("a3"),
1421        (5, -1) => Ok("d5"),
1422        (5, 0) => Ok("P5"),
1423        (5, 1) => Ok("a5"),
1424        (7, -1) => Ok("m7"),
1425        (7, 0) => Ok("M7"),
1426        (9, -1) => Ok("m9"),
1427        (9, 0) => Ok("M9"),
1428        (9, 1) => Ok("a9"),
1429        (11, -1) => Ok("d11"),
1430        (11, 0) => Ok("P11"),
1431        (11, 1) => Ok("a11"),
1432        (13, -1) => Ok("m13"),
1433        (13, 0) => Ok("M13"),
1434        (13, 1) => Ok("a13"),
1435        _ => Err(Error::Chord(format!(
1436            "unsupported chord-symbol added tone {addition:?}"
1437        ))),
1438    }
1439}
1440
1441fn interval_sort_key(name: &str) -> u8 {
1442    name.chars()
1443        .filter(|ch| ch.is_ascii_digit())
1444        .collect::<String>()
1445        .parse::<u8>()
1446        .unwrap_or(1)
1447}
1448
1449#[cfg(test)]
1450mod tests {
1451    use super::*;
1452
1453    #[test]
1454    fn parses_major_seventh_symbol() {
1455        let symbol: ChordSymbol = "Cmaj7".parse().unwrap();
1456        assert_eq!(symbol.root().name(), "C");
1457        assert_eq!(symbol.quality(), ChordQuality::Major);
1458        assert_eq!(symbol.extensions(), &[7]);
1459        assert_eq!(
1460            symbol.to_chord().unwrap().pitched_common_name(),
1461            "C-major seventh chord"
1462        );
1463    }
1464
1465    #[test]
1466    fn parses_half_diminished_symbol() {
1467        let symbol = ChordSymbol::parse("F#m7b5").unwrap();
1468        assert_eq!(symbol.root().name(), "F#");
1469        assert_eq!(symbol.quality(), ChordQuality::HalfDiminished);
1470        assert_eq!(
1471            symbol.to_chord().unwrap().pitched_common_name(),
1472            "F#-half-diminished seventh chord"
1473        );
1474    }
1475
1476    #[test]
1477    fn parses_dominant_altered_symbol() {
1478        let symbol = ChordSymbol::parse("Bb7#11").unwrap();
1479        assert_eq!(symbol.root().name(), "B-");
1480        assert_eq!(symbol.quality(), ChordQuality::Dominant);
1481        assert_eq!(symbol.extensions(), &[7, 11]);
1482        assert_eq!(symbol.alterations()[0], ChordAlteration::new(11, 1));
1483        let names = symbol
1484            .to_chord()
1485            .unwrap()
1486            .pitches()
1487            .iter()
1488            .map(Pitch::name)
1489            .collect::<Vec<_>>();
1490        assert_eq!(names, vec!["B-", "D", "F", "A-", "E"]);
1491    }
1492
1493    #[test]
1494    fn parses_added_tones_without_changing_the_base_chord() {
1495        let symbol = ChordSymbol::parse("Cdim9 add(#5)").unwrap();
1496        assert_eq!(symbol.extensions(), &[9]);
1497        assert_eq!(symbol.additions(), &[ChordAlteration::new(5, 1)]);
1498        assert_eq!(
1499            symbol.to_chord().unwrap().pitch_classes(),
1500            vec![0, 2, 3, 6, 8, 9]
1501        );
1502    }
1503
1504    #[test]
1505    fn parses_altered_dominant_with_slash_bass() {
1506        let symbol = ChordSymbol::parse("D7b9#11/C").unwrap();
1507        assert_eq!(symbol.root().name(), "D");
1508        assert_eq!(symbol.bass().map(Pitch::name).as_deref(), Some("C"));
1509        assert_eq!(symbol.quality(), ChordQuality::Dominant);
1510        assert_eq!(symbol.extensions(), &[7, 9, 11]);
1511        assert_eq!(
1512            symbol.alterations(),
1513            &[ChordAlteration::new(9, -1), ChordAlteration::new(11, 1)]
1514        );
1515        assert_eq!(
1516            symbol.to_chord().unwrap().pitch_classes(),
1517            vec![0, 2, 3, 6, 8, 9]
1518        );
1519    }
1520
1521    #[test]
1522    fn parses_music21_pitch_name_additions() {
1523        let symbol = ChordSymbol::parse("Ddom7dim5/CaddA,E-").unwrap();
1524
1525        assert_eq!(symbol.root().name(), "D");
1526        assert_eq!(symbol.bass().map(Pitch::name).as_deref(), Some("C"));
1527        assert_eq!(symbol.quality(), ChordQuality::Dominant);
1528        assert_eq!(symbol.alterations(), &[ChordAlteration::new(5, -1)]);
1529        assert_eq!(
1530            symbol.additions(),
1531            &[ChordAlteration::new(5, 0), ChordAlteration::new(9, -1)]
1532        );
1533        assert_eq!(
1534            symbol.to_chord().unwrap().pitch_classes(),
1535            vec![0, 2, 3, 6, 8, 9]
1536        );
1537    }
1538
1539    #[test]
1540    fn generates_petrushka_chord_symbol_name() {
1541        let chord = Chord::new("C4 D4 Eb4 F#4 Ab4 A4").unwrap();
1542        let names = chord_symbol_spellings(&chord);
1543
1544        assert_eq!(
1545            names.first().map(String::as_str),
1546            Some("Ddom7dim5/CaddA,E-")
1547        );
1548        assert!(names.iter().any(|name| name == "Ddom7dim5/CaddA,E-"));
1549    }
1550
1551    #[test]
1552    fn generates_common_chord_symbols() {
1553        let major_seventh = Chord::new("C E G B").unwrap();
1554        let dominant_ninth = Chord::new("C E G B- D").unwrap();
1555
1556        assert_eq!(
1557            chord_symbol_spellings(&major_seventh)
1558                .first()
1559                .map(String::as_str),
1560            Some("Cmaj7")
1561        );
1562        assert_eq!(
1563            chord_symbol_spellings(&dominant_ninth)
1564                .first()
1565                .map(String::as_str),
1566            Some("C9")
1567        );
1568    }
1569
1570    #[test]
1571    fn split_third_triads_do_not_spell_lower_third_as_sharp_nine() {
1572        let split_third = Chord::new("D4 A4 F#4 F4").unwrap();
1573        let names = chord_symbol_spellings(&split_third);
1574
1575        assert_eq!(names.first().map(String::as_str), Some("DaddF"));
1576        assert!(!names.iter().any(|name| name == "D add(#9)"));
1577    }
1578
1579    #[test]
1580    fn altered_dominants_use_music21_pitch_name_additions() {
1581        let altered_dominant = Chord::new("C4 E4 G4 Bb4 Eb5").unwrap();
1582        let names = chord_symbol_spellings(&altered_dominant);
1583
1584        assert_eq!(names.first().map(String::as_str), Some("C7addE-"));
1585    }
1586
1587    #[test]
1588    fn unrecognized_music21_figures_return_no_symbol() {
1589        let chord = Chord::new("F4 C5 D5 E-5").unwrap();
1590        let names = chord_symbol_spellings(&chord);
1591
1592        assert!(names.is_empty());
1593    }
1594
1595    #[test]
1596    fn generates_music21_figures_with_explicit_root() {
1597        let major_triad = Chord::new("G3 C4 E4").unwrap();
1598        let dominant_seventh = Chord::new("G3 B-3 C4 E4").unwrap();
1599        let power_chord = Chord::new("C4 G4").unwrap();
1600        let unsupported_dyad = Chord::new("C4 A4").unwrap();
1601
1602        assert_eq!(
1603            chord_symbol_spellings_with_root(&major_triad, 0)
1604                .first()
1605                .map(String::as_str),
1606            Some("C/G")
1607        );
1608        assert_eq!(
1609            chord_symbol_spellings_with_root(&dominant_seventh, 0)
1610                .first()
1611                .map(String::as_str),
1612            Some("C7/G")
1613        );
1614        assert_eq!(
1615            chord_symbol_spellings_with_root(&power_chord, 0)
1616                .first()
1617                .map(String::as_str),
1618            Some("Cpower")
1619        );
1620        assert!(chord_symbol_spellings_with_root(&unsupported_dyad, 0).is_empty());
1621    }
1622
1623    #[test]
1624    fn dense_sets_follow_music21_fallback_matching() {
1625        let chord = Chord::new("C4 D-4 E-4 E4 F#4 G4 A-4 A4").unwrap();
1626
1627        assert_eq!(
1628            chord_symbol_spellings(&chord).first().map(String::as_str),
1629            Some("CsusaddA,A-,D-,E,E-,F#omitF")
1630        );
1631    }
1632}