1use 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
37const CENTS_PER_OCTAVE: FloatType = 1200.0;
39
40#[derive(Clone, Copy, Debug, PartialEq)]
46#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
47#[must_use]
48pub enum ScalaDegree {
49 Ratio(Ratio),
52 Cents(FloatType),
54}
55
56impl ScalaDegree {
57 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 pub fn cents(self) -> FloatType {
67 match self {
68 Self::Ratio(ratio) => ratio.cents(),
69 Self::Cents(cents) => cents,
70 }
71 }
72
73 pub fn as_ratio(self) -> Option<Ratio> {
75 match self {
76 Self::Ratio(ratio) => Some(ratio),
77 Self::Cents(_) => None,
78 }
79 }
80
81 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 fn parse(token: &str) -> Result<Self> {
108 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 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 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
181fn 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#[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 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 #[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 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 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 pub fn parse_bytes(bytes: &[u8]) -> Result<Self> {
319 let text: String = bytes.iter().map(|&byte| byte as char).collect();
321 Self::parse(&text)
322 }
323
324 pub fn description(&self) -> &str {
326 &self.description
327 }
328
329 pub fn degrees(&self) -> &[ScalaDegree] {
331 &self.degrees
332 }
333
334 pub fn period(&self) -> ScalaDegree {
339 self.period
340 }
341
342 pub fn len(&self) -> usize {
344 self.degrees.len()
345 }
346
347 pub fn is_empty(&self) -> bool {
352 self.degrees.is_empty()
353 }
354
355 pub fn written_cents(&self) -> Vec<FloatType> {
365 self.written_degrees()
366 .iter()
367 .map(|degree| degree.cents())
368 .collect()
369 }
370
371 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 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 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 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 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 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 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 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 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 lines.push(String::new());
477 lines.join(
478 "
479",
480 )
481 }
482
483 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 pub fn cents_above_root(&self, index: IntegerType) -> FloatType {
506 CENTS_PER_OCTAVE * self.ratio_at(index).log2()
507 }
508
509 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 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#[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 pub fn new() -> Self {
573 Self::default()
574 }
575
576 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 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 pub fn get(&self, file_name: &str) -> Option<&ScalaScale> {
602 self.scales.get(file_name)
603 }
604
605 pub fn names(&self) -> impl Iterator<Item = &str> {
607 self.scales.keys().map(String::as_str)
608 }
609
610 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 pub fn len(&self) -> usize {
619 self.scales.len()
620 }
621
622 pub fn is_empty(&self) -> bool {
624 self.scales.is_empty()
625 }
626
627 #[cfg(feature = "scala-archive")]
636 pub fn bundled() -> Self {
637 Self::bundled_with_failures().0
638 }
639
640 #[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 #[cfg(feature = "scala-archive")]
665 pub fn bundled_len() -> usize {
666 crate::tuningsystem::scala_bundled::SCALES.len()
667 }
668
669 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 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#[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 assert_eq!(names, [] as [&str; 0]);
774 assert_eq!(archive.len(), ScalaArchive::bundled_len());
775 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 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 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 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 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 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 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 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 "", "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", ] {
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 assert_eq!(
1052 archive.search("mbira"),
1053 ["mbira_banda.scl", "mbira_banda2.scl", "mbira_zimb.scl"]
1054 );
1055 assert_eq!(
1057 archive.search("mbira banda"),
1058 ["mbira_banda.scl", "mbira_banda2.scl"]
1059 );
1060 assert_eq!(
1062 archive.search("mbirabanda"),
1063 ["mbira_banda.scl", "mbira_banda2.scl"]
1064 );
1065 assert_eq!(archive.search("PARTCH"), ["partch_43.scl"]);
1067 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 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 #[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 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 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 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 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 assert_eq!(found("badFileName.scl"), None);
1202
1203 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}