diff --git a/crates/domain/src/services/transposer.rs b/crates/domain/src/services/transposer.rs index 48b9b54..0a421bd 100644 --- a/crates/domain/src/services/transposer.rs +++ b/crates/domain/src/services/transposer.rs @@ -15,9 +15,14 @@ pub enum TransposeError { 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; + let bass = chord.bass.map(|b| { + let s = (b.semitone() as i16 + semitones as i16).rem_euclid(12) as u8; + Note::from_semitone(s) + }); Chord { root: Note::from_semitone(new_semitone), descriptor: chord.descriptor.clone(), + bass, } } diff --git a/crates/domain/src/tests/chord.rs b/crates/domain/src/tests/chord.rs index 9b45306..88a16dc 100644 --- a/crates/domain/src/tests/chord.rs +++ b/crates/domain/src/tests/chord.rs @@ -5,6 +5,7 @@ fn parse_simple() { let c = Chord::parse("Em").unwrap(); assert_eq!(c.root, crate::value_objects::Note::E); assert_eq!(c.descriptor.as_deref(), Some("m")); + assert!(c.bass.is_none()); } #[test] @@ -12,6 +13,7 @@ fn parse_no_descriptor() { let c = Chord::parse("G").unwrap(); assert_eq!(c.root, crate::value_objects::Note::G); assert!(c.descriptor.is_none()); + assert!(c.bass.is_none()); } #[test] @@ -26,6 +28,7 @@ fn name_sharp() { let c = Chord { root: crate::value_objects::Note::FSharpGFlat, descriptor: Some("m".into()), + bass: None, }; assert_eq!(c.name(true), "F#m"); } @@ -35,6 +38,7 @@ fn name_flat() { let c = Chord { root: crate::value_objects::Note::ASharpBFlat, descriptor: None, + bass: None, }; assert_eq!(c.name(false), "Bb"); } @@ -45,3 +49,39 @@ fn parse_flat_with_descriptor() { assert_eq!(c.root, crate::value_objects::Note::ASharpBFlat); assert_eq!(c.descriptor.as_deref(), Some("m")); } + +#[test] +fn parse_slash_chord() { + let c = Chord::parse("G/B").unwrap(); + assert_eq!(c.root, crate::value_objects::Note::G); + assert!(c.descriptor.is_none()); + assert_eq!(c.bass, Some(crate::value_objects::Note::B)); +} + +#[test] +fn parse_slash_chord_with_descriptor() { + let c = Chord::parse("Am7/G").unwrap(); + assert_eq!(c.root, crate::value_objects::Note::A); + assert_eq!(c.descriptor.as_deref(), Some("m7")); + assert_eq!(c.bass, Some(crate::value_objects::Note::G)); +} + +#[test] +fn parse_slash_chord_with_sharp_bass() { + let c = Chord::parse("D/F#").unwrap(); + assert_eq!(c.root, crate::value_objects::Note::D); + assert_eq!(c.bass, Some(crate::value_objects::Note::FSharpGFlat)); +} + +#[test] +fn name_slash_chord() { + let c = Chord::parse("G/B").unwrap(); + assert_eq!(c.name(true), "G/B"); +} + +#[test] +fn name_slash_chord_transpose_display() { + let c = Chord::parse("D/F#").unwrap(); + assert_eq!(c.name(true), "D/F#"); + assert_eq!(c.name(false), "D/Gb"); +} diff --git a/crates/domain/src/tests/song.rs b/crates/domain/src/tests/song.rs index 1c09a9c..c30fb1a 100644 --- a/crates/domain/src/tests/song.rs +++ b/crates/domain/src/tests/song.rs @@ -11,6 +11,7 @@ fn lyric_line_chord_positions() { chord: Chord { root: Note::E, descriptor: Some("m".into()), + bass: None, }, }, ChordPosition { @@ -18,6 +19,7 @@ fn lyric_line_chord_positions() { chord: Chord { root: Note::C, descriptor: None, + bass: None, }, }, ], diff --git a/crates/domain/src/tests/transposer.rs b/crates/domain/src/tests/transposer.rs index ae7d7ec..6f6abcc 100644 --- a/crates/domain/src/tests/transposer.rs +++ b/crates/domain/src/tests/transposer.rs @@ -66,3 +66,17 @@ fn transpose_to_key() { let result = t.transpose_to_key(&song, "A").unwrap(); assert_eq!(result.sections.len(), 0); } + +#[test] +fn transpose_slash_chord() { + let t = ChordTransposer; + let result = t.transpose_chord(&chord("G/B"), 2); + assert_eq!(result.name(true), "A/C#"); +} + +#[test] +fn transpose_slash_chord_down() { + let t = ChordTransposer; + let result = t.transpose_chord(&chord("D/F#"), -2); + assert_eq!(result.name(true), "C/E"); +} diff --git a/crates/domain/src/value_objects/chord.rs b/crates/domain/src/value_objects/chord.rs index 49a320d..788fd3b 100644 --- a/crates/domain/src/value_objects/chord.rs +++ b/crates/domain/src/value_objects/chord.rs @@ -6,17 +6,33 @@ use serde::{Deserialize, Serialize}; pub struct Chord { pub root: Note, pub descriptor: Option, + pub bass: Option, } impl Chord { pub fn parse(s: &str) -> Option { - let (root, consumed) = Note::parse_prefix(s)?; - let descriptor = if consumed < s.len() { - Some(s[consumed..].to_string()) + let (main, bass_str) = match s.rsplit_once('/') { + Some((m, b)) if !b.is_empty() => (m, Some(b)), + _ => (s, None), + }; + + let (root, consumed) = Note::parse_prefix(main)?; + let descriptor = if consumed < main.len() { + Some(main[consumed..].to_string()) } else { None }; - Some(Chord { root, descriptor }) + + let bass = match bass_str { + Some(b) => Some(Note::parse(b)?), + None => None, + }; + + Some(Chord { + root, + descriptor, + bass, + }) } pub fn name(&self, use_sharps: bool) -> String { @@ -25,9 +41,20 @@ impl Chord { } else { self.root.to_flat_str() }; - match &self.descriptor { + let base = match &self.descriptor { Some(d) => format!("{}{}", root_str, d), None => root_str.to_string(), + }; + match &self.bass { + Some(b) => { + let bass_str = if use_sharps { + b.to_sharp_str() + } else { + b.to_flat_str() + }; + format!("{}/{}", base, bass_str) + } + None => base, } } }