use serde::{Deserialize, Serialize};
use crate::error::{NaadError, Result};
#[inline]
#[must_use]
pub fn equal_temperament_freq(note: u8, a4_hz: f32) -> f32 {
a4_hz * 2.0f32.powf((note as f32 - 69.0) / 12.0)
}
#[inline]
#[must_use]
pub fn midi_to_freq(note: u8) -> f32 {
equal_temperament_freq(note, 440.0)
}
#[inline]
#[must_use]
pub fn freq_to_midi(freq: f32) -> u8 {
if freq <= 0.0 {
return 0;
}
let note = 69.0 + 12.0 * (freq / 440.0).log2();
note.round().clamp(0.0, 127.0) as u8
}
#[inline]
#[must_use]
pub fn cents(f1: f32, f2: f32) -> f32 {
if f1 <= 0.0 || f2 <= 0.0 {
return 0.0;
}
1200.0 * (f2 / f1).log2()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum TuningSystem {
EqualTemperament,
JustIntonation,
Pythagorean,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TuningTable {
pub name: String,
pub ratios: [f32; 12],
pub a4_hz: f32,
}
impl TuningTable {
#[must_use]
pub fn from_system(system: TuningSystem, a4_hz: f32) -> Self {
let (name, ratios) = match system {
TuningSystem::EqualTemperament => {
let mut r = [0.0f32; 12];
for (i, ratio) in r.iter_mut().enumerate() {
*ratio = 2.0f32.powf(i as f32 / 12.0);
}
("Equal Temperament".to_string(), r)
}
TuningSystem::JustIntonation => (
"Just Intonation".to_string(),
[
1.0, 16.0 / 15.0, 9.0 / 8.0, 6.0 / 5.0, 5.0 / 4.0, 4.0 / 3.0, 45.0 / 32.0, 3.0 / 2.0, 8.0 / 5.0, 5.0 / 3.0, 9.0 / 5.0, 15.0 / 8.0, ],
),
TuningSystem::Pythagorean => (
"Pythagorean".to_string(),
[
1.0, 256.0 / 243.0, 9.0 / 8.0, 32.0 / 27.0, 81.0 / 64.0, 4.0 / 3.0, 729.0 / 512.0, 3.0 / 2.0, 128.0 / 81.0, 27.0 / 16.0, 16.0 / 9.0, 243.0 / 128.0, ],
),
};
Self {
name,
ratios,
a4_hz,
}
}
pub fn custom(name: String, ratios: [f32; 12], a4_hz: f32) -> Result<Self> {
if a4_hz <= 0.0 || !a4_hz.is_finite() {
return Err(NaadError::InvalidParameter {
name: "a4_hz".to_string(),
reason: "must be > 0 and finite".to_string(),
});
}
for (i, &r) in ratios.iter().enumerate() {
if r <= 0.0 || !r.is_finite() {
return Err(NaadError::InvalidParameter {
name: format!("ratios[{i}]"),
reason: "must be > 0 and finite".to_string(),
});
}
}
Ok(Self {
name,
ratios,
a4_hz,
})
}
#[must_use]
pub fn note_to_freq(&self, note: u8) -> f32 {
let note_i = note as i32;
let a4_degree = 9;
let semitone_from_a4 = note_i - 69;
let total_degree = a4_degree + semitone_from_a4;
let octave = total_degree.div_euclid(12);
let degree = total_degree.rem_euclid(12) as usize;
let ratio = self.ratios[degree];
let a4_ratio = self.ratios[a4_degree as usize];
self.a4_hz * (ratio / a4_ratio) * 2.0f32.powi(octave - a4_degree.div_euclid(12))
}
}
#[must_use]
pub fn note_name(note: u8) -> String {
const NAMES: [&str; 12] = [
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
];
let octave = (note / 12) as i32 - 1;
let name = NAMES[(note % 12) as usize];
format!("{name}{octave}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_a4_440() {
let freq = midi_to_freq(69);
assert!(
(freq - 440.0).abs() < 0.01,
"A4 should be 440 Hz, got {freq}"
);
}
#[test]
fn test_c4_261() {
let freq = midi_to_freq(60);
assert!(
(freq - 261.63).abs() < 0.1,
"C4 should be ~261.63 Hz, got {freq}"
);
}
#[test]
fn test_octave() {
let a4 = midi_to_freq(69);
let a5 = midi_to_freq(81);
assert!((a5 / a4 - 2.0).abs() < 0.01, "octave should be 2:1 ratio");
}
#[test]
fn test_freq_to_midi_roundtrip() {
for note in 21..=108 {
let freq = midi_to_freq(note);
let back = freq_to_midi(freq);
assert_eq!(note, back, "roundtrip failed for note {note}");
}
}
#[test]
fn test_cents() {
let c = cents(440.0, 880.0);
assert!(
(c - 1200.0).abs() < 0.1,
"octave should be 1200 cents, got {c}"
);
}
#[test]
fn test_cents_semitone() {
let a4 = midi_to_freq(69);
let bb4 = midi_to_freq(70);
let c = cents(a4, bb4);
assert!(
(c - 100.0).abs() < 0.1,
"semitone should be 100 cents, got {c}"
);
}
#[test]
fn test_note_name() {
assert_eq!(note_name(69), "A4");
assert_eq!(note_name(60), "C4");
assert_eq!(note_name(72), "C5");
}
#[test]
fn test_just_intonation() {
let table = TuningTable::from_system(TuningSystem::JustIntonation, 440.0);
let a4 = table.note_to_freq(69);
assert!(
(a4 - 440.0).abs() < 0.1,
"A4 in just intonation should be 440 Hz, got {a4}"
);
}
#[test]
fn test_pythagorean() {
let table = TuningTable::from_system(TuningSystem::Pythagorean, 440.0);
let a4 = table.note_to_freq(69);
assert!(
(a4 - 440.0).abs() < 0.1,
"A4 in Pythagorean should be 440 Hz, got {a4}"
);
}
#[test]
fn test_equal_temperament_table() {
let table = TuningTable::from_system(TuningSystem::EqualTemperament, 440.0);
let c4 = table.note_to_freq(60);
let et_c4 = midi_to_freq(60);
assert!(
(c4 - et_c4).abs() < 1.0,
"ET table C4={c4} should match midi_to_freq C4={et_c4}"
);
}
#[test]
fn test_custom_tuning_validation() {
let mut ratios = [1.0f32; 12];
ratios[3] = -1.0;
assert!(TuningTable::custom("bad".to_string(), ratios, 440.0).is_err());
}
#[test]
fn test_serde_roundtrip() {
let table = TuningTable::from_system(TuningSystem::JustIntonation, 442.0);
let json = serde_json::to_string(&table).unwrap();
let back: TuningTable = serde_json::from_str(&json).unwrap();
assert_eq!(table.name, back.name);
assert!((table.a4_hz - back.a4_hz).abs() < f32::EPSILON);
}
}