134 lines
4.0 KiB
Rust
134 lines
4.0 KiB
Rust
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);
|
|
}
|
|
}
|