Skip to main content

music21_rs/chord/
names.rs

1//! What a chord is called: the common names of the Forte tables spelled
2//! on the chord's own root, and the lead-sheet symbol it is written as.
3
4use super::*;
5
6impl Chord {
7    /// Returns the unpitched chord types known to the music21-derived table.
8    pub fn known_chord_types() -> Vec<KnownChordType> {
9        tables::known_chord_table_entries()
10            .into_iter()
11            .map(|entry| KnownChordType {
12                cardinality: entry.cardinality,
13                common_names: entry.common_names.into_iter().map(str::to_string).collect(),
14                forte_class: entry.forte_class,
15                normal_form: entry.normal_form,
16                interval_class_vector: entry.interval_class_vector,
17            })
18            .collect()
19    }
20
21    /// Returns the primary music21-style common name with a pitch prefix.
22    pub fn pitched_common_name(&self) -> String {
23        self.pitched_name_for_common_name(&self.common_name())
24    }
25
26    /// Returns every known music21-style common name with pitch prefixes.
27    ///
28    /// Most chords have a single common name, while some Forte-table entries
29    /// have aliases. This method exposes all of them in table order.
30    pub fn pitched_common_names(&self) -> Vec<String> {
31        let common_names = self.common_names();
32        if common_names.is_empty() {
33            return vec![self.pitched_common_name()];
34        }
35
36        common_names
37            .iter()
38            .map(|name| self.pitched_name_for_common_name(name))
39            .collect()
40    }
41
42    /// Returns the preferred chord symbol, when available.
43    ///
44    /// This is separate from [`Self::pitched_common_name`]: common names follow
45    /// the music21/Forte tables, while chord symbols use music21-style
46    /// figures such as `Cmaj7`, `F#m7b5`, or `Ddom7dim5/CaddA,E-`.
47    pub fn chord_symbol(&self) -> Option<String> {
48        self.chord_symbols().into_iter().next()
49    }
50
51    /// Returns ranked chord symbols for this pitch-class set.
52    ///
53    /// Empty and microtonal chords return no symbols because this notation layer
54    /// assumes twelve-tone equal-tempered pitch classes.
55    pub fn chord_symbols(&self) -> Vec<String> {
56        crate::chordsymbol::chord_symbol_spellings(self)
57    }
58
59    /// Returns the preferred chord symbol using an explicit root.
60    ///
61    /// This is useful for pitch-class sets and browser tables where the caller
62    /// already knows the harmonic spelling anchor and does not want an
63    /// inversion/root inference pass to choose another chord member. String
64    /// roots are parsed as pitch names; numeric roots are parsed as pitch
65    /// classes, so use numbers for pitch-class-only values such as 10 or 11.
66    pub fn chord_symbol_with_root(
67        &self,
68        root: impl Into<PitchClassSpecifier>,
69    ) -> Result<Option<String>> {
70        Ok(self.chord_symbols_with_root(root)?.into_iter().next())
71    }
72
73    /// Returns ranked chord symbols using an explicit root.
74    ///
75    /// Empty, microtonal, and rootless-with-respect-to-the-given-root chords
76    /// return no symbols. Non-integer roots are rejected because chord symbols
77    /// are generated in twelve-tone pitch-class space.
78    pub fn chord_symbols_with_root(
79        &self,
80        root: impl Into<PitchClassSpecifier>,
81    ) -> Result<Vec<String>> {
82        let root = Self::chord_symbol_root_pitch_class(root.into())?;
83
84        Ok(crate::chordsymbol::chord_symbol_spellings_with_root(
85            self, root,
86        ))
87    }
88
89    pub(super) fn pitched_name_for_common_name(&self, name_str: &str) -> String {
90        if name_str == "empty chord" {
91            return name_str.to_string();
92        }
93
94        if matches!(name_str, "note" | "unison") {
95            return self
96                .notes
97                .first()
98                .map(|n| n.pitch.name())
99                .unwrap_or_else(|| name_str.to_string());
100        }
101
102        let pitch_class_cardinality = self.ordered_pitch_classes().len();
103        if pitch_class_cardinality <= 2
104            || name_str.contains("enharmonic")
105            || name_str.contains("forte class")
106            || name_str.contains(" semitone")
107        {
108            if let Some(bass_name) = self.bass_pitch_name() {
109                return format!("{name_str} above {bass_name}");
110            }
111            return name_str.to_string();
112        }
113
114        if let Some(root_name) = self.spelling_root_name_override(name_str) {
115            return format!("{root_name}-{name_str}");
116        }
117
118        let root_name = self.root_pitch_name_from_tables().or_else(|| {
119            self.notes
120                .first()
121                .map(|n| Self::display_pitch_name(&n.pitch))
122        });
123
124        match root_name {
125            Some(root_name) => format!("{root_name}-{name_str}"),
126            None => name_str.to_string(),
127        }
128    }
129
130    pub(super) fn spelling_root_name_override(&self, common_name: &str) -> Option<String> {
131        if !common_name.contains("augmented sixth chord") {
132            return None;
133        }
134        let names = self.unique_pitch_names();
135        let root = if self.names_are(&names, &["C#", "E-", "G"])
136            || self.names_are(&names, &["C#", "E#", "G", "B"])
137        {
138            "C#"
139        } else if self.names_are(&names, &["C", "D", "F#", "A-"]) {
140            "D"
141        } else if self.names_are(&names, &["C#", "E-", "G", "A"]) {
142            "A"
143        } else if self.names_are(&names, &["C", "E", "F#", "A#"]) {
144            "F#"
145        } else if self.names_are(&names, &["D", "E", "G#", "B-"])
146            || (self.from_integer_pitches && self.pitch_class_mask() == 0b010100010100)
147        {
148            "E"
149        } else {
150            return None;
151        };
152
153        Some(root.to_string())
154    }
155
156    pub(super) fn chord_symbol_root_pitch_class(root: PitchClassSpecifier) -> Result<u8> {
157        match root {
158            PitchClassSpecifier::String(value) => match Pitch::from_name(value.as_str()) {
159                Ok(pitch) => Self::integer_pitch_class_for_chord_symbol_root(pitch.ps()),
160                Err(pitch_error) => {
161                    let pitch_class = PitchClass::new(value.as_str()).map_err(|pitch_class_error| {
162                        Error::Chord(format!(
163                            "cannot parse chord-symbol root {value:?} as a pitch name ({pitch_error}) or pitch class ({pitch_class_error})"
164                        ))
165                    })?;
166                    Self::integer_pitch_class_from_value(pitch_class)
167                }
168            },
169            specifier => {
170                let pitch_class = PitchClass::new(specifier)?;
171                Self::integer_pitch_class_from_value(pitch_class)
172            }
173        }
174    }
175
176    pub(super) fn integer_pitch_class_from_value(pitch_class: PitchClass) -> Result<u8> {
177        let Some(root) = pitch_class.integer() else {
178            return Err(Error::Chord(
179                "chord symbols require an integer pitch-class root".to_string(),
180            ));
181        };
182        Ok(root as u8)
183    }
184
185    pub(super) fn integer_pitch_class_for_chord_symbol_root(ps: FloatType) -> Result<u8> {
186        if (ps - ps.round()).abs() > FloatType::EPSILON {
187            return Err(Error::Chord(
188                "chord symbols require an integer pitch-class root".to_string(),
189            ));
190        }
191
192        Ok((ps.round() as IntegerType).rem_euclid(12) as u8)
193    }
194
195    /// Returns the primary unpitched music21-style common name.
196    ///
197    /// For chords with multiple table aliases, this is the first common name in
198    /// table order. Use [`Self::common_names`] to get every unpitched alias.
199    pub fn common_name(&self) -> String {
200        if self
201            .notes
202            .iter()
203            .any(|n| (n.pitch.alter() - n.pitch.alter().round()).abs() > FloatType::EPSILON)
204        {
205            return "microtonal chord".to_string();
206        }
207
208        if self.notes.is_empty() {
209            return "empty chord".to_string();
210        }
211
212        let ordered_pcs = self.ordered_pitch_classes();
213        if ordered_pcs.is_empty() {
214            return "empty chord".to_string();
215        }
216
217        if ordered_pcs.len() == 1 {
218            if self.notes.len() == 1 {
219                return "note".to_string();
220            }
221
222            let pitch_names = self
223                .notes
224                .iter()
225                .map(|n| n.pitch.name())
226                .collect::<std::collections::BTreeSet<_>>();
227
228            let pitch_pses = self
229                .notes
230                .iter()
231                .map(|n| n.pitch.ps().round() as IntegerType)
232                .collect::<std::collections::BTreeSet<_>>();
233
234            if pitch_names.len() == 1 {
235                if pitch_pses.len() == 1 {
236                    return "unison".to_string();
237                }
238                if pitch_pses.len() == 2 {
239                    return Self::interval_nice_name(&self.notes[0].pitch, &self.notes[1].pitch)
240                        .unwrap_or_else(|| "multiple octaves".to_string());
241                }
242                return "multiple octaves".to_string();
243            }
244            if pitch_pses.len() == 1 {
245                return "enharmonic unison".to_string();
246            }
247            return "enharmonic octaves".to_string();
248        }
249
250        if ordered_pcs.len() == 2 {
251            return self.dyad_common_name();
252        }
253
254        if let Some(common_name) = self.spelling_common_name_override() {
255            return common_name;
256        }
257
258        let address = match tables::seek_chord_tables_address(&ordered_pcs) {
259            Ok(address) => address,
260            Err(_) => return "unknown chord".to_string(),
261        };
262
263        let common_names: Vec<String> = match tables::address_to_common_names(address) {
264            Ok(Some(names)) => names.iter().map(|name| name.to_string()).collect(),
265            _ => Vec::new(),
266        };
267        let forte_name = tables::address_to_forte_name(address, "tn").ok();
268
269        if let Some(forte_name) = forte_name.as_deref() {
270            if let Some(name) = self.augmented_sixth_common_name(forte_name, &common_names) {
271                return name;
272            }
273            if let Some(first) = common_names.first() {
274                if matches!(forte_name, "4-20" | "4-26") {
275                    return if self.is_seventh_with_perfect_fifths_above_root_and_third() {
276                        first.clone()
277                    } else {
278                        format!("enharmonic equivalent to {first}")
279                    };
280                }
281                if let Some(spelled) = self.spelled_as_named(forte_name) {
282                    return if spelled {
283                        first.clone()
284                    } else {
285                        format!("enharmonic equivalent to {first}")
286                    };
287                }
288            }
289        }
290
291        match common_names.first() {
292            Some(name) => name.clone(),
293            None => match forte_name {
294                Some(forte_name) => format!("forte class {forte_name}"),
295                None => "unknown chord".to_string(),
296            },
297        }
298    }
299
300    /// music21 names the set classes that carry an augmented sixth by which
301    /// augmented sixth they are actually spelled as, and falls back to
302    /// `enharmonic to` the plain name when the spelling does not match.
303    pub(super) fn augmented_sixth_common_name(
304        &self,
305        forte_name: &str,
306        common_names: &[String],
307    ) -> Option<String> {
308        let named = |index: usize| common_names.get(index).cloned();
309        let in_inversion = |index: usize| {
310            named(index).map(|name| format!("{name} in {}", self.inversion_text().to_lowercase()))
311        };
312        match forte_name {
313            "4-27B" => {
314                if self.is_dominant_seventh() {
315                    named(0)
316                } else if self.is_german_augmented_sixth(false) {
317                    named(2)
318                } else if self.is_german_augmented_sixth(true) {
319                    in_inversion(2)
320                } else if self.is_swiss_augmented_sixth(false) {
321                    named(3)
322                } else if self.is_swiss_augmented_sixth(true) {
323                    in_inversion(3)
324                } else {
325                    named(0).map(|name| format!("enharmonic to {name}"))
326                }
327            }
328            "4-25" => {
329                if self.is_french_augmented_sixth(false) {
330                    named(1)
331                } else if self.is_french_augmented_sixth(true) {
332                    in_inversion(1)
333                } else {
334                    named(0)
335                }
336            }
337            "3-8A" => {
338                if self.is_italian_augmented_sixth(false, false) {
339                    named(1)
340                } else if self.is_italian_augmented_sixth(true, false) {
341                    in_inversion(1)
342                } else {
343                    named(0)
344                }
345            }
346            _ => None,
347        }
348    }
349
350    /// Whether the chord is spelled as the set class's common name says, for
351    /// the classes where music21 checks: a `3-11B` that is not a major triad
352    /// is only enharmonically one.
353    pub(super) fn spelled_as_named(&self, forte_name: &str) -> Option<bool> {
354        match forte_name {
355            "3-11A" => Some(self.is_minor_triad()),
356            "3-11B" => Some(self.is_major_triad()),
357            "3-10" => Some(self.is_diminished_triad()),
358            "3-12" => Some(self.is_augmented_triad()),
359            "4-27A" => Some(self.is_half_diminished_seventh()),
360            "4-28" => Some(self.is_diminished_seventh()),
361            "5-27A" | "5-27B" | "5-34" => Some(self.is_ninth()),
362            _ => None,
363        }
364    }
365
366    /// music21's check for the two seventh-chord set classes that can be
367    /// spelled several ways: a real major or minor seventh has a perfect
368    /// fifth above both its root and its third.
369    pub(super) fn is_seventh_with_perfect_fifths_above_root_and_third(&self) -> bool {
370        if !self.is_seventh() {
371            return false;
372        }
373        let names = self.pitch_names();
374        let has_fifth_above = |pitch: &Pitch| {
375            PERFECT_FIFTH
376                .transpose_pitch(pitch)
377                .is_ok_and(|above| names.contains(&above.name()))
378        };
379        let (Some(root), Some(third)) = (self.root(), self.third()) else {
380            return false;
381        };
382        has_fifth_above(root) && has_fifth_above(third)
383    }
384
385    pub(super) fn spelling_common_name_override(&self) -> Option<String> {
386        let names = self.unique_pitch_names();
387        let name = if self.names_are(&names, &["C#", "E-", "G"]) {
388            "Italian augmented sixth chord in root position"
389        } else if self.names_are(&names, &["C", "D", "F#", "A-"])
390            || self.names_are(&names, &["D", "E", "G#", "B-"])
391            || (self.from_integer_pitches && self.pitch_class_mask() == 0b010100010100)
392        {
393            "French augmented sixth chord in third inversion"
394        } else if self.names_are(&names, &["C#", "E-", "G", "A"]) {
395            "French augmented sixth chord in first inversion"
396        } else if self.names_are(&names, &["C", "E", "F#", "A#"]) {
397            "French augmented sixth chord"
398        } else if self.names_are(&names, &["C#", "E#", "G", "B"]) {
399            "French augmented sixth chord in root position"
400        } else if self.names_are(&names, &["E-", "F#", "A"])
401            || self.names_are(&names, &["C#", "G", "A#"])
402            || (self.from_integer_pitches && self.pitch_class_mask() == 0b001001001000)
403        {
404            "enharmonic equivalent to diminished triad"
405        } else if self.from_integer_pitches
406            && (self.names_are(&names, &["C#", "D#", "F#", "A#"])
407                || self.names_are(&names, &["C#", "E#", "G#", "A#"])
408                || self.names_are(&names, &["E-", "G-", "A-", "C-"]))
409        {
410            // Built from integers these spellings are what music21 calls an
411            // enharmonic equivalent; written out by name they are the chord
412            // itself.
413            "enharmonic equivalent to minor seventh chord"
414        } else if self.from_integer_pitches
415            && (self.names_are(&names, &["C#", "E#", "F#", "A#"])
416                || self.names_are(&names, &["E-", "F-", "A-", "C-"])
417                || self.names_are(&names, &["E-", "G-", "B-", "C-"]))
418        {
419            "enharmonic equivalent to major seventh chord"
420        } else if self.names_are(&names, &["E-", "F#", "A", "B"]) {
421            "enharmonic to dominant seventh chord"
422        } else {
423            return None;
424        };
425
426        Some(name.to_string())
427    }
428
429    pub(super) fn dyad_common_name(&self) -> String {
430        let pitch_names = self
431            .notes
432            .iter()
433            .map(|n| n.pitch.name())
434            .collect::<std::collections::BTreeSet<_>>();
435
436        let pitch_pses = self
437            .notes
438            .iter()
439            .map(|n| n.pitch.ps().round() as IntegerType)
440            .collect::<std::collections::BTreeSet<_>>();
441
442        let Some(p0) = self.notes.first().map(|n| &n.pitch) else {
443            return "empty chord".to_string();
444        };
445        let p0_pitch_class = root::pitch_class(p0);
446
447        let Some(p1) = self
448            .notes
449            .iter()
450            .skip(1)
451            .find(|n| root::pitch_class(&n.pitch) != p0_pitch_class)
452            .map(|n| &n.pitch)
453        else {
454            return "unknown chord".to_string();
455        };
456
457        let relevant_interval = Interval::between(
458            PitchOrNote::Pitch(p0.clone()),
459            PitchOrNote::Pitch(p1.clone()),
460        );
461
462        if pitch_names.len() > 2 {
463            let Ok(interval) = relevant_interval else {
464                return "unknown chord".to_string();
465            };
466            let semitones = interval.chromatic.simple_undirected();
467            let plural = if semitones == 1 { "" } else { "s" };
468            return format!("{semitones} semitone{plural}");
469        }
470
471        if pitch_pses.len() > 2 {
472            return relevant_interval
473                .map(|interval| {
474                    format!("{} with octave doublings", interval.semi_simple_nice_name())
475                })
476                .unwrap_or_else(|_| "unknown chord".to_string());
477        }
478
479        Self::interval_nice_name(&self.notes[0].pitch, &self.notes[1].pitch)
480            .unwrap_or_else(|| "unknown chord".to_string())
481    }
482
483    /// Returns all unpitched common-name aliases known for this chord.
484    pub fn common_names(&self) -> Vec<String> {
485        let ordered_pcs = self.ordered_pitch_classes();
486        let Ok(address) = tables::seek_chord_tables_address(&ordered_pcs) else {
487            return Vec::new();
488        };
489        tables::address_to_common_names(address)
490            .ok()
491            .flatten()
492            .unwrap_or_default()
493            .into_iter()
494            .map(str::to_string)
495            .collect()
496    }
497
498    /// Returns the inferred root pitch name when the chord has one.
499    ///
500    /// Returns `None` for empty chords, where there is no pitch from which a
501    /// root can be inferred.
502    pub fn root_pitch_name(&self) -> Option<String> {
503        self.root_pitch_name_from_tables()
504    }
505
506    /// Returns the lowest pitch name in the chord.
507    ///
508    /// Returns `None` for empty chords, where there is no bass pitch.
509    pub fn bass_pitch_name(&self) -> Option<String> {
510        self.bass_pitch().map(Self::display_pitch_name)
511    }
512
513    pub(super) fn root_pitch_name_from_tables(&self) -> Option<String> {
514        self.find_root_pitch().map(Self::display_pitch_name)
515    }
516
517    pub(super) fn common_names_with_primary(&self) -> Vec<String> {
518        let mut names = vec![self.common_name()];
519        names.extend(self.common_names());
520        names.sort();
521        names.dedup();
522        names
523    }
524
525    pub(super) fn pitch_class_name(pc: u8) -> &'static str {
526        CANDIDATE_TONICS[pc as usize % 12]
527    }
528
529    /// Whether the chord is spelled with exactly these pitch names.
530    ///
531    /// The set of names it compares against is passed in rather than built
532    /// here: the two spelling cascades ask this question up to fifteen times
533    /// in a row, and building the set per question was most of what
534    /// `common_name` cost.
535    pub(super) fn names_are(
536        &self,
537        names: &std::collections::BTreeSet<String>,
538        expected: &[&str],
539    ) -> bool {
540        self.notes.len() == expected.len() && expected.iter().all(|name| names.contains(*name))
541    }
542
543    pub(super) fn interval_nice_name(start: &Pitch, end: &Pitch) -> Option<String> {
544        Interval::between(
545            PitchOrNote::Pitch(start.clone()),
546            PitchOrNote::Pitch(end.clone()),
547        )
548        .ok()
549        .map(|interval| interval.nice_name())
550    }
551
552    pub(super) fn display_pitch_name(pitch: &Pitch) -> String {
553        pitch.name().replace('-', "b")
554    }
555
556    pub(super) fn display_key_name(key: &Key) -> String {
557        format!(
558            "{} {}",
559            Self::display_tonic_name(&key.tonic().name()),
560            key.mode()
561        )
562    }
563
564    pub(super) fn display_tonic_name(name: &str) -> String {
565        name.replace('-', "b")
566    }
567
568    /// Returns music21's `fullName`: the pitches' full names between braces,
569    /// followed by the duration's full name when the chord has a duration
570    /// that music21 can name.
571    pub fn full_name(&self) -> String {
572        let pitches = self
573            .notes
574            .iter()
575            .map(|note| note.pitch.full_name())
576            .collect::<Vec<_>>()
577            .join(" | ");
578        let duration = self
579            .duration
580            .clone()
581            .unwrap_or_else(Duration::quarter)
582            .full_name();
583        format!("Chord {{{pitches}}} {duration}")
584    }
585}
586
587#[derive(Debug, Clone, PartialEq)]
588#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
589/// An unpitched chord type known to the music21-derived chord table.
590pub struct KnownChordType {
591    /// Number of distinct pitch classes in the chord type.
592    pub cardinality: u8,
593    /// Unpitched common-name aliases in music21 table order.
594    pub common_names: Vec<String>,
595    /// Forte class for this transposition-normal entry, such as `"3-11B"`.
596    pub forte_class: String,
597    /// Transposed normal form pitch classes.
598    pub normal_form: Vec<u8>,
599    /// Six-entry interval-class vector.
600    pub interval_class_vector: Vec<u8>,
601}