Skip to main content

mlua_pulse/
theory.rs

1use crate::error::{PulseError, PulseResult};
2use tunes::theory::{chord as tunes_chord, scale as tunes_scale, transpose, transpose_sequence};
3use tunes::theory::{ChordPattern, ScalePattern};
4
5const A4_MIDI: i32 = 69;
6const A4_FREQUENCY: f32 = 440.0;
7const MODE_NAMES: &[&str] = &[
8    "major",
9    "ionian",
10    "minor",
11    "aeolian",
12    "dorian",
13    "phrygian",
14    "lydian",
15    "mixolydian",
16    "locrian",
17];
18const CHORD_KIND_NAMES: &[&str] = &[
19    "major",
20    "maj",
21    "minor",
22    "min",
23    "maj7",
24    "major7",
25    "min7",
26    "minor7",
27    "dominant7",
28    "dom7",
29    "7",
30    "dim",
31    "diminished",
32    "aug",
33    "augmented",
34];
35
36/// Parses a note name like `C4`, `C#4`, or `Db4` into a frequency in Hertz.
37pub fn parse_note_frequency(value: &str) -> PulseResult<f32> {
38    let trimmed = value.trim();
39    if trimmed.len() < 2 {
40        return Err(PulseError::InvalidNoteName {
41            value: value.to_string(),
42        });
43    }
44
45    let mut chars = trimmed.chars().peekable();
46    let base = chars.next().ok_or_else(|| PulseError::InvalidNoteName {
47        value: value.to_string(),
48    })?;
49
50    let base_semitone = match base.to_ascii_uppercase() {
51        'C' => 0,
52        'D' => 2,
53        'E' => 4,
54        'F' => 5,
55        'G' => 7,
56        'A' => 9,
57        'B' => 11,
58        _ => {
59            return Err(PulseError::InvalidNoteName {
60                value: value.to_string(),
61            });
62        }
63    };
64
65    let accidental = match chars.peek().copied() {
66        Some('#') => {
67            chars.next();
68            1
69        }
70        Some('b') | Some('B') => {
71            chars.next();
72            -1
73        }
74        _ => 0,
75    };
76
77    let octave_text: String = chars.collect();
78    let octave = octave_text
79        .parse::<i32>()
80        .map_err(|_| PulseError::InvalidNoteName {
81            value: value.to_string(),
82        })?;
83
84    // Compute the MIDI note in `i64` so an absurd octave cannot overflow the
85    // `i32` arithmetic and panic; `octave` is already bounded to `i32`.
86    let midi = (i64::from(octave) + 1) * 12 + i64::from(base_semitone) + i64::from(accidental);
87    let frequency = A4_FREQUENCY * 2.0_f32.powf((midi - i64::from(A4_MIDI)) as f32 / 12.0);
88    if !frequency.is_finite() || frequency <= 0.0 {
89        return Err(PulseError::InvalidNoteName {
90            value: value.to_string(),
91        });
92    }
93    Ok(frequency)
94}
95
96/// Returns all Lua-visible scale and mode names.
97pub fn mode_names() -> &'static [&'static str] {
98    MODE_NAMES
99}
100
101/// Returns all Lua-visible chord kind names.
102pub fn chord_kind_names() -> &'static [&'static str] {
103    CHORD_KIND_NAMES
104}
105
106/// Generates a scale using `tunes` scale patterns.
107pub fn scale(root: &str, mode: &str) -> PulseResult<Vec<f32>> {
108    let root = parse_note_frequency(root)?;
109    let pattern = scale_pattern(mode)?;
110    Ok(tunes_scale(root, pattern))
111}
112
113/// Generates a chord using `tunes` chord patterns.
114pub fn chord(root: &str, kind: &str) -> PulseResult<Vec<f32>> {
115    let root = parse_note_frequency(root)?;
116    let pattern = chord_pattern(kind)?;
117    Ok(tunes_chord(root, pattern))
118}
119
120/// Transposes one frequency by semitones.
121pub fn transpose_frequency(frequency: f32, semitones: i32) -> f32 {
122    transpose(frequency, semitones)
123}
124
125/// Transposes a sequence of frequencies by semitones.
126pub fn transpose_notes(notes: &[f32], semitones: i32) -> Vec<f32> {
127    transpose_sequence(notes, semitones)
128}
129
130fn scale_pattern(mode: &str) -> PulseResult<&'static ScalePattern> {
131    match normalize_name(mode).as_str() {
132        "major" | "ionian" => Ok(&ScalePattern::MAJOR),
133        "minor" | "aeolian" => Ok(&ScalePattern::MINOR),
134        "dorian" => Ok(&ScalePattern::DORIAN),
135        "phrygian" => Ok(&ScalePattern::PHRYGIAN),
136        "lydian" => Ok(&ScalePattern::LYDIAN),
137        "mixolydian" => Ok(&ScalePattern::MIXOLYDIAN),
138        "locrian" => Ok(&ScalePattern::LOCRIAN),
139        _ => Err(PulseError::InvalidMode {
140            value: mode.to_string(),
141        }),
142    }
143}
144
145fn chord_pattern(kind: &str) -> PulseResult<&'static ChordPattern> {
146    match normalize_name(kind).as_str() {
147        "major" | "maj" => Ok(&ChordPattern::MAJOR),
148        "minor" | "min" => Ok(&ChordPattern::MINOR),
149        "maj7" | "major7" => Ok(&ChordPattern::MAJOR7),
150        "min7" | "minor7" => Ok(&ChordPattern::MINOR7),
151        "dominant7" | "dom7" | "7" => Ok(&ChordPattern::DOMINANT7),
152        "dim" | "diminished" => Ok(&ChordPattern::DIMINISHED),
153        "aug" | "augmented" => Ok(&ChordPattern::AUGMENTED),
154        _ => Err(PulseError::InvalidChordKind {
155            value: kind.to_string(),
156        }),
157    }
158}
159
160fn normalize_name(value: &str) -> String {
161    value
162        .trim()
163        .to_ascii_lowercase()
164        .replace(['_', '-', ' '], "")
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    fn approx_eq(left: f32, right: f32) {
172        assert!(
173            (left - right).abs() < 0.02,
174            "expected {left} to be close to {right}"
175        );
176    }
177
178    #[test]
179    fn parses_common_note_names_to_frequency() {
180        approx_eq(parse_note_frequency("A4").expect("A4 should parse"), 440.0);
181        approx_eq(parse_note_frequency("C4").expect("C4 should parse"), 261.63);
182        approx_eq(
183            parse_note_frequency("C#4").expect("C#4 should parse"),
184            277.18,
185        );
186        approx_eq(
187            parse_note_frequency("Db4").expect("Db4 should parse"),
188            277.18,
189        );
190    }
191
192    #[test]
193    fn rejects_unknown_note_names() {
194        let error = parse_note_frequency("H4").expect_err("H4 is not a note");
195        assert_eq!(error.to_string(), "invalid note name: H4");
196    }
197
198    #[test]
199    fn rejects_overflowing_octaves_without_panicking() {
200        // An octave near `i32::MAX` must not trigger integer-arithmetic
201        // overflow; it should be reported as an invalid note name.
202        let error =
203            parse_note_frequency("C2147483647").expect_err("oversized octave should not overflow");
204        assert_eq!(error.to_string(), "invalid note name: C2147483647");
205    }
206
207    #[test]
208    fn exposes_supported_mode_and_chord_kind_names() {
209        assert!(mode_names().contains(&"ionian"));
210        assert!(mode_names().contains(&"locrian"));
211        assert!(chord_kind_names().contains(&"maj7"));
212        assert!(chord_kind_names().contains(&"dominant7"));
213    }
214
215    #[test]
216    fn generates_greek_modes_with_octave_note() {
217        let ionian = scale("C4", "ionian").expect("ionian should work");
218        assert_eq!(ionian.len(), 8);
219        approx_eq(ionian[0], parse_note_frequency("C4").unwrap());
220        approx_eq(ionian[1], parse_note_frequency("D4").unwrap());
221        approx_eq(ionian[7], parse_note_frequency("C5").unwrap());
222
223        let dorian = scale("D4", "dorian").expect("dorian should work");
224        approx_eq(dorian[2], parse_note_frequency("F4").unwrap());
225
226        let aeolian = scale("A4", "minor").expect("minor alias should work");
227        approx_eq(aeolian[2], parse_note_frequency("C5").unwrap());
228    }
229
230    #[test]
231    fn generates_chords_from_tunes_patterns() {
232        let c_major7 = chord("C4", "maj7").expect("maj7 should work");
233        assert_eq!(c_major7.len(), 4);
234        approx_eq(c_major7[0], parse_note_frequency("C4").unwrap());
235        approx_eq(c_major7[1], parse_note_frequency("E4").unwrap());
236        approx_eq(c_major7[2], parse_note_frequency("G4").unwrap());
237        approx_eq(c_major7[3], parse_note_frequency("B4").unwrap());
238
239        let a_minor7 = chord("A3", "min7").expect("min7 should work");
240        approx_eq(a_minor7[1], parse_note_frequency("C4").unwrap());
241    }
242
243    #[test]
244    fn transposes_single_frequency_and_sequences() {
245        let c4 = parse_note_frequency("C4").unwrap();
246        approx_eq(
247            transpose_frequency(c4, 2),
248            parse_note_frequency("D4").unwrap(),
249        );
250
251        let notes = vec![c4, parse_note_frequency("E4").unwrap()];
252        let transposed = transpose_notes(&notes, -12);
253        approx_eq(transposed[0], parse_note_frequency("C3").unwrap());
254        approx_eq(transposed[1], parse_note_frequency("E3").unwrap());
255    }
256}