1use std::fmt;
18
19use crate::music::MusicError;
20
21#[must_use]
23pub fn pitch_class(pitch: u8) -> u8 {
24 pitch % 12
25}
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum Spelling {
30 Sharps,
32 Flats,
34}
35
36const SHARPS: [&str; 12] = [
37 "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
38];
39const FLATS: [&str; 12] = [
40 "C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B",
41];
42
43#[must_use]
45pub fn class_name(class: u8, spelling: Spelling) -> &'static str {
46 let names = match spelling {
47 Spelling::Sharps => &SHARPS,
48 Spelling::Flats => &FLATS,
49 };
50 names[usize::from(class % 12)]
51}
52
53#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
55pub enum OctaveLabel {
56 C3,
58 C4,
60 C5,
62}
63
64impl OctaveLabel {
65 pub const ALL: [Self; 3] = [Self::C4, Self::C3, Self::C5];
67
68 #[must_use]
70 pub fn octave(self, pitch: u8) -> i8 {
71 let base = i8::try_from(pitch / 12).unwrap_or(i8::MAX);
73 base - self.shift()
74 }
75
76 fn shift(self) -> i8 {
78 match self {
79 Self::C3 => 2,
80 Self::C4 => 1,
81 Self::C5 => 0,
82 }
83 }
84}
85
86impl fmt::Display for OctaveLabel {
87 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88 f.pad(match self {
89 Self::C3 => "C3",
90 Self::C4 => "C4",
91 Self::C5 => "C5",
92 })
93 }
94}
95
96#[must_use]
98pub fn note_name(pitch: u8, label: OctaveLabel, spelling: Spelling) -> String {
99 format!(
100 "{}{}",
101 class_name(pitch_class(pitch), spelling),
102 label.octave(pitch)
103 )
104}
105
106pub(crate) fn letter_class(letter: char) -> Option<i16> {
108 match letter.to_ascii_uppercase() {
109 'C' => Some(0),
110 'D' => Some(2),
111 'E' => Some(4),
112 'F' => Some(5),
113 'G' => Some(7),
114 'A' => Some(9),
115 'B' => Some(11),
116 _ => None,
117 }
118}
119
120pub(crate) fn read_class(text: &str) -> Option<(i16, &str)> {
124 let mut chars = text.char_indices();
125 let (_, letter) = chars.next()?;
126 let mut class = letter_class(letter)?;
127 let mut rest = &text[letter.len_utf8()..];
128 for _ in 0..2 {
129 let next = rest.chars().next();
130 match next {
131 Some('#' | '♯') => class += 1,
132 Some('b' | '♭') => class -= 1,
133 _ => break,
134 }
135 rest = &rest[next.map_or(0, char::len_utf8)..];
136 }
137 Some((class, rest))
138}
139
140pub fn parse_note(text: &str, label: OctaveLabel) -> Result<u8, MusicError> {
148 let unknown = || MusicError::UnknownNote(text.to_owned());
149 let (class, rest) = read_class(text.trim()).ok_or_else(unknown)?;
150 let octave: i16 = rest.trim().parse().map_err(|_| unknown())?;
151 let pitch = octave
152 .checked_add(i16::from(label.shift()))
153 .and_then(|octave| octave.checked_mul(12))
154 .and_then(|pitch| pitch.checked_add(class))
155 .ok_or_else(unknown)?;
156 u8::try_from(pitch)
157 .ok()
158 .filter(|pitch| *pitch <= 127)
159 .ok_or_else(unknown)
160}
161
162#[derive(Clone, Copy, Debug, PartialEq, Eq)]
164pub struct NoteReading {
165 pub pitch: u8,
167 pub label: OctaveLabel,
169 pub assumed: bool,
171}
172
173pub fn read_note(text: &str, range: Option<[u8; 2]>) -> Result<NoteReading, MusicError> {
183 let readings: Vec<(OctaveLabel, u8)> = OctaveLabel::ALL
184 .into_iter()
185 .filter_map(|label| parse_note(text, label).ok().map(|pitch| (label, pitch)))
186 .collect();
187 let inside: Vec<&(OctaveLabel, u8)> = readings
188 .iter()
189 .filter(|(_, pitch)| range.is_some_and(|[low, high]| (low..=high).contains(pitch)))
190 .collect();
191 let (label, pitch) = match inside.as_slice() {
192 [only] => **only,
193 _ => readings
194 .iter()
195 .find(|(label, _)| *label == OctaveLabel::C4)
196 .or(readings.first())
197 .copied()
198 .ok_or_else(|| MusicError::UnknownNote(text.to_owned()))?,
199 };
200 Ok(NoteReading {
201 pitch,
202 label,
203 assumed: true,
204 })
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn names_the_published_test_vectors() {
213 let vectors = [
214 (21, "A0", "A-1", "A1"),
215 (28, "E1", "E0", "E2"),
216 (36, "C2", "C1", "C3"),
217 (60, "C4", "C3", "C5"),
218 (69, "A4", "A3", "A5"),
219 (127, "G9", "G8", "G10"),
220 ];
221 for (pitch, scientific, c3, c5) in vectors {
222 assert_eq!(
223 note_name(pitch, OctaveLabel::C4, Spelling::Sharps),
224 scientific
225 );
226 assert_eq!(note_name(pitch, OctaveLabel::C3, Spelling::Sharps), c3);
227 assert_eq!(note_name(pitch, OctaveLabel::C5, Spelling::Sharps), c5);
228 }
229 }
230
231 #[test]
232 fn every_pitch_round_trips_in_every_convention() {
233 for label in OctaveLabel::ALL {
234 for spelling in [Spelling::Sharps, Spelling::Flats] {
235 for pitch in 0..=127 {
236 let name = note_name(pitch, label, spelling);
237 assert_eq!(parse_note(&name, label), Ok(pitch), "{name} in {label}");
238 }
239 }
240 }
241 }
242
243 #[test]
244 fn rejects_names_outside_midi() {
245 for text in [
246 "C-2", "H4", "C", "4", "C#x", "G#9", "G#5461", "C-32768", "B32767",
247 ] {
248 assert!(parse_note(text, OctaveLabel::C4).is_err(), "{text}");
249 }
250 assert_eq!(parse_note("Cb4", OctaveLabel::C4), Ok(59));
251 assert_eq!(parse_note("B#3", OctaveLabel::C4), Ok(60));
252 assert_eq!(parse_note("D♭4", OctaveLabel::C4), Ok(61));
253 }
254
255 #[test]
256 fn a_name_with_no_convention_picks_the_reading_in_the_range() {
257 let bass = Some([28, 48]);
258 let c3 = read_note("C3", bass).unwrap();
259 assert_eq!(
260 (c3.pitch, c3.label, c3.assumed),
261 (48, OctaveLabel::C4, true)
262 );
263 let c1 = read_note("C1", bass).unwrap();
264 assert_eq!((c1.pitch, c1.label), (36, OctaveLabel::C3));
265 let lead = read_note("C5", Some([60, 84])).unwrap();
266 assert_eq!((lead.pitch, lead.label), (72, OctaveLabel::C4));
267 let unbound = read_note("A4", None).unwrap();
268 assert_eq!((unbound.pitch, unbound.label), (69, OctaveLabel::C4));
269 let wide = read_note("C4", Some([21, 108])).unwrap();
270 assert_eq!((wide.pitch, wide.label), (60, OctaveLabel::C4));
271 }
272}