use crate::ast::{Chord, Line, LyricsLine, LyricsSegment, Song};
use crate::chord::{Accidental, ChordDetail, Note};
const SHARP_NAMES: [(Note, Option<Accidental>); 12] = [
(Note::C, None),
(Note::C, Some(Accidental::Sharp)),
(Note::D, None),
(Note::D, Some(Accidental::Sharp)),
(Note::E, None),
(Note::F, None),
(Note::F, Some(Accidental::Sharp)),
(Note::G, None),
(Note::G, Some(Accidental::Sharp)),
(Note::A, None),
(Note::A, Some(Accidental::Sharp)),
(Note::B, None),
];
const FLAT_NAMES: [(Note, Option<Accidental>); 12] = [
(Note::C, None),
(Note::D, Some(Accidental::Flat)),
(Note::D, None),
(Note::E, Some(Accidental::Flat)),
(Note::E, None),
(Note::F, None),
(Note::G, Some(Accidental::Flat)),
(Note::G, None),
(Note::A, Some(Accidental::Flat)),
(Note::A, None),
(Note::B, Some(Accidental::Flat)),
(Note::B, None),
];
fn note_to_semitone(note: Note, accidental: Option<Accidental>) -> u8 {
let base = match note {
Note::C => 0,
Note::D => 2,
Note::E => 4,
Note::F => 5,
Note::G => 7,
Note::A => 9,
Note::B => 11,
};
match accidental {
Some(Accidental::Sharp) => (base + 1) % 12,
Some(Accidental::Flat) => (base + 11) % 12,
None => base,
}
}
fn shift_semitone(semitone: u8, shift: i8) -> u8 {
((i16::from(semitone) + i16::from(shift)).rem_euclid(12)) as u8
}
fn transposed_note(semitone: u8, prefer_flat: bool) -> (Note, Option<Accidental>) {
if prefer_flat {
FLAT_NAMES[semitone as usize]
} else {
SHARP_NAMES[semitone as usize]
}
}
#[must_use]
pub fn transpose_detail(detail: &ChordDetail, semitones: i8) -> ChordDetail {
let prefer_flat = detail.root_accidental == Some(Accidental::Flat);
let root_semitone = note_to_semitone(detail.root, detail.root_accidental);
let new_semitone = shift_semitone(root_semitone, semitones);
let (new_root, new_acc) = transposed_note(new_semitone, prefer_flat);
let new_bass = detail.bass_note.map(|(bass_note, bass_acc)| {
let bass_prefer_flat = bass_acc == Some(Accidental::Flat);
let bass_semitone = note_to_semitone(bass_note, bass_acc);
let new_bass_semitone = shift_semitone(bass_semitone, semitones);
transposed_note(new_bass_semitone, bass_prefer_flat)
});
ChordDetail {
root: new_root,
root_accidental: new_acc,
quality: detail.quality,
extension: detail.extension.clone(),
bass_note: new_bass,
}
}
#[must_use]
pub fn transpose_chord(chord: &Chord, semitones: i8) -> Chord {
match &chord.detail {
Some(detail) => {
let new_detail = transpose_detail(detail, semitones);
let new_name = new_detail.to_string();
Chord {
name: new_name,
detail: Some(new_detail),
display: chord.display.clone(),
}
}
None => chord.clone(),
}
}
#[must_use]
pub fn transpose(song: &Song, semitones: i8) -> Song {
let new_lines = song
.lines
.iter()
.map(|line| match line {
Line::Lyrics(lyrics_line) => Line::Lyrics(transpose_lyrics(lyrics_line, semitones)),
other => other.clone(),
})
.collect();
Song {
metadata: song.metadata.clone(),
lines: new_lines,
}
}
#[must_use]
pub fn combine_transpose(file_offset: i8, cli_offset: i8) -> (i8, bool) {
let exact = file_offset as i16 + cli_offset as i16;
let saturated = exact < i8::MIN as i16 || exact > i8::MAX as i16;
(file_offset.saturating_add(cli_offset), saturated)
}
fn transpose_lyrics(lyrics_line: &LyricsLine, semitones: i8) -> LyricsLine {
LyricsLine {
segments: lyrics_line
.segments
.iter()
.map(|seg| LyricsSegment {
spans: seg.spans.clone(),
chord: seg.chord.as_ref().map(|c| transpose_chord(c, semitones)),
text: seg.text.clone(),
})
.collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chord::{ChordQuality, parse_chord};
#[test]
fn test_note_to_semitone() {
assert_eq!(note_to_semitone(Note::C, None), 0);
assert_eq!(note_to_semitone(Note::C, Some(Accidental::Sharp)), 1);
assert_eq!(note_to_semitone(Note::D, Some(Accidental::Flat)), 1);
assert_eq!(note_to_semitone(Note::D, None), 2);
assert_eq!(note_to_semitone(Note::E, None), 4);
assert_eq!(note_to_semitone(Note::F, None), 5);
assert_eq!(note_to_semitone(Note::G, None), 7);
assert_eq!(note_to_semitone(Note::A, None), 9);
assert_eq!(note_to_semitone(Note::B, None), 11);
assert_eq!(note_to_semitone(Note::B, Some(Accidental::Sharp)), 0);
}
#[test]
fn test_transpose_c_up_2() {
let detail = parse_chord("C").unwrap();
let t = transpose_detail(&detail, 2);
assert_eq!(t.root, Note::D);
assert_eq!(t.root_accidental, None);
}
#[test]
fn test_transpose_g_up_2() {
let detail = parse_chord("G").unwrap();
let t = transpose_detail(&detail, 2);
assert_eq!(t.root, Note::A);
assert_eq!(t.root_accidental, None);
}
#[test]
fn test_transpose_b_up_1_wraps() {
let detail = parse_chord("B").unwrap();
let t = transpose_detail(&detail, 1);
assert_eq!(t.root, Note::C);
assert_eq!(t.root_accidental, None);
}
#[test]
fn test_transpose_c_down_1() {
let detail = parse_chord("C").unwrap();
let t = transpose_detail(&detail, -1);
assert_eq!(t.root, Note::B);
assert_eq!(t.root_accidental, None);
}
#[test]
fn test_transpose_preserves_quality() {
let detail = parse_chord("Am7").unwrap();
let t = transpose_detail(&detail, 3);
assert_eq!(t.root, Note::C);
assert_eq!(t.quality, ChordQuality::Minor);
assert_eq!(t.extension.as_deref(), Some("7"));
}
#[test]
fn test_transpose_sharp_chord() {
let detail = parse_chord("F#m").unwrap();
let t = transpose_detail(&detail, 2);
assert_eq!(t.root, Note::G);
assert_eq!(t.root_accidental, Some(Accidental::Sharp));
assert_eq!(t.quality, ChordQuality::Minor);
}
#[test]
fn test_transpose_flat_preserves_flat_spelling() {
let detail = parse_chord("Bb").unwrap();
let t = transpose_detail(&detail, 2);
assert_eq!(t.root, Note::C);
assert_eq!(t.root_accidental, None);
}
#[test]
fn test_transpose_flat_to_flat() {
let detail = parse_chord("Eb").unwrap();
let t = transpose_detail(&detail, 2);
assert_eq!(t.root, Note::F);
assert_eq!(t.root_accidental, None);
}
#[test]
fn test_transpose_slash_chord() {
let detail = parse_chord("G/B").unwrap();
let t = transpose_detail(&detail, 2);
assert_eq!(t.root, Note::A);
assert_eq!(t.bass_note, Some((Note::C, Some(Accidental::Sharp))));
}
#[test]
fn test_transpose_zero_is_noop() {
let detail = parse_chord("Am7").unwrap();
let t = transpose_detail(&detail, 0);
assert_eq!(t, detail);
}
#[test]
fn test_transpose_full_cycle() {
let detail = parse_chord("C").unwrap();
let t = transpose_detail(&detail, 12);
assert_eq!(t.root, Note::C);
assert_eq!(t.root_accidental, None);
}
#[test]
fn test_transpose_negative_full_cycle() {
let detail = parse_chord("Am").unwrap();
let t = transpose_detail(&detail, -12);
assert_eq!(t, detail);
}
#[test]
fn test_transpose_chord_updates_name() {
let chord = Chord::new("G");
let t = transpose_chord(&chord, 2);
assert_eq!(t.name, "A");
}
#[test]
fn test_transpose_chord_unparseable_unchanged() {
let chord = Chord {
name: "N.C.".to_string(),
detail: None,
display: None,
};
let t = transpose_chord(&chord, 5);
assert_eq!(t.name, "N.C.");
assert!(t.detail.is_none());
}
#[test]
fn test_transpose_chord_preserves_display() {
let mut chord = Chord::new("Am");
chord.display = Some("A minor".to_string());
let t = transpose_chord(&chord, 2);
assert_eq!(t.name, "Bm");
assert_eq!(t.display, Some("A minor".to_string()));
assert_eq!(t.display_name(), "A minor");
}
#[test]
fn test_transpose_chord_preserves_none_display() {
let chord = Chord::new("Am");
let t = transpose_chord(&chord, 2);
assert_eq!(t.name, "Bm");
assert_eq!(t.display, None);
assert_eq!(t.display_name(), "Bm");
}
#[test]
fn test_transpose_song_preserves_display() {
let song = crate::parse("{define: Am display=\"A minor\"}\n[Am]Hello").unwrap();
let t = transpose(&song, 3);
let lyrics_line = t.lines.iter().find_map(|line| {
if let Line::Lyrics(l) = line {
Some(l)
} else {
None
}
});
let l = lyrics_line.expect("expected a lyrics line");
let chord = l.segments[0].chord.as_ref().unwrap();
assert_eq!(chord.name, "Cm");
assert_eq!(chord.display_name(), "A minor");
}
#[test]
fn test_transpose_song() {
let song = crate::parse("[G]Hello [C]world").unwrap();
let t = transpose(&song, 2);
if let Line::Lyrics(l) = &t.lines[0] {
assert_eq!(l.segments[0].chord.as_ref().unwrap().name, "A");
assert_eq!(l.segments[1].chord.as_ref().unwrap().name, "D");
} else {
panic!("expected lyrics line");
}
}
#[test]
fn test_transpose_preserves_lyrics_text() {
let song = crate::parse("[Am]Hello").unwrap();
let t = transpose(&song, 3);
if let Line::Lyrics(l) = &t.lines[0] {
assert_eq!(l.segments[0].text, "Hello");
assert_eq!(l.segments[0].chord.as_ref().unwrap().name, "Cm");
} else {
panic!("expected lyrics line");
}
}
#[test]
fn test_transpose_preserves_metadata() {
let song = crate::parse("{title: Test}\n[G]Hello").unwrap();
let t = transpose(&song, 5);
assert_eq!(t.metadata.title.as_deref(), Some("Test"));
}
#[test]
fn test_transpose_all_12_keys() {
let expected_roots = [
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
];
for (i, expected) in expected_roots.iter().enumerate() {
let detail = parse_chord("C").unwrap();
let t = transpose_detail(&detail, i as i8);
assert_eq!(t.to_string(), *expected, "transpose C by {i}");
}
}
#[test]
fn combine_transpose_no_overflow() {
let (result, saturated) = combine_transpose(5, 3);
assert_eq!(result, 8);
assert!(!saturated);
}
#[test]
fn combine_transpose_positive_overflow() {
let (result, saturated) = combine_transpose(100, 50);
assert_eq!(result, 127);
assert!(saturated);
}
#[test]
fn combine_transpose_negative_overflow() {
let (result, saturated) = combine_transpose(-100, -50);
assert_eq!(result, -128);
assert!(saturated);
}
#[test]
fn combine_transpose_exact_max() {
let (result, saturated) = combine_transpose(100, 27);
assert_eq!(result, 127);
assert!(!saturated);
}
}