Skip to main content

music21_rs/tuningsystem/
scala.rs

1//! Runtime parsing of Scala `.scl` scale files.
2//!
3//! Scala files are the de facto interchange format for microtonal scales, and
4//! music21 ships an archive of several thousand of them. Unlike the fixed
5//! [`TuningSystem`](super::TuningSystem) tables, a scale loaded here is owned
6//! data decided at runtime, and its degrees may be either exact integer ratios
7//! or cents.
8//!
9//! ```
10//! use music21_rs::ScalaScale;
11//!
12//! let scale: ScalaScale = "! example.scl\n\
13//!                          !\n\
14//!                          A perfect fifth and an octave\n\
15//!                          2\n\
16//!                          !\n\
17//!                          3/2\n\
18//!                          2/1\n".parse()?;
19//!
20//! assert_eq!(scale.description(), "A perfect fifth and an octave");
21//! assert_eq!(scale.len(), 2);
22//! assert_eq!(scale.ratio_at(1), 1.5);
23//! // Degree 2 is the first degree of the next period.
24//! assert_eq!(scale.ratio_at(2), 2.0);
25//! # Ok::<(), music21_rs::Error>(())
26//! ```
27
28use super::Fraction;
29use crate::defaults::{FloatType, IntegerType, UnsignedIntegerType};
30use crate::error::{Error, Result};
31
32use std::collections::BTreeMap;
33use std::fmt::{Display, Formatter};
34use std::str::FromStr;
35
36/// Cents in one octave, used to convert between cents and frequency ratios.
37const CENTS_PER_OCTAVE: FloatType = 1200.0;
38
39/// A single degree of a [`ScalaScale`].
40///
41/// Scala files express each degree either as an exact ratio (`3/2`) or as a
42/// cents value (`701.955`). The distinction is preserved rather than collapsed,
43/// because a ratio carries exactness that cents cannot express.
44#[derive(Clone, Copy, Debug, PartialEq)]
45#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
46pub enum ScalaDegree {
47    /// An exact integer ratio, such as `3/2`.
48    Ratio(Fraction),
49    /// A cents value above the scale root, such as `701.955`.
50    Cents(FloatType),
51}
52
53impl ScalaDegree {
54    /// Returns this degree as a frequency ratio above the scale root.
55    pub fn ratio(self) -> FloatType {
56        match self {
57            Self::Ratio(fraction) => fraction.ratio(),
58            Self::Cents(cents) => (2.0 as FloatType).powf(cents / CENTS_PER_OCTAVE),
59        }
60    }
61
62    /// Returns this degree in cents above the scale root.
63    pub fn cents(self) -> FloatType {
64        match self {
65            Self::Ratio(fraction) => CENTS_PER_OCTAVE * fraction.ratio().log2(),
66            Self::Cents(cents) => cents,
67        }
68    }
69
70    /// Returns the exact ratio, when this degree was written as one.
71    pub fn as_fraction(self) -> Option<Fraction> {
72        match self {
73            Self::Ratio(fraction) => Some(fraction),
74            Self::Cents(_) => None,
75        }
76    }
77
78    /// Parses one Scala note line.
79    fn parse(token: &str) -> Result<Self> {
80        // Anything after the value on a note line is a comment. A `!` opens one
81        // without needing whitespace before it, as in dyadic53tone9div.scl's
82        // `2957/2048!Gb`.
83        let token = token.split('!').next().unwrap_or_default();
84        let token = token.split_whitespace().next().unwrap_or_default();
85        if token.is_empty() {
86            return Err(Error::TuningSystem("empty scala degree".to_string()));
87        }
88
89        // Scala also writes equal-tempered steps as `n\m`, meaning `n` steps of
90        // `m`-EDO. `1\41` is one step of 41-EDO, or 29.268 cents.
91        if let Some((steps, divisions)) = token.split_once('\\') {
92            let steps: FloatType = steps.trim().parse().map_err(|_| {
93                Error::TuningSystem(format!("invalid scala step count in {token:?}"))
94            })?;
95            let divisions: FloatType = divisions.trim().parse().map_err(|_| {
96                Error::TuningSystem(format!("invalid scala division count in {token:?}"))
97            })?;
98            if divisions == 0.0 {
99                return Err(Error::TuningSystem(format!(
100                    "scala degree {token:?} divides the octave into zero steps"
101                )));
102            }
103            return Ok(Self::Cents(CENTS_PER_OCTAVE * steps / divisions));
104        }
105
106        if token.contains('.') {
107            let cents: FloatType = token
108                .parse()
109                .map_err(|_| Error::TuningSystem(format!("invalid scala cents value {token:?}")))?;
110            if !cents.is_finite() {
111                return Err(Error::TuningSystem(format!(
112                    "scala cents value {token:?} is not finite"
113                )));
114            }
115            return Ok(Self::Cents(cents));
116        }
117
118        let (numerator, denominator) = match token.split_once('/') {
119            Some((numerator, denominator)) => (numerator.trim(), denominator.trim()),
120            None => (token, "1"),
121        };
122
123        if let (Ok(numerator), Ok(denominator)) = (
124            numerator.parse::<UnsignedIntegerType>(),
125            denominator.parse::<UnsignedIntegerType>(),
126        ) && numerator != 0
127            && denominator != 0
128        {
129            return Ok(Self::Ratio(Fraction::new(numerator, denominator)));
130        }
131
132        // Four archive scales quote ratios far wider than the u32 `Fraction`
133        // can hold, such as atomschis.scl's
134        // 156348578434374084375/147573952589676412928. Rather than reject the
135        // whole file, keep the degree as cents - `as_fraction` then reports
136        // that it is not exact.
137        let (numerator, denominator) = (
138            parse_ratio_term(numerator, token, "numerator")?,
139            parse_ratio_term(denominator, token, "denominator")?,
140        );
141        if numerator <= 0.0 || denominator <= 0.0 {
142            return Err(Error::TuningSystem(format!(
143                "scala ratio {token:?} must be positive"
144            )));
145        }
146
147        Ok(Self::Cents(
148            CENTS_PER_OCTAVE * (numerator / denominator).log2(),
149        ))
150    }
151}
152
153/// Parses one side of a ratio that did not fit `Fraction`'s integer range.
154fn parse_ratio_term(term: &str, token: &str, side: &str) -> Result<FloatType> {
155    if term.is_empty() || !term.bytes().all(|byte| byte.is_ascii_digit()) {
156        return Err(Error::TuningSystem(format!(
157            "invalid scala ratio {side} in {token:?}"
158        )));
159    }
160    term.parse()
161        .map_err(|_| Error::TuningSystem(format!("invalid scala ratio {side} in {token:?}")))
162}
163
164impl Display for ScalaDegree {
165    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
166        match self {
167            Self::Ratio(fraction) => write!(f, "{fraction}"),
168            Self::Cents(cents) => write!(f, "{cents}"),
169        }
170    }
171}
172
173/// A scale parsed from a Scala `.scl` file.
174///
175/// The stored degrees follow this crate's convention rather than Scala's: the
176/// implicit `1/1` unison is made explicit at index 0, and the final entry of
177/// the file — the interval the scale repeats at — is held separately as the
178/// [period](Self::period) instead of being a degree. So a 12-note file yields
179/// 12 degrees plus a period, and `degrees()[0]` is always `1/1`.
180#[derive(Clone, Debug, PartialEq)]
181#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
182pub struct ScalaScale {
183    description: String,
184    degrees: Vec<ScalaDegree>,
185    period: ScalaDegree,
186}
187
188impl ScalaScale {
189    /// Builds a scale from already-split parts.
190    ///
191    /// Used by the bundled archive, whose degrees were separated from the file
192    /// structure when the archive was generated.
193    #[cfg(feature = "scala-archive")]
194    pub(crate) fn from_parts(
195        description: String,
196        degrees: Vec<ScalaDegree>,
197        period: ScalaDegree,
198    ) -> Self {
199        Self {
200            description,
201            degrees,
202            period,
203        }
204    }
205
206    /// Parses the contents of a `.scl` file.
207    ///
208    /// Both ratio and cents degrees are accepted. Lines beginning with `!` are
209    /// comments; the first non-comment line is the description, the second is
210    /// the degree count, and the rest are degrees.
211    pub fn parse(contents: &str) -> Result<Self> {
212        let lines: Vec<&str> = contents
213            .lines()
214            .map(str::trim)
215            .filter(|line| !line.starts_with('!'))
216            .collect();
217
218        let count_line = lines
219            .get(1)
220            .ok_or_else(|| Error::TuningSystem("scala file has no degree count".to_string()))?;
221        let count: usize = count_line
222            .split_whitespace()
223            .next()
224            .unwrap_or_default()
225            .parse()
226            .map_err(|_| {
227                Error::TuningSystem(format!("invalid scala degree count {count_line:?}"))
228            })?;
229        let entries: Vec<&str> = lines
230            .iter()
231            .skip(2)
232            .filter(|line| !line.is_empty())
233            .take(count)
234            .copied()
235            .collect();
236        if entries.len() != count {
237            return Err(Error::TuningSystem(format!(
238                "scala file declares {count} degrees but lists {}",
239                entries.len()
240            )));
241        }
242
243        let mut parsed = Vec::with_capacity(count);
244        for entry in entries {
245            parsed.push(ScalaDegree::parse(entry)?);
246        }
247
248        // Scala's last entry is the repeat interval, not a degree of the scale.
249        // A file may legitimately declare zero degrees, in which case there is
250        // neither a degree list nor a repeat interval to read.
251        let (mut degrees, period) = match parsed.pop() {
252            Some(period) => (vec![ScalaDegree::Ratio(Fraction::new(1, 1))], period),
253            None => (Vec::new(), ScalaDegree::Ratio(Fraction::new(1, 1))),
254        };
255        degrees.append(&mut parsed);
256
257        Ok(Self {
258            description: lines.first().unwrap_or(&"").trim().to_string(),
259            degrees,
260            period,
261        })
262    }
263
264    /// Parses the raw bytes of a `.scl` file.
265    ///
266    /// The Scala format is defined as latin-1 (ISO-8859-1), which music21's
267    /// `scale.scala` module states explicitly, and 73 of the ~3900 files it
268    /// ships are not valid UTF-8. Bytes are therefore decoded as latin-1 rather
269    /// than as UTF-8, so accented characters in description lines survive
270    /// instead of becoming replacement characters. Degree lines are ASCII in
271    /// either reading, so numbers are unaffected.
272    ///
273    /// This crate does no file IO of its own, so read the file yourself:
274    ///
275    /// ```no_run
276    /// use music21_rs::ScalaScale;
277    ///
278    /// let bytes = std::fs::read("partch_43.scl")?;
279    /// let scale = ScalaScale::parse_bytes(&bytes)?;
280    /// # Ok::<(), Box<dyn std::error::Error>>(())
281    /// ```
282    pub fn parse_bytes(bytes: &[u8]) -> Result<Self> {
283        // In latin-1 every byte is its own code point, so this cannot fail.
284        let text: String = bytes.iter().map(|&byte| byte as char).collect();
285        Self::parse(&text)
286    }
287
288    /// Returns the scale's description line.
289    pub fn description(&self) -> &str {
290        &self.description
291    }
292
293    /// Returns the degrees of one period, starting with `1/1`.
294    pub fn degrees(&self) -> &[ScalaDegree] {
295        &self.degrees
296    }
297
298    /// Returns the interval the scale repeats at.
299    ///
300    /// This is usually an octave, but need not be — Bohlen-Pierce repeats at
301    /// `3/1`, and non-octave scales are common in the Scala archive.
302    pub fn period(&self) -> ScalaDegree {
303        self.period
304    }
305
306    /// Returns the number of degrees in one period.
307    pub fn len(&self) -> usize {
308        self.degrees.len()
309    }
310
311    /// Returns whether the scale has no degrees.
312    ///
313    /// A Scala file may declare zero degrees — the archive's `xxx.scl` does,
314    /// and music21 reads it as a scale with no pitches.
315    pub fn is_empty(&self) -> bool {
316        self.degrees.is_empty()
317    }
318
319    /// Returns the frequency ratio above the root for a degree index.
320    ///
321    /// Indices outside one period wrap, shifting by the [period](Self::period)
322    /// for each wrap. Negative indices run below the root.
323    ///
324    /// A scale with no degrees has nothing to wrap through, so every index
325    /// returns the root ratio of `1.0`.
326    pub fn ratio_at(&self, index: IntegerType) -> FloatType {
327        if self.degrees.is_empty() {
328            return 1.0;
329        }
330        let len = self.degrees.len() as IntegerType;
331        let periods = index.div_euclid(len);
332        let degree = index.rem_euclid(len) as usize;
333        self.degrees[degree].ratio() * self.period.ratio().powi(periods)
334    }
335
336    /// Returns the cents above the root for a degree index.
337    ///
338    /// Note this is absolute distance from the scale root, unlike
339    /// [`TuningSystem::cents_at`](super::TuningSystem::cents_at), which reports
340    /// deviation from equal temperament.
341    pub fn cents_above_root(&self, index: IntegerType) -> FloatType {
342        CENTS_PER_OCTAVE * self.ratio_at(index).log2()
343    }
344
345    /// Returns the frequency in hertz for a degree index, given a root pitch.
346    ///
347    /// A Scala scale fixes no absolute pitch, so the root is supplied by the
348    /// caller.
349    pub fn frequency_at(&self, root_hz: FloatType, index: IntegerType) -> FloatType {
350        root_hz * self.ratio_at(index)
351    }
352}
353
354impl FromStr for ScalaScale {
355    type Err = Error;
356
357    fn from_str(contents: &str) -> Result<Self> {
358        Self::parse(contents)
359    }
360}
361
362impl Display for ScalaScale {
363    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
364        write!(f, "{} ({} degrees)", self.description, self.degrees.len())
365    }
366}
367
368/// A searchable collection of Scala scales, keyed by file name.
369///
370/// This is the runtime counterpart to music21's `scale.scala` module, whose
371/// `search` and `getPaths` helpers index the Scala scale archive. The archive
372/// itself is **not** bundled with this crate — see the note below — so the
373/// caller supplies the files and this type indexes whatever it is given, which
374/// also keeps the crate free of file IO and usable from wasm.
375///
376/// ```
377/// use music21_rs::ScalaArchive;
378///
379/// let mut archive = ScalaArchive::new();
380/// archive.insert("mbira_banda.scl", b"Mbira Banda\n 1\n 2/1\n")?;
381/// archive.insert("slendro5_2.scl", b"Slendro\n 1\n 2/1\n")?;
382///
383/// assert_eq!(archive.search("mbira"), ["mbira_banda.scl"]);
384/// assert!(archive.get("slendro5_2.scl").is_some());
385/// # Ok::<(), music21_rs::Error>(())
386/// ```
387#[derive(Clone, Debug, Default, PartialEq)]
388#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
389pub struct ScalaArchive {
390    scales: BTreeMap<String, ScalaScale>,
391}
392
393impl ScalaArchive {
394    /// Creates an empty archive.
395    pub fn new() -> Self {
396        Self::default()
397    }
398
399    /// Parses and indexes one `.scl` file under the given name.
400    ///
401    /// The name is the file name as music21 refers to it, such as
402    /// `"partch_43.scl"`. Returns the scale it replaced, if any.
403    pub fn insert(
404        &mut self,
405        file_name: impl Into<String>,
406        bytes: &[u8],
407    ) -> Result<Option<ScalaScale>> {
408        let file_name = file_name.into();
409        let scale = ScalaScale::parse_bytes(bytes)
410            .map_err(|error| Error::TuningSystem(format!("{file_name}: {error}")))?;
411        Ok(self.scales.insert(file_name, scale))
412    }
413
414    /// Indexes an already-parsed scale under the given name.
415    pub fn insert_scale(
416        &mut self,
417        file_name: impl Into<String>,
418        scale: ScalaScale,
419    ) -> Option<ScalaScale> {
420        self.scales.insert(file_name.into(), scale)
421    }
422
423    /// Returns the scale stored under a file name.
424    pub fn get(&self, file_name: &str) -> Option<&ScalaScale> {
425        self.scales.get(file_name)
426    }
427
428    /// Returns every indexed file name, in sorted order.
429    pub fn names(&self) -> impl Iterator<Item = &str> {
430        self.scales.keys().map(String::as_str)
431    }
432
433    /// Returns every indexed scale with its file name, in sorted order.
434    pub fn iter(&self) -> impl Iterator<Item = (&str, &ScalaScale)> {
435        self.scales
436            .iter()
437            .map(|(name, scale)| (name.as_str(), scale))
438    }
439
440    /// Returns the number of indexed scales.
441    pub fn len(&self) -> usize {
442        self.scales.len()
443    }
444
445    /// Returns whether the archive is empty.
446    pub fn is_empty(&self) -> bool {
447        self.scales.is_empty()
448    }
449
450    /// Builds an archive from the `.scl` files bundled with the crate.
451    ///
452    /// Only available under the non-default `scala-archive` feature, which adds
453    /// roughly a megabyte of scale data to the build. Without it, supply the
454    /// files yourself with [`ScalaArchive::insert`].
455    ///
456    /// Use [`ScalaArchive::bundled_with_failures`] to see any scale that fails
457    /// to load rather than silently dropping it.
458    #[cfg(feature = "scala-archive")]
459    pub fn bundled() -> Self {
460        Self::bundled_with_failures().0
461    }
462
463    /// Builds an archive from the bundled files, reporting the ones that fail.
464    ///
465    /// The failures are a fixed property of the bundled data, so the returned
466    /// list is the same on every call.
467    #[cfg(feature = "scala-archive")]
468    pub fn bundled_with_failures() -> (Self, Vec<(&'static str, Error)>) {
469        let mut archive = Self::new();
470        let mut failures = Vec::new();
471        for (file_name, description, degrees, period) in crate::tuningsystem::scala_bundled::SCALES
472        {
473            match build_bundled(description, degrees, period) {
474                Ok(scale) => {
475                    archive.insert_scale(file_name, scale);
476                }
477                Err(error) => failures.push((file_name, error)),
478            }
479        }
480        (archive, failures)
481    }
482
483    /// Returns the number of `.scl` files bundled with the crate.
484    ///
485    /// Counts files without parsing them, so this includes the one that
486    /// [`ScalaArchive::bundled`] skips.
487    #[cfg(feature = "scala-archive")]
488    pub fn bundled_len() -> usize {
489        crate::tuningsystem::scala_bundled::SCALES.len()
490    }
491
492    /// Finds file names matching a search string, as music21's
493    /// `scale.scala.search` does.
494    ///
495    /// Spaces in the target are ignored, an exact file-name match is preferred,
496    /// and the remaining matches are substring hits against the name with its
497    /// extension dropped and against a form with `_` and `-` removed. Results
498    /// are sorted.
499    pub fn search(&self, target: &str) -> Vec<&str> {
500        let target = target.replace(' ', "").to_lowercase();
501        let mut matches = Vec::new();
502
503        for name in self.scales.keys() {
504            if name.to_lowercase() == target {
505                matches.push(name.as_str());
506            }
507        }
508
509        for name in self.scales.keys() {
510            if matches.contains(&name.as_str()) {
511                continue;
512            }
513            let stem = name.strip_suffix(".scl").unwrap_or(name).to_lowercase();
514            let squashed = stem.replace(['_', '-'], "");
515            if stem.contains(&target) || squashed.contains(&target) {
516                matches.push(name.as_str());
517            }
518        }
519
520        matches.sort_unstable();
521        matches
522    }
523}
524
525/// Rebuilds a [`ScalaScale`] from the tokens emitted into `scala_bundled`.
526///
527/// The generator already split description, degrees and period, so this only
528/// has to parse each degree token — no Scala file structure is involved.
529#[cfg(feature = "scala-archive")]
530fn build_bundled(description: &str, degrees: &[&str], period: &str) -> Result<ScalaScale> {
531    let degrees = degrees
532        .iter()
533        .map(|token| ScalaDegree::parse(token))
534        .collect::<Result<Vec<_>>>()?;
535    Ok(ScalaScale::from_parts(
536        description.to_string(),
537        degrees,
538        ScalaDegree::parse(period)?,
539    ))
540}
541
542impl Extend<(String, ScalaScale)> for ScalaArchive {
543    fn extend<T: IntoIterator<Item = (String, ScalaScale)>>(&mut self, iter: T) {
544        self.scales.extend(iter);
545    }
546}
547
548impl FromIterator<(String, ScalaScale)> for ScalaArchive {
549    fn from_iter<T: IntoIterator<Item = (String, ScalaScale)>>(iter: T) -> Self {
550        Self {
551            scales: iter.into_iter().collect(),
552        }
553    }
554}
555
556#[cfg(all(test, feature = "scala-archive"))]
557mod bundled_tests {
558    use super::ScalaArchive;
559
560    #[test]
561    fn the_whole_bundled_archive_parses() {
562        let (archive, failures) = ScalaArchive::bundled_with_failures();
563        let names: Vec<&str> = failures.iter().map(|(name, _)| *name).collect();
564        // Every scale in the bundle parses. `sparschuh-stanhope.scl` used to
565        // fail here for writing a degree as `697//441`; upstream fixed it in
566        // cuthbertLab/music21#2003. `xxx.scl` declares zero degrees, which is
567        // legal Scala and which music21 accepts too.
568        assert_eq!(names, [] as [&str; 0]);
569        assert_eq!(archive.len(), ScalaArchive::bundled_len());
570        // 3932 from the music21 submodule plus 62 from the hexatone one.
571        assert_eq!(ScalaArchive::bundled_len(), 3994);
572    }
573
574    #[test]
575    fn the_bundled_archive_carries_the_scales_the_tuning_tables_cite() {
576        let archive = ScalaArchive::bundled();
577        for name in [
578            "partch_43.scl",
579            "partch_29.scl",
580            "werck3.scl",
581            "vallotti.scl",
582            "meanquar.scl",
583            "ptolemy.scl",
584            "pyth_12.scl",
585            "kirnberger3.scl",
586            "rameau.scl",
587            "young2.scl",
588            "carlos_harm.scl",
589            "riley_albion.scl",
590            "indian.scl",
591            "indian-sagrama.scl",
592        ] {
593            assert!(
594                archive.get(name).is_some(),
595                "{name} missing from the bundle"
596            );
597        }
598    }
599
600    #[test]
601    fn bundled_file_names_are_sorted_and_unique() {
602        let names: Vec<&str> = crate::tuningsystem::scala_bundled::SCALES
603            .iter()
604            .map(|(name, _, _, _)| *name)
605            .collect();
606        let mut sorted = names.clone();
607        sorted.sort_unstable();
608        sorted.dedup();
609        assert_eq!(names, sorted, "bundled file list must be sorted and unique");
610    }
611}
612
613#[cfg(test)]
614mod tests {
615
616    #[test]
617    fn accepts_a_scale_declaring_zero_degrees() {
618        // music21 reads the archive's xxx.scl as a scale with no pitches, so
619        // the count line being `0` is legal rather than malformed.
620        let scale = ScalaScale::parse(
621            "! xxx.scl
622!
623Saved scale from Scala
624 0
625!
626",
627        )
628        .expect("a zero-degree file parses");
629        assert!(scale.is_empty());
630        assert_eq!(scale.len(), 0);
631        assert_eq!(scale.description(), "Saved scale from Scala");
632        // Nothing to wrap through, so every index is the root.
633        assert_eq!(scale.ratio_at(0), 1.0);
634        assert_eq!(scale.ratio_at(7), 1.0);
635        assert_eq!(scale.ratio_at(-3), 1.0);
636    }
637
638    use super::*;
639
640    const FIFTH_AND_OCTAVE: &str = "! example.scl\n!\nA fifth and an octave\n 2\n!\n 3/2\n 2/1\n";
641
642    #[test]
643    fn parses_ratios_into_the_crate_convention() {
644        let scale = ScalaScale::parse(FIFTH_AND_OCTAVE).unwrap();
645        assert_eq!(scale.description(), "A fifth and an octave");
646        assert_eq!(scale.len(), 2);
647        assert_eq!(
648            scale.degrees(),
649            &[
650                ScalaDegree::Ratio(Fraction::new(1, 1)),
651                ScalaDegree::Ratio(Fraction::new(3, 2)),
652            ]
653        );
654        assert_eq!(scale.period(), ScalaDegree::Ratio(Fraction::new(2, 1)));
655    }
656
657    #[test]
658    fn wraps_indices_by_the_period_in_both_directions() {
659        let scale = ScalaScale::parse(FIFTH_AND_OCTAVE).unwrap();
660        assert_eq!(scale.ratio_at(0), 1.0);
661        assert_eq!(scale.ratio_at(1), 1.5);
662        assert_eq!(scale.ratio_at(2), 2.0);
663        assert_eq!(scale.ratio_at(3), 3.0);
664        assert_eq!(scale.ratio_at(-2), 0.5);
665        assert_eq!(scale.ratio_at(-1), 0.75);
666    }
667
668    #[test]
669    fn honours_a_non_octave_period() {
670        // Bohlen-Pierce repeats at a tritave rather than an octave.
671        let scale = ScalaScale::parse("Tritave\n 2\n 5/3\n 3/1\n").unwrap();
672        assert_eq!(scale.period(), ScalaDegree::Ratio(Fraction::new(3, 1)));
673        assert_eq!(scale.ratio_at(2), 3.0);
674        assert_eq!(scale.ratio_at(4), 9.0);
675    }
676
677    #[test]
678    fn accepts_cents_degrees() {
679        let scale = ScalaScale::parse("Cents\n 2\n 701.955\n 1200.0\n").unwrap();
680        assert_eq!(scale.degrees()[1], ScalaDegree::Cents(701.955));
681        assert!((scale.ratio_at(1) - 1.5).abs() < 1e-6);
682        assert!((scale.ratio_at(2) - 2.0).abs() < 1e-9);
683    }
684
685    #[test]
686    fn mixes_ratio_and_cents_degrees() {
687        let scale = ScalaScale::parse("Mixed\n 3\n 100.0\n 3/2\n 2/1\n").unwrap();
688        assert!(matches!(scale.degrees()[1], ScalaDegree::Cents(_)));
689        assert!(matches!(scale.degrees()[2], ScalaDegree::Ratio(_)));
690        assert!((scale.cents_above_root(2) - 701.955).abs() < 1e-3);
691    }
692
693    #[test]
694    fn treats_a_bare_integer_as_a_whole_ratio() {
695        let scale = ScalaScale::parse("Integers\n 2\n 3\n 4\n").unwrap();
696        assert_eq!(scale.degrees()[1], ScalaDegree::Ratio(Fraction::new(3, 1)));
697        assert_eq!(scale.period(), ScalaDegree::Ratio(Fraction::new(4, 1)));
698    }
699
700    #[test]
701    fn ignores_trailing_comments_on_degree_lines() {
702        let scale = ScalaScale::parse("Commented\n 2\n 3/2 the fifth\n 2/1 octave\n").unwrap();
703        assert_eq!(scale.ratio_at(1), 1.5);
704    }
705
706    #[test]
707    fn ignores_a_bang_comment_with_no_leading_space() {
708        // dyadic53tone9div.scl writes degrees as `2957/2048!Gb`.
709        let scale = ScalaScale::parse("Bang\n 2\n 3/2!the fifth\n 2/1!octave\n").unwrap();
710        assert_eq!(scale.ratio_at(1), 1.5);
711        assert_eq!(scale.period(), ScalaDegree::Ratio(Fraction::new(2, 1)));
712    }
713
714    #[test]
715    fn keeps_an_empty_description_line() {
716        let scale = ScalaScale::parse("\n 1\n 2/1\n").unwrap();
717        assert_eq!(scale.description(), "");
718        assert_eq!(scale.len(), 1);
719    }
720
721    #[test]
722    fn computes_frequencies_from_a_caller_supplied_root() {
723        let scale = ScalaScale::parse(FIFTH_AND_OCTAVE).unwrap();
724        assert!((scale.frequency_at(440.0, 1) - 660.0).abs() < 1e-9);
725        assert!((scale.frequency_at(440.0, 2) - 880.0).abs() < 1e-9);
726    }
727
728    #[test]
729    fn falls_back_to_cents_for_ratios_too_wide_for_u32() {
730        // First degree of atomschis.scl, whose numerator and denominator are
731        // both far past u32.
732        let scale = ScalaScale::parse(
733            "Atom Schisma\n 2\n 156348578434374084375/147573952589676412928\n 2/1\n",
734        )
735        .unwrap();
736
737        let degree = scale.degrees()[1];
738        assert!(degree.as_fraction().is_none(), "not exactly representable");
739        assert!(
740            (degree.cents() - 99.993_599_6).abs() < 1e-6,
741            "{}",
742            degree.cents()
743        );
744    }
745
746    #[test]
747    fn parses_latin1_description_bytes_lossily() {
748        // 0xE9 is Latin-1 "e-acute" and is not valid UTF-8 on its own.
749        let bytes = b"Caf\xe9 scale\n 1\n 2/1\n";
750        let scale = ScalaScale::parse_bytes(bytes).unwrap();
751        assert!(scale.description().starts_with("Caf"));
752        assert_eq!(scale.len(), 1);
753    }
754
755    #[test]
756    fn reads_equal_tempered_step_notation() {
757        // `n\\m` is n steps of m-EDO, which several archives use for EDO files.
758        let scale = ScalaScale::parse("41-EDO\n 2\n 1\\41\n 41\\41\n").unwrap();
759        assert!((scale.degrees()[1].cents() - 1200.0 / 41.0).abs() < 1e-9);
760        assert!((scale.period().cents() - 1200.0).abs() < 1e-9);
761    }
762
763    #[test]
764    fn rejects_a_zero_division_step() {
765        assert!(ScalaScale::parse("Bad\n 1\\0\n").is_err());
766    }
767
768    #[test]
769    fn rejects_malformed_input() {
770        for bad in [
771            "",                           // nothing at all
772            "Only a description\n",       // no count
773            "Bad count\n not-a-number\n", // unparseable count
774            "Too few\n 4\n 3/2\n 2/1\n",  // count exceeds the listed degrees
775            "Bad ratio\n 1\n 3/0\n",      // zero denominator
776            "Bad ratio\n 1\n 1/2/3\n",    // not a ratio
777        ] {
778            assert!(
779                ScalaScale::parse(bad).is_err(),
780                "expected {bad:?} to be rejected"
781            );
782        }
783    }
784
785    fn archive_of(names: &[&str]) -> ScalaArchive {
786        let mut archive = ScalaArchive::new();
787        for name in names {
788            archive
789                .insert(*name, b"A scale\n 1\n 2/1\n")
790                .expect("fixture scale parses");
791        }
792        archive
793    }
794
795    #[test]
796    fn archive_indexes_and_retrieves_by_file_name() {
797        let archive = archive_of(&["partch_43.scl", "slendro5_2.scl"]);
798        assert_eq!(archive.len(), 2);
799        assert!(archive.get("partch_43.scl").is_some());
800        assert!(archive.get("missing.scl").is_none());
801        assert_eq!(
802            archive.names().collect::<Vec<_>>(),
803            ["partch_43.scl", "slendro5_2.scl"]
804        );
805    }
806
807    #[test]
808    fn archive_search_matches_music21_semantics() {
809        let archive = archive_of(&[
810            "mbira_banda.scl",
811            "mbira_banda2.scl",
812            "mbira_zimb.scl",
813            "slendro5_2.scl",
814            "partch_43.scl",
815        ]);
816
817        // Substring hit against the stem.
818        assert_eq!(
819            archive.search("mbira"),
820            ["mbira_banda.scl", "mbira_banda2.scl", "mbira_zimb.scl"]
821        );
822        // Spaces in the target are ignored.
823        assert_eq!(
824            archive.search("mbira banda"),
825            ["mbira_banda.scl", "mbira_banda2.scl"]
826        );
827        // Underscores and hyphens are ignored on the indexed side.
828        assert_eq!(
829            archive.search("mbirabanda"),
830            ["mbira_banda.scl", "mbira_banda2.scl"]
831        );
832        // Matching is case-insensitive.
833        assert_eq!(archive.search("PARTCH"), ["partch_43.scl"]);
834        // An exact file name matches.
835        assert_eq!(archive.search("slendro5_2.scl"), ["slendro5_2.scl"]);
836        assert!(archive.search("nothing-here").is_empty());
837    }
838
839    #[test]
840    fn archive_reports_the_offending_file_on_a_parse_error() {
841        let mut archive = ScalaArchive::new();
842        let error = archive
843            .insert("broken.scl", b"Broken\n not-a-number\n")
844            .expect_err("should reject");
845        assert!(error.to_string().contains("broken.scl"), "{error}");
846    }
847
848    #[test]
849    fn decodes_latin1_description_bytes() {
850        // 0xE9 is latin-1 "e-acute"; the Scala format is defined as latin-1.
851        let scale = ScalaScale::parse_bytes(b"Caf\xe9\n 1\n 2/1\n").unwrap();
852        assert_eq!(scale.description(), "Caf\u{e9}");
853    }
854    #[test]
855    fn round_trips_through_from_str() {
856        let scale: ScalaScale = FIFTH_AND_OCTAVE.parse().unwrap();
857        assert_eq!(scale.len(), 2);
858    }
859}