1use 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
27pub const ALT_TONES: [u8; 5] = [0, 4, 10, 15, 20];
30
31pub const ALTERED_DOMINANT: &str = "chord.altered-dominant";
34
35#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct ChordSymbol {
38 pub root: u8,
40 pub tones: Vec<u8>,
42 pub bass: Option<u8>,
44 pub entry: Option<&'static str>,
48}
49
50impl ChordSymbol {
51 #[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
95pub 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
128fn 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
153fn quality(text: &str, spellings: &Spellings) -> Option<(Vec<u8>, Option<&'static str>)> {
157 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
200fn 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}