development
rs 219 lines 6.46 KB
Raw
1 //! Spelled notes, MIDI numbers and frequencies.
2
3 use anyhow::{Result, bail};
4
5 /// Pitch class of each natural letter, C D E F G A B.
6 pub const NATURAL_PC: [i32; 7] = [0, 2, 4, 5, 7, 9, 11];
7 const LETTERS: [char; 7] = ['C', 'D', 'E', 'F', 'G', 'A', 'B'];
8
9 /// A spelled pitch class: a letter plus an accidental (+1 sharp, -1 flat, ...).
10 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11 pub struct Note {
12 /// 0 = C ... 6 = B.
13 pub letter: u8,
14 pub acc: i8,
15 }
16
17 impl Note {
18 pub fn new(letter: u8, acc: i8) -> Self {
19 Self {
20 letter: letter % 7,
21 acc,
22 }
23 }
24
25 pub fn pc(self) -> i32 {
26 (NATURAL_PC[self.letter as usize] + self.acc as i32).rem_euclid(12)
27 }
28
29 pub fn name(self) -> String {
30 let mut s = LETTERS[self.letter as usize].to_string();
31 match self.acc {
32 0 => {}
33 n if n > 0 => s.push_str(&"#".repeat(n as usize)),
34 n => s.push_str(&"b".repeat((-n) as usize)),
35 }
36 s
37 }
38
39 /// Parse `C`, `F#`, `Bb`, `Ebb`, `C##`, `Fx`, `B♭`. Returns the note and the unparsed rest.
40 pub fn parse_prefix(s: &str) -> Option<(Note, &str)> {
41 let mut chars = s.chars();
42 let first = chars.next()?;
43 let letter = LETTERS
44 .iter()
45 .position(|&l| l == first.to_ascii_uppercase())?;
46 // Lowercase letters are only accepted by the caller when it asks for them.
47 if !first.is_ascii_uppercase() {
48 return None;
49 }
50 let mut acc = 0i8;
51 let mut rest = &s[first.len_utf8()..];
52 while let Some(c) = rest.chars().next() {
53 let delta = match c {
54 '#' | '♯' => 1,
55 'x' | '𝄪' => 2,
56 // `b` is an accidental here; the caller decides whether a trailing `b` after a
57 // root could instead be part of a quality (it never is for Bb/Ebb style roots).
58 'b' | '♭' => -1,
59 _ => break,
60 };
61 acc += delta;
62 rest = &rest[c.len_utf8()..];
63 }
64 Some((Note::new(letter as u8, acc), rest))
65 }
66
67 pub fn parse(s: &str) -> Result<Note> {
68 let s = s.trim();
69 match Note::parse_prefix(s) {
70 Some((n, "")) => Ok(n),
71 _ => bail!("not a note name: `{s}` (use e.g. C, F#, Bb)"),
72 }
73 }
74
75 /// Move up by `steps` letters and `semitones` semitones, spelling the result diatonically.
76 pub fn up(self, steps: usize, semitones: i32) -> Note {
77 let letter = (self.letter as usize + steps) % 7;
78 let target = (self.pc() + semitones).rem_euclid(12);
79 let mut acc = (target - NATURAL_PC[letter]).rem_euclid(12);
80 if acc > 6 {
81 acc -= 12;
82 }
83 Note {
84 letter: letter as u8,
85 acc: acc as i8,
86 }
87 }
88
89 /// Semitones from `self` up to `other`, 0..12.
90 pub fn semis_to(self, other: Note) -> i32 {
91 (other.pc() - self.pc()).rem_euclid(12)
92 }
93 }
94
95 /// Spell a pitch class using the conventional sharp or flat set.
96 pub fn spell_pc(pc: i32, prefer_flats: bool) -> Note {
97 const SHARP: [(u8, i8); 12] = [
98 (0, 0),
99 (0, 1),
100 (1, 0),
101 (1, 1),
102 (2, 0),
103 (3, 0),
104 (3, 1),
105 (4, 0),
106 (4, 1),
107 (5, 0),
108 (5, 1),
109 (6, 0),
110 ];
111 const FLAT: [(u8, i8); 12] = [
112 (0, 0),
113 (1, -1),
114 (1, 0),
115 (2, -1),
116 (2, 0),
117 (3, 0),
118 (4, -1),
119 (4, 0),
120 (5, -1),
121 (5, 0),
122 (6, -1),
123 (6, 0),
124 ];
125 let (l, a) = if prefer_flats { FLAT } else { SHARP }[pc.rem_euclid(12) as usize];
126 Note { letter: l, acc: a }
127 }
128
129 /// A note with an octave in scientific pitch notation (C4 = middle C = MIDI 60).
130 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
131 pub struct Pitch {
132 pub note: Note,
133 pub octave: i32,
134 }
135
136 impl Pitch {
137 pub fn parse(s: &str) -> Result<Pitch> {
138 let s = s.trim();
139 let Some((note, rest)) = Note::parse_prefix(s) else {
140 bail!("not a pitch: `{s}` (use e.g. A4, C#3, Bb2)");
141 };
142 let octave: i32 = rest
143 .parse()
144 .map_err(|_| anyhow::anyhow!("not a pitch: `{s}` (octave number missing, e.g. A4)"))?;
145 Ok(Pitch { note, octave })
146 }
147
148 pub fn midi(self) -> i32 {
149 let base = NATURAL_PC[self.note.letter as usize] + self.note.acc as i32;
150 (self.octave + 1) * 12 + base
151 }
152
153 pub fn name(self) -> String {
154 format!("{}{}", self.note.name(), self.octave)
155 }
156
157 pub fn from_midi(midi: i32, prefer_flats: bool) -> Pitch {
158 let note = spell_pc(midi, prefer_flats);
159 // Octave follows the letter, so B#3 / Cb4 style spellings keep the right octave.
160 let octave = (midi - NATURAL_PC[note.letter as usize] - note.acc as i32).div_euclid(12) - 1;
161 Pitch { note, octave }
162 }
163 }
164
165 pub fn midi_to_hz(midi: f64, a4: f64) -> f64 {
166 a4 * 2f64.powf((midi - 69.0) / 12.0)
167 }
168
169 pub fn hz_to_midi(hz: f64, a4: f64) -> f64 {
170 69.0 + 12.0 * (hz / a4).log2()
171 }
172
173 pub fn cents_between(hz_a: f64, hz_b: f64) -> f64 {
174 1200.0 * (hz_b / hz_a).log2()
175 }
176
177 #[cfg(test)]
178 mod tests {
179 use super::*;
180
181 #[test]
182 fn a4_is_midi_69_and_440() {
183 let p = Pitch::parse("A4").unwrap();
184 assert_eq!(p.midi(), 69);
185 assert!((midi_to_hz(69.0, 440.0) - 440.0).abs() < 1e-9);
186 assert!((hz_to_midi(440.0, 440.0) - 69.0).abs() < 1e-9);
187 assert_eq!(Pitch::parse("C4").unwrap().midi(), 60);
188 assert_eq!(Pitch::parse("C-1").unwrap().midi(), 0);
189 }
190
191 #[test]
192 fn a4_432_shifts_frequencies() {
193 assert!((midi_to_hz(69.0, 432.0) - 432.0).abs() < 1e-9);
194 assert!((midi_to_hz(60.0, 432.0) - 256.87).abs() < 0.01);
195 }
196
197 #[test]
198 fn enharmonic_octaves() {
199 assert_eq!(Pitch::parse("Cb4").unwrap().midi(), 59);
200 assert_eq!(Pitch::parse("B#3").unwrap().midi(), 60);
201 assert_eq!(Pitch::from_midi(61, false).name(), "C#4");
202 assert_eq!(Pitch::from_midi(61, true).name(), "Db4");
203 }
204
205 #[test]
206 fn spelling_up() {
207 let c_sharp = Note::parse("C#").unwrap();
208 // major third above C# is E#, not F.
209 assert_eq!(c_sharp.up(2, 4).name(), "E#");
210 assert_eq!(c_sharp.up(6, 11).name(), "B#");
211 assert_eq!(Note::parse("Bb").unwrap().up(2, 4).name(), "D");
212 assert_eq!(Note::parse("Fb").unwrap().up(4, 7).name(), "Cb");
213 }
214
215 #[test]
216 fn cents() {
217 assert!((cents_between(440.0, 440.0 * 2f64.powf(1.0 / 12.0)) - 100.0).abs() < 1e-6);
218 }
219 }