mlua-pulse 0.1.0

Lua-friendly music composition and audio export bindings built on tunes and mlua
Documentation
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",
];

/// Parses a note name like `C4`, `C#4`, or `Db4` into a frequency in Hertz.
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(),
        })?;

    // Compute the MIDI note in `i64` so an absurd octave cannot overflow the
    // `i32` arithmetic and panic; `octave` is already bounded to `i32`.
    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)
}

/// Returns all Lua-visible scale and mode names.
pub fn mode_names() -> &'static [&'static str] {
    MODE_NAMES
}

/// Returns all Lua-visible chord kind names.
pub fn chord_kind_names() -> &'static [&'static str] {
    CHORD_KIND_NAMES
}

/// Generates a scale using `tunes` scale patterns.
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))
}

/// Generates a chord using `tunes` chord patterns.
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))
}

/// Transposes one frequency by semitones.
pub fn transpose_frequency(frequency: f32, semitones: i32) -> f32 {
    transpose(frequency, semitones)
}

/// Transposes a sequence of frequencies by 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() {
        // An octave near `i32::MAX` must not trigger integer-arithmetic
        // overflow; it should be reported as an invalid note name.
        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(&notes, -12);
        approx_eq(transposed[0], parse_note_frequency("C3").unwrap());
        approx_eq(transposed[1], parse_note_frequency("E3").unwrap());
    }
}