sim_lib_pitch_core/model.rs
1use std::{num::NonZeroU16, 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 /// An octave-space division count was zero.
19 #[error("invalid octave-space division count {0}")]
20 InvalidOctaveSpace(u16),
21}
22
23/// A positive modular division count for octave-like pitch spaces.
24///
25/// [`PitchClass`] and [`Pitch`] remain fixed to the canonical 12-class,
26/// MIDI-compatible pitch identity. `OctaveSpace` is for algorithms that need
27/// floor decomposition or circular distance in another positive division count.
28///
29/// # Examples
30///
31/// ```
32/// use sim_lib_pitch_core::{split_floor, OctaveSpace};
33///
34/// let twelve = OctaveSpace::new(12).unwrap();
35/// assert_eq!(split_floor(-13, twelve), (-2, 11));
36/// ```
37#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
38pub struct OctaveSpace {
39 /// Positive divisions in one octave-like cycle.
40 pub divisions: NonZeroU16,
41}
42
43impl OctaveSpace {
44 /// Constructs an octave space, rejecting zero-sized spaces.
45 pub fn new(divisions: u16) -> Result<Self, PitchError> {
46 let divisions =
47 NonZeroU16::new(divisions).ok_or(PitchError::InvalidOctaveSpace(divisions))?;
48 Ok(Self { divisions })
49 }
50
51 /// Returns the canonical 12-division semitone space.
52 pub fn twelve_tone() -> Self {
53 Self {
54 divisions: NonZeroU16::new(12).expect("12 is non-zero"),
55 }
56 }
57
58 /// Returns the positive division count.
59 pub fn len(self) -> u16 {
60 self.divisions.get()
61 }
62
63 /// Returns `true` because an [`OctaveSpace`] is always non-empty.
64 pub fn is_empty(self) -> bool {
65 false
66 }
67}
68
69/// Direction used when a folded distance has two equally short paths.
70#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
71pub enum TieDirection {
72 /// Choose the ascending path.
73 Ascending,
74 /// Choose the descending path.
75 Descending,
76}
77
78/// Splits an integer value into a floor octave and folded class for `space`.
79///
80/// The returned class is always in `0..space.len()`, including for negative
81/// inputs.
82pub fn split_floor(value: i64, space: OctaveSpace) -> (i64, u16) {
83 let divisions = i64::from(space.len());
84 (
85 value.div_euclid(divisions),
86 value.rem_euclid(divisions) as u16,
87 )
88}
89
90/// Folds an integer value into `0..space.len()` using floor modulus.
91pub fn fold(value: i64, space: OctaveSpace) -> u16 {
92 split_floor(value, space).1
93}
94
95/// Returns the unsigned shortest circular distance between two values in `space`.
96pub fn folded_unsigned_distance(a: i64, b: i64, space: OctaveSpace) -> u16 {
97 let divisions = i128::from(space.len());
98 let ascending = (i128::from(b) - i128::from(a)).rem_euclid(divisions);
99 ascending.min(divisions - ascending) as u16
100}
101
102/// Returns the signed shortest circular distance from `a` to `b` in `space`.
103///
104/// Positive values move upward and negative values move downward. When the space
105/// has an even division count and the two paths are equally short,
106/// `tie` selects the sign.
107pub fn folded_distance(a: i64, b: i64, space: OctaveSpace, tie: TieDirection) -> i32 {
108 let divisions = i128::from(space.len());
109 let ascending = (i128::from(b) - i128::from(a)).rem_euclid(divisions);
110 let descending = ascending - divisions;
111 match ascending.cmp(&(-descending)) {
112 std::cmp::Ordering::Less => ascending as i32,
113 std::cmp::Ordering::Greater => descending as i32,
114 std::cmp::Ordering::Equal => match tie {
115 TieDirection::Ascending => ascending as i32,
116 TieDirection::Descending => descending as i32,
117 },
118 }
119}
120
121/// A mod-12 pitch class, where `C = 0` and values increase by semitone to `B = 11`.
122///
123/// Pitch classes are octave-agnostic: every C, regardless of register, shares the
124/// pitch class `C`. The inner `u8` is always in the range `0..12`.
125///
126/// # Examples
127///
128/// ```
129/// use sim_lib_pitch_core::PitchClass;
130///
131/// assert_eq!(PitchClass::C.transpose(7), PitchClass::G);
132/// assert_eq!(PitchClass::E.interval_class(PitchClass::C), 4);
133/// ```
134#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
135pub struct PitchClass(u8);
136
137impl PitchClass {
138 /// The pitch class C (0).
139 pub const C: Self = Self(0);
140 /// The pitch class C-sharp / D-flat (1).
141 pub const CS: Self = Self(1);
142 /// The pitch class D (2).
143 pub const D: Self = Self(2);
144 /// The pitch class D-sharp / E-flat (3).
145 pub const DS: Self = Self(3);
146 /// The pitch class E (4).
147 pub const E: Self = Self(4);
148 /// The pitch class F (5).
149 pub const F: Self = Self(5);
150 /// The pitch class F-sharp / G-flat (6).
151 pub const FS: Self = Self(6);
152 /// The pitch class G (7).
153 pub const G: Self = Self(7);
154 /// The pitch class G-sharp / A-flat (8).
155 pub const GS: Self = Self(8);
156 /// The pitch class A (9).
157 pub const A: Self = Self(9);
158 /// The pitch class A-sharp / B-flat (10).
159 pub const AS: Self = Self(10);
160 /// The pitch class B (11).
161 pub const B: Self = Self(11);
162
163 /// Constructs a pitch class from a raw value, rejecting values of 12 or more.
164 pub fn new(value: u8) -> Result<Self, PitchError> {
165 if value < 12 {
166 Ok(Self(value))
167 } else {
168 Err(PitchError::InvalidPitchClass(value))
169 }
170 }
171
172 /// Returns the raw mod-12 pitch-class value.
173 pub const fn value(self) -> u8 {
174 self.0
175 }
176
177 /// Returns this pitch class shifted up by `semitones` (or down if negative),
178 /// wrapping within the mod-12 octave.
179 pub fn transpose(self, semitones: i32) -> Self {
180 Self(((self.0 as i32 + semitones).rem_euclid(12)) as u8)
181 }
182
183 /// Returns the inversion of this pitch class about `axis`, wrapping within the
184 /// mod-12 octave.
185 pub fn invert(self, axis: PitchClass) -> Self {
186 Self(((2 * axis.0 as i32 - self.0 as i32).rem_euclid(12)) as u8)
187 }
188
189 /// Returns the interval class (0..=6) between this pitch class and `other`,
190 /// the smaller of the ascending and descending distances.
191 pub fn interval_class(self, other: PitchClass) -> u8 {
192 let delta = (other.0 as i32 - self.0 as i32).rem_euclid(12) as u8;
193 delta.min(12 - delta)
194 }
195
196 /// Returns the canonical sharp-spelled name of this pitch class (for example
197 /// `"C#"` for pitch class 1).
198 pub fn canonical_name(self) -> &'static str {
199 match self.0 {
200 0 => "C",
201 1 => "C#",
202 2 => "D",
203 3 => "D#",
204 4 => "E",
205 5 => "F",
206 6 => "F#",
207 7 => "G",
208 8 => "G#",
209 9 => "A",
210 10 => "A#",
211 11 => "B",
212 _ => unreachable!(),
213 }
214 }
215}
216
217/// An octave-aware pitch: a [`PitchClass`] together with an octave number.
218///
219/// The octave follows the MIDI convention in which middle C (`C4`) is MIDI note
220/// 60, so [`Pitch::semitone`] returns a continuous semitone index across octaves.
221#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
222pub struct Pitch {
223 /// The mod-12 pitch class.
224 pub class: PitchClass,
225 /// The octave number, with `C4` (MIDI 60) in octave 4.
226 pub octave: i16,
227}
228
229impl PartialOrd for Pitch {
230 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
231 Some(self.cmp(other))
232 }
233}
234
235impl Ord for Pitch {
236 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
237 self.semitone().cmp(&other.semitone())
238 }
239}
240
241impl Pitch {
242 /// Returns the absolute semitone index of this pitch, where MIDI 60 (`C4`) is 60.
243 pub fn semitone(self) -> i32 {
244 (i32::from(self.octave) + 1) * 12 + i32::from(self.class.value())
245 }
246
247 /// Constructs a pitch from an absolute semitone index, the inverse of
248 /// [`Pitch::semitone`].
249 pub fn from_semitone(semitone: i32) -> Self {
250 Self {
251 class: PitchClass(semitone.rem_euclid(12) as u8),
252 octave: (semitone.div_euclid(12) - 1) as i16,
253 }
254 }
255
256 /// Returns the MIDI note number for this pitch, or `None` if it falls outside
257 /// the playable range `0..=127`.
258 pub fn to_midi(self) -> Option<u8> {
259 let semitone = self.semitone();
260 (0..=127).contains(&semitone).then_some(semitone as u8)
261 }
262
263 /// Constructs a pitch from a MIDI note number.
264 pub fn from_midi(value: u8) -> Self {
265 Self::from_semitone(value as i32)
266 }
267
268 /// Returns this pitch shifted by `semitones`, preserving the MIDI mapping.
269 pub fn transpose(self, semitones: i32) -> Self {
270 Self::from_semitone(self.semitone() + semitones)
271 }
272
273 /// Returns the inversion of this pitch about `axis`.
274 pub fn invert(self, axis: Pitch) -> Self {
275 Self::from_semitone(2 * axis.semitone() - self.semitone())
276 }
277}
278
279/// A diatonic letter name, independent of accidental.
280#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
281pub enum Letter {
282 /// The letter C.
283 C,
284 /// The letter D.
285 D,
286 /// The letter E.
287 E,
288 /// The letter F.
289 F,
290 /// The letter G.
291 G,
292 /// The letter A.
293 A,
294 /// The letter B.
295 B,
296}
297
298/// A spelled pitch: a diatonic [`Letter`], a chromatic accidental, and an octave.
299///
300/// Unlike [`Pitch`], a spelled pitch retains its enharmonic spelling, so `Cs4`
301/// and `Db4` are distinct even though they map to the same [`Pitch`].
302#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
303pub struct SpelledPitch {
304 /// The diatonic letter name.
305 pub letter: Letter,
306 /// The accidental offset in semitones (positive for sharps, negative for flats).
307 pub accidental: i8,
308 /// The octave number, following the MIDI convention.
309 pub octave: i16,
310}
311
312impl SpelledPitch {
313 /// Resolves this spelled pitch to its octave-aware [`Pitch`], discarding the
314 /// enharmonic spelling.
315 pub fn to_pitch(self) -> Pitch {
316 let base = match self.letter {
317 Letter::C => 0,
318 Letter::D => 2,
319 Letter::E => 4,
320 Letter::F => 5,
321 Letter::G => 7,
322 Letter::A => 9,
323 Letter::B => 11,
324 };
325 Pitch {
326 class: PitchClass((base + self.accidental as i32).rem_euclid(12) as u8),
327 octave: self.octave,
328 }
329 }
330}
331
332/// A pitch interval measured in semitones.
333///
334/// Positive values are ascending and negative values descending. The signed
335/// distance is preserved; use [`Interval::class`] to collapse to an interval class.
336#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
337pub struct Interval {
338 /// The signed distance in semitones.
339 pub semitones: i32,
340}
341
342impl Interval {
343 /// The perfect unison (0 semitones).
344 pub const UNISON: Self = Self { semitones: 0 };
345 /// The minor third (3 semitones).
346 pub const MINOR_3: Self = Self { semitones: 3 };
347 /// The major third (4 semitones).
348 pub const MAJOR_3: Self = Self { semitones: 4 };
349 /// The perfect fifth (7 semitones).
350 pub const PERFECT_5: Self = Self { semitones: 7 };
351 /// The tritone (6 semitones).
352 pub const TRITONE: Self = Self { semitones: 6 };
353 /// The major seventh (11 semitones).
354 pub const MAJOR_7: Self = Self { semitones: 11 };
355
356 /// Returns the directed interval from `a` to `b`.
357 pub fn between(a: Pitch, b: Pitch) -> Self {
358 Self {
359 semitones: b.semitone() - a.semitone(),
360 }
361 }
362
363 /// Returns the interval class (0..=6) of this interval, the smaller of the
364 /// ascending and descending mod-12 distances.
365 pub fn class(self) -> u8 {
366 let delta = self.semitones.rem_euclid(12) as u8;
367 delta.min(12 - delta)
368 }
369}
370
371impl FromStr for Pitch {
372 type Err = PitchError;
373
374 fn from_str(value: &str) -> Result<Self, Self::Err> {
375 parse_pitch(value)
376 }
377}
378
379impl FromStr for Interval {
380 type Err = PitchError;
381
382 fn from_str(value: &str) -> Result<Self, Self::Err> {
383 parse_interval(value)
384 }
385}
386
387/// Parses a pitch spelling such as `"C4"`, `"Eb5"`, or `"Cs4"` into a [`Pitch`].
388///
389/// Accidentals accept `#` or `s` for sharp and `b` for flat; an octave number is
390/// required. Returns [`PitchError::InvalidPitch`] on malformed input.
391///
392/// # Examples
393///
394/// ```
395/// use sim_lib_pitch_core::{parse_pitch, Pitch};
396///
397/// assert_eq!(parse_pitch("Eb5").unwrap(), Pitch::from_semitone(75));
398/// ```
399pub fn parse_pitch(value: &str) -> Result<Pitch, PitchError> {
400 let mut chars = value.chars();
401 let letter = match chars.next() {
402 Some('C') => Letter::C,
403 Some('D') => Letter::D,
404 Some('E') => Letter::E,
405 Some('F') => Letter::F,
406 Some('G') => Letter::G,
407 Some('A') => Letter::A,
408 Some('B') => Letter::B,
409 _ => return Err(PitchError::InvalidPitch),
410 };
411 let rest = chars.as_str();
412 let (accidental, octave_str) = if let Some(rest) = rest.strip_prefix('#') {
413 (1, rest)
414 } else if let Some(rest) = rest.strip_prefix('s') {
415 (1, rest)
416 } else if let Some(rest) = rest.strip_prefix('b') {
417 (-1, rest)
418 } else {
419 (0, rest)
420 };
421 if octave_str.is_empty() {
422 return Err(PitchError::InvalidPitch);
423 }
424 let octave = octave_str
425 .parse::<i16>()
426 .map_err(|_| PitchError::InvalidPitch)?;
427 Ok(SpelledPitch {
428 letter,
429 accidental,
430 octave,
431 }
432 .to_pitch())
433}
434
435/// Parses one of the recognized interval tokens (`"P5"`, `"m3"`, `"M7"`, `"TT"`)
436/// into an [`Interval`].
437///
438/// Returns [`PitchError::InvalidInterval`] for any unrecognized token.
439pub fn parse_interval(value: &str) -> Result<Interval, PitchError> {
440 match value {
441 "P5" => Ok(Interval::PERFECT_5),
442 "m3" => Ok(Interval::MINOR_3),
443 "M7" => Ok(Interval::MAJOR_7),
444 "TT" => Ok(Interval::TRITONE),
445 _ => Err(PitchError::InvalidInterval),
446 }
447}