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