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.cmp(&0) {
175            std::cmp::Ordering::Greater => "#".repeat(self.alter as usize),
176            std::cmp::Ordering::Less => "b".repeat(self.alter.unsigned_abs() as usize),
177            std::cmp::Ordering::Equal => String::new(),
178        };
179        format!("{}{}{}", self.step.to_char(), accidental, self.octave)
180    }
181
182    /// Return the enharmonic equivalent of this pitch.
183    ///
184    /// When `prefer_flat` is `true`, chromatic pitches use a flat spelling (Db, Eb, Gb, Ab, Bb).
185    /// When `false`, they use a sharp spelling (C#, D#, F#, G#, A#).
186    /// Natural pitches and edge cases (E#→F, B#→C, Cb→B, Fb→E) are always resolved to the
187    /// simplest diatonic form regardless of the flag.
188    pub fn respell(&self, prefer_flat: bool) -> Pitch {
189        let mut pitch = Pitch::from_midi(self.to_midi().clamp(0, 127) as u8, prefer_flat);
190        pitch.microtone_cents = self.microtone_cents;
191        pitch
192    }
193}
194
195impl std::str::FromStr for Pitch {
196    type Err = ();
197
198    /// Parse scientific pitch notation: `"C4"`, `"F#5"`, `"Bb3"`, `"C##4"`.
199    ///
200    /// Accepts upper- or lower-case step letters. Returns `Err(())` on any parse failure.
201    fn from_str(s: &str) -> Result<Self, Self::Err> {
202        let mut chars = s.chars().peekable();
203        let step = Step::from_char(chars.next().ok_or(())?).ok_or(())?;
204        let mut alter: i8 = 0;
205        loop {
206            match chars.peek() {
207                Some('#') => {
208                    alter = alter.checked_add(1).ok_or(())?;
209                    chars.next();
210                }
211                Some('b') => {
212                    alter = alter.checked_sub(1).ok_or(())?;
213                    chars.next();
214                }
215                _ => break,
216            }
217        }
218        let octave: i8 = chars.collect::<String>().parse().map_err(|_| ())?;
219        Ok(Pitch::with_alter(step, octave, alter))
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn middle_c_midi() {
229        assert_eq!(Pitch::new(Step::C, 4).to_midi(), 60);
230    }
231
232    #[test]
233    fn a4_midi() {
234        assert_eq!(Pitch::new(Step::A, 4).to_midi(), 69);
235    }
236
237    #[test]
238    fn scientific_name_sharp() {
239        let p = Pitch::with_alter(Step::F, 5, 1);
240        assert_eq!(p.to_scientific_name(), "F#5");
241    }
242
243    #[test]
244    fn scientific_name_flat() {
245        let p = Pitch::with_alter(Step::B, 3, -1);
246        assert_eq!(p.to_scientific_name(), "Bb3");
247    }
248
249    #[test]
250    fn respell_natural_unchanged() {
251        let p = Pitch::new(Step::C, 4);
252        assert_eq!(p.respell(true), Pitch::new(Step::C, 4));
253        assert_eq!(p.respell(false), Pitch::new(Step::C, 4));
254    }
255
256    #[test]
257    fn respell_csharp_to_db() {
258        let p = Pitch::with_alter(Step::C, 4, 1); // C#4, midi=61
259        let flat = p.respell(true);
260        assert_eq!(flat.step, Step::D);
261        assert_eq!(flat.alter, -1);
262        assert_eq!(flat.octave, 4);
263        assert_eq!(flat.to_midi(), 61);
264    }
265
266    #[test]
267    fn respell_db_to_csharp() {
268        let p = Pitch::with_alter(Step::D, 4, -1); // Db4, midi=61
269        let sharp = p.respell(false);
270        assert_eq!(sharp.step, Step::C);
271        assert_eq!(sharp.alter, 1);
272        assert_eq!(sharp.octave, 4);
273        assert_eq!(sharp.to_midi(), 61);
274    }
275
276    #[test]
277    fn respell_bsharp_to_c_next_octave() {
278        let p = Pitch::with_alter(Step::B, 4, 1); // B#4, midi=72 (C5)
279        let resolved = p.respell(true);
280        assert_eq!(resolved.step, Step::C);
281        assert_eq!(resolved.alter, 0);
282        assert_eq!(resolved.octave, 5);
283        assert_eq!(resolved.to_midi(), 72);
284    }
285
286    #[test]
287    fn respell_cb_to_b_prev_octave() {
288        let p = Pitch::with_alter(Step::C, 5, -1); // Cb5, midi=71 (B4)
289        let resolved = p.respell(false);
290        assert_eq!(resolved.step, Step::B);
291        assert_eq!(resolved.alter, 0);
292        assert_eq!(resolved.octave, 4);
293        assert_eq!(resolved.to_midi(), 71);
294    }
295
296    #[test]
297    fn from_midi_middle_c() {
298        let p = Pitch::from_midi(60, false);
299        assert_eq!(p.step, Step::C);
300        assert_eq!(p.octave, 4);
301        assert_eq!(p.alter, 0);
302    }
303
304    #[test]
305    fn from_midi_c_sharp_prefer_sharp() {
306        let p = Pitch::from_midi(61, false);
307        assert_eq!(p.step, Step::C);
308        assert_eq!(p.octave, 4);
309        assert_eq!(p.alter, 1);
310    }
311
312    #[test]
313    fn from_midi_d_flat_prefer_flat() {
314        let p = Pitch::from_midi(61, true);
315        assert_eq!(p.step, Step::D);
316        assert_eq!(p.octave, 4);
317        assert_eq!(p.alter, -1);
318    }
319
320    #[test]
321    fn from_midi_a4() {
322        let p = Pitch::from_midi(69, false);
323        assert_eq!(p.step, Step::A);
324        assert_eq!(p.octave, 4);
325        assert_eq!(p.alter, 0);
326    }
327
328    #[test]
329    fn from_midi_respell_roundtrip() {
330        for midi in 21u8..=108 {
331            for prefer_flat in [false, true] {
332                let p = Pitch::from_midi(midi, prefer_flat);
333                assert_eq!(
334                    p.to_midi() as u8,
335                    midi,
336                    "from_midi({midi},{prefer_flat}) roundtrip failed: {:?}",
337                    p
338                );
339            }
340        }
341    }
342
343    #[test]
344    fn from_str_c4() {
345        let p: Pitch = "C4".parse().unwrap();
346        assert_eq!(p, Pitch::new(Step::C, 4));
347    }
348
349    #[test]
350    fn from_str_fsharp5() {
351        let p: Pitch = "F#5".parse().unwrap();
352        assert_eq!(p, Pitch::with_alter(Step::F, 5, 1));
353    }
354
355    #[test]
356    fn from_str_bflat3() {
357        let p: Pitch = "Bb3".parse().unwrap();
358        assert_eq!(p.step, Step::B);
359        assert_eq!(p.alter, -1);
360        assert_eq!(p.octave, 3);
361        assert_eq!(p.to_midi(), 58);
362    }
363
364    #[test]
365    fn from_str_double_sharp() {
366        let p: Pitch = "C##4".parse().unwrap();
367        assert_eq!(p.step, Step::C);
368        assert_eq!(p.alter, 2);
369        assert_eq!(p.octave, 4);
370    }
371
372    #[test]
373    fn scientific_name_preserves_extended_accidentals() {
374        assert_eq!(
375            Pitch::with_alter(Step::C, 4, 3).to_scientific_name(),
376            "C###4"
377        );
378        assert_eq!(
379            Pitch::with_alter(Step::C, 4, -3).to_scientific_name(),
380            "Cbbb4"
381        );
382    }
383
384    #[test]
385    fn from_str_rejects_accidental_overflow() {
386        assert!(format!("C{}4", "#".repeat(128)).parse::<Pitch>().is_err());
387        assert!(format!("C{}4", "b".repeat(129)).parse::<Pitch>().is_err());
388    }
389
390    #[test]
391    fn from_str_invalid_step_returns_err() {
392        assert!("X4".parse::<Pitch>().is_err());
393        assert!("".parse::<Pitch>().is_err());
394        assert!("C".parse::<Pitch>().is_err()); // no octave
395    }
396
397    #[test]
398    fn from_str_roundtrip() {
399        for midi in 21u8..=108 {
400            for prefer_flat in [false, true] {
401                let p = Pitch::from_midi(midi, prefer_flat);
402                let name = p.to_scientific_name();
403                let parsed: Pitch = name
404                    .parse()
405                    .unwrap_or_else(|_| panic!("parse failed for {:?}", name));
406                assert_eq!(
407                    parsed.to_midi() as u8,
408                    midi,
409                    "roundtrip failed for {:?} (midi {})",
410                    name,
411                    midi
412                );
413            }
414        }
415    }
416
417    #[test]
418    fn respell_fsharp_to_gb() {
419        let p = Pitch::with_alter(Step::F, 4, 1); // F#4, midi=66
420        let flat = p.respell(true);
421        assert_eq!(flat.step, Step::G);
422        assert_eq!(flat.alter, -1);
423        assert_eq!(flat.to_midi(), 66);
424    }
425
426    #[test]
427    fn midi_cents_preserves_microtonal_boundary() {
428        let quarter_sharp = Pitch::with_microtone(Step::C, 4, 0, 50);
429        let quarter_flat = Pitch::with_microtone(Step::C, 4, 0, -50);
430        assert_eq!(quarter_sharp.to_midi_cents(), 6050);
431        assert_eq!(quarter_flat.to_midi_cents(), 5950);
432        assert_eq!(quarter_sharp.to_midi(), 61);
433        assert_eq!(quarter_flat.to_midi(), 60);
434    }
435
436    #[test]
437    fn try_with_microtone_rejects_without_clamping() {
438        assert_eq!(
439            Pitch::try_with_microtone(Step::C, 4, 0, -99)
440                .unwrap()
441                .microtone_cents,
442            -99
443        );
444        assert_eq!(
445            Pitch::try_with_microtone(Step::C, 4, 0, 99)
446                .unwrap()
447                .microtone_cents,
448            99
449        );
450        assert!(Pitch::try_with_microtone(Step::C, 4, 0, 100).is_none());
451        assert_eq!(
452            Pitch::with_microtone(Step::C, 4, 0, 100).microtone_cents,
453            99
454        );
455    }
456}