Skip to main content

acorde_core/model/
pitch.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4pub enum Step {
5    C,
6    D,
7    E,
8    F,
9    G,
10    A,
11    B,
12}
13
14impl Step {
15    pub fn to_semitone(&self) -> u8 {
16        match self {
17            Step::C => 0,
18            Step::D => 2,
19            Step::E => 4,
20            Step::F => 5,
21            Step::G => 7,
22            Step::A => 9,
23            Step::B => 11,
24        }
25    }
26
27    pub fn from_char(c: char) -> Option<Self> {
28        match c.to_ascii_uppercase() {
29            'C' => Some(Step::C),
30            'D' => Some(Step::D),
31            'E' => Some(Step::E),
32            'F' => Some(Step::F),
33            'G' => Some(Step::G),
34            'A' => Some(Step::A),
35            'B' => Some(Step::B),
36            _ => None,
37        }
38    }
39
40    pub fn to_char(&self) -> char {
41        match self {
42            Step::C => 'C',
43            Step::D => 'D',
44            Step::E => 'E',
45            Step::F => 'F',
46            Step::G => 'G',
47            Step::A => 'A',
48            Step::B => 'B',
49        }
50    }
51}
52
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct Pitch {
55    pub step: Step,
56    pub octave: i8,
57    pub alter: i8,
58    /// Additional cents beyond the integer semitone alteration.
59    #[serde(default)]
60    pub microtone_cents: i16,
61}
62
63impl Pitch {
64    pub fn new(step: Step, octave: i8) -> Self {
65        Self {
66            step,
67            octave,
68            alter: 0,
69            microtone_cents: 0,
70        }
71    }
72
73    pub fn with_alter(step: Step, octave: i8, alter: i8) -> Self {
74        Self {
75            step,
76            octave,
77            alter,
78            microtone_cents: 0,
79        }
80    }
81
82    pub fn with_microtone(step: Step, octave: i8, alter: i8, microtone_cents: i16) -> Self {
83        let mut pitch = Self::with_alter(step, octave, alter);
84        pitch.microtone_cents = microtone_cents.clamp(-99, 99);
85        pitch
86    }
87
88    /// MIDI note number (middle C = 60 = C4).
89    pub fn to_midi(&self) -> i16 {
90        let semitone = self.step.to_semitone() as i16;
91        let base = (self.octave as i16 + 1) * 12;
92        (base as f32 + semitone as f32 + self.alter as f32 + self.microtone_cents as f32 / 100.0)
93            .round() as i16
94    }
95
96    /// Convert a MIDI note number (0–127) to a `Pitch`.
97    ///
98    /// `prefer_flat` selects the spelling for accidentals:
99    /// - `true`  → Db / Eb / Gb / Ab / Bb
100    /// - `false` → C# / D# / F# / G# / A#
101    pub fn from_midi(midi: u8, prefer_flat: bool) -> Pitch {
102        let pc = midi % 12;
103        let (step, alter): (Step, i8) = if prefer_flat {
104            match pc {
105                0 => (Step::C, 0),
106                1 => (Step::D, -1),
107                2 => (Step::D, 0),
108                3 => (Step::E, -1),
109                4 => (Step::E, 0),
110                5 => (Step::F, 0),
111                6 => (Step::G, -1),
112                7 => (Step::G, 0),
113                8 => (Step::A, -1),
114                9 => (Step::A, 0),
115                10 => (Step::B, -1),
116                11 => (Step::B, 0),
117                _ => (Step::C, 0),
118            }
119        } else {
120            match pc {
121                0 => (Step::C, 0),
122                1 => (Step::C, 1),
123                2 => (Step::D, 0),
124                3 => (Step::D, 1),
125                4 => (Step::E, 0),
126                5 => (Step::F, 0),
127                6 => (Step::F, 1),
128                7 => (Step::G, 0),
129                8 => (Step::G, 1),
130                9 => (Step::A, 0),
131                10 => (Step::A, 1),
132                11 => (Step::B, 0),
133                _ => (Step::C, 0),
134            }
135        };
136        let step_semitone = step.to_semitone() as i16 + alter as i16;
137        let octave = ((midi as i16 - step_semitone) / 12 - 1) as i8;
138        Pitch::with_alter(step, octave, alter)
139    }
140
141    /// Scientific pitch notation, e.g. "C4", "F#5", "Bb3".
142    pub fn to_scientific_name(&self) -> String {
143        let accidental = match self.alter {
144            2 => "##",
145            1 => "#",
146            0 => "",
147            -1 => "b",
148            -2 => "bb",
149            _ => "",
150        };
151        format!("{}{}{}", self.step.to_char(), accidental, self.octave)
152    }
153
154    /// Return the enharmonic equivalent of this pitch.
155    ///
156    /// When `prefer_flat` is `true`, chromatic pitches use a flat spelling (Db, Eb, Gb, Ab, Bb).
157    /// When `false`, they use a sharp spelling (C#, D#, F#, G#, A#).
158    /// Natural pitches and edge cases (E#→F, B#→C, Cb→B, Fb→E) are always resolved to the
159    /// simplest diatonic form regardless of the flag.
160    pub fn respell(&self, prefer_flat: bool) -> Pitch {
161        let mut pitch = Pitch::from_midi(self.to_midi().clamp(0, 127) as u8, prefer_flat);
162        pitch.microtone_cents = self.microtone_cents;
163        pitch
164    }
165}
166
167impl std::str::FromStr for Pitch {
168    type Err = ();
169
170    /// Parse scientific pitch notation: `"C4"`, `"F#5"`, `"Bb3"`, `"C##4"`.
171    ///
172    /// Accepts upper- or lower-case step letters. Returns `Err(())` on any parse failure.
173    fn from_str(s: &str) -> Result<Self, Self::Err> {
174        let mut chars = s.chars().peekable();
175        let step = Step::from_char(chars.next().ok_or(())?).ok_or(())?;
176        let mut alter: i8 = 0;
177        loop {
178            match chars.peek() {
179                Some('#') => {
180                    alter += 1;
181                    chars.next();
182                }
183                Some('b') => {
184                    alter -= 1;
185                    chars.next();
186                }
187                _ => break,
188            }
189        }
190        let octave: i8 = chars.collect::<String>().parse().map_err(|_| ())?;
191        Ok(Pitch::with_alter(step, octave, alter))
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn middle_c_midi() {
201        assert_eq!(Pitch::new(Step::C, 4).to_midi(), 60);
202    }
203
204    #[test]
205    fn a4_midi() {
206        assert_eq!(Pitch::new(Step::A, 4).to_midi(), 69);
207    }
208
209    #[test]
210    fn scientific_name_sharp() {
211        let p = Pitch::with_alter(Step::F, 5, 1);
212        assert_eq!(p.to_scientific_name(), "F#5");
213    }
214
215    #[test]
216    fn scientific_name_flat() {
217        let p = Pitch::with_alter(Step::B, 3, -1);
218        assert_eq!(p.to_scientific_name(), "Bb3");
219    }
220
221    #[test]
222    fn respell_natural_unchanged() {
223        let p = Pitch::new(Step::C, 4);
224        assert_eq!(p.respell(true), Pitch::new(Step::C, 4));
225        assert_eq!(p.respell(false), Pitch::new(Step::C, 4));
226    }
227
228    #[test]
229    fn respell_csharp_to_db() {
230        let p = Pitch::with_alter(Step::C, 4, 1); // C#4, midi=61
231        let flat = p.respell(true);
232        assert_eq!(flat.step, Step::D);
233        assert_eq!(flat.alter, -1);
234        assert_eq!(flat.octave, 4);
235        assert_eq!(flat.to_midi(), 61);
236    }
237
238    #[test]
239    fn respell_db_to_csharp() {
240        let p = Pitch::with_alter(Step::D, 4, -1); // Db4, midi=61
241        let sharp = p.respell(false);
242        assert_eq!(sharp.step, Step::C);
243        assert_eq!(sharp.alter, 1);
244        assert_eq!(sharp.octave, 4);
245        assert_eq!(sharp.to_midi(), 61);
246    }
247
248    #[test]
249    fn respell_bsharp_to_c_next_octave() {
250        let p = Pitch::with_alter(Step::B, 4, 1); // B#4, midi=72 (C5)
251        let resolved = p.respell(true);
252        assert_eq!(resolved.step, Step::C);
253        assert_eq!(resolved.alter, 0);
254        assert_eq!(resolved.octave, 5);
255        assert_eq!(resolved.to_midi(), 72);
256    }
257
258    #[test]
259    fn respell_cb_to_b_prev_octave() {
260        let p = Pitch::with_alter(Step::C, 5, -1); // Cb5, midi=71 (B4)
261        let resolved = p.respell(false);
262        assert_eq!(resolved.step, Step::B);
263        assert_eq!(resolved.alter, 0);
264        assert_eq!(resolved.octave, 4);
265        assert_eq!(resolved.to_midi(), 71);
266    }
267
268    #[test]
269    fn from_midi_middle_c() {
270        let p = Pitch::from_midi(60, false);
271        assert_eq!(p.step, Step::C);
272        assert_eq!(p.octave, 4);
273        assert_eq!(p.alter, 0);
274    }
275
276    #[test]
277    fn from_midi_c_sharp_prefer_sharp() {
278        let p = Pitch::from_midi(61, false);
279        assert_eq!(p.step, Step::C);
280        assert_eq!(p.octave, 4);
281        assert_eq!(p.alter, 1);
282    }
283
284    #[test]
285    fn from_midi_d_flat_prefer_flat() {
286        let p = Pitch::from_midi(61, true);
287        assert_eq!(p.step, Step::D);
288        assert_eq!(p.octave, 4);
289        assert_eq!(p.alter, -1);
290    }
291
292    #[test]
293    fn from_midi_a4() {
294        let p = Pitch::from_midi(69, false);
295        assert_eq!(p.step, Step::A);
296        assert_eq!(p.octave, 4);
297        assert_eq!(p.alter, 0);
298    }
299
300    #[test]
301    fn from_midi_respell_roundtrip() {
302        for midi in 21u8..=108 {
303            for prefer_flat in [false, true] {
304                let p = Pitch::from_midi(midi, prefer_flat);
305                assert_eq!(
306                    p.to_midi() as u8,
307                    midi,
308                    "from_midi({midi},{prefer_flat}) roundtrip failed: {:?}",
309                    p
310                );
311            }
312        }
313    }
314
315    #[test]
316    fn from_str_c4() {
317        let p: Pitch = "C4".parse().unwrap();
318        assert_eq!(p, Pitch::new(Step::C, 4));
319    }
320
321    #[test]
322    fn from_str_fsharp5() {
323        let p: Pitch = "F#5".parse().unwrap();
324        assert_eq!(p, Pitch::with_alter(Step::F, 5, 1));
325    }
326
327    #[test]
328    fn from_str_bflat3() {
329        let p: Pitch = "Bb3".parse().unwrap();
330        assert_eq!(p.step, Step::B);
331        assert_eq!(p.alter, -1);
332        assert_eq!(p.octave, 3);
333        assert_eq!(p.to_midi(), 58);
334    }
335
336    #[test]
337    fn from_str_double_sharp() {
338        let p: Pitch = "C##4".parse().unwrap();
339        assert_eq!(p.step, Step::C);
340        assert_eq!(p.alter, 2);
341        assert_eq!(p.octave, 4);
342    }
343
344    #[test]
345    fn from_str_invalid_step_returns_err() {
346        assert!("X4".parse::<Pitch>().is_err());
347        assert!("".parse::<Pitch>().is_err());
348        assert!("C".parse::<Pitch>().is_err()); // no octave
349    }
350
351    #[test]
352    fn from_str_roundtrip() {
353        for midi in 21u8..=108 {
354            for prefer_flat in [false, true] {
355                let p = Pitch::from_midi(midi, prefer_flat);
356                let name = p.to_scientific_name();
357                let parsed: Pitch = name
358                    .parse()
359                    .unwrap_or_else(|_| panic!("parse failed for {:?}", name));
360                assert_eq!(
361                    parsed.to_midi() as u8,
362                    midi,
363                    "roundtrip failed for {:?} (midi {})",
364                    name,
365                    midi
366                );
367            }
368        }
369    }
370
371    #[test]
372    fn respell_fsharp_to_gb() {
373        let p = Pitch::with_alter(Step::F, 4, 1); // F#4, midi=66
374        let flat = p.respell(true);
375        assert_eq!(flat.step, Step::G);
376        assert_eq!(flat.alter, -1);
377        assert_eq!(flat.to_midi(), 66);
378    }
379}