development
rs 632 lines 20.6 KB
Raw
1 //! Keys, Roman-numeral analysis, key detection from a progression, and realizing numerals.
2
3 use anyhow::{Result, bail};
4
5 use super::chord::Chord;
6 use super::pitch::Note;
7 use super::scale;
8
9 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
10 pub enum Mode {
11 Major,
12 Minor,
13 }
14
15 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
16 pub struct Key {
17 pub tonic: Note,
18 pub mode: Mode,
19 }
20
21 const MAJOR_REF: [i32; 7] = [0, 2, 4, 5, 7, 9, 11];
22 const MINOR_REF: [i32; 7] = [0, 2, 3, 5, 7, 8, 10];
23 const ROMAN: [&str; 7] = ["I", "II", "III", "IV", "V", "VI", "VII"];
24
25 impl Key {
26 /// `C`, `C major`, `Am`, `A minor`, `F#m`, `Bb`, `Ebmin`.
27 pub fn parse(s: &str) -> Result<Key> {
28 let s = s.trim();
29 let mut chars = s.chars();
30 let Some(first) = chars.next().filter(|c| c.is_ascii_alphabetic()) else {
31 bail!("not a key: `{s}` (use e.g. C, Am, F# minor, Bb major)");
32 };
33 let mut rest = &s[1..];
34 let mut note = first.to_ascii_uppercase().to_string();
35 if let Some(c) = rest
36 .chars()
37 .next()
38 .filter(|c| matches!(c, '#' | '♯' | 'b' | '♭'))
39 {
40 // `Bb`, `F#`; but not a bare mode word like `b minor` → handled since `b` then space.
41 note.push(if matches!(c, '#' | '♯') { '#' } else { 'b' });
42 rest = &rest[c.len_utf8()..];
43 }
44 let tonic = Note::parse(&note)?;
45 let mode = match rest.trim().to_lowercase().as_str() {
46 "" | "major" | "maj" | "ionian" => Mode::Major,
47 "m" | "min" | "minor" | "aeolian" => Mode::Minor,
48 other => bail!("unknown key mode `{other}` in `{s}` (use major or minor)"),
49 };
50 Ok(Key { tonic, mode })
51 }
52
53 pub fn name(&self) -> String {
54 format!(
55 "{} {}",
56 self.tonic.name(),
57 match self.mode {
58 Mode::Major => "major",
59 Mode::Minor => "minor",
60 }
61 )
62 }
63
64 fn reference(&self) -> &'static [i32; 7] {
65 match self.mode {
66 Mode::Major => &MAJOR_REF,
67 Mode::Minor => &MINOR_REF,
68 }
69 }
70
71 /// The seven scale notes (major or natural minor), spelled.
72 pub fn notes(&self) -> Vec<Note> {
73 let r = self.reference();
74 (0..7).map(|i| self.tonic.up(i, r[i])).collect()
75 }
76
77 pub fn pcs(&self) -> Vec<i32> {
78 self.notes().iter().map(|n| n.pc()).collect()
79 }
80
81 pub fn signature(&self) -> i32 {
82 scale::signature(&self.notes())
83 }
84
85 /// The note on `degree` (0-based) raised/lowered by `acc` semitones from the key's scale.
86 fn degree_note(&self, degree: usize, acc: i32) -> Note {
87 self.tonic.up(degree, self.reference()[degree] + acc)
88 }
89
90 pub fn relative(&self) -> Key {
91 match self.mode {
92 Mode::Major => Key {
93 tonic: self.tonic.up(5, 9),
94 mode: Mode::Minor,
95 },
96 Mode::Minor => Key {
97 tonic: self.tonic.up(2, 3),
98 mode: Mode::Major,
99 },
100 }
101 }
102 }
103
104 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
105 pub enum Kind {
106 Diatonic,
107 DiatonicHarmonic,
108 SecondaryDominant,
109 SecondaryLeadingTone,
110 TritoneSub,
111 Neapolitan,
112 Borrowed,
113 Chromatic,
114 }
115
116 #[derive(Debug, Clone)]
117 pub struct ChordAnalysis {
118 pub numeral: String,
119 pub kind: Kind,
120 pub role: String,
121 }
122
123 fn semis_diff(semis: i32, reference: i32) -> i32 {
124 (semis - reference + 6).rem_euclid(12) - 6
125 }
126
127 fn accidental_prefix(diff: i32) -> String {
128 if diff > 0 {
129 "#".repeat(diff as usize)
130 } else {
131 "b".repeat((-diff) as usize)
132 }
133 }
134
135 /// Numeral decoration derived from the canonical chord suffix (`ii7`, `viiø7`, `V7#9`, `I+`).
136 fn numeral_suffix(suffix: &str) -> String {
137 if let Some(rest) = suffix.strip_prefix("m7b5") {
138 format!("ø7{rest}")
139 } else if let Some(rest) = suffix.strip_prefix("dim7") {
140 format!("°7{rest}")
141 } else if let Some(rest) = suffix.strip_prefix("dim") {
142 format!("°{rest}")
143 } else if let Some(rest) = suffix.strip_prefix("aug") {
144 format!("+{rest}")
145 } else if let Some(rest) = suffix.strip_prefix("mMaj") {
146 format!("(maj{rest})")
147 } else if suffix.starts_with("maj") {
148 suffix.to_string()
149 } else if let Some(rest) = suffix.strip_prefix('m') {
150 rest.to_string()
151 } else {
152 suffix.to_string()
153 }
154 }
155
156 fn numeral_for(key: &Key, chord: &Chord) -> (String, usize) {
157 let steps = (chord.root.letter as usize + 7 - key.tonic.letter as usize) % 7;
158 let semis = key.tonic.semis_to(chord.root);
159 let mut diff = semis_diff(semis, key.reference()[steps]);
160 if key.mode == Mode::Minor && steps == 6 && diff == 1 {
161 diff = 0; // leading-tone vii° of harmonic minor
162 }
163 let minor_like = chord.is_minor_third();
164 let base = if minor_like {
165 ROMAN[steps].to_lowercase()
166 } else {
167 ROMAN[steps].to_string()
168 };
169 let mut numeral = format!(
170 "{}{}{}",
171 accidental_prefix(diff),
172 base,
173 numeral_suffix(&chord.suffix())
174 );
175 if let Some(b) = chord.bass.filter(|b| b.pc() != chord.root.pc()) {
176 numeral.push('/');
177 numeral.push_str(&b.name());
178 }
179 (numeral, steps)
180 }
181
182 /// Triad numeral of the diatonic chord on scale degree `idx` (`ii`, `V`, `vii°`).
183 fn diatonic_numeral(key: &Key, idx: usize) -> String {
184 let notes = key.notes();
185 let third = notes[idx].semis_to(notes[(idx + 2) % 7]);
186 let fifth = notes[idx].semis_to(notes[(idx + 4) % 7]);
187 match (third, fifth) {
188 (3, 6) => format!("{}°", ROMAN[idx].to_lowercase()),
189 (3, _) => ROMAN[idx].to_lowercase(),
190 _ => ROMAN[idx].to_string(),
191 }
192 }
193
194 fn mode_pcs(tonic: Note, name: &str) -> Vec<i32> {
195 scale::find(name)
196 .map(|d| d.spell(tonic).iter().map(|n| n.pc()).collect())
197 .unwrap_or_default()
198 }
199
200 fn function_name(key: &Key, steps: usize) -> &'static str {
201 match steps {
202 0 => "tonic",
203 1 => "supertonic (predominant)",
204 2 => match key.mode {
205 Mode::Major => "mediant (tonic function)",
206 Mode::Minor => "mediant (relative major, tonic function)",
207 },
208 3 => "subdominant (predominant)",
209 4 => "dominant",
210 5 => match key.mode {
211 Mode::Major => "submediant (tonic function)",
212 Mode::Minor => "submediant (predominant function)",
213 },
214 _ => match key.mode {
215 Mode::Major => "leading-tone (dominant function)",
216 Mode::Minor => "subtonic (dominant function)",
217 },
218 }
219 }
220
221 pub fn analyze(key: &Key, chord: &Chord) -> ChordAnalysis {
222 let (numeral, steps) = numeral_for(key, chord);
223 let done = |kind, role: String| ChordAnalysis {
224 numeral: numeral.clone(),
225 kind,
226 role,
227 };
228 let scale_pcs = key.pcs();
229 let chord_pcs = chord.pcs();
230 let in_scale = |pcs: &[i32], scale: &[i32]| pcs.iter().all(|p| scale.contains(p));
231
232 if in_scale(&chord_pcs, &scale_pcs) {
233 let in_scale_root = scale_pcs.contains(&chord.root.pc());
234 let role = if in_scale_root {
235 function_name(key, steps).to_string()
236 } else {
237 "diatonic".to_string()
238 };
239 return done(Kind::Diatonic, role);
240 }
241 if key.mode == Mode::Minor && matches!(steps, 4 | 6) {
242 let harmonic = mode_pcs(key.tonic, "harmonic minor");
243 if in_scale(&chord_pcs, &harmonic) {
244 return done(
245 Kind::DiatonicHarmonic,
246 format!(
247 "{} (raised leading tone, harmonic minor)",
248 function_name(key, steps)
249 ),
250 );
251 }
252 }
253
254 let rel = key.tonic.semis_to(chord.root);
255 let notes = key.notes();
256 let target_for = |target_pc: i32| -> Option<usize> {
257 notes
258 .iter()
259 .position(|n| n.pc() == target_pc)
260 .filter(|&i| i != 0 && !diatonic_numeral(key, i).ends_with('°'))
261 };
262 let dominant_type = chord.has(2, 4) && (chord.seventh().is_none() || chord.has(6, 10));
263 let suffix = chord.suffix();
264 if dominant_type && let Some(t) = target_for((chord.root.pc() + 5) % 12) {
265 return done(
266 Kind::SecondaryDominant,
267 format!(
268 "secondary dominant (V{suffix}/{})",
269 diatonic_numeral(key, t)
270 ),
271 );
272 }
273 let dim_type = chord.is_minor_third() && chord.has(4, 6);
274 if dim_type && let Some(t) = target_for((chord.root.pc() + 1) % 12) {
275 let kind = if chord.has(6, 9) {
276 "°7"
277 } else if chord.has(6, 10) {
278 "ø7"
279 } else {
280 "°"
281 };
282 return done(
283 Kind::SecondaryLeadingTone,
284 format!(
285 "secondary leading-tone chord (vii{kind}/{})",
286 diatonic_numeral(key, t)
287 ),
288 );
289 }
290 if chord.is_dominant() {
291 let down = (chord.root.pc() + 11) % 12;
292 if down == key.tonic.pc() {
293 return done(
294 Kind::TritoneSub,
295 "tritone substitution of V7 (subV7), resolves down by half step to the tonic"
296 .into(),
297 );
298 }
299 if let Some(t) = target_for(down) {
300 return done(
301 Kind::TritoneSub,
302 format!(
303 "tritone substitution of V7/{} (subV7/{})",
304 diatonic_numeral(key, t),
305 diatonic_numeral(key, t)
306 ),
307 );
308 }
309 }
310 if rel == 1 && chord.has(2, 4) && chord.seventh().is_none() {
311 return done(
312 Kind::Neapolitan,
313 "Neapolitan chord (bII), usually in first inversion before V".into(),
314 );
315 }
316 let parallel: &[(&str, &str)] = match key.mode {
317 Mode::Major => &[
318 ("natural minor", "parallel minor"),
319 ("dorian", "dorian"),
320 ("mixolydian", "mixolydian"),
321 ("lydian", "lydian"),
322 ("phrygian", "phrygian"),
323 ("harmonic minor", "parallel harmonic minor"),
324 ("melodic minor", "parallel melodic minor"),
325 ],
326 Mode::Minor => &[
327 ("major", "parallel major"),
328 ("dorian", "dorian"),
329 ("mixolydian", "mixolydian"),
330 ("lydian", "lydian"),
331 ("phrygian", "phrygian"),
332 ("melodic minor", "melodic minor"),
333 ],
334 };
335 for (scale_name, label) in parallel {
336 if in_scale(&chord_pcs, &mode_pcs(key.tonic, scale_name)) {
337 return done(
338 Kind::Borrowed,
339 format!(
340 "borrowed chord (modal interchange from {} {label})",
341 key.tonic.name()
342 ),
343 );
344 }
345 }
346 done(
347 Kind::Chromatic,
348 "chromatic: outside the key and not a common function".into(),
349 )
350 }
351
352 #[derive(Debug, Clone)]
353 pub struct KeyGuess {
354 pub key: Key,
355 pub score: f64,
356 pub confidence: f64,
357 pub diatonic: usize,
358 }
359
360 const MAJOR_TONICS: [&str; 15] = [
361 "C", "G", "D", "A", "E", "B", "F#", "C#", "F", "Bb", "Eb", "Ab", "Db", "Gb", "Cb",
362 ];
363 const MINOR_TONICS: [&str; 15] = [
364 "A", "E", "B", "F#", "C#", "G#", "D#", "A#", "D", "G", "C", "F", "Bb", "Eb", "Ab",
365 ];
366
367 /// Rank candidate keys for a progression. Scores reward diatonic fit, a tonic at the start
368 /// and end, and V→I motion; borrowed chords and secondary dominants count for less.
369 pub fn detect(chords: &[Chord]) -> Vec<KeyGuess> {
370 if chords.is_empty() {
371 return Vec::new();
372 }
373 let mut guesses = Vec::new();
374 for (tonics, mode) in [(MAJOR_TONICS, Mode::Major), (MINOR_TONICS, Mode::Minor)] {
375 for t in tonics {
376 let key = Key {
377 tonic: Note::parse(t).unwrap(),
378 mode,
379 };
380 let analyses: Vec<ChordAnalysis> = chords.iter().map(|c| analyze(&key, c)).collect();
381 let mut score = 0.0;
382 let mut diatonic = 0;
383 for a in &analyses {
384 score += match a.kind {
385 Kind::Diatonic | Kind::DiatonicHarmonic => {
386 diatonic += 1;
387 1.0
388 }
389 Kind::SecondaryDominant | Kind::SecondaryLeadingTone => 0.5,
390 Kind::TritoneSub | Kind::Neapolitan => 0.4,
391 Kind::Borrowed => 0.3,
392 Kind::Chromatic => 0.0,
393 };
394 }
395 let is_tonic = |c: &Chord| {
396 c.root.pc() == key.tonic.pc()
397 && (c.is_minor_third() == (mode == Mode::Minor))
398 && c.third().is_some()
399 };
400 if is_tonic(&chords[0]) {
401 score += 0.9;
402 }
403 if chords.len() > 1 && is_tonic(&chords[chords.len() - 1]) {
404 score += 0.75;
405 }
406 let has_cadence = chords.windows(2).any(|w| {
407 w[0].root.pc() == (key.tonic.pc() + 7) % 12 && w[0].has(2, 4) && is_tonic(&w[1])
408 });
409 if has_cadence {
410 score += 0.5;
411 }
412 // Prefer the spelling with fewer accidentals when keys are enharmonic.
413 score -= key.signature().unsigned_abs() as f64 * 0.001;
414 let max = chords.len() as f64 + 2.15;
415 guesses.push(KeyGuess {
416 key,
417 score,
418 confidence: (score / max).clamp(0.0, 1.0),
419 diatonic,
420 });
421 }
422 }
423 guesses.sort_by(|a, b| b.score.total_cmp(&a.score));
424 guesses
425 }
426
427 fn parse_roman(s: &str) -> Option<(usize, &str)> {
428 let upper = s.to_ascii_uppercase();
429 // Longest match first so IV isn't read as I + V.
430 for (i, name) in ROMAN.iter().enumerate().rev() {
431 if upper.starts_with(name) {
432 return Some((i, &s[name.len()..]));
433 }
434 }
435 None
436 }
437
438 /// Realize a Roman numeral (`ii7`, `V7`, `Imaj7`, `bVII`, `vii°7`, `V7/V`) in `key`.
439 pub fn realize(key: &Key, numeral: &str) -> Result<Chord> {
440 let numeral = numeral.trim();
441 if let Some((head, target)) = numeral.split_once('/') {
442 let target_chord = realize(key, target)?;
443 let sub_key = Key {
444 tonic: target_chord.root,
445 mode: Mode::Major,
446 };
447 return realize(&sub_key, head);
448 }
449 let body = numeral.trim_start_matches(['#', 'b']);
450 let acc: i32 = numeral[..numeral.len() - body.len()]
451 .chars()
452 .map(|c| if c == '#' { 1 } else { -1 })
453 .sum();
454 let Some((degree, suffix)) = parse_roman(body) else {
455 bail!("not a Roman numeral: `{numeral}` (use e.g. ii7, V7, Imaj7, bVII, vii°7)");
456 };
457 let letters = &body[..body.len() - suffix.len()];
458 let lower = letters.chars().all(|c| c.is_ascii_lowercase());
459 let root = key.degree_note(degree, acc);
460 let quality = if let Some(r) = suffix
461 .strip_prefix(['°', 'º', 'o'])
462 .or_else(|| suffix.strip_prefix("dim"))
463 {
464 format!("dim{r}")
465 } else if let Some(r) = suffix.strip_prefix(['ø', 'Ø']) {
466 format!("m7b5{}", r.strip_prefix('7').unwrap_or(r))
467 } else if let Some(r) = suffix
468 .strip_prefix('+')
469 .or_else(|| suffix.strip_prefix("aug"))
470 {
471 format!("aug{r}")
472 } else if lower {
473 match suffix.strip_prefix("maj") {
474 Some(r) => format!("mMaj{r}"),
475 None => format!("m{suffix}"),
476 }
477 } else {
478 suffix.to_string()
479 };
480 Chord::parse(&format!("{}{quality}", root.name()))
481 }
482
483 /// Split a progression string into chord symbols (whitespace, `|` and `,` separate).
484 pub fn split_progression(s: &str) -> Vec<String> {
485 s.split(|c: char| c.is_whitespace() || matches!(c, '|' | ','))
486 .map(str::trim)
487 .filter(|t| !t.is_empty() && t.chars().any(|c| c.is_alphanumeric()))
488 .map(String::from)
489 .collect()
490 }
491
492 #[cfg(test)]
493 mod tests {
494 use super::*;
495
496 fn chords(s: &str) -> Vec<Chord> {
497 split_progression(s)
498 .iter()
499 .map(|c| Chord::parse(c).unwrap())
500 .collect()
501 }
502
503 fn numerals(key: &str, s: &str) -> Vec<String> {
504 let key = Key::parse(key).unwrap();
505 chords(s).iter().map(|c| analyze(&key, c).numeral).collect()
506 }
507
508 #[test]
509 fn parse_keys() {
510 assert_eq!(Key::parse("Am").unwrap().name(), "A minor");
511 assert_eq!(Key::parse("F# minor").unwrap().name(), "F# minor");
512 assert_eq!(Key::parse("Bb").unwrap().name(), "Bb major");
513 assert_eq!(Key::parse("c major").unwrap().name(), "C major");
514 assert_eq!(Key::parse("Ebmin").unwrap().name(), "Eb minor");
515 assert!(Key::parse("H").is_err());
516 assert!(Key::parse("C lydian").is_err());
517 }
518
519 #[test]
520 fn roman_numerals_in_major() {
521 assert_eq!(numerals("C", "Dm7 G7 Cmaj7"), ["ii7", "V7", "Imaj7"]);
522 assert_eq!(numerals("C", "C Am F G7"), ["I", "vi", "IV", "V7"]);
523 assert_eq!(
524 numerals("C", "Bdim Bm7b5 Bdim7"),
525 ["vii°", "viiø7", "vii°7"]
526 );
527 assert_eq!(numerals("C", "Bb Ab Fm"), ["bVII", "bVI", "iv"]);
528 }
529
530 #[test]
531 fn roman_numerals_in_minor() {
532 assert_eq!(numerals("Am", "Am Dm E7 Am"), ["i", "iv", "V7", "i"]);
533 assert_eq!(numerals("Am", "Am G F E"), ["i", "VII", "VI", "V"]);
534 assert_eq!(numerals("Am", "G#dim7"), ["vii°7"]);
535 }
536
537 #[test]
538 fn secondary_dominants_and_borrowed() {
539 let key = Key::parse("C").unwrap();
540 let a = analyze(&key, &Chord::parse("A7").unwrap());
541 assert_eq!(a.kind, Kind::SecondaryDominant);
542 assert!(a.role.contains("V7/ii"), "{}", a.role);
543 let a = analyze(&key, &Chord::parse("D7").unwrap());
544 assert!(a.role.contains("V7/V"), "{}", a.role);
545 let a = analyze(&key, &Chord::parse("F#dim7").unwrap());
546 assert_eq!(a.kind, Kind::SecondaryLeadingTone);
547 assert!(a.role.contains("vii°7/V"), "{}", a.role);
548 let a = analyze(&key, &Chord::parse("Fm").unwrap());
549 assert_eq!(a.kind, Kind::Borrowed);
550 assert!(a.role.contains("parallel minor"));
551 assert_eq!(
552 analyze(&key, &Chord::parse("Db7").unwrap()).kind,
553 Kind::TritoneSub
554 );
555 assert_eq!(
556 analyze(&key, &Chord::parse("Db").unwrap()).kind,
557 Kind::Neapolitan
558 );
559 assert_eq!(
560 analyze(&key, &Chord::parse("F#maj7").unwrap()).kind,
561 Kind::Chromatic
562 );
563 }
564
565 #[test]
566 fn detects_keys() {
567 let top = |s: &str| detect(&chords(s))[0].key.name();
568 assert_eq!(top("Dm7 G7 Cmaj7"), "C major");
569 assert_eq!(top("C F G7 C"), "C major");
570 assert_eq!(top("Am Dm E7 Am"), "A minor");
571 assert_eq!(top("F#m7 B7 Emaj7"), "E major");
572 assert_eq!(top("Bbmaj7 Gm7 Cm7 F7"), "Bb major");
573 let guesses = detect(&chords("Dm7 G7 Cmaj7"));
574 assert!(guesses[0].confidence > 0.8);
575 assert!(guesses[0].confidence > guesses[1].confidence);
576 assert!(detect(&[]).is_empty());
577 }
578
579 #[test]
580 fn realizes_ii_v_i_in_all_keys() {
581 let expected = [
582 ("C", "Dm7 G7 Cmaj7"),
583 ("G", "Am7 D7 Gmaj7"),
584 ("D", "Em7 A7 Dmaj7"),
585 ("A", "Bm7 E7 Amaj7"),
586 ("E", "F#m7 B7 Emaj7"),
587 ("B", "C#m7 F#7 Bmaj7"),
588 ("F#", "G#m7 C#7 F#maj7"),
589 ("C#", "D#m7 G#7 C#maj7"),
590 ("F", "Gm7 C7 Fmaj7"),
591 ("Bb", "Cm7 F7 Bbmaj7"),
592 ("Eb", "Fm7 Bb7 Ebmaj7"),
593 ("Ab", "Bbm7 Eb7 Abmaj7"),
594 ("Db", "Ebm7 Ab7 Dbmaj7"),
595 ("Gb", "Abm7 Db7 Gbmaj7"),
596 ("Cb", "Dbm7 Gb7 Cbmaj7"),
597 ];
598 for (key, want) in expected {
599 let k = Key::parse(key).unwrap();
600 let got: Vec<String> = ["ii7", "V7", "Imaj7"]
601 .iter()
602 .map(|n| realize(&k, n).unwrap().name())
603 .collect();
604 assert_eq!(got.join(" "), want, "key {key}");
605 }
606 }
607
608 #[test]
609 fn realizes_borrowed_secondary_and_minor() {
610 let c = Key::parse("C").unwrap();
611 let name = |k: &Key, n: &str| realize(k, n).unwrap().name();
612 assert_eq!(name(&c, "bVII"), "Bb");
613 assert_eq!(name(&c, "V7/V"), "D7");
614 assert_eq!(name(&c, "vii°7/ii"), "C#dim7");
615 assert_eq!(name(&c, "viiø7"), "Bm7b5");
616 assert_eq!(name(&c, "IVmaj7"), "Fmaj7");
617 let am = Key::parse("Am").unwrap();
618 assert_eq!(name(&am, "i7"), "Am7");
619 assert_eq!(name(&am, "iv"), "Dm");
620 assert_eq!(name(&am, "VI"), "F");
621 assert_eq!(name(&am, "V7"), "E7");
622 assert!(realize(&c, "IX").is_err());
623 }
624
625 #[test]
626 fn splits_progressions() {
627 assert_eq!(
628 split_progression("| Dm7 | G7, Cmaj7 - |"),
629 ["Dm7", "G7", "Cmaj7"]
630 );
631 }
632 }