Skip to main content

music21_rs/scale/
stepscale.rs

1//! Scales built from a caller-supplied cycle of intervals.
2//!
3//! [`ScaleType`](super::ScaleType) covers music21's *named* `ConcreteScale`
4//! subclasses, which are fixed tables and so can be an enum. `CyclicalScale`
5//! and `OctaveRepeatingScale` are not: they take an arbitrary interval list at
6//! construction, so they are a runtime type rather than a variant.
7//!
8//! The two differ only in how they close:
9//!
10//! - [`StepScale::cyclical`] walks the intervals once and stops, so the scale
11//!   need not span or repeat at an octave — `["P5"]` from C is just `C G`.
12//! - [`StepScale::octave_repeating`] appends whatever interval is needed to
13//!   reach the octave above the tonic, so `["m3", "M3"]` from C becomes
14//!   `C E- G C`.
15
16use crate::error::Result;
17use crate::interval::Interval;
18use crate::pitch::Pitch;
19use crate::sieve::Sieve;
20
21use std::sync::LazyLock;
22
23/// music21 defaults an absent interval list to a single minor second.
24static DEFAULT_STEP: LazyLock<Interval> =
25    LazyLock::new(|| Interval::from_name("m2").expect("m2 is a valid interval name"));
26
27/// A scale built by walking a cycle of intervals from a tonic.
28///
29/// ```
30/// use music21_rs::{Pitch, StepScale};
31///
32/// let tonic = Pitch::from_name("C4")?;
33/// let cyclical = StepScale::cyclical(tonic.clone(), &["m3", "M3"])?;
34/// let names: Vec<String> = cyclical.pitches()?.iter().map(|p| p.name()).collect();
35/// assert_eq!(names, ["C", "E-", "G"]);
36///
37/// let repeating = StepScale::octave_repeating(tonic, &["m3", "M3"])?;
38/// let names: Vec<String> = repeating.pitches()?.iter().map(|p| p.name()).collect();
39/// assert_eq!(names, ["C", "E-", "G", "C"]);
40/// # Ok::<(), music21_rs::Error>(())
41/// ```
42/// `Interval` implements neither `PartialEq` nor `Hash`, so neither is derived
43/// here; compare realized [`pitches`](StepScale::pitches) instead.
44#[derive(Clone, Debug)]
45pub struct StepScale {
46    tonic: Pitch,
47    steps: Vec<Interval>,
48}
49
50impl StepScale {
51    /// Builds music21's `CyclicalScale`: the intervals walked once, no closing.
52    ///
53    /// An empty list defaults to a single `m2`, as music21 does.
54    pub fn cyclical(tonic: Pitch, steps: &[&str]) -> Result<Self> {
55        Ok(Self {
56            tonic,
57            steps: parse_steps(steps)?,
58        })
59    }
60
61    /// Builds music21's `OctaveRepeatingScale`: the intervals plus a closing
62    /// interval that completes the octave.
63    ///
64    /// An empty list defaults to a single `m2`, as music21 does.
65    ///
66    /// The closing interval is the *complement of the interval sum*, which is
67    /// how music21 derives it — not the interval between the last realized
68    /// pitch and the octave. The two differ in spelling whenever realization
69    /// respells a degree: three `m2` steps from C sum to `dd4`, whose
70    /// complement is `AA5`, so the scale closes on `B#` rather than `C`. Taking
71    /// it from the realized pitches would read the answer off already-simplified
72    /// output and lose that.
73    ///
74    /// music21's own behaviour for a cycle wider than an octave is erratic —
75    /// `["P5", "P5"]` returns pitches an octave above the tonic it was given,
76    /// and it mutates the caller's interval list in place. Neither is
77    /// reproduced here: the cycle is closed at the octave above the last pitch
78    /// and the input is left alone.
79    pub fn octave_repeating(tonic: Pitch, steps: &[&str]) -> Result<Self> {
80        let mut steps = parse_steps(steps)?;
81        steps.push(interval_sum(&tonic, &steps)?.inversion()?);
82        Ok(Self { tonic, steps })
83    }
84
85    /// Builds music21's `SieveScale` from a Xenakis sieve expression.
86    ///
87    /// The sieve's interval widths become the cycle, which music21 then treats
88    /// as a `CyclicalScale` — so `"3@0"` from C is `C E-`, and the major-scale
89    /// sieve gives a major scale.
90    ///
91    /// music21's `SieveScale` also takes an `eld` (elementary displacement) to
92    /// scale the widths for non-semitone steps. Only the default of one
93    /// semitone is supported here, since the crate has no microtonal step type
94    /// to widen to.
95    pub fn sieve(tonic: Pitch, expression: &str) -> Result<Self> {
96        let widths = Sieve::parse(expression)?.interval_widths()?;
97        let steps = widths
98            .into_iter()
99            .map(Interval::from_semitones)
100            .collect::<Result<Vec<_>>>()?;
101        Ok(Self { tonic, steps })
102    }
103
104    /// Returns the tonic pitch.
105    pub fn tonic(&self) -> &Pitch {
106        &self.tonic
107    }
108
109    /// Returns the step intervals, including any closing interval.
110    pub fn steps(&self) -> &[Interval] {
111        &self.steps
112    }
113
114    /// Returns the number of steps, which is one fewer than the pitch count.
115    pub fn degree_count(&self) -> usize {
116        self.steps.len()
117    }
118
119    /// Returns the pitches of one pass through the cycle, starting at the tonic.
120    pub fn pitches(&self) -> Result<Vec<Pitch>> {
121        let mut pitches = Vec::with_capacity(self.steps.len() + 1);
122        pitches.push(self.tonic.clone());
123
124        let mut current = self.tonic.clone();
125        for step in &self.steps {
126            // music21's IntervalNetwork defaults to pitchSimplification
127            // 'maxAccidental' with a cap of one, which is what respells the
128            // third step of an m2 cycle from E-double-flat to D.
129            current = step.transpose_pitch_with_options(&current, false, Some(1))?;
130            pitches.push(current.clone());
131        }
132        Ok(pitches)
133    }
134}
135
136/// Returns the sum of `steps`, spelled exactly.
137///
138/// Computed by transposing a reference pitch with simplification switched off
139/// and measuring the result, so the sum keeps the accidentals the arithmetic
140/// actually produces rather than the ones a realized scale would show.
141fn interval_sum(reference: &Pitch, steps: &[Interval]) -> Result<Interval> {
142    let mut current = reference.clone();
143    for step in steps {
144        current = step.transpose_pitch_with_options(&current, false, None)?;
145    }
146    Interval::between_pitches(reference, &current)
147}
148
149fn parse_steps(steps: &[&str]) -> Result<Vec<Interval>> {
150    if steps.is_empty() {
151        return Ok(vec![DEFAULT_STEP.clone()]);
152    }
153    steps
154        .iter()
155        .map(|name| Interval::from_name(*name))
156        .collect()
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    fn names(scale: &StepScale) -> Vec<String> {
164        scale
165            .pitches()
166            .expect("scale realizes")
167            .iter()
168            .map(|pitch| pitch.name())
169            .collect()
170    }
171
172    fn tonic(name: &str) -> Pitch {
173        Pitch::from_name(name).expect("valid tonic")
174    }
175
176    #[test]
177    fn cyclical_walks_the_cycle_once() {
178        let scale = StepScale::cyclical(tonic("C4"), &["P5"]).unwrap();
179        assert_eq!(names(&scale), ["C", "G"]);
180
181        let scale = StepScale::cyclical(tonic("C4"), &["m3", "M3"]).unwrap();
182        assert_eq!(names(&scale), ["C", "E-", "G"]);
183    }
184
185    #[test]
186    fn octave_repeating_closes_on_the_octave() {
187        let scale = StepScale::octave_repeating(tonic("C4"), &["m3", "M3"]).unwrap();
188        assert_eq!(names(&scale), ["C", "E-", "G", "C"]);
189        let pitches = scale.pitches().unwrap();
190        assert_eq!(pitches.first().unwrap().octave(), Some(4));
191        assert_eq!(pitches.last().unwrap().octave(), Some(5));
192    }
193
194    #[test]
195    fn an_empty_interval_list_defaults_to_a_minor_second() {
196        // music21: CyclicalScale() is [C4, D-4]; OctaveRepeatingScale() is
197        // [C4, D-4, C5].
198        assert_eq!(
199            names(&StepScale::cyclical(tonic("C4"), &[]).unwrap()),
200            ["C", "D-"]
201        );
202        assert_eq!(
203            names(&StepScale::octave_repeating(tonic("C4"), &[]).unwrap()),
204            ["C", "D-", "C"]
205        );
206    }
207
208    #[test]
209    fn the_closing_interval_comes_from_the_interval_sum_not_the_pitches() {
210        // Three m2 steps sum to dd4, whose complement is AA5. Realization
211        // respells the third degree from E-double-flat to D, so reading the
212        // closing interval off the pitches would give a major sixth to C
213        // instead. music21 closes on B#, and so does this.
214        let scale = StepScale::octave_repeating(tonic("C4"), &["m2", "m2", "m2"]).unwrap();
215        assert_eq!(names(&scale), ["C", "D-", "D", "E-", "B#"]);
216
217        // Where realization changes nothing, the two agree: M2+M2+m2 is P4 and
218        // its complement P5 closes on the octave.
219        let scale = StepScale::octave_repeating(tonic("C4"), &["M2", "M2", "m2"]).unwrap();
220        assert_eq!(names(&scale), ["C", "D", "E", "F", "C"]);
221        let scale = StepScale::octave_repeating(tonic("F#4"), &["M2", "M2", "m2"]).unwrap();
222        assert_eq!(names(&scale), ["F#", "G#", "A#", "B", "F#"]);
223    }
224
225    #[test]
226    fn a_cycle_wider_than_an_octave_closes_above_it() {
227        // Two fifths sum to M9, whose complement is m7, so the cycle closes an
228        // octave higher rather than folding back. music21 agrees on the
229        // intervals here but reports the pitches an octave off its own tonic.
230        let scale = StepScale::octave_repeating(tonic("C4"), &["P5", "P5"]).unwrap();
231        assert_eq!(names(&scale), ["C", "G", "D", "C"]);
232        let pitches = scale.pitches().unwrap();
233        assert_eq!(pitches.first().unwrap().octave(), Some(4));
234        assert_eq!(pitches.last().unwrap().octave(), Some(6));
235    }
236
237    #[test]
238    fn sieve_scales_match_music21() {
239        let cases: [(&str, &str, &[&str]); 6] = [
240            ("C4", "3@0", &["C", "E-"]),
241            ("D4", "3@0", &["D", "F"]),
242            ("E-4", "2@0", &["E-", "F"]),
243            (
244                "C2",
245                "(-3@2 & 4) | (-3@1 & 4@1) | (3@2 & 4@2) | (-3 & 4@3)",
246                &["C", "D", "E", "F", "G", "A", "B", "C"],
247            ),
248            (
249                "C4",
250                "3@0|7@0",
251                &["C", "E-", "F#", "G", "A", "C", "D", "E-", "F#", "A"],
252            ),
253            ("C4", "{3@0|4@0}", &["C", "E-", "E", "F#", "G#", "A", "C"]),
254        ];
255
256        for (tonic_name, expression, expected) in cases {
257            let scale = StepScale::sieve(tonic(tonic_name), expression).expect("sieve realizes");
258            assert_eq!(names(&scale), expected, "{tonic_name} {expression}");
259        }
260    }
261
262    #[test]
263    fn a_sieve_with_no_intervals_errors() {
264        assert!(StepScale::sieve(tonic("C4"), "3@1").is_err());
265        assert!(StepScale::sieve(tonic("C4"), "not a sieve").is_err());
266    }
267
268    #[test]
269    fn malformed_interval_names_error_instead_of_panicking() {
270        assert!(StepScale::cyclical(tonic("C4"), &["nonsense"]).is_err());
271    }
272}