feat(domain): add Note enum with semitone math and display

This commit is contained in:
2026-04-08 01:26:17 +02:00
commit a3ae4429f5
2 changed files with 110 additions and 0 deletions

3
crates/domain/src/lib.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod note;
pub use note::Note;

107
crates/domain/src/note.rs Normal file
View 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);
}
}