diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 0877499..59755c1 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -2,8 +2,10 @@ pub mod note; pub mod chord; pub mod song; pub mod ports; +pub mod transposer; pub use note::Note; pub use chord::Chord; pub use song::{ChordPosition, LyricLine, Section, SectionKind, SongMeta, Song}; pub use ports::{FetchError, ParseError, TabFetcherPort, TabParserPort, TabSource}; +pub use transposer::{ChordTransposer, TransposeError}; diff --git a/crates/domain/src/transposer.rs b/crates/domain/src/transposer.rs new file mode 100644 index 0000000..4f80c3b --- /dev/null +++ b/crates/domain/src/transposer.rs @@ -0,0 +1,133 @@ +use thiserror::Error; +use crate::{Chord, Note, Song, Section, LyricLine, ChordPosition}; + +pub struct ChordTransposer; + +#[derive(Debug, Error)] +pub enum TransposeError { + #[error("Song has no original_key set")] + MissingOriginalKey, + #[error("Unrecognized key: {0}")] + UnrecognizedKey(String), +} + +impl ChordTransposer { + pub fn transpose_chord(&self, chord: &Chord, semitones: i8) -> Chord { + let new_semitone = (chord.root.semitone() as i16 + semitones as i16).rem_euclid(12) as u8; + Chord { + root: Note::from_semitone(new_semitone), + descriptor: chord.descriptor.clone(), + } + } + + pub fn transpose_song(&self, song: &Song, semitones: i8) -> Song { + Song { + meta: song.meta.clone(), + sections: song.sections.iter().map(|s| self.transpose_section(s, semitones)).collect(), + } + } + + pub fn transpose_to_key(&self, song: &Song, target_key: &str) -> Result { + let original = song.meta.original_key.as_deref() + .ok_or(TransposeError::MissingOriginalKey)?; + let from = Note::parse(Self::root_of(original)) + .ok_or_else(|| TransposeError::UnrecognizedKey(original.to_string()))?; + let to = Note::parse(Self::root_of(target_key)) + .ok_or_else(|| TransposeError::UnrecognizedKey(target_key.to_string()))?; + let semitones = (to.semitone() as i16 - from.semitone() as i16).rem_euclid(12) as i8; + Ok(self.transpose_song(song, semitones)) + } + + fn root_of(key: &str) -> &str { + if key.len() >= 2 && (key.as_bytes()[1] == b'#' || key.as_bytes()[1] == b'b') { + &key[..2] + } else { + &key[..1] + } + } + + fn transpose_section(&self, section: &Section, semitones: i8) -> Section { + Section { + kind: section.kind.clone(), + label: section.label.clone(), + lines: section.lines.iter().map(|l| self.transpose_line(l, semitones)).collect(), + } + } + + fn transpose_line(&self, line: &LyricLine, semitones: i8) -> LyricLine { + LyricLine { + text: line.text.clone(), + chords: line.chords.iter().map(|cp| ChordPosition { + offset: cp.offset, + chord: self.transpose_chord(&cp.chord, semitones), + }).collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Chord, Note}; + + fn chord(s: &str) -> Chord { Chord::parse(s).unwrap() } + + #[test] + fn up_two_semitones() { + let t = ChordTransposer; + let result = t.transpose_chord(&chord("Em"), 2); + assert_eq!(result.name(true), "F#m"); + } + + #[test] + fn down_two_semitones() { + let t = ChordTransposer; + let result = t.transpose_chord(&chord("Em"), -2); + assert_eq!(result.name(false), "Dm"); + } + + #[test] + fn up_prefers_sharps() { + let t = ChordTransposer; + let result = t.transpose_chord(&chord("G"), 1); + assert_eq!(result.name(true), "G#"); + } + + #[test] + fn down_prefers_flats() { + let t = ChordTransposer; + let result = t.transpose_chord(&chord("G"), -1); + assert_eq!(result.name(false), "Gb"); + } + + #[test] + fn zero_unchanged() { + let t = ChordTransposer; + let result = t.transpose_chord(&chord("Am7"), 0); + assert_eq!(result.descriptor.as_deref(), Some("m7")); + assert_eq!(result.root, Note::A); + } + + #[test] + fn wraps_octave() { + let t = ChordTransposer; + let result = t.transpose_chord(&chord("B"), 1); + assert_eq!(result.name(true), "C"); + } + + #[test] + fn transpose_to_key() { + let t = ChordTransposer; + let meta = crate::SongMeta { + title: "Test".into(), + artist: "Test".into(), + capo: None, + original_key: Some("G".into()), + tuning: None, + tempo: None, + }; + let song = crate::Song { meta, sections: vec![] }; + let result = t.transpose_to_key(&song, "A").unwrap(); + assert_eq!(result.sections.len(), 0); + } +}