feat: slash chord support (G/B parses+transposes bass note)
All checks were successful
CI / Check / Test (push) Successful in 11m54s

This commit is contained in:
2026-07-11 22:12:03 +02:00
parent 2f635c9b24
commit 2a628db521
5 changed files with 93 additions and 5 deletions

View File

@@ -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,
}
}

View File

@@ -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");
}

View File

@@ -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,
},
},
],

View File

@@ -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");
}

View File

@@ -6,17 +6,33 @@ use serde::{Deserialize, Serialize};
pub struct Chord {
pub root: Note,
pub descriptor: Option<String>,
pub bass: Option<Note>,
}
impl Chord {
pub fn parse(s: &str) -> Option<Self> {
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,
}
}
}