use serde::{Deserialize, Serialize};
fn letter_semitones(byte: u8) -> Option<i16> {
match byte.to_ascii_uppercase() {
b'C' => Some(0),
b'D' => Some(2),
b'E' => Some(4),
b'F' => Some(5),
b'G' => Some(7),
b'A' => Some(9),
b'B' => Some(11),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct PitchClass(u8);
impl PitchClass {
pub const fn from_semitone(semitone: u8) -> PitchClass {
PitchClass(semitone % 12)
}
pub fn from_name(name: &str) -> Result<PitchClass, MusicError> {
let expected =
r#"one letter A–G with an optional #/s/b accidental, like "C", "F#", or "Gb""#;
let bytes = name.as_bytes();
if bytes.is_empty() || bytes.len() > 2 {
return Err(bad_name("pitch-class", name, expected));
}
let Some(mut semis) = letter_semitones(bytes[0]) else {
return Err(bad_name("pitch-class", name, expected));
};
match bytes.get(1) {
None => {}
Some(b'#') | Some(b's') => semis += 1,
Some(b'b') => semis -= 1,
_ => return Err(bad_name("pitch-class", name, expected)),
}
Ok(PitchClass(semis.rem_euclid(12) as u8))
}
pub const fn semitone(&self) -> u8 {
self.0
}
pub fn sharp_name(&self) -> &'static str {
[
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
][self.0 as usize]
}
pub fn flat_name(&self) -> &'static str {
[
"C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B",
][self.0 as usize]
}
}
impl std::fmt::Display for PitchClass {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.sharp_name())
}
}
impl Serialize for PitchClass {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_u8(self.0)
}
}
impl<'de> Deserialize<'de> for PitchClass {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let semitone = u8::deserialize(deserializer)?;
if semitone > 11 {
return Err(serde::de::Error::custom(format!(
"pitch-class {semitone} is out of range — pitch classes span 0..=11 (C..B)"
)));
}
Ok(PitchClass(semitone))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Interval(i16);
impl Interval {
pub const UNISON: Interval = Interval(0);
pub const MINOR_SECOND: Interval = Interval(1);
pub const MAJOR_SECOND: Interval = Interval(2);
pub const MINOR_THIRD: Interval = Interval(3);
pub const MAJOR_THIRD: Interval = Interval(4);
pub const PERFECT_FOURTH: Interval = Interval(5);
pub const TRITONE: Interval = Interval(6);
pub const PERFECT_FIFTH: Interval = Interval(7);
pub const MINOR_SIXTH: Interval = Interval(8);
pub const MAJOR_SIXTH: Interval = Interval(9);
pub const MINOR_SEVENTH: Interval = Interval(10);
pub const MAJOR_SEVENTH: Interval = Interval(11);
pub const OCTAVE: Interval = Interval(12);
pub const fn new(semitones: i16) -> Interval {
Interval(semitones)
}
pub const fn semitones(&self) -> i16 {
self.0
}
}
impl std::fmt::Display for Interval {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let name = match self.0 {
0 => "P1",
1 => "m2",
2 => "M2",
3 => "m3",
4 => "M3",
5 => "P4",
6 => "TT",
7 => "P5",
8 => "m6",
9 => "M6",
10 => "m7",
11 => "M7",
12 => "P8",
n => return write!(f, "{n:+}"),
};
f.write_str(name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Pitch(u8);
impl Pitch {
pub fn from_midi(midi: u8) -> Result<Pitch, MusicError> {
Self::from_midi_i32(midi as i32)
}
pub fn new(class: PitchClass, octave: i8) -> Result<Pitch, MusicError> {
Self::from_midi_i32((octave as i32 + 1) * 12 + class.0 as i32)
}
pub fn from_name(name: &str) -> Result<Pitch, MusicError> {
let expected = r#"a note name like "C4", "F#3", "Gb5", or "midi:60""#;
if let Some(num) = name.strip_prefix("midi:") {
let midi: i16 = num.parse().map_err(|_| bad_name("pitch", name, expected))?;
return Self::from_midi_i32(midi as i32);
}
let bytes = name.as_bytes();
let Some((&letter, mut rest)) = bytes.split_first() else {
return Err(bad_name("pitch", name, expected));
};
let Some(mut semis) = letter_semitones(letter) else {
return Err(bad_name("pitch", name, expected));
};
if let Some((&acc, tail)) = rest.split_first() {
match acc {
b'#' | b's' => {
semis += 1;
rest = tail;
}
b'b' => {
semis -= 1;
rest = tail;
}
_ => {}
}
}
let octave: i8 = std::str::from_utf8(rest)
.ok()
.filter(|s| !s.is_empty())
.and_then(|s| s.parse().ok())
.ok_or_else(|| bad_name("pitch", name, expected))?;
Self::from_midi_i32((octave as i32 + 1) * 12 + semis as i32)
}
pub const fn to_midi(&self) -> u8 {
self.0
}
pub const fn pitch_class(&self) -> PitchClass {
PitchClass(self.0 % 12)
}
pub const fn octave(&self) -> i8 {
(self.0 / 12) as i8 - 1
}
pub fn to_hz(&self) -> f32 {
440.0 * 2f32.powf((self.0 as f32 - 69.0) / 12.0)
}
pub fn transpose(&self, interval: Interval) -> Result<Pitch, MusicError> {
self.add_semitones(interval.semitones())
}
pub fn add_semitones(&self, semitones: i16) -> Result<Pitch, MusicError> {
Self::from_midi_i32(self.0 as i32 + semitones as i32)
}
fn from_midi_i32(midi: i32) -> Result<Pitch, MusicError> {
if (0..=127).contains(&midi) {
Ok(Pitch(midi as u8))
} else {
Err(MusicError::OutOfRange(format!(
"pitch midi {midi} is out of range — MIDI pitches span 0..=127 (\"C-1\" to \"G9\")"
)))
}
}
}
impl std::fmt::Display for Pitch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}{}", self.pitch_class().sharp_name(), self.octave())
}
}
impl Serialize for Pitch {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for Pitch {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let name = String::deserialize(deserializer)?;
Pitch::from_name(&name).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Scale {
Major,
NaturalMinor,
HarmonicMinor,
MelodicMinor,
MajorPentatonic,
MinorPentatonic,
Dorian,
Mixolydian,
Chromatic,
}
impl Scale {
pub fn intervals(&self) -> &'static [u8] {
match self {
Scale::Major => &[0, 2, 4, 5, 7, 9, 11],
Scale::NaturalMinor => &[0, 2, 3, 5, 7, 8, 10],
Scale::HarmonicMinor => &[0, 2, 3, 5, 7, 8, 11],
Scale::MelodicMinor => &[0, 2, 3, 5, 7, 9, 11],
Scale::MajorPentatonic => &[0, 2, 4, 7, 9],
Scale::MinorPentatonic => &[0, 3, 5, 7, 10],
Scale::Dorian => &[0, 2, 3, 5, 7, 9, 10],
Scale::Mixolydian => &[0, 2, 4, 5, 7, 9, 10],
Scale::Chromatic => &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
}
}
pub fn name(&self) -> &'static str {
match self {
Scale::Major => "major",
Scale::NaturalMinor => "natural_minor",
Scale::HarmonicMinor => "harmonic_minor",
Scale::MelodicMinor => "melodic_minor",
Scale::MajorPentatonic => "major_pentatonic",
Scale::MinorPentatonic => "minor_pentatonic",
Scale::Dorian => "dorian",
Scale::Mixolydian => "mixolydian",
Scale::Chromatic => "chromatic",
}
}
pub fn from_name(name: &str) -> Result<Scale, MusicError> {
match name {
"major" => Ok(Scale::Major),
"minor" | "natural_minor" => Ok(Scale::NaturalMinor),
"harmonic_minor" => Ok(Scale::HarmonicMinor),
"melodic_minor" => Ok(Scale::MelodicMinor),
"major_pentatonic" => Ok(Scale::MajorPentatonic),
"minor_pentatonic" => Ok(Scale::MinorPentatonic),
"dorian" => Ok(Scale::Dorian),
"mixolydian" => Ok(Scale::Mixolydian),
"chromatic" => Ok(Scale::Chromatic),
_ => Err(bad_name(
"scale",
name,
r#"one of "major", "minor", "natural_minor", "harmonic_minor", "melodic_minor", "major_pentatonic", "minor_pentatonic", "dorian", "mixolydian", or "chromatic""#,
)),
}
}
pub fn contains(&self, class: PitchClass) -> bool {
self.intervals().contains(&class.semitone())
}
pub fn degree(&self, n: u32) -> Result<Interval, MusicError> {
if n == 0 {
return Err(MusicError::BadDegree(0));
}
let steps = self.intervals();
let index = (n - 1) as usize;
let octaves = (index / steps.len()) as i64 * 12;
let semitones = steps[index % steps.len()] as i64 + octaves;
let semitones = i16::try_from(semitones).map_err(|_| {
MusicError::OutOfRange(format!("scale degree {n} is too large for an interval"))
})?;
Ok(Interval::new(semitones))
}
pub fn notes(&self, tonic: PitchClass) -> Vec<PitchClass> {
self.intervals()
.iter()
.map(|&step| PitchClass::from_semitone(tonic.semitone() + step))
.collect()
}
}
impl std::fmt::Display for Scale {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.name())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Key {
pub tonic: PitchClass,
pub scale: Scale,
}
impl Key {
pub const fn new(tonic: PitchClass, scale: Scale) -> Key {
Key { tonic, scale }
}
pub fn from_name(name: &str) -> Result<Key, MusicError> {
let expected =
r#"a tonic, one space, and a scale name, like "C major", "A minor", or "F# dorian""#;
let Some((tonic, scale)) = name.split_once(' ') else {
return Err(bad_name("key", name, expected));
};
let tonic = PitchClass::from_name(tonic).map_err(|_| bad_name("key", name, expected))?;
let scale = Scale::from_name(scale).map_err(|_| bad_name("key", name, expected))?;
Ok(Key { tonic, scale })
}
pub fn degree_pitch(&self, n: u32, octave: i8) -> Result<Pitch, MusicError> {
Pitch::new(self.tonic, octave)?.transpose(self.scale.degree(n)?)
}
pub fn contains(&self, pitch: Pitch) -> bool {
self.scale.notes(self.tonic).contains(&pitch.pitch_class())
}
}
impl std::fmt::Display for Key {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} {}", self.tonic.sharp_name(), self.scale.name())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChordQuality {
Major,
Minor,
Diminished,
Augmented,
MajorSeventh,
MinorSeventh,
DominantSeventh,
}
impl ChordQuality {
pub fn intervals(&self) -> &'static [u8] {
match self {
ChordQuality::Major => &[0, 4, 7],
ChordQuality::Minor => &[0, 3, 7],
ChordQuality::Diminished => &[0, 3, 6],
ChordQuality::Augmented => &[0, 4, 8],
ChordQuality::MajorSeventh => &[0, 4, 7, 11],
ChordQuality::MinorSeventh => &[0, 3, 7, 10],
ChordQuality::DominantSeventh => &[0, 4, 7, 10],
}
}
pub fn suffix(&self) -> &'static str {
match self {
ChordQuality::Major => "",
ChordQuality::Minor => "m",
ChordQuality::Diminished => "dim",
ChordQuality::Augmented => "aug",
ChordQuality::MajorSeventh => "maj7",
ChordQuality::MinorSeventh => "m7",
ChordQuality::DominantSeventh => "7",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Chord {
pub root: PitchClass,
pub quality: ChordQuality,
}
impl Chord {
pub const fn new(root: PitchClass, quality: ChordQuality) -> Chord {
Chord { root, quality }
}
pub fn from_name(name: &str) -> Result<Chord, MusicError> {
let expected = r#"a root plus one of "", "m", "maj7", "m7", "7", "dim", or "aug", like "C", "Cm", or "Cmaj7""#;
if !name.is_ascii() || name.is_empty() {
return Err(bad_name("chord", name, expected));
}
let bytes = name.as_bytes();
let root_len = match bytes.get(1) {
Some(b'#') | Some(b's') | Some(b'b') => 2,
_ => 1,
};
let (root, suffix) = name.split_at(root_len);
let root = PitchClass::from_name(root).map_err(|_| bad_name("chord", name, expected))?;
let quality = match suffix {
"" => ChordQuality::Major,
"m" => ChordQuality::Minor,
"maj7" => ChordQuality::MajorSeventh,
"m7" => ChordQuality::MinorSeventh,
"7" => ChordQuality::DominantSeventh,
"dim" => ChordQuality::Diminished,
"aug" => ChordQuality::Augmented,
_ => return Err(bad_name("chord", name, expected)),
};
Ok(Chord { root, quality })
}
pub fn notes(&self) -> Vec<PitchClass> {
self.quality
.intervals()
.iter()
.map(|&step| PitchClass::from_semitone(self.root.semitone() + step))
.collect()
}
pub fn invert(&self, n: u32) -> Inversion {
Inversion {
chord: *self,
inversion: n,
}
}
pub fn arp(&self, octave: i8) -> Result<Vec<Pitch>, MusicError> {
let root = Pitch::new(self.root, octave)?;
self.quality
.intervals()
.iter()
.map(|&step| root.add_semitones(step as i16))
.collect()
}
pub fn contains(&self, class: PitchClass) -> bool {
self.notes().contains(&class)
}
}
impl std::fmt::Display for Chord {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}{}", self.root.sharp_name(), self.quality.suffix())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Inversion {
pub chord: Chord,
pub inversion: u32,
}
impl Inversion {
pub fn pitch_classes(&self) -> Vec<PitchClass> {
let notes = self.chord.notes();
let n = (self.inversion as usize) % notes.len();
notes[n..].iter().chain(¬es[..n]).copied().collect()
}
pub fn pitches(&self, octave: i8) -> Result<Vec<Pitch>, MusicError> {
let root = Pitch::new(self.chord.root, octave)?;
let intervals = self.chord.quality.intervals();
let n = (self.inversion as usize) % intervals.len();
intervals[n..]
.iter()
.map(|&step| root.add_semitones(step as i16))
.chain(
intervals[..n]
.iter()
.map(|&step| root.add_semitones(step as i16 + 12)),
)
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
pub struct Voicing {
pitches: Vec<Pitch>,
}
impl Voicing {
pub fn close(chord: Chord, octave: i8) -> Result<Voicing, MusicError> {
Voicing::checked(chord.arp(octave)?)
}
pub fn open(chord: Chord, octave: i8) -> Result<Voicing, MusicError> {
let close = chord.arp(octave)?;
let dropped = close[close.len() - 2].add_semitones(-12)?;
let mut pitches = vec![dropped];
pitches.extend_from_slice(&close[..close.len() - 2]);
pitches.push(close[close.len() - 1]);
Voicing::checked(pitches)
}
pub fn with_bass(chord: Chord, octave: i8, bass: PitchClass) -> Result<Voicing, MusicError> {
let close = chord.arp(octave)?;
let root = close[0].to_midi() as i16;
let mut bass_midi = (octave as i16 + 1) * 12 + bass.semitone() as i16;
if bass_midi >= root {
bass_midi -= 12;
}
let bass = Pitch::from_midi_i32(bass_midi as i32)?;
let mut pitches = vec![bass];
pitches.extend_from_slice(&close);
Voicing::checked(pitches)
}
pub fn pitches(&self) -> &[Pitch] {
&self.pitches
}
pub fn transpose(&self, interval: Interval) -> Result<Voicing, MusicError> {
let pitches = self
.pitches
.iter()
.map(|p| p.transpose(interval))
.collect::<Result<Vec<_>, _>>()?;
Voicing::checked(pitches)
}
fn checked(pitches: Vec<Pitch>) -> Result<Voicing, MusicError> {
if pitches.is_empty() {
return Err(MusicError::BadVoicing(
"a voicing needs at least one pitch".to_string(),
));
}
if pitches.windows(2).any(|pair| pair[0] >= pair[1]) {
return Err(MusicError::BadVoicing(
"voicing pitches must be strictly ascending (bass to soprano)".to_string(),
));
}
Ok(Voicing { pitches })
}
}
impl<'de> Deserialize<'de> for Voicing {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
struct Raw {
pitches: Vec<Pitch>,
}
Voicing::checked(Raw::deserialize(deserializer)?.pitches).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MusicError {
BadName(String),
OutOfRange(String),
BadDegree(u32),
BadVoicing(String),
}
impl std::fmt::Display for MusicError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MusicError::BadName(message)
| MusicError::OutOfRange(message)
| MusicError::BadVoicing(message) => f.write_str(message),
MusicError::BadDegree(n) => {
write!(f, "degree {n} is invalid — degrees are 1-based (1 = tonic)")
}
}
}
}
impl std::error::Error for MusicError {}
fn bad_name(kind: &str, input: &str, expected: &str) -> MusicError {
MusicError::BadName(format!(
"invalid {kind} name \"{input}\" — expected {expected}"
))
}
#[cfg(test)]
mod tests {
use super::*;
fn pc(name: &str) -> PitchClass {
PitchClass::from_name(name).unwrap()
}
fn pitch(name: &str) -> Pitch {
Pitch::from_name(name).unwrap()
}
fn midis(pitches: &[Pitch]) -> Vec<u8> {
pitches.iter().map(|p| p.to_midi()).collect()
}
#[test]
fn pitch_class_from_semitone_wraps() {
assert_eq!(PitchClass::from_semitone(0).semitone(), 0);
assert_eq!(PitchClass::from_semitone(11).semitone(), 11);
assert_eq!(PitchClass::from_semitone(12).semitone(), 0);
assert_eq!(PitchClass::from_semitone(13).semitone(), 1);
assert_eq!(PitchClass::from_semitone(255).semitone(), 3);
}
#[test]
fn pitch_class_names_parse_strictly() {
assert_eq!(pc("C").semitone(), 0);
assert_eq!(pc("F#").semitone(), 6);
assert_eq!(pc("Gb").semitone(), 6);
assert_eq!(pc("f#").semitone(), 6); assert_eq!(pc("Fs").semitone(), 6); assert_eq!(pc("Cb").semitone(), 11); assert_eq!(pc("B#").semitone(), 0); assert_eq!(pc("E#").semitone(), 5);
assert_eq!(pc("Fb").semitone(), 4);
for bad in ["", "H", "C##", "Fis", "Cb#", "C b", "CC"] {
assert!(
PitchClass::from_name(bad).is_err(),
"{bad:?} must not parse"
);
}
}
#[test]
fn pitch_class_displays_with_sharps() {
assert_eq!(pc("Gb").to_string(), "F#");
assert_eq!(pc("Bb").to_string(), "A#");
assert_eq!(pc("C").to_string(), "C");
assert_eq!(pc("Gb").flat_name(), "Gb");
assert_eq!(pc("E").flat_name(), "E"); }
#[test]
fn pitch_names_round_trip() {
for name in ["C4", "F#3", "A4", "C-1", "G9", "Bb2"] {
let p = pitch(name);
assert_eq!(pitch(&p.to_string()), p, "{name} must round-trip");
}
assert_eq!(pitch("C#4"), pitch("Db4"));
assert_eq!(pitch("Db4").to_string(), "C#4");
assert_eq!(pitch("Gb3").to_string(), "F#3");
assert_eq!(pitch("midi:60"), pitch("C4"));
assert_eq!(pitch("midi:69"), pitch("A4"));
}
#[test]
fn pitch_midi_octave_class_agree() {
let c4 = pitch("C4");
assert_eq!(c4.to_midi(), 60);
assert_eq!(c4.pitch_class(), pc("C"));
assert_eq!(c4.octave(), 4);
let b_minus_1 = pitch("B-1");
assert_eq!(b_minus_1.to_midi(), 11);
assert_eq!(b_minus_1.octave(), -1);
}
#[test]
fn pitch_new_checks_bounds() {
assert_eq!(Pitch::new(pc("C"), -1).unwrap().to_midi(), 0);
assert_eq!(Pitch::new(pc("G"), 9).unwrap().to_midi(), 127);
assert!(Pitch::new(pc("G#"), 9).is_err()); assert!(Pitch::new(pc("C"), -2).is_err());
assert_eq!(Pitch::from_midi(127).unwrap(), pitch("G9"));
assert!(Pitch::from_midi(128).is_err());
}
#[test]
fn pitch_grammar_is_strict() {
for bad in [
"",
"H4",
"C",
"C##4",
"C4 ",
" C4",
"midi:",
"midi:60.5",
"m60",
"C#b4",
] {
assert!(Pitch::from_name(bad).is_err(), "{bad:?} must not parse");
}
assert_eq!(
Pitch::from_name("C").unwrap_err().to_string(),
r#"invalid pitch name "C" — expected a note name like "C4", "F#3", "Gb5", or "midi:60""#
);
assert_eq!(
Pitch::from_name("midi:128").unwrap_err().to_string(),
r#"pitch midi 128 is out of range — MIDI pitches span 0..=127 ("C-1" to "G9")"#
);
assert!(Pitch::from_name("C10").is_err());
assert!(Pitch::from_name("G#9").is_err());
}
#[test]
fn pitch_to_hz_matches_the_dsl_formula() {
assert_eq!(pitch("A4").to_hz(), 440.0);
for name in ["C4", "F#3", "Gb5", "C-1", "G9"] {
assert_eq!(
pitch(name).to_hz(),
crate::dsl::note_to_hz(name).unwrap(),
"{name} must match note_to_hz"
);
}
let c4 = pitch("C4").to_hz();
assert!((c4 - 261.63).abs() < 0.01, "C4 ≈ 261.63 Hz, got {c4}");
}
#[test]
fn interval_constants_and_display() {
assert_eq!(Interval::UNISON.semitones(), 0);
assert_eq!(Interval::MINOR_THIRD.semitones(), 3);
assert_eq!(Interval::MAJOR_THIRD.semitones(), 4);
assert_eq!(Interval::PERFECT_FIFTH.semitones(), 7);
assert_eq!(Interval::TRITONE.semitones(), 6);
assert_eq!(Interval::MINOR_SEVENTH.semitones(), 10);
assert_eq!(Interval::MAJOR_SEVENTH.semitones(), 11);
assert_eq!(Interval::OCTAVE.semitones(), 12);
assert_eq!(Interval::UNISON.to_string(), "P1");
assert_eq!(Interval::MINOR_THIRD.to_string(), "m3");
assert_eq!(Interval::MAJOR_THIRD.to_string(), "M3");
assert_eq!(Interval::PERFECT_FIFTH.to_string(), "P5");
assert_eq!(Interval::TRITONE.to_string(), "TT");
assert_eq!(Interval::OCTAVE.to_string(), "P8");
assert_eq!(Interval::new(14).to_string(), "+14");
assert_eq!(Interval::new(-3).to_string(), "-3");
}
#[test]
fn transpose_crosses_octaves_and_checks_bounds() {
assert_eq!(
pitch("C4").transpose(Interval::MAJOR_THIRD).unwrap(),
pitch("E4")
);
assert_eq!(
pitch("B4").transpose(Interval::MINOR_SECOND).unwrap(),
pitch("C5")
);
assert_eq!(pitch("C4").add_semitones(-12).unwrap(), pitch("C3"));
assert!(pitch("G9").transpose(Interval::MINOR_SECOND).is_err());
assert!(pitch("C-1").add_semitones(-1).is_err());
assert!(pitch("G9").add_semitones(i16::MAX).is_err());
assert!(pitch("C-1").add_semitones(i16::MIN).is_err());
}
#[test]
fn pitches_order_by_midi() {
assert!(pitch("C4") < pitch("C5"));
assert!(pitch("F#3") == pitch("Gb3")); let mut v = vec![pitch("E4"), pitch("C4"), pitch("G4")];
v.sort();
assert_eq!(v, vec![pitch("C4"), pitch("E4"), pitch("G4")]);
}
#[test]
fn scale_intervals_are_the_named_patterns() {
assert_eq!(Scale::Major.intervals(), &[0, 2, 4, 5, 7, 9, 11]);
assert_eq!(Scale::NaturalMinor.intervals(), &[0, 2, 3, 5, 7, 8, 10]);
assert_eq!(Scale::HarmonicMinor.intervals(), &[0, 2, 3, 5, 7, 8, 11]);
assert_eq!(Scale::MelodicMinor.intervals(), &[0, 2, 3, 5, 7, 9, 11]);
assert_eq!(Scale::MajorPentatonic.intervals(), &[0, 2, 4, 7, 9]);
assert_eq!(Scale::MinorPentatonic.intervals(), &[0, 3, 5, 7, 10]);
assert_eq!(Scale::Dorian.intervals(), &[0, 2, 3, 5, 7, 9, 10]);
assert_eq!(Scale::Mixolydian.intervals(), &[0, 2, 4, 5, 7, 9, 10]);
assert_eq!(
Scale::Chromatic.intervals(),
&[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
);
}
#[test]
fn scale_notes_build_on_a_tonic() {
let semis = |scale: Scale, tonic: &str| {
scale
.notes(pc(tonic))
.iter()
.map(|p| p.semitone())
.collect::<Vec<_>>()
};
assert_eq!(semis(Scale::Major, "C"), vec![0, 2, 4, 5, 7, 9, 11]); assert_eq!(semis(Scale::NaturalMinor, "A"), vec![9, 11, 0, 2, 4, 5, 7]);
assert_eq!(semis(Scale::Dorian, "D"), vec![2, 4, 5, 7, 9, 11, 0]);
assert_eq!(semis(Scale::MinorPentatonic, "A"), vec![9, 0, 2, 4, 7]);
}
#[test]
fn scale_degrees_are_one_based() {
assert_eq!(Scale::Major.degree(1).unwrap(), Interval::UNISON);
assert_eq!(Scale::Major.degree(2).unwrap(), Interval::MAJOR_SECOND);
assert_eq!(Scale::Major.degree(7).unwrap(), Interval::MAJOR_SEVENTH);
assert_eq!(Scale::Major.degree(8).unwrap(), Interval::OCTAVE);
assert_eq!(Scale::Major.degree(9).unwrap().semitones(), 14);
assert_eq!(Scale::MajorPentatonic.degree(6).unwrap(), Interval::OCTAVE);
assert_eq!(Scale::Major.degree(0), Err(MusicError::BadDegree(0)));
assert!(Scale::Major.degree(u32::MAX).is_err());
}
#[test]
fn scale_contains_is_pattern_membership() {
assert!(Scale::Major.contains(pc("E"))); assert!(!Scale::Major.contains(pc("C#")));
assert!(Scale::Chromatic.contains(pc("C#")));
}
#[test]
fn scale_names_are_strict_lowercase() {
assert_eq!(Scale::from_name("major"), Ok(Scale::Major));
assert_eq!(Scale::from_name("minor"), Ok(Scale::NaturalMinor));
assert_eq!(Scale::from_name("natural_minor"), Ok(Scale::NaturalMinor));
assert_eq!(Scale::from_name("dorian"), Ok(Scale::Dorian));
for bad in ["", "Major", "ionian", "min", "pentatonic"] {
assert!(Scale::from_name(bad).is_err(), "{bad:?} must not parse");
}
for scale in [
Scale::Major,
Scale::NaturalMinor,
Scale::HarmonicMinor,
Scale::MelodicMinor,
Scale::MajorPentatonic,
Scale::MinorPentatonic,
Scale::Dorian,
Scale::Mixolydian,
Scale::Chromatic,
] {
assert_eq!(Scale::from_name(scale.name()), Ok(scale));
}
}
#[test]
fn key_names_round_trip() {
let c_major = Key::new(pc("C"), Scale::Major);
assert_eq!(Key::from_name("C major"), Ok(c_major));
assert_eq!(
Key::from_name("A minor"),
Ok(Key::new(pc("A"), Scale::NaturalMinor))
);
assert_eq!(
Key::from_name("F# dorian"),
Ok(Key::new(pc("F#"), Scale::Dorian))
);
for key in [
c_major,
Key::new(pc("A"), Scale::NaturalMinor),
Key::from_name("F# dorian").unwrap(),
] {
assert_eq!(
Key::from_name(&key.to_string()),
Ok(key),
"{key} must round-trip"
);
}
assert_eq!(
Key::new(pc("A"), Scale::NaturalMinor).to_string(),
"A natural_minor"
);
}
#[test]
fn key_grammar_is_strict() {
for bad in ["", "Cmajor", "C major", "C major ", "C ionian", "H major"] {
assert!(Key::from_name(bad).is_err(), "{bad:?} must not parse");
}
assert_eq!(
Key::from_name("Cmajor").unwrap_err().to_string(),
r#"invalid key name "Cmajor" — expected a tonic, one space, and a scale name, like "C major", "A minor", or "F# dorian""#
);
}
#[test]
fn key_degree_pitch_resolves_across_octaves() {
let c_major = Key::new(pc("C"), Scale::Major);
assert_eq!(c_major.degree_pitch(1, 4).unwrap(), pitch("C4"));
assert_eq!(c_major.degree_pitch(3, 4).unwrap(), pitch("E4"));
assert_eq!(c_major.degree_pitch(7, 4).unwrap(), pitch("B4"));
assert_eq!(c_major.degree_pitch(8, 4).unwrap(), pitch("C5"));
let g_major = Key::new(pc("G"), Scale::Major);
assert_eq!(g_major.degree_pitch(7, 4).unwrap(), pitch("F#5"));
assert!(c_major.degree_pitch(0, 4).is_err());
assert!(c_major.degree_pitch(8, 9).is_err()); }
#[test]
fn key_contains_checks_membership() {
let c_major = Key::new(pc("C"), Scale::Major);
assert!(c_major.contains(pitch("E4")));
assert!(c_major.contains(pitch("B2")));
assert!(!c_major.contains(pitch("C#4")));
assert!(!c_major.contains(pitch("Bb3")));
}
#[test]
fn chord_quality_intervals() {
assert_eq!(ChordQuality::Major.intervals(), &[0, 4, 7]);
assert_eq!(ChordQuality::Minor.intervals(), &[0, 3, 7]);
assert_eq!(ChordQuality::Diminished.intervals(), &[0, 3, 6]);
assert_eq!(ChordQuality::Augmented.intervals(), &[0, 4, 8]);
assert_eq!(ChordQuality::MajorSeventh.intervals(), &[0, 4, 7, 11]);
assert_eq!(ChordQuality::MinorSeventh.intervals(), &[0, 3, 7, 10]);
assert_eq!(ChordQuality::DominantSeventh.intervals(), &[0, 4, 7, 10]);
}
#[test]
fn chord_names_parse_the_seven_forms() {
let cases = [
("C", ChordQuality::Major),
("Cm", ChordQuality::Minor),
("Cmaj7", ChordQuality::MajorSeventh),
("Cm7", ChordQuality::MinorSeventh),
("C7", ChordQuality::DominantSeventh),
("Cdim", ChordQuality::Diminished),
("Caug", ChordQuality::Augmented),
];
for (name, quality) in cases {
assert_eq!(Chord::from_name(name), Ok(Chord::new(pc("C"), quality)));
}
assert_eq!(Chord::from_name("F#m7").unwrap().root, pc("Gb"));
assert_eq!(Chord::from_name("Bb7").unwrap().root, pc("A#"));
assert_eq!(Chord::from_name("b7").unwrap().root, pc("B"));
for name in ["C", "Cm", "Cmaj7", "Cm7", "C7", "Cdim", "Caug", "F#m7"] {
let chord = Chord::from_name(name).unwrap();
assert_eq!(Chord::from_name(&chord.to_string()), Ok(chord));
}
}
#[test]
fn chord_grammar_never_guesses() {
for bad in [
"", "CM", "Cmaj", "Csus4", "C sus4", "Cm9", "C7 ", "H", "CM7", "cM",
] {
assert!(Chord::from_name(bad).is_err(), "{bad:?} must not parse");
}
assert_eq!(
Chord::from_name("CM").unwrap_err().to_string(),
r#"invalid chord name "CM" — expected a root plus one of "", "m", "maj7", "m7", "7", "dim", or "aug", like "C", "Cm", or "Cmaj7""#
);
}
#[test]
fn chord_notes_and_contains() {
let c = Chord::from_name("C").unwrap();
let semis: Vec<u8> = c.notes().iter().map(|p| p.semitone()).collect();
assert_eq!(semis, vec![0, 4, 7]);
let d7 = Chord::from_name("D7").unwrap();
let semis: Vec<u8> = d7.notes().iter().map(|p| p.semitone()).collect();
assert_eq!(semis, vec![2, 6, 9, 0]); assert!(d7.contains(pc("C")));
assert!(!d7.contains(pc("C#")));
assert!(c.contains(pc("E")));
}
#[test]
fn chord_inversions_rotate_the_bass_up() {
let c = Chord::from_name("C").unwrap();
let semis = |n: u32| {
c.invert(n)
.pitch_classes()
.iter()
.map(|p| p.semitone())
.collect::<Vec<_>>()
};
assert_eq!(semis(0), vec![0, 4, 7]); assert_eq!(semis(1), vec![4, 7, 0]); assert_eq!(semis(2), vec![7, 0, 4]); assert_eq!(semis(3), vec![0, 4, 7]);
assert_eq!(
midis(&c.invert(1).pitches(4).unwrap()),
vec![64, 67, 72] );
}
#[test]
fn chord_arp_ascends_from_the_root() {
let cmaj7 = Chord::from_name("Cmaj7").unwrap();
let names: Vec<String> = cmaj7
.arp(4)
.unwrap()
.iter()
.map(|p| p.to_string())
.collect();
assert_eq!(names, ["C4", "E4", "G4", "B4"]);
assert!(cmaj7.arp(9).is_err());
assert!(Chord::from_name("C").unwrap().arp(9).is_ok()); }
#[test]
fn close_voicing_stacks_in_root_position() {
let c = Chord::from_name("C").unwrap();
assert_eq!(
midis(Voicing::close(c, 4).unwrap().pitches()),
vec![60, 64, 67] );
assert!(Voicing::close(Chord::from_name("Cmaj7").unwrap(), 9).is_err());
}
#[test]
fn open_voicing_is_drop_two() {
let c = Chord::from_name("C").unwrap();
assert_eq!(
midis(Voicing::open(c, 4).unwrap().pitches()),
vec![52, 60, 67] );
let cmaj7 = Chord::from_name("Cmaj7").unwrap();
assert_eq!(
midis(Voicing::open(cmaj7, 4).unwrap().pitches()),
vec![55, 60, 64, 71] );
assert!(Voicing::open(c, -1).is_err());
}
#[test]
fn slash_voicing_puts_the_bass_below() {
let c = Chord::from_name("C").unwrap();
let bass = |bass: &str| midis(Voicing::with_bass(c, 4, pc(bass)).unwrap().pitches());
assert_eq!(bass("E"), vec![52, 60, 64, 67]); assert_eq!(bass("G"), vec![55, 60, 64, 67]); assert_eq!(bass("C"), vec![48, 60, 64, 67]); assert_eq!(bass("B"), vec![59, 60, 64, 67]); assert!(Voicing::with_bass(c, -1, pc("E")).is_err());
}
#[test]
fn voicing_transpose_checks_every_voice() {
let c = Chord::from_name("C").unwrap();
let up = Voicing::close(c, 4)
.unwrap()
.transpose(Interval::OCTAVE)
.unwrap();
assert_eq!(midis(up.pitches()), vec![72, 76, 79]);
let high = Voicing::close(c, 8).unwrap(); assert!(high.transpose(Interval::new(14)).is_err());
assert!(
Voicing::close(c, 0)
.unwrap()
.transpose(Interval::new(-13))
.is_err()
);
}
#[test]
fn serde_round_trips_every_type() {
assert_eq!(serde_json::to_string(&pc("F#")).unwrap(), "6");
assert_eq!(serde_json::from_str::<PitchClass>("6").unwrap(), pc("F#"));
assert_eq!(
serde_json::to_string(&Interval::PERFECT_FIFTH).unwrap(),
"7"
);
assert_eq!(
serde_json::from_str::<Interval>("-3").unwrap(),
Interval::new(-3)
);
assert_eq!(
serde_json::to_string(&Scale::NaturalMinor).unwrap(),
r#""natural_minor""#
);
assert_eq!(
serde_json::from_str::<Scale>(r#""dorian""#).unwrap(),
Scale::Dorian
);
assert_eq!(
serde_json::to_string(&ChordQuality::DominantSeventh).unwrap(),
r#""dominant_seventh""#
);
assert_eq!(serde_json::to_string(&pitch("Gb3")).unwrap(), r#""F#3""#);
assert_eq!(
serde_json::from_str::<Pitch>(r#""Gb3""#).unwrap(),
pitch("F#3")
);
let key = Key::from_name("C major").unwrap();
assert_eq!(
serde_json::to_string(&key).unwrap(),
r#"{"tonic":0,"scale":"major"}"#
);
assert_eq!(
serde_json::from_str::<Key>(r#"{"tonic":0,"scale":"major"}"#).unwrap(),
key
);
let chord = Chord::from_name("C7").unwrap();
assert_eq!(
serde_json::to_string(&chord).unwrap(),
r#"{"root":0,"quality":"dominant_seventh"}"#
);
assert_eq!(
serde_json::from_str::<Chord>(r#"{"root":0,"quality":"dominant_seventh"}"#).unwrap(),
chord
);
let inversion = chord.invert(1);
let json = serde_json::to_string(&inversion).unwrap();
assert_eq!(serde_json::from_str::<Inversion>(&json).unwrap(), inversion);
let voicing = Voicing::with_bass(Chord::from_name("C").unwrap(), 4, pc("E")).unwrap();
assert_eq!(
serde_json::to_string(&voicing).unwrap(),
r#"{"pitches":["E3","C4","E4","G4"]}"#
);
assert_eq!(
serde_json::from_str::<Voicing>(r#"{"pitches":["E3","C4","E4","G4"]}"#).unwrap(),
voicing
);
}
#[test]
fn serde_rejects_the_invalid() {
assert!(serde_json::from_str::<Pitch>(r#""H4""#).is_err());
assert!(serde_json::from_str::<Pitch>(r#""C""#).is_err());
assert!(serde_json::from_str::<Pitch>("60").is_err());
assert!(serde_json::from_str::<PitchClass>("12").is_err());
assert!(serde_json::from_str::<Scale>(r#""Major""#).is_err());
assert!(serde_json::from_str::<Key>(r#""C major""#).is_err());
assert!(serde_json::from_str::<Voicing>(r#"{"pitches":[]}"#).is_err());
assert!(serde_json::from_str::<Voicing>(r#"{"pitches":["E4","C4"]}"#).is_err());
}
#[test]
fn errors_name_the_rejected_input() {
assert_eq!(
PitchClass::from_name("H").unwrap_err().to_string(),
r#"invalid pitch-class name "H" — expected one letter A–G with an optional #/s/b accidental, like "C", "F#", or "Gb""#
);
assert_eq!(
Scale::Major.degree(0).unwrap_err().to_string(),
"degree 0 is invalid — degrees are 1-based (1 = tonic)"
);
assert_eq!(
Pitch::new(pc("G#"), 9).unwrap_err().to_string(),
r#"pitch midi 128 is out of range — MIDI pitches span 0..=127 ("C-1" to "G9")"#
);
}
}