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(¤t, 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(¤t, false, None)?;
145 }
146 Interval::between_pitches(reference, ¤t)
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}