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::Ratio;
29use crate::defaults::{FloatType, IntegerType, UnsignedIntegerType};
30use crate::error::{Error, Result};
31use crate::interval::{ChromaticInterval, Interval};
32
33use std::collections::BTreeMap;
34use std::fmt::{Display, Formatter};
35use std::str::FromStr;
36
37/// Cents in one octave, used to convert between cents and frequency ratios.
38const CENTS_PER_OCTAVE: FloatType = 1200.0;
39
40/// A single degree of a [`ScalaScale`].
41///
42/// Scala files express each degree either as an exact ratio (`3/2`) or as a
43/// cents value (`701.955`). The distinction is preserved rather than collapsed,
44/// because a ratio carries exactness that cents cannot express.
45#[derive(Clone, Copy, Debug, PartialEq)]
46#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
47#[must_use]
48pub enum ScalaDegree {
49    /// An exact ratio, such as `3/2`. A Scala file writes only ratios of
50    /// whole numbers, so this is always [`Ratio::Rational`].
51    Ratio(Ratio),
52    /// A cents value above the scale root, such as `701.955`.
53    Cents(FloatType),
54}
55
56impl ScalaDegree {
57    /// Returns this degree as a frequency ratio above the scale root.
58    pub fn ratio(self) -> FloatType {
59        match self {
60            Self::Ratio(ratio) => ratio.value(),
61            Self::Cents(cents) => (2.0 as FloatType).powf(cents / CENTS_PER_OCTAVE),
62        }
63    }
64
65    /// Returns this degree in cents above the scale root.
66    pub fn cents(self) -> FloatType {
67        match self {
68            Self::Ratio(ratio) => ratio.cents(),
69            Self::Cents(cents) => cents,
70        }
71    }
72
73    /// Returns the exact ratio, when this degree was written as one.
74    pub fn as_ratio(self) -> Option<Ratio> {
75        match self {
76            Self::Ratio(ratio) => Some(ratio),
77            Self::Cents(_) => None,
78        }
79    }
80
81    /// The line a Scala file writes for this degree: the other half of the
82    /// reading [`FromStr`] does.
83    ///
84    /// Scala tells the two spellings apart by the point: a value carrying one
85    /// is cents and a value without one is a ratio, so a whole number of
86    /// cents is written `1200.0` and never `1200`, which would be a ratio of
87    /// twelve hundred to one.
88    pub fn written(self) -> String {
89        match self {
90            Self::Ratio(Ratio::Rational {
91                numerator,
92                denominator,
93            }) => format!("{numerator}/{denominator}"),
94            Self::Ratio(root @ Ratio::Root { .. }) => Self::Cents(root.cents()).written(),
95            Self::Cents(cents) => {
96                let written = format!("{cents}");
97                if written.contains(['.', 'e', 'E', 'n', 'i']) {
98                    written
99                } else {
100                    format!("{written}.0")
101                }
102            }
103        }
104    }
105
106    /// Parses one Scala note line.
107    fn parse(token: &str) -> Result<Self> {
108        // Anything after the value on a note line is a comment. A `!` opens one
109        // without needing whitespace before it, as in dyadic53tone9div.scl's
110        // `2957/2048!Gb`.
111        let token = token.split('!').next().unwrap_or_default();
112        let token = token.split_whitespace().next().unwrap_or_default();
113        if token.is_empty() {
114            return Err(Error::TuningSystem("empty scala degree".to_string()));
115        }
116
117        // Scala also writes equal-tempered steps as `n\m`, meaning `n` steps of
118        // `m`-EDO. `1\41` is one step of 41-EDO, or 29.268 cents.
119        if let Some((steps, divisions)) = token.split_once('\\') {
120            let steps: FloatType = steps.trim().parse().map_err(|_| {
121                Error::TuningSystem(format!("invalid scala step count in {token:?}"))
122            })?;
123            let divisions: FloatType = divisions.trim().parse().map_err(|_| {
124                Error::TuningSystem(format!("invalid scala division count in {token:?}"))
125            })?;
126            if divisions == 0.0 {
127                return Err(Error::TuningSystem(format!(
128                    "scala degree {token:?} divides the octave into zero steps"
129                )));
130            }
131            return Ok(Self::Cents(CENTS_PER_OCTAVE * steps / divisions));
132        }
133
134        if token.contains('.') {
135            let cents: FloatType = token
136                .parse()
137                .map_err(|_| Error::TuningSystem(format!("invalid scala cents value {token:?}")))?;
138            if !cents.is_finite() {
139                return Err(Error::TuningSystem(format!(
140                    "scala cents value {token:?} is not finite"
141                )));
142            }
143            return Ok(Self::Cents(cents));
144        }
145
146        let (numerator, denominator) = match token.split_once('/') {
147            Some((numerator, denominator)) => (numerator.trim(), denominator.trim()),
148            None => (token, "1"),
149        };
150
151        if let (Ok(numerator), Ok(denominator)) = (
152            numerator.parse::<UnsignedIntegerType>(),
153            denominator.parse::<UnsignedIntegerType>(),
154        ) && numerator != 0
155            && denominator != 0
156        {
157            return Ok(Self::Ratio(Ratio::new(numerator, denominator)));
158        }
159
160        // Four archive scales quote ratios far wider than the u32 `Ratio`
161        // can hold, such as atomschis.scl's
162        // 156348578434374084375/147573952589676412928. Rather than reject the
163        // whole file, keep the degree as cents - `as_ratio` then reports
164        // that it is not exact.
165        let (numerator, denominator) = (
166            parse_ratio_term(numerator, token, "numerator")?,
167            parse_ratio_term(denominator, token, "denominator")?,
168        );
169        if numerator <= 0.0 || denominator <= 0.0 {
170            return Err(Error::TuningSystem(format!(
171                "scala ratio {token:?} must be positive"
172            )));
173        }
174
175        Ok(Self::Cents(
176            CENTS_PER_OCTAVE * (numerator / denominator).log2(),
177        ))
178    }
179}
180
181/// Parses one side of a ratio that did not fit `Ratio`'s integer range.
182fn parse_ratio_term(term: &str, token: &str, side: &str) -> Result<FloatType> {
183    if term.is_empty() || !term.bytes().all(|byte| byte.is_ascii_digit()) {
184        return Err(Error::TuningSystem(format!(
185            "invalid scala ratio {side} in {token:?}"
186        )));
187    }
188    term.parse()
189        .map_err(|_| Error::TuningSystem(format!("invalid scala ratio {side} in {token:?}")))
190}
191
192impl Display for ScalaDegree {
193    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
194        match self {
195            Self::Ratio(fraction) => write!(f, "{fraction}"),
196            Self::Cents(cents) => write!(f, "{cents}"),
197        }
198    }
199}
200
201/// A scale parsed from a Scala `.scl` file.
202///
203/// The stored degrees follow this crate's convention rather than Scala's: the
204/// implicit `1/1` unison is made explicit at index 0, and the final entry of
205/// the file — the interval the scale repeats at — is held separately as the
206/// [period](Self::period) instead of being a degree. So a 12-note file yields
207/// 12 degrees plus a period, and `degrees()[0]` is always `1/1`.
208#[derive(Clone, Debug, PartialEq)]
209#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
210#[must_use]
211pub struct ScalaScale {
212    description: String,
213    degrees: Vec<ScalaDegree>,
214    period: ScalaDegree,
215}
216
217impl ScalaScale {
218    /// A scale from its parts: a description, the degrees with the unison
219    /// at index 0, and the interval it repeats at.
220    pub fn new(
221        description: impl Into<String>,
222        degrees: Vec<ScalaDegree>,
223        period: ScalaDegree,
224    ) -> Self {
225        Self {
226            description: description.into(),
227            degrees,
228            period,
229        }
230    }
231
232    /// [`Self::new`] under the name the bundled archive is emitted with.
233    #[cfg(feature = "scala-archive")]
234    pub(crate) fn from_parts(
235        description: String,
236        degrees: Vec<ScalaDegree>,
237        period: ScalaDegree,
238    ) -> Self {
239        Self::new(description, degrees, period)
240    }
241
242    /// Parses the contents of a `.scl` file.
243    ///
244    /// Both ratio and cents degrees are accepted. Lines beginning with `!` are
245    /// comments; the first non-comment line is the description, the second is
246    /// the degree count, and the rest are degrees.
247    pub fn parse(contents: &str) -> Result<Self> {
248        let lines: Vec<&str> = contents
249            .lines()
250            .map(str::trim)
251            .filter(|line| !line.starts_with('!'))
252            .collect();
253
254        let count_line = lines
255            .get(1)
256            .ok_or_else(|| Error::TuningSystem("scala file has no degree count".to_string()))?;
257        let count: usize = count_line
258            .split_whitespace()
259            .next()
260            .unwrap_or_default()
261            .parse()
262            .map_err(|_| {
263                Error::TuningSystem(format!("invalid scala degree count {count_line:?}"))
264            })?;
265        let entries: Vec<&str> = lines
266            .iter()
267            .skip(2)
268            .filter(|line| !line.is_empty())
269            .take(count)
270            .copied()
271            .collect();
272        if entries.len() != count {
273            return Err(Error::TuningSystem(format!(
274                "scala file declares {count} degrees but lists {}",
275                entries.len()
276            )));
277        }
278
279        let mut parsed = Vec::with_capacity(count);
280        for entry in entries {
281            parsed.push(ScalaDegree::parse(entry)?);
282        }
283
284        // Scala's last entry is the repeat interval, not a degree of the scale.
285        // A file may legitimately declare zero degrees, in which case there is
286        // neither a degree list nor a repeat interval to read.
287        let (mut degrees, period) = match parsed.pop() {
288            Some(period) => (vec![ScalaDegree::Ratio(Ratio::new(1, 1))], period),
289            None => (Vec::new(), ScalaDegree::Ratio(Ratio::new(1, 1))),
290        };
291        degrees.append(&mut parsed);
292
293        Ok(Self {
294            description: lines.first().unwrap_or(&"").trim().to_string(),
295            degrees,
296            period,
297        })
298    }
299
300    /// Parses the raw bytes of a `.scl` file.
301    ///
302    /// The Scala format is defined as latin-1 (ISO-8859-1), which music21's
303    /// `scale.scala` module states explicitly, and 73 of the ~3900 files it
304    /// ships are not valid UTF-8. Bytes are therefore decoded as latin-1 rather
305    /// than as UTF-8, so accented characters in description lines survive
306    /// instead of becoming replacement characters. Degree lines are ASCII in
307    /// either reading, so numbers are unaffected.
308    ///
309    /// This crate does no file IO of its own, so read the file yourself:
310    ///
311    /// ```no_run
312    /// use music21_rs::ScalaScale;
313    ///
314    /// let bytes = std::fs::read("partch_43.scl")?;
315    /// let scale = ScalaScale::parse_bytes(&bytes)?;
316    /// # Ok::<(), Box<dyn std::error::Error>>(())
317    /// ```
318    pub fn parse_bytes(bytes: &[u8]) -> Result<Self> {
319        // In latin-1 every byte is its own code point, so this cannot fail.
320        let text: String = bytes.iter().map(|&byte| byte as char).collect();
321        Self::parse(&text)
322    }
323
324    /// Returns the scale's description line.
325    pub fn description(&self) -> &str {
326        &self.description
327    }
328
329    /// Returns the degrees of one period, starting with `1/1`.
330    pub fn degrees(&self) -> &[ScalaDegree] {
331        &self.degrees
332    }
333
334    /// Returns the interval the scale repeats at.
335    ///
336    /// This is usually an octave, but need not be — Bohlen-Pierce repeats at
337    /// `3/1`, and non-octave scales are common in the Scala archive.
338    pub fn period(&self) -> ScalaDegree {
339        self.period
340    }
341
342    /// Returns the number of degrees in one period.
343    pub fn len(&self) -> usize {
344        self.degrees.len()
345    }
346
347    /// Returns whether the scale has no degrees.
348    ///
349    /// A Scala file may declare zero degrees — the archive's `xxx.scl` does,
350    /// and music21 reads it as a scale with no pitches.
351    pub fn is_empty(&self) -> bool {
352        self.degrees.is_empty()
353    }
354
355    /// The cents of every degree the file writes: music21's
356    /// `getCentsAboveTonic`.
357    ///
358    /// The unison is left out and the period is the last of them, which is
359    /// how a `.scl` file writes a scale — twelve lines for a twelve-tone
360    /// scale, the last of them the octave. [`cents_above_root`] is the other
361    /// reading, the cents of an index counted from the root.
362    ///
363    /// [`cents_above_root`]: Self::cents_above_root
364    pub fn written_cents(&self) -> Vec<FloatType> {
365        self.written_degrees()
366            .iter()
367            .map(|degree| degree.cents())
368            .collect()
369    }
370
371    /// The degrees a `.scl` file writes for this scale: every degree above
372    /// the unison, and the period last. A scale with no degrees writes none.
373    pub fn written_degrees(&self) -> Vec<ScalaDegree> {
374        if self.degrees.is_empty() {
375            return Vec::new();
376        }
377        self.degrees
378            .iter()
379            .skip(1)
380            .copied()
381            .chain(std::iter::once(self.period))
382            .collect()
383    }
384
385    /// The width of each step in cents: music21's `getAdjacentCents`.
386    ///
387    /// One width per degree the file writes, the first being the step off the
388    /// unison and the last the step that closes the period.
389    pub fn adjacent_cents(&self) -> Vec<FloatType> {
390        let mut widths = Vec::with_capacity(self.degrees.len());
391        let mut below = 0.0;
392        for cents in self.written_cents() {
393            widths.push(cents - below);
394            below = cents;
395        }
396        widths
397    }
398
399    /// A scale from the cents of every degree a file writes, the period last:
400    /// the reverse of [`written_cents`](Self::written_cents).
401    pub fn from_written_cents(description: impl Into<String>, cents: &[FloatType]) -> Self {
402        let written = cents.iter().copied().map(ScalaDegree::Cents).collect();
403        Self::from_written(description, written)
404    }
405
406    /// A scale from the width of each step, in cents: music21's
407    /// `setAdjacentCents`.
408    ///
409    /// The last width closes the period, so `[100.0; 12]` is twelve-tone
410    /// equal temperament.
411    pub fn from_adjacent_cents(description: impl Into<String>, widths: &[FloatType]) -> Self {
412        let mut above = 0.0;
413        let written: Vec<FloatType> = widths
414            .iter()
415            .map(|width| {
416                above += width;
417                above
418            })
419            .collect();
420        Self::from_written_cents(description, &written)
421    }
422
423    /// Each step of the scale as an interval: music21's
424    /// `getIntervalSequence`.
425    ///
426    /// A step is measured in cents and an interval counts semitones, which
427    /// [`ChromaticInterval`] counts fractionally — so a step of 137 cents is a
428    /// second a little over a third of a semitone wide, and says so.
429    pub fn interval_sequence(&self) -> Result<Vec<Interval>> {
430        self.adjacent_cents()
431            .into_iter()
432            .map(|cents| Interval::from_chromatic(ChromaticInterval::new(cents / 100.0)?))
433            .collect()
434    }
435
436    /// A scale from an interval per step: music21's `setIntervalSequence`.
437    pub fn from_interval_sequence(description: impl Into<String>, steps: &[Interval]) -> Self {
438        let widths: Vec<FloatType> = steps.iter().map(Interval::cents).collect();
439        Self::from_adjacent_cents(description, &widths)
440    }
441
442    /// A scale from the degrees a file writes, the period last.
443    fn from_written(description: impl Into<String>, mut written: Vec<ScalaDegree>) -> Self {
444        let (degrees, period) = match written.pop() {
445            Some(period) => {
446                let mut degrees = vec![ScalaDegree::Ratio(Ratio::new(1, 1))];
447                degrees.append(&mut written);
448                (degrees, period)
449            }
450            None => (Vec::new(), ScalaDegree::Ratio(Ratio::new(1, 1))),
451        };
452        Self::new(description, degrees, period)
453    }
454
455    /// The text of a `.scl` file for this scale: music21's `getFileString`.
456    ///
457    /// The name is written as the first comment where one is given, as the
458    /// archive's own files write it. A degree that was read as a ratio is
459    /// written back as that ratio — both spellings are Scala, and only this
460    /// one reads back as the scale it came from; music21 keeps cents alone
461    /// and so cannot.
462    pub fn file_string(&self, file_name: Option<&str>) -> String {
463        let mut lines = Vec::with_capacity(self.degrees.len() + 5);
464        if let Some(name) = file_name {
465            lines.push(format!("! {name}"));
466        }
467        // Conventionally a bare comment line follows the name.
468        lines.push("!".to_string());
469        lines.push(self.description.clone());
470        lines.push(self.len().to_string());
471        lines.push("!".to_string());
472        for degree in self.written_degrees() {
473            lines.push(degree.written());
474        }
475        // The file ends with a newline.
476        lines.push(String::new());
477        lines.join(
478            "
479",
480        )
481    }
482
483    /// Returns the frequency ratio above the root for a degree index.
484    ///
485    /// Indices outside one period wrap, shifting by the [period](Self::period)
486    /// for each wrap. Negative indices run below the root.
487    ///
488    /// A scale with no degrees has nothing to wrap through, so every index
489    /// returns the root ratio of `1.0`.
490    pub fn ratio_at(&self, index: IntegerType) -> FloatType {
491        if self.degrees.is_empty() {
492            return 1.0;
493        }
494        let len = self.degrees.len() as IntegerType;
495        let periods = index.div_euclid(len);
496        let degree = index.rem_euclid(len) as usize;
497        self.degrees[degree].ratio() * self.period.ratio().powi(periods)
498    }
499
500    /// Returns the cents above the root for a degree index.
501    ///
502    /// Note this is absolute distance from the scale root, unlike
503    /// [`TuningSystem::cents_at`](super::TuningSystem::cents_at), which reports
504    /// deviation from equal temperament.
505    pub fn cents_above_root(&self, index: IntegerType) -> FloatType {
506        CENTS_PER_OCTAVE * self.ratio_at(index).log2()
507    }
508
509    /// Returns the frequency in hertz for a degree index, given a root pitch.
510    ///
511    /// A Scala scale fixes no absolute pitch, so the root is supplied by the
512    /// caller.
513    pub fn frequency_at(&self, root_hz: FloatType, index: IntegerType) -> FloatType {
514        root_hz * self.ratio_at(index)
515    }
516}
517
518impl FromStr for ScalaDegree {
519    type Err = Error;
520
521    /// Reads one note line of a Scala file: music21's `ScalaPitch.parse`.
522    ///
523    /// A value carrying a point is cents and one without is a ratio, `n\m`
524    /// is `n` steps of `m`-EDO, and anything after the value is a comment.
525    fn from_str(token: &str) -> Result<Self> {
526        Self::parse(token)
527    }
528}
529
530impl FromStr for ScalaScale {
531    type Err = Error;
532
533    fn from_str(contents: &str) -> Result<Self> {
534        Self::parse(contents)
535    }
536}
537
538impl Display for ScalaScale {
539    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
540        write!(f, "{} ({} degrees)", self.description, self.degrees.len())
541    }
542}
543
544/// A searchable collection of Scala scales, keyed by file name.
545///
546/// This is the runtime counterpart to music21's `scale.scala` module, whose
547/// `search` and `getPaths` helpers index the Scala scale archive. The archive
548/// itself is **not** bundled with this crate — see the note below — so the
549/// caller supplies the files and this type indexes whatever it is given, which
550/// also keeps the crate free of file IO and usable from wasm.
551///
552/// ```
553/// use music21_rs::ScalaArchive;
554///
555/// let mut archive = ScalaArchive::new();
556/// archive.insert("mbira_banda.scl", b"Mbira Banda\n 1\n 2/1\n")?;
557/// archive.insert("slendro5_2.scl", b"Slendro\n 1\n 2/1\n")?;
558///
559/// assert_eq!(archive.search("mbira"), ["mbira_banda.scl"]);
560/// assert!(archive.get("slendro5_2.scl").is_some());
561/// # Ok::<(), music21_rs::Error>(())
562/// ```
563#[derive(Clone, Debug, Default, PartialEq)]
564#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
565#[must_use]
566pub struct ScalaArchive {
567    scales: BTreeMap<String, ScalaScale>,
568}
569
570impl ScalaArchive {
571    /// Creates an empty archive.
572    pub fn new() -> Self {
573        Self::default()
574    }
575
576    /// Parses and indexes one `.scl` file under the given name.
577    ///
578    /// The name is the file name as music21 refers to it, such as
579    /// `"partch_43.scl"`. Returns the scale it replaced, if any.
580    pub fn insert(
581        &mut self,
582        file_name: impl Into<String>,
583        bytes: &[u8],
584    ) -> Result<Option<ScalaScale>> {
585        let file_name = file_name.into();
586        let scale = ScalaScale::parse_bytes(bytes)
587            .map_err(|error| Error::TuningSystem(format!("{file_name}: {error}")))?;
588        Ok(self.scales.insert(file_name, scale))
589    }
590
591    /// Indexes an already-parsed scale under the given name.
592    pub fn insert_scale(
593        &mut self,
594        file_name: impl Into<String>,
595        scale: ScalaScale,
596    ) -> Option<ScalaScale> {
597        self.scales.insert(file_name.into(), scale)
598    }
599
600    /// Returns the scale stored under a file name.
601    pub fn get(&self, file_name: &str) -> Option<&ScalaScale> {
602        self.scales.get(file_name)
603    }
604
605    /// Returns every indexed file name, in sorted order.
606    pub fn names(&self) -> impl Iterator<Item = &str> {
607        self.scales.keys().map(String::as_str)
608    }
609
610    /// Returns every indexed scale with its file name, in sorted order.
611    pub fn iter(&self) -> impl Iterator<Item = (&str, &ScalaScale)> {
612        self.scales
613            .iter()
614            .map(|(name, scale)| (name.as_str(), scale))
615    }
616
617    /// Returns the number of indexed scales.
618    pub fn len(&self) -> usize {
619        self.scales.len()
620    }
621
622    /// Returns whether the archive is empty.
623    pub fn is_empty(&self) -> bool {
624        self.scales.is_empty()
625    }
626
627    /// Builds an archive from the `.scl` files bundled with the crate.
628    ///
629    /// Only available under the non-default `scala-archive` feature, which adds
630    /// roughly a megabyte of scale data to the build. Without it, supply the
631    /// files yourself with [`ScalaArchive::insert`].
632    ///
633    /// Use [`ScalaArchive::bundled_with_failures`] to see any scale that fails
634    /// to load rather than silently dropping it.
635    #[cfg(feature = "scala-archive")]
636    pub fn bundled() -> Self {
637        Self::bundled_with_failures().0
638    }
639
640    /// Builds an archive from the bundled files, reporting the ones that fail.
641    ///
642    /// The failures are a fixed property of the bundled data, so the returned
643    /// list is the same on every call.
644    #[cfg(feature = "scala-archive")]
645    pub fn bundled_with_failures() -> (Self, Vec<(&'static str, Error)>) {
646        let mut archive = Self::new();
647        let mut failures = Vec::new();
648        for (file_name, description, degrees, period) in crate::tuningsystem::scala_bundled::SCALES
649        {
650            match build_bundled(description, degrees, period) {
651                Ok(scale) => {
652                    archive.insert_scale(file_name, scale);
653                }
654                Err(error) => failures.push((file_name, error)),
655            }
656        }
657        (archive, failures)
658    }
659
660    /// Returns the number of `.scl` files bundled with the crate.
661    ///
662    /// Counts files without parsing them, so this includes the one that
663    /// [`ScalaArchive::bundled`] skips.
664    #[cfg(feature = "scala-archive")]
665    pub fn bundled_len() -> usize {
666        crate::tuningsystem::scala_bundled::SCALES.len()
667    }
668
669    /// The one scale a name names, with the file name it is filed under:
670    /// the searching half of music21's `scale.scala.parse`.
671    ///
672    /// A file name is looked for first, then a name written without its
673    /// extension or its underscores and hyphens, and failing both the first
674    /// file whose name contains what was asked for. Reading a file from disk
675    /// is the caller's business; this looks in the archive.
676    pub fn find(&self, target: &str) -> Option<(&str, &ScalaScale)> {
677        let target = target.replace(' ', "").to_lowercase();
678        let named = self
679            .scales
680            .keys()
681            .find(|name| name.to_lowercase() == target);
682        let found = named.or_else(|| {
683            self.scales.keys().find(|name| {
684                let stem = name.strip_suffix(".scl").unwrap_or(name).to_lowercase();
685                stem == target || stem.replace(['_', '-'], "") == target
686            })
687        });
688        let found = found.or_else(|| {
689            self.scales.keys().find(|name| {
690                let stem = name.strip_suffix(".scl").unwrap_or(name).to_lowercase();
691                stem.contains(&target) || stem.replace(['_', '-'], "").contains(&target)
692            })
693        })?;
694        self.scales
695            .get_key_value(found.as_str())
696            .map(|(name, scale)| (name.as_str(), scale))
697    }
698
699    /// Finds file names matching a search string, as music21's
700    /// `scale.scala.search` does.
701    ///
702    /// Spaces in the target are ignored, an exact file-name match is preferred,
703    /// and the remaining matches are substring hits against the name with its
704    /// extension dropped and against a form with `_` and `-` removed. Results
705    /// are sorted.
706    pub fn search(&self, target: &str) -> Vec<&str> {
707        let target = target.replace(' ', "").to_lowercase();
708        let mut matches = Vec::new();
709
710        for name in self.scales.keys() {
711            if name.to_lowercase() == target {
712                matches.push(name.as_str());
713            }
714        }
715
716        for name in self.scales.keys() {
717            if matches.contains(&name.as_str()) {
718                continue;
719            }
720            let stem = name.strip_suffix(".scl").unwrap_or(name).to_lowercase();
721            let squashed = stem.replace(['_', '-'], "");
722            if stem.contains(&target) || squashed.contains(&target) {
723                matches.push(name.as_str());
724            }
725        }
726
727        matches.sort_unstable();
728        matches
729    }
730}
731
732/// Rebuilds a [`ScalaScale`] from the tokens emitted into `scala_bundled`.
733///
734/// The generator already split description, degrees and period, so this only
735/// has to parse each degree token — no Scala file structure is involved.
736#[cfg(feature = "scala-archive")]
737fn build_bundled(description: &str, degrees: &[&str], period: &str) -> Result<ScalaScale> {
738    let degrees = degrees
739        .iter()
740        .map(|token| ScalaDegree::parse(token))
741        .collect::<Result<Vec<_>>>()?;
742    Ok(ScalaScale::from_parts(
743        description.to_string(),
744        degrees,
745        ScalaDegree::parse(period)?,
746    ))
747}
748
749impl Extend<(String, ScalaScale)> for ScalaArchive {
750    fn extend<T: IntoIterator<Item = (String, ScalaScale)>>(&mut self, iter: T) {
751        self.scales.extend(iter);
752    }
753}
754
755impl FromIterator<(String, ScalaScale)> for ScalaArchive {
756    fn from_iter<T: IntoIterator<Item = (String, ScalaScale)>>(iter: T) -> Self {
757        Self {
758            scales: iter.into_iter().collect(),
759        }
760    }
761}
762
763#[cfg(all(test, feature = "scala-archive"))]
764mod bundled_tests {
765    use super::ScalaArchive;
766
767    #[test]
768    fn the_whole_bundled_archive_parses() {
769        let (archive, failures) = ScalaArchive::bundled_with_failures();
770        let names: Vec<&str> = failures.iter().map(|(name, _)| *name).collect();
771        // Every scale in the bundle parses. `xxx.scl` declares zero degrees,
772        // which is legal Scala and which music21 accepts too.
773        assert_eq!(names, [] as [&str; 0]);
774        assert_eq!(archive.len(), ScalaArchive::bundled_len());
775        // 3932 from the music21 submodule plus 62 from the hexatone one.
776        assert_eq!(ScalaArchive::bundled_len(), 3994);
777    }
778
779    #[test]
780    fn the_bundled_archive_carries_the_scales_the_tuning_tables_cite() {
781        let archive = ScalaArchive::bundled();
782        for name in [
783            "partch_43.scl",
784            "partch_29.scl",
785            "werck3.scl",
786            "vallotti.scl",
787            "meanquar.scl",
788            "ptolemy.scl",
789            "pyth_12.scl",
790            "kirnberger3.scl",
791            "rameau.scl",
792            "young2.scl",
793            "carlos_harm.scl",
794            "riley_albion.scl",
795            "indian.scl",
796            "indian-sagrama.scl",
797        ] {
798            assert!(
799                archive.get(name).is_some(),
800                "{name} missing from the bundle"
801            );
802        }
803    }
804
805    #[test]
806    fn bundled_file_names_are_sorted_and_unique() {
807        let names: Vec<&str> = crate::tuningsystem::scala_bundled::SCALES
808            .iter()
809            .map(|(name, _, _, _)| *name)
810            .collect();
811        let mut sorted = names.clone();
812        sorted.sort_unstable();
813        sorted.dedup();
814        assert_eq!(names, sorted, "bundled file list must be sorted and unique");
815    }
816}
817
818#[cfg(test)]
819mod tests {
820    #[test]
821    fn an_archive_is_built_from_scales_and_walked() {
822        use super::{ScalaArchive, ScalaDegree, ScalaScale};
823
824        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";
825        let scale = ScalaScale::parse(text).unwrap();
826        assert_eq!(scale.to_string(), "Five equal steps (5 degrees)");
827        assert_eq!(scale.degrees()[1].to_string(), "240");
828        assert_eq!(scale.period().to_string(), "2");
829
830        let mut archive = ScalaArchive::new();
831        assert!(archive.is_empty());
832        assert!(archive.insert_scale("five.scl", scale.clone()).is_none());
833        assert!(archive.insert_scale("five.scl", scale.clone()).is_some());
834        archive.extend([("six.scl".to_string(), scale.clone())]);
835        assert_eq!(archive.len(), 2);
836        let names: Vec<&str> = archive.iter().map(|(name, _)| name).collect();
837        assert!(names.contains(&"five.scl") && names.contains(&"six.scl"));
838        let collected: ScalaArchive = [("one.scl".to_string(), scale)].into_iter().collect();
839        assert_eq!(collected.len(), 1);
840        assert!(!collected.is_empty());
841
842        assert!(ScalaScale::parse("Bad\n 1\n 3//2\n").is_err());
843        assert!(matches!(
844            ScalaScale::parse("Cents\n 1\n 701.955\n").unwrap().period(),
845            ScalaDegree::Cents(_)
846        ));
847    }
848
849    #[test]
850    fn accepts_a_scale_declaring_zero_degrees() {
851        // music21 reads the archive's xxx.scl as a scale with no pitches, so
852        // the count line being `0` is legal rather than malformed.
853        let scale = ScalaScale::parse(
854            "! xxx.scl
855!
856Saved scale from Scala
857 0
858!
859",
860        )
861        .expect("a zero-degree file parses");
862        assert!(scale.is_empty());
863        assert_eq!(scale.len(), 0);
864        assert_eq!(scale.description(), "Saved scale from Scala");
865        // Nothing to wrap through, so every index is the root.
866        assert_eq!(scale.ratio_at(0), 1.0);
867        assert_eq!(scale.ratio_at(7), 1.0);
868        assert_eq!(scale.ratio_at(-3), 1.0);
869    }
870
871    use super::*;
872
873    const FIFTH_AND_OCTAVE: &str = "! example.scl\n!\nA fifth and an octave\n 2\n!\n 3/2\n 2/1\n";
874
875    #[test]
876    fn parses_ratios_into_the_crate_convention() {
877        let scale = ScalaScale::parse(FIFTH_AND_OCTAVE).unwrap();
878        assert_eq!(scale.description(), "A fifth and an octave");
879        assert_eq!(scale.len(), 2);
880        assert_eq!(
881            scale.degrees(),
882            &[
883                ScalaDegree::Ratio(Ratio::new(1, 1)),
884                ScalaDegree::Ratio(Ratio::new(3, 2)),
885            ]
886        );
887        assert_eq!(scale.period(), ScalaDegree::Ratio(Ratio::new(2, 1)));
888    }
889
890    #[test]
891    fn wraps_indices_by_the_period_in_both_directions() {
892        let scale = ScalaScale::parse(FIFTH_AND_OCTAVE).unwrap();
893        assert_eq!(scale.ratio_at(0), 1.0);
894        assert_eq!(scale.ratio_at(1), 1.5);
895        assert_eq!(scale.ratio_at(2), 2.0);
896        assert_eq!(scale.ratio_at(3), 3.0);
897        assert_eq!(scale.ratio_at(-2), 0.5);
898        assert_eq!(scale.ratio_at(-1), 0.75);
899    }
900
901    #[test]
902    fn honours_a_non_octave_period() {
903        // Bohlen-Pierce repeats at a tritave rather than an octave.
904        let scale = ScalaScale::parse("Tritave\n 2\n 5/3\n 3/1\n").unwrap();
905        assert_eq!(scale.period(), ScalaDegree::Ratio(Ratio::new(3, 1)));
906        assert_eq!(scale.ratio_at(2), 3.0);
907        assert_eq!(scale.ratio_at(4), 9.0);
908    }
909
910    #[test]
911    fn accepts_cents_degrees() {
912        let scale = ScalaScale::parse("Cents\n 2\n 701.955\n 1200.0\n").unwrap();
913        assert_eq!(scale.degrees()[1], ScalaDegree::Cents(701.955));
914        assert!((scale.ratio_at(1) - 1.5).abs() < 1e-6);
915        assert!((scale.ratio_at(2) - 2.0).abs() < 1e-9);
916    }
917
918    #[test]
919    fn mixes_ratio_and_cents_degrees() {
920        let scale = ScalaScale::parse("Mixed\n 3\n 100.0\n 3/2\n 2/1\n").unwrap();
921        assert!(matches!(scale.degrees()[1], ScalaDegree::Cents(_)));
922        assert!(matches!(scale.degrees()[2], ScalaDegree::Ratio(_)));
923        assert!((scale.cents_above_root(2) - 701.955).abs() < 1e-3);
924    }
925
926    #[test]
927    fn treats_a_bare_integer_as_a_whole_ratio() {
928        let scale = ScalaScale::parse("Integers\n 2\n 3\n 4\n").unwrap();
929        assert_eq!(scale.degrees()[1], ScalaDegree::Ratio(Ratio::new(3, 1)));
930        assert_eq!(scale.period(), ScalaDegree::Ratio(Ratio::new(4, 1)));
931    }
932
933    #[test]
934    fn ignores_trailing_comments_on_degree_lines() {
935        let scale = ScalaScale::parse("Commented\n 2\n 3/2 the fifth\n 2/1 octave\n").unwrap();
936        assert_eq!(scale.ratio_at(1), 1.5);
937    }
938
939    #[test]
940    fn ignores_a_bang_comment_with_no_leading_space() {
941        // dyadic53tone9div.scl writes degrees as `2957/2048!Gb`.
942        let scale = ScalaScale::parse("Bang\n 2\n 3/2!the fifth\n 2/1!octave\n").unwrap();
943        assert_eq!(scale.ratio_at(1), 1.5);
944        assert_eq!(scale.period(), ScalaDegree::Ratio(Ratio::new(2, 1)));
945    }
946
947    #[test]
948    fn keeps_an_empty_description_line() {
949        let scale = ScalaScale::parse("\n 1\n 2/1\n").unwrap();
950        assert_eq!(scale.description(), "");
951        assert_eq!(scale.len(), 1);
952    }
953
954    #[test]
955    fn computes_frequencies_from_a_caller_supplied_root() {
956        let scale = ScalaScale::parse(FIFTH_AND_OCTAVE).unwrap();
957        assert!((scale.frequency_at(440.0, 1) - 660.0).abs() < 1e-9);
958        assert!((scale.frequency_at(440.0, 2) - 880.0).abs() < 1e-9);
959    }
960
961    #[test]
962    fn falls_back_to_cents_for_ratios_too_wide_for_u32() {
963        // First degree of atomschis.scl, whose numerator and denominator are
964        // both far past u32.
965        let scale = ScalaScale::parse(
966            "Atom Schisma\n 2\n 156348578434374084375/147573952589676412928\n 2/1\n",
967        )
968        .unwrap();
969
970        let degree = scale.degrees()[1];
971        assert!(degree.as_ratio().is_none(), "not exactly representable");
972        assert!(
973            (degree.cents() - 99.993_599_6).abs() < 1e-6,
974            "{}",
975            degree.cents()
976        );
977    }
978
979    #[test]
980    fn parses_latin1_description_bytes_lossily() {
981        // 0xE9 is Latin-1 "e-acute" and is not valid UTF-8 on its own.
982        let bytes = b"Caf\xe9 scale\n 1\n 2/1\n";
983        let scale = ScalaScale::parse_bytes(bytes).unwrap();
984        assert!(scale.description().starts_with("Caf"));
985        assert_eq!(scale.len(), 1);
986    }
987
988    #[test]
989    fn reads_equal_tempered_step_notation() {
990        // `n\\m` is n steps of m-EDO, which several archives use for EDO files.
991        let scale = ScalaScale::parse("41-EDO\n 2\n 1\\41\n 41\\41\n").unwrap();
992        assert!((scale.degrees()[1].cents() - 1200.0 / 41.0).abs() < 1e-9);
993        assert!((scale.period().cents() - 1200.0).abs() < 1e-9);
994    }
995
996    #[test]
997    fn rejects_a_zero_division_step() {
998        assert!(ScalaScale::parse("Bad\n 1\\0\n").is_err());
999    }
1000
1001    #[test]
1002    fn rejects_malformed_input() {
1003        for bad in [
1004            "",                           // nothing at all
1005            "Only a description\n",       // no count
1006            "Bad count\n not-a-number\n", // unparseable count
1007            "Too few\n 4\n 3/2\n 2/1\n",  // count exceeds the listed degrees
1008            "Bad ratio\n 1\n 3/0\n",      // zero denominator
1009            "Bad ratio\n 1\n 1/2/3\n",    // not a ratio
1010        ] {
1011            assert!(
1012                ScalaScale::parse(bad).is_err(),
1013                "expected {bad:?} to be rejected"
1014            );
1015        }
1016    }
1017
1018    fn archive_of(names: &[&str]) -> ScalaArchive {
1019        let mut archive = ScalaArchive::new();
1020        for name in names {
1021            archive
1022                .insert(*name, b"A scale\n 1\n 2/1\n")
1023                .expect("fixture scale parses");
1024        }
1025        archive
1026    }
1027
1028    #[test]
1029    fn archive_indexes_and_retrieves_by_file_name() {
1030        let archive = archive_of(&["partch_43.scl", "slendro5_2.scl"]);
1031        assert_eq!(archive.len(), 2);
1032        assert!(archive.get("partch_43.scl").is_some());
1033        assert!(archive.get("missing.scl").is_none());
1034        assert_eq!(
1035            archive.names().collect::<Vec<_>>(),
1036            ["partch_43.scl", "slendro5_2.scl"]
1037        );
1038    }
1039
1040    #[test]
1041    fn archive_search_matches_music21_semantics() {
1042        let archive = archive_of(&[
1043            "mbira_banda.scl",
1044            "mbira_banda2.scl",
1045            "mbira_zimb.scl",
1046            "slendro5_2.scl",
1047            "partch_43.scl",
1048        ]);
1049
1050        // Substring hit against the stem.
1051        assert_eq!(
1052            archive.search("mbira"),
1053            ["mbira_banda.scl", "mbira_banda2.scl", "mbira_zimb.scl"]
1054        );
1055        // Spaces in the target are ignored.
1056        assert_eq!(
1057            archive.search("mbira banda"),
1058            ["mbira_banda.scl", "mbira_banda2.scl"]
1059        );
1060        // Underscores and hyphens are ignored on the indexed side.
1061        assert_eq!(
1062            archive.search("mbirabanda"),
1063            ["mbira_banda.scl", "mbira_banda2.scl"]
1064        );
1065        // Matching is case-insensitive.
1066        assert_eq!(archive.search("PARTCH"), ["partch_43.scl"]);
1067        // An exact file name matches.
1068        assert_eq!(archive.search("slendro5_2.scl"), ["slendro5_2.scl"]);
1069        assert!(archive.search("nothing-here").is_empty());
1070    }
1071
1072    #[test]
1073    fn archive_reports_the_offending_file_on_a_parse_error() {
1074        let mut archive = ScalaArchive::new();
1075        let error = archive
1076            .insert("broken.scl", b"Broken\n not-a-number\n")
1077            .expect_err("should reject");
1078        assert!(error.to_string().contains("broken.scl"), "{error}");
1079    }
1080
1081    #[test]
1082    fn decodes_latin1_description_bytes() {
1083        // 0xE9 is latin-1 "e-acute"; the Scala format is defined as latin-1.
1084        let scale = ScalaScale::parse_bytes(b"Caf\xe9\n 1\n 2/1\n").unwrap();
1085        assert_eq!(scale.description(), "Caf\u{e9}");
1086    }
1087    #[test]
1088    fn round_trips_through_from_str() {
1089        let scale: ScalaScale = FIFTH_AND_OCTAVE.parse().unwrap();
1090        assert_eq!(scale.len(), 2);
1091    }
1092
1093    /// music21's own `balafon6.scl`, and every expectation here was read off
1094    /// music21's `scale.scala`.
1095    #[test]
1096    fn a_scale_is_read_and_written_by_the_width_of_its_steps() {
1097        use super::ScalaScale;
1098
1099        let text = concat!(
1100            "! balafon6.scl
1101!
1102",
1103            "Observed balafon tuning from Burma, Helmholtz/Ellis p. 518, nr.84
1104",
1105            " 7
1106!
1107",
1108            " 114.00000
1109 350.00000
1110 550.00000
1111 687.00000
1112",
1113            " 838.00000
1114 1032.00000
1115 1196.00000
1116",
1117        );
1118        let scale = ScalaScale::parse(text).unwrap();
1119        assert_eq!(scale.len(), 7);
1120        assert_eq!(
1121            scale.written_cents(),
1122            [114.0, 350.0, 550.0, 687.0, 838.0, 1032.0, 1196.0]
1123        );
1124        assert_eq!(
1125            scale.adjacent_cents(),
1126            [114.0, 236.0, 200.0, 137.0, 151.0, 194.0, 164.0]
1127        );
1128        let written: Vec<String> = scale
1129            .interval_sequence()
1130            .unwrap()
1131            .iter()
1132            .map(ToString::to_string)
1133            .collect();
1134        assert_eq!(
1135            written,
1136            [
1137                "m2 (+14c)",
1138                "M2 (+36c)",
1139                "M2",
1140                "m2 (+37c)",
1141                "M2 (-49c)",
1142                "M2 (-6c)",
1143                "M2 (-36c)"
1144            ]
1145        );
1146
1147        // The widths say the whole scale, so a scale built from them is the
1148        // one they came from.
1149        let rebuilt = ScalaScale::from_adjacent_cents(scale.description(), &scale.adjacent_cents());
1150        assert_eq!(rebuilt.written_cents(), scale.written_cents());
1151        let by_interval =
1152            ScalaScale::from_interval_sequence("steps", &scale.interval_sequence().unwrap());
1153        assert_eq!(by_interval.len(), 7);
1154
1155        // A file written out is read back as the scale it came from, cents
1156        // and ratios alike.
1157        let over = ScalaScale::parse(
1158            "! ji.scl
1159Just
1160 3
1161 5/4
1162 701.955
1163 2/1
1164",
1165        )
1166        .unwrap();
1167        let out = over.file_string(Some("ji.scl"));
1168        assert!(out.starts_with(
1169            "! ji.scl
1170!
1171Just
11723
1173!
11745/4
1175701.955
11762/1
1177"
1178        ));
1179        assert_eq!(ScalaScale::parse(&out).unwrap(), over);
1180
1181        // Every name here is one music21's own `scale.scala.parse` resolves
1182        // the same way, in an archive filed under the same names.
1183        let mut archive = super::ScalaArchive::new();
1184        for name in [
1185            "mbira_banda.scl",
1186            "barbour_chrom1.scl",
1187            "blackj_gws.scl",
1188            "fj-12tet.scl",
1189        ] {
1190            let _ = archive.insert_scale(name, scale.clone());
1191        }
1192        let found = |target| archive.find(target).map(|(name, _)| name);
1193        // A name written with a space, an underscore or neither.
1194        assert_eq!(found("mbira banda"), Some("mbira_banda.scl"));
1195        assert_eq!(found("mbira_banda"), Some("mbira_banda.scl"));
1196        assert_eq!(found("MBIRA_BANDA.SCL"), Some("mbira_banda.scl"));
1197        assert_eq!(found("barbourChrom1"), Some("barbour_chrom1.scl"));
1198        assert_eq!(found("blackj_gws.scl"), Some("blackj_gws.scl"));
1199        assert_eq!(found("fj-12tet.scl"), Some("fj-12tet.scl"));
1200        // A name that names nothing, rather than the nearest thing to it.
1201        assert_eq!(found("badFileName.scl"), None);
1202
1203        // A scale with no degrees writes none, and music21 reads the
1204        // archive's own `xxx.scl` that way.
1205        let empty = ScalaScale::parse(
1206            "Nothing
1207 0
1208",
1209        )
1210        .unwrap();
1211        assert!(empty.written_cents().is_empty());
1212        assert!(empty.adjacent_cents().is_empty());
1213        assert_eq!(
1214            empty.file_string(None),
1215            "!
1216Nothing
12170
1218!
1219"
1220        );
1221    }
1222}