Skip to main content

quiver/
scala.rs

1//! Scala (`.scl`) scale file parser for microtuning (Q146).
2//!
3//! Parses the [Scala scale file format](https://www.huygens-fokker.org/scala/scl_format.html):
4//! a description line, a note count, and one pitch per line given either as a
5//! ratio (`3/2`, or a bare integer `2` meaning `2/1`) or as a cents value (any
6//! token containing a `.`, e.g. `701.955`). Lines beginning with `!` are comments
7//! and are ignored. Parsing never panics; malformed input returns [`ScalaError`].
8//!
9//! Alloc-tier: this module is only compiled with the `alloc` feature (it produces
10//! heap-allocated `Vec`/`String` scale data), mirroring the crate's other
11//! alloc-gated conveniences.
12
13use alloc::string::{String, ToString};
14use alloc::vec::Vec;
15use libm::Libm;
16
17/// Error returned when a `.scl` file cannot be parsed.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum ScalaError {
20    /// The file contained no non-comment content.
21    Empty,
22    /// The note-count line was missing.
23    MissingCount,
24    /// The note-count line was not a valid non-negative integer.
25    BadCount(String),
26    /// A pitch line could not be parsed (carries the offending token).
27    BadPitch(String),
28    /// The number of pitch lines did not match the declared count.
29    CountMismatch {
30        /// Declared count from the header.
31        expected: usize,
32        /// Pitch lines actually found.
33        found: usize,
34    },
35}
36
37impl core::fmt::Display for ScalaError {
38    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
39        match self {
40            ScalaError::Empty => write!(f, "scala: empty file"),
41            ScalaError::MissingCount => write!(f, "scala: missing note count"),
42            ScalaError::BadCount(s) => write!(f, "scala: invalid note count '{s}'"),
43            ScalaError::BadPitch(s) => write!(f, "scala: invalid pitch '{s}'"),
44            ScalaError::CountMismatch { expected, found } => {
45                write!(f, "scala: expected {expected} pitches, found {found}")
46            }
47        }
48    }
49}
50
51/// A parsed Scala scale.
52#[derive(Debug, Clone, PartialEq)]
53pub struct ScalaScale {
54    /// Free-text description from the first non-comment line.
55    pub description: String,
56    /// Pitches exactly as listed, converted to cents above `1/1`. The final entry
57    /// is normally the period (e.g. `1200.0` for an octave).
58    pub pitches_cents: Vec<f64>,
59}
60
61impl ScalaScale {
62    /// Parse a `.scl` file body.
63    pub fn parse(source: &str) -> Result<Self, ScalaError> {
64        // Non-comment, meaningful lines. A `!` at the very start of a (trimmed)
65        // line is a comment.
66        let mut lines = source
67            .lines()
68            .map(|l| l.trim_end())
69            .filter(|l| !l.trim_start().starts_with('!'));
70
71        let description = lines.next().ok_or(ScalaError::Empty)?.trim().to_string();
72
73        let count_line = lines.next().ok_or(ScalaError::MissingCount)?.trim();
74        let count: usize = count_line
75            .split_whitespace()
76            .next()
77            .ok_or_else(|| ScalaError::BadCount(count_line.to_string()))?
78            .parse()
79            .map_err(|_| ScalaError::BadCount(count_line.to_string()))?;
80
81        let mut pitches_cents = Vec::with_capacity(count);
82        for line in lines {
83            let trimmed = line.trim();
84            if trimmed.is_empty() {
85                continue;
86            }
87            pitches_cents.push(parse_pitch(trimmed)?);
88            if pitches_cents.len() == count {
89                break;
90            }
91        }
92
93        if pitches_cents.len() != count {
94            return Err(ScalaError::CountMismatch {
95                expected: count,
96                found: pitches_cents.len(),
97            });
98        }
99
100        Ok(Self {
101            description,
102            pitches_cents,
103        })
104    }
105
106    /// The scale degrees reduced into a single octave `[0, 1200)` cents, sorted and
107    /// de-duplicated, with the implicit `1/1` (0 cents) included.
108    ///
109    /// This is the form consumed by
110    /// [`ScaleQuantizer::set_custom_scale`](crate::modules::ScaleQuantizer::set_custom_scale).
111    pub fn degrees_within_octave(&self) -> Vec<f64> {
112        let mut degrees: Vec<f64> = Vec::with_capacity(self.pitches_cents.len() + 1);
113        degrees.push(0.0);
114        for &c in &self.pitches_cents {
115            // Octave-reduce into [0, 1200).
116            let mut r = Libm::<f64>::fmod(c, 1200.0);
117            if r < 0.0 {
118                r += 1200.0;
119            }
120            degrees.push(r);
121        }
122        degrees.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
123        // De-duplicate near-equal degrees (e.g. a listed 1200.0 octave folds onto 0).
124        degrees.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
125        degrees
126    }
127}
128
129/// Parse a single Scala pitch token to cents above `1/1`.
130///
131/// A token containing `.` is a cents value; otherwise it is a ratio (`a/b`, or a
132/// bare integer `n` = `n/1`). Any trailing content after the first whitespace is
133/// ignored (Scala allows inline comments after the value).
134fn parse_pitch(token: &str) -> Result<f64, ScalaError> {
135    let first = token
136        .split_whitespace()
137        .next()
138        .ok_or_else(|| ScalaError::BadPitch(token.to_string()))?;
139
140    if first.contains('.') {
141        // Cents value.
142        first
143            .parse::<f64>()
144            .map_err(|_| ScalaError::BadPitch(first.to_string()))
145    } else if let Some((num, den)) = first.split_once('/') {
146        let n: f64 = num
147            .trim()
148            .parse()
149            .map_err(|_| ScalaError::BadPitch(first.to_string()))?;
150        let d: f64 = den
151            .trim()
152            .parse()
153            .map_err(|_| ScalaError::BadPitch(first.to_string()))?;
154        if n <= 0.0 || d <= 0.0 {
155            return Err(ScalaError::BadPitch(first.to_string()));
156        }
157        Ok(1200.0 * Libm::<f64>::log2(n / d))
158    } else {
159        // Bare integer ratio n/1.
160        let n: f64 = first
161            .parse()
162            .map_err(|_| ScalaError::BadPitch(first.to_string()))?;
163        if n <= 0.0 {
164            return Err(ScalaError::BadPitch(first.to_string()));
165        }
166        Ok(1200.0 * Libm::<f64>::log2(n))
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    const TWELVE_TET: &str = "\
175! meantone.scl
17612-tone equal temperament
177 12
178!
179 100.0
180 200.0
181 300.0
182 400.0
183 500.0
184 600.0
185 700.0
186 800.0
187 900.0
188 1000.0
189 1100.0
190 2/1
191";
192
193    // Ptolemy's intense diatonic (7-note just major).
194    const JUST_MAJOR: &str = "\
195! ptolemy.scl
196Ptolemy intense diatonic
1977
1989/8
1995/4
2004/3
2013/2
2025/3
20315/8
2042/1
205";
206
207    #[test]
208    fn test_parse_12tet() {
209        let scale = ScalaScale::parse(TWELVE_TET).unwrap();
210        assert_eq!(scale.description, "12-tone equal temperament");
211        assert_eq!(scale.pitches_cents.len(), 12);
212        // Last entry is the octave 2/1 = 1200 cents.
213        assert!((scale.pitches_cents[11] - 1200.0).abs() < 1e-6);
214        assert!((scale.pitches_cents[0] - 100.0).abs() < 1e-6);
215
216        let degrees = scale.degrees_within_octave();
217        // 12 unique degrees (2/1 folds onto 0).
218        assert_eq!(degrees.len(), 12);
219        assert!((degrees[0]).abs() < 1e-6);
220        assert!((degrees[1] - 100.0).abs() < 1e-6);
221        assert!((degrees[11] - 1100.0).abs() < 1e-6);
222    }
223
224    #[test]
225    fn test_parse_just_major() {
226        let scale = ScalaScale::parse(JUST_MAJOR).unwrap();
227        assert_eq!(scale.pitches_cents.len(), 7);
228        // 3/2 == 701.955 cents.
229        assert!((scale.pitches_cents[3] - 701.955).abs() < 0.01);
230        let degrees = scale.degrees_within_octave();
231        assert_eq!(degrees.len(), 7); // 0, 9/8, 5/4, 4/3, 3/2, 5/3, 15/8
232        assert!((degrees[0]).abs() < 1e-6);
233        // 9/8 = 203.91 cents is the second degree.
234        assert!((degrees[1] - 203.91).abs() < 0.01);
235    }
236
237    #[test]
238    fn test_bare_integer_ratio() {
239        // "3" means 3/1 = 1901.955 cents.
240        assert!((parse_pitch("3").unwrap() - 1901.955).abs() < 0.01);
241    }
242
243    #[test]
244    fn test_malformed_errors_do_not_panic() {
245        assert_eq!(ScalaScale::parse(""), Err(ScalaError::Empty));
246        assert_eq!(
247            ScalaScale::parse("only a description"),
248            Err(ScalaError::MissingCount)
249        );
250        assert!(matches!(
251            ScalaScale::parse("desc\nnotanumber\n"),
252            Err(ScalaError::BadCount(_))
253        ));
254        assert!(matches!(
255            ScalaScale::parse("desc\n2\n100.0\n"),
256            Err(ScalaError::CountMismatch {
257                expected: 2,
258                found: 1
259            })
260        ));
261        assert!(matches!(
262            ScalaScale::parse("desc\n1\nnonsense\n"),
263            Err(ScalaError::BadPitch(_))
264        ));
265        // Negative / zero ratios are rejected.
266        assert!(matches!(parse_pitch("0/1"), Err(ScalaError::BadPitch(_))));
267        assert!(matches!(parse_pitch("-3/2"), Err(ScalaError::BadPitch(_))));
268    }
269
270    #[test]
271    fn test_error_display() {
272        let e = ScalaError::BadPitch("x".to_string());
273        assert!(e.to_string().contains("x"));
274    }
275}