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