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