Skip to main content

music21_rs/chordsymbol/
figure.rs

1//! Naming a chord as a lead-sheet symbol, the way music21's
2//! `chordSymbolFigureFromChord` names it.
3
4use super::*;
5use crate::scale::{Scale, ScaleType};
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8pub(super) struct Music21FigureMatch {
9    kind: &'static str,
10    notation: &'static str,
11    abbreviation: &'static str,
12}
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub(super) struct Music21ChordAnalysis {
16    d3: Option<u8>,
17    d5: Option<u8>,
18    d7: Option<u8>,
19    d9: Option<u8>,
20    d11: Option<u8>,
21    d13: Option<u8>,
22    is_triad: bool,
23    is_seventh: bool,
24}
25
26/// The lead-sheet symbol a chord is written as, as a list so a caller can
27/// keep using it where several were once offered: [`ChordSymbolFigure`]
28/// written out, or nothing when no kind fits.
29pub(crate) fn chord_symbol_spellings(chord: &Chord) -> Vec<String> {
30    ChordSymbolFigure::from_chord(chord)
31        .map(|figure| figure.to_string())
32        .into_iter()
33        .collect()
34}
35
36/// [`chord_symbol_spellings`] with the root fixed as the pitch of the chord
37/// that has the given pitch class, or nothing when no pitch has it.
38pub(crate) fn chord_symbol_spellings_with_root(chord: &Chord, root: u8) -> Vec<String> {
39    let Some(root) = chord
40        .pitches()
41        .into_iter()
42        .find(|pitch| pitch_class(pitch) == root % 12)
43    else {
44        return Vec::new();
45    };
46    ChordSymbolFigure::from_chord_with_root(chord, &root)
47        .map(|figure| figure.to_string())
48        .into_iter()
49        .collect()
50}
51
52/// A chord named as a lead-sheet symbol, in the parts music21's
53/// `chordSymbolFigureFromChord` writes it from: the root, the kind and its
54/// abbreviation, the bass where it is not the root, and the notes the kind
55/// does not account for.
56///
57/// `Display` writes the figure as music21 writes it, flats as `b` — `C7`,
58/// `Ebm7/Gb`, `Caddb9`, `Am7add11/C` — and [`Self::written_with`] writes it
59/// with another abbreviation for the kind.
60#[derive(Clone, Debug, PartialEq, Eq)]
61#[must_use]
62pub struct ChordSymbolFigure {
63    /// The root the figure is written on.
64    ///
65    /// A suspended second in inversion is read as a suspended fourth on its
66    /// bass, as music21 reads it, so for that chord this is the bass.
67    pub root: String,
68    /// music21's name for the kind, `dominant-seventh`.
69    pub kind: &'static str,
70    /// The abbreviation music21 writes the kind with, `7`.
71    pub abbreviation: &'static str,
72    /// The bass, where it is not the root.
73    pub bass: Option<String>,
74    /// The notes of the chord the kind does not account for, each as a
75    /// degree above the root and how far it stands from that degree of the
76    /// root's major scale, in order of degree: `G B D F A- C E-` over `G7` is
77    /// a flat ninth, an eleventh and a flat thirteenth.
78    ///
79    /// A second, fourth or sixth the kind does not have is counted an octave
80    /// up, as a ninth, eleventh or thirteenth.
81    pub additions: Vec<ChordAlteration>,
82    /// The degrees the kind expects that the chord lacks, in order, counted
83    /// as [`Self::additions`] are, so a suspended fourth that lacks its fourth
84    /// omits a 4 and not an 11. music21 writes these only beside additions: a
85    /// chord that leaves out a note of its kind is still that kind, and says
86    /// nothing about it.
87    pub omissions: Vec<u8>,
88}
89
90impl ChordSymbolFigure {
91    /// The figure of a chord, or `None` for an empty chord, a microtonal one,
92    /// or one no kind in music21's table fits.
93    pub fn from_chord(chord: &Chord) -> Option<Self> {
94        let pitches = chord.pitches();
95        let microtonal = pitches
96            .iter()
97            .any(|pitch| (pitch.ps() - pitch.ps().round()).abs() > FloatType::EPSILON);
98        if pitches.is_empty() || microtonal {
99            return None;
100        }
101        let root = chord.root()?.clone();
102        if pitches.len() == 1 {
103            return Some(Self {
104                root: root.name(),
105                kind: "pedal",
106                abbreviation: "pedal",
107                bass: None,
108                additions: Vec::new(),
109                omissions: Vec::new(),
110            });
111        }
112        let matched = identify_music21_chord_type(&Music21ChordAnalysis::of(chord))?;
113        let bass = chord.bass()?.clone();
114        let inverted = pitch_class(&bass) != pitch_class(&root);
115        let (root, kind, abbreviation, notation) = if inverted && matched.kind == "suspended-second"
116        {
117            (bass.clone(), "suspended-fourth", "sus", "1,4,5")
118        } else {
119            (root, matched.kind, matched.abbreviation, matched.notation)
120        };
121        let bass = (pitch_class(&bass) != pitch_class(&root)).then(|| bass.name());
122        let mut perfect = kind_pitch_names(&root, notation).ok()?;
123        // music21 reads the figure back through its `ChordSymbol`, which
124        // adds a bass the kind does not carry to the notes it sounds, so a
125        // bass is never an addition.
126        perfect.extend(bass.clone());
127        let present: BTreeSet<String> = pitches.iter().map(Pitch::name).collect();
128        let (additions, omissions) = if perfect.is_superset(&present) {
129            (Vec::new(), Vec::new())
130        } else {
131            let major = Scale::new(ScaleType::Major, root.clone());
132            let kind_degrees: Vec<u8> = notation_intervals(notation)
133                .ok()?
134                .into_iter()
135                .map(|(degree, _)| degree)
136                .collect();
137            let degree_and_alter = |name: &String| -> Option<(u8, IntegerType)> {
138                let (degree, accidental) = major
139                    .degree_and_accidental_of(&Pitch::from_name(name).ok()?)
140                    .ok()?;
141                let mut degree = u8::try_from(degree).ok()?;
142                if matches!(degree, 2 | 4 | 6) && !kind_degrees.contains(&degree) {
143                    degree += 7;
144                }
145                Some((
146                    degree,
147                    accidental.map_or(0, |accidental| accidental.alter() as IntegerType),
148                ))
149            };
150            let mut additions = present
151                .difference(&perfect)
152                .map(degree_and_alter)
153                .collect::<Option<Vec<_>>>()?;
154            additions.sort_unstable();
155            let mut omissions = perfect
156                .difference(&present)
157                .map(|name| degree_and_alter(name).map(|(degree, _)| degree))
158                .collect::<Option<Vec<_>>>()?;
159            omissions.sort_unstable();
160            (
161                additions
162                    .into_iter()
163                    .map(|(degree, alter)| ChordAlteration::new(degree, alter))
164                    .collect(),
165                omissions,
166            )
167        };
168        Some(Self {
169            root: root.name(),
170            kind,
171            abbreviation,
172            bass,
173            additions,
174            omissions,
175        })
176    }
177
178    /// The figure with the root fixed by the caller rather than inferred,
179    /// which is how music21 names the augmented sixths.
180    pub fn from_chord_with_root(chord: &Chord, root: &Pitch) -> Option<Self> {
181        let mut chord = chord.clone();
182        chord.set_root(Some(root.clone()));
183        Self::from_chord(&chord)
184    }
185
186    /// The figure written with another abbreviation for its kind, which is
187    /// how music21 writes it after `changeAbbreviationFor`.
188    pub fn written_with(&self, abbreviation: &str) -> String {
189        let mut figure = format!("{}{abbreviation}", self.root);
190        for addition in &self.additions {
191            figure.push_str("add");
192            figure.push_str(&alter_to_chord_symbol_string(addition.semitones()));
193            figure.push_str(&addition.degree().to_string());
194        }
195        for omission in &self.omissions {
196            figure.push_str("omit");
197            figure.push_str(&omission.to_string());
198        }
199        if let Some(bass) = &self.bass {
200            figure.push('/');
201            figure.push_str(bass);
202        }
203        figure
204    }
205}
206
207impl std::fmt::Display for ChordSymbolFigure {
208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        f.write_str(&self.written_with(self.abbreviation))
210    }
211}
212
213impl Music21ChordAnalysis {
214    /// What music21's `chordSymbolFigureFromChord` reads off a chord before
215    /// it looks for a kind, above the root the chord reports.
216    fn of(chord: &Chord) -> Self {
217        let step = |degree: u8| chord.semitones_from_chord_step(degree);
218        Self {
219            d3: step(3),
220            d5: step(5),
221            d7: step(7),
222            d9: step(2),
223            d11: step(4),
224            d13: step(6),
225            is_triad: chord.is_triad(),
226            is_seventh: chord.is_seventh(),
227        }
228    }
229}
230
231/// How a lead-sheet symbol writes an alteration of so many semitones: a `b`
232/// for each semitone down and a `#` for each one up, so `-1` is `b`, `2` is
233/// `##` and nought is nothing. music21's `alterToChordSymbolString`.
234pub fn alter_to_chord_symbol_string(alter: IntegerType) -> String {
235    let sign = if alter < 0 { "b" } else { "#" };
236    sign.repeat(alter.unsigned_abs() as usize)
237}
238
239pub(super) fn identify_music21_chord_type(
240    analysis: &Music21ChordAnalysis,
241) -> Option<Music21FigureMatch> {
242    let mut matched = None;
243
244    for chord_type in MUSIC21_CHORD_TYPES {
245        let chord_degrees = chord_degrees_for_notation(chord_type.notation)?;
246        let is_match = match chord_degrees.len() {
247            2 if analysis.is_triad => {
248                compare_music21_degrees(&[analysis.d3, analysis.d5], &chord_degrees, &[])
249            }
250            3 if analysis.is_seventh => compare_music21_degrees(
251                &[analysis.d3, analysis.d5, analysis.d7],
252                &chord_degrees,
253                &[],
254            ),
255            4 if music21_truthy(analysis.d9)
256                && !music21_truthy(analysis.d11)
257                && !music21_truthy(analysis.d13) =>
258            {
259                compare_music21_degrees(
260                    &[analysis.d3, analysis.d5, analysis.d7, analysis.d9],
261                    &chord_degrees,
262                    &[5],
263                )
264            }
265            5 if music21_truthy(analysis.d11) && !music21_truthy(analysis.d13) => {
266                compare_music21_degrees(
267                    &[
268                        analysis.d3,
269                        analysis.d5,
270                        analysis.d7,
271                        analysis.d9,
272                        analysis.d11,
273                    ],
274                    &chord_degrees,
275                    &[3, 5],
276                )
277            }
278            6 if music21_truthy(analysis.d13) => compare_music21_degrees(
279                &[
280                    analysis.d3,
281                    analysis.d5,
282                    analysis.d7,
283                    analysis.d9,
284                    analysis.d11,
285                    analysis.d13,
286                ],
287                &chord_degrees,
288                &[5, 11, 9],
289            ),
290            _ => false,
291        };
292
293        if is_match {
294            matched = Some(Music21FigureMatch {
295                kind: chord_type.kind,
296                notation: chord_type.notation,
297                abbreviation: chord_type.abbreviation,
298            });
299        }
300    }
301
302    if matched.is_some() {
303        return matched;
304    }
305
306    let mut number_of_matched_degrees = 0;
307    for chord_type in MUSIC21_CHORD_TYPES {
308        let chord_degrees = chord_degrees_for_notation(chord_type.notation)?;
309        let mut degrees = degree_numbers_for_notation(chord_type.notation)?;
310        degrees.sort_unstable();
311        let to_compare = degrees
312            .into_iter()
313            .filter(|degree| *degree != 1)
314            .map(|degree| analysis_value_for_degree(analysis, degree))
315            .collect::<Vec<_>>();
316
317        if compare_music21_degrees(&to_compare, &chord_degrees, &[])
318            && number_of_matched_degrees < chord_degrees.len()
319        {
320            number_of_matched_degrees = chord_degrees.len();
321            matched = Some(Music21FigureMatch {
322                kind: chord_type.kind,
323                notation: chord_type.notation,
324                abbreviation: chord_type.abbreviation,
325            });
326        }
327    }
328
329    matched
330}
331
332pub(super) fn compare_music21_degrees(
333    in_chord_nums: &[Option<u8>],
334    given_chord_nums: &[u8],
335    permitted_omissions: &[u8],
336) -> bool {
337    if given_chord_nums.len() > in_chord_nums.len() {
338        return false;
339    }
340
341    for (index, expected) in given_chord_nums.iter().enumerate() {
342        if in_chord_nums[index] == Some(*expected) {
343            continue;
344        }
345
346        let (degree, natural) = match index {
347            0 => (3, 4),
348            1 => (5, 7),
349            2 => (7, 11),
350            3 => (9, 2),
351            4 => (11, 5),
352            5 => (13, 9),
353            _ => return false,
354        };
355
356        if !(permitted_omissions.contains(&degree)
357            && *expected == natural
358            && in_chord_nums[index].is_none())
359        {
360            return false;
361        }
362    }
363
364    true
365}
366
367pub(super) fn music21_truthy(value: Option<u8>) -> bool {
368    value.is_some_and(|value| value != 0)
369}
370
371pub(super) fn analysis_value_for_degree(analysis: &Music21ChordAnalysis, degree: u8) -> Option<u8> {
372    match degree {
373        2 | 9 => analysis.d9,
374        3 => analysis.d3,
375        4 | 11 => analysis.d11,
376        5 => analysis.d5,
377        6 | 13 => analysis.d13,
378        7 => analysis.d7,
379        _ => None,
380    }
381}
382
383/// Names a chord as a lead-sheet symbol: music21's
384/// `chordSymbolFigureFromChord`, so `C E G B-` is `C7`, `E G C` is `C/E`
385/// and a lone `C` is `Cpedal`. Notes the kind cannot account for are listed
386/// after `add`, and beside them the notes the kind expects but the chord
387/// lacks after `omit`, as music21 writes them. `None` when no kind fits,
388/// where music21 returns the sentence "Chord Symbol Cannot Be Identified";
389/// an empty chord gives an empty string. The parts the figure is written
390/// from are [`ChordSymbolFigure`].
391pub fn chord_symbol_figure_from_chord(chord: &Chord) -> Result<Option<String>> {
392    if chord.notes().is_empty() {
393        return Ok(Some(String::new()));
394    }
395    Ok(ChordSymbolFigure::from_chord(chord).map(|figure| figure.to_string()))
396}
397
398/// The kind of chord a figure is written with: what music21 answers beside
399/// the figure when `chordSymbolFigureFromChord` is asked to include the chord
400/// type, so `C E G` is `major` and a lone `C` is `pedal`.
401///
402/// `None` for an empty chord, which has no figure either, and for a chord no
403/// kind in music21's table fits.
404#[must_use]
405pub fn chord_symbol_kind_from_chord(chord: &Chord) -> Option<&'static str> {
406    ChordSymbolFigure::from_chord(chord).map(|figure| figure.kind)
407}
408/// A [`ChordSymbol`] read off a chord: music21's `chordSymbolFromChord`,
409/// [`chord_symbol_figure_from_chord`] parsed back. `None` when no kind fits.
410pub fn chord_symbol_from_chord(chord: &Chord) -> Result<Option<ChordSymbol>> {
411    match chord_symbol_figure_from_chord(chord)? {
412        Some(figure) if !figure.is_empty() => Ok(Some(ChordSymbol::parse(figure)?)),
413        _ => Ok(None),
414    }
415}