| 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(¬e)?; |
| 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 | } |