use super::notation::{ChordSymbol, KeySignature};
use super::pitch::Pitch;
const TEMPLATES: &[(&[u8], &str)] = &[
(&[0, 4, 7], "major"),
(&[0, 3, 7], "minor"),
(&[0, 4, 7, 10], "dominant"),
(&[0, 4, 7, 11], "major-seventh"),
(&[0, 3, 7, 10], "minor-seventh"),
(&[0, 3, 6], "diminished"),
(&[0, 3, 6, 9], "diminished-seventh"),
(&[0, 3, 6, 10], "half-diminished"),
(&[0, 4, 8], "augmented"),
(&[0, 2, 7], "suspended-second"),
(&[0, 5, 7], "suspended-fourth"),
(&[0, 4, 7, 9], "major-sixth"),
(&[0, 3, 7, 9], "minor-sixth"),
(&[0, 7], "power"),
(&[0, 2, 4, 7], "major-add9"),
(&[0, 2, 3, 7], "minor-add9"),
(&[0, 3, 7, 11], "minor-major-seventh"),
(&[0, 4, 6, 10], "dominant-flat-five"),
(&[0, 4, 8, 10], "dominant-sharp-five"),
(&[0, 2, 4, 7, 10], "dominant-ninth"),
(&[0, 2, 4, 7, 11], "major-ninth"),
(&[0, 2, 3, 7, 10], "minor-ninth"),
];
pub fn detect_chord(pitches: &[Pitch]) -> Option<ChordSymbol> {
if pitches.len() < 2 {
return None;
}
let mut pcs: Vec<(u8, &Pitch)> = Vec::new();
for p in pitches {
let pc = (p.to_midi().rem_euclid(12)) as u8;
if !pcs.iter().any(|(c, _)| *c == pc) {
pcs.push((pc, p));
}
}
let bass_pitch = pitches.iter().min_by_key(|p| p.to_midi())?;
let bass_pc = (bass_pitch.to_midi().rem_euclid(12)) as u8;
for &(root_pc, root_pitch) in &pcs {
let mut intervals: Vec<u8> = pcs.iter().map(|(pc, _)| (pc + 12 - root_pc) % 12).collect();
intervals.sort_unstable();
for &(template, kind) in TEMPLATES {
if intervals.as_slice() == template {
let root = pitch_name(root_pitch);
let bass = if bass_pc != root_pc {
Some(pitch_name(bass_pitch))
} else {
None
};
return Some(ChordSymbol {
root,
kind: kind.to_string(),
bass,
placement: None,
extender: false,
harmonic_degree: None,
harmony_function: None,
harmony_type: None,
chord_ref: None,
range_end: None,
degrees: Vec::new(),
});
}
}
}
None
}
fn root_to_pc(root: &str) -> Option<u8> {
let mut chars = root.chars();
let base = match chars.next()? {
'C' => 0u8,
'D' => 2,
'E' => 4,
'F' => 5,
'G' => 7,
'A' => 9,
'B' => 11,
_ => return None,
};
let accidental: String = chars.collect();
let offset = match accidental.as_str() {
"" => 0,
"#" => 1,
"##" => 2,
"b" => -1,
"bb" => -2,
_ => return None,
};
Some((base as i16 + offset).rem_euclid(12) as u8)
}
fn pitch_name(pitch: &Pitch) -> String {
let accidental = match pitch.alter {
-2 => "bb",
-1 => "b",
1 => "#",
2 => "##",
_ => "",
};
format!("{}{}", pitch.step.to_char(), accidental)
}
pub fn roman_numeral(chord: &ChordSymbol, key: &KeySignature) -> Option<String> {
const NUMERALS: &[&str] = &["I", "II", "III", "IV", "V", "VI", "VII"];
let (key_step, key_alter) = key.tonic();
let key_root_pc = {
let base: u8 = match key_step {
crate::Step::C => 0,
crate::Step::D => 2,
crate::Step::E => 4,
crate::Step::F => 5,
crate::Step::G => 7,
crate::Step::A => 9,
crate::Step::B => 11,
};
((base as i8 + key_alter).rem_euclid(12)) as u8
};
let scale_intervals: &[u8] = if key.mode == "minor" {
&[0, 2, 3, 5, 7, 8, 10] } else {
&[0, 2, 4, 5, 7, 9, 11] };
let pc_to_degree = |pc: u8| -> Option<usize> {
let interval = ((pc as i16 - key_root_pc as i16).rem_euclid(12)) as u8;
scale_intervals.iter().position(|&i| i == interval)
};
let chord_pc = root_to_pc(&chord.root)?;
let raised_leading_tone = |pc: u8| {
key.mode == "minor" && (i16::from(pc) - i16::from(key_root_pc)).rem_euclid(12) == 11
};
let degree = pc_to_degree(chord_pc).or_else(|| {
(raised_leading_tone(chord_pc)
&& matches!(
chord.kind.as_str(),
"diminished" | "diminished-seventh" | "half-diminished"
))
.then_some(6)
})?;
let numeral = NUMERALS[degree];
let (upper, suffix) = match chord.kind.as_str() {
"major" => (true, ""),
"dominant" => (true, "7"),
"major-seventh" => (true, "maj7"),
"major-sixth" => (true, "6"),
"power" => (true, "5"),
"major-add9" => (true, "add9"),
"minor-add9" => (false, "add9"),
"minor-major-seventh" => (false, "maj7"),
"dominant-flat-five" => (true, "7b5"),
"dominant-sharp-five" => (true, "7#5"),
"dominant-ninth" => (true, "9"),
"major-ninth" => (true, "maj9"),
"minor-ninth" => (false, "9"),
"augmented" => (true, "+"),
k if k.starts_with("suspended") => (true, ""),
"minor" => (false, ""),
"minor-seventh" => (false, "7"),
"minor-sixth" => (false, "6"),
"diminished" => (false, "o"),
"diminished-seventh" => (false, "o7"),
"half-diminished" => (false, "\u{00f8}7"),
_ => (true, ""),
};
let rn = if upper {
format!("{}{}", numeral, suffix)
} else {
format!("{}{}", numeral.to_lowercase(), suffix)
};
let slash = if let Some(bass) = &chord.bass {
let bass_pc = root_to_pc(bass)?;
let bass_degree = pc_to_degree(bass_pc)?;
let bass_numeral = NUMERALS[bass_degree];
format!("/{}", bass_numeral)
} else {
String::new()
};
Some(format!("{}{}", rn, slash))
}
pub fn roman_numeral_figured(chord: &ChordSymbol, key: &KeySignature) -> Option<String> {
let mut root_chord = chord.clone();
root_chord.bass = None;
let root_roman = roman_numeral(&root_chord, key)?;
let root_roman = if chord.kind == "major-seventh" {
root_roman.replacen("maj7", "7", 1)
} else {
root_roman
};
let Some(bass) = chord.bass.as_deref() else {
return Some(root_roman);
};
let Some(root_pc) = root_to_pc(&chord.root) else {
return roman_numeral(chord, key);
};
let Some(bass_pc) = root_to_pc(bass) else {
return roman_numeral(chord, key);
};
let interval = (i16::from(bass_pc) - i16::from(root_pc)).rem_euclid(12);
let seventh = matches!(
chord.kind.as_str(),
"dominant"
| "major-seventh"
| "minor-seventh"
| "diminished-seventh"
| "half-diminished"
| "minor-major-seventh"
);
let figure = if seventh {
match interval {
3 | 4 => "65",
6..=8 => "43",
9..=11 => "2",
_ => return roman_numeral(chord, key),
}
} else if matches!(
chord.kind.as_str(),
"major" | "minor" | "diminished" | "augmented"
) {
match interval {
3 | 4 => "6",
6..=8 => "64",
_ => return roman_numeral(chord, key),
}
} else {
return roman_numeral(chord, key);
};
let stem = if seventh {
root_roman.strip_suffix('7').unwrap_or(&root_roman)
} else {
&root_roman
};
Some(format!("{stem}{figure}"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Pitch, Step};
fn p(step: Step, octave: i8) -> Pitch {
Pitch::new(step, octave)
}
fn pa(step: Step, octave: i8, alter: i8) -> Pitch {
Pitch::with_alter(step, octave, alter)
}
#[test]
fn detect_chord_c_major_root_pos() {
let pitches = [p(Step::C, 4), p(Step::E, 4), p(Step::G, 4)];
let cs = detect_chord(&pitches).unwrap();
assert_eq!(cs.root, "C");
assert_eq!(cs.kind, "major");
assert_eq!(cs.bass, None);
}
#[test]
fn detect_chord_g_dominant_seventh() {
let pitches = [p(Step::G, 4), p(Step::B, 4), p(Step::D, 4), p(Step::F, 4)];
let cs = detect_chord(&pitches).unwrap();
assert_eq!(cs.root, "G");
assert_eq!(cs.kind, "dominant");
}
#[test]
fn detect_chord_d_minor() {
let pitches = [p(Step::D, 4), p(Step::F, 4), p(Step::A, 4)];
let cs = detect_chord(&pitches).unwrap();
assert_eq!(cs.root, "D");
assert_eq!(cs.kind, "minor");
}
#[test]
fn detect_chord_first_inversion() {
let pitches = [p(Step::E, 4), p(Step::G, 4), p(Step::C, 5)];
let cs = detect_chord(&pitches).unwrap();
assert_eq!(cs.root, "C");
assert_eq!(cs.kind, "major");
assert_eq!(cs.bass, Some("E".to_string()));
}
#[test]
fn detect_chord_slash_chord() {
let pitches = [p(Step::G, 3), p(Step::C, 4), p(Step::E, 4), p(Step::G, 4)];
let cs = detect_chord(&pitches).unwrap();
assert_eq!(cs.root, "C");
assert_eq!(cs.kind, "major");
assert_eq!(cs.bass, Some("G".to_string()));
}
#[test]
fn detect_chord_too_few_notes() {
let pitches = [p(Step::C, 4)];
assert!(detect_chord(&pitches).is_none());
}
#[test]
fn detect_chord_no_template_match() {
let pitches = [p(Step::C, 4), pa(Step::C, 4, 1)];
assert!(detect_chord(&pitches).is_none());
}
#[test]
fn detect_chord_diminished() {
let pitches = [p(Step::B, 3), p(Step::D, 4), p(Step::F, 4)];
let cs = detect_chord(&pitches).unwrap();
assert_eq!(cs.root, "B");
assert_eq!(cs.kind, "diminished");
}
#[test]
fn detect_chord_flat_root() {
let pitches = [pa(Step::B, 3, -1), p(Step::D, 4), p(Step::F, 4)];
let cs = detect_chord(&pitches).unwrap();
assert_eq!(cs.root, "Bb");
assert_eq!(cs.kind, "major");
}
#[test]
fn detect_chord_preserves_double_accidentals_in_root_and_bass() {
let pitches = [pa(Step::C, 4, 2), pa(Step::E, 4, 2), pa(Step::G, 4, 2)];
let chord = detect_chord(&pitches).expect("double-sharp chord should be detected");
assert_eq!(chord.root, "C##");
assert_eq!(chord.bass, None);
assert_eq!(root_to_pc("C##"), Some(2));
assert_eq!(root_to_pc("Abb"), Some(7));
}
#[test]
fn detect_chord_common_extended_qualities() {
let cases = vec![
(
vec![p(Step::C, 4), p(Step::D, 4), p(Step::E, 4), p(Step::G, 4)],
"major-add9",
),
(
vec![
p(Step::G, 3),
p(Step::B, 3),
p(Step::D, 4),
p(Step::F, 4),
p(Step::A, 4),
],
"dominant-ninth",
),
(vec![p(Step::C, 4), p(Step::G, 4)], "power"),
];
for (pitches, expected_kind) in cases {
let chord = detect_chord(&pitches).expect("extended chord should be detected");
assert_eq!(chord.kind, expected_kind);
}
}
#[test]
fn extended_chord_qualities_have_stable_display_and_roman_suffixes() {
let chord = detect_chord(&[
p(Step::G, 3),
p(Step::B, 3),
p(Step::D, 4),
p(Step::F, 4),
p(Step::A, 4),
])
.expect("dominant ninth should be detected");
assert_eq!(chord.display_text(), "G9");
assert_eq!(
roman_numeral(&chord, &c_major_key()),
Some("V9".to_string())
);
}
fn c_major_key() -> KeySignature {
KeySignature {
fifths: 0,
mode: "major".to_string(),
}
}
#[test]
fn harmonic_minor_leading_tone_diminished_inversions() {
let a_minor = KeySignature {
fifths: 0,
mode: "minor".to_string(),
};
let root = detect_chord(&[pa(Step::G, 4, 1), p(Step::B, 4), p(Step::D, 5)])
.expect("G-sharp diminished triad");
assert_eq!(
roman_numeral_figured(&root, &a_minor).as_deref(),
Some("viio")
);
let first_inversion = detect_chord(&[p(Step::B, 3), p(Step::D, 4), pa(Step::G, 4, 1)])
.expect("first inversion of G-sharp diminished");
assert_eq!(
roman_numeral_figured(&first_inversion, &a_minor).as_deref(),
Some("viio6")
);
let seventh = detect_chord(&[
pa(Step::G, 4, 1),
p(Step::B, 4),
p(Step::D, 5),
p(Step::F, 5),
])
.expect("G-sharp diminished seventh");
assert_eq!(
roman_numeral_figured(&seventh, &a_minor).as_deref(),
Some("viio7")
);
let unrelated_chromatic = detect_chord(&[pa(Step::F, 4, 1), p(Step::A, 4), p(Step::C, 5)])
.expect("F-sharp diminished triad");
assert_eq!(roman_numeral_figured(&unrelated_chromatic, &a_minor), None);
let chromatic_major =
detect_chord(&[pa(Step::G, 4, 1), pa(Step::B, 4, 1), pa(Step::D, 5, 1)])
.expect("G-sharp major triad");
assert_eq!(roman_numeral_figured(&chromatic_major, &a_minor), None);
}
#[test]
fn roman_numeral_i_major() {
let chord = ChordSymbol {
root: "C".to_string(),
kind: "major".to_string(),
bass: None,
placement: None,
extender: false,
harmonic_degree: None,
harmony_function: None,
harmony_type: None,
chord_ref: None,
range_end: None,
degrees: Vec::new(),
};
assert_eq!(roman_numeral(&chord, &c_major_key()), Some("I".to_string()));
}
#[test]
fn roman_numeral_v7() {
let chord = ChordSymbol {
root: "G".to_string(),
kind: "dominant".to_string(),
bass: None,
placement: None,
extender: false,
harmonic_degree: None,
harmony_function: None,
harmony_type: None,
chord_ref: None,
range_end: None,
degrees: Vec::new(),
};
assert_eq!(
roman_numeral(&chord, &c_major_key()),
Some("V7".to_string())
);
}
#[test]
fn roman_numeral_ii_minor() {
let chord = ChordSymbol {
root: "D".to_string(),
kind: "minor".to_string(),
bass: None,
placement: None,
extender: false,
harmonic_degree: None,
harmony_function: None,
harmony_type: None,
chord_ref: None,
range_end: None,
degrees: Vec::new(),
};
assert_eq!(
roman_numeral(&chord, &c_major_key()),
Some("ii".to_string())
);
}
#[test]
fn roman_numeral_vii_diminished() {
let chord = ChordSymbol {
root: "B".to_string(),
kind: "diminished".to_string(),
bass: None,
placement: None,
extender: false,
harmonic_degree: None,
harmony_function: None,
harmony_type: None,
chord_ref: None,
range_end: None,
degrees: Vec::new(),
};
assert_eq!(
roman_numeral(&chord, &c_major_key()),
Some("viio".to_string())
);
}
#[test]
fn roman_numeral_out_of_key_returns_none() {
let chord = ChordSymbol {
root: "F#".to_string(),
kind: "major".to_string(),
bass: None,
placement: None,
extender: false,
harmonic_degree: None,
harmony_function: None,
harmony_type: None,
chord_ref: None,
range_end: None,
degrees: Vec::new(),
};
assert!(roman_numeral(&chord, &c_major_key()).is_none());
}
#[test]
fn roman_numeral_slash_chord() {
let chord = ChordSymbol {
root: "C".to_string(),
kind: "major".to_string(),
bass: Some("G".to_string()),
placement: None,
extender: false,
harmonic_degree: None,
harmony_function: None,
harmony_type: None,
chord_ref: None,
range_end: None,
degrees: Vec::new(),
};
assert_eq!(
roman_numeral(&chord, &c_major_key()),
Some("I/V".to_string())
);
}
#[test]
fn figured_roman_numerals_cover_triad_and_seventh_inversions() {
let mut chord = ChordSymbol {
root: "C".to_string(),
kind: "major".to_string(),
bass: Some("E".to_string()),
placement: None,
extender: false,
harmonic_degree: None,
harmony_function: None,
harmony_type: None,
chord_ref: None,
range_end: None,
degrees: Vec::new(),
};
assert_eq!(
roman_numeral_figured(&chord, &c_major_key()).as_deref(),
Some("I6")
);
chord.bass = Some("G".to_string());
assert_eq!(
roman_numeral_figured(&chord, &c_major_key()).as_deref(),
Some("I64")
);
chord.root = "G".to_string();
chord.kind = "dominant".to_string();
for (bass, expected) in [("B", "V65"), ("D", "V43"), ("F", "V2")] {
chord.bass = Some(bass.to_string());
assert_eq!(
roman_numeral_figured(&chord, &c_major_key()).as_deref(),
Some(expected)
);
}
chord.root = "F".to_string();
chord.kind = "major-seventh".to_string();
chord.bass = Some("E".to_string());
assert_eq!(
roman_numeral_figured(&chord, &c_major_key()).as_deref(),
Some("IV2")
);
chord.root = "G".to_string();
chord.kind = "dominant".to_string();
chord.bass = Some("B".to_string());
let c_minor = KeySignature {
fifths: -3,
mode: "minor".to_string(),
};
assert_eq!(roman_numeral(&chord, &c_minor), None);
assert_eq!(
roman_numeral_figured(&chord, &c_minor).as_deref(),
Some("V65")
);
}
}