Skip to main content

music21_rs/scale/
scaletype.rs

1//! The named scales music21 exposes as `ConcreteScale` subclasses.
2//!
3//! Each scale is a sequence of step intervals walked from the tonic (upward,
4//! except for the one descending step Rag Marwa's network contains),
5//! matching the edges of music21's `IntervalNetwork`, plus the pitch
6//! simplification that network applies. Both together are needed: the steps
7//! alone give the right pitch classes but the wrong spelling for scales that
8//! run out of reasonable accidentals, which is why a C whole-tone scale ends
9//! `A#` rather than `B-` but a B whole-tone scale does not end `A##`.
10
11use crate::error::Result;
12use crate::interval::Interval;
13use crate::pitch::Pitch;
14
15use std::collections::HashMap;
16use std::sync::LazyLock;
17
18/// How a scale respells pitches that would otherwise pile up accidentals.
19///
20/// Mirrors music21's `IntervalNetwork.pitchSimplification`.
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22enum Simplification {
23    /// Spell literally, however many accidentals that takes.
24    Exact,
25    /// Cap at one accidental, respelling anything beyond it.
26    MaxAccidental,
27    /// Respell to the most common spelling of the pitch class.
28    MostCommon,
29}
30
31/// Distinct step intervals used by the scale tables, parsed once.
32static STEP_INTERVALS: LazyLock<HashMap<&'static str, Interval>> = LazyLock::new(|| {
33    ["m2", "M2", "a2", "m3", "M3", "-M2"]
34        .into_iter()
35        .map(|name| {
36            let interval =
37                Interval::from_name(name).expect("scale step intervals are valid interval names");
38            (name, interval)
39        })
40        .collect()
41});
42
43fn step_interval(name: &str) -> &'static Interval {
44    STEP_INTERVALS
45        .get(name)
46        .expect("scale tables only use intervals listed in STEP_INTERVALS")
47}
48
49/// A named scale from music21's scale module.
50///
51/// Ordered as music21 defines them: the seven church modes, their plagal
52/// counterparts, the altered minors, then the symmetrical and non-Western
53/// scales.
54#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
55#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
56#[non_exhaustive]
57pub enum ScaleType {
58    /// Major (Ionian).
59    Major,
60    /// Natural minor (Aeolian).
61    Minor,
62    /// Dorian mode.
63    Dorian,
64    /// Phrygian mode.
65    Phrygian,
66    /// Lydian mode.
67    Lydian,
68    /// Mixolydian mode.
69    Mixolydian,
70    /// Locrian mode.
71    Locrian,
72    /// Hypodorian mode. Shares Dorian's pitches; the ambitus differs.
73    Hypodorian,
74    /// Hypophrygian mode. Shares Phrygian's pitches.
75    Hypophrygian,
76    /// Hypolydian mode. Shares Lydian's pitches.
77    Hypolydian,
78    /// Hypomixolydian mode. Shares Mixolydian's pitches.
79    Hypomixolydian,
80    /// Hypolocrian mode. Shares Locrian's pitches.
81    Hypolocrian,
82    /// Hypoaeolian mode. Shares natural minor's pitches.
83    Hypoaeolian,
84    /// Harmonic minor, with a raised seventh.
85    HarmonicMinor,
86    /// Ascending melodic minor.
87    MelodicMinor,
88    /// Twelve-tone chromatic scale.
89    Chromatic,
90    /// Six-tone whole-tone scale.
91    WholeTone,
92    /// Eight-tone octatonic scale, alternating tone and semitone.
93    Octatonic,
94    /// Rag Asawari, as a five-tone ascending scale.
95    RagAsawari,
96    /// Rag Marwa, as a seven-step ascending scale.
97    ///
98    /// Not monotonic: music21's ascending network dips back down a major
99    /// second from the sixth degree before rising a minor third to the octave,
100    /// so the realized pitches repeat a note and briefly descend.
101    RagMarwa,
102}
103
104impl ScaleType {
105    /// Every scale type, in declaration order.
106    pub const ALL: [ScaleType; 20] = [
107        Self::Major,
108        Self::Minor,
109        Self::Dorian,
110        Self::Phrygian,
111        Self::Lydian,
112        Self::Mixolydian,
113        Self::Locrian,
114        Self::Hypodorian,
115        Self::Hypophrygian,
116        Self::Hypolydian,
117        Self::Hypomixolydian,
118        Self::Hypolocrian,
119        Self::Hypoaeolian,
120        Self::HarmonicMinor,
121        Self::MelodicMinor,
122        Self::Chromatic,
123        Self::WholeTone,
124        Self::Octatonic,
125        Self::RagAsawari,
126        Self::RagMarwa,
127    ];
128
129    /// Returns the music21 class name for this scale.
130    pub fn music21_name(self) -> &'static str {
131        match self {
132            Self::Major => "MajorScale",
133            Self::Minor => "MinorScale",
134            Self::Dorian => "DorianScale",
135            Self::Phrygian => "PhrygianScale",
136            Self::Lydian => "LydianScale",
137            Self::Mixolydian => "MixolydianScale",
138            Self::Locrian => "LocrianScale",
139            Self::Hypodorian => "HypodorianScale",
140            Self::Hypophrygian => "HypophrygianScale",
141            Self::Hypolydian => "HypolydianScale",
142            Self::Hypomixolydian => "HypomixolydianScale",
143            Self::Hypolocrian => "HypolocrianScale",
144            Self::Hypoaeolian => "HypoaeolianScale",
145            Self::HarmonicMinor => "HarmonicMinorScale",
146            Self::MelodicMinor => "MelodicMinorScale",
147            Self::Chromatic => "ChromaticScale",
148            Self::WholeTone => "WholeToneScale",
149            Self::Octatonic => "OctatonicScale",
150            Self::RagAsawari => "RagAsawari",
151            Self::RagMarwa => "RagMarwa",
152        }
153    }
154
155    /// Returns the step intervals walked from the tonic.
156    ///
157    /// Almost every scale ascends throughout; Rag Marwa is the exception, and
158    /// carries one descending step (`-M2`), matching its network edge.
159    ///
160    /// These are music21's `IntervalNetwork` edges, not the intervals between
161    /// the pitches it finally reports — the two differ wherever simplification
162    /// respells a degree.
163    fn steps(self) -> &'static [&'static str] {
164        match self {
165            Self::Major => &["M2", "M2", "m2", "M2", "M2", "M2", "m2"],
166            Self::Minor | Self::Hypoaeolian => &["M2", "m2", "M2", "M2", "m2", "M2", "M2"],
167            Self::Dorian => &["M2", "m2", "M2", "M2", "M2", "m2", "M2"],
168            Self::Phrygian => &["m2", "M2", "M2", "M2", "m2", "M2", "M2"],
169            Self::Lydian => &["M2", "M2", "M2", "m2", "M2", "M2", "m2"],
170            Self::Mixolydian => &["M2", "M2", "m2", "M2", "M2", "m2", "M2"],
171            Self::Locrian => &["m2", "M2", "M2", "m2", "M2", "M2", "M2"],
172            Self::Hypodorian => &["M2", "m2", "M2", "M2", "M2", "m2", "M2"],
173            Self::Hypophrygian => &["m2", "M2", "M2", "M2", "m2", "M2", "M2"],
174            Self::Hypolydian => &["M2", "M2", "M2", "m2", "M2", "M2", "m2"],
175            Self::Hypomixolydian => &["M2", "M2", "m2", "M2", "M2", "m2", "M2"],
176            Self::Hypolocrian => &["m2", "M2", "M2", "m2", "M2", "M2", "M2"],
177            Self::HarmonicMinor => &["M2", "m2", "M2", "M2", "m2", "a2", "m2"],
178            Self::MelodicMinor => &["M2", "m2", "M2", "M2", "M2", "M2", "m2"],
179            Self::Chromatic => &["m2"; 12],
180            Self::WholeTone => &["M2"; 6],
181            Self::Octatonic => &["M2", "m2", "M2", "m2", "M2", "m2", "M2", "m2"],
182            Self::RagAsawari => &["M2", "m3", "M2", "m2", "M3"],
183            // The sixth step is music21's `M-2` edge: a descending major
184            // second inside an otherwise ascending network.
185            Self::RagMarwa => &["m2", "a2", "M2", "m3", "M2", "-M2", "m3"],
186        }
187    }
188
189    /// Returns how this scale respells pitches, matching music21's network.
190    fn simplification(self) -> Simplification {
191        match self {
192            Self::WholeTone | Self::Octatonic | Self::RagMarwa => Simplification::MaxAccidental,
193            Self::Chromatic | Self::RagAsawari => Simplification::MostCommon,
194            _ => Simplification::Exact,
195        }
196    }
197
198    /// Returns the number of distinct degrees in one octave.
199    pub fn degree_count(self) -> usize {
200        self.steps().len()
201    }
202}
203
204/// A named scale realized from a tonic pitch.
205///
206/// ```
207/// use music21_rs::{Pitch, Scale, ScaleType};
208///
209/// let scale = Scale::new(ScaleType::Octatonic, Pitch::from_name("C4")?);
210/// let names: Vec<String> = scale.pitches()?.iter().map(|p| p.name()).collect();
211///
212/// assert_eq!(names, ["C", "D", "E-", "F", "G-", "A-", "A", "B", "C"]);
213/// # Ok::<(), music21_rs::Error>(())
214/// ```
215#[derive(Clone, Debug, PartialEq)]
216#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
217pub struct Scale {
218    scale_type: ScaleType,
219    tonic: Pitch,
220}
221
222impl Scale {
223    /// Builds a scale of the given type on a tonic.
224    pub fn new(scale_type: ScaleType, tonic: Pitch) -> Self {
225        Self { scale_type, tonic }
226    }
227
228    /// Returns the scale type.
229    pub fn scale_type(&self) -> ScaleType {
230        self.scale_type
231    }
232
233    /// Returns the tonic pitch.
234    pub fn tonic(&self) -> &Pitch {
235        &self.tonic
236    }
237
238    /// Returns the pitches of one octave, from the tonic through its octave.
239    ///
240    /// The result has `degree_count() + 1` entries, since the closing octave is
241    /// included the way music21's `getPitches` includes it.
242    pub fn pitches(&self) -> Result<Vec<Pitch>> {
243        let simplification = self.scale_type.simplification();
244        let mut pitches = Vec::with_capacity(self.scale_type.degree_count() + 1);
245        pitches.push(self.tonic.clone());
246
247        let mut current = self.tonic.clone();
248        for step in self.scale_type.steps() {
249            current = advance(&current, step, simplification)?;
250            pitches.push(current.clone());
251        }
252        Ok(pitches)
253    }
254
255    /// Returns the pitch at a one-based scale degree.
256    ///
257    /// Degree 1 is the tonic and `degree_count() + 1` is the octave above it.
258    /// Degrees beyond that continue into higher octaves.
259    pub fn pitch_at_degree(&self, degree: usize) -> Result<Pitch> {
260        if degree == 0 {
261            return Err(crate::error::Error::Ordinal(
262                "scale degree must be >= 1".to_string(),
263            ));
264        }
265
266        let simplification = self.scale_type.simplification();
267        let steps = self.scale_type.steps();
268        let mut current = self.tonic.clone();
269        for index in 0..(degree - 1) {
270            current = advance(&current, steps[index % steps.len()], simplification)?;
271        }
272        Ok(current)
273    }
274}
275
276/// Transposes one scale step, applying the scale's simplification.
277fn advance(pitch: &Pitch, step: &str, simplification: Simplification) -> Result<Pitch> {
278    let interval = step_interval(step);
279    match simplification {
280        Simplification::MaxAccidental => {
281            interval.transpose_pitch_with_options(pitch, false, Some(1))
282        }
283        Simplification::Exact => interval.transpose_pitch_with_options(pitch, false, None),
284        Simplification::MostCommon => {
285            let mut transposed = interval.transpose_pitch_with_options(pitch, false, None)?;
286            if transposed.accidental().alter() != 0.0 {
287                transposed.simplify_enharmonic_in_place(true)?;
288            }
289            Ok(transposed)
290        }
291    }
292}
293
294/// Returns the maximum accidental count used by a scale, for tests.
295#[cfg(test)]
296fn max_alter(pitches: &[Pitch]) -> crate::defaults::IntegerType {
297    pitches
298        .iter()
299        .map(|pitch| pitch.accidental().alter().abs() as crate::defaults::IntegerType)
300        .max()
301        .unwrap_or(0)
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    fn names(scale_type: ScaleType, tonic: &str) -> Vec<String> {
309        Scale::new(scale_type, Pitch::from_name(tonic).expect("valid tonic"))
310            .pitches()
311            .expect("scale realizes")
312            .iter()
313            .map(|pitch| pitch.name())
314            .collect()
315    }
316
317    #[test]
318    fn realizes_the_church_modes_on_c() {
319        assert_eq!(
320            names(ScaleType::Major, "C4"),
321            ["C", "D", "E", "F", "G", "A", "B", "C"]
322        );
323        assert_eq!(
324            names(ScaleType::Minor, "C4"),
325            ["C", "D", "E-", "F", "G", "A-", "B-", "C"]
326        );
327        assert_eq!(
328            names(ScaleType::Dorian, "C4"),
329            ["C", "D", "E-", "F", "G", "A", "B-", "C"]
330        );
331        assert_eq!(
332            names(ScaleType::Phrygian, "C4"),
333            ["C", "D-", "E-", "F", "G", "A-", "B-", "C"]
334        );
335        assert_eq!(
336            names(ScaleType::Lydian, "C4"),
337            ["C", "D", "E", "F#", "G", "A", "B", "C"]
338        );
339        assert_eq!(
340            names(ScaleType::Mixolydian, "C4"),
341            ["C", "D", "E", "F", "G", "A", "B-", "C"]
342        );
343        assert_eq!(
344            names(ScaleType::Locrian, "C4"),
345            ["C", "D-", "E-", "F", "G-", "A-", "B-", "C"]
346        );
347    }
348
349    #[test]
350    fn realizes_the_altered_minors() {
351        assert_eq!(
352            names(ScaleType::HarmonicMinor, "C4"),
353            ["C", "D", "E-", "F", "G", "A-", "B", "C"]
354        );
355        assert_eq!(
356            names(ScaleType::MelodicMinor, "C4"),
357            ["C", "D", "E-", "F", "G", "A", "B", "C"]
358        );
359    }
360
361    #[test]
362    fn plagal_modes_share_their_authentic_pitches() {
363        for (plagal, authentic) in [
364            (ScaleType::Hypodorian, ScaleType::Dorian),
365            (ScaleType::Hypophrygian, ScaleType::Phrygian),
366            (ScaleType::Hypolydian, ScaleType::Lydian),
367            (ScaleType::Hypomixolydian, ScaleType::Mixolydian),
368            (ScaleType::Hypolocrian, ScaleType::Locrian),
369            (ScaleType::Hypoaeolian, ScaleType::Minor),
370        ] {
371            assert_eq!(
372                names(plagal, "C4"),
373                names(authentic, "C4"),
374                "{plagal:?} should share {authentic:?}'s pitch collection"
375            );
376        }
377    }
378
379    #[test]
380    fn whole_tone_spells_upward_until_accidentals_run_out() {
381        // From C the scale can stay on sharps and closes on B#, not C.
382        assert_eq!(
383            names(ScaleType::WholeTone, "C4"),
384            ["C", "D", "E", "F#", "G#", "A#", "B#"]
385        );
386        // From B a literal spelling would need G##, so music21 respells.
387        assert_eq!(
388            names(ScaleType::WholeTone, "B4"),
389            ["B", "C#", "D#", "E#", "G", "A", "B"]
390        );
391    }
392
393    #[test]
394    fn octatonic_alternates_tone_and_semitone() {
395        assert_eq!(
396            names(ScaleType::Octatonic, "C4"),
397            ["C", "D", "E-", "F", "G-", "A-", "A", "B", "C"]
398        );
399        assert_eq!(
400            names(ScaleType::Octatonic, "G4"),
401            ["G", "A", "B-", "C", "D-", "E-", "F-", "G-", "G"]
402        );
403    }
404
405    #[test]
406    fn chromatic_uses_the_most_common_spelling() {
407        assert_eq!(
408            names(ScaleType::Chromatic, "C4"),
409            [
410                "C", "C#", "D", "E-", "E", "F", "F#", "G", "A-", "A", "B-", "B", "C"
411            ]
412        );
413    }
414
415    #[test]
416    fn rag_asawari_is_pentatonic() {
417        assert_eq!(
418            names(ScaleType::RagAsawari, "C4"),
419            ["C", "D", "F", "G", "A-", "C"]
420        );
421    }
422
423    #[test]
424    fn rag_marwa_dips_below_its_sixth_degree() {
425        // music21's ascending network steps down a M2 from B before closing on
426        // C, so A appears twice and the line is not monotonic.
427        assert_eq!(
428            names(ScaleType::RagMarwa, "C4"),
429            ["C", "D-", "E", "F#", "A", "B", "A", "C"]
430        );
431        assert_eq!(
432            names(ScaleType::RagMarwa, "E-4"),
433            ["E-", "F-", "G", "A", "C", "D", "C", "E-"]
434        );
435    }
436
437    #[test]
438    fn simplifying_scales_never_exceed_their_accidental_budget() {
439        for scale_type in [ScaleType::WholeTone, ScaleType::Octatonic] {
440            for tonic in ["C4", "G4", "D4", "A4", "E4", "B4", "F#4", "E-4", "G-4"] {
441                let pitches = Scale::new(scale_type, Pitch::from_name(tonic).unwrap())
442                    .pitches()
443                    .unwrap();
444                assert!(
445                    max_alter(&pitches) <= 1,
446                    "{scale_type:?} on {tonic} exceeded one accidental"
447                );
448            }
449        }
450    }
451
452    #[test]
453    fn degree_lookup_matches_the_realized_pitches() {
454        for scale_type in ScaleType::ALL {
455            let scale = Scale::new(scale_type, Pitch::from_name("E-4").unwrap());
456            let pitches = scale.pitches().unwrap();
457            for (index, expected) in pitches.iter().enumerate() {
458                let actual = scale.pitch_at_degree(index + 1).unwrap();
459                assert_eq!(
460                    actual.name_with_octave(),
461                    expected.name_with_octave(),
462                    "{scale_type:?} degree {}",
463                    index + 1
464                );
465            }
466        }
467    }
468
469    #[test]
470    fn degree_zero_is_rejected() {
471        let scale = Scale::new(ScaleType::Major, Pitch::from_name("C4").unwrap());
472        assert!(scale.pitch_at_degree(0).is_err());
473    }
474
475    #[test]
476    fn every_scale_realizes_on_every_common_tonic() {
477        for scale_type in ScaleType::ALL {
478            for tonic in [
479                "C4", "G4", "D4", "A4", "E4", "B4", "F#4", "F4", "B-4", "E-4", "A-4",
480            ] {
481                let scale = Scale::new(scale_type, Pitch::from_name(tonic).unwrap());
482                let pitches = scale.pitches().expect("scale realizes");
483                assert_eq!(
484                    pitches.len(),
485                    scale_type.degree_count() + 1,
486                    "{scale_type:?} on {tonic}"
487                );
488            }
489        }
490    }
491
492    #[test]
493    fn music21_names_are_distinct() {
494        let mut names: Vec<&str> = ScaleType::ALL.iter().map(|s| s.music21_name()).collect();
495        names.sort_unstable();
496        let count = names.len();
497        names.dedup();
498        assert_eq!(names.len(), count, "music21 names must be unique");
499    }
500}