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