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    /// The next enharmonic spelling of this pitch, as MuseScore's "Change enharmonic spelling"
189    /// cycles it: among the spellings of the same semitone, in the order natural, sharp, flat,
190    /// double sharp, double flat. Microtone cents are kept. A spelling outside that set (for
191    /// example a triple sharp) moves to the first available spelling.
192    pub fn next_enharmonic(&self) -> Pitch {
193        let target = (i16::from(self.octave) + 1) * 12
194            + i16::from(self.step.to_semitone())
195            + i16::from(self.alter);
196        let mut candidates = Vec::new();
197        for alter in [0i8, 1, -1, 2, -2] {
198            for step in [
199                Step::C,
200                Step::D,
201                Step::E,
202                Step::F,
203                Step::G,
204                Step::A,
205                Step::B,
206            ] {
207                let offset = target - i16::from(step.to_semitone()) - i16::from(alter);
208                if offset.rem_euclid(12) != 0 {
209                    continue;
210                }
211                if let Ok(octave) = i8::try_from(offset / 12 - 1) {
212                    candidates.push(Pitch::with_microtone(
213                        step,
214                        octave,
215                        alter,
216                        self.microtone_cents,
217                    ));
218                }
219            }
220        }
221        let current = candidates.iter().position(|candidate| {
222            candidate.step == self.step
223                && candidate.octave == self.octave
224                && candidate.alter == self.alter
225        });
226        match current {
227            Some(index) => candidates[(index + 1) % candidates.len()].clone(),
228            None => candidates.first().cloned().unwrap_or_else(|| self.clone()),
229        }
230    }
231
232    pub fn respell(&self, prefer_flat: bool) -> Pitch {
233        let mut pitch = Pitch::from_midi(self.to_midi().clamp(0, 127) as u8, prefer_flat);
234        pitch.microtone_cents = self.microtone_cents;
235        pitch
236    }
237}
238
239impl std::str::FromStr for Pitch {
240    type Err = ();
241
242    /// Parse scientific pitch notation: `"C4"`, `"F#5"`, `"Bb3"`, `"C##4"`.
243    ///
244    /// Accepts upper- or lower-case step letters. Returns `Err(())` on any parse failure.
245    fn from_str(s: &str) -> Result<Self, Self::Err> {
246        let mut chars = s.chars().peekable();
247        let step = Step::from_char(chars.next().ok_or(())?).ok_or(())?;
248        let mut alter: i8 = 0;
249        loop {
250            match chars.peek() {
251                Some('#') => {
252                    alter = alter.checked_add(1).ok_or(())?;
253                    chars.next();
254                }
255                Some('b') => {
256                    alter = alter.checked_sub(1).ok_or(())?;
257                    chars.next();
258                }
259                _ => break,
260            }
261        }
262        let octave: i8 = chars.collect::<String>().parse().map_err(|_| ())?;
263        Ok(Pitch::with_alter(step, octave, alter))
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    #[test]
270    fn next_enharmonic_cycles_spellings_of_one_semitone() {
271        use super::{Pitch, Step};
272        let c_sharp = Pitch::with_alter(Step::C, 4, 1);
273        let d_flat = c_sharp.next_enharmonic();
274        assert_eq!(d_flat, Pitch::with_alter(Step::D, 4, -1));
275        let b_double_sharp = d_flat.next_enharmonic();
276        assert_eq!(b_double_sharp, Pitch::with_alter(Step::B, 3, 2));
277        assert_eq!(b_double_sharp.next_enharmonic(), c_sharp);
278
279        let c = Pitch::new(Step::C, 4);
280        assert_eq!(c.next_enharmonic(), Pitch::with_alter(Step::B, 3, 1));
281        assert_eq!(
282            c.next_enharmonic().next_enharmonic(),
283            Pitch::with_alter(Step::D, 4, -2)
284        );
285        // G#/Ab has only two spellings.
286        let g_sharp = Pitch::with_alter(Step::G, 4, 1);
287        assert_eq!(g_sharp.next_enharmonic().next_enharmonic(), g_sharp);
288        let quarter = Pitch::with_microtone(Step::F, 4, 1, 50);
289        let next = quarter.next_enharmonic();
290        assert_eq!(
291            (next.step.clone(), next.alter, next.microtone_cents),
292            (Step::G, -1, 50)
293        );
294        assert_eq!(next.to_midi_cents(), quarter.to_midi_cents());
295    }
296
297    use super::*;
298
299    #[test]
300    fn middle_c_midi() {
301        assert_eq!(Pitch::new(Step::C, 4).to_midi(), 60);
302    }
303
304    #[test]
305    fn a4_midi() {
306        assert_eq!(Pitch::new(Step::A, 4).to_midi(), 69);
307    }
308
309    #[test]
310    fn scientific_name_sharp() {
311        let p = Pitch::with_alter(Step::F, 5, 1);
312        assert_eq!(p.to_scientific_name(), "F#5");
313    }
314
315    #[test]
316    fn scientific_name_flat() {
317        let p = Pitch::with_alter(Step::B, 3, -1);
318        assert_eq!(p.to_scientific_name(), "Bb3");
319    }
320
321    #[test]
322    fn respell_natural_unchanged() {
323        let p = Pitch::new(Step::C, 4);
324        assert_eq!(p.respell(true), Pitch::new(Step::C, 4));
325        assert_eq!(p.respell(false), Pitch::new(Step::C, 4));
326    }
327
328    #[test]
329    fn respell_csharp_to_db() {
330        let p = Pitch::with_alter(Step::C, 4, 1); // C#4, midi=61
331        let flat = p.respell(true);
332        assert_eq!(flat.step, Step::D);
333        assert_eq!(flat.alter, -1);
334        assert_eq!(flat.octave, 4);
335        assert_eq!(flat.to_midi(), 61);
336    }
337
338    #[test]
339    fn respell_db_to_csharp() {
340        let p = Pitch::with_alter(Step::D, 4, -1); // Db4, midi=61
341        let sharp = p.respell(false);
342        assert_eq!(sharp.step, Step::C);
343        assert_eq!(sharp.alter, 1);
344        assert_eq!(sharp.octave, 4);
345        assert_eq!(sharp.to_midi(), 61);
346    }
347
348    #[test]
349    fn respell_bsharp_to_c_next_octave() {
350        let p = Pitch::with_alter(Step::B, 4, 1); // B#4, midi=72 (C5)
351        let resolved = p.respell(true);
352        assert_eq!(resolved.step, Step::C);
353        assert_eq!(resolved.alter, 0);
354        assert_eq!(resolved.octave, 5);
355        assert_eq!(resolved.to_midi(), 72);
356    }
357
358    #[test]
359    fn respell_cb_to_b_prev_octave() {
360        let p = Pitch::with_alter(Step::C, 5, -1); // Cb5, midi=71 (B4)
361        let resolved = p.respell(false);
362        assert_eq!(resolved.step, Step::B);
363        assert_eq!(resolved.alter, 0);
364        assert_eq!(resolved.octave, 4);
365        assert_eq!(resolved.to_midi(), 71);
366    }
367
368    #[test]
369    fn from_midi_middle_c() {
370        let p = Pitch::from_midi(60, false);
371        assert_eq!(p.step, Step::C);
372        assert_eq!(p.octave, 4);
373        assert_eq!(p.alter, 0);
374    }
375
376    #[test]
377    fn from_midi_c_sharp_prefer_sharp() {
378        let p = Pitch::from_midi(61, false);
379        assert_eq!(p.step, Step::C);
380        assert_eq!(p.octave, 4);
381        assert_eq!(p.alter, 1);
382    }
383
384    #[test]
385    fn from_midi_d_flat_prefer_flat() {
386        let p = Pitch::from_midi(61, true);
387        assert_eq!(p.step, Step::D);
388        assert_eq!(p.octave, 4);
389        assert_eq!(p.alter, -1);
390    }
391
392    #[test]
393    fn from_midi_a4() {
394        let p = Pitch::from_midi(69, false);
395        assert_eq!(p.step, Step::A);
396        assert_eq!(p.octave, 4);
397        assert_eq!(p.alter, 0);
398    }
399
400    #[test]
401    fn from_midi_respell_roundtrip() {
402        for midi in 21u8..=108 {
403            for prefer_flat in [false, true] {
404                let p = Pitch::from_midi(midi, prefer_flat);
405                assert_eq!(
406                    p.to_midi() as u8,
407                    midi,
408                    "from_midi({midi},{prefer_flat}) roundtrip failed: {:?}",
409                    p
410                );
411            }
412        }
413    }
414
415    #[test]
416    fn from_str_c4() {
417        let p: Pitch = "C4".parse().unwrap();
418        assert_eq!(p, Pitch::new(Step::C, 4));
419    }
420
421    #[test]
422    fn from_str_fsharp5() {
423        let p: Pitch = "F#5".parse().unwrap();
424        assert_eq!(p, Pitch::with_alter(Step::F, 5, 1));
425    }
426
427    #[test]
428    fn from_str_bflat3() {
429        let p: Pitch = "Bb3".parse().unwrap();
430        assert_eq!(p.step, Step::B);
431        assert_eq!(p.alter, -1);
432        assert_eq!(p.octave, 3);
433        assert_eq!(p.to_midi(), 58);
434    }
435
436    #[test]
437    fn from_str_double_sharp() {
438        let p: Pitch = "C##4".parse().unwrap();
439        assert_eq!(p.step, Step::C);
440        assert_eq!(p.alter, 2);
441        assert_eq!(p.octave, 4);
442    }
443
444    #[test]
445    fn scientific_name_preserves_extended_accidentals() {
446        assert_eq!(
447            Pitch::with_alter(Step::C, 4, 3).to_scientific_name(),
448            "C###4"
449        );
450        assert_eq!(
451            Pitch::with_alter(Step::C, 4, -3).to_scientific_name(),
452            "Cbbb4"
453        );
454    }
455
456    #[test]
457    fn from_str_rejects_accidental_overflow() {
458        assert!(format!("C{}4", "#".repeat(128)).parse::<Pitch>().is_err());
459        assert!(format!("C{}4", "b".repeat(129)).parse::<Pitch>().is_err());
460    }
461
462    #[test]
463    fn from_str_invalid_step_returns_err() {
464        assert!("X4".parse::<Pitch>().is_err());
465        assert!("".parse::<Pitch>().is_err());
466        assert!("C".parse::<Pitch>().is_err()); // no octave
467    }
468
469    #[test]
470    fn from_str_roundtrip() {
471        for midi in 21u8..=108 {
472            for prefer_flat in [false, true] {
473                let p = Pitch::from_midi(midi, prefer_flat);
474                let name = p.to_scientific_name();
475                let parsed: Pitch = name
476                    .parse()
477                    .unwrap_or_else(|_| panic!("parse failed for {:?}", name));
478                assert_eq!(
479                    parsed.to_midi() as u8,
480                    midi,
481                    "roundtrip failed for {:?} (midi {})",
482                    name,
483                    midi
484                );
485            }
486        }
487    }
488
489    #[test]
490    fn respell_fsharp_to_gb() {
491        let p = Pitch::with_alter(Step::F, 4, 1); // F#4, midi=66
492        let flat = p.respell(true);
493        assert_eq!(flat.step, Step::G);
494        assert_eq!(flat.alter, -1);
495        assert_eq!(flat.to_midi(), 66);
496    }
497
498    #[test]
499    fn midi_cents_preserves_microtonal_boundary() {
500        let quarter_sharp = Pitch::with_microtone(Step::C, 4, 0, 50);
501        let quarter_flat = Pitch::with_microtone(Step::C, 4, 0, -50);
502        assert_eq!(quarter_sharp.to_midi_cents(), 6050);
503        assert_eq!(quarter_flat.to_midi_cents(), 5950);
504        assert_eq!(quarter_sharp.to_midi(), 61);
505        assert_eq!(quarter_flat.to_midi(), 60);
506    }
507
508    #[test]
509    fn try_with_microtone_rejects_without_clamping() {
510        assert_eq!(
511            Pitch::try_with_microtone(Step::C, 4, 0, -99)
512                .unwrap()
513                .microtone_cents,
514            -99
515        );
516        assert_eq!(
517            Pitch::try_with_microtone(Step::C, 4, 0, 99)
518                .unwrap()
519                .microtone_cents,
520            99
521        );
522        assert!(Pitch::try_with_microtone(Step::C, 4, 0, 100).is_none());
523        assert_eq!(
524            Pitch::with_microtone(Step::C, 4, 0, 100).microtone_cents,
525            99
526        );
527    }
528}