use alloc::string::{String, ToString};
use alloc::vec::Vec;
use libm::Libm;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScalaError {
Empty,
MissingCount,
BadCount(String),
BadPitch(String),
CountMismatch {
expected: usize,
found: usize,
},
}
impl core::fmt::Display for ScalaError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
ScalaError::Empty => write!(f, "scala: empty file"),
ScalaError::MissingCount => write!(f, "scala: missing note count"),
ScalaError::BadCount(s) => write!(f, "scala: invalid note count '{s}'"),
ScalaError::BadPitch(s) => write!(f, "scala: invalid pitch '{s}'"),
ScalaError::CountMismatch { expected, found } => {
write!(f, "scala: expected {expected} pitches, found {found}")
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ScalaScale {
pub description: String,
pub pitches_cents: Vec<f64>,
}
impl ScalaScale {
pub fn parse(source: &str) -> Result<Self, ScalaError> {
let mut lines = source
.lines()
.map(|l| l.trim_end())
.filter(|l| !l.trim_start().starts_with('!'));
let description = lines.next().ok_or(ScalaError::Empty)?.trim().to_string();
let count_line = lines.next().ok_or(ScalaError::MissingCount)?.trim();
let count: usize = count_line
.split_whitespace()
.next()
.ok_or_else(|| ScalaError::BadCount(count_line.to_string()))?
.parse()
.map_err(|_| ScalaError::BadCount(count_line.to_string()))?;
let mut pitches_cents = Vec::with_capacity(count);
for line in lines {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
pitches_cents.push(parse_pitch(trimmed)?);
if pitches_cents.len() == count {
break;
}
}
if pitches_cents.len() != count {
return Err(ScalaError::CountMismatch {
expected: count,
found: pitches_cents.len(),
});
}
Ok(Self {
description,
pitches_cents,
})
}
pub fn degrees_within_octave(&self) -> Vec<f64> {
let mut degrees: Vec<f64> = Vec::with_capacity(self.pitches_cents.len() + 1);
degrees.push(0.0);
for &c in &self.pitches_cents {
let mut r = Libm::<f64>::fmod(c, 1200.0);
if r < 0.0 {
r += 1200.0;
}
degrees.push(r);
}
degrees.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
degrees.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
degrees
}
}
fn parse_pitch(token: &str) -> Result<f64, ScalaError> {
let first = token
.split_whitespace()
.next()
.ok_or_else(|| ScalaError::BadPitch(token.to_string()))?;
if first.contains('.') {
first
.parse::<f64>()
.map_err(|_| ScalaError::BadPitch(first.to_string()))
} else if let Some((num, den)) = first.split_once('/') {
let n: f64 = num
.trim()
.parse()
.map_err(|_| ScalaError::BadPitch(first.to_string()))?;
let d: f64 = den
.trim()
.parse()
.map_err(|_| ScalaError::BadPitch(first.to_string()))?;
if n <= 0.0 || d <= 0.0 {
return Err(ScalaError::BadPitch(first.to_string()));
}
Ok(1200.0 * Libm::<f64>::log2(n / d))
} else {
let n: f64 = first
.parse()
.map_err(|_| ScalaError::BadPitch(first.to_string()))?;
if n <= 0.0 {
return Err(ScalaError::BadPitch(first.to_string()));
}
Ok(1200.0 * Libm::<f64>::log2(n))
}
}
#[cfg(test)]
mod tests {
use super::*;
const TWELVE_TET: &str = "\
! meantone.scl
12-tone equal temperament
12
!
100.0
200.0
300.0
400.0
500.0
600.0
700.0
800.0
900.0
1000.0
1100.0
2/1
";
const JUST_MAJOR: &str = "\
! ptolemy.scl
Ptolemy intense diatonic
7
9/8
5/4
4/3
3/2
5/3
15/8
2/1
";
#[test]
fn test_parse_12tet() {
let scale = ScalaScale::parse(TWELVE_TET).unwrap();
assert_eq!(scale.description, "12-tone equal temperament");
assert_eq!(scale.pitches_cents.len(), 12);
assert!((scale.pitches_cents[11] - 1200.0).abs() < 1e-6);
assert!((scale.pitches_cents[0] - 100.0).abs() < 1e-6);
let degrees = scale.degrees_within_octave();
assert_eq!(degrees.len(), 12);
assert!((degrees[0]).abs() < 1e-6);
assert!((degrees[1] - 100.0).abs() < 1e-6);
assert!((degrees[11] - 1100.0).abs() < 1e-6);
}
#[test]
fn test_parse_just_major() {
let scale = ScalaScale::parse(JUST_MAJOR).unwrap();
assert_eq!(scale.pitches_cents.len(), 7);
assert!((scale.pitches_cents[3] - 701.955).abs() < 0.01);
let degrees = scale.degrees_within_octave();
assert_eq!(degrees.len(), 7); assert!((degrees[0]).abs() < 1e-6);
assert!((degrees[1] - 203.91).abs() < 0.01);
}
#[test]
fn test_bare_integer_ratio() {
assert!((parse_pitch("3").unwrap() - 1901.955).abs() < 0.01);
}
#[test]
fn test_malformed_errors_do_not_panic() {
assert_eq!(ScalaScale::parse(""), Err(ScalaError::Empty));
assert_eq!(
ScalaScale::parse("only a description"),
Err(ScalaError::MissingCount)
);
assert!(matches!(
ScalaScale::parse("desc\nnotanumber\n"),
Err(ScalaError::BadCount(_))
));
assert!(matches!(
ScalaScale::parse("desc\n2\n100.0\n"),
Err(ScalaError::CountMismatch {
expected: 2,
found: 1
})
));
assert!(matches!(
ScalaScale::parse("desc\n1\nnonsense\n"),
Err(ScalaError::BadPitch(_))
));
assert!(matches!(parse_pitch("0/1"), Err(ScalaError::BadPitch(_))));
assert!(matches!(parse_pitch("-3/2"), Err(ScalaError::BadPitch(_))));
}
#[test]
fn test_error_display() {
let e = ScalaError::BadPitch("x".to_string());
assert!(e.to_string().contains("x"));
}
}