feat(domain): add ChordTransposer with semitone and key transposition
This commit is contained in:
@@ -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};
|
||||
|
||||
133
crates/domain/src/transposer.rs
Normal file
133
crates/domain/src/transposer.rs
Normal file
@@ -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<Song, TransposeError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user