Skip to main content

aether_midi/
tuning.rs

1//! Custom tuning tables for non-Western instruments.
2//!
3//! Standard MIDI assumes 12-tone equal temperament (12-TET).
4//! Many instruments — Ethiopian, Indian, Arabic, Turkish, gamelan —
5//! use different tuning systems. This module lets you define the exact
6//! frequency for each MIDI note number.
7
8use serde::{Deserialize, Serialize};
9
10/// Maps MIDI note numbers (0–127) to frequencies in Hz.
11/// Stored as Vec<f32> for serde compatibility.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct TuningTable {
14    /// Frequency in Hz for each MIDI note 0–127.
15    pub frequencies: Vec<f32>,
16    /// Human-readable name.
17    pub name: String,
18    /// Description of the tuning system.
19    pub description: String,
20}
21
22impl TuningTable {
23    /// Standard 12-tone equal temperament.
24    /// A4 (MIDI note 69) = concert_a Hz (typically 440.0).
25    pub fn equal_temperament(concert_a: f32) -> Self {
26        let mut frequencies = vec![0.0f32; 128];
27        for note in 0..128u8 {
28            frequencies[note as usize] = concert_a * 2.0f32.powf((note as f32 - 69.0) / 12.0);
29        }
30        Self {
31            frequencies,
32            name: "12-TET".into(),
33            description: "Standard 12-tone equal temperament, A4=440Hz".into(),
34        }
35    }
36
37    /// Build a tuning table from cents offsets per semitone within an octave.
38    /// `offsets` is a 12-element array of cent offsets from 12-TET for each
39    /// pitch class (C, C#, D, D#, E, F, F#, G, G#, A, A#, B).
40    pub fn from_cents_offsets(concert_a: f32, offsets: &[f32; 12]) -> Self {
41        let base = Self::equal_temperament(concert_a);
42        let mut frequencies = base.frequencies;
43        for note in 0..128usize {
44            let pitch_class = note % 12;
45            let cents_offset = offsets[pitch_class];
46            frequencies[note] *= 2.0f32.powf(cents_offset / 1200.0);
47        }
48        Self {
49            frequencies,
50            name: "Custom".into(),
51            description: "Custom tuning with per-pitch-class cent offsets".into(),
52        }
53    }
54
55    /// Build from explicit frequency list. Length must be 128.
56    pub fn from_frequencies(freqs: Vec<f32>, name: &str, description: &str) -> Option<Self> {
57        if freqs.len() != 128 {
58            return None;
59        }
60        Some(Self {
61            frequencies: freqs,
62            name: name.into(),
63            description: description.into(),
64        })
65    }
66
67    /// Get frequency for a MIDI note number.
68    #[inline]
69    pub fn frequency(&self, note: u8) -> f32 {
70        self.frequencies.get(note as usize).copied().unwrap_or(0.0)
71    }
72
73    /// Convert frequency to the nearest MIDI note + cents deviation.
74    pub fn freq_to_note_cents(&self, freq: f32) -> (u8, f32) {
75        let mut best_note = 0u8;
76        let mut best_dist = f32::MAX;
77        for (i, &f) in self.frequencies.iter().enumerate() {
78            let dist = (freq - f).abs();
79            if dist < best_dist {
80                best_dist = dist;
81                best_note = i as u8;
82            }
83        }
84        let base_freq = self.frequencies[best_note as usize];
85        let cents = if base_freq > 0.0 {
86            1200.0 * (freq / base_freq).log2()
87        } else {
88            0.0
89        };
90        (best_note, cents)
91    }
92
93    /// Ethiopian Kiñit (pentatonic) approximation.
94    /// Uses the Tizita major scale pattern.
95    pub fn ethiopian_tizita(concert_a: f32) -> Self {
96        let offsets = [
97            0.0,    // C  — root
98            -50.0,  // C# — slightly flat
99            0.0,    // D
100            -30.0,  // D# — slightly flat
101            0.0,    // E
102            0.0,    // F
103            -20.0,  // F# — slightly flat
104            0.0,    // G
105            -40.0,  // G# — slightly flat
106            0.0,    // A
107            -30.0,  // A# — slightly flat
108            0.0,    // B
109        ];
110        let mut t = Self::from_cents_offsets(concert_a, &offsets);
111        t.name = "Ethiopian Tizita".into();
112        t.description = "Approximation of Ethiopian Tizita major pentatonic scale".into();
113        t
114    }
115
116    /// Just intonation (pure intervals based on harmonic series).
117    pub fn just_intonation(concert_a: f32) -> Self {
118        let ratios: [f32; 12] = [
119            1.0, 16.0/15.0, 9.0/8.0, 6.0/5.0, 5.0/4.0, 4.0/3.0,
120            45.0/32.0, 3.0/2.0, 8.0/5.0, 5.0/3.0, 9.0/5.0, 15.0/8.0,
121        ];
122        let tet_ratios: [f32; 12] = [
123            1.0, 2.0f32.powf(1.0/12.0), 2.0f32.powf(2.0/12.0), 2.0f32.powf(3.0/12.0),
124            2.0f32.powf(4.0/12.0), 2.0f32.powf(5.0/12.0), 2.0f32.powf(6.0/12.0),
125            2.0f32.powf(7.0/12.0), 2.0f32.powf(8.0/12.0), 2.0f32.powf(9.0/12.0),
126            2.0f32.powf(10.0/12.0), 2.0f32.powf(11.0/12.0),
127        ];
128        let offsets: [f32; 12] = std::array::from_fn(|i| {
129            1200.0 * (ratios[i] / tet_ratios[i]).log2()
130        });
131        let mut t = Self::from_cents_offsets(concert_a, &offsets);
132        t.name = "Just Intonation".into();
133        t.description = "Pure harmonic ratios — no beating on perfect intervals".into();
134        t
135    }
136}
137
138impl Default for TuningTable {
139    fn default() -> Self {
140        Self::equal_temperament(440.0)
141    }
142}