use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Step {
C,
D,
E,
F,
G,
A,
B,
}
impl Step {
pub fn to_semitone(&self) -> u8 {
match self {
Step::C => 0,
Step::D => 2,
Step::E => 4,
Step::F => 5,
Step::G => 7,
Step::A => 9,
Step::B => 11,
}
}
pub fn from_char(c: char) -> Option<Self> {
match c.to_ascii_uppercase() {
'C' => Some(Step::C),
'D' => Some(Step::D),
'E' => Some(Step::E),
'F' => Some(Step::F),
'G' => Some(Step::G),
'A' => Some(Step::A),
'B' => Some(Step::B),
_ => None,
}
}
pub fn to_char(&self) -> char {
match self {
Step::C => 'C',
Step::D => 'D',
Step::E => 'E',
Step::F => 'F',
Step::G => 'G',
Step::A => 'A',
Step::B => 'B',
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Pitch {
pub step: Step,
pub octave: i8,
pub alter: i8,
}
impl Pitch {
pub fn new(step: Step, octave: i8) -> Self {
Self {
step,
octave,
alter: 0,
}
}
pub fn with_alter(step: Step, octave: i8, alter: i8) -> Self {
Self {
step,
octave,
alter,
}
}
pub fn to_midi(&self) -> i16 {
let semitone = self.step.to_semitone() as i16;
let base = (self.octave as i16 + 1) * 12;
base + semitone + self.alter as i16
}
pub fn from_midi(midi: u8, prefer_flat: bool) -> Pitch {
let pc = midi % 12;
let (step, alter): (Step, i8) = if prefer_flat {
match pc {
0 => (Step::C, 0),
1 => (Step::D, -1),
2 => (Step::D, 0),
3 => (Step::E, -1),
4 => (Step::E, 0),
5 => (Step::F, 0),
6 => (Step::G, -1),
7 => (Step::G, 0),
8 => (Step::A, -1),
9 => (Step::A, 0),
10 => (Step::B, -1),
11 => (Step::B, 0),
_ => (Step::C, 0),
}
} else {
match pc {
0 => (Step::C, 0),
1 => (Step::C, 1),
2 => (Step::D, 0),
3 => (Step::D, 1),
4 => (Step::E, 0),
5 => (Step::F, 0),
6 => (Step::F, 1),
7 => (Step::G, 0),
8 => (Step::G, 1),
9 => (Step::A, 0),
10 => (Step::A, 1),
11 => (Step::B, 0),
_ => (Step::C, 0),
}
};
let step_semitone = step.to_semitone() as i16 + alter as i16;
let octave = ((midi as i16 - step_semitone) / 12 - 1) as i8;
Pitch::with_alter(step, octave, alter)
}
pub fn to_scientific_name(&self) -> String {
let accidental = match self.alter {
2 => "##",
1 => "#",
0 => "",
-1 => "b",
-2 => "bb",
_ => "",
};
format!("{}{}{}", self.step.to_char(), accidental, self.octave)
}
pub fn respell(&self, prefer_flat: bool) -> Pitch {
Pitch::from_midi(self.to_midi().clamp(0, 127) as u8, prefer_flat)
}
}
impl std::str::FromStr for Pitch {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut chars = s.chars().peekable();
let step = Step::from_char(chars.next().ok_or(())?).ok_or(())?;
let mut alter: i8 = 0;
loop {
match chars.peek() {
Some('#') => {
alter += 1;
chars.next();
}
Some('b') => {
alter -= 1;
chars.next();
}
_ => break,
}
}
let octave: i8 = chars.collect::<String>().parse().map_err(|_| ())?;
Ok(Pitch::with_alter(step, octave, alter))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn middle_c_midi() {
assert_eq!(Pitch::new(Step::C, 4).to_midi(), 60);
}
#[test]
fn a4_midi() {
assert_eq!(Pitch::new(Step::A, 4).to_midi(), 69);
}
#[test]
fn scientific_name_sharp() {
let p = Pitch::with_alter(Step::F, 5, 1);
assert_eq!(p.to_scientific_name(), "F#5");
}
#[test]
fn scientific_name_flat() {
let p = Pitch::with_alter(Step::B, 3, -1);
assert_eq!(p.to_scientific_name(), "Bb3");
}
#[test]
fn respell_natural_unchanged() {
let p = Pitch::new(Step::C, 4);
assert_eq!(p.respell(true), Pitch::new(Step::C, 4));
assert_eq!(p.respell(false), Pitch::new(Step::C, 4));
}
#[test]
fn respell_csharp_to_db() {
let p = Pitch::with_alter(Step::C, 4, 1); let flat = p.respell(true);
assert_eq!(flat.step, Step::D);
assert_eq!(flat.alter, -1);
assert_eq!(flat.octave, 4);
assert_eq!(flat.to_midi(), 61);
}
#[test]
fn respell_db_to_csharp() {
let p = Pitch::with_alter(Step::D, 4, -1); let sharp = p.respell(false);
assert_eq!(sharp.step, Step::C);
assert_eq!(sharp.alter, 1);
assert_eq!(sharp.octave, 4);
assert_eq!(sharp.to_midi(), 61);
}
#[test]
fn respell_bsharp_to_c_next_octave() {
let p = Pitch::with_alter(Step::B, 4, 1); let resolved = p.respell(true);
assert_eq!(resolved.step, Step::C);
assert_eq!(resolved.alter, 0);
assert_eq!(resolved.octave, 5);
assert_eq!(resolved.to_midi(), 72);
}
#[test]
fn respell_cb_to_b_prev_octave() {
let p = Pitch::with_alter(Step::C, 5, -1); let resolved = p.respell(false);
assert_eq!(resolved.step, Step::B);
assert_eq!(resolved.alter, 0);
assert_eq!(resolved.octave, 4);
assert_eq!(resolved.to_midi(), 71);
}
#[test]
fn from_midi_middle_c() {
let p = Pitch::from_midi(60, false);
assert_eq!(p.step, Step::C);
assert_eq!(p.octave, 4);
assert_eq!(p.alter, 0);
}
#[test]
fn from_midi_c_sharp_prefer_sharp() {
let p = Pitch::from_midi(61, false);
assert_eq!(p.step, Step::C);
assert_eq!(p.octave, 4);
assert_eq!(p.alter, 1);
}
#[test]
fn from_midi_d_flat_prefer_flat() {
let p = Pitch::from_midi(61, true);
assert_eq!(p.step, Step::D);
assert_eq!(p.octave, 4);
assert_eq!(p.alter, -1);
}
#[test]
fn from_midi_a4() {
let p = Pitch::from_midi(69, false);
assert_eq!(p.step, Step::A);
assert_eq!(p.octave, 4);
assert_eq!(p.alter, 0);
}
#[test]
fn from_midi_respell_roundtrip() {
for midi in 21u8..=108 {
for prefer_flat in [false, true] {
let p = Pitch::from_midi(midi, prefer_flat);
assert_eq!(
p.to_midi() as u8,
midi,
"from_midi({midi},{prefer_flat}) roundtrip failed: {:?}",
p
);
}
}
}
#[test]
fn from_str_c4() {
let p: Pitch = "C4".parse().unwrap();
assert_eq!(p, Pitch::new(Step::C, 4));
}
#[test]
fn from_str_fsharp5() {
let p: Pitch = "F#5".parse().unwrap();
assert_eq!(p, Pitch::with_alter(Step::F, 5, 1));
}
#[test]
fn from_str_bflat3() {
let p: Pitch = "Bb3".parse().unwrap();
assert_eq!(p.step, Step::B);
assert_eq!(p.alter, -1);
assert_eq!(p.octave, 3);
assert_eq!(p.to_midi(), 58);
}
#[test]
fn from_str_double_sharp() {
let p: Pitch = "C##4".parse().unwrap();
assert_eq!(p.step, Step::C);
assert_eq!(p.alter, 2);
assert_eq!(p.octave, 4);
}
#[test]
fn from_str_invalid_step_returns_err() {
assert!("X4".parse::<Pitch>().is_err());
assert!("".parse::<Pitch>().is_err());
assert!("C".parse::<Pitch>().is_err()); }
#[test]
fn from_str_roundtrip() {
for midi in 21u8..=108 {
for prefer_flat in [false, true] {
let p = Pitch::from_midi(midi, prefer_flat);
let name = p.to_scientific_name();
let parsed: Pitch = name
.parse()
.unwrap_or_else(|_| panic!("parse failed for {:?}", name));
assert_eq!(
parsed.to_midi() as u8,
midi,
"roundtrip failed for {:?} (midi {})",
name,
midi
);
}
}
}
#[test]
fn respell_fsharp_to_gb() {
let p = Pitch::with_alter(Step::F, 4, 1); let flat = p.respell(true);
assert_eq!(flat.step, Step::G);
assert_eq!(flat.alter, -1);
assert_eq!(flat.to_midi(), 66);
}
}