sim_lib_pitch_core/model.rs
1use std::str::FromStr;
2
3use thiserror::Error;
4
5/// Error returned when a pitch, pitch class, or interval cannot be constructed
6/// or parsed.
7#[derive(Debug, Error, Clone, PartialEq, Eq)]
8pub enum PitchError {
9 /// A pitch-class value of 12 or greater was supplied where only `0..12` is valid.
10 #[error("invalid pitch class {0}")]
11 InvalidPitchClass(u8),
12 /// A pitch spelling could not be parsed into a letter, accidental, and octave.
13 #[error("invalid pitch spelling")]
14 InvalidPitch,
15 /// An interval spelling was not one of the recognized tokens.
16 #[error("invalid interval spelling")]
17 InvalidInterval,
18}
19
20/// A mod-12 pitch class, where `C = 0` and values increase by semitone to `B = 11`.
21///
22/// Pitch classes are octave-agnostic: every C, regardless of register, shares the
23/// pitch class `C`. The inner `u8` is always in the range `0..12`.
24///
25/// # Examples
26///
27/// ```
28/// use sim_lib_pitch_core::PitchClass;
29///
30/// assert_eq!(PitchClass::C.transpose(7), PitchClass::G);
31/// assert_eq!(PitchClass::E.interval_class(PitchClass::C), 4);
32/// ```
33#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
34pub struct PitchClass(u8);
35
36impl PitchClass {
37 /// The pitch class C (0).
38 pub const C: Self = Self(0);
39 /// The pitch class C-sharp / D-flat (1).
40 pub const CS: Self = Self(1);
41 /// The pitch class D (2).
42 pub const D: Self = Self(2);
43 /// The pitch class D-sharp / E-flat (3).
44 pub const DS: Self = Self(3);
45 /// The pitch class E (4).
46 pub const E: Self = Self(4);
47 /// The pitch class F (5).
48 pub const F: Self = Self(5);
49 /// The pitch class F-sharp / G-flat (6).
50 pub const FS: Self = Self(6);
51 /// The pitch class G (7).
52 pub const G: Self = Self(7);
53 /// The pitch class G-sharp / A-flat (8).
54 pub const GS: Self = Self(8);
55 /// The pitch class A (9).
56 pub const A: Self = Self(9);
57 /// The pitch class A-sharp / B-flat (10).
58 pub const AS: Self = Self(10);
59 /// The pitch class B (11).
60 pub const B: Self = Self(11);
61
62 /// Constructs a pitch class from a raw value, rejecting values of 12 or more.
63 pub fn new(value: u8) -> Result<Self, PitchError> {
64 if value < 12 {
65 Ok(Self(value))
66 } else {
67 Err(PitchError::InvalidPitchClass(value))
68 }
69 }
70
71 /// Returns the raw mod-12 pitch-class value.
72 pub const fn value(self) -> u8 {
73 self.0
74 }
75
76 /// Returns this pitch class shifted up by `semitones` (or down if negative),
77 /// wrapping within the mod-12 octave.
78 pub fn transpose(self, semitones: i32) -> Self {
79 Self(((self.0 as i32 + semitones).rem_euclid(12)) as u8)
80 }
81
82 /// Returns the inversion of this pitch class about `axis`, wrapping within the
83 /// mod-12 octave.
84 pub fn invert(self, axis: PitchClass) -> Self {
85 Self(((2 * axis.0 as i32 - self.0 as i32).rem_euclid(12)) as u8)
86 }
87
88 /// Returns the interval class (0..=6) between this pitch class and `other`,
89 /// the smaller of the ascending and descending distances.
90 pub fn interval_class(self, other: PitchClass) -> u8 {
91 let delta = (other.0 as i32 - self.0 as i32).rem_euclid(12) as u8;
92 delta.min(12 - delta)
93 }
94
95 /// Returns the canonical sharp-spelled name of this pitch class (for example
96 /// `"C#"` for pitch class 1).
97 pub fn canonical_name(self) -> &'static str {
98 match self.0 {
99 0 => "C",
100 1 => "C#",
101 2 => "D",
102 3 => "D#",
103 4 => "E",
104 5 => "F",
105 6 => "F#",
106 7 => "G",
107 8 => "G#",
108 9 => "A",
109 10 => "A#",
110 11 => "B",
111 _ => unreachable!(),
112 }
113 }
114}
115
116/// An octave-aware pitch: a [`PitchClass`] together with an octave number.
117///
118/// The octave follows the MIDI convention in which middle C (`C4`) is MIDI note
119/// 60, so [`Pitch::semitone`] returns a continuous semitone index across octaves.
120#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
121pub struct Pitch {
122 /// The mod-12 pitch class.
123 pub class: PitchClass,
124 /// The octave number, with `C4` (MIDI 60) in octave 4.
125 pub octave: i16,
126}
127
128impl Pitch {
129 /// Returns the absolute semitone index of this pitch, where MIDI 60 (`C4`) is 60.
130 pub fn semitone(self) -> i32 {
131 (i32::from(self.octave) + 1) * 12 + i32::from(self.class.value())
132 }
133
134 /// Constructs a pitch from an absolute semitone index, the inverse of
135 /// [`Pitch::semitone`].
136 pub fn from_semitone(semitone: i32) -> Self {
137 Self {
138 class: PitchClass(semitone.rem_euclid(12) as u8),
139 octave: (semitone.div_euclid(12) - 1) as i16,
140 }
141 }
142
143 /// Returns the MIDI note number for this pitch, or `None` if it falls outside
144 /// the playable range `0..=127`.
145 pub fn to_midi(self) -> Option<u8> {
146 let semitone = self.semitone();
147 (0..=127).contains(&semitone).then_some(semitone as u8)
148 }
149
150 /// Constructs a pitch from a MIDI note number.
151 pub fn from_midi(value: u8) -> Self {
152 Self::from_semitone(value as i32)
153 }
154
155 /// Returns this pitch shifted by `semitones`, preserving the MIDI mapping.
156 pub fn transpose(self, semitones: i32) -> Self {
157 Self::from_semitone(self.semitone() + semitones)
158 }
159
160 /// Returns the inversion of this pitch about `axis`.
161 pub fn invert(self, axis: Pitch) -> Self {
162 Self::from_semitone(2 * axis.semitone() - self.semitone())
163 }
164}
165
166/// A diatonic letter name, independent of accidental.
167#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
168pub enum Letter {
169 /// The letter C.
170 C,
171 /// The letter D.
172 D,
173 /// The letter E.
174 E,
175 /// The letter F.
176 F,
177 /// The letter G.
178 G,
179 /// The letter A.
180 A,
181 /// The letter B.
182 B,
183}
184
185/// A spelled pitch: a diatonic [`Letter`], a chromatic accidental, and an octave.
186///
187/// Unlike [`Pitch`], a spelled pitch retains its enharmonic spelling, so `Cs4`
188/// and `Db4` are distinct even though they map to the same [`Pitch`].
189#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
190pub struct SpelledPitch {
191 /// The diatonic letter name.
192 pub letter: Letter,
193 /// The accidental offset in semitones (positive for sharps, negative for flats).
194 pub accidental: i8,
195 /// The octave number, following the MIDI convention.
196 pub octave: i16,
197}
198
199impl SpelledPitch {
200 /// Resolves this spelled pitch to its octave-aware [`Pitch`], discarding the
201 /// enharmonic spelling.
202 pub fn to_pitch(self) -> Pitch {
203 let base = match self.letter {
204 Letter::C => 0,
205 Letter::D => 2,
206 Letter::E => 4,
207 Letter::F => 5,
208 Letter::G => 7,
209 Letter::A => 9,
210 Letter::B => 11,
211 };
212 Pitch {
213 class: PitchClass((base + self.accidental as i32).rem_euclid(12) as u8),
214 octave: self.octave,
215 }
216 }
217}
218
219/// A pitch interval measured in semitones.
220///
221/// Positive values are ascending and negative values descending. The signed
222/// distance is preserved; use [`Interval::class`] to collapse to an interval class.
223#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
224pub struct Interval {
225 /// The signed distance in semitones.
226 pub semitones: i32,
227}
228
229impl Interval {
230 /// The perfect unison (0 semitones).
231 pub const UNISON: Self = Self { semitones: 0 };
232 /// The minor third (3 semitones).
233 pub const MINOR_3: Self = Self { semitones: 3 };
234 /// The major third (4 semitones).
235 pub const MAJOR_3: Self = Self { semitones: 4 };
236 /// The perfect fifth (7 semitones).
237 pub const PERFECT_5: Self = Self { semitones: 7 };
238 /// The tritone (6 semitones).
239 pub const TRITONE: Self = Self { semitones: 6 };
240 /// The major seventh (11 semitones).
241 pub const MAJOR_7: Self = Self { semitones: 11 };
242
243 /// Returns the directed interval from `a` to `b`.
244 pub fn between(a: Pitch, b: Pitch) -> Self {
245 Self {
246 semitones: b.semitone() - a.semitone(),
247 }
248 }
249
250 /// Returns the interval class (0..=6) of this interval, the smaller of the
251 /// ascending and descending mod-12 distances.
252 pub fn class(self) -> u8 {
253 let delta = self.semitones.rem_euclid(12) as u8;
254 delta.min(12 - delta)
255 }
256}
257
258impl FromStr for Pitch {
259 type Err = PitchError;
260
261 fn from_str(value: &str) -> Result<Self, Self::Err> {
262 parse_pitch(value)
263 }
264}
265
266impl FromStr for Interval {
267 type Err = PitchError;
268
269 fn from_str(value: &str) -> Result<Self, Self::Err> {
270 parse_interval(value)
271 }
272}
273
274/// Parses a pitch spelling such as `"C4"`, `"Eb5"`, or `"Cs4"` into a [`Pitch`].
275///
276/// Accidentals accept `#` or `s` for sharp and `b` for flat; an octave number is
277/// required. Returns [`PitchError::InvalidPitch`] on malformed input.
278///
279/// # Examples
280///
281/// ```
282/// use sim_lib_pitch_core::{parse_pitch, Pitch};
283///
284/// assert_eq!(parse_pitch("Eb5").unwrap(), Pitch::from_semitone(75));
285/// ```
286pub fn parse_pitch(value: &str) -> Result<Pitch, PitchError> {
287 let mut chars = value.chars();
288 let letter = match chars.next() {
289 Some('C') => Letter::C,
290 Some('D') => Letter::D,
291 Some('E') => Letter::E,
292 Some('F') => Letter::F,
293 Some('G') => Letter::G,
294 Some('A') => Letter::A,
295 Some('B') => Letter::B,
296 _ => return Err(PitchError::InvalidPitch),
297 };
298 let rest = chars.as_str();
299 let (accidental, octave_str) = if let Some(rest) = rest.strip_prefix('#') {
300 (1, rest)
301 } else if let Some(rest) = rest.strip_prefix('s') {
302 (1, rest)
303 } else if let Some(rest) = rest.strip_prefix('b') {
304 (-1, rest)
305 } else {
306 (0, rest)
307 };
308 if octave_str.is_empty() {
309 return Err(PitchError::InvalidPitch);
310 }
311 let octave = octave_str
312 .parse::<i16>()
313 .map_err(|_| PitchError::InvalidPitch)?;
314 Ok(SpelledPitch {
315 letter,
316 accidental,
317 octave,
318 }
319 .to_pitch())
320}
321
322/// Parses one of the recognized interval tokens (`"P5"`, `"m3"`, `"M7"`, `"TT"`)
323/// into an [`Interval`].
324///
325/// Returns [`PitchError::InvalidInterval`] for any unrecognized token.
326pub fn parse_interval(value: &str) -> Result<Interval, PitchError> {
327 match value {
328 "P5" => Ok(Interval::PERFECT_5),
329 "m3" => Ok(Interval::MINOR_3),
330 "M7" => Ok(Interval::MAJOR_7),
331 "TT" => Ok(Interval::TRITONE),
332 _ => Err(PitchError::InvalidInterval),
333 }
334}