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); } }