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