1const NAMES: [&str; 12] = [
7 "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
8];
9
10pub fn name(note: u8) -> String {
11 let octave = (note / 12) as i8 - 1;
12 format!("{}{octave}", NAMES[(note % 12) as usize])
13}
14
15pub fn parse(s: &str) -> Result<u8, String> {
17 let t = s.trim();
18 if t.chars().next().is_some_and(|c| c.is_ascii_digit()) {
19 return t
20 .parse::<u8>()
21 .ok()
22 .filter(|&n| n <= 127)
23 .ok_or_else(|| format!("{s:?} is not a MIDI note (0-127)"));
24 }
25 let mut chars = t.chars();
26 let semitone = match chars.next().map(|c| c.to_ascii_uppercase()) {
27 Some('C') => 0i32,
28 Some('D') => 2,
29 Some('E') => 4,
30 Some('F') => 5,
31 Some('G') => 7,
32 Some('A') => 9,
33 Some('B') => 11,
34 _ => return Err(format!("{s:?} is not a note name or a number")),
35 };
36 let rest = chars.as_str();
37 let (accidental, octave) = match rest.chars().next() {
38 Some('#') => (1, &rest[1..]),
39 Some('b') => (-1, &rest[1..]),
40 _ => (0, rest),
41 };
42 let octave: i32 = octave
44 .parse()
45 .ok()
46 .filter(|_| !octave.starts_with('+'))
47 .ok_or_else(|| format!("{s:?} has no octave number"))?;
48 (-1..=9)
51 .contains(&octave)
52 .then(|| (octave + 1) * 12 + semitone + accidental)
53 .and_then(|n| u8::try_from(n).ok())
54 .filter(|&n| n <= 127)
55 .ok_or_else(|| format!("{s:?} is outside MIDI's 0-127"))
56}
57
58#[cfg(test)]
59mod tests {
60 use super::*;
61
62 #[test]
63 fn names_round_trip() {
64 for n in 0..=127u8 {
65 assert_eq!(parse(&name(n)).unwrap(), n);
66 }
67 }
68
69 #[test]
70 fn middle_c_is_c4() {
71 assert_eq!(name(60), "C4");
72 assert_eq!(parse("C4").unwrap(), 60);
73 assert_eq!(name(0), "C-1");
74 }
75
76 #[test]
77 fn accidentals_and_numbers() {
78 assert_eq!(parse("F#3").unwrap(), 54);
79 assert_eq!(parse("Bb2").unwrap(), 46);
80 assert_eq!(parse("c4").unwrap(), 60);
81 assert_eq!(parse("60").unwrap(), 60);
82 }
83
84 #[test]
87 fn an_octave_outside_the_keyboard_is_refused_rather_than_wrapped() {
88 for bad in ["C357913941", "C2147483647", "C10", "Cb-1"] {
89 let err = parse(bad).unwrap_err();
90 assert!(err.contains("outside MIDI's 0-127"), "{bad}: {err}");
91 }
92 assert!(parse("C+4").unwrap_err().contains("octave"));
93 }
94
95 #[test]
96 fn nonsense_is_refused() {
97 for bad in ["128", "H4", "C", "C99", ""] {
98 assert!(parse(bad).is_err(), "{bad:?}");
99 }
100 }
101}