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        Self {
84            step,
85            octave,
86            alter,
87            microtone_cents: microtone_cents.clamp(-99, 99),
88        }
89    }
90
91    /// Construct a pitch without silently changing its microtone component.
92    ///
93    /// Returns `None` when `microtone_cents` is outside the canonical -99..=99
94    /// range. Use [`Pitch::with_microtone`] only when clamping is intentional.
95    pub fn try_with_microtone(
96        step: Step,
97        octave: i8,
98        alter: i8,
99        microtone_cents: i16,
100    ) -> Option<Self> {
101        (-99..=99).contains(&microtone_cents).then_some(Self {
102            step,
103            octave,
104            alter,
105            microtone_cents,
106        })
107    }
108
109    /// MIDI note number (middle C = 60 = C4).
110    pub fn to_midi(&self) -> i16 {
111        let semitone = self.step.to_semitone() as i16;
112        let base = (self.octave as i16 + 1) * 12;
113        (base as f32 + semitone as f32 + self.alter as f32 + self.microtone_cents as f32 / 100.0)
114            .round() as i16
115    }
116
117    /// Exact sounding pitch in hundredths of a MIDI semitone.
118    ///
119    /// Unlike [`Pitch::to_midi`], this preserves fractional alteration and is
120    /// the canonical value for interchange comparisons and pitch-bend adapters.
121    pub fn to_midi_cents(&self) -> i32 {
122        let semitone = self.step.to_semitone() as i32;
123        ((self.octave as i32 + 1) * 12 + semitone + self.alter as i32) * 100
124            + self.microtone_cents as i32
125    }
126
127    /// Convert a MIDI note number (0–127) to a `Pitch`.
128    ///
129    /// `prefer_flat` selects the spelling for accidentals:
130    /// - `true`  → Db / Eb / Gb / Ab / Bb
131    /// - `false` → C# / D# / F# / G# / A#
132    pub fn from_midi(midi: u8, prefer_flat: bool) -> Pitch {
133        let pc = midi % 12;
134        let (step, alter): (Step, i8) = if prefer_flat {
135            match pc {
136                0 => (Step::C, 0),
137                1 => (Step::D, -1),
138                2 => (Step::D, 0),
139                3 => (Step::E, -1),
140                4 => (Step::E, 0),
141                5 => (Step::F, 0),
142                6 => (Step::G, -1),
143                7 => (Step::G, 0),
144                8 => (Step::A, -1),
145                9 => (Step::A, 0),
146                10 => (Step::B, -1),
147                11 => (Step::B, 0),
148                _ => (Step::C, 0),
149            }
150        } else {
151            match pc {
152                0 => (Step::C, 0),
153                1 => (Step::C, 1),
154                2 => (Step::D, 0),
155                3 => (Step::D, 1),
156                4 => (Step::E, 0),
157                5 => (Step::F, 0),
158                6 => (Step::F, 1),
159                7 => (Step::G, 0),
160                8 => (Step::G, 1),
161                9 => (Step::A, 0),
162                10 => (Step::A, 1),
163                11 => (Step::B, 0),
164                _ => (Step::C, 0),
165            }
166        };
167        let step_semitone = step.to_semitone() as i16 + alter as i16;
168        let octave = ((midi as i16 - step_semitone) / 12 - 1) as i8;
169        Pitch::with_alter(step, octave, alter)
170    }
171
172    /// Scientific pitch notation, e.g. "C4", "F#5", "Bb3".
173    pub fn to_scientific_name(&self) -> String {
174        let accidental = match self.alter {
175            2 => "##",
176            1 => "#",
177            0 => "",
178            -1 => "b",
179            -2 => "bb",
180            _ => "",
181        };
182        format!("{}{}{}", self.step.to_char(), accidental, self.octave)
183    }
184
185    /// Return the enharmonic equivalent of this pitch.
186    ///
187    /// When `prefer_flat` is `true`, chromatic pitches use a flat spelling (Db, Eb, Gb, Ab, Bb).
188    /// When `false`, they use a sharp spelling (C#, D#, F#, G#, A#).
189    /// Natural pitches and edge cases (E#→F, B#→C, Cb→B, Fb→E) are always resolved to the
190    /// simplest diatonic form regardless of the flag.
191    pub fn respell(&self, prefer_flat: bool) -> Pitch {
192        let mut pitch = Pitch::from_midi(self.to_midi().clamp(0, 127) as u8, prefer_flat);
193        pitch.microtone_cents = self.microtone_cents;
194        pitch
195    }
196}
197
198impl std::str::FromStr for Pitch {
199    type Err = ();
200
201    /// Parse scientific pitch notation: `"C4"`, `"F#5"`, `"Bb3"`, `"C##4"`.
202    ///
203    /// Accepts upper- or lower-case step letters. Returns `Err(())` on any parse failure.
204    fn from_str(s: &str) -> Result<Self, Self::Err> {
205        let mut chars = s.chars().peekable();
206        let step = Step::from_char(chars.next().ok_or(())?).ok_or(())?;
207        let mut alter: i8 = 0;
208        loop {
209            match chars.peek() {
210                Some('#') => {
211                    alter += 1;
212                    chars.next();
213                }
214                Some('b') => {
215                    alter -= 1;
216                    chars.next();
217                }
218                _ => break,
219            }
220        }
221        let octave: i8 = chars.collect::<String>().parse().map_err(|_| ())?;
222        Ok(Pitch::with_alter(step, octave, alter))
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn middle_c_midi() {
232        assert_eq!(Pitch::new(Step::C, 4).to_midi(), 60);
233    }
234
235    #[test]
236    fn a4_midi() {
237        assert_eq!(Pitch::new(Step::A, 4).to_midi(), 69);
238    }
239
240    #[test]
241    fn scientific_name_sharp() {
242        let p = Pitch::with_alter(Step::F, 5, 1);
243        assert_eq!(p.to_scientific_name(), "F#5");
244    }
245
246    #[test]
247    fn scientific_name_flat() {
248        let p = Pitch::with_alter(Step::B, 3, -1);
249        assert_eq!(p.to_scientific_name(), "Bb3");
250    }
251
252    #[test]
253    fn respell_natural_unchanged() {
254        let p = Pitch::new(Step::C, 4);
255        assert_eq!(p.respell(true), Pitch::new(Step::C, 4));
256        assert_eq!(p.respell(false), Pitch::new(Step::C, 4));
257    }
258
259    #[test]
260    fn respell_csharp_to_db() {
261        let p = Pitch::with_alter(Step::C, 4, 1); // C#4, midi=61
262        let flat = p.respell(true);
263        assert_eq!(flat.step, Step::D);
264        assert_eq!(flat.alter, -1);
265        assert_eq!(flat.octave, 4);
266        assert_eq!(flat.to_midi(), 61);
267    }
268
269    #[test]
270    fn respell_db_to_csharp() {
271        let p = Pitch::with_alter(Step::D, 4, -1); // Db4, midi=61
272        let sharp = p.respell(false);
273        assert_eq!(sharp.step, Step::C);
274        assert_eq!(sharp.alter, 1);
275        assert_eq!(sharp.octave, 4);
276        assert_eq!(sharp.to_midi(), 61);
277    }
278
279    #[test]
280    fn respell_bsharp_to_c_next_octave() {
281        let p = Pitch::with_alter(Step::B, 4, 1); // B#4, midi=72 (C5)
282        let resolved = p.respell(true);
283        assert_eq!(resolved.step, Step::C);
284        assert_eq!(resolved.alter, 0);
285        assert_eq!(resolved.octave, 5);
286        assert_eq!(resolved.to_midi(), 72);
287    }
288
289    #[test]
290    fn respell_cb_to_b_prev_octave() {
291        let p = Pitch::with_alter(Step::C, 5, -1); // Cb5, midi=71 (B4)
292        let resolved = p.respell(false);
293        assert_eq!(resolved.step, Step::B);
294        assert_eq!(resolved.alter, 0);
295        assert_eq!(resolved.octave, 4);
296        assert_eq!(resolved.to_midi(), 71);
297    }
298
299    #[test]
300    fn from_midi_middle_c() {
301        let p = Pitch::from_midi(60, false);
302        assert_eq!(p.step, Step::C);
303        assert_eq!(p.octave, 4);
304        assert_eq!(p.alter, 0);
305    }
306
307    #[test]
308    fn from_midi_c_sharp_prefer_sharp() {
309        let p = Pitch::from_midi(61, false);
310        assert_eq!(p.step, Step::C);
311        assert_eq!(p.octave, 4);
312        assert_eq!(p.alter, 1);
313    }
314
315    #[test]
316    fn from_midi_d_flat_prefer_flat() {
317        let p = Pitch::from_midi(61, true);
318        assert_eq!(p.step, Step::D);
319        assert_eq!(p.octave, 4);
320        assert_eq!(p.alter, -1);
321    }
322
323    #[test]
324    fn from_midi_a4() {
325        let p = Pitch::from_midi(69, false);
326        assert_eq!(p.step, Step::A);
327        assert_eq!(p.octave, 4);
328        assert_eq!(p.alter, 0);
329    }
330
331    #[test]
332    fn from_midi_respell_roundtrip() {
333        for midi in 21u8..=108 {
334            for prefer_flat in [false, true] {
335                let p = Pitch::from_midi(midi, prefer_flat);
336                assert_eq!(
337                    p.to_midi() as u8,
338                    midi,
339                    "from_midi({midi},{prefer_flat}) roundtrip failed: {:?}",
340                    p
341                );
342            }
343        }
344    }
345
346    #[test]
347    fn from_str_c4() {
348        let p: Pitch = "C4".parse().unwrap();
349        assert_eq!(p, Pitch::new(Step::C, 4));
350    }
351
352    #[test]
353    fn from_str_fsharp5() {
354        let p: Pitch = "F#5".parse().unwrap();
355        assert_eq!(p, Pitch::with_alter(Step::F, 5, 1));
356    }
357
358    #[test]
359    fn from_str_bflat3() {
360        let p: Pitch = "Bb3".parse().unwrap();
361        assert_eq!(p.step, Step::B);
362        assert_eq!(p.alter, -1);
363        assert_eq!(p.octave, 3);
364        assert_eq!(p.to_midi(), 58);
365    }
366
367    #[test]
368    fn from_str_double_sharp() {
369        let p: Pitch = "C##4".parse().unwrap();
370        assert_eq!(p.step, Step::C);
371        assert_eq!(p.alter, 2);
372        assert_eq!(p.octave, 4);
373    }
374
375    #[test]
376    fn from_str_invalid_step_returns_err() {
377        assert!("X4".parse::<Pitch>().is_err());
378        assert!("".parse::<Pitch>().is_err());
379        assert!("C".parse::<Pitch>().is_err()); // no octave
380    }
381
382    #[test]
383    fn from_str_roundtrip() {
384        for midi in 21u8..=108 {
385            for prefer_flat in [false, true] {
386                let p = Pitch::from_midi(midi, prefer_flat);
387                let name = p.to_scientific_name();
388                let parsed: Pitch = name
389                    .parse()
390                    .unwrap_or_else(|_| panic!("parse failed for {:?}", name));
391                assert_eq!(
392                    parsed.to_midi() as u8,
393                    midi,
394                    "roundtrip failed for {:?} (midi {})",
395                    name,
396                    midi
397                );
398            }
399        }
400    }
401
402    #[test]
403    fn respell_fsharp_to_gb() {
404        let p = Pitch::with_alter(Step::F, 4, 1); // F#4, midi=66
405        let flat = p.respell(true);
406        assert_eq!(flat.step, Step::G);
407        assert_eq!(flat.alter, -1);
408        assert_eq!(flat.to_midi(), 66);
409    }
410
411    #[test]
412    fn midi_cents_preserves_microtonal_boundary() {
413        let quarter_sharp = Pitch::with_microtone(Step::C, 4, 0, 50);
414        let quarter_flat = Pitch::with_microtone(Step::C, 4, 0, -50);
415        assert_eq!(quarter_sharp.to_midi_cents(), 6050);
416        assert_eq!(quarter_flat.to_midi_cents(), 5950);
417        assert_eq!(quarter_sharp.to_midi(), 61);
418        assert_eq!(quarter_flat.to_midi(), 60);
419    }
420
421    #[test]
422    fn try_with_microtone_rejects_without_clamping() {
423        assert_eq!(
424            Pitch::try_with_microtone(Step::C, 4, 0, -99)
425                .unwrap()
426                .microtone_cents,
427            -99
428        );
429        assert_eq!(
430            Pitch::try_with_microtone(Step::C, 4, 0, 99)
431                .unwrap()
432                .microtone_cents,
433            99
434        );
435        assert!(Pitch::try_with_microtone(Step::C, 4, 0, 100).is_none());
436        assert_eq!(
437            Pitch::with_microtone(Step::C, 4, 0, 100).microtone_cents,
438            99
439        );
440    }
441}