1use alloc::string::{String, ToString};
14use alloc::vec::Vec;
15use libm::Libm;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum ScalaError {
20 Empty,
22 MissingCount,
24 BadCount(String),
26 BadPitch(String),
28 CountMismatch {
30 expected: usize,
32 found: usize,
34 },
35}
36
37impl core::fmt::Display for ScalaError {
38 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
39 match self {
40 ScalaError::Empty => write!(f, "scala: empty file"),
41 ScalaError::MissingCount => write!(f, "scala: missing note count"),
42 ScalaError::BadCount(s) => write!(f, "scala: invalid note count '{s}'"),
43 ScalaError::BadPitch(s) => write!(f, "scala: invalid pitch '{s}'"),
44 ScalaError::CountMismatch { expected, found } => {
45 write!(f, "scala: expected {expected} pitches, found {found}")
46 }
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq)]
53pub struct ScalaScale {
54 pub description: String,
56 pub pitches_cents: Vec<f64>,
59}
60
61impl ScalaScale {
62 pub fn parse(source: &str) -> Result<Self, ScalaError> {
64 let mut lines = source
67 .lines()
68 .map(|l| l.trim_end())
69 .filter(|l| !l.trim_start().starts_with('!'));
70
71 let description = lines.next().ok_or(ScalaError::Empty)?.trim().to_string();
72
73 let count_line = lines.next().ok_or(ScalaError::MissingCount)?.trim();
74 let count: usize = count_line
75 .split_whitespace()
76 .next()
77 .ok_or_else(|| ScalaError::BadCount(count_line.to_string()))?
78 .parse()
79 .map_err(|_| ScalaError::BadCount(count_line.to_string()))?;
80
81 let mut pitches_cents = Vec::with_capacity(count);
82 for line in lines {
83 let trimmed = line.trim();
84 if trimmed.is_empty() {
85 continue;
86 }
87 pitches_cents.push(parse_pitch(trimmed)?);
88 if pitches_cents.len() == count {
89 break;
90 }
91 }
92
93 if pitches_cents.len() != count {
94 return Err(ScalaError::CountMismatch {
95 expected: count,
96 found: pitches_cents.len(),
97 });
98 }
99
100 Ok(Self {
101 description,
102 pitches_cents,
103 })
104 }
105
106 pub fn degrees_within_octave(&self) -> Vec<f64> {
112 let mut degrees: Vec<f64> = Vec::with_capacity(self.pitches_cents.len() + 1);
113 degrees.push(0.0);
114 for &c in &self.pitches_cents {
115 let mut r = Libm::<f64>::fmod(c, 1200.0);
117 if r < 0.0 {
118 r += 1200.0;
119 }
120 degrees.push(r);
121 }
122 degrees.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
123 degrees.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
125 degrees
126 }
127}
128
129fn parse_pitch(token: &str) -> Result<f64, ScalaError> {
135 let first = token
136 .split_whitespace()
137 .next()
138 .ok_or_else(|| ScalaError::BadPitch(token.to_string()))?;
139
140 if first.contains('.') {
141 first
143 .parse::<f64>()
144 .map_err(|_| ScalaError::BadPitch(first.to_string()))
145 } else if let Some((num, den)) = first.split_once('/') {
146 let n: f64 = num
147 .trim()
148 .parse()
149 .map_err(|_| ScalaError::BadPitch(first.to_string()))?;
150 let d: f64 = den
151 .trim()
152 .parse()
153 .map_err(|_| ScalaError::BadPitch(first.to_string()))?;
154 if n <= 0.0 || d <= 0.0 {
155 return Err(ScalaError::BadPitch(first.to_string()));
156 }
157 Ok(1200.0 * Libm::<f64>::log2(n / d))
158 } else {
159 let n: f64 = first
161 .parse()
162 .map_err(|_| ScalaError::BadPitch(first.to_string()))?;
163 if n <= 0.0 {
164 return Err(ScalaError::BadPitch(first.to_string()));
165 }
166 Ok(1200.0 * Libm::<f64>::log2(n))
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 const TWELVE_TET: &str = "\
175! meantone.scl
17612-tone equal temperament
177 12
178!
179 100.0
180 200.0
181 300.0
182 400.0
183 500.0
184 600.0
185 700.0
186 800.0
187 900.0
188 1000.0
189 1100.0
190 2/1
191";
192
193 const JUST_MAJOR: &str = "\
195! ptolemy.scl
196Ptolemy intense diatonic
1977
1989/8
1995/4
2004/3
2013/2
2025/3
20315/8
2042/1
205";
206
207 #[test]
208 fn test_parse_12tet() {
209 let scale = ScalaScale::parse(TWELVE_TET).unwrap();
210 assert_eq!(scale.description, "12-tone equal temperament");
211 assert_eq!(scale.pitches_cents.len(), 12);
212 assert!((scale.pitches_cents[11] - 1200.0).abs() < 1e-6);
214 assert!((scale.pitches_cents[0] - 100.0).abs() < 1e-6);
215
216 let degrees = scale.degrees_within_octave();
217 assert_eq!(degrees.len(), 12);
219 assert!((degrees[0]).abs() < 1e-6);
220 assert!((degrees[1] - 100.0).abs() < 1e-6);
221 assert!((degrees[11] - 1100.0).abs() < 1e-6);
222 }
223
224 #[test]
225 fn test_parse_just_major() {
226 let scale = ScalaScale::parse(JUST_MAJOR).unwrap();
227 assert_eq!(scale.pitches_cents.len(), 7);
228 assert!((scale.pitches_cents[3] - 701.955).abs() < 0.01);
230 let degrees = scale.degrees_within_octave();
231 assert_eq!(degrees.len(), 7); assert!((degrees[0]).abs() < 1e-6);
233 assert!((degrees[1] - 203.91).abs() < 0.01);
235 }
236
237 #[test]
238 fn test_bare_integer_ratio() {
239 assert!((parse_pitch("3").unwrap() - 1901.955).abs() < 0.01);
241 }
242
243 #[test]
244 fn test_malformed_errors_do_not_panic() {
245 assert_eq!(ScalaScale::parse(""), Err(ScalaError::Empty));
246 assert_eq!(
247 ScalaScale::parse("only a description"),
248 Err(ScalaError::MissingCount)
249 );
250 assert!(matches!(
251 ScalaScale::parse("desc\nnotanumber\n"),
252 Err(ScalaError::BadCount(_))
253 ));
254 assert!(matches!(
255 ScalaScale::parse("desc\n2\n100.0\n"),
256 Err(ScalaError::CountMismatch {
257 expected: 2,
258 found: 1
259 })
260 ));
261 assert!(matches!(
262 ScalaScale::parse("desc\n1\nnonsense\n"),
263 Err(ScalaError::BadPitch(_))
264 ));
265 assert!(matches!(parse_pitch("0/1"), Err(ScalaError::BadPitch(_))));
267 assert!(matches!(parse_pitch("-3/2"), Err(ScalaError::BadPitch(_))));
268 }
269
270 #[test]
271 fn test_error_display() {
272 let e = ScalaError::BadPitch("x".to_string());
273 assert!(e.to_string().contains("x"));
274 }
275}