Skip to main content

sound_to_patch/music/
chord.rs

1//! The chord-symbol parser.
2//!
3//! A symbol is a root letter with an optional sharp or flat, a spelling
4//! from a chord entry of the dictionary, and an optional `/X` bass note.
5//! Parentheses list alterations (`C7(b9,#11)`), and `no3`, `omit3` and
6//! `no5` remove a tone. A `5` straight after the root is the power chord,
7//! so `Cb5` is a power chord on Cb, not C with a flat fifth. A spelling
8//! outside the dictionary is an error that names it. The tones of a symbol with several readings follow the
9//! dictionary's policy, not any one program.
10//!
11//! ```
12//! use sound_to_patch::music::chord::parse_chord;
13//!
14//! let chord = parse_chord("Am7/G")?;
15//! assert_eq!((chord.root, chord.tones.as_slice(), chord.bass), (9, &[0, 3, 7, 10][..], Some(7)));
16//! assert_eq!(chord.entry, Some("chord.minor-seventh"));
17//! # Ok::<(), sound_to_patch::music::MusicError>(())
18//! ```
19
20use std::collections::{BTreeSet, HashMap};
21use std::sync::OnceLock;
22
23use crate::music::entry::{Group, Rule};
24use crate::music::pitch::letter_class;
25use crate::music::{MusicError, dictionary};
26
27/// The tones the parser gives `alt`: the dominant's root, third and
28/// seventh with a sharp ninth and a flat thirteenth.
29pub const ALT_TONES: [u8; 5] = [0, 4, 10, 15, 20];
30
31/// The one chord entry that chooses its tones; its spellings take
32/// [`ALT_TONES`].
33pub const ALTERED_DOMINANT: &str = "chord.altered-dominant";
34
35/// A parsed chord symbol.
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct ChordSymbol {
38    /// The root pitch class.
39    pub root: u8,
40    /// Semitones above the root, rising; 14 is a ninth.
41    pub tones: Vec<u8>,
42    /// The bass pitch class after a slash, if any.
43    pub bass: Option<u8>,
44    /// The chord entry with these tones: the entry of the spelling, or after
45    /// alterations the entry whose tones match. None when no entry has
46    /// exactly these tones.
47    pub entry: Option<&'static str>,
48}
49
50impl ChordSymbol {
51    /// The pitch classes the chord sounds, bass included.
52    #[must_use]
53    pub fn classes(&self) -> BTreeSet<u8> {
54        self.tones
55            .iter()
56            .map(|tone| (self.root + tone) % 12)
57            .chain(self.bass)
58            .collect()
59    }
60}
61
62struct Spellings {
63    by_symbol: HashMap<&'static str, (Vec<u8>, &'static str)>,
64    by_tones: HashMap<Vec<u8>, &'static str>,
65}
66
67fn spellings() -> Result<&'static Spellings, MusicError> {
68    static MAP: OnceLock<Result<Spellings, MusicError>> = OnceLock::new();
69    MAP.get_or_init(|| {
70        let mut by_symbol = HashMap::new();
71        let mut by_tones = HashMap::new();
72        for entry in dictionary()?.group(Group::Chord) {
73            let Rule::ChordTones { offsets, .. } = &entry.rule else {
74                continue;
75            };
76            let tones = if entry.id == ALTERED_DOMINANT {
77                ALT_TONES.to_vec()
78            } else {
79                offsets.clone()
80            };
81            for symbol in &entry.symbols {
82                by_symbol.insert(symbol.as_str(), (tones.clone(), entry.id.as_str()));
83            }
84            by_tones.entry(tones).or_insert(entry.id.as_str());
85        }
86        Ok(Spellings {
87            by_symbol,
88            by_tones,
89        })
90    })
91    .as_ref()
92    .map_err(Clone::clone)
93}
94
95/// Parses a chord symbol such as `Cmaj7`, `F#m7b5`, `Bb7(b9)`, `C13b9` or
96/// `C/E`.
97///
98/// # Errors
99///
100/// [`MusicError::UnsupportedChord`] naming the symbol when its spelling is
101/// not in the dictionary, and [`MusicError::BrokenData`] when the
102/// dictionary does not load.
103pub fn parse_chord(text: &str) -> Result<ChordSymbol, MusicError> {
104    let unsupported = || MusicError::UnsupportedChord(text.to_owned());
105    let trimmed = text.trim();
106    let (root, rest) = read_root(trimmed).ok_or_else(unsupported)?;
107    let spellings = spellings()?;
108    let (quality_text, bass) = if quality(rest, spellings).is_some() {
109        (rest, None)
110    } else {
111        let (quality_text, bass_text) = rest.rsplit_once('/').ok_or_else(unsupported)?;
112        let bass = read_root(bass_text)
113            .filter(|(_, after)| after.is_empty())
114            .map(|(bass, _)| bass)
115            .ok_or_else(unsupported)?;
116        (quality_text, Some(bass))
117    };
118    let (tones, entry) = quality(quality_text, spellings).ok_or_else(unsupported)?;
119    let entry = entry.or_else(|| spellings.by_tones.get(&tones).copied());
120    Ok(ChordSymbol {
121        root,
122        tones,
123        bass,
124        entry,
125    })
126}
127
128/// An upper-case letter with up to two sharps or two flats.
129fn read_root(text: &str) -> Option<(u8, &str)> {
130    let letter = text.chars().next().filter(char::is_ascii_uppercase)?;
131    let class = letter_class(letter)?;
132    let mut rest = &text[1..];
133    let mut step = 0;
134    for _ in 0..2 {
135        let sign = match rest.chars().next() {
136            Some(sign @ ('#' | '♯')) if step >= 0 => {
137                step += 1;
138                sign
139            }
140            Some(sign @ ('b' | '♭')) if step <= 0 => {
141                step -= 1;
142                sign
143            }
144            _ => break,
145        };
146        rest = &rest[sign.len_utf8()..];
147    }
148    u8::try_from((class + step).rem_euclid(12))
149        .ok()
150        .map(|root| (root, rest))
151}
152
153/// The tones of the text after the root, and the entry when the text is a
154/// spelling: a spelling, a spelling with a removed tone, with alterations
155/// in parentheses, or with trailing alterations such as `13b9`.
156fn quality(text: &str, spellings: &Spellings) -> Option<(Vec<u8>, Option<&'static str>)> {
157    // A bare root is the major triad.
158    let text = if text.is_empty() { "maj" } else { text };
159    if let Some((tones, entry)) = spellings.by_symbol.get(text) {
160        return Some((tones.clone(), Some(entry)));
161    }
162    for (suffix, removed) in [
163        ("(no3)", [3, 4]),
164        ("(omit3)", [3, 4]),
165        ("no3", [3, 4]),
166        ("omit3", [3, 4]),
167        ("(no5)", [7, 7]),
168        ("(omit5)", [7, 7]),
169        ("no5", [7, 7]),
170        ("omit5", [7, 7]),
171    ] {
172        if let Some(base) = text.strip_suffix(suffix) {
173            let (mut tones, _) = quality(base, spellings)?;
174            tones.retain(|tone| !removed.contains(tone));
175            return Some((tones, None));
176        }
177    }
178    if let Some((base, alterations)) = text.strip_suffix(')').and_then(|t| t.split_once('(')) {
179        let (mut tones, _) = quality(base, spellings)?;
180        for alteration in alterations.split(',') {
181            alter(&mut tones, alteration.trim())?;
182        }
183        return Some((tones, None));
184    }
185    for sign in ["b", "♭", "#", "♯"] {
186        for degree in ["13", "11", "9", "5"] {
187            let token = format!("{sign}{degree}");
188            if let Some(base) = text.strip_suffix(&token)
189                && !base.is_empty()
190                && let Some((mut tones, _)) = quality(base, spellings)
191            {
192                alter(&mut tones, &token)?;
193                return Some((tones, None));
194            }
195        }
196    }
197    None
198}
199
200/// Applies one alteration such as `b9`, `#11`, `add13` or `maj7`.
201fn alter(tones: &mut Vec<u8>, alteration: &str) -> Option<()> {
202    match alteration {
203        "maj7" => {
204            tones.retain(|tone| *tone != 10);
205            tones.push(11);
206        }
207        "maj9" => {
208            tones.retain(|tone| *tone != 10);
209            tones.extend([11, 14]);
210        }
211        _ => {
212            let text = alteration.strip_prefix("add").unwrap_or(alteration);
213            let (step, degree) = match text.chars().next()? {
214                'b' | '♭' | '-' => (-1, &text[text.chars().next()?.len_utf8()..]),
215                '#' | '♯' | '+' => (1, &text[text.chars().next()?.len_utf8()..]),
216                _ => (0, text),
217            };
218            let natural: i16 = match degree {
219                "5" => 7,
220                "9" => 14,
221                "11" => 17,
222                "13" => 21,
223                _ => return None,
224            };
225            let tone = u8::try_from(natural + step).ok()?;
226            let replaced = u8::try_from(natural).ok()?;
227            tones.retain(|existing| *existing != replaced);
228            tones.push(tone);
229        }
230    }
231    tones.sort_unstable();
232    tones.dedup();
233    Some(())
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    fn tones(text: &str) -> Vec<u8> {
241        parse_chord(text)
242            .unwrap_or_else(|error| panic!("{text}: {error}"))
243            .tones
244    }
245
246    #[test]
247    fn parses_roots_and_slash_basses() {
248        let chord = parse_chord("F#m7b5").unwrap();
249        assert_eq!(
250            (chord.root, chord.tones.as_slice()),
251            (6, &[0, 3, 6, 10][..])
252        );
253        let chord = parse_chord("C/E").unwrap();
254        assert_eq!((chord.root, chord.bass), (0, Some(4)));
255        assert_eq!(chord.classes(), BTreeSet::from([0, 4, 7]));
256        let chord = parse_chord("Bb").unwrap();
257        assert_eq!((chord.root, chord.entry), (10, Some("chord.major-triad")));
258        let chord = parse_chord("Dbb7").unwrap();
259        assert_eq!(
260            (chord.root, chord.entry),
261            (0, Some("chord.dominant-seventh"))
262        );
263        assert_eq!(parse_chord("C/Bbb").unwrap().bass, Some(9));
264        assert_eq!(parse_chord("F##").unwrap().root, 7);
265        let chord = parse_chord("Cb5").unwrap();
266        assert_eq!((chord.root, chord.tones.as_slice()), (11, &[0, 7][..]));
267    }
268
269    #[test]
270    fn applies_alterations_and_removals() {
271        assert_eq!(tones("C7(b9,#11)"), [0, 4, 7, 10, 13, 18]);
272        assert_eq!(tones("C7b9#11"), [0, 4, 7, 10, 13, 18]);
273        assert_eq!(tones("C7#9b13"), [0, 4, 7, 10, 15, 20]);
274        assert_eq!(tones("C13b9"), [0, 4, 7, 10, 13, 21]);
275        assert_eq!(tones("Cm(maj7)"), [0, 3, 7, 11]);
276        assert_eq!(tones("Cm(maj9)"), [0, 3, 7, 11, 14]);
277        assert_eq!(tones("C7alt"), ALT_TONES);
278    }
279
280    #[test]
281    fn removals_drop_the_third_or_the_fifth() {
282        for text in ["C7(no3)", "C7no3", "C7omit3", "C7(omit3)"] {
283            assert_eq!(tones(text), [0, 7, 10], "{text}");
284        }
285        assert_eq!(tones("Cm7no3"), [0, 7, 10]);
286        for text in ["C7(no5)", "C7no5", "C7omit5", "C7(omit5)"] {
287            assert_eq!(tones(text), [0, 4, 10], "{text}");
288        }
289        assert_eq!(tones("C6no5"), [0, 4, 9]);
290        assert_eq!(tones("C13no5"), [0, 4, 10, 14, 21]);
291    }
292
293    #[test]
294    fn an_altered_chord_takes_the_entry_of_its_tones() {
295        assert_eq!(
296            parse_chord("Cm7(b5)").unwrap().entry,
297            Some("chord.half-diminished")
298        );
299        assert_eq!(
300            parse_chord("Cm(maj7)").unwrap().entry,
301            Some("chord.minor-major-seventh")
302        );
303        assert_eq!(parse_chord("C7(no5)").unwrap().entry, None);
304    }
305
306    #[test]
307    fn names_an_unsupported_symbol() {
308        for text in [
309            "Cxyz", "c", "H7", "C/H", "C7(b10)", "", "C2", "C4", "Cadd2", "C#b7",
310        ] {
311            let error = parse_chord(text).unwrap_err();
312            assert_eq!(
313                error,
314                MusicError::UnsupportedChord(text.to_owned()),
315                "{text}"
316            );
317        }
318        assert!(
319            parse_chord("Cxyz")
320                .unwrap_err()
321                .to_string()
322                .contains("`Cxyz`")
323        );
324    }
325}