Skip to main content

sim_lib_pitch_scale/
model.rs

1use thiserror::Error;
2
3use sim_lib_pitch_core::{Pitch, PitchClass};
4use sim_lib_pitch_set::PitchClassMask;
5
6/// Error returned when a scale operation fails or a scale cannot be constructed.
7#[derive(Debug, Error, Clone, PartialEq, Eq)]
8pub enum PitchScaleError {
9    /// A pitch class was requested as a scale degree but is not part of the scale.
10    #[error("pitch class {0} is not in the scale")]
11    PitchClassOutOfScale(u8),
12    /// A custom scale was built from an empty interval list.
13    #[error("scale must contain at least one pitch class")]
14    EmptyScale,
15    /// A custom scale interval fell outside the valid `0..12` semitone range.
16    #[error("scale interval {0} is outside 0..12")]
17    InvalidScaleInterval(u8),
18    /// A one-based scale degree was zero or otherwise unsupported.
19    #[error("invalid scale degree {0}")]
20    InvalidScaleDegree(usize),
21}
22
23/// A scale mode, defined by its semitone interval pattern from the tonic.
24#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
25pub enum Mode {
26    /// The Ionian / major scale.
27    Major,
28    /// The natural minor scale (equivalent to [`Mode::Aeolian`]).
29    MinorNatural,
30    /// The harmonic minor scale (raised seventh).
31    MinorHarmonic,
32    /// The ascending melodic minor scale (raised sixth and seventh).
33    MinorMelodic,
34    /// The Dorian mode.
35    Dorian,
36    /// The Phrygian mode.
37    Phrygian,
38    /// The Lydian mode.
39    Lydian,
40    /// The Mixolydian mode.
41    Mixolydian,
42    /// The Aeolian mode (equivalent to [`Mode::MinorNatural`]).
43    Aeolian,
44    /// The Locrian mode.
45    Locrian,
46    /// The symmetric whole-tone scale (six notes).
47    WholeTone,
48    /// The symmetric octatonic / diminished scale (eight notes).
49    Diminished,
50    /// The full chromatic scale (all twelve pitch classes).
51    Chromatic,
52}
53
54impl Mode {
55    /// Returns the mode's semitone offsets from the tonic, in ascending order.
56    pub fn intervals(self) -> &'static [u8] {
57        match self {
58            Self::Major => &[0, 2, 4, 5, 7, 9, 11],
59            Self::MinorNatural | Self::Aeolian => &[0, 2, 3, 5, 7, 8, 10],
60            Self::MinorHarmonic => &[0, 2, 3, 5, 7, 8, 11],
61            Self::MinorMelodic => &[0, 2, 3, 5, 7, 9, 11],
62            Self::Dorian => &[0, 2, 3, 5, 7, 9, 10],
63            Self::Phrygian => &[0, 1, 3, 5, 7, 8, 10],
64            Self::Lydian => &[0, 2, 4, 6, 7, 9, 11],
65            Self::Mixolydian => &[0, 2, 4, 5, 7, 9, 10],
66            Self::Locrian => &[0, 1, 3, 5, 6, 8, 10],
67            Self::WholeTone => &[0, 2, 4, 6, 8, 10],
68            Self::Diminished => &[0, 2, 3, 5, 6, 8, 9, 11],
69            Self::Chromatic => &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
70        }
71    }
72
73    /// Returns the canonical lowercase name of the mode (for example `"dorian"`).
74    pub fn name(self) -> &'static str {
75        match self {
76            Self::Major => "major",
77            Self::MinorNatural => "minor-natural",
78            Self::MinorHarmonic => "minor-harmonic",
79            Self::MinorMelodic => "minor-melodic",
80            Self::Dorian => "dorian",
81            Self::Phrygian => "phrygian",
82            Self::Lydian => "lydian",
83            Self::Mixolydian => "mixolydian",
84            Self::Aeolian => "aeolian",
85            Self::Locrian => "locrian",
86            Self::WholeTone => "whole-tone",
87            Self::Diminished => "diminished",
88            Self::Chromatic => "chromatic",
89        }
90    }
91}
92
93/// A musical key: a tonic pitch class paired with a [`Mode`].
94#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
95pub struct Key {
96    /// The tonic pitch class.
97    pub tonic: PitchClass,
98    /// The mode of the key.
99    pub mode: Mode,
100}
101
102/// A concrete scale: a [`Mode`] anchored to a tonic pitch class.
103///
104/// # Examples
105///
106/// ```
107/// use sim_lib_pitch_core::PitchClass;
108/// use sim_lib_pitch_scale::Scale;
109///
110/// let c_major = Scale::major(PitchClass::C);
111/// assert_eq!(c_major.degree_of(PitchClass::G), Some(5));
112/// ```
113#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
114pub struct Scale {
115    /// The tonic pitch class.
116    pub tonic: PitchClass,
117    /// The mode of the scale.
118    pub mode: Mode,
119}
120
121impl Scale {
122    /// Constructs a scale from a `tonic` and `mode`.
123    pub const fn new(tonic: PitchClass, mode: Mode) -> Self {
124        Self { tonic, mode }
125    }
126
127    /// Constructs a major scale on `tonic`.
128    pub const fn major(tonic: PitchClass) -> Self {
129        Self::new(tonic, Mode::Major)
130    }
131    /// Constructs a natural minor scale on `tonic`.
132    pub const fn minor_natural(tonic: PitchClass) -> Self {
133        Self::new(tonic, Mode::MinorNatural)
134    }
135    /// Constructs a harmonic minor scale on `tonic`.
136    pub const fn minor_harmonic(tonic: PitchClass) -> Self {
137        Self::new(tonic, Mode::MinorHarmonic)
138    }
139    /// Constructs a melodic minor scale on `tonic`.
140    pub const fn minor_melodic(tonic: PitchClass) -> Self {
141        Self::new(tonic, Mode::MinorMelodic)
142    }
143    /// Constructs a Dorian scale on `tonic`.
144    pub const fn dorian(tonic: PitchClass) -> Self {
145        Self::new(tonic, Mode::Dorian)
146    }
147    /// Constructs a Phrygian scale on `tonic`.
148    pub const fn phrygian(tonic: PitchClass) -> Self {
149        Self::new(tonic, Mode::Phrygian)
150    }
151    /// Constructs a Lydian scale on `tonic`.
152    pub const fn lydian(tonic: PitchClass) -> Self {
153        Self::new(tonic, Mode::Lydian)
154    }
155    /// Constructs a Mixolydian scale on `tonic`.
156    pub const fn mixolydian(tonic: PitchClass) -> Self {
157        Self::new(tonic, Mode::Mixolydian)
158    }
159    /// Constructs an Aeolian scale on `tonic`.
160    pub const fn aeolian(tonic: PitchClass) -> Self {
161        Self::new(tonic, Mode::Aeolian)
162    }
163    /// Constructs a Locrian scale on `tonic`.
164    pub const fn locrian(tonic: PitchClass) -> Self {
165        Self::new(tonic, Mode::Locrian)
166    }
167    /// Constructs a whole-tone scale on `tonic`.
168    pub const fn whole_tone(tonic: PitchClass) -> Self {
169        Self::new(tonic, Mode::WholeTone)
170    }
171    /// Constructs a diminished (octatonic) scale on `tonic`.
172    pub const fn diminished(tonic: PitchClass) -> Self {
173        Self::new(tonic, Mode::Diminished)
174    }
175    /// Constructs a chromatic scale on `tonic`.
176    pub const fn chromatic(tonic: PitchClass) -> Self {
177        Self::new(tonic, Mode::Chromatic)
178    }
179
180    /// Returns the scale's pitch classes in ascending degree order from the tonic.
181    pub fn pitch_classes(self) -> Vec<PitchClass> {
182        self.mode
183            .intervals()
184            .iter()
185            .map(|step| self.tonic.transpose(i32::from(*step)))
186            .collect()
187    }
188
189    /// Returns the scale's pitch classes as a [`PitchClassMask`].
190    pub fn mask(self) -> PitchClassMask {
191        PitchClassMask::from_pitch_classes(&self.pitch_classes())
192    }
193
194    /// Returns the one-based scale degree of `pitch_class`, or `None` if it is not
195    /// in the scale.
196    pub fn degree_of(self, pitch_class: PitchClass) -> Option<usize> {
197        self.pitch_classes()
198            .iter()
199            .position(|candidate| *candidate == pitch_class)
200            .map(|index| index + 1)
201    }
202
203    /// Returns the pitch class at the one-based `degree`, wrapping past the octave.
204    pub fn pitch_at_degree(self, degree: usize) -> Result<PitchClass, PitchScaleError> {
205        self.try_pitch_at_degree(degree)
206    }
207
208    /// Returns the pitch class at the one-based `degree`, wrapping past the octave.
209    pub fn try_pitch_at_degree(self, degree: usize) -> Result<PitchClass, PitchScaleError> {
210        let index = degree
211            .checked_sub(1)
212            .ok_or(PitchScaleError::InvalidScaleDegree(degree))?;
213        let intervals = self.mode.intervals();
214        Ok(self
215            .tonic
216            .transpose(i32::from(intervals[index % intervals.len()])))
217    }
218
219    /// Transposes `pitch` by `steps` scale degrees, staying within the scale and
220    /// adjusting octaves as needed.
221    ///
222    /// Returns [`PitchScaleError::PitchClassOutOfScale`] if `pitch` is not a member
223    /// of the scale.
224    pub fn transpose_diatonic(self, pitch: Pitch, steps: i32) -> Result<Pitch, PitchScaleError> {
225        let degree = self
226            .degree_of(pitch.class)
227            .ok_or(PitchScaleError::PitchClassOutOfScale(pitch.class.value()))?;
228        let intervals = self.mode.intervals();
229        let start = degree as i32 - 1;
230        let target = start + steps;
231        let width = intervals.len() as i32;
232        let octave_delta = target.div_euclid(width);
233        let target_index = target.rem_euclid(width) as usize;
234        let start_semitones = i32::from(intervals[start as usize]);
235        let end_semitones = i32::from(intervals[target_index]) + octave_delta * 12;
236        Ok(pitch.transpose(end_semitones - start_semitones))
237    }
238
239    /// Maps a one-based chord-tone index (root, third, fifth, ...) to the
240    /// one-based scale degree it occupies in tertian stacking.
241    pub fn chord_tone_to_scale_tone(chord_tone: usize) -> Result<usize, PitchScaleError> {
242        let offset = chord_tone
243            .checked_sub(1)
244            .ok_or(PitchScaleError::InvalidScaleDegree(chord_tone))?;
245        offset
246            .checked_mul(2)
247            .and_then(|value| value.checked_add(1))
248            .ok_or(PitchScaleError::InvalidScaleDegree(chord_tone))
249    }
250
251    /// Maps a one-based scale degree to its zero-based diatonic step offset from
252    /// the tonic.
253    pub fn scale_tone_to_diatonic(scale_tone: usize) -> Result<i32, PitchScaleError> {
254        let offset = scale_tone
255            .checked_sub(1)
256            .ok_or(PitchScaleError::InvalidScaleDegree(scale_tone))?;
257        i32::try_from(offset).map_err(|_| PitchScaleError::InvalidScaleDegree(scale_tone))
258    }
259}