#[cfg(test)]
mod tests;
pub use crate::error::PitchyError;
use core::str::FromStr;
use crate::{Note, math::*};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Pitch {
frequency: f64,
}
impl Pitch {
pub fn new(frequency: f64) -> Self {
Self { frequency }
}
pub fn try_from_midi_number(midi: u8) -> Result<Self, PitchyError> {
if midi > 127 {
return Err(PitchyError::OutOfMidiRange(midi));
}
let frequency = powf2((midi as f64 - 69.0) / 12.0) * 440.0;
Ok(Self { frequency })
}
pub fn frequency(&self) -> f64 {
self.frequency
}
pub fn transpose(&self, semitones: f64) -> Self {
Self {
frequency: self.frequency * powf2(semitones / 12.0),
}
}
pub fn try_midi_number(&self) -> Result<u8, PitchyError> {
let midi = 69.0 + 12.0 * log2(self.frequency / 440.0);
let rounded = round(midi);
if (0.0..=127.0).contains(&rounded) {
Ok(rounded as u8)
} else {
let fallback = rounded.clamp(0.0, 127.0) as u8;
Err(PitchyError::OutOfMidiRange(fallback))
}
}
pub fn octave(&self) -> Option<i8> {
self.try_midi_number().ok().map(|midi| midi as i8 / 12 - 1)
}
}
impl FromStr for Pitch {
type Err = PitchyError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s = s.trim();
if s.len() < 2 || s.len() > 4 {
return Err(PitchyError::InvalidName);
}
let split_index = s
.find(|c: char| c.is_ascii_digit() || c == '-')
.ok_or(PitchyError::InvalidOctave)?;
let (note_part, octave_str) = s.split_at(split_index);
let octave: i8 = octave_str.parse().map_err(|_| PitchyError::InvalidOctave)?;
let semitone = match note_part {
n if n.eq_ignore_ascii_case("C") => 0,
n if n.eq_ignore_ascii_case("C#") || n.eq_ignore_ascii_case("Db") => 1,
n if n.eq_ignore_ascii_case("D") => 2,
n if n.eq_ignore_ascii_case("D#") || n.eq_ignore_ascii_case("Eb") => 3,
n if n.eq_ignore_ascii_case("E") => 4,
n if n.eq_ignore_ascii_case("F") => 5,
n if n.eq_ignore_ascii_case("F#") || n.eq_ignore_ascii_case("Gb") => 6,
n if n.eq_ignore_ascii_case("G") => 7,
n if n.eq_ignore_ascii_case("G#") || n.eq_ignore_ascii_case("Ab") => 8,
n if n.eq_ignore_ascii_case("A") => 9,
n if n.eq_ignore_ascii_case("A#") || n.eq_ignore_ascii_case("Bb") => 10,
n if n.eq_ignore_ascii_case("B") => 11,
_ => return Err(PitchyError::InvalidName),
};
let midi = (octave as i16)
.checked_add(1)
.and_then(|v| v.checked_mul(12))
.and_then(|v| v.checked_add(semitone as i16))
.ok_or(PitchyError::MidiOverflow)?;
if !(0..=127).contains(&midi) {
return Err(PitchyError::OutOfMidiRange(midi as u8));
}
let hz = powf2((midi as f64 - 69.0) / 12.0) * 440.0;
Ok(Pitch::new(hz))
}
}
impl TryFrom<Note> for Pitch {
type Error = PitchyError;
fn try_from(note: Note) -> Result<Pitch, PitchyError> {
let semitone = (note.letter() as i8) + (note.accidental() as i8);
let midi = ((note.octave() + 1) * 12 + semitone) as i16;
if !(0..=127).contains(&midi) {
return Err(PitchyError::OutOfMidiRange(midi as u8));
}
Pitch::try_from_midi_number(midi as u8)
}
}