1use 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
36const CENTS_PER_OCTAVE: FloatType = 1200.0;
38
39#[derive(Clone, Copy, Debug, PartialEq)]
45#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
46pub enum ScalaDegree {
47 Ratio(Fraction),
49 Cents(FloatType),
51}
52
53impl ScalaDegree {
54 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 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 pub fn as_fraction(self) -> Option<Fraction> {
72 match self {
73 Self::Ratio(fraction) => Some(fraction),
74 Self::Cents(_) => None,
75 }
76 }
77
78 fn parse(token: &str) -> Result<Self> {
80 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 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 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
153fn 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#[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 #[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 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 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 pub fn parse_bytes(bytes: &[u8]) -> Result<Self> {
283 let text: String = bytes.iter().map(|&byte| byte as char).collect();
285 Self::parse(&text)
286 }
287
288 pub fn description(&self) -> &str {
290 &self.description
291 }
292
293 pub fn degrees(&self) -> &[ScalaDegree] {
295 &self.degrees
296 }
297
298 pub fn period(&self) -> ScalaDegree {
303 self.period
304 }
305
306 pub fn len(&self) -> usize {
308 self.degrees.len()
309 }
310
311 pub fn is_empty(&self) -> bool {
316 self.degrees.is_empty()
317 }
318
319 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 pub fn cents_above_root(&self, index: IntegerType) -> FloatType {
342 CENTS_PER_OCTAVE * self.ratio_at(index).log2()
343 }
344
345 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#[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 pub fn new() -> Self {
396 Self::default()
397 }
398
399 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 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 pub fn get(&self, file_name: &str) -> Option<&ScalaScale> {
425 self.scales.get(file_name)
426 }
427
428 pub fn names(&self) -> impl Iterator<Item = &str> {
430 self.scales.keys().map(String::as_str)
431 }
432
433 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 pub fn len(&self) -> usize {
442 self.scales.len()
443 }
444
445 pub fn is_empty(&self) -> bool {
447 self.scales.is_empty()
448 }
449
450 #[cfg(feature = "scala-archive")]
459 pub fn bundled() -> Self {
460 Self::bundled_with_failures().0
461 }
462
463 #[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 #[cfg(feature = "scala-archive")]
488 pub fn bundled_len() -> usize {
489 crate::tuningsystem::scala_bundled::SCALES.len()
490 }
491
492 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#[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 assert_eq!(names, [] as [&str; 0]);
569 assert_eq!(archive.len(), ScalaArchive::bundled_len());
570 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 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 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 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 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 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 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 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 "", "Only a description\n", "Bad count\n not-a-number\n", "Too few\n 4\n 3/2\n 2/1\n", "Bad ratio\n 1\n 3/0\n", "Bad ratio\n 1\n 1/2/3\n", ] {
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 assert_eq!(
819 archive.search("mbira"),
820 ["mbira_banda.scl", "mbira_banda2.scl", "mbira_zimb.scl"]
821 );
822 assert_eq!(
824 archive.search("mbira banda"),
825 ["mbira_banda.scl", "mbira_banda2.scl"]
826 );
827 assert_eq!(
829 archive.search("mbirabanda"),
830 ["mbira_banda.scl", "mbira_banda2.scl"]
831 );
832 assert_eq!(archive.search("PARTCH"), ["partch_43.scl"]);
834 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 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}