use crate::math::Rational;
use std::f64::consts::LN_2;
#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)]
pub struct Pitch(pub f64);
impl Pitch {
pub const UNISON: Pitch = Pitch(0.0);
pub const OCTAVE: Pitch = Pitch(1.0);
pub fn from_ratio(num: f64, den: f64) -> Self {
Self((num / den).ln() / LN_2)
}
pub fn from_rational(r: Rational) -> Self {
Self::from_ratio(r.numer() as f64, r.denom() as f64)
}
pub fn from_cents(cents: f64) -> Self {
Self(cents / 1200.0)
}
pub fn to_hz(self, reference_hz: f64) -> f64 {
reference_hz * (self.0 * LN_2).exp()
}
pub fn to_cents(self) -> f64 {
self.0 * 1200.0
}
pub fn transpose(self, interval: Pitch) -> Self {
Self(self.0 + interval.0)
}
pub fn invert(self) -> Self {
Self(-self.0)
}
}
#[derive(Clone, Debug)]
pub struct Scale {
pub period: Pitch,
pub classes: Vec<Pitch>,
}
impl Scale {
pub fn resolve(&self, degree: i32) -> Pitch {
let n = self.classes.len() as i32;
let idx = degree.rem_euclid(n) as usize;
let octave = degree.div_euclid(n);
Pitch(self.classes[idx].0 + self.period.0 * octave as f64)
}
pub fn len(&self) -> usize {
self.classes.len()
}
pub fn is_empty(&self) -> bool {
self.classes.is_empty()
}
}
#[derive(Clone, Debug)]
pub enum Tuning {
Equal { divisions: u32, interval: Pitch },
Just { ratios: Vec<Rational> },
Free { pitches: Vec<Pitch> },
}
impl Tuning {
pub fn to_scale(&self) -> Scale {
match self {
Tuning::Equal {
divisions,
interval,
} => {
let classes = (0..*divisions)
.map(|i| Pitch(interval.0 * i as f64 / *divisions as f64))
.collect();
Scale {
period: *interval,
classes,
}
}
Tuning::Just { ratios } => {
let mut classes: Vec<Pitch> =
ratios.iter().map(|r| Pitch::from_rational(*r)).collect();
classes.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
let period = if classes.is_empty() {
Pitch::OCTAVE
} else {
Pitch::OCTAVE
};
Scale { period, classes }
}
Tuning::Free { pitches } => {
let mut classes = pitches.clone();
classes.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
let period = Pitch::OCTAVE;
Scale { period, classes }
}
}
}
pub fn edo12() -> Self {
Tuning::Equal {
divisions: 12,
interval: Pitch::OCTAVE,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn edo12_scale() {
let scale = Tuning::edo12().to_scale();
assert_eq!(scale.len(), 12);
let a4 = 440.0;
let a5 = scale.resolve(12).to_hz(a4);
assert!((a5 - 880.0).abs() < 0.01);
}
#[test]
fn negative_degrees() {
let scale = Tuning::edo12().to_scale();
let p = scale.resolve(-12);
assert!((p.0 - (-1.0)).abs() < 1e-10);
}
#[test]
fn just_intonation() {
let tuning = Tuning::Just {
ratios: vec![
Rational::new(1, 1),
Rational::new(9, 8),
Rational::new(5, 4),
Rational::new(4, 3),
Rational::new(3, 2),
Rational::new(5, 3),
Rational::new(15, 8),
],
};
let scale = tuning.to_scale();
assert_eq!(scale.len(), 7);
let fifth = scale.resolve(4);
let expected = Pitch::from_ratio(3.0, 2.0);
assert!((fifth.0 - expected.0).abs() < 1e-10);
}
#[test]
fn pitch_cents_roundtrip() {
let p = Pitch::from_cents(700.0);
assert!((p.to_cents() - 700.0).abs() < 1e-10);
}
}