sim-lib-pitch-core 0.2.0

Pitch classes, octave-aware pitches, intervals, and MIDI conversion helpers for SIM music crates.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
use std::{num::NonZeroU16, str::FromStr};

use thiserror::Error;

/// Error returned when a pitch, pitch class, or interval cannot be constructed
/// or parsed.
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum PitchError {
    /// A pitch-class value of 12 or greater was supplied where only `0..12` is valid.
    #[error("invalid pitch class {0}")]
    InvalidPitchClass(u8),
    /// A pitch spelling could not be parsed into a letter, accidental, and octave.
    #[error("invalid pitch spelling")]
    InvalidPitch,
    /// An interval spelling was not one of the recognized tokens.
    #[error("invalid interval spelling")]
    InvalidInterval,
    /// An octave-space division count was zero.
    #[error("invalid octave-space division count {0}")]
    InvalidOctaveSpace(u16),
}

/// A positive modular division count for octave-like pitch spaces.
///
/// [`PitchClass`] and [`Pitch`] remain fixed to the canonical 12-class,
/// MIDI-compatible pitch identity. `OctaveSpace` is for algorithms that need
/// floor decomposition or circular distance in another positive division count.
///
/// # Examples
///
/// ```
/// use sim_lib_pitch_core::{split_floor, OctaveSpace};
///
/// let twelve = OctaveSpace::new(12).unwrap();
/// assert_eq!(split_floor(-13, twelve), (-2, 11));
/// ```
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub struct OctaveSpace {
    /// Positive divisions in one octave-like cycle.
    pub divisions: NonZeroU16,
}

impl OctaveSpace {
    /// Constructs an octave space, rejecting zero-sized spaces.
    pub fn new(divisions: u16) -> Result<Self, PitchError> {
        let divisions =
            NonZeroU16::new(divisions).ok_or(PitchError::InvalidOctaveSpace(divisions))?;
        Ok(Self { divisions })
    }

    /// Returns the canonical 12-division semitone space.
    pub fn twelve_tone() -> Self {
        Self {
            divisions: NonZeroU16::new(12).expect("12 is non-zero"),
        }
    }

    /// Returns the positive division count.
    pub fn len(self) -> u16 {
        self.divisions.get()
    }

    /// Returns `true` because an [`OctaveSpace`] is always non-empty.
    pub fn is_empty(self) -> bool {
        false
    }
}

/// Direction used when a folded distance has two equally short paths.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub enum TieDirection {
    /// Choose the ascending path.
    Ascending,
    /// Choose the descending path.
    Descending,
}

/// Splits an integer value into a floor octave and folded class for `space`.
///
/// The returned class is always in `0..space.len()`, including for negative
/// inputs.
pub fn split_floor(value: i64, space: OctaveSpace) -> (i64, u16) {
    let divisions = i64::from(space.len());
    (
        value.div_euclid(divisions),
        value.rem_euclid(divisions) as u16,
    )
}

/// Folds an integer value into `0..space.len()` using floor modulus.
pub fn fold(value: i64, space: OctaveSpace) -> u16 {
    split_floor(value, space).1
}

/// Returns the unsigned shortest circular distance between two values in `space`.
pub fn folded_unsigned_distance(a: i64, b: i64, space: OctaveSpace) -> u16 {
    let divisions = i128::from(space.len());
    let ascending = (i128::from(b) - i128::from(a)).rem_euclid(divisions);
    ascending.min(divisions - ascending) as u16
}

/// Returns the signed shortest circular distance from `a` to `b` in `space`.
///
/// Positive values move upward and negative values move downward. When the space
/// has an even division count and the two paths are equally short,
/// `tie` selects the sign.
pub fn folded_distance(a: i64, b: i64, space: OctaveSpace, tie: TieDirection) -> i32 {
    let divisions = i128::from(space.len());
    let ascending = (i128::from(b) - i128::from(a)).rem_euclid(divisions);
    let descending = ascending - divisions;
    match ascending.cmp(&(-descending)) {
        std::cmp::Ordering::Less => ascending as i32,
        std::cmp::Ordering::Greater => descending as i32,
        std::cmp::Ordering::Equal => match tie {
            TieDirection::Ascending => ascending as i32,
            TieDirection::Descending => descending as i32,
        },
    }
}

/// A mod-12 pitch class, where `C = 0` and values increase by semitone to `B = 11`.
///
/// Pitch classes are octave-agnostic: every C, regardless of register, shares the
/// pitch class `C`. The inner `u8` is always in the range `0..12`.
///
/// # Examples
///
/// ```
/// use sim_lib_pitch_core::PitchClass;
///
/// assert_eq!(PitchClass::C.transpose(7), PitchClass::G);
/// assert_eq!(PitchClass::E.interval_class(PitchClass::C), 4);
/// ```
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct PitchClass(u8);

impl PitchClass {
    /// The pitch class C (0).
    pub const C: Self = Self(0);
    /// The pitch class C-sharp / D-flat (1).
    pub const CS: Self = Self(1);
    /// The pitch class D (2).
    pub const D: Self = Self(2);
    /// The pitch class D-sharp / E-flat (3).
    pub const DS: Self = Self(3);
    /// The pitch class E (4).
    pub const E: Self = Self(4);
    /// The pitch class F (5).
    pub const F: Self = Self(5);
    /// The pitch class F-sharp / G-flat (6).
    pub const FS: Self = Self(6);
    /// The pitch class G (7).
    pub const G: Self = Self(7);
    /// The pitch class G-sharp / A-flat (8).
    pub const GS: Self = Self(8);
    /// The pitch class A (9).
    pub const A: Self = Self(9);
    /// The pitch class A-sharp / B-flat (10).
    pub const AS: Self = Self(10);
    /// The pitch class B (11).
    pub const B: Self = Self(11);

    /// Constructs a pitch class from a raw value, rejecting values of 12 or more.
    pub fn new(value: u8) -> Result<Self, PitchError> {
        if value < 12 {
            Ok(Self(value))
        } else {
            Err(PitchError::InvalidPitchClass(value))
        }
    }

    /// Returns the raw mod-12 pitch-class value.
    pub const fn value(self) -> u8 {
        self.0
    }

    /// Returns this pitch class shifted up by `semitones` (or down if negative),
    /// wrapping within the mod-12 octave.
    pub fn transpose(self, semitones: i32) -> Self {
        Self(((self.0 as i32 + semitones).rem_euclid(12)) as u8)
    }

    /// Returns the inversion of this pitch class about `axis`, wrapping within the
    /// mod-12 octave.
    pub fn invert(self, axis: PitchClass) -> Self {
        Self(((2 * axis.0 as i32 - self.0 as i32).rem_euclid(12)) as u8)
    }

    /// Returns the interval class (0..=6) between this pitch class and `other`,
    /// the smaller of the ascending and descending distances.
    pub fn interval_class(self, other: PitchClass) -> u8 {
        let delta = (other.0 as i32 - self.0 as i32).rem_euclid(12) as u8;
        delta.min(12 - delta)
    }

    /// Returns the canonical sharp-spelled name of this pitch class (for example
    /// `"C#"` for pitch class 1).
    pub fn canonical_name(self) -> &'static str {
        match self.0 {
            0 => "C",
            1 => "C#",
            2 => "D",
            3 => "D#",
            4 => "E",
            5 => "F",
            6 => "F#",
            7 => "G",
            8 => "G#",
            9 => "A",
            10 => "A#",
            11 => "B",
            _ => unreachable!(),
        }
    }
}

/// An octave-aware pitch: a [`PitchClass`] together with an octave number.
///
/// The octave follows the MIDI convention in which middle C (`C4`) is MIDI note
/// 60, so [`Pitch::semitone`] returns a continuous semitone index across octaves.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub struct Pitch {
    /// The mod-12 pitch class.
    pub class: PitchClass,
    /// The octave number, with `C4` (MIDI 60) in octave 4.
    pub octave: i16,
}

impl PartialOrd for Pitch {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for Pitch {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        self.semitone().cmp(&other.semitone())
    }
}

impl Pitch {
    /// Returns the absolute semitone index of this pitch, where MIDI 60 (`C4`) is 60.
    pub fn semitone(self) -> i32 {
        (i32::from(self.octave) + 1) * 12 + i32::from(self.class.value())
    }

    /// Constructs a pitch from an absolute semitone index, the inverse of
    /// [`Pitch::semitone`].
    pub fn from_semitone(semitone: i32) -> Self {
        Self {
            class: PitchClass(semitone.rem_euclid(12) as u8),
            octave: (semitone.div_euclid(12) - 1) as i16,
        }
    }

    /// Returns the MIDI note number for this pitch, or `None` if it falls outside
    /// the playable range `0..=127`.
    pub fn to_midi(self) -> Option<u8> {
        let semitone = self.semitone();
        (0..=127).contains(&semitone).then_some(semitone as u8)
    }

    /// Constructs a pitch from a MIDI note number.
    pub fn from_midi(value: u8) -> Self {
        Self::from_semitone(value as i32)
    }

    /// Returns this pitch shifted by `semitones`, preserving the MIDI mapping.
    pub fn transpose(self, semitones: i32) -> Self {
        Self::from_semitone(self.semitone() + semitones)
    }

    /// Returns the inversion of this pitch about `axis`.
    pub fn invert(self, axis: Pitch) -> Self {
        Self::from_semitone(2 * axis.semitone() - self.semitone())
    }
}

/// A diatonic letter name, independent of accidental.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub enum Letter {
    /// The letter C.
    C,
    /// The letter D.
    D,
    /// The letter E.
    E,
    /// The letter F.
    F,
    /// The letter G.
    G,
    /// The letter A.
    A,
    /// The letter B.
    B,
}

/// A spelled pitch: a diatonic [`Letter`], a chromatic accidental, and an octave.
///
/// Unlike [`Pitch`], a spelled pitch retains its enharmonic spelling, so `Cs4`
/// and `Db4` are distinct even though they map to the same [`Pitch`].
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub struct SpelledPitch {
    /// The diatonic letter name.
    pub letter: Letter,
    /// The accidental offset in semitones (positive for sharps, negative for flats).
    pub accidental: i8,
    /// The octave number, following the MIDI convention.
    pub octave: i16,
}

impl SpelledPitch {
    /// Resolves this spelled pitch to its octave-aware [`Pitch`], discarding the
    /// enharmonic spelling.
    pub fn to_pitch(self) -> Pitch {
        let base = match self.letter {
            Letter::C => 0,
            Letter::D => 2,
            Letter::E => 4,
            Letter::F => 5,
            Letter::G => 7,
            Letter::A => 9,
            Letter::B => 11,
        };
        Pitch {
            class: PitchClass((base + self.accidental as i32).rem_euclid(12) as u8),
            octave: self.octave,
        }
    }
}

/// A pitch interval measured in semitones.
///
/// Positive values are ascending and negative values descending. The signed
/// distance is preserved; use [`Interval::class`] to collapse to an interval class.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub struct Interval {
    /// The signed distance in semitones.
    pub semitones: i32,
}

impl Interval {
    /// The perfect unison (0 semitones).
    pub const UNISON: Self = Self { semitones: 0 };
    /// The minor third (3 semitones).
    pub const MINOR_3: Self = Self { semitones: 3 };
    /// The major third (4 semitones).
    pub const MAJOR_3: Self = Self { semitones: 4 };
    /// The perfect fifth (7 semitones).
    pub const PERFECT_5: Self = Self { semitones: 7 };
    /// The tritone (6 semitones).
    pub const TRITONE: Self = Self { semitones: 6 };
    /// The major seventh (11 semitones).
    pub const MAJOR_7: Self = Self { semitones: 11 };

    /// Returns the directed interval from `a` to `b`.
    pub fn between(a: Pitch, b: Pitch) -> Self {
        Self {
            semitones: b.semitone() - a.semitone(),
        }
    }

    /// Returns the interval class (0..=6) of this interval, the smaller of the
    /// ascending and descending mod-12 distances.
    pub fn class(self) -> u8 {
        let delta = self.semitones.rem_euclid(12) as u8;
        delta.min(12 - delta)
    }
}

impl FromStr for Pitch {
    type Err = PitchError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        parse_pitch(value)
    }
}

impl FromStr for Interval {
    type Err = PitchError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        parse_interval(value)
    }
}

/// Parses a pitch spelling such as `"C4"`, `"Eb5"`, or `"Cs4"` into a [`Pitch`].
///
/// Accidentals accept `#` or `s` for sharp and `b` for flat; an octave number is
/// required. Returns [`PitchError::InvalidPitch`] on malformed input.
///
/// # Examples
///
/// ```
/// use sim_lib_pitch_core::{parse_pitch, Pitch};
///
/// assert_eq!(parse_pitch("Eb5").unwrap(), Pitch::from_semitone(75));
/// ```
pub fn parse_pitch(value: &str) -> Result<Pitch, PitchError> {
    let mut chars = value.chars();
    let letter = match chars.next() {
        Some('C') => Letter::C,
        Some('D') => Letter::D,
        Some('E') => Letter::E,
        Some('F') => Letter::F,
        Some('G') => Letter::G,
        Some('A') => Letter::A,
        Some('B') => Letter::B,
        _ => return Err(PitchError::InvalidPitch),
    };
    let rest = chars.as_str();
    let (accidental, octave_str) = if let Some(rest) = rest.strip_prefix('#') {
        (1, rest)
    } else if let Some(rest) = rest.strip_prefix('s') {
        (1, rest)
    } else if let Some(rest) = rest.strip_prefix('b') {
        (-1, rest)
    } else {
        (0, rest)
    };
    if octave_str.is_empty() {
        return Err(PitchError::InvalidPitch);
    }
    let octave = octave_str
        .parse::<i16>()
        .map_err(|_| PitchError::InvalidPitch)?;
    Ok(SpelledPitch {
        letter,
        accidental,
        octave,
    }
    .to_pitch())
}

/// Parses one of the recognized interval tokens (`"P5"`, `"m3"`, `"M7"`, `"TT"`)
/// into an [`Interval`].
///
/// Returns [`PitchError::InvalidInterval`] for any unrecognized token.
pub fn parse_interval(value: &str) -> Result<Interval, PitchError> {
    match value {
        "P5" => Ok(Interval::PERFECT_5),
        "m3" => Ok(Interval::MINOR_3),
        "M7" => Ok(Interval::MAJOR_7),
        "TT" => Ok(Interval::TRITONE),
        _ => Err(PitchError::InvalidInterval),
    }
}