feat(domain): add ChordTransposer with semitone and key transposition

This commit is contained in:
2026-04-08 01:37:41 +02:00
parent 2f703fbb2a
commit a5897a29d8
2 changed files with 135 additions and 0 deletions

View File

@@ -2,8 +2,10 @@ pub mod note;
pub mod chord; pub mod chord;
pub mod song; pub mod song;
pub mod ports; pub mod ports;
pub mod transposer;
pub use note::Note; pub use note::Note;
pub use chord::Chord; pub use chord::Chord;
pub use song::{ChordPosition, LyricLine, Section, SectionKind, SongMeta, Song}; pub use song::{ChordPosition, LyricLine, Section, SectionKind, SongMeta, Song};
pub use ports::{FetchError, ParseError, TabFetcherPort, TabParserPort, TabSource}; pub use ports::{FetchError, ParseError, TabFetcherPort, TabParserPort, TabSource};
pub use transposer::{ChordTransposer, TransposeError};

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