| 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 | } |