Skip to main content

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}