use crate::error::{PulseError, PulseResult};
use tunes::theory::{chord as tunes_chord, scale as tunes_scale, transpose, transpose_sequence};
use tunes::theory::{ChordPattern, ScalePattern};
const A4_MIDI: i32 = 69;
const A4_FREQUENCY: f32 = 440.0;
const MODE_NAMES: &[&str] = &[
"major",
"ionian",
"minor",
"aeolian",
"dorian",
"phrygian",
"lydian",
"mixolydian",
"locrian",
];
const CHORD_KIND_NAMES: &[&str] = &[
"major",
"maj",
"minor",
"min",
"maj7",
"major7",
"min7",
"minor7",
"dominant7",
"dom7",
"7",
"dim",
"diminished",
"aug",
"augmented",
];
pub fn parse_note_frequency(value: &str) -> PulseResult<f32> {
let trimmed = value.trim();
if trimmed.len() < 2 {
return Err(PulseError::InvalidNoteName {
value: value.to_string(),
});
}
let mut chars = trimmed.chars().peekable();
let base = chars.next().ok_or_else(|| PulseError::InvalidNoteName {
value: value.to_string(),
})?;
let base_semitone = match base.to_ascii_uppercase() {
'C' => 0,
'D' => 2,
'E' => 4,
'F' => 5,
'G' => 7,
'A' => 9,
'B' => 11,
_ => {
return Err(PulseError::InvalidNoteName {
value: value.to_string(),
});
}
};
let accidental = match chars.peek().copied() {
Some('#') => {
chars.next();
1
}
Some('b') | Some('B') => {
chars.next();
-1
}
_ => 0,
};
let octave_text: String = chars.collect();
let octave = octave_text
.parse::<i32>()
.map_err(|_| PulseError::InvalidNoteName {
value: value.to_string(),
})?;
let midi = (i64::from(octave) + 1) * 12 + i64::from(base_semitone) + i64::from(accidental);
let frequency = A4_FREQUENCY * 2.0_f32.powf((midi - i64::from(A4_MIDI)) as f32 / 12.0);
if !frequency.is_finite() || frequency <= 0.0 {
return Err(PulseError::InvalidNoteName {
value: value.to_string(),
});
}
Ok(frequency)
}
pub fn mode_names() -> &'static [&'static str] {
MODE_NAMES
}
pub fn chord_kind_names() -> &'static [&'static str] {
CHORD_KIND_NAMES
}
pub fn scale(root: &str, mode: &str) -> PulseResult<Vec<f32>> {
let root = parse_note_frequency(root)?;
let pattern = scale_pattern(mode)?;
Ok(tunes_scale(root, pattern))
}
pub fn chord(root: &str, kind: &str) -> PulseResult<Vec<f32>> {
let root = parse_note_frequency(root)?;
let pattern = chord_pattern(kind)?;
Ok(tunes_chord(root, pattern))
}
pub fn transpose_frequency(frequency: f32, semitones: i32) -> f32 {
transpose(frequency, semitones)
}
pub fn transpose_notes(notes: &[f32], semitones: i32) -> Vec<f32> {
transpose_sequence(notes, semitones)
}
fn scale_pattern(mode: &str) -> PulseResult<&'static ScalePattern> {
match normalize_name(mode).as_str() {
"major" | "ionian" => Ok(&ScalePattern::MAJOR),
"minor" | "aeolian" => Ok(&ScalePattern::MINOR),
"dorian" => Ok(&ScalePattern::DORIAN),
"phrygian" => Ok(&ScalePattern::PHRYGIAN),
"lydian" => Ok(&ScalePattern::LYDIAN),
"mixolydian" => Ok(&ScalePattern::MIXOLYDIAN),
"locrian" => Ok(&ScalePattern::LOCRIAN),
_ => Err(PulseError::InvalidMode {
value: mode.to_string(),
}),
}
}
fn chord_pattern(kind: &str) -> PulseResult<&'static ChordPattern> {
match normalize_name(kind).as_str() {
"major" | "maj" => Ok(&ChordPattern::MAJOR),
"minor" | "min" => Ok(&ChordPattern::MINOR),
"maj7" | "major7" => Ok(&ChordPattern::MAJOR7),
"min7" | "minor7" => Ok(&ChordPattern::MINOR7),
"dominant7" | "dom7" | "7" => Ok(&ChordPattern::DOMINANT7),
"dim" | "diminished" => Ok(&ChordPattern::DIMINISHED),
"aug" | "augmented" => Ok(&ChordPattern::AUGMENTED),
_ => Err(PulseError::InvalidChordKind {
value: kind.to_string(),
}),
}
}
fn normalize_name(value: &str) -> String {
value
.trim()
.to_ascii_lowercase()
.replace(['_', '-', ' '], "")
}
#[cfg(test)]
mod tests {
use super::*;
fn approx_eq(left: f32, right: f32) {
assert!(
(left - right).abs() < 0.02,
"expected {left} to be close to {right}"
);
}
#[test]
fn parses_common_note_names_to_frequency() {
approx_eq(parse_note_frequency("A4").expect("A4 should parse"), 440.0);
approx_eq(parse_note_frequency("C4").expect("C4 should parse"), 261.63);
approx_eq(
parse_note_frequency("C#4").expect("C#4 should parse"),
277.18,
);
approx_eq(
parse_note_frequency("Db4").expect("Db4 should parse"),
277.18,
);
}
#[test]
fn rejects_unknown_note_names() {
let error = parse_note_frequency("H4").expect_err("H4 is not a note");
assert_eq!(error.to_string(), "invalid note name: H4");
}
#[test]
fn rejects_overflowing_octaves_without_panicking() {
let error =
parse_note_frequency("C2147483647").expect_err("oversized octave should not overflow");
assert_eq!(error.to_string(), "invalid note name: C2147483647");
}
#[test]
fn exposes_supported_mode_and_chord_kind_names() {
assert!(mode_names().contains(&"ionian"));
assert!(mode_names().contains(&"locrian"));
assert!(chord_kind_names().contains(&"maj7"));
assert!(chord_kind_names().contains(&"dominant7"));
}
#[test]
fn generates_greek_modes_with_octave_note() {
let ionian = scale("C4", "ionian").expect("ionian should work");
assert_eq!(ionian.len(), 8);
approx_eq(ionian[0], parse_note_frequency("C4").unwrap());
approx_eq(ionian[1], parse_note_frequency("D4").unwrap());
approx_eq(ionian[7], parse_note_frequency("C5").unwrap());
let dorian = scale("D4", "dorian").expect("dorian should work");
approx_eq(dorian[2], parse_note_frequency("F4").unwrap());
let aeolian = scale("A4", "minor").expect("minor alias should work");
approx_eq(aeolian[2], parse_note_frequency("C5").unwrap());
}
#[test]
fn generates_chords_from_tunes_patterns() {
let c_major7 = chord("C4", "maj7").expect("maj7 should work");
assert_eq!(c_major7.len(), 4);
approx_eq(c_major7[0], parse_note_frequency("C4").unwrap());
approx_eq(c_major7[1], parse_note_frequency("E4").unwrap());
approx_eq(c_major7[2], parse_note_frequency("G4").unwrap());
approx_eq(c_major7[3], parse_note_frequency("B4").unwrap());
let a_minor7 = chord("A3", "min7").expect("min7 should work");
approx_eq(a_minor7[1], parse_note_frequency("C4").unwrap());
}
#[test]
fn transposes_single_frequency_and_sequences() {
let c4 = parse_note_frequency("C4").unwrap();
approx_eq(
transpose_frequency(c4, 2),
parse_note_frequency("D4").unwrap(),
);
let notes = vec![c4, parse_note_frequency("E4").unwrap()];
let transposed = transpose_notes(¬es, -12);
approx_eq(transposed[0], parse_note_frequency("C3").unwrap());
approx_eq(transposed[1], parse_note_frequency("E3").unwrap());
}
}