Skip to main content

sva_ast/
scale.rs

1// Concern: a scale's rows of cents, the scales the language names, each degree's ratio | Non-concern: which scale a composition plays in (reserved.rs) | IO: (rows | name) -> Scale; (degree) -> ratio
2
3use crate::diag::{ByteSpan, Diag, DiagCode};
4
5/// Rows in cents above an unwritten tonic, the last the period, as a Scala `.scl` lists them.
6#[derive(Clone, Debug, PartialEq)]
7pub struct Scale {
8    pub cents: Vec<f64>,
9}
10
11const TWELVE: &str = "twelve-tone equal temperament";
12
13/// maqamworld.com's quarter-tone spelling read as 24-tone equal; a performer's neutral second
14/// or third varies by region.
15const MAQAM: &str = "24-tone equal temperament, as maqamworld.com notates the maqam";
16
17const MAJOR: &[f64] = &[200.0, 400.0, 500.0, 700.0, 900.0, 1100.0, 1200.0];
18const MINOR: &[f64] = &[200.0, 300.0, 500.0, 700.0, 800.0, 1000.0, 1200.0];
19
20/// `(name, cents with the period last, their tuning)`: the scales `variables/scale` may name
21/// bare; any other is a scale file named by a ref.
22pub const BUILTIN_SCALES: [(&str, &[f64], &str); 19] = [
23    ("major", MAJOR, TWELVE),
24    ("minor", MINOR, TWELVE),
25    ("ionian", MAJOR, TWELVE),
26    (
27        "dorian",
28        &[200.0, 300.0, 500.0, 700.0, 900.0, 1000.0, 1200.0],
29        TWELVE,
30    ),
31    (
32        "phrygian",
33        &[100.0, 300.0, 500.0, 700.0, 800.0, 1000.0, 1200.0],
34        TWELVE,
35    ),
36    (
37        "lydian",
38        &[200.0, 400.0, 600.0, 700.0, 900.0, 1100.0, 1200.0],
39        TWELVE,
40    ),
41    (
42        "mixolydian",
43        &[200.0, 400.0, 500.0, 700.0, 900.0, 1000.0, 1200.0],
44        TWELVE,
45    ),
46    ("aeolian", MINOR, TWELVE),
47    (
48        "locrian",
49        &[100.0, 300.0, 500.0, 600.0, 800.0, 1000.0, 1200.0],
50        TWELVE,
51    ),
52    (
53        "harmonic-minor",
54        &[200.0, 300.0, 500.0, 700.0, 800.0, 1100.0, 1200.0],
55        TWELVE,
56    ),
57    (
58        "melodic-minor",
59        &[200.0, 300.0, 500.0, 700.0, 900.0, 1100.0, 1200.0],
60        TWELVE,
61    ),
62    (
63        "major-pentatonic",
64        &[200.0, 400.0, 700.0, 900.0, 1200.0],
65        TWELVE,
66    ),
67    (
68        "minor-pentatonic",
69        &[300.0, 500.0, 700.0, 1000.0, 1200.0],
70        TWELVE,
71    ),
72    (
73        "blues",
74        &[300.0, 500.0, 600.0, 700.0, 1000.0, 1200.0],
75        TWELVE,
76    ),
77    (
78        "whole-tone",
79        &[200.0, 400.0, 600.0, 800.0, 1000.0, 1200.0],
80        TWELVE,
81    ),
82    (
83        "chromatic",
84        &[
85            100.0, 200.0, 300.0, 400.0, 500.0, 600.0, 700.0, 800.0, 900.0, 1000.0, 1100.0, 1200.0,
86        ],
87        TWELVE,
88    ),
89    (
90        "rast",
91        &[200.0, 350.0, 500.0, 700.0, 900.0, 1050.0, 1200.0],
92        MAQAM,
93    ),
94    (
95        "bayati",
96        &[150.0, 300.0, 500.0, 700.0, 800.0, 1000.0, 1200.0],
97        MAQAM,
98    ),
99    (
100        "hijaz",
101        &[100.0, 400.0, 500.0, 700.0, 800.0, 1000.0, 1200.0],
102        MAQAM,
103    ),
104];
105
106pub fn builtin_scale(name: &str) -> Option<Scale> {
107    BUILTIN_SCALES
108        .iter()
109        .find(|(known, ..)| *known == name)
110        .map(|(_, cents, _)| Scale {
111            cents: cents.to_vec(),
112        })
113}
114
115pub fn is_scale(rows: &[(usize, &str)]) -> bool {
116    rows.len() > 1
117        && rows
118            .iter()
119            .all(|(_, row)| row.trim().parse::<f64>().is_ok())
120}
121
122pub fn parse_scale(rows: &[(usize, &str)]) -> Result<Scale, Diag> {
123    let mut cents: Vec<f64> = Vec::with_capacity(rows.len());
124    for (at, row) in rows {
125        let value: f64 = row
126            .trim()
127            .parse()
128            .expect("is_scale read every row as a number");
129        let below = cents.last().copied().unwrap_or(0.0);
130        if !value.is_finite() || value <= below {
131            return Err(Diag::new(
132                DiagCode::BadScale,
133                ByteSpan::new(*at, at + row.len()),
134                format!(
135                    "a scale row is a degree above the tonic in cents, each above the one \
136                     before and the last the period, so `{}` cannot follow `{below}`",
137                    row.trim()
138                ),
139            ));
140        }
141        cents.push(value);
142    }
143    Ok(Scale { cents })
144}
145
146impl Scale {
147    /// Degree 1 is the tonic; every other integer counts on through the rows and periods.
148    /// `None` for a degree so far out its ratio is no finite, nonzero number.
149    pub fn ratio(&self, degree: i64) -> Option<f64> {
150        let n = self.cents.len() as i64;
151        let period = *self.cents.last().expect("a scale holds a row");
152        let index = degree.checked_sub(1)?;
153        let (periods, step) = (index.div_euclid(n), index.rem_euclid(n));
154        let within = match step {
155            0 => 0.0,
156            k => self.cents[k as usize - 1],
157        };
158        let ratio = ((periods as f64 * period + within) / 1200.0).exp2();
159        (ratio.is_finite() && ratio > 0.0).then_some(ratio)
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    fn major() -> Scale {
168        Scale {
169            cents: vec![200.0, 400.0, 500.0, 700.0, 900.0, 1100.0, 1200.0],
170        }
171    }
172
173    #[test]
174    fn degrees_count_from_one_and_continue_through_periods_both_ways() {
175        let st = |n: f64| (n / 12.0).exp2();
176        let close = |a: Option<f64>, b: f64| a.is_some_and(|a| (a - b).abs() < 1e-12);
177        assert!(close(major().ratio(1), 1.0));
178        assert!(close(major().ratio(5), st(7.0)));
179        assert!(close(major().ratio(8), 2.0));
180        assert!(close(major().ratio(10), st(16.0)));
181        assert!(close(major().ratio(0), st(-1.0)), "the leading tone below");
182        assert!(close(major().ratio(-6), 0.5));
183        assert_eq!(major().ratio(i64::MIN), None);
184        assert_eq!(major().ratio(i64::MAX), None);
185    }
186
187    #[test]
188    fn every_builtin_scale_is_rows_a_scale_file_could_hold() {
189        for (name, cents, _) in BUILTIN_SCALES {
190            let text: Vec<String> = cents.iter().map(f64::to_string).collect();
191            let rows: Vec<(usize, &str)> = text.iter().map(|row| (0, row.as_str())).collect();
192            assert!(is_scale(&rows), "{name}");
193            assert_eq!(parse_scale(&rows).ok(), builtin_scale(name), "{name}");
194            assert_eq!(cents.last(), Some(&1200.0), "{name} repeats at the octave");
195            let named = BUILTIN_SCALES.iter().filter(|(n, ..)| *n == name).count();
196            assert_eq!(named, 1, "{name} is named once");
197        }
198        assert_eq!(builtin_scale("major"), Some(major()));
199        assert_eq!(builtin_scale("ionian"), builtin_scale("major"));
200        assert_eq!(builtin_scale("aeolian"), builtin_scale("minor"));
201        assert_eq!(builtin_scale("Major"), None);
202    }
203
204    #[test]
205    fn a_row_not_above_the_one_before_refuses_located() {
206        let rows = [(0, "200"), (4, "100")];
207        let refused = parse_scale(&rows).unwrap_err();
208        assert_eq!(refused.code, DiagCode::BadScale);
209        assert_eq!(refused.span, ByteSpan::new(4, 7));
210    }
211}