feat(domain): add Note enum with semitone math and display
This commit is contained in:
107
crates/domain/src/note.rs
Normal file
107
crates/domain/src/note.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Note {
|
||||
A, ASharpBFlat, B, C, CSharpDFlat, D,
|
||||
DSharpEFlat, E, F, FSharpGFlat, G, GSharpAFlat,
|
||||
}
|
||||
|
||||
impl Note {
|
||||
pub fn semitone(&self) -> u8 {
|
||||
match self {
|
||||
Note::C => 0, Note::CSharpDFlat => 1, Note::D => 2,
|
||||
Note::DSharpEFlat => 3, Note::E => 4, Note::F => 5,
|
||||
Note::FSharpGFlat => 6, Note::G => 7, Note::GSharpAFlat => 8,
|
||||
Note::A => 9, Note::ASharpBFlat => 10, Note::B => 11,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_semitone(s: u8, use_sharps: bool) -> Note {
|
||||
match s % 12 {
|
||||
0 => Note::C, 1 => Note::CSharpDFlat, 2 => Note::D,
|
||||
3 => Note::DSharpEFlat, 4 => Note::E, 5 => Note::F,
|
||||
6 => Note::FSharpGFlat, 7 => Note::G, 8 => Note::GSharpAFlat,
|
||||
9 => Note::A, 10 => Note::ASharpBFlat, 11 => Note::B,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_sharp_str(&self) -> &'static str {
|
||||
match self {
|
||||
Note::C => "C", Note::CSharpDFlat => "C#", Note::D => "D",
|
||||
Note::DSharpEFlat => "D#", Note::E => "E", Note::F => "F",
|
||||
Note::FSharpGFlat => "F#", Note::G => "G", Note::GSharpAFlat => "G#",
|
||||
Note::A => "A", Note::ASharpBFlat => "A#", Note::B => "B",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_flat_str(&self) -> &'static str {
|
||||
match self {
|
||||
Note::C => "C", Note::CSharpDFlat => "Db", Note::D => "D",
|
||||
Note::DSharpEFlat => "Eb", Note::E => "E", Note::F => "F",
|
||||
Note::FSharpGFlat => "Gb", Note::G => "G", Note::GSharpAFlat => "Ab",
|
||||
Note::A => "A", Note::ASharpBFlat => "Bb", Note::B => "B",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse just the note portion from the start of a string.
|
||||
/// Returns (Note, chars_consumed) or None.
|
||||
pub fn parse_prefix(s: &str) -> Option<(Note, usize)> {
|
||||
let mut chars = s.chars();
|
||||
let root = match chars.next()? {
|
||||
'A' => Note::A, 'B' => Note::B, 'C' => Note::C, 'D' => Note::D,
|
||||
'E' => Note::E, 'F' => Note::F, 'G' => Note::G, _ => return None,
|
||||
};
|
||||
match chars.next() {
|
||||
Some('#') => Some((Self::sharp_of(root), 2)),
|
||||
Some('b') if s.len() > 1 => {
|
||||
let flatted = Self::flat_of(root)?;
|
||||
Some((flatted, 2))
|
||||
}
|
||||
_ => Some((root, 1)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Option<Note> {
|
||||
let (note, consumed) = Self::parse_prefix(s)?;
|
||||
if consumed == s.len() { Some(note) } else { None }
|
||||
}
|
||||
|
||||
fn sharp_of(root: Note) -> Note {
|
||||
Note::from_semitone((root.semitone() + 1) % 12, true)
|
||||
}
|
||||
|
||||
fn flat_of(root: Note) -> Option<Note> {
|
||||
if root.semitone() == 0 { return None; } // Cb is unusual, skip
|
||||
Some(Note::from_semitone((root.semitone() + 11) % 12, false))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn semitone_roundtrip() {
|
||||
assert_eq!(Note::from_semitone(Note::A.semitone(), true), Note::A);
|
||||
assert_eq!(Note::from_semitone(Note::FSharpGFlat.semitone(), true), Note::FSharpGFlat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharp_display() {
|
||||
assert_eq!(Note::ASharpBFlat.to_sharp_str(), "A#");
|
||||
assert_eq!(Note::FSharpGFlat.to_sharp_str(), "F#");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_display() {
|
||||
assert_eq!(Note::ASharpBFlat.to_flat_str(), "Bb");
|
||||
assert_eq!(Note::CSharpDFlat.to_flat_str(), "Db");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_note() {
|
||||
assert_eq!(Note::from_str("F#"), Some(Note::FSharpGFlat));
|
||||
assert_eq!(Note::from_str("Gb"), Some(Note::FSharpGFlat));
|
||||
assert_eq!(Note::from_str("A"), Some(Note::A));
|
||||
assert_eq!(Note::from_str("X"), None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user