Skip to main content

music21_rs/chordsymbol/
mod.rs

1use std::str::FromStr;
2
3use crate::{
4    chord::Chord,
5    chord::root::{pitch_class, step_num},
6    defaults::{FloatType, IntegerType},
7    error::{Error, Result},
8    interval::Interval,
9    pitch::Pitch,
10};
11use std::collections::BTreeSet;
12
13mod figure;
14mod parse;
15mod tables;
16
17pub use figure::{
18    ChordSymbolFigure, chord_symbol_figure_from_chord, chord_symbol_from_chord,
19    chord_symbol_kind_from_chord,
20};
21pub(crate) use figure::{chord_symbol_spellings, chord_symbol_spellings_with_root};
22pub use tables::{
23    Music21ChordType, abbreviations_for_kind, current_abbreviation_for_kind,
24    known_chord_symbol_types, notation_for_kind,
25};
26
27use parse::*;
28use tables::*;
29
30/// Tertian quality parsed from a chord symbol.
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
33pub enum ChordQuality {
34    /// Major triad or major-family sonority.
35    Major,
36    /// Minor triad or minor-family sonority.
37    Minor,
38    /// Dominant seventh-family sonority.
39    Dominant,
40    /// Diminished triad or diminished-family sonority.
41    Diminished,
42    /// Augmented triad sonority.
43    Augmented,
44    /// Half-diminished seventh-family sonority.
45    HalfDiminished,
46    /// Suspended-second sonority.
47    Suspended2,
48    /// Suspended-fourth sonority.
49    Suspended4,
50    /// Power-chord sonority containing a root and fifth.
51    Power,
52    /// A single pitch: music21's `pedal` kind.
53    Pedal,
54}
55
56/// A chord-symbol alteration such as `b5` or `#11`.
57#[derive(Clone, Debug, Eq, PartialEq)]
58#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
59pub struct ChordAlteration {
60    degree: u8,
61    semitones: IntegerType,
62}
63
64impl ChordAlteration {
65    /// Creates an alteration for a scale degree and semitone displacement.
66    pub fn new(degree: u8, semitones: IntegerType) -> Self {
67        Self { degree, semitones }
68    }
69
70    /// Returns the altered or added chord degree.
71    pub fn degree(&self) -> u8 {
72        self.degree
73    }
74
75    /// Returns the semitone displacement from the unaltered degree.
76    pub fn semitones(&self) -> IntegerType {
77        self.semitones
78    }
79}
80
81/// Parsed chord symbol.
82#[derive(Clone, Debug, PartialEq)]
83#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
84#[must_use]
85pub struct ChordSymbol {
86    figure: String,
87    root: Pitch,
88    bass: Option<Pitch>,
89    quality: ChordQuality,
90    extensions: Vec<u8>,
91    alterations: Vec<ChordAlteration>,
92    #[cfg_attr(feature = "serde", serde(default))]
93    omissions: Vec<u8>,
94    #[cfg_attr(feature = "serde", serde(default))]
95    additions: Vec<ChordAlteration>,
96    /// music21's kind, where the shorthand is one of its abbreviations.
97    #[cfg_attr(feature = "serde", serde(default))]
98    kind: Option<String>,
99    /// What music21 reads after the kind, applied in order when the symbol
100    /// is realized.
101    #[cfg_attr(feature = "serde", serde(default))]
102    modifications: Vec<Modification>,
103}
104
105/// One change music21 reads off a figure after its kind: a degree added,
106/// taken away or altered, with the semitones it is altered by.
107#[derive(Clone, Debug, PartialEq, Eq)]
108#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
109struct Modification {
110    kind: ModificationKind,
111    degree: u8,
112    alter: IntegerType,
113}
114
115/// What a [`Modification`] does: music21's `add`, `subtract` (written
116/// `omit`) and `alter`.
117#[derive(Clone, Copy, Debug, PartialEq, Eq)]
118#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
119enum ModificationKind {
120    Add,
121    Subtract,
122    Alter,
123}
124
125/// The kind a shorthand names outright, music21's `_getKindFromShortHand`:
126/// the shorthand up to its first `add`, `alter`, `omit` or `subtract`, and
127/// up to the first sharp or flat that has a digit after it, matched whole
128/// against the abbreviations. With it, how much of the shorthand it took.
129fn music21_kind(shorthand: &str) -> Option<(&'static Music21ChordType, usize)> {
130    let mut cut = shorthand.len();
131    for marker in ["add", "alter", "omit", "subtract"] {
132        if let Some(at) = shorthand.find(marker) {
133            cut = cut.min(at);
134        }
135    }
136    let mut head = &shorthand[..cut];
137    if let Some(at) = head.find('#')
138        && head[at + 1..].starts_with(|c: char| c.is_ascii_digit())
139    {
140        head = &head[..at];
141    }
142    if let Some(at) = head.find('b')
143        && at < head.len() - 1
144        && head[at + 1..].starts_with(|c: char| c.is_ascii_digit())
145        && !head.contains("ob9")
146        && !head.contains("øb9")
147    {
148        head = &head[..at];
149    }
150    MUSIC21_CHORD_TYPES
151        .iter()
152        .find(|chord_type| chord_type.abbreviations.contains(&head))
153        .map(|chord_type| (chord_type, head.len()))
154}
155
156/// What music21's `_parseFigure` reads out of the shorthand after the kind:
157/// the `add`, `alter`, `omit` and `subtract` tokens in the order written,
158/// then every sharpened or flattened degree left over, each as an addition.
159/// `None` where the leftover is not degrees at all, which music21 refuses.
160fn music21_modifications(remaining: &str) -> Option<Vec<Modification>> {
161    const MARKERS: [(&str, ModificationKind); 4] = [
162        ("add", ModificationKind::Add),
163        ("alter", ModificationKind::Alter),
164        ("omit", ModificationKind::Subtract),
165        ("subtract", ModificationKind::Subtract),
166    ];
167    let mut out = Vec::new();
168    let first_marker = MARKERS
169        .iter()
170        .filter_map(|(marker, _)| remaining.find(marker))
171        .min();
172    if let Some(start) = first_marker {
173        let mut text = &remaining[start..];
174        while let Some((at, marker, kind)) = MARKERS
175            .iter()
176            .filter_map(|(marker, kind)| text.find(marker).map(|at| (at, *marker, *kind)))
177            .min_by_key(|(at, _, _)| *at)
178        {
179            let mut rest = &text[at + marker.len()..];
180            let mut alter = 0;
181            if let Some(after) = rest.strip_prefix('b') {
182                alter = -1;
183                rest = after;
184            } else if let Some(after) = rest.strip_prefix('#') {
185                alter = 1;
186                rest = after;
187            }
188            let digits = rest.chars().take_while(char::is_ascii_digit).count().min(2);
189            let degree_text = &rest[..digits];
190            if let Ok(degree) = degree_text.parse::<u8>() {
191                out.push(Modification {
192                    kind,
193                    degree,
194                    alter,
195                });
196            }
197            text = &rest[digits..];
198        }
199    }
200
201    // The degrees before any marker, sharpened or flattened, each an
202    // addition: `b9` in `C7b9`, or `#5b9` in `C7#5b9`.
203    let before: String = remaining[..first_marker.unwrap_or(remaining.len())].replace(',', "");
204    let mut items: Vec<String> = Vec::new();
205    if before.contains(['b', '#']) {
206        let chars: Vec<char> = before.chars().collect();
207        let mut current = String::new();
208        let mut index = 0;
209        while index < chars.len() {
210            if matches!(chars[index], 'b' | '#') {
211                if !current.is_empty() {
212                    items.push(std::mem::take(&mut current));
213                }
214                let mut group = String::new();
215                while index < chars.len() && matches!(chars[index], 'b' | '#') {
216                    group.push(chars[index]);
217                    index += 1;
218                }
219                while index < chars.len() && !matches!(chars[index], 'b' | '#') {
220                    group.push(chars[index]);
221                    index += 1;
222                }
223                items.push(group);
224            } else {
225                current.push(chars[index]);
226                index += 1;
227            }
228        }
229        if !current.is_empty() {
230            items.push(current);
231        }
232    } else if !before.is_empty() {
233        items.push(before);
234    }
235
236    let mut tokens: Vec<String> = Vec::new();
237    for item in items {
238        let digits: String = item.chars().filter(char::is_ascii_digit).collect();
239        let number: u32 = digits.parse().ok()?;
240        if number > 20 {
241            // Several degrees run together, `69` or `b913`: a `1` takes the
242            // digit after it, and an accidental waits for the digit after it.
243            let chars: Vec<char> = item.chars().collect();
244            let mut prefix = String::new();
245            let mut skip = false;
246            for (index, &ch) in chars.iter().enumerate() {
247                if skip {
248                    skip = false;
249                    continue;
250                }
251                if ch == '1' {
252                    let mut token = String::from(ch);
253                    if let Some(next) = chars.get(index + 1) {
254                        token.push(*next);
255                    }
256                    tokens.push(token);
257                    skip = true;
258                } else if matches!(ch, 'b' | '#') {
259                    prefix.push(ch);
260                } else {
261                    prefix.push(ch);
262                    tokens.push(std::mem::take(&mut prefix));
263                }
264            }
265        } else {
266            tokens.push(item);
267        }
268    }
269    for token in tokens {
270        let alter = if token.contains('b') {
271            -(token.matches('b').count() as IntegerType)
272        } else {
273            token.matches('#').count() as IntegerType
274        };
275        let digits: String = token
276            .chars()
277            .skip_while(|c| !c.is_ascii_digit())
278            .take_while(char::is_ascii_digit)
279            .collect();
280        if let Ok(degree) = digits.parse::<u8>() {
281            out.push(Modification {
282                kind: ModificationKind::Add,
283                degree,
284                alter,
285            });
286        }
287    }
288    Some(out)
289}
290
291impl ChordSymbol {
292    /// Parses a chord symbol such as `"Cmaj7"`, `"F#m7b5"`, or `"Bb7#11"`.
293    pub fn parse(figure: impl Into<String>) -> Result<Self> {
294        let figure = figure.into();
295        let trimmed = figure.trim();
296        if trimmed.is_empty() {
297            return Err(Error::Chord("chord symbol cannot be empty".to_string()));
298        }
299        // music21 drops every space before reading a figure, so `C7 omit 3`
300        // is `C7omit3`.
301        let compact: String = trimmed.chars().filter(|c| !c.is_whitespace()).collect();
302
303        let (body, bass_segment) = match compact.split_once('/') {
304            Some((body, bass)) => (body, Some(bass)),
305            None => (compact.as_str(), None),
306        };
307        let body_parts = split_music21_pitch_modifiers(body);
308        let bass_parts = bass_segment.map(split_music21_pitch_modifiers);
309        let bass = bass_parts
310            .as_ref()
311            .map(|parts| parse_pitch_only(&parts.base))
312            .transpose()?;
313
314        let (root_name, suffix) = parse_pitch_prefix(&body_parts.base)?;
315        let root = Pitch::from_name(root_name)?;
316        let suffix_without_additions = strip_addition_groups(suffix);
317        let mut additions = parse_additions(suffix);
318        let mut omissions = parse_omissions(suffix);
319        for pitch_name in body_parts
320            .additions
321            .iter()
322            .chain(bass_parts.iter().flat_map(|parts| parts.additions.iter()))
323        {
324            if let Some(addition) = pitch_name_addition(&root, pitch_name) {
325                additions.push(addition);
326            }
327        }
328        for pitch_name in body_parts
329            .omissions
330            .iter()
331            .chain(bass_parts.iter().flat_map(|parts| parts.omissions.iter()))
332        {
333            if let Some(omission) = pitch_name_degree(&root, pitch_name)
334                && !omissions.contains(&omission)
335            {
336                omissions.push(omission);
337            }
338        }
339
340        let mut alterations = parse_alterations(&suffix_without_additions);
341        add_implicit_music21_alterations(&suffix_without_additions, &mut alterations);
342        let extensions = parse_extensions(&suffix_without_additions, &alterations);
343        let quality = parse_quality(&suffix_without_additions, &alterations);
344        // music21's `_getKindFromShortHand` names the kind outright, and
345        // what it leaves is read as music21 reads it; a leftover that is not
346        // degrees is a figure music21 refuses, which the crate then reads
347        // its own way.
348        // The whole of the figure after the root, `add` and `omit` included,
349        // which the pitch-modifier split above took off `suffix`.
350        let full_suffix = &body[body_parts.base.len() - suffix.len()..];
351        let (kind, modifications) = match music21_kind(full_suffix) {
352            Some((chord_type, taken)) => match music21_modifications(&full_suffix[taken..]) {
353                Some(modifications) => (Some(chord_type.kind.to_string()), modifications),
354                None => (None, Vec::new()),
355            },
356            None => (None, Vec::new()),
357        };
358
359        Ok(Self {
360            figure: trimmed.to_string(),
361            root,
362            bass,
363            quality,
364            extensions,
365            alterations,
366            omissions,
367            additions,
368            kind,
369            modifications,
370        })
371    }
372
373    /// music21's `chordKind`: the kind the shorthand names, where it is one
374    /// of music21's abbreviations, so `Cmaj7` is `major-seventh` and `CN6`
375    /// the Neapolitan. `None` for a shorthand the crate reads on its own.
376    pub fn kind(&self) -> Option<&str> {
377        self.kind.as_deref()
378    }
379
380    /// Returns the original chord-symbol figure.
381    pub fn figure(&self) -> &str {
382        &self.figure
383    }
384
385    /// Returns the root pitch.
386    pub fn root(&self) -> &Pitch {
387        &self.root
388    }
389
390    /// Returns the slash bass pitch, if one was supplied.
391    pub fn bass(&self) -> Option<&Pitch> {
392        self.bass.as_ref()
393    }
394
395    /// Returns the parsed chord quality.
396    pub fn quality(&self) -> ChordQuality {
397        self.quality
398    }
399
400    /// Returns parsed extension degrees.
401    pub fn extensions(&self) -> &[u8] {
402        &self.extensions
403    }
404
405    /// Returns parsed alterations.
406    pub fn alterations(&self) -> &[ChordAlteration] {
407        &self.alterations
408    }
409
410    /// Returns degrees omitted with `no...` or `omit...` markers.
411    pub fn omissions(&self) -> &[u8] {
412        &self.omissions
413    }
414
415    /// Returns parsed added tones from `add(...)` groups.
416    pub fn additions(&self) -> &[ChordAlteration] {
417        &self.additions
418    }
419
420    /// Rebuilds the figure from the parsed parts in one canonical spelling:
421    /// music21's `findFigure`. Whatever was typed, a half-diminished seventh
422    /// comes back as `m7b5`, an augmented triad as `aug`, an added ninth as
423    /// `add(9)`, and the result parses back to the same chord.
424    pub fn find_figure(&self) -> String {
425        let mut figure = self.root.name();
426        figure.push_str(&self.quality_and_extension());
427        for alteration in &self.alterations {
428            if alteration.degree == 5 && self.fifth_is_implied() {
429                continue;
430            }
431            figure.push_str(&alteration_text(alteration));
432        }
433        for addition in &self.additions {
434            figure.push_str(&format!("add({})", alteration_text(addition)));
435        }
436        for omission in &self.omissions {
437            figure.push_str(&format!("no{omission}"));
438        }
439        if let Some(bass) = &self.bass
440            && bass.name() != self.root.name()
441        {
442            figure.push('/');
443            figure.push_str(&bass.name());
444        }
445        figure
446    }
447
448    /// The same chord on a transposed root and bass, its figure rebuilt by
449    /// [`Self::find_figure`]: music21's `transpose`, so `B-/D` up a major
450    /// second is `C/E`.
451    pub fn transpose(&self, interval: &Interval) -> Result<ChordSymbol> {
452        let mut transposed = self.clone();
453        transposed.root = self.root.transpose(interval)?;
454        transposed.bass = self
455            .bass
456            .as_ref()
457            .map(|bass| bass.transpose(interval))
458            .transpose()?;
459        transposed.figure = transposed.find_figure();
460        Ok(transposed)
461    }
462
463    /// Whether the chord has enough members for the given inversion:
464    /// music21's `inversionIsValid`, so first and second inversions always
465    /// are, a third needs a seventh, a fourth a ninth and a fifth an
466    /// eleventh or thirteenth. Root position is not an inversion.
467    pub fn inversion_is_valid(&self, inversion: u8) -> bool {
468        let highest = self
469            .extensions
470            .iter()
471            .copied()
472            .filter(|degree| matches!(degree, 7 | 9 | 11 | 13))
473            .max()
474            .unwrap_or(5);
475        match inversion {
476            1 | 2 => true,
477            3 => highest >= 7,
478            4 => highest >= 9,
479            5 => highest >= 11,
480            _ => false,
481        }
482    }
483
484    fn fifth_is_implied(&self) -> bool {
485        matches!(
486            self.quality,
487            ChordQuality::Diminished | ChordQuality::Augmented | ChordQuality::HalfDiminished
488        )
489    }
490
491    /// The quality with the extension it is written with: the highest
492    /// extension that no alteration accounts for, so `E7#9` keeps its `7`.
493    fn quality_and_extension(&self) -> String {
494        let seventh_family = self
495            .extensions
496            .iter()
497            .copied()
498            .filter(|degree| matches!(degree, 7 | 9 | 11 | 13))
499            .filter(|degree| {
500                !self
501                    .alterations
502                    .iter()
503                    .any(|alteration| alteration.degree == *degree)
504            })
505            .max()
506            .or_else(|| {
507                self.extensions
508                    .iter()
509                    .any(|degree| matches!(degree, 7 | 9 | 11 | 13))
510                    .then_some(7)
511            });
512        let sixth = self.extensions.contains(&6);
513        let extension = |written: &str| -> String {
514            match seventh_family {
515                Some(degree) => format!("{written}{degree}"),
516                None if sixth => format!("{written}6"),
517                None => written.to_string(),
518            }
519        };
520        match self.quality {
521            ChordQuality::Major => match seventh_family {
522                Some(degree) => format!("maj{degree}"),
523                None if sixth => "6".to_string(),
524                None => String::new(),
525            },
526            ChordQuality::Minor => extension("m"),
527            ChordQuality::Dominant => seventh_family.unwrap_or(7).to_string(),
528            ChordQuality::Diminished => extension("dim"),
529            ChordQuality::Augmented => extension("aug"),
530            ChordQuality::HalfDiminished => format!("m{}b5", seventh_family.unwrap_or(7)),
531            ChordQuality::Suspended2 => match seventh_family {
532                Some(degree) => format!("{degree}sus2"),
533                None => "sus2".to_string(),
534            },
535            ChordQuality::Suspended4 => match seventh_family {
536                Some(degree) => format!("{degree}sus4"),
537                None => "sus4".to_string(),
538            },
539            ChordQuality::Power => "5".to_string(),
540            ChordQuality::Pedal => "pedal".to_string(),
541        }
542    }
543
544    /// Realizes the symbol as a chord. An eleventh implies the ninth and a
545    /// thirteenth implies the ninth and eleventh, as music21's chord kinds
546    /// spell them, unless the figure omits them.
547    pub fn to_chord(&self) -> Result<Chord> {
548        let mut pitches = match self.kind_notation() {
549            Some(notation) => self.music21_pitches(notation)?,
550            None => {
551                let mut intervals = self.spelled_intervals()?;
552                intervals.sort_unstable_by_key(|(degree, _)| *degree);
553                intervals.dedup();
554                intervals
555                    .into_iter()
556                    .map(|(_, name)| Interval::from_name(&name)?.transpose_pitch(&self.root))
557                    .collect::<Result<Vec<_>>>()?
558            }
559        };
560
561        if let Some(bass) = &self.bass {
562            if let Some(index) = pitches.iter().position(|pitch| pitch.name() == bass.name()) {
563                let bass = pitches.remove(index);
564                pitches.insert(0, bass);
565            } else {
566                pitches.insert(0, bass.clone());
567            }
568        }
569
570        Chord::new(pitches.as_slice())
571    }
572
573    /// The notation the symbol is realized from when its shorthand names one
574    /// of music21's kinds outright: `N6` is the Neapolitan whatever the
575    /// crate's own reading of the letters would be.
576    fn kind_notation(&self) -> Option<&'static str> {
577        notation_for_kind(self.kind.as_deref()?)
578    }
579
580    /// The pitches music21 realizes a kind's notation as, with the
581    /// modifications read off the figure applied in order the way its
582    /// `_adjustPitchesForChordStepModifications` applies them, then the
583    /// degrees the crate read out of `add(...)` groups and pitch names.
584    fn music21_pitches(&self, notation: &'static str) -> Result<Vec<Pitch>> {
585        let mut degrees: Vec<String> = notation.split(',').map(str::to_string).collect();
586        let mut pitches = notation_intervals(notation)?
587            .into_iter()
588            .map(|(_, name)| Interval::from_name(&name)?.transpose_pitch(&self.root))
589            .collect::<Result<Vec<_>>>()?;
590        let scale = crate::scale::Scale::new(crate::scale::ScaleType::Major, self.root.clone());
591        let degree_number =
592            |written: &str| -> Option<u8> { written.trim_matches(['-', '#', 'A']).parse().ok() };
593        let semitone = |pitch: &Pitch, alter: IntegerType| -> Result<Pitch> {
594            let step = Interval::from_name(if alter > 0 { "A1" } else { "A-1" })?;
595            let mut moved = pitch.clone();
596            for _ in 0..alter.unsigned_abs() {
597                moved = moved.transpose(&step)?;
598            }
599            Ok(moved)
600        };
601
602        for modification in &self.modifications {
603            match modification.kind {
604                ModificationKind::Add => {
605                    let folded = IntegerType::from((modification.degree - 1) % 7 + 1);
606                    let mut to_add = scale.pitch_at_degree(folded)?;
607                    if modification.alter != 0 {
608                        // A raised seventh is raised from the minor seventh
609                        // every kind but the major ones carries, which
610                        // music21 reaches by dropping a semitone first; the
611                        // pitch that gives is spelled from its number, so the
612                        // raised one is too.
613                        if modification.degree == 7 && modification.alter > 0 {
614                            to_add = Pitch::from_number(to_add.ps() - 1.0)?;
615                        }
616                        to_add = semitone(&to_add, modification.alter)?;
617                    }
618                    if degrees
619                        .iter()
620                        .any(|written| *written == modification.degree.to_string())
621                    {
622                        // A degree the kind has is replaced, found among the
623                        // pitches by its scale degree, which only the first
624                        // seven can be; an added ninth over a kind that has
625                        // one is lost, as it is upstream.
626                        let mut replaced = Vec::new();
627                        for (index, pitch) in pitches.iter().enumerate() {
628                            if scale.degree_of(pitch)? == Some(usize::from(modification.degree)) {
629                                replaced.push(index);
630                            }
631                        }
632                        for index in replaced {
633                            pitches[index] = to_add.clone();
634                        }
635                    } else {
636                        pitches.push(to_add);
637                    }
638                }
639                ModificationKind::Subtract => {
640                    let found: Vec<usize> = degrees
641                        .iter()
642                        .enumerate()
643                        .filter(|(index, written)| {
644                            *index < pitches.len()
645                                && degree_number(written) == Some(modification.degree)
646                        })
647                        .map(|(index, _)| index)
648                        .collect();
649                    if found.is_empty() {
650                        return Err(Error::Chord(format!(
651                            "Degree not in specified chord: {}",
652                            modification.degree
653                        )));
654                    }
655                    for index in found.into_iter().rev() {
656                        let _ = pitches.remove(index);
657                        let _ = degrees.remove(index);
658                    }
659                }
660                ModificationKind::Alter => {
661                    let mut found = false;
662                    for (index, written) in degrees.iter().enumerate() {
663                        if index < pitches.len()
664                            && degree_number(written) == Some(modification.degree)
665                        {
666                            pitches[index] = semitone(&pitches[index], modification.alter)?;
667                            found = true;
668                        }
669                    }
670                    if !found {
671                        let folded = IntegerType::from((modification.degree - 1) % 7 + 1);
672                        let mut to_add = scale.pitch_at_degree(folded)?;
673                        if modification.alter != 0 {
674                            to_add = semitone(&to_add, modification.alter)?;
675                        }
676                        pitches.push(to_add);
677                    }
678                }
679            }
680        }
681
682        // What the crate reads beyond music21: `add(...)` groups and pitches
683        // named outright, which its own figure writer produces.
684        if !self.omissions.is_empty() {
685            let kept: Vec<bool> = degrees
686                .iter()
687                .map(|written| {
688                    degree_number(written).is_none_or(|degree| !self.omissions.contains(&degree))
689                })
690                .collect();
691            let mut index = 0;
692            pitches.retain(|_| {
693                let keep = kept.get(index).copied().unwrap_or(true);
694                index += 1;
695                keep
696            });
697        }
698        for addition in &self.additions {
699            let (_, name) = added_interval(addition)?;
700            let pitch = Interval::from_name(name)?.transpose_pitch(&self.root)?;
701            if !pitches
702                .iter()
703                .any(|existing| existing.name() == pitch.name())
704            {
705                pitches.push(pitch);
706            }
707        }
708        Ok(pitches)
709    }
710
711    /// The intervals the symbol's quality, extensions, alterations and
712    /// additions spell, for a shorthand that names no kind outright.
713    fn spelled_intervals(&self) -> Result<Vec<(u8, String)>> {
714        let mut intervals = self.base_intervals();
715
716        let highest = self
717            .extensions
718            .iter()
719            .copied()
720            .filter(|degree| !self.alterations.iter().any(|alt| alt.degree == *degree))
721            .max()
722            .unwrap_or(0);
723        for extension in [6, 9, 11, 13] {
724            let implied = match extension {
725                9 => highest >= 11,
726                11 => highest == 13,
727                _ => false,
728            };
729            if (self.extensions.contains(&extension) || implied)
730                && !self.alterations.iter().any(|alt| alt.degree == extension)
731                && !self.omissions.contains(&extension)
732            {
733                intervals.push((extension, default_extension_interval(extension)));
734            }
735        }
736
737        for alteration in &self.alterations {
738            if alteration.degree == 5 {
739                continue;
740            }
741            let altered = altered_interval(alteration)?;
742            // An altered degree stands in place of the one the chord's own
743            // quality would give, rather than sounding beside it: the seventh
744            // of `F#mM7` is `E#` alone, not `E` and `E#` together.
745            intervals.retain(|(degree, _)| *degree != altered.0);
746            intervals.push(altered);
747        }
748
749        for addition in &self.additions {
750            intervals.push(added_interval(addition)?);
751        }
752
753        Ok(intervals
754            .into_iter()
755            .map(|(degree, name)| (degree, name.to_string()))
756            .collect())
757    }
758
759    fn base_intervals(&self) -> Vec<(u8, &'static str)> {
760        let altered_fifth = self
761            .alterations
762            .iter()
763            .find(|alteration| alteration.degree == 5)
764            .and_then(|alteration| match alteration.semitones {
765                -1 => Some("d5"),
766                1 => Some("a5"),
767                _ => None,
768            });
769
770        let fifth = altered_fifth.unwrap_or("P5");
771        let has_seventh = self
772            .extensions
773            .iter()
774            .any(|degree| matches!(degree, 7 | 9 | 11 | 13));
775
776        let intervals = match self.quality {
777            ChordQuality::Major => {
778                if has_seventh {
779                    vec![(1, "P1"), (3, "M3"), (5, fifth), (7, "M7")]
780                } else {
781                    vec![(1, "P1"), (3, "M3"), (5, fifth)]
782                }
783            }
784            ChordQuality::Minor => {
785                if has_seventh {
786                    vec![(1, "P1"), (3, "m3"), (5, fifth), (7, "m7")]
787                } else {
788                    vec![(1, "P1"), (3, "m3"), (5, fifth)]
789                }
790            }
791            ChordQuality::Dominant => vec![(1, "P1"), (3, "M3"), (5, fifth), (7, "m7")],
792            ChordQuality::Diminished => {
793                if has_seventh {
794                    vec![(1, "P1"), (3, "m3"), (5, "d5"), (7, "d7")]
795                } else {
796                    vec![(1, "P1"), (3, "m3"), (5, "d5")]
797                }
798            }
799            ChordQuality::Augmented => vec![(1, "P1"), (3, "M3"), (5, "a5")],
800            ChordQuality::HalfDiminished => vec![(1, "P1"), (3, "m3"), (5, "d5"), (7, "m7")],
801            ChordQuality::Suspended2 => vec![(1, "P1"), (2, "M2"), (5, fifth)],
802            ChordQuality::Suspended4 => vec![(1, "P1"), (4, "P4"), (5, fifth)],
803            ChordQuality::Power => vec![(1, "P1"), (5, fifth)],
804            ChordQuality::Pedal => vec![(1, "P1")],
805        };
806
807        intervals
808            .into_iter()
809            .filter(|(degree, _)| !self.omissions.contains(degree))
810            .collect()
811    }
812}
813
814impl FromStr for ChordSymbol {
815    type Err = Error;
816
817    fn from_str(value: &str) -> Result<Self> {
818        Self::parse(value)
819    }
820}
821
822impl TryFrom<&str> for ChordSymbol {
823    type Error = Error;
824
825    fn try_from(value: &str) -> Result<Self> {
826        Self::parse(value)
827    }
828}
829
830impl TryFrom<String> for ChordSymbol {
831    type Error = Error;
832
833    fn try_from(value: String) -> Result<Self> {
834        Self::parse(value)
835    }
836}
837
838#[cfg(test)]
839mod tests {
840    /// Every shorthand in music21's table realizes the notes its notation
841    /// names, read independently off the root's major scale.
842    #[test]
843    fn every_music21_kind_realizes_its_own_notation() {
844        use super::{ChordSymbol, MUSIC21_CHORD_TYPES};
845        use crate::pitch::Accidental;
846        use crate::{Key, Pitch};
847        use std::collections::BTreeSet;
848
849        let major = Key::from_tonic("C").unwrap();
850        for chord_type in MUSIC21_CHORD_TYPES {
851            let expected: BTreeSet<String> = chord_type
852                .notation
853                .split(',')
854                .map(|token| {
855                    let degree: usize = token.trim_matches(['#', '-']).parse().unwrap();
856                    let alter =
857                        token.matches('#').count() as f64 - token.matches('-').count() as f64;
858                    let mut pitch = major.pitch_from_degree((degree - 1) % 7 + 1).unwrap();
859                    if alter != 0.0 {
860                        pitch.set_accidental(Some(Accidental::new(alter).unwrap()));
861                    }
862                    pitch.name()
863                })
864                .collect();
865            for abbreviation in chord_type.abbreviations {
866                // music21 reads a sharp or flat with a digit after it as an
867                // alteration before it looks the kind up, so `m7b5` is its
868                // minor seventh with a lowered fifth; those abbreviations are
869                // checked against music21 itself in `chord_symbol_parity`.
870                if music21_kind(abbreviation).is_none_or(|(read, _)| read.kind != chord_type.kind) {
871                    continue;
872                }
873                let symbol = ChordSymbol::parse(format!("C{abbreviation}")).unwrap();
874                assert_eq!(symbol.kind(), Some(chord_type.kind), "C{abbreviation}");
875                let names: BTreeSet<String> = symbol
876                    .to_chord()
877                    .unwrap()
878                    .pitch_names()
879                    .into_iter()
880                    .collect();
881                assert_eq!(names, expected, "C{abbreviation} ({})", chord_type.kind);
882            }
883        }
884        // music21's kind for `C7#11` is the dominant seventh, with the
885        // sharpened eleventh added after.
886        assert_eq!(
887            ChordSymbol::parse("C7#11").unwrap().kind(),
888            Some("dominant-seventh")
889        );
890        assert_eq!(
891            ChordSymbol::parse("CN6/E")
892                .unwrap()
893                .to_chord()
894                .unwrap()
895                .pitch_names(),
896            ["E", "C", "D-", "G-"]
897        );
898        let _ = Pitch::from_name("C").unwrap();
899    }
900
901    /// music21's own `chordSymbolFigureFromChord` examples, the special
902    /// kinds and the suspended second in inversion among them.
903    #[test]
904    fn figures_are_written_as_music21_writes_them() {
905        use super::{
906            ChordSymbolFigure, chord_symbol_figure_from_chord, chord_symbol_kind_from_chord,
907        };
908        use crate::{Chord, Pitch};
909
910        let figure =
911            |notes: &str| chord_symbol_figure_from_chord(&Chord::new(notes).unwrap()).unwrap();
912        let kind = |notes: &str| chord_symbol_kind_from_chord(&Chord::new(notes).unwrap());
913        assert_eq!(figure("F3 A3 C#4 E-4 G4 B-4").as_deref(), Some("F+11"));
914        assert_eq!(kind("F3 A3 C#4 E-4 G4 B-4"), Some("augmented-11th"));
915        assert_eq!(figure("C3 E3 B3 D4").as_deref(), Some("CM9"));
916        assert_eq!(figure("C3 D-3 E3 G-3").as_deref(), Some("CN6"));
917        assert_eq!(kind("C3 D-3 E3 G-3"), Some("Neapolitan"));
918        assert_eq!(figure("C3 D3 G3").as_deref(), Some("Csus2"));
919        assert_eq!(figure("C3 E3 G3 D-4").as_deref(), Some("CaddD-"));
920        assert_eq!(figure("C3").as_deref(), Some("Cpedal"));
921        assert_eq!(figure("").as_deref(), Some(""));
922
923        // A suspended second in inversion is a suspended fourth on the bass.
924        let inverted = Chord::new("C3 F3 G3").unwrap();
925        let parts = ChordSymbolFigure::from_chord(&inverted).unwrap();
926        assert_eq!(parts.root, "C");
927        assert_eq!(
928            (parts.kind, parts.abbreviation, parts.bass.as_deref()),
929            ("suspended-fourth", "sus", None)
930        );
931        assert_eq!(parts.to_string(), "Csus");
932        assert_eq!(parts.written_with("sus4"), "Csus4");
933
934        // The augmented sixths are named from a root the caller fixes.
935        let with_root = |notes: &str, root: &str| {
936            ChordSymbolFigure::from_chord_with_root(
937                &Chord::new(notes).unwrap(),
938                &Pitch::from_name(root).unwrap(),
939            )
940            .unwrap()
941        };
942        assert_eq!(with_root("C3 F#3 A-3", "C3").to_string(), "CIt+6");
943        assert_eq!(with_root("C3 D3 F#3 A-3", "C3").to_string(), "CFr+6");
944        assert_eq!(with_root("C3 E-3 F#3 A-3", "C3").to_string(), "CGr+6");
945        assert_eq!(with_root("F2 B2 D#3 G#3", "F2").to_string(), "Ftristan");
946        assert_eq!(with_root("F2 B2 D#3 G#3", "F2").kind, "Tristan");
947
948        // An inversion writes the bass, and additions are written with the
949        // notes the kind lacks beside them.
950        let parts = ChordSymbolFigure::from_chord(&Chord::new("E3 G3 C4 B-4").unwrap()).unwrap();
951        assert_eq!(parts.to_string(), "C7/E");
952        // A bass the kind does not carry is written as the bass alone.
953        assert_eq!(figure("D3 C4 E-4 G4").as_deref(), Some("Cm/D"));
954        assert!(ChordSymbolFigure::from_chord(&Chord::new("C4 C~4").unwrap()).is_none());
955        assert!(ChordSymbolFigure::from_chord(&Chord::new("").unwrap()).is_none());
956    }
957
958    #[test]
959    fn a_symbol_is_parsed_through_try_from_and_reports_its_alterations() {
960        use super::{ChordSymbol, chord_symbol_kind_from_chord};
961        use crate::chord::Chord;
962
963        let symbol = ChordSymbol::try_from("C7#11").unwrap();
964        assert_eq!(symbol.alterations().len(), 1);
965        assert_eq!(symbol.alterations()[0].degree(), 11);
966        assert_eq!(symbol.alterations()[0].semitones(), 1);
967        assert!(symbol.omissions().is_empty());
968        let omitting = ChordSymbol::try_from("C[no3]".to_string()).unwrap();
969        assert_eq!(omitting.omissions(), [3]);
970        assert!(ChordSymbol::try_from("").is_err());
971
972        assert_eq!(
973            chord_symbol_kind_from_chord(&Chord::new("C").unwrap()),
974            Some("pedal")
975        );
976        assert_eq!(
977            chord_symbol_kind_from_chord(&Chord::new("C G").unwrap()),
978            Some("power")
979        );
980        assert_eq!(
981            chord_symbol_kind_from_chord(&Chord::new("C E G").unwrap()),
982            Some("major")
983        );
984        assert_eq!(chord_symbol_kind_from_chord(&Chord::new("").unwrap()), None);
985    }
986
987    #[test]
988    fn chord_symbol_figures_match_music21() {
989        let cases: [(&str, Option<&str>); 26] = [
990            ("C4 E4 G4", Some("C")),
991            ("C4 E-4 G4", Some("Cm")),
992            ("C4 E4 G#4", Some("C+")),
993            ("C4 E-4 G-4", Some("Cdim")),
994            ("C4 E4 G4 B-4", Some("C7")),
995            ("C4 E4 G4 B4", Some("Cmaj7")),
996            ("C4 E-4 G4 B-4", Some("Cm7")),
997            ("C4 E-4 G-4 B--4", Some("Co7")),
998            ("C4 E-4 G-4 B-4", Some("C\u{00f8}7")),
999            ("E4 G4 C5", Some("C/E")),
1000            ("G3 C4 E4", Some("C/G")),
1001            ("E4 G4 B-4 C5", Some("C7/E")),
1002            ("C4 E4 G4 B-4 D5", Some("C9")),
1003            ("C4 E4 G4 B4 D5", Some("CM9")),
1004            ("C4 D4 G4", Some("Csus2")),
1005            ("C4 F4 G4", Some("Csus")),
1006            ("C4", Some("Cpedal")),
1007            ("C4 G4", Some("Cpower")),
1008            ("C4 E4 G4 B-4 D5 F5", Some("C11")),
1009            ("C4 E4 G4 B-4 D5 F5 A5", Some("C13")),
1010            ("C4 E4 G4 A4", Some("Am7/C")),
1011            ("C4 E-4 G4 A4", Some("A\u{00f8}7/C")),
1012            ("C4 E4 G4 D5", Some("CaddD")),
1013            ("F4 A4 C5 D5", Some("Dm7/F")),
1014            ("B3 D4 F4", Some("Bdim")),
1015            ("C4 D-4 E4", None),
1016        ];
1017        for (notes, expected) in cases {
1018            let chord = Chord::new(notes).unwrap();
1019            assert_eq!(
1020                chord_symbol_figure_from_chord(&chord).unwrap().as_deref(),
1021                expected,
1022                "{notes}"
1023            );
1024        }
1025        assert_eq!(
1026            chord_symbol_figure_from_chord(&Chord::empty())
1027                .unwrap()
1028                .as_deref(),
1029            Some("")
1030        );
1031
1032        let symbol = chord_symbol_from_chord(&Chord::new("E4 G4 B-4 C5").unwrap())
1033            .unwrap()
1034            .unwrap();
1035        assert_eq!(symbol.figure(), "C7/E");
1036        assert_eq!(symbol.root().name(), "C");
1037        assert_eq!(symbol.bass().map(Pitch::name).as_deref(), Some("E"));
1038        assert!(
1039            chord_symbol_from_chord(&Chord::new("C4 D-4 E4").unwrap())
1040                .unwrap()
1041                .is_none()
1042        );
1043        assert!(chord_symbol_from_chord(&Chord::empty()).unwrap().is_none());
1044    }
1045
1046    #[test]
1047    fn kind_lookups_match_music21() {
1048        assert_eq!(
1049            abbreviations_for_kind("dominant-seventh"),
1050            Some(&["7", "dom7"][..])
1051        );
1052        assert_eq!(notation_for_kind("dominant-seventh"), Some("1,3,5,-7"));
1053        assert_eq!(current_abbreviation_for_kind("dominant-seventh"), Some("7"));
1054        assert_eq!(abbreviations_for_kind("major"), Some(&["", "M", "maj"][..]));
1055        assert_eq!(current_abbreviation_for_kind("major"), Some(""));
1056        assert_eq!(abbreviations_for_kind("nonsense"), None);
1057        for chord_type in known_chord_symbol_types() {
1058            assert_eq!(
1059                chord_type.abbreviations.first(),
1060                Some(&chord_type.abbreviation)
1061            );
1062        }
1063    }
1064
1065    #[test]
1066    fn find_figure_writes_one_canonical_spelling_that_parses_back() {
1067        let cases = [
1068            ("C", "C", "D"),
1069            ("Cm7", "Cm7", "Dm7"),
1070            ("F#dim", "F#dim", "G#dim"),
1071            ("B-/D", "B-/D", "C/E"),
1072            ("G7/B", "G7/B", "A7/C#"),
1073            ("Am/C", "Am/C", "Bm/D"),
1074            ("Cmaj7", "Cmaj7", "Dmaj7"),
1075            ("Dm7b5", "Dm7b5", "Em7b5"),
1076            ("C\u{00f8}7", "Cm7b5", "Dm7b5"),
1077            ("E7#9", "E7#9", "F#7#9"),
1078            ("Csus4", "Csus4", "Dsus4"),
1079            ("G7sus4", "G7sus4", "A7sus4"),
1080            ("Cadd(9)", "Cadd(9)", "Dadd(9)"),
1081            ("C6", "C6", "D6"),
1082            ("Cm6", "Cm6", "Dm6"),
1083            ("Cdim7", "Cdim7", "Ddim7"),
1084            ("Caug", "Caug", "Daug"),
1085            ("C+", "Caug", "Daug"),
1086            ("C9", "C9", "D9"),
1087            ("Cmaj7#11", "Cmaj7#11", "Dmaj7#11"),
1088            ("C5", "C5", "D5"),
1089            ("Cno3", "Cno3", "Dno3"),
1090        ];
1091        let whole_tone = Interval::from_name("M2").unwrap();
1092        for (typed, canonical, transposed) in cases {
1093            let symbol = ChordSymbol::parse(typed).unwrap();
1094            assert_eq!(symbol.find_figure(), canonical, "{typed}");
1095            let reparsed = ChordSymbol::parse(symbol.find_figure()).unwrap();
1096            assert_eq!(reparsed.quality(), symbol.quality(), "{typed}");
1097            assert_eq!(
1098                reparsed.to_chord().unwrap().pitch_names(),
1099                symbol.to_chord().unwrap().pitch_names(),
1100                "{typed}"
1101            );
1102            let up = symbol.transpose(&whole_tone).unwrap();
1103            assert_eq!(up.figure(), transposed, "{typed}");
1104            assert_eq!(up.find_figure(), transposed, "{typed}");
1105        }
1106    }
1107
1108    #[test]
1109    fn inversion_validity_follows_the_chord_size() {
1110        let valid = |figure: &str| -> Vec<u8> {
1111            let symbol = ChordSymbol::parse(figure).unwrap();
1112            (0..=6).filter(|n| symbol.inversion_is_valid(*n)).collect()
1113        };
1114        assert_eq!(valid("C"), [1, 2]);
1115        assert_eq!(valid("C6"), [1, 2]);
1116        assert_eq!(valid("Cm7"), [1, 2, 3]);
1117        assert_eq!(valid("C9"), [1, 2, 3, 4]);
1118        assert_eq!(valid("Cmaj11"), [1, 2, 3, 4, 5]);
1119        assert_eq!(valid("C13"), [1, 2, 3, 4, 5]);
1120        assert_eq!(valid("E7#9"), [1, 2, 3, 4]);
1121    }
1122    use super::*;
1123
1124    #[test]
1125    fn parses_major_seventh_symbol() {
1126        let symbol: ChordSymbol = "Cmaj7".parse().unwrap();
1127        assert_eq!(symbol.root().name(), "C");
1128        assert_eq!(symbol.quality(), ChordQuality::Major);
1129        assert_eq!(symbol.extensions(), &[7]);
1130        assert_eq!(
1131            symbol.to_chord().unwrap().pitched_common_name(),
1132            "C-major seventh chord"
1133        );
1134    }
1135
1136    #[test]
1137    fn parses_half_diminished_symbol() {
1138        let symbol = ChordSymbol::parse("F#m7b5").unwrap();
1139        assert_eq!(symbol.root().name(), "F#");
1140        assert_eq!(symbol.quality(), ChordQuality::HalfDiminished);
1141        assert_eq!(
1142            symbol.to_chord().unwrap().pitched_common_name(),
1143            "F#-half-diminished seventh chord"
1144        );
1145    }
1146
1147    #[test]
1148    fn parses_dominant_altered_symbol() {
1149        let symbol = ChordSymbol::parse("Bb7#11").unwrap();
1150        assert_eq!(symbol.root().name(), "B-");
1151        assert_eq!(symbol.quality(), ChordQuality::Dominant);
1152        assert_eq!(symbol.extensions(), &[7, 11]);
1153        assert_eq!(symbol.alterations()[0], ChordAlteration::new(11, 1));
1154        let names = symbol
1155            .to_chord()
1156            .unwrap()
1157            .pitches()
1158            .iter()
1159            .map(Pitch::name)
1160            .collect::<Vec<_>>();
1161        assert_eq!(names, vec!["B-", "D", "F", "A-", "E"]);
1162    }
1163
1164    #[test]
1165    fn parses_added_tones_without_changing_the_base_chord() {
1166        let symbol = ChordSymbol::parse("Cdim9 add(#5)").unwrap();
1167        assert_eq!(symbol.extensions(), &[9]);
1168        assert_eq!(symbol.additions(), &[ChordAlteration::new(5, 1)]);
1169        assert_eq!(
1170            symbol.to_chord().unwrap().pitch_classes(),
1171            vec![0, 2, 3, 6, 8, 9]
1172        );
1173    }
1174
1175    #[test]
1176    fn parses_altered_dominant_with_slash_bass() {
1177        let symbol = ChordSymbol::parse("D7b9#11/C").unwrap();
1178        assert_eq!(symbol.root().name(), "D");
1179        assert_eq!(symbol.bass().map(Pitch::name).as_deref(), Some("C"));
1180        assert_eq!(symbol.quality(), ChordQuality::Dominant);
1181        assert_eq!(symbol.extensions(), &[7, 9, 11]);
1182        assert_eq!(
1183            symbol.alterations(),
1184            &[ChordAlteration::new(9, -1), ChordAlteration::new(11, 1)]
1185        );
1186        assert_eq!(
1187            symbol.to_chord().unwrap().pitch_classes(),
1188            vec![0, 2, 3, 6, 8, 9]
1189        );
1190    }
1191
1192    #[test]
1193    fn parses_music21_pitch_name_additions() {
1194        let symbol = ChordSymbol::parse("Ddom7dim5/CaddA,E-").unwrap();
1195
1196        assert_eq!(symbol.root().name(), "D");
1197        assert_eq!(symbol.bass().map(Pitch::name).as_deref(), Some("C"));
1198        assert_eq!(symbol.quality(), ChordQuality::Dominant);
1199        assert_eq!(symbol.alterations(), &[ChordAlteration::new(5, -1)]);
1200        assert_eq!(
1201            symbol.additions(),
1202            &[ChordAlteration::new(5, 0), ChordAlteration::new(9, -1)]
1203        );
1204        assert_eq!(
1205            symbol.to_chord().unwrap().pitch_classes(),
1206            vec![0, 2, 3, 6, 8, 9]
1207        );
1208    }
1209
1210    #[test]
1211    fn generates_petrushka_chord_symbol_name() {
1212        let chord = Chord::new("C4 D4 Eb4 F#4 Ab4 A4").unwrap();
1213        let names = chord_symbol_spellings(&chord);
1214
1215        assert_eq!(
1216            names.first().map(String::as_str),
1217            Some("Ddom7dim5/CaddA,E-")
1218        );
1219        assert!(names.iter().any(|name| name == "Ddom7dim5/CaddA,E-"));
1220    }
1221
1222    #[test]
1223    fn generates_common_chord_symbols() {
1224        let major_seventh = Chord::new("C E G B").unwrap();
1225        let dominant_ninth = Chord::new("C E G B- D").unwrap();
1226
1227        assert_eq!(
1228            chord_symbol_spellings(&major_seventh)
1229                .first()
1230                .map(String::as_str),
1231            Some("Cmaj7")
1232        );
1233        assert_eq!(
1234            chord_symbol_spellings(&dominant_ninth)
1235                .first()
1236                .map(String::as_str),
1237            Some("C9")
1238        );
1239    }
1240
1241    #[test]
1242    fn split_third_triads_do_not_spell_lower_third_as_sharp_nine() {
1243        let split_third = Chord::new("D4 A4 F#4 F4").unwrap();
1244        let names = chord_symbol_spellings(&split_third);
1245
1246        assert_eq!(names.first().map(String::as_str), Some("DaddF"));
1247        assert!(!names.iter().any(|name| name == "D add(#9)"));
1248    }
1249
1250    #[test]
1251    fn altered_dominants_use_music21_pitch_name_additions() {
1252        let altered_dominant = Chord::new("C4 E4 G4 Bb4 Eb5").unwrap();
1253        let names = chord_symbol_spellings(&altered_dominant);
1254
1255        assert_eq!(names.first().map(String::as_str), Some("C7addE-"));
1256    }
1257
1258    #[test]
1259    fn unrecognized_music21_figures_return_no_symbol() {
1260        let chord = Chord::new("F4 C5 D5 E-5").unwrap();
1261        let names = chord_symbol_spellings(&chord);
1262
1263        assert!(names.is_empty());
1264    }
1265
1266    #[test]
1267    fn generates_music21_figures_with_explicit_root() {
1268        let major_triad = Chord::new("G3 C4 E4").unwrap();
1269        let dominant_seventh = Chord::new("G3 B-3 C4 E4").unwrap();
1270        let power_chord = Chord::new("C4 G4").unwrap();
1271        let unsupported_dyad = Chord::new("C4 A4").unwrap();
1272
1273        assert_eq!(
1274            chord_symbol_spellings_with_root(&major_triad, 0)
1275                .first()
1276                .map(String::as_str),
1277            Some("C/G")
1278        );
1279        assert_eq!(
1280            chord_symbol_spellings_with_root(&dominant_seventh, 0)
1281                .first()
1282                .map(String::as_str),
1283            Some("C7/G")
1284        );
1285        assert_eq!(
1286            chord_symbol_spellings_with_root(&power_chord, 0)
1287                .first()
1288                .map(String::as_str),
1289            Some("Cpower")
1290        );
1291        assert!(chord_symbol_spellings_with_root(&unsupported_dyad, 0).is_empty());
1292    }
1293
1294    #[test]
1295    fn dense_sets_follow_music21_fallback_matching() {
1296        let chord = Chord::new("C4 D-4 E-4 E4 F#4 G4 A-4 A4").unwrap();
1297
1298        assert_eq!(
1299            chord_symbol_spellings(&chord).first().map(String::as_str),
1300            Some("CsusaddA,A-,D-,E,E-,F#,omitF")
1301        );
1302    }
1303}