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, freq) in frequencies.iter_mut().enumerate() {
28            *freq = 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, freq) in frequencies.iter_mut().enumerate().take(128) {
44            let pitch_class = note % 12;
45            let cents_offset = offsets[pitch_class];
46            *freq *= 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}
143
144// ── Additional world music tuning systems ─────────────────────────────────────
145
146impl TuningTable {
147    /// Arabic Maqam Rast — the most common Arabic maqam.
148    /// Uses quarter-tone flats on the 3rd and 7th scale degrees.
149    pub fn arabic_maqam_rast(concert_a: f32) -> Self {
150        let offsets = [
151            0.0,    // C  — root (Rast)
152            0.0,    // C#
153            0.0,    // D  — whole tone
154            -50.0,  // D# — E half-flat (quarter tone flat)
155            0.0,    // E
156            0.0,    // F  — perfect fourth
157            0.0,    // F#
158            0.0,    // G  — perfect fifth
159            0.0,    // G#
160            0.0,    // A
161            -50.0,  // A# — B half-flat (quarter tone flat)
162            0.0,    // B
163        ];
164        let mut t = Self::from_cents_offsets(concert_a, &offsets);
165        t.name = "Arabic Maqam Rast".into();
166        t.description = "Arabic Maqam Rast — quarter-tone flats on 3rd and 7th degrees".into();
167        t
168    }
169
170    /// Arabic Maqam Bayati — second most common Arabic maqam.
171    /// Characteristic half-flat on the 2nd degree.
172    pub fn arabic_maqam_bayati(concert_a: f32) -> Self {
173        let offsets = [
174            0.0,    // C  — root
175            -50.0,  // C# — D half-flat (characteristic Bayati interval)
176            0.0,    // D
177            -30.0,  // D# — slightly flat
178            0.0,    // E
179            0.0,    // F
180            0.0,    // F#
181            0.0,    // G
182            0.0,    // G#
183            0.0,    // A
184            -50.0,  // A# — B half-flat
185            0.0,    // B
186        ];
187        let mut t = Self::from_cents_offsets(concert_a, &offsets);
188        t.name = "Arabic Maqam Bayati".into();
189        t.description = "Arabic Maqam Bayati — half-flat on 2nd degree, characteristic of Arabic music".into();
190        t
191    }
192
193    /// Ethiopian Bati scale — minor pentatonic variant.
194    pub fn ethiopian_bati(concert_a: f32) -> Self {
195        let offsets = [
196            0.0,    // C
197            0.0,    // C#
198            -20.0,  // D  — slightly flat
199            0.0,    // D#
200            0.0,    // E
201            0.0,    // F
202            -30.0,  // F# — slightly flat
203            0.0,    // G
204            0.0,    // G#
205            -20.0,  // A  — slightly flat
206            0.0,    // A#
207            0.0,    // B
208        ];
209        let mut t = Self::from_cents_offsets(concert_a, &offsets);
210        t.name = "Ethiopian Bati".into();
211        t.description = "Ethiopian Bati scale — minor pentatonic variant used in traditional music".into();
212        t
213    }
214
215    /// Indian Raga Yaman (Kalyan thaat) — the most common North Indian raga.
216    /// Uses a raised 4th (Ma tivra).
217    pub fn indian_raga_yaman(concert_a: f32) -> Self {
218        // Yaman uses all natural notes except F# (raised 4th)
219        // In just intonation ratios from Sa (root):
220        // Sa Re Ga Ma# Pa Dha Ni Sa
221        // 1  9/8 5/4 45/32 3/2 5/3 15/8 2
222        let offsets = [
223            0.0,   // C  — Sa
224            0.0,   // C#
225            3.9,   // D  — Re (9/8 just = +3.9 cents from 12-TET)
226            0.0,   // D#
227            -13.7, // E  — Ga (5/4 just = -13.7 cents from 12-TET)
228            0.0,   // F
229            -9.8,  // F# — Ma# (45/32 just = -9.8 cents from 12-TET)
230            2.0,   // G  — Pa (3/2 just = +2.0 cents from 12-TET)
231            0.0,   // G#
232            -15.6, // A  — Dha (5/3 just = -15.6 cents from 12-TET)
233            0.0,   // A#
234            -11.7, // B  — Ni (15/8 just = -11.7 cents from 12-TET)
235        ];
236        let mut t = Self::from_cents_offsets(concert_a, &offsets);
237        t.name = "Indian Raga Yaman".into();
238        t.description = "Indian Raga Yaman (Kalyan thaat) — raised 4th, just intonation".into();
239        t
240    }
241
242    /// Javanese Gamelan Slendro — 5-tone scale.
243    /// Approximate equal division of the octave into 5 parts.
244    pub fn gamelan_slendro(_concert_a: f32) -> Self {
245        // Slendro divides the octave into 5 roughly equal parts (~240 cents each)
246        // but with characteristic deviations. Using a common approximation.
247        let step = 1200.0 / 5.0; // 240 cents per step
248        let mut frequencies = vec![0.0f32; 128];
249        for (note, freq) in frequencies.iter_mut().enumerate() {
250            // Map MIDI notes to Slendro: every 2-3 semitones is one Slendro step
251            let slendro_step = (note as f32 / 2.4).floor();
252            let cents_from_c0 = slendro_step * step;
253            *freq = 16.352 * 2.0f32.powf(cents_from_c0 / 1200.0);
254        }
255        Self {
256            frequencies,
257            name: "Gamelan Slendro".into(),
258            description: "Javanese Gamelan Slendro — 5-tone scale, ~240 cents per step".into(),
259        }
260    }
261
262    /// Javanese Gamelan Pelog — 7-tone scale with characteristic large and small intervals.
263    pub fn gamelan_pelog(concert_a: f32) -> Self {
264        // Pelog has 7 tones with unequal steps. Common approximation in cents from root:
265        // 0, 120, 270, 540, 675, 785, 950, 1200
266        let pelog_cents = [0.0f32, 120.0, 270.0, 540.0, 675.0, 785.0, 950.0];
267        let mut frequencies = vec![0.0f32; 128];
268        for (note, freq) in frequencies.iter_mut().enumerate() {
269            let octave = note / 7;
270            let step = note % 7;
271            let cents = pelog_cents[step] + octave as f32 * 1200.0;
272            *freq = 16.352 * 2.0f32.powf(cents / 1200.0);
273        }
274        // Normalize so A4 (MIDI 69) = concert_a
275        let a4_freq = frequencies[69];
276        if a4_freq > 0.0 {
277            let ratio = concert_a / a4_freq;
278            for f in frequencies.iter_mut() { *f *= ratio; }
279        }
280        Self {
281            frequencies,
282            name: "Gamelan Pelog".into(),
283            description: "Javanese Gamelan Pelog — 7-tone scale with characteristic unequal intervals".into(),
284        }
285    }
286}