commit a3ae4429f5c52e9c166786290ab2b5893ed6119b Author: Gabriel Kaszewski Date: Wed Apr 8 01:26:17 2026 +0200 feat(domain): add Note enum with semitone math and display diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs new file mode 100644 index 0000000..dc9d2cf --- /dev/null +++ b/crates/domain/src/lib.rs @@ -0,0 +1,3 @@ +pub mod note; + +pub use note::Note; diff --git a/crates/domain/src/note.rs b/crates/domain/src/note.rs new file mode 100644 index 0000000..79f0134 --- /dev/null +++ b/crates/domain/src/note.rs @@ -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 { + 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 { + 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); + } +}