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