const PITCH_MAP: [(char, i32); 7] = [
('C', 0),
('D', 2),
('E', 4),
('F', 5),
('G', 7),
('A', 9),
('B', 11),
];
pub(crate) fn pitch_to_semitone(ch: char) -> Option<i32> {
let upper = ch.to_ascii_uppercase();
PITCH_MAP
.iter()
.find(|&&(c, _)| c == upper)
.map(|&(_, s)| s)
}
pub(crate) fn acc_value(ch: char) -> Option<i32> {
match ch {
'#' | '\u{266F}' => Some(1), 'b' | '!' | '\u{266D}' => Some(-1), '\u{1D12A}' => Some(2), '\u{1D12B}' => Some(-2), '\u{266E}' | 'n' => Some(0), _ => None,
}
}
fn parse_accidentals(chars: &[char], pos: &mut usize) -> i32 {
let mut offset = 0i32;
while *pos < chars.len() {
if let Some(v) = acc_value(chars[*pos]) {
offset += v;
*pos += 1;
} else {
break;
}
}
offset
}
const MODE_ABBREVS: [(&str, usize); 7] = [
("ion", 0),
("dor", 1),
("phr", 2),
("lyd", 3),
("mix", 4),
("aeo", 5),
("loc", 6),
];
fn mode_to_major_tonic(mode: &str, tonic: char) -> Option<&'static str> {
let table: &[(&str, [(char, &str); 7])] = &[
(
"dor",
[
('C', "B\u{266D}"),
('D', "C"),
('E', "D"),
('F', "E\u{266D}"),
('G', "F"),
('A', "G"),
('B', "A"),
],
),
(
"phr",
[
('C', "A\u{266D}"),
('D', "B\u{266D}"),
('E', "C"),
('F', "D\u{266D}"),
('G', "E\u{266D}"),
('A', "F"),
('B', "G"),
],
),
(
"lyd",
[
('C', "G"),
('D', "A"),
('E', "B"),
('F', "C"),
('G', "D"),
('A', "E"),
('B', "F\u{266F}"),
],
),
(
"mix",
[
('C', "F"),
('D', "G"),
('E', "A"),
('F', "B\u{266D}"),
('G', "C"),
('A', "D"),
('B', "E"),
],
),
(
"aeo",
[
('C', "E\u{266D}"),
('D', "F"),
('E', "G"),
('F', "A\u{266D}"),
('G', "B\u{266D}"),
('A', "C"),
('B', "D"),
],
),
(
"loc",
[
('C', "D\u{266D}"),
('D', "E\u{266D}"),
('E', "F"),
('F', "G\u{266D}"),
('G', "A\u{266D}"),
('A', "B\u{266D}"),
('B', "C"),
],
),
];
for &(m, ref mapping) in table {
if m == mode {
let upper = tonic.to_ascii_uppercase();
for &(letter, result) in mapping {
if letter == upper {
return Some(result);
}
}
}
}
None
}
fn parse_key(key: &str) -> Result<(char, i32, String), String> {
let chars: Vec<char> = key.chars().collect();
if chars.is_empty() {
return Err(format!("Improper key format: {}", key));
}
let tonic = chars[0];
if pitch_to_semitone(tonic).is_none() {
return Err(format!("Improper key format: {}", key));
}
let mut pos = 1;
let acc_offset = parse_accidentals(&chars, &mut pos);
if pos >= chars.len() || chars[pos] != ':' {
return Err(format!("Improper key format: {}", key));
}
pos += 1;
let rest: String = chars[pos..].iter().collect();
let rest_lower = rest.to_lowercase();
if rest_lower == "maj" || rest_lower == "major" {
return Ok((tonic, acc_offset, "maj".to_string()));
}
if rest_lower == "min" || rest_lower == "minor" {
return Ok((tonic, acc_offset, "min".to_string()));
}
let abbrev = if rest_lower.len() >= 3 {
&rest_lower[..3]
} else {
&rest_lower
};
for &(mode_name, _) in &MODE_ABBREVS {
if abbrev == mode_name {
let full_modes = [
("ion", "ionian"),
("dor", "dorian"),
("phr", "phrygian"),
("lyd", "lydian"),
("mix", "mixolydian"),
("aeo", "aeolian"),
("loc", "locrian"),
];
let valid = full_modes.iter().any(|&(short, long)| {
short == abbrev
&& (rest_lower == short || rest_lower == long || long.starts_with(&rest_lower))
});
if valid {
return Ok((tonic, acc_offset, abbrev.to_string()));
}
}
}
Err(format!("Improper key format: {}", key))
}
fn note_to_degree(note: &str) -> Result<usize, String> {
let chars: Vec<char> = note.chars().collect();
if chars.is_empty() {
return Err(format!("Improper note format: {}", note));
}
let base =
pitch_to_semitone(chars[0]).ok_or_else(|| format!("Improper note format: {}", note))?;
let mut pos = 1;
let acc = parse_accidentals(&chars, &mut pos);
Ok(((base + acc) % 12 + 12) as usize % 12)
}
fn simplify_note(note: &str, additional_acc: i32, unicode: bool) -> Result<String, String> {
let chars: Vec<char> = note.chars().collect();
if chars.is_empty() {
return Err(format!("Improper note format: {}", note));
}
let letter = chars[0].to_ascii_uppercase();
if pitch_to_semitone(letter).is_none() {
return Err(format!("Improper note format: {}", note));
}
let mut pos = 1;
let acc = parse_accidentals(&chars, &mut pos);
let total = acc + additional_acc;
let mut result = String::new();
result.push(letter);
if total >= 0 {
let total = total as usize;
if unicode {
for _ in 0..(total / 2) {
result.push('\u{1D12A}'); }
for _ in 0..(total % 2) {
result.push('\u{266F}'); }
} else {
for _ in 0..total {
result.push('#');
}
}
} else {
let abs_total = (-total) as usize;
if unicode {
for _ in 0..(abs_total / 2) {
result.push('\u{1D12B}'); }
for _ in 0..(abs_total % 2) {
result.push('\u{266D}'); }
} else {
for _ in 0..abs_total {
result.push('b');
}
}
}
Ok(result)
}
fn mode_to_key(key: &str, unicode: bool) -> Result<String, String> {
let (tonic, acc_offset, mode) = parse_key(key)?;
if mode == "maj" || mode == "min" {
let tonic_str = format!("{}", tonic.to_ascii_uppercase());
let simplified = simplify_note_with_offset(&tonic_str, acc_offset, unicode)?;
let scale = if mode == "maj" { "maj" } else { "min" };
return Ok(format!("{}:{}", simplified, scale));
}
let major_tonic =
mode_to_major_tonic(&mode, tonic).ok_or_else(|| format!("Improper key format: {}", key))?;
let simplified = simplify_note(major_tonic, acc_offset, unicode)?;
Ok(format!("{}:maj", simplified))
}
fn simplify_note_with_offset(letter: &str, offset: i32, unicode: bool) -> Result<String, String> {
let mut note = letter.to_string();
if offset > 0 {
for _ in 0..offset {
note.push('\u{266F}');
}
} else if offset < 0 {
for _ in 0..(-offset) {
note.push('\u{266D}');
}
}
simplify_note(¬e, 0, unicode)
}
pub fn key_to_notes(key: &str, unicode: bool) -> Result<Vec<String>, String> {
let (tonic, offset, scale_or_mode) = parse_key(key)?;
if scale_or_mode != "maj" && scale_or_mode != "min" {
let equiv = mode_to_key(key, unicode)?;
return key_to_notes(&equiv, unicode);
}
let tonic_upper = tonic.to_ascii_uppercase();
let base_semitone = pitch_to_semitone(tonic_upper).unwrap();
let multiple = offset.abs() >= 2;
if multiple {
let reduced_offset = offset.signum() * (offset.abs() - 1);
let mut reduced_key = format!("{}", tonic_upper);
if reduced_offset > 0 {
for _ in 0..reduced_offset {
reduced_key.push('\u{266F}');
}
} else if reduced_offset < 0 {
for _ in 0..(-reduced_offset) {
reduced_key.push('\u{266D}');
}
}
reduced_key.push_str(&format!(":{}", scale_or_mode));
let intermediate = key_to_notes(&reduced_key, true)?;
let add_acc = if offset > 0 { 1 } else { -1 };
let mut notes: Vec<String> = intermediate
.iter()
.map(|n| simplify_note(n, add_acc, true).unwrap_or_else(|_| n.clone()))
.collect();
let degrees: Vec<usize> = notes
.iter()
.map(|n| note_to_degree(n).unwrap_or(0))
.collect();
if let Some(c_pos) = degrees.iter().position(|&d| d == 0) {
notes.rotate_left(c_pos);
}
if !unicode {
notes = notes.iter().map(|n| to_ascii(n)).collect();
}
return Ok(notes);
}
let major = scale_or_mode == "maj";
let tonic_number = if major {
(((base_semitone + offset) * 7) % 12 + 12) % 12
} else {
(((base_semitone + offset) * 7 + 9) % 12 + 12) % 12
};
let use_sharps = offset >= 0 && (offset > 0 || tonic_number < 6);
let mut notes_sharp = [
"C",
"C\u{266F}",
"D",
"D\u{266F}",
"E",
"F",
"F\u{266F}",
"G",
"G\u{266F}",
"A",
"A\u{266F}",
"B",
]
.map(String::from);
let mut notes_flat = [
"C",
"D\u{266D}",
"D",
"E\u{266D}",
"E",
"F",
"G\u{266D}",
"G",
"A\u{266D}",
"A",
"B\u{266D}",
"B",
]
.map(String::from);
let sharp_corrections: [(usize, &str); 7] = [
(5, "E\u{266F}"),
(0, "B\u{266F}"),
(7, "F\u{1D12A}"),
(2, "C\u{1D12A}"),
(9, "G\u{1D12A}"),
(4, "D\u{1D12A}"),
(11, "A\u{1D12A}"),
];
let flat_corrections: [(usize, &str); 6] = [
(11, "C\u{266D}"),
(4, "F\u{266D}"),
(9, "B\u{1D12B}"),
(2, "E\u{1D12B}"),
(7, "A\u{1D12B}"),
(0, "D\u{1D12B}"),
];
let n_sharps = if tonic_number == 0 && tonic_upper == 'B' {
12
} else {
tonic_number
};
let notes = if use_sharps {
if n_sharps >= 6 {
for n in 0..=(n_sharps as i32 - 6) as usize {
if n < sharp_corrections.len() {
let (idx, name) = sharp_corrections[n];
notes_sharp[idx] = name.to_string();
}
}
}
notes_sharp
} else {
let n_flats = (12 - tonic_number) % 12;
if n_flats >= 6 {
for n in 0..=(n_flats as i32 - 6) as usize {
if n < flat_corrections.len() {
let (idx, name) = flat_corrections[n];
notes_flat[idx] = name.to_string();
}
}
}
notes_flat
};
let result: Vec<String> = if unicode {
notes.to_vec()
} else {
notes.iter().map(|n| to_ascii(n)).collect()
};
Ok(result)
}
fn to_ascii(s: &str) -> String {
let mut result = String::new();
for ch in s.chars() {
match ch {
'\u{266F}' => result.push('#'),
'\u{266D}' => result.push('b'),
'\u{1D12A}' => result.push_str("##"),
'\u{1D12B}' => result.push_str("bb"),
'\u{266E}' => result.push('n'),
_ => result.push(ch),
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_key_to_notes_c_major() {
let notes = key_to_notes("C:maj", true).unwrap();
assert_eq!(
notes,
vec![
"C",
"C\u{266F}",
"D",
"D\u{266F}",
"E",
"F",
"F\u{266F}",
"G",
"G\u{266F}",
"A",
"A\u{266F}",
"B"
]
);
}
#[test]
fn test_key_to_notes_a_minor() {
let notes = key_to_notes("A:min", true).unwrap();
assert_eq!(
notes,
vec![
"C",
"C\u{266F}",
"D",
"D\u{266F}",
"E",
"F",
"F\u{266F}",
"G",
"G\u{266F}",
"A",
"A\u{266F}",
"B"
]
);
}
#[test]
fn test_key_to_notes_f_minor() {
let notes = key_to_notes("F:min", true).unwrap();
assert_eq!(
notes,
vec![
"C",
"D\u{266D}",
"D",
"E\u{266D}",
"E",
"F",
"G\u{266D}",
"G",
"A\u{266D}",
"A",
"B\u{266D}",
"B"
]
);
}
#[test]
fn test_key_to_notes_a_sharp_minor() {
let notes = key_to_notes("A#:min", true).unwrap();
assert_eq!(
notes,
vec![
"B\u{266F}",
"C\u{266F}",
"D",
"D\u{266F}",
"E",
"E\u{266F}",
"F\u{266F}",
"G",
"G\u{266F}",
"A",
"A\u{266F}",
"B"
]
);
}
#[test]
fn test_key_to_notes_g_sharp_major() {
let notes = key_to_notes("G#:maj", true).unwrap();
assert_eq!(
notes,
vec![
"B\u{266F}",
"C\u{266F}",
"D",
"D\u{266F}",
"E",
"E\u{266F}",
"F\u{266F}",
"F\u{1D12A}",
"G\u{266F}",
"A",
"A\u{266F}",
"B"
]
);
}
#[test]
fn test_key_to_notes_fb_minor() {
let notes = key_to_notes("Fb:min", true).unwrap();
assert_eq!(
notes,
vec![
"D\u{1D12B}",
"D\u{266D}",
"E\u{1D12B}",
"E\u{266D}",
"F\u{266D}",
"F",
"G\u{266D}",
"A\u{1D12B}",
"A\u{266D}",
"B\u{1D12B}",
"B\u{266D}",
"C\u{266D}"
]
);
}
#[test]
fn test_key_to_notes_g_locrian() {
let notes = key_to_notes("G:loc", true).unwrap();
assert_eq!(
notes,
vec![
"C",
"D\u{266D}",
"D",
"E\u{266D}",
"E",
"F",
"G\u{266D}",
"G",
"A\u{266D}",
"A",
"B\u{266D}",
"B"
]
);
}
#[test]
fn test_key_to_notes_ascii() {
let notes = key_to_notes("C:maj", false).unwrap();
assert_eq!(
notes,
vec![
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"
]
);
}
#[test]
fn test_key_to_notes_g_sharp_major_ascii() {
let notes = key_to_notes("G#:maj", false).unwrap();
assert_eq!(
notes,
vec![
"B#", "C#", "D", "D#", "E", "E#", "F#", "F##", "G#", "A", "A#", "B"
]
);
}
#[test]
fn test_parse_key_invalid() {
assert!(parse_key("X:maj").is_err());
assert!(parse_key("C").is_err());
assert!(parse_key("").is_err());
assert!(parse_key("C:xyz").is_err());
}
#[test]
fn test_key_to_notes_d_dorian() {
let notes = key_to_notes("D:dor", true).unwrap();
let c_maj = key_to_notes("C:maj", true).unwrap();
assert_eq!(notes, c_maj);
}
}