Skip to main content

nord_format/
note.rs

1//! MIDI note names, the spelling the formats' key ranges are read and written in.
2//!
3//! Middle C (60) is spelled C4, the sample editor's own labelling.
4//! Inferred from specimens; not confirmed on hardware.
5
6const 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
15/// A note as an edit value: a name (`C4`, `F#3`, `Bb2`) or a plain number (`60`).
16pub 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    // `parse` would also take `+4`, and an octave has one spelling.
43    let octave: i32 = octave
44        .parse()
45        .ok()
46        .filter(|_| !octave.starts_with('+'))
47        .ok_or_else(|| format!("{s:?} has no octave number"))?;
48    // ⚠️ C-1 is note 0 and G9 is 127. A wider octave overflows the sum in a release
49    // build, where it wraps into a number that passes the range check below.
50    (-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    /// ⚠️ The octave reaches the note number through a multiplication, so an
85    /// unbounded one wraps in a release build: `C357913941` came back as note 8.
86    #[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}