Skip to main content

sound_to_patch/music/
pitch.rs

1//! Pitch classes, spelling for display and the three middle-C labels.
2//!
3//! Inside the tool a pitch is a MIDI number and a pitch class an integer
4//! from 0 (C) to 11 (B). Names are for display only. MIDI fixes note 60 as
5//! middle C but names no octave, so programs label it C3, C4 or C5; an
6//! [`OctaveLabel`] names each convention by that label.
7//!
8//! ```
9//! use sound_to_patch::music::pitch::{OctaveLabel, Spelling, note_name, parse_note};
10//!
11//! assert_eq!(note_name(60, OctaveLabel::C4, Spelling::Sharps), "C4");
12//! assert_eq!(note_name(60, OctaveLabel::C3, Spelling::Sharps), "C3");
13//! assert_eq!(parse_note("A-1", OctaveLabel::C3)?, 21);
14//! # Ok::<(), sound_to_patch::music::MusicError>(())
15//! ```
16
17use std::fmt;
18
19use crate::music::MusicError;
20
21/// The pitch class of a MIDI number.
22#[must_use]
23pub fn pitch_class(pitch: u8) -> u8 {
24    pitch % 12
25}
26
27/// Whether a name uses sharps or flats.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum Spelling {
30    /// C#, D#, F#, G#, A#.
31    Sharps,
32    /// Db, Eb, Gb, Ab, Bb.
33    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/// The name of a pitch class.
44#[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/// A convention for octave numbers, named by the label it gives note 60.
54#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
55pub enum OctaveLabel {
56    /// Note 60 is C3; Serum 2 uses this label.
57    C3,
58    /// Note 60 is C4: scientific pitch notation.
59    C4,
60    /// Note 60 is C5.
61    C5,
62}
63
64impl OctaveLabel {
65    /// Every convention, scientific pitch first.
66    pub const ALL: [Self; 3] = [Self::C4, Self::C3, Self::C5];
67
68    /// The octave number of a MIDI pitch.
69    #[must_use]
70    pub fn octave(self, pitch: u8) -> i8 {
71        // A MIDI pitch is at most 127, so pitch / 12 fits in an i8.
72        let base = i8::try_from(pitch / 12).unwrap_or(i8::MAX);
73        base - self.shift()
74    }
75
76    /// How many octaves the number of note 60 lies below 5.
77    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/// The name of a MIDI pitch in a convention, such as `C4` or `A-1`.
97#[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
106/// The pitch class of a letter, `C` to `B`, in either case.
107pub(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
120/// Reads a letter and its sharps and flats from the start of `text`. Returns
121/// the pitch-class offset from C, which may fall outside 0 to 11, and the
122/// rest of the text.
123pub(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
140/// The MIDI pitch of a note name in a convention: a letter, up to two
141/// sharps or flats and an octave number, such as `C4`, `F#2` or `A-1`.
142///
143/// # Errors
144///
145/// [`MusicError::UnknownNote`] for any other text or a pitch outside 0 to
146/// 127.
147pub 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/// A note name read without a stated convention.
163#[derive(Clone, Copy, Debug, PartialEq, Eq)]
164pub struct NoteReading {
165    /// The MIDI pitch.
166    pub pitch: u8,
167    /// The convention taken.
168    pub label: OctaveLabel,
169    /// Always true: the convention is an assumption to record or ask about.
170    pub assumed: bool,
171}
172
173/// Reads a note name when the producer names no convention.
174///
175/// Picks the one convention whose pitch lies in `range`; when several or
176/// none do, takes scientific pitch. The reading is flagged as an assumption
177/// either way.
178///
179/// # Errors
180///
181/// [`MusicError::UnknownNote`] when no convention reads the text.
182pub 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}