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
36pub 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 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
96pub fn mode_names() -> &'static [&'static str] {
98 MODE_NAMES
99}
100
101pub fn chord_kind_names() -> &'static [&'static str] {
103 CHORD_KIND_NAMES
104}
105
106pub 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
113pub 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
120pub fn transpose_frequency(frequency: f32, semitones: i32) -> f32 {
122 transpose(frequency, semitones)
123}
124
125pub 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 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(¬es, -12);
253 approx_eq(transposed[0], parse_note_frequency("C3").unwrap());
254 approx_eq(transposed[1], parse_note_frequency("E3").unwrap());
255 }
256}