diff --git a/kord/chord.pest b/kord/chord.pest index 5966661..1cbe1aa 100644 --- a/kord/chord.pest +++ b/kord/chord.pest @@ -4,6 +4,8 @@ accidental = { "#" | "♯" | "b" | "♭" } note = { letter ~ accidental? ~ accidental? } +note_atomic = @{ letter ~ accidental? ~ accidental? } + digit = { ASCII_DIGIT } note_with_octave = { note ~ digit? } @@ -42,6 +44,67 @@ hat = { "^" } bang = { "!" } +// Mode names - match base modes plus common compound forms +// Most alias resolution is done in Rust normalization +mode_name = @{ + // Special compound mode names (match these first before base names) + ^"phrygian" ~ " "+ ~ ^"dominant" | + ^"spanish" ~ " "+ ~ ^"phrygian" | + ^"lydian" ~ " "+ ~ ^"dominant" | + ^"aeolian" ~ " "+ ~ ^"dominant" | + ^"super" ~ " "+ ~ ^"locrian" | + ^"ultra" ~ " "+ ~ ^"locrian" | + ^"ultralocrian" | + ^"acoustic" ~ (" "+ ~ ^"scale")? | + ^"altered" ~ (" "+ ~ ^"scale")? | + // Base modes with degree modifiers (let Rust normalize symbols) + ^"locrian" ~ " "+ ~ (^"natural" | ^"nat" | "♮" | ^"sharp" | "#" | "♯") ~ " "* ~ ASCII_DIGIT | + ^"ionian" ~ " "+ ~ (^"augmented" | ^"sharp" | "#" | "♯") ~ " "* ~ ASCII_DIGIT? | + ^"dorian" ~ " "+ ~ (^"sharp" | "#" | "♯" | ^"flat" | "b" | "♭") ~ " "* ~ ASCII_DIGIT | + ^"phrygian" ~ " "+ ~ (^"natural" | ^"nat" | "♮" | ^"major") ~ (" "* ~ ASCII_DIGIT)? | + ^"lydian" ~ " "+ ~ (^"sharp" | "#" | "♯" | ^"flat" | "b" | "♭" | ^"augmented") ~ " "* ~ ASCII_DIGIT? | + ^"mixolydian" ~ " "+ ~ (^"sharp" | "#" | "♯" | ^"flat" | "b" | "♭") ~ " "* ~ ASCII_DIGIT | + ^"major" ~ " "+ ~ (^"sharp" | "#" | "♯") ~ " "* ~ ASCII_DIGIT | + // Base mode names (unmodified) + ^"ionian" | + ^"dorian" | + ^"phrygian" | + ^"lydian" | + ^"mixolydian" | + ^"aeolian" | + ^"locrian" +} + +// Scale names - simplified, Rust handles most normalization +scale_name = @{ + ^"major" ~ " "? ~ ^"pentatonic" | + ^"minor" ~ " "? ~ ^"pentatonic" | + ^"natural" ~ " "? ~ ^"minor" | + ^"harmonic" ~ " "? ~ ^"minor" | + ^"melodic" ~ " "? ~ ^"minor" | + ^"whole" ~ " "? ~ ^"tone" | + ^"diminished" ~ " "+ ~ ^"whole" ~ "-"? ~ ^"half" | + ^"diminished" ~ " "+ ~ ^"half" ~ "-"? ~ ^"whole" | + ^"chromatic" | + ^"blues" | + ^"major" +} + +// Mode or Scale with root note +mode = { + SOI ~ + note_atomic ~ + mode_name ~ + EOI +} + +scale = { + SOI ~ + note_atomic ~ + scale_name ~ + EOI +} + WHITESPACE = _{ " " } chord = { diff --git a/kord/src/bin.rs b/kord/src/bin.rs index 6ce6951..a11968c 100644 --- a/kord/src/bin.rs +++ b/kord/src/bin.rs @@ -819,7 +819,7 @@ fn start(args: Args) -> Void { } fn describe(chord: &Chord) { - println!("{chord}"); + println!("{}", chord.format_with_scale_candidates()); } fn play(chord: &Chord, delay: f32, length: f32, fade_in: f32) -> Void { diff --git a/kord/src/core/chord.rs b/kord/src/core/chord.rs index 1fcc23c..3cb46cc 100644 --- a/kord/src/core/chord.rs +++ b/kord/src/core/chord.rs @@ -10,7 +10,7 @@ use pest::Parser; use crate::core::{ base::{HasDescription, HasName, HasPreciseName, HasStaticName, Parsable, Res}, interval::Interval, - known_chord::{HasRelativeChord, HasRelativeScale, KnownChord}, + known_chord::{HasRelativeChord, HasRelativeScale, HasScaleCandidates, IntervalCandidate, IntervalCollectionKind, KnownChord, ScaleCandidate}, modifier::{known_modifier_sets, likely_extension_sets, one_off_modifier_sets, Degree, Extension, HasIsDominant, Modifier}, named_pitch::HasNamedPitch, note::{CZero, Note, NoteRecreator}, @@ -624,7 +624,12 @@ impl Display for Chord { let scale = self.scale().iter().map(HasStaticName::static_name).collect::>().join(", "); let chord = self.chord().iter().map(HasStaticName::static_name).collect::>().join(", "); - write!(f, "{}\n {}\n {}\n {}", self.precise_name(), self.description(), scale, chord) + writeln!(f, "{}", self.precise_name())?; + writeln!(f, " {}", self.description())?; + writeln!(f, " {}", scale)?; + write!(f, " {}", chord)?; + + Ok(()) } } @@ -985,6 +990,22 @@ impl HasDescription for Chord { } } +impl Chord { + /// Returns the static interval candidates for this chord + pub fn scale_interval_candidates(&self) -> &'static [IntervalCandidate] { + self.known_chord().scale_interval_candidates() + } +} + +impl HasScaleCandidates for Chord { + fn scale_candidates(&self) -> Vec { + self.scale_interval_candidates() + .iter() + .map(IntervalCandidate::to_scale_candidate) + .collect() + } +} + impl HasRelativeScale for Chord { fn relative_scale(&self) -> Vec { self.known_chord().relative_scale() @@ -1092,7 +1113,21 @@ impl HasRelativeChord for Chord { impl HasScale for Chord { fn scale(&self) -> Vec { - self.relative_scale().into_iter().map(|i| self.root + i).collect() + // Get the first (primary) interval candidate and root it at self.root() + let candidates = self.scale_interval_candidates(); + if let Some(candidate) = candidates.first() { + match candidate.kind { + IntervalCollectionKind::Mode(kind) => { + crate::core::mode::Mode::new(self.root, kind).notes() + } + IntervalCollectionKind::Scale(kind) => { + crate::core::scale::Scale::new(self.root, kind).notes() + } + } + } else { + // Fallback to relative_scale if no candidates (shouldn't happen except for Unknown) + self.relative_scale().into_iter().map(|i| self.root + i).collect() + } } } @@ -1339,6 +1374,45 @@ impl Default for Chord { } } +impl Chord { + /// Formats the chord with full scale/mode candidate recommendations. + /// + /// This returns a verbose string representation that includes: + /// - Chord name, description, scale notes, and chord tones + /// - Complete list of recommended scales/modes with rankings, reasons, notes, and descriptions + /// + /// Use this when you want comprehensive improvisation guidance. + /// For minimal output, use `Display` instead (via `to_string()` or `format!("{}", chord)`). + pub fn format_with_scale_candidates(&self) -> String { + use std::fmt::Write; + + let mut result = String::new(); + + let scale = self.scale().iter().map(HasStaticName::static_name).collect::>().join(", "); + let chord = self.chord().iter().map(HasStaticName::static_name).collect::>().join(", "); + + writeln!(&mut result, "{}", self.precise_name()).unwrap(); + writeln!(&mut result, " {}", self.description()).unwrap(); + writeln!(&mut result, " {}", scale).unwrap(); + writeln!(&mut result, " {}", chord).unwrap(); + + // Add scale/mode candidates + let candidates = self.scale_candidates(); + if !candidates.is_empty() { + writeln!(&mut result).unwrap(); + writeln!(&mut result, " Recommended scales/modes:").unwrap(); + for candidate in candidates { + let notes = candidate.notes(self.root()); + let notes_str = notes.iter().map(HasStaticName::static_name).collect::>().join(", "); + writeln!(&mut result, " {}. {} - {} ({})", candidate.rank(), candidate.name(), candidate.reason(), notes_str).unwrap(); + writeln!(&mut result, " {}", candidate.description()).unwrap(); + } + } + + result + } +} + // Tests. #[cfg(test)] @@ -1354,10 +1428,33 @@ mod tests { assert_eq!(Chord::new(C).minor().augmented().name(), "Cm(♯5)"); assert_eq!(Chord::new(C).with_octave(Octave::Six).precise_name(), "C@6"); - assert_eq!( - format!("{}", Chord::new(C).minor().seven().flat_five()), - "Cm7(♭5)\n half diminished, locrian, minor seven flat five, seventh mode of major scale, major scale starting one half step up\n C, D♭, E♭, F, G♭, A♭, B♭\n C, E♭, G♭, B♭" - ); + // Test Display is minimal (no scale candidates) + let display_output = format!("{}", Chord::new(C).minor().seven().flat_five()); + assert!(display_output.contains("Cm7(♭5)")); + assert!(display_output.contains("half diminished")); + assert!(display_output.contains("C, D♭, E♭, F, G♭, A♭, B♭")); + assert!(display_output.contains("C, E♭, G♭, B♭")); + assert!(!display_output.contains("Recommended scales/modes:")); + + // Test format_with_scale_candidates includes recommendations + let verbose_output = Chord::new(C).minor().seven().flat_five().format_with_scale_candidates(); + assert!(verbose_output.contains("Recommended scales/modes:")); + assert!(verbose_output.contains("locrian")); + } + + #[test] + fn test_display_format() { + // Test that Display output is minimal and stable (no scale candidates) + let chord = Chord::new(C); + let output = format!("{}", chord); + let expected = "C\n major\n C, D, E, F, G, A, B\n C, E, G"; + assert_eq!(output, expected); + + // Test that format_with_scale_candidates includes recommendations + let verbose_output = chord.format_with_scale_candidates(); + assert!(verbose_output.contains("Recommended scales/modes:")); + assert!(verbose_output.contains("ionian")); + assert!(verbose_output.contains("major pentatonic")); } #[test] diff --git a/kord/src/core/interval.rs b/kord/src/core/interval.rs index edd0e9e..c45d9ae 100644 --- a/kord/src/core/interval.rs +++ b/kord/src/core/interval.rs @@ -12,6 +12,12 @@ use crate::core::octave::{HasOctave, Octave}; // Traits. +/// A trait for types that have a list of intervals. +pub trait HasIntervals { + /// Returns the intervals of the type. + fn intervals(&self) -> &'static [Interval]; +} + /// A trait for types that have an enharmonic distance. pub trait HasEnharmonicDistance { /// Returns the enharmonic distance of the type (most likely an interval). diff --git a/kord/src/core/known_chord.rs b/kord/src/core/known_chord.rs index e02d471..87ebc3b 100644 --- a/kord/src/core/known_chord.rs +++ b/kord/src/core/known_chord.rs @@ -3,7 +3,12 @@ use crate::core::{ base::{HasDescription, HasName, HasStaticName}, interval::Interval, + mode::Mode, + mode_kind::ModeKind, modifier::Degree, + note::Note, + scale::Scale, + scale_kind::ScaleKind, }; #[cfg(feature = "serde")] @@ -31,6 +36,120 @@ pub trait HasRelativeChord { fn relative_chord(&self) -> Vec; } +/// A trait for types that can enumerate recommended scales and modes. +pub trait HasScaleCandidates { + /// Returns a list of recommended scale/mode candidates for this chord, + /// ranked by relevance. + fn scale_candidates(&self) -> Vec; +} + +// Structures. + +/// Represents a recommended scale or mode for a chord. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum ScaleCandidate { + /// A mode candidate + Mode { + /// The mode kind + kind: ModeKind, + /// Ranking (1 = most relevant) + rank: u8, + /// Reason why this mode fits the chord + reason: &'static str, + }, + /// A scale candidate + Scale { + /// The scale kind + kind: ScaleKind, + /// Ranking (1 = most relevant) + rank: u8, + /// Reason why this scale fits the chord + reason: &'static str, + }, +} + +impl ScaleCandidate { + /// Returns the rank of this candidate + pub fn rank(&self) -> u8 { + match self { + ScaleCandidate::Mode { rank, .. } => *rank, + ScaleCandidate::Scale { rank, .. } => *rank, + } + } + + /// Returns the reason for this candidate + pub fn reason(&self) -> &'static str { + match self { + ScaleCandidate::Mode { reason, .. } => reason, + ScaleCandidate::Scale { reason, .. } => reason, + } + } + + /// Returns the name of this candidate + pub fn name(&self) -> String { + match self { + ScaleCandidate::Mode { kind, .. } => kind.name(), + ScaleCandidate::Scale { kind, .. } => kind.name(), + } + } + + /// Returns the description of this candidate + pub fn description(&self) -> &'static str { + match self { + ScaleCandidate::Mode { kind, .. } => kind.description(), + ScaleCandidate::Scale { kind, .. } => kind.description(), + } + } + + /// Returns the notes of this candidate rooted at the given note + pub fn notes(&self, root: Note) -> Vec { + match self { + ScaleCandidate::Mode { kind, .. } => Mode::new(root, *kind).notes(), + ScaleCandidate::Scale { kind, .. } => Scale::new(root, *kind).notes(), + } + } +} + +/// Represents the kind of interval collection (mode or scale) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum IntervalCollectionKind { + /// A mode + Mode(ModeKind), + /// A scale + Scale(ScaleKind), +} + +/// Represents a scale or mode candidate in static storage format +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct IntervalCandidate { + /// The kind of interval collection + pub kind: IntervalCollectionKind, + /// Ranking (1 = most relevant) + pub rank: u8, + /// Reason why this fits the chord + pub reason: &'static str, +} + +impl IntervalCandidate { + /// Converts this interval candidate to a rooted scale candidate + pub fn to_scale_candidate(&self) -> ScaleCandidate { + match self.kind { + IntervalCollectionKind::Mode(kind) => ScaleCandidate::Mode { + kind, + rank: self.rank, + reason: self.reason, + }, + IntervalCollectionKind::Scale(kind) => ScaleCandidate::Scale { + kind, + rank: self.rank, + reason: self.reason, + }, + } + } +} + // Enum. /// An enum representing a known chord. @@ -84,21 +203,21 @@ impl HasDescription for KnownChord { KnownChord::Unknown => panic!("KnownChord::Unknown should never be used in description()"), KnownChord::Major => "major", KnownChord::Minor => "minor", - KnownChord::Major7 => "major 7, ionian, first mode of major scale", - KnownChord::Dominant(_) => "dominant, mixolydian, fifth mode of major scale, major with flat seven", - KnownChord::MinorMajor7 => "minor major 7, melodic minor, major with flat third", - KnownChord::MinorDominant(_) => "minor 7, dorian, second mode of major scale, major with flat third and flat seven", - KnownChord::DominantSharp11(_) => "dominant sharp 11, lydian dominant, lyxian, major with sharp four and flat seven", - KnownChord::Augmented => "augmented, major with sharp five", - KnownChord::AugmentedMajor7 => "augmented major 7, major with sharp four and five, third mode of melodic minor", - KnownChord::AugmentedDominant(_) => "augmented dominant, whole tone", - KnownChord::HalfDiminished(_) => "half diminished, locrian, minor seven flat five, seventh mode of major scale, major scale starting one half step up", - KnownChord::Diminished => "fully diminished (whole first), diminished seventh, whole/half/whole diminished", - KnownChord::DominantFlat9(_) => "dominant flat 9, fully diminished (half first), half/whole/half diminished", - KnownChord::DominantSharp9(_) => "dominant sharp 9, altered, altered dominant, super locrian, diminished whole tone, seventh mode of a melodic minor scale, melodic minor up a half step", - KnownChord::MinorDominantFlat13(_) => "minor dominant flat 13, aeolian, sixth mode of major scale", - KnownChord::MinorDominantFlat9Flat13(_) => "dominant flat 9 flat 13, phrygian, third mode of a major scale", - KnownChord::Sharp11 => "sharp 11, lydian, fourth mode of a major scale", + KnownChord::Major7 => "major 7", + KnownChord::Dominant(_) => "dominant", + KnownChord::MinorMajor7 => "minor major 7", + KnownChord::MinorDominant(_) => "minor 7", + KnownChord::DominantSharp11(_) => "dominant sharp 11", + KnownChord::Augmented => "augmented", + KnownChord::AugmentedMajor7 => "augmented major 7", + KnownChord::AugmentedDominant(_) => "augmented dominant", + KnownChord::HalfDiminished(_) => "half diminished", + KnownChord::Diminished => "diminished", + KnownChord::DominantFlat9(_) => "dominant flat 9", + KnownChord::DominantSharp9(_) => "dominant sharp 9", + KnownChord::MinorDominantFlat13(_) => "minor dominant flat 13", + KnownChord::MinorDominantFlat9Flat13(_) => "minor dominant flat 9 flat 13", + KnownChord::Sharp11 => "sharp 11", } } } @@ -333,3 +452,637 @@ impl HasName for KnownChord { } } } + +impl KnownChord { + /// Returns the static interval candidates for this chord + pub fn scale_interval_candidates(&self) -> &'static [IntervalCandidate] { + match self { + KnownChord::Unknown => &[], + KnownChord::Major => MAJOR_CANDIDATES, + KnownChord::Minor => MINOR_CANDIDATES, + KnownChord::Major7 => MAJOR7_CANDIDATES, + KnownChord::Dominant(_) => DOMINANT_CANDIDATES, + KnownChord::MinorMajor7 => MINOR_MAJOR7_CANDIDATES, + KnownChord::MinorDominant(_) => MINOR_DOMINANT_CANDIDATES, + KnownChord::DominantSharp11(_) => DOMINANT_SHARP11_CANDIDATES, + KnownChord::Augmented => AUGMENTED_CANDIDATES, + KnownChord::AugmentedMajor7 => AUGMENTED_MAJOR7_CANDIDATES, + KnownChord::AugmentedDominant(_) => AUGMENTED_DOMINANT_CANDIDATES, + KnownChord::HalfDiminished(_) => HALF_DIMINISHED_CANDIDATES, + KnownChord::Diminished => DIMINISHED_CANDIDATES, + KnownChord::DominantFlat9(_) => DOMINANT_FLAT9_CANDIDATES, + KnownChord::DominantSharp9(_) => DOMINANT_SHARP9_CANDIDATES, + KnownChord::MinorDominantFlat13(_) => MINOR_DOMINANT_FLAT13_CANDIDATES, + KnownChord::MinorDominantFlat9Flat13(_) => MINOR_DOMINANT_FLAT9_FLAT13_CANDIDATES, + KnownChord::Sharp11 => SHARP11_CANDIDATES, + } + } +} + +impl HasScaleCandidates for KnownChord { + fn scale_candidates(&self) -> Vec { + self.scale_interval_candidates() + .iter() + .map(IntervalCandidate::to_scale_candidate) + .collect() + } +} + +// Static interval candidates for each KnownChord variant + +static MAJOR_CANDIDATES: &[IntervalCandidate] = &[ + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::Ionian), + rank: 1, + reason: "Default diatonic major scale for functional harmony in tonal contexts; works over I, IV, V progressions; avoid dwelling on the 4th scale degree as it can clash with the major 3rd in the triad", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Scale(ScaleKind::MajorPentatonic), + rank: 2, + reason: "Safe major melody that avoids the 4th and 7th scale degrees completely; eliminates avoid-note concerns; perfect for pop, rock, and country hooks where you want guaranteed consonance", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::Lydian), + rank: 3, + reason: "Bright major sound with ♯11 sheen; major with ♯4 (4th mode of major scale); common in modern jazz and film scoring; lean into the ♯4/♯11 as the characteristic color tone that distinguishes it from Ionian", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::Mixolydian), + rank: 4, + reason: "Adds ♭7 over a major triad for blues, rock, and modal flavor; creates a dominant-like quality without needing to resolve; stylistic choice for a looser, groovier feel", + }, +]; + +static MINOR_CANDIDATES: &[IntervalCandidate] = &[ + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::Aeolian), + rank: 1, + reason: "Default natural minor scale for functional harmony; 6th mode of major scale; provides the classic melancholic, sad tonality with ♭6; contains ♭3, ♭6, and ♭7 for full minor character", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Scale(ScaleKind::MinorPentatonic), + rank: 2, + reason: "Safe minor melody that avoids the ♭2 and ♭6 scale degrees; the go-to scale for blues, rock, and pentatonic-based improvisation; simple and effective with no avoid-note concerns", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Scale(ScaleKind::Blues), + rank: 3, + reason: "Essential blues vocabulary built on minor pentatonic with added ♯4 blue note; phrasing-driven rather than theoretical; creates that characteristic blues 'bend' and expressive quality", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::Dorian), + rank: 4, + reason: "Minor mode with raised 6th (♮6) that provides a brighter, jazzier lift compared to Aeolian; 2nd mode of major scale; less sad than natural minor; common choice for jazz and funk minor sounds", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::Phrygian), + rank: 5, + reason: "Minor mode with a lowered 2nd (♭2) that creates an exotic, Spanish, or Flamenco flavor; 3rd mode of major scale; dark and modal with a distinctly non-functional character", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Scale(ScaleKind::HarmonicMinor), + rank: 6, + reason: "Classical minor scale with raised 7th (♮7) for strong V-i resolution; the ♭6 creates tension and augmented-second interval; essential for traditional minor key harmony", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Scale(ScaleKind::MelodicMinor), + rank: 7, + reason: "Modern jazz minor with both ♮6 and ♮7 for smooth ascending melodic motion; eliminates the augmented second of harmonic minor; bright and sophisticated minor color", + }, +]; + +static MAJOR7_CANDIDATES: &[IntervalCandidate] = &[ + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::Ionian), + rank: 1, + reason: "Default major 7 scale from functional harmony; Ionian mode provides the natural maj7 chord tones; watch out for the 4th scale degree which can clash as an avoid-note over the maj7 chord", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::Lydian), + rank: 2, + reason: "Bright major 7 sound with ♯11 sheen; major 7 with ♯4 (4th mode of major scale); the modern jazz choice for maj7 chords; the ♯4/♯11 is the color tone that makes this sparkle; eliminates the avoid-note issue of the natural 4th", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Scale(ScaleKind::MajorPentatonic), + rank: 3, + reason: "Safe major 7 melody that completely avoids both the 4th and 7th scale degrees; provides consonant, hook-friendly melodic material with zero avoid-note concerns", + }, +]; + +static DOMINANT_CANDIDATES: &[IntervalCandidate] = &[ + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::Mixolydian), + rank: 1, + reason: "Default dominant 7 scale; 5th mode of major scale; provides major 3rd with ♭7 for functional V chord resolution; the bread-and-butter choice for tonal dominant chords in ii-V-I progressions", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Scale(ScaleKind::Blues), + rank: 2, + reason: "Blues vocabulary over dominant 7 chords; includes the ♯4 blue note for characteristic blues phrasing; phrasing-driven rather than theoretical; perfect for blues turnarounds and rock contexts", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::LydianDominant), + rank: 3, + reason: "Dominant 7 with ♯11 color (Mixolydian with ♯4); 4th mode of melodic minor; sophisticated modern jazz sound; use when you want bright ♯11 upper-structure tension on your V chord; also called Acoustic scale", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::MixolydianFlat6), + rank: 4, + reason: "Dominant scale with ♭13 (♭6) for darker tension; 5th mode of melodic minor; creates a minor-leaning dominant sound; useful when the V chord needs to lean toward a minor resolution", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Scale(ScaleKind::WholeTone), + rank: 5, + reason: "Symmetrical whole tone scale for augmented dominant (#5) color; creates dreamy, floating, harmonically ambiguous sound; every note is a whole step apart; use for suspended, unresolved V7#5 or V+7 sounds", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Scale(ScaleKind::DiminishedHalfWhole), + rank: 6, + reason: "Classic dominant diminished (half-whole octatonic); supports ♭9, ♯9, ♯11, and 13 simultaneously; use when the V chord feels 'hot' and needs maximum upper-structure tension; distinct from Altered mode", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::Altered), + rank: 7, + reason: "The ultimate altered dominant tool (7th mode of melodic minor); provides maximum tension with ♭9, ♯9, ♭5, and ♯5 (♭13) all available; strongest when resolving to minor i or in modern jazz contexts needing maximum harmonic pull", + }, +]; + +static MINOR_MAJOR7_CANDIDATES: &[IntervalCandidate] = &[ + IntervalCandidate { + kind: IntervalCollectionKind::Scale(ScaleKind::MelodicMinor), + rank: 1, + reason: "Primary source scale for minor-major 7 chords; melodic minor provides the ♮6 and ♮7 needed for this chord quality; creates smooth ascending melodic lines in classical and jazz contexts", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Scale(ScaleKind::HarmonicMinor), + rank: 2, + reason: "Alternative source for mMaj7 with a more exotic flavor; harmonic minor provides the ♮7 but retains the ♭6 creating characteristic tension and the augmented second interval between ♭6 and ♮7", + }, +]; + +static MINOR_DOMINANT_CANDIDATES: &[IntervalCandidate] = &[ + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::Dorian), + rank: 1, + reason: "Default minor 7 scale with raised 6th (♮6) that lifts and brightens the sound; 2nd mode of major scale; the classic jazz ii chord sound in ii-V-I progressions; more optimistic than Aeolian", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Scale(ScaleKind::MinorPentatonic), + rank: 2, + reason: "Safe minor 7 melody that avoids the ♭2 and ♭6 scale degrees for guaranteed consonance; simple and effective choice; the go-to for straightforward, bluesy minor 7 improvisation", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Scale(ScaleKind::Blues), + rank: 3, + reason: "Blues vocabulary over minor 7 chords with the characteristic ♯4 blue note; stylistic phrasing choice for blues, rock, and R&B contexts; phrasing-driven rather than purely harmonic", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::Aeolian), + rank: 4, + reason: "Natural minor over m7 chords; includes the ♭6 for darker, more melancholic color compared to Dorian; functional and traditional; creates a more classically minor feel", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::Phrygian), + rank: 5, + reason: "Modal minor 7 with lowered 2nd (♭2) for exotic, Spanish, or Flamenco flavor; 3rd mode of major scale; creates a distinctly modal, non-functional character; dark and mysterious", + }, +]; + +static DOMINANT_SHARP11_CANDIDATES: &[IntervalCandidate] = &[ + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::LydianDominant), + rank: 1, + reason: "The defining scale for V7♯11 chords; dominant 7 with ♯11 (Mixolydian with ♯4); 4th mode of melodic minor; sophisticated modern jazz sound for dominant chords with upper-structure tension; also called Acoustic scale", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::Mixolydian), + rank: 2, + reason: "Basic dominant fallback that de-emphasizes the ♯11 extension; provides functional V7 sound (dominant 7 with ♭7); when you want simpler, more traditional dominant approach without the ♯11 color", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Scale(ScaleKind::DiminishedHalfWhole), + rank: 3, + reason: "Adds even more tension options on top of the V7♯11; half-whole diminished (dominant diminished) provides ♭9, ♯9, ♯11, and 13 simultaneously; use for hot, dense dominant sound with maximum upper-structure complexity", + }, +]; + +static AUGMENTED_CANDIDATES: &[IntervalCandidate] = &[ + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::IonianSharp5), + rank: 1, + reason: "Major scale with raised 5th (♯5); 3rd mode of harmonic minor; provides a functional augmented triad sound while maintaining major scale characteristics; the classic augmented triad source", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Scale(ScaleKind::WholeTone), + rank: 2, + reason: "Symmetrical whole tone scale where every augmented triad shares the same notes; creates a dreamy, floating, harmonically ambiguous quality; all notes are whole steps apart; perfect for suspended augmented sounds", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::LydianAugmented), + rank: 3, + reason: "Major scale with both ♯4 and ♯5; 3rd mode of melodic minor; provides a bright, exotic augmented color with Lydian characteristics; sophisticated choice for modern jazz augmented triads", + }, +]; + +static AUGMENTED_MAJOR7_CANDIDATES: &[IntervalCandidate] = &[ + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::LydianAugmented), + rank: 1, + reason: "3rd mode of melodic minor; provides major 7 with both ♯4/♯11 and ♯5; bright, exotic, and sophisticated augmented major 7 sound; the modern jazz choice for this chord quality", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::IonianSharp5), + rank: 2, + reason: "Major 7 with raised 5th from harmonic minor; more functional and traditional approach to augmented major 7; retains natural 4th unlike Lydian Augmented", + }, +]; + +static AUGMENTED_DOMINANT_CANDIDATES: &[IntervalCandidate] = &[ + IntervalCandidate { + kind: IntervalCollectionKind::Scale(ScaleKind::WholeTone), + rank: 1, + reason: "Primary scale for augmented dominant 7 (V+7 or V7♯5) chords; symmetrical whole tone scale naturally provides the ♯5 augmented quality; creates dreamy, floating dominant color; perfect for unresolved, suspended V+7 sounds", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::LydianDominant), + rank: 2, + reason: "Dominant 7 with ♯11 (Mixolydian with ♯4) that can accommodate or bend toward ♯5; flexible modern dominant sound from melodic minor; use when you want V7 character with option to lean into augmented implications", + }, +]; + +static HALF_DIMINISHED_CANDIDATES: &[IntervalCandidate] = &[ + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::Locrian), + rank: 1, + reason: "Default half-diminished (m7♭5) scale; 7th mode of major scale; provides the functional ii°7 sound in minor key ii°-V-i progressions; classic diminished 5th with minor 7th", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::LocrianNatural2), + rank: 2, + reason: "Half-diminished with raised 2nd (♮2) for smoother melodic motion; 6th mode of melodic minor; eliminates the difficult ♭2 interval; modern jazz choice for m7♭5 chords", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::LocrianNatural6), + rank: 3, + reason: "Half-diminished with raised 6th (♮6 or ♮13) for a brighter color; 2nd mode of harmonic minor; provides major 6th/13th extension while maintaining the ♭5; exotic flavor", + }, +]; + +static DIMINISHED_CANDIDATES: &[IntervalCandidate] = &[ + IntervalCandidate { + kind: IntervalCollectionKind::Scale(ScaleKind::DiminishedWholeHalf), + rank: 1, + reason: "Symmetrical diminished 7 scale with whole-half step pattern; primary choice for fully diminished 7 chords; every diminished 7 chord shares three others a minor third apart in this scale", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Scale(ScaleKind::DiminishedHalfWhole), + rank: 2, + reason: "Alternative diminished scale with half-whole step pattern; more commonly used for dominant 7 chords but can work as a passing chord option for dim7; provides different color than W-H pattern", + }, +]; + +static DOMINANT_FLAT9_CANDIDATES: &[IntervalCandidate] = &[ + IntervalCandidate { + kind: IntervalCollectionKind::Scale(ScaleKind::DiminishedHalfWhole), + rank: 1, + reason: "Primary scale for V7♭9 chords using symmetrical half-whole diminished (dominant diminished) pattern; provides ♭9, ♯9, ♯11, and 13 simultaneously; rich dominant tension palette for modern jazz and classical harmony", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::PhrygianDominant), + rank: 2, + reason: "Phrygian with major 3rd creates Spanish or Phrygian dominant sound; 5th mode of harmonic minor; characteristic ♭2/♭9 with major 3rd fingerprint; exotic, Middle Eastern, or Flamenco flavor; strong V in minor keys", + }, +]; + +static DOMINANT_SHARP9_CANDIDATES: &[IntervalCandidate] = &[ + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::Altered), + rank: 1, + reason: "The altered dominant scale (7th mode of melodic minor); provides all alterations including ♭9, ♯9, ♭5, and ♯5 (♭13); maximum tension with strongest resolution pull; the ultimate V7♯9 choice for modern jazz and chromatic harmony", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Scale(ScaleKind::DiminishedHalfWhole), + rank: 2, + reason: "Symmetrical half-whole diminished (dominant diminished) providing ♭9, ♯9, ♯11, and 13; comprehensive dominant tension palette; hot V7 sound with dense upper-structure options; works when you need maximum harmonic complexity", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Scale(ScaleKind::Blues), + rank: 3, + reason: "Blues vocabulary that naturally includes the ♯9 sound through the ♯4 blue note; phrasing-driven stylistic choice rather than theoretical; perfect for blues and rock contexts where ♯9 is part of the blues language", + }, +]; + +static MINOR_DOMINANT_FLAT13_CANDIDATES: &[IntervalCandidate] = &[ + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::Aeolian), + rank: 1, + reason: "Natural minor scale over minor 7 chords; provides the ♭6/♭13 extension naturally; functional sound for minor dominant chords where you need the flat 13 characteristic clearly present", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::Phrygian), + rank: 2, + reason: "Modal minor 7 with both ♭2 and ♭6/♭13; 3rd mode of major scale; creates an exotic, darker minor dominant with Spanish or modal flavor; both characteristic tensions present", + }, +]; + +static MINOR_DOMINANT_FLAT9_FLAT13_CANDIDATES: &[IntervalCandidate] = &[ + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::Phrygian), + rank: 1, + reason: "Phrygian mode provides both characteristic tensions: ♭2/♭9 and ♭6/♭13; 3rd mode of major scale; creates exotic, modal, Spanish or Flamenco flavor; dark and mysterious minor dominant sound", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Scale(ScaleKind::Blues), + rank: 2, + reason: "Blues scale offers simpler, more phrasing-driven vocabulary; stylistic choice rather than literal interval match; use when you want blues vocabulary over this complex minor dominant chord symbol", + }, +]; + +static SHARP11_CANDIDATES: &[IntervalCandidate] = &[ + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::Lydian), + rank: 1, + reason: "Major 7 with ♯11 extension; major with ♯4 (4th mode of major scale); the bright, modern sound where ♯4/♯11 is the defining color tone; sophisticated jazz and film score choice; eliminates the avoid-note issue of natural 4th", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Mode(ModeKind::Ionian), + rank: 2, + reason: "Major 7 fallback that de-emphasizes the ♯11 extension; returns to functional harmony; use when you want to downplay the ♯11 color or need a simpler, more traditional major 7 approach", + }, + IntervalCandidate { + kind: IntervalCollectionKind::Scale(ScaleKind::MajorPentatonic), + rank: 3, + reason: "Safe major 7 melody that avoids both the 4th and 7th scale degrees; provides consonant melodic material with zero avoid-note concerns; perfect for hooks and melodic lines over maj7♯11 chords", + }, +]; + +// Tests. + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::modifier::Degree; + use pretty_assertions::assert_eq; + + #[test] + fn test_dominant_chord_candidates() { + let candidates = KnownChord::Dominant(Degree::Seven).scale_candidates(); + assert!(!candidates.is_empty(), "G7 should have scale candidates"); + + match &candidates[0] { + ScaleCandidate::Mode { kind, rank, .. } => { + assert_eq!(*kind, ModeKind::Mixolydian); + assert_eq!(*rank, 1); + } + _ => panic!("First candidate for G7 should be a Mode"), + } + + match &candidates[1] { + ScaleCandidate::Scale { kind, rank, .. } => { + assert_eq!(*kind, ScaleKind::Blues); + assert_eq!(*rank, 2); + } + _ => panic!("Second candidate for G7 should be a Scale"), + } + + match &candidates[2] { + ScaleCandidate::Mode { kind, rank, .. } => { + assert_eq!(*kind, ModeKind::LydianDominant); + assert_eq!(*rank, 3); + } + _ => panic!("Third candidate for G7 should be a Mode"), + } + } + + #[test] + fn test_dominant_sharp11_candidates() { + let candidates = KnownChord::DominantSharp11(Degree::Seven).scale_candidates(); + assert!(!candidates.is_empty(), "G7#11 should have scale candidates"); + + match &candidates[0] { + ScaleCandidate::Mode { kind, rank, .. } => { + assert_eq!(*kind, ModeKind::LydianDominant); + assert_eq!(*rank, 1); + } + _ => panic!("First candidate for G7#11 should be a Mode"), + } + } + + #[test] + fn test_dominant_flat9_candidates() { + let candidates = KnownChord::DominantFlat9(Degree::Seven).scale_candidates(); + assert!(!candidates.is_empty(), "G7b9 should have scale candidates"); + + match &candidates[0] { + ScaleCandidate::Scale { kind, rank, .. } => { + assert_eq!(*kind, ScaleKind::DiminishedHalfWhole); + assert_eq!(*rank, 1); + } + ScaleCandidate::Mode { kind, rank, .. } => { + assert_eq!(*kind, ModeKind::PhrygianDominant); + assert_eq!(*rank, 1); + } + } + } + + #[test] + fn test_dominant_sharp9_candidates() { + let candidates = KnownChord::DominantSharp9(Degree::Seven).scale_candidates(); + assert!(!candidates.is_empty(), "G7#9 should have scale candidates"); + + match &candidates[0] { + ScaleCandidate::Mode { kind, rank, .. } => { + assert_eq!(*kind, ModeKind::Altered); + assert_eq!(*rank, 1); + } + _ => panic!("First candidate for G7#9 should be a Mode"), + } + } + + #[test] + fn test_half_diminished_candidates() { + let candidates = KnownChord::HalfDiminished(Degree::Seven).scale_candidates(); + assert!(!candidates.is_empty(), "Cm7b5 should have scale candidates"); + + match &candidates[0] { + ScaleCandidate::Mode { kind, rank, .. } => { + assert_eq!(*kind, ModeKind::Locrian); + assert_eq!(*rank, 1); + } + _ => panic!("First candidate for Cm7b5 should be a Mode"), + } + } + + #[test] + fn test_augmented_dominant_candidates() { + let candidates = KnownChord::AugmentedDominant(Degree::Seven).scale_candidates(); + assert!(!candidates.is_empty(), "Augmented dominant should have scale candidates"); + + match &candidates[0] { + ScaleCandidate::Scale { kind, rank, .. } => { + assert_eq!(*kind, ScaleKind::WholeTone); + assert_eq!(*rank, 1); + } + _ => panic!("First candidate for augmented dominant should be a Scale"), + } + } + + #[test] + fn test_major_chord_candidates() { + let candidates = KnownChord::Major.scale_candidates(); + assert!(candidates.len() >= 3, "Major chord should have at least 3 candidates"); + + match &candidates[0] { + ScaleCandidate::Mode { kind, rank, .. } => { + assert_eq!(*kind, ModeKind::Ionian); + assert_eq!(*rank, 1); + } + _ => panic!("First candidate for Major should be Ionian mode"), + } + + match &candidates[1] { + ScaleCandidate::Scale { kind, rank, .. } => { + assert_eq!(*kind, ScaleKind::MajorPentatonic); + assert_eq!(*rank, 2); + } + _ => panic!("Second candidate for Major should be MajorPentatonic scale"), + } + + match &candidates[2] { + ScaleCandidate::Mode { kind, rank, .. } => { + assert_eq!(*kind, ModeKind::Lydian); + assert_eq!(*rank, 3); + } + _ => panic!("Third candidate for Major should be Lydian mode"), + } + } + + #[test] + fn test_minor_chord_candidates() { + let candidates = KnownChord::Minor.scale_candidates(); + assert!(candidates.len() >= 3, "Minor chord should have at least 3 candidates"); + + match &candidates[0] { + ScaleCandidate::Mode { kind, rank, .. } => { + assert_eq!(*kind, ModeKind::Aeolian); + assert_eq!(*rank, 1); + } + _ => panic!("First candidate for Minor should be Aeolian mode"), + } + + match &candidates[1] { + ScaleCandidate::Scale { kind, rank, .. } => { + assert_eq!(*kind, ScaleKind::MinorPentatonic); + assert_eq!(*rank, 2); + } + _ => panic!("Second candidate for Minor should be MinorPentatonic scale"), + } + + match &candidates[2] { + ScaleCandidate::Scale { kind, rank, .. } => { + assert_eq!(*kind, ScaleKind::Blues); + assert_eq!(*rank, 3); + } + _ => panic!("Third candidate for Minor should be Blues scale"), + } + } + + #[test] + fn test_minor_dominant_candidates() { + let candidates = KnownChord::MinorDominant(Degree::Seven).scale_candidates(); + assert!(candidates.len() >= 3, "Minor dominant should have at least 3 candidates"); + + match &candidates[0] { + ScaleCandidate::Mode { kind, rank, .. } => { + assert_eq!(*kind, ModeKind::Dorian); + assert_eq!(*rank, 1); + } + _ => panic!("First candidate for Minor dominant should be Dorian mode"), + } + + match &candidates[1] { + ScaleCandidate::Scale { kind, rank, .. } => { + assert_eq!(*kind, ScaleKind::MinorPentatonic); + assert_eq!(*rank, 2); + } + _ => panic!("Second candidate for Minor dominant should be MinorPentatonic scale"), + } + + match &candidates[2] { + ScaleCandidate::Scale { kind, rank, .. } => { + assert_eq!(*kind, ScaleKind::Blues); + assert_eq!(*rank, 3); + } + _ => panic!("Third candidate for Minor dominant should be Blues scale"), + } + } + + #[test] + fn test_interval_candidates_kinds_and_order() { + // Test that all KnownChord variants return properly ordered interval candidates + let all_variants = vec![ + KnownChord::Unknown, + KnownChord::Major, + KnownChord::Minor, + KnownChord::Major7, + KnownChord::Dominant(Degree::Seven), + KnownChord::MinorMajor7, + KnownChord::MinorDominant(Degree::Seven), + KnownChord::DominantSharp11(Degree::Seven), + KnownChord::Augmented, + KnownChord::AugmentedMajor7, + KnownChord::AugmentedDominant(Degree::Seven), + KnownChord::HalfDiminished(Degree::Seven), + KnownChord::Diminished, + KnownChord::DominantFlat9(Degree::Seven), + KnownChord::DominantSharp9(Degree::Seven), + KnownChord::MinorDominantFlat13(Degree::Seven), + KnownChord::MinorDominantFlat9Flat13(Degree::Seven), + KnownChord::Sharp11, + ]; + + for known_chord in all_variants { + let candidates = known_chord.scale_interval_candidates(); + + // Verify ranks are sequential starting at 1 + for (i, candidate) in candidates.iter().enumerate() { + let expected_rank = (i + 1) as u8; + assert_eq!( + candidate.rank, expected_rank, + "{:?}: Expected rank {} at position {}, got {}", + known_chord, expected_rank, i, candidate.rank + ); + + // Verify reason is non-empty + assert!( + !candidate.reason.is_empty(), + "{:?}: Candidate at position {} has empty reason", + known_chord, i + ); + + // Verify kind matches what scale_candidates() returns + let scale_candidates = known_chord.scale_candidates(); + if i < scale_candidates.len() { + match (&candidate.kind, &scale_candidates[i]) { + (IntervalCollectionKind::Mode(mk), ScaleCandidate::Mode { kind, .. }) => { + assert_eq!(mk, kind, "{:?}: Mode kind mismatch at position {}", known_chord, i); + } + (IntervalCollectionKind::Scale(sk), ScaleCandidate::Scale { kind, .. }) => { + assert_eq!(sk, kind, "{:?}: Scale kind mismatch at position {}", known_chord, i); + } + _ => panic!("{:?}: Kind type mismatch at position {}", known_chord, i), + } + } + } + + // Verify scale_candidates() matches interval_candidates() + let scale_candidates = known_chord.scale_candidates(); + assert_eq!( + candidates.len(), + scale_candidates.len(), + "{:?}: Mismatch in candidate count", + known_chord + ); + } + } +} diff --git a/kord/src/core/mod.rs b/kord/src/core/mod.rs index 0f97da1..4c719c2 100644 --- a/kord/src/core/mod.rs +++ b/kord/src/core/mod.rs @@ -5,6 +5,8 @@ pub mod chord; pub mod helpers; pub mod interval; pub mod known_chord; +pub mod mode; +pub mod mode_kind; pub mod modifier; pub mod named_pitch; pub mod note; @@ -12,3 +14,5 @@ pub mod octave; #[allow(missing_docs)] pub mod parser; pub mod pitch; +pub mod scale; +pub mod scale_kind; diff --git a/kord/src/core/mode.rs b/kord/src/core/mode.rs new file mode 100644 index 0000000..48f6f1a --- /dev/null +++ b/kord/src/core/mode.rs @@ -0,0 +1,583 @@ +//! A module for working with modes. + +use std::fmt::{Display, Error, Formatter}; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +use pest::Parser; + +use crate::core::{ + base::{HasDescription, HasName, HasPreciseName, HasStaticName, Parsable, Res}, + chord::HasRoot, + interval::{HasIntervals, Interval}, + mode_kind::ModeKind, + note::Note, + parser::{mode_name_str_to_mode_kind, note_str_to_note, ChordParser, Rule}, +}; + +// Traits. + +/// A trait that represents a type that has a mode kind. +pub trait HasModeKind { + /// Returns the mode kind of the implementor (most likely a [`Mode`]). + fn kind(&self) -> ModeKind; +} + +// Struct. + +/// A mode with a root note. +/// +/// This combines a root note with a mode kind to produce an actual mode +/// with specific notes. +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(PartialEq, Eq, Copy, Clone, Debug)] +pub struct Mode { + /// The root note of the mode. + root: Note, + /// The kind of mode. + kind: ModeKind, +} + +// Impls. + +impl Mode { + /// Creates a new mode with the given root note and mode kind. + pub fn new(root: Note, kind: ModeKind) -> Self { + Self { root, kind } + } + + /// Returns the intervals of this mode (delegates to the mode kind). + pub fn intervals(&self) -> &'static [Interval] { + self.kind.intervals() + } + + /// Returns the notes of this mode (root + each interval). + pub fn notes(&self) -> Vec { + self.intervals().iter().map(|&interval| self.root + interval).collect() + } +} + +impl HasRoot for Mode { + fn root(&self) -> Note { + self.root + } +} + +impl HasModeKind for Mode { + fn kind(&self) -> ModeKind { + self.kind + } +} + +impl HasIntervals for Mode { + fn intervals(&self) -> &'static [Interval] { + self.kind.intervals() + } +} + +impl HasStaticName for Mode { + fn static_name(&self) -> &'static str { + self.kind.static_name() + } +} + +impl HasName for Mode { + fn name(&self) -> String { + format!("{} {}", self.root.static_name(), self.kind.static_name()) + } +} + +impl HasPreciseName for Mode { + fn precise_name(&self) -> String { + format!("{} {}", self.root.name(), self.kind.static_name()) + } +} + +impl HasDescription for Mode { + fn description(&self) -> &'static str { + self.kind.description() + } +} + +impl Display for Mode { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> { + let notes = self.notes().iter().map(|n| n.static_name()).collect::>().join(", "); + write!(f, "{}\n {}\n {}", self.name(), self.description(), notes) + } +} + +impl Parsable for Mode { + fn parse(input: &str) -> Res + where + Self: Sized, + { + let pairs = ChordParser::parse(Rule::mode, input)?; + let root = pairs.clone().next().unwrap(); + + assert_eq!(Rule::mode, root.as_rule()); + + let mut components = root.into_inner(); + + let note = components.next().unwrap(); + assert_eq!(Rule::note_atomic, note.as_rule()); + let root_note = note_str_to_note(note.as_str().trim())?; + + let mode_name = components.next().unwrap(); + assert_eq!(Rule::mode_name, mode_name.as_rule()); + let mode_kind = mode_name_str_to_mode_kind(mode_name.as_str())?; + + Ok(Mode::new(root_note, mode_kind)) + } +} + +// Tests. + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::note::*; + use crate::core::named_pitch::{HasNamedPitch, HasLetter}; + use pretty_assertions::assert_eq; + + impl Mode { + /// Validates that the mode has correct enharmonic spelling. + /// + /// For 7-note modes (most modes), each letter A-G should appear exactly once. + /// For other modes, no letter should repeat unless it's a chromatic/octatonic exception. + pub(crate) fn validate_spelling(&self) -> Result<(), String> { + use std::collections::HashMap; + use crate::core::named_pitch::{HasLetter, HasNamedPitch}; + + let notes = self.notes(); + let intervals_count = self.intervals().len(); + + // For chromatic scale (12 notes), we allow letter repeats + if intervals_count == 12 { + return Ok(()); + } + + // Check for letter uniqueness + let mut letter_counts: HashMap<&str, usize> = HashMap::new(); + for note in ¬es { + let letter = note.named_pitch().letter(); + *letter_counts.entry(letter).or_insert(0) += 1; + } + + // For 7-note collections, we expect exactly one of each letter + if intervals_count == 7 { + if letter_counts.len() != 7 { + return Err(format!( + "{} {} has {} unique letters, expected 7. Letters: {:?}", + self.root().static_name(), + self.kind().static_name(), + letter_counts.len(), + notes.iter().map(|n| n.static_name()).collect::>() + )); + } + + for (letter, count) in &letter_counts { + if *count != 1 { + return Err(format!( + "{} {} has letter {} appearing {} times, expected 1. Notes: {:?}", + self.root().static_name(), + self.kind().static_name(), + letter, + count, + notes.iter().map(|n| n.static_name()).collect::>() + )); + } + } + } else { + // For non-7-note collections (pentatonic, etc.), just check no duplicates + for (letter, count) in &letter_counts { + if *count > 1 { + return Err(format!( + "{} {} has letter {} appearing {} times. Notes: {:?}", + self.root().static_name(), + self.kind().static_name(), + letter, + count, + notes.iter().map(|n| n.static_name()).collect::>() + )); + } + } + } + + Ok(()) + } + } + + #[test] + fn test_mode_creation() { + let mode = Mode::new(D, ModeKind::Dorian); + assert_eq!(mode.root(), D); + assert_eq!(mode.kind(), ModeKind::Dorian); + } + + #[test] + fn test_mode_intervals() { + let mode = Mode::new(D, ModeKind::Dorian); + assert_eq!(mode.intervals().len(), 7); + assert_eq!( + mode.intervals(), + &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MinorThird, + Interval::PerfectFourth, + Interval::PerfectFifth, + Interval::MajorSixth, + Interval::MinorSeventh, + ] + ); + } + + #[test] + fn test_mode_notes() { + // D Dorian + let mode = Mode::new(D, ModeKind::Dorian); + assert_eq!(mode.notes(), vec![D, E, F, G, A, B, CFive]); + + // C Ionian (same as C major) + let mode = Mode::new(C, ModeKind::Ionian); + assert_eq!(mode.notes(), vec![C, D, E, F, G, A, B]); + + // E Phrygian + let mode = Mode::new(E, ModeKind::Phrygian); + assert_eq!(mode.notes(), vec![E, F, G, A, B, CFive, DFive]); + + // F Lydian + let mode = Mode::new(F, ModeKind::Lydian); + assert_eq!(mode.notes(), vec![F, G, A, B, CFive, DFive, EFive]); + + // G Mixolydian + let mode = Mode::new(G, ModeKind::Mixolydian); + assert_eq!(mode.notes(), vec![G, A, B, CFive, DFive, EFive, FFive]); + + // A Aeolian (natural minor) + let mode = Mode::new(A, ModeKind::Aeolian); + assert_eq!(mode.notes(), vec![A, B, CFive, DFive, EFive, FFive, GFive]); + + // B Locrian + let mode = Mode::new(B, ModeKind::Locrian); + assert_eq!(mode.notes(), vec![B, CFive, DFive, EFive, FFive, GFive, AFive]); + } + + #[test] + fn test_mode_names() { + let mode = Mode::new(D, ModeKind::Dorian); + assert_eq!(mode.name(), "D dorian"); + assert_eq!(mode.static_name(), "dorian"); + + let mode = Mode::new(FSharp, ModeKind::Lydian); + assert_eq!(mode.name(), "F♯ lydian"); + + let mode = Mode::new(BFlat, ModeKind::Mixolydian); + assert_eq!(mode.name(), "B♭ mixolydian"); + } + + #[test] + fn test_mode_display() { + let mode = Mode::new(D, ModeKind::Dorian); + let display = format!("{}", mode); + assert!(display.contains("D dorian")); + assert!(display.contains("D, E, F, G, A, B, C")); + assert!(display.contains("dorian")); + } + + #[test] + fn test_all_modes_of_c_major() { + // All modes of C major scale should contain the same note classes (C, D, E, F, G, A, B) + // but starting from different degrees. Notes may be in different octaves. + + let c_ionian = Mode::new(C, ModeKind::Ionian); + assert_eq!(c_ionian.notes(), vec![C, D, E, F, G, A, B]); + + let d_dorian = Mode::new(D, ModeKind::Dorian); + assert_eq!(d_dorian.notes(), vec![D, E, F, G, A, B, CFive]); + + let e_phrygian = Mode::new(E, ModeKind::Phrygian); + assert_eq!(e_phrygian.notes(), vec![E, F, G, A, B, CFive, DFive]); + + let f_lydian = Mode::new(F, ModeKind::Lydian); + assert_eq!(f_lydian.notes(), vec![F, G, A, B, CFive, DFive, EFive]); + + let g_mixolydian = Mode::new(G, ModeKind::Mixolydian); + assert_eq!(g_mixolydian.notes(), vec![G, A, B, CFive, DFive, EFive, FFive]); + + let a_aeolian = Mode::new(A, ModeKind::Aeolian); + assert_eq!(a_aeolian.notes(), vec![A, B, CFive, DFive, EFive, FFive, GFive]); + + let b_locrian = Mode::new(B, ModeKind::Locrian); + assert_eq!(b_locrian.notes(), vec![B, CFive, DFive, EFive, FFive, GFive, AFive]); + } + + #[test] + fn test_mode_characteristic_intervals() { + // D Dorian characteristic: major 6th (B) in minor context + let mode = Mode::new(D, ModeKind::Dorian); + let notes = mode.notes(); + assert_eq!(notes[5], B); // 6th degree is major 6th + + // E Phrygian characteristic: minor 2nd (F) + let mode = Mode::new(E, ModeKind::Phrygian); + let notes = mode.notes(); + assert_eq!(notes[1], F); // 2nd degree is minor 2nd + + // F Lydian characteristic: augmented 4th (B) + let mode = Mode::new(F, ModeKind::Lydian); + let notes = mode.notes(); + assert_eq!(notes[3], B); // 4th degree is augmented 4th + + // B Locrian characteristic: diminished 5th (F) + let mode = Mode::new(B, ModeKind::Locrian); + let notes = mode.notes(); + assert_eq!(notes[4], FFive); // 5th degree is diminished 5th + } + + #[test] + fn test_mode_parse() { + // Test parsing various modes + let mode = Mode::parse("D dorian").unwrap(); + assert_eq!(mode.root(), D); + assert_eq!(mode.kind(), ModeKind::Dorian); + + let mode = Mode::parse("C ionian").unwrap(); + assert_eq!(mode.root(), C); + assert_eq!(mode.kind(), ModeKind::Ionian); + + let mode = Mode::parse("E phrygian").unwrap(); + assert_eq!(mode.root(), E); + assert_eq!(mode.kind(), ModeKind::Phrygian); + + let mode = Mode::parse("F lydian").unwrap(); + assert_eq!(mode.root(), F); + assert_eq!(mode.kind(), ModeKind::Lydian); + + let mode = Mode::parse("G mixolydian").unwrap(); + assert_eq!(mode.root(), G); + assert_eq!(mode.kind(), ModeKind::Mixolydian); + + let mode = Mode::parse("A aeolian").unwrap(); + assert_eq!(mode.root(), A); + assert_eq!(mode.kind(), ModeKind::Aeolian); + + let mode = Mode::parse("B locrian").unwrap(); + assert_eq!(mode.root(), B); + assert_eq!(mode.kind(), ModeKind::Locrian); + + // Test with accidentals + let mode = Mode::parse("F# dorian").unwrap(); + assert_eq!(mode.root(), FSharp); + assert_eq!(mode.kind(), ModeKind::Dorian); + + let mode = Mode::parse("Bb lydian").unwrap(); + assert_eq!(mode.root(), BFlat); + assert_eq!(mode.kind(), ModeKind::Lydian); + } + + #[test] + fn test_harmonic_minor_modes_parse() { + // Test harmonic minor modes + let mode = Mode::parse("B locrian nat6").unwrap(); + assert_eq!(mode.kind(), ModeKind::LocrianNatural6); + + let mode = Mode::parse("C ionian #5").unwrap(); + assert_eq!(mode.kind(), ModeKind::IonianSharp5); + + let mode = Mode::parse("D dorian sharp 4").unwrap(); + assert_eq!(mode.kind(), ModeKind::DorianSharp4); + + let mode = Mode::parse("E phrygian dominant").unwrap(); + assert_eq!(mode.kind(), ModeKind::PhrygianDominant); + + let mode = Mode::parse("F lydian #2").unwrap(); + assert_eq!(mode.kind(), ModeKind::LydianSharp2); + + let mode = Mode::parse("G# ultralocrian").unwrap(); + assert_eq!(mode.kind(), ModeKind::Ultralocrian); + } + + #[test] + fn test_melodic_minor_modes_parse() { + // Test melodic minor modes + let mode = Mode::parse("B dorian b2").unwrap(); + assert_eq!(mode.kind(), ModeKind::DorianFlat2); + + let mode = Mode::parse("C lydian augmented").unwrap(); + assert_eq!(mode.kind(), ModeKind::LydianAugmented); + + let mode = Mode::parse("D lydian dominant").unwrap(); + assert_eq!(mode.kind(), ModeKind::LydianDominant); + + let mode = Mode::parse("E mixolydian b6").unwrap(); + assert_eq!(mode.kind(), ModeKind::MixolydianFlat6); + + let mode = Mode::parse("F# locrian nat2").unwrap(); + assert_eq!(mode.kind(), ModeKind::LocrianNatural2); + + let mode = Mode::parse("G# altered").unwrap(); + assert_eq!(mode.kind(), ModeKind::Altered); + } + + #[test] + fn test_enharmonic_spelling_diatonic_modes() { + // Test all diatonic modes with various root notes to ensure correct enharmonic spelling + // Each 7-note mode should use each letter A-G exactly once + + // C Ionian - all natural notes + let mode = Mode::new(C, ModeKind::Ionian); + mode.validate_spelling().unwrap(); + + // F# Dorian - should use sharps, not flats + let mode = Mode::new(FSharp, ModeKind::Dorian); + mode.validate_spelling().unwrap(); + let notes = mode.notes(); + // F# Dorian: F# G# A B C# D E# + assert_eq!(notes[0].named_pitch().letter(), "F"); + assert_eq!(notes[1].named_pitch().letter(), "G"); + assert_eq!(notes[2].named_pitch().letter(), "A"); + assert_eq!(notes[3].named_pitch().letter(), "B"); + assert_eq!(notes[4].named_pitch().letter(), "C"); + assert_eq!(notes[5].named_pitch().letter(), "D"); + assert_eq!(notes[6].named_pitch().letter(), "E"); + + // Db Lydian - should use flats + let mode = Mode::new(DFlat, ModeKind::Lydian); + mode.validate_spelling().unwrap(); + let notes = mode.notes(); + // Db Lydian: Db Eb F G Ab Bb C + assert_eq!(notes[0].named_pitch().letter(), "D"); + assert_eq!(notes[1].named_pitch().letter(), "E"); + assert_eq!(notes[2].named_pitch().letter(), "F"); + assert_eq!(notes[3].named_pitch().letter(), "G"); + assert_eq!(notes[4].named_pitch().letter(), "A"); + assert_eq!(notes[5].named_pitch().letter(), "B"); + assert_eq!(notes[6].named_pitch().letter(), "C"); + + // Bb Mixolydian + let mode = Mode::new(BFlat, ModeKind::Mixolydian); + mode.validate_spelling().unwrap(); + + // E Locrian + let mode = Mode::new(E, ModeKind::Locrian); + mode.validate_spelling().unwrap(); + } + + #[test] + fn test_enharmonic_spelling_harmonic_minor_modes() { + // Test harmonic minor modes + + // F# Locrian Natural 6 - should spell with F# G A B C# D E# + let mode = Mode::new(FSharp, ModeKind::LocrianNatural6); + mode.validate_spelling().unwrap(); + + // C Ionian #5 + let mode = Mode::new(C, ModeKind::IonianSharp5); + mode.validate_spelling().unwrap(); + + // D Dorian #4 + let mode = Mode::new(D, ModeKind::DorianSharp4); + mode.validate_spelling().unwrap(); + + // E Phrygian Dominant + let mode = Mode::new(E, ModeKind::PhrygianDominant); + mode.validate_spelling().unwrap(); + + // F Lydian #2 + let mode = Mode::new(F, ModeKind::LydianSharp2); + mode.validate_spelling().unwrap(); + + // G# Ultralocrian + let mode = Mode::new(GSharp, ModeKind::Ultralocrian); + mode.validate_spelling().unwrap(); + } + + #[test] + fn test_enharmonic_spelling_melodic_minor_modes() { + // Test melodic minor modes + + // B Dorian b2 + let mode = Mode::new(B, ModeKind::DorianFlat2); + mode.validate_spelling().unwrap(); + + // C Lydian Augmented + let mode = Mode::new(C, ModeKind::LydianAugmented); + mode.validate_spelling().unwrap(); + + // D Lydian Dominant + let mode = Mode::new(D, ModeKind::LydianDominant); + mode.validate_spelling().unwrap(); + + // E Mixolydian b6 + let mode = Mode::new(E, ModeKind::MixolydianFlat6); + mode.validate_spelling().unwrap(); + + // F# Locrian natural 2 + let mode = Mode::new(FSharp, ModeKind::LocrianNatural2); + mode.validate_spelling().unwrap(); + + // G# Altered + let mode = Mode::new(GSharp, ModeKind::Altered); + mode.validate_spelling().unwrap(); + } + + #[test] + fn test_enharmonic_spelling_all_roots() { + // Test a few modes with all 12 root notes to ensure consistency + for root in [C, CSharp, D, DFlat, DSharp, E, EFlat, F, FSharp, G, GFlat, GSharp, A, AFlat, ASharp, B, BFlat] { + // Ionian (Major) + let mode = Mode::new(root, ModeKind::Ionian); + mode.validate_spelling().unwrap_or_else(|e| panic!("Ionian spelling failed for {}: {}", root.static_name(), e)); + + // Dorian + let mode = Mode::new(root, ModeKind::Dorian); + mode.validate_spelling().unwrap_or_else(|e| panic!("Dorian spelling failed for {}: {}", root.static_name(), e)); + + // Lydian + let mode = Mode::new(root, ModeKind::Lydian); + mode.validate_spelling().unwrap_or_else(|e| panic!("Lydian spelling failed for {}: {}", root.static_name(), e)); + } + } + + #[test] + fn test_mode_spelling() { + let mode = Mode::new(E, ModeKind::PhrygianDominant); + assert_eq!( + mode.notes(), + vec![E, F, GSharp, A, B, CFive, DFive], + "E phrygian dominant spelling incorrect" + ); + mode.validate_spelling().unwrap(); + + let mode = Mode::new(B, ModeKind::LocrianNatural6); + assert_eq!( + mode.notes(), + vec![B, CFive, DFive, EFive, FFive, GSharpFive, AFive], + "B locrian nat6 spelling incorrect" + ); + mode.validate_spelling().unwrap(); + + let mode = Mode::new(D, ModeKind::LydianDominant); + assert_eq!( + mode.notes(), + vec![D, E, FSharp, GSharp, A, B, CFive], + "D lydian dominant spelling incorrect" + ); + mode.validate_spelling().unwrap(); + + assert_eq!( + mode.intervals(), + &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MajorThird, + Interval::AugmentedFourth, + Interval::PerfectFifth, + Interval::MajorSixth, + Interval::MinorSeventh, + ] + ); + } +} diff --git a/kord/src/core/mode_kind.rs b/kord/src/core/mode_kind.rs new file mode 100644 index 0000000..a83b4fc --- /dev/null +++ b/kord/src/core/mode_kind.rs @@ -0,0 +1,587 @@ +//! A module for working with mode kinds. + +use crate::core::{ + base::{HasDescription, HasName, HasStaticName}, + interval::{HasIntervals, Interval}, + scale_kind::ScaleKind, +}; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +// Enum. + +/// An enum representing a mode kind (type of mode). +/// +/// Each mode kind has an **explicit** list of intervals that define the mode. +/// These intervals are NOT derived by rotation - they are the authoritative definition. +/// Parent scale information is included for documentation purposes only. +#[derive(PartialEq, Eq, Copy, Clone, Hash, Debug, Ord, PartialOrd)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[repr(u8)] +pub enum ModeKind { + // Major scale modes (diatonic) + /// Ionian mode (1st mode of major scale). + Ionian, + /// Dorian mode (2nd mode of major scale). + Dorian, + /// Phrygian mode (3rd mode of major scale). + Phrygian, + /// Lydian mode (4th mode of major scale). + Lydian, + /// Mixolydian mode (5th mode of major scale). + Mixolydian, + /// Aeolian mode (6th mode of major scale). + Aeolian, + /// Locrian mode (7th mode of major scale). + Locrian, + + // Harmonic minor modes + /// Locrian ♮6 mode (2nd mode of harmonic minor). + LocrianNatural6, + /// Ionian ♯5 mode (3rd mode of harmonic minor). + IonianSharp5, + /// Dorian ♯4 mode (4th mode of harmonic minor). + DorianSharp4, + /// Phrygian Dominant mode (5th mode of harmonic minor). + PhrygianDominant, + /// Lydian ♯2 mode (6th mode of harmonic minor). + LydianSharp2, + /// Ultralocrian mode (7th mode of harmonic minor). + Ultralocrian, + + // Melodic minor modes + /// Dorian ♭2 mode (2nd mode of melodic minor). + DorianFlat2, + /// Lydian Augmented mode (3rd mode of melodic minor). + LydianAugmented, + /// Lydian Dominant mode (4th mode of melodic minor). + LydianDominant, + /// Mixolydian ♭6 mode (5th mode of melodic minor). + MixolydianFlat6, + /// Locrian ♮2 mode (6th mode of melodic minor). + LocrianNatural2, + /// Altered / Super Locrian mode (7th mode of melodic minor). + Altered, +} + +// Impls. + +impl ModeKind { + /// Returns the parent scale kind for this mode (for documentation only). + /// + /// This is metadata - the intervals are NOT derived from the parent. + pub fn parent_scale(&self) -> ScaleKind { + match self { + ModeKind::Ionian + | ModeKind::Dorian + | ModeKind::Phrygian + | ModeKind::Lydian + | ModeKind::Mixolydian + | ModeKind::Aeolian + | ModeKind::Locrian => ScaleKind::Major, + + ModeKind::LocrianNatural6 + | ModeKind::IonianSharp5 + | ModeKind::DorianSharp4 + | ModeKind::PhrygianDominant + | ModeKind::LydianSharp2 + | ModeKind::Ultralocrian => ScaleKind::HarmonicMinor, + + ModeKind::DorianFlat2 + | ModeKind::LydianAugmented + | ModeKind::LydianDominant + | ModeKind::MixolydianFlat6 + | ModeKind::LocrianNatural2 + | ModeKind::Altered => ScaleKind::MelodicMinor, + } + } + + /// Returns the degree of the parent scale that this mode starts on (for documentation only). + /// + /// This is metadata - the intervals are NOT derived from the parent. + pub fn parent_degree(&self) -> u8 { + match self { + // Major scale modes + ModeKind::Ionian => 1, + ModeKind::Dorian => 2, + ModeKind::Phrygian => 3, + ModeKind::Lydian => 4, + ModeKind::Mixolydian => 5, + ModeKind::Aeolian => 6, + ModeKind::Locrian => 7, + + // Harmonic minor modes + ModeKind::LocrianNatural6 => 2, + ModeKind::IonianSharp5 => 3, + ModeKind::DorianSharp4 => 4, + ModeKind::PhrygianDominant => 5, + ModeKind::LydianSharp2 => 6, + ModeKind::Ultralocrian => 7, + + // Melodic minor modes + ModeKind::DorianFlat2 => 2, + ModeKind::LydianAugmented => 3, + ModeKind::LydianDominant => 4, + ModeKind::MixolydianFlat6 => 5, + ModeKind::LocrianNatural2 => 6, + ModeKind::Altered => 7, + } + } +} + +impl HasIntervals for ModeKind { + fn intervals(&self) -> &'static [Interval] { + match self { + // MAJOR SCALE MODES + + // Ionian: W-W-H-W-W-W-H (same as major scale) + ModeKind::Ionian => &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MajorThird, + Interval::PerfectFourth, + Interval::PerfectFifth, + Interval::MajorSixth, + Interval::MajorSeventh, + ], + // Dorian: W-H-W-W-W-H-W (minor scale with raised 6th) + ModeKind::Dorian => &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MinorThird, + Interval::PerfectFourth, + Interval::PerfectFifth, + Interval::MajorSixth, + Interval::MinorSeventh, + ], + // Phrygian: H-W-W-W-H-W-W (minor scale with lowered 2nd) + ModeKind::Phrygian => &[ + Interval::PerfectUnison, + Interval::MinorSecond, + Interval::MinorThird, + Interval::PerfectFourth, + Interval::PerfectFifth, + Interval::MinorSixth, + Interval::MinorSeventh, + ], + // Lydian: W-W-W-H-W-W-H (major scale with raised 4th) + ModeKind::Lydian => &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MajorThird, + Interval::AugmentedFourth, + Interval::PerfectFifth, + Interval::MajorSixth, + Interval::MajorSeventh, + ], + // Mixolydian: W-W-H-W-W-H-W (major scale with lowered 7th) + ModeKind::Mixolydian => &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MajorThird, + Interval::PerfectFourth, + Interval::PerfectFifth, + Interval::MajorSixth, + Interval::MinorSeventh, + ], + // Aeolian: W-H-W-W-H-W-W (natural minor scale) + ModeKind::Aeolian => &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MinorThird, + Interval::PerfectFourth, + Interval::PerfectFifth, + Interval::MinorSixth, + Interval::MinorSeventh, + ], + // Locrian: H-W-W-H-W-W-W (diminished scale) + ModeKind::Locrian => &[ + Interval::PerfectUnison, + Interval::MinorSecond, + Interval::MinorThird, + Interval::PerfectFourth, + Interval::DiminishedFifth, + Interval::MinorSixth, + Interval::MinorSeventh, + ], + + // HARMONIC MINOR MODES + + // Locrian ♮6: H-W-W-H-W+H-W (Locrian with natural 6th) + // Example: B Locrian ♮6 = B C D E F G♯ A + ModeKind::LocrianNatural6 => &[ + Interval::PerfectUnison, + Interval::MinorSecond, + Interval::MinorThird, + Interval::PerfectFourth, + Interval::DiminishedFifth, + Interval::MajorSixth, + Interval::MinorSeventh, + ], + // Ionian ♯5: W-W-H-W+H-H-H (Major with augmented 5th) + // Example: C Ionian ♯5 = C D E F G♯ A B + ModeKind::IonianSharp5 => &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MajorThird, + Interval::PerfectFourth, + Interval::AugmentedFifth, + Interval::MajorSixth, + Interval::MajorSeventh, + ], + // Dorian ♯4: W-H-W+H-H-W-W (Dorian with augmented 4th) + // Example: D Dorian ♯4 = D E F G♯ A B C + ModeKind::DorianSharp4 => &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MinorThird, + Interval::AugmentedFourth, + Interval::PerfectFifth, + Interval::MajorSixth, + Interval::MinorSeventh, + ], + // Phrygian Dominant: H-W+H-H-W-H-W-W (Phrygian with major 3rd) + // Example: E Phrygian Dominant = E F G♯ A B C D + ModeKind::PhrygianDominant => &[ + Interval::PerfectUnison, + Interval::MinorSecond, + Interval::MajorThird, + Interval::PerfectFourth, + Interval::PerfectFifth, + Interval::MinorSixth, + Interval::MinorSeventh, + ], + // Lydian ♯2: W+H-H-W-H-W-W-H (Lydian with augmented 2nd) + // Example: F Lydian ♯2 = F G♯ A B C D E + ModeKind::LydianSharp2 => &[ + Interval::PerfectUnison, + Interval::AugmentedSecond, + Interval::MajorThird, + Interval::AugmentedFourth, + Interval::PerfectFifth, + Interval::MajorSixth, + Interval::MajorSeventh, + ], + // Ultralocrian: H-W-H-W-W-W-W (Locrian ♭♭7) + // Example: G♯ Ultralocrian = G♯ A B C D E F + ModeKind::Ultralocrian => &[ + Interval::PerfectUnison, + Interval::MinorSecond, + Interval::MinorThird, + Interval::DiminishedFourth, + Interval::DiminishedFifth, + Interval::MinorSixth, + Interval::DiminishedSeventh, + ], + + // MELODIC MINOR MODES + + // Dorian ♭2 (Phrygian ♮6): H-W-W-W-W-W-H (Dorian with flat 2nd) + // Example: B Dorian ♭2 = B C D E F♯ G♯ A + ModeKind::DorianFlat2 => &[ + Interval::PerfectUnison, + Interval::MinorSecond, + Interval::MinorThird, + Interval::PerfectFourth, + Interval::PerfectFifth, + Interval::MajorSixth, + Interval::MajorSeventh, + ], + // Lydian Augmented: W-W-W-W-H-W-H (Lydian with augmented 5th) + // Example: C Lydian Augmented = C D E F♯ G♯ A B + ModeKind::LydianAugmented => &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MajorThird, + Interval::AugmentedFourth, + Interval::AugmentedFifth, + Interval::MajorSixth, + Interval::MajorSeventh, + ], + // Lydian Dominant (Acoustic): W-W-W-H-W-H-W (Mixolydian with sharp 4th) + // Example: D Lydian Dominant = D E F♯ G♯ A B C + ModeKind::LydianDominant => &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MajorThird, + Interval::AugmentedFourth, + Interval::PerfectFifth, + Interval::MajorSixth, + Interval::MinorSeventh, + ], + // Mixolydian ♭6 (Aeolian Dominant): W-W-H-W-H-W-W (Mixolydian with flat 6th) + // Example: E Mixolydian ♭6 = E F♯ G♯ A B C D + ModeKind::MixolydianFlat6 => &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MajorThird, + Interval::PerfectFourth, + Interval::PerfectFifth, + Interval::MinorSixth, + Interval::MinorSeventh, + ], + // Locrian ♮2 (Half-diminished): W-H-W-H-W-W-W (Locrian with natural 2nd) + // Example: F♯ Locrian ♮2 = F♯ G♯ A B C D E + ModeKind::LocrianNatural2 => &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MinorThird, + Interval::PerfectFourth, + Interval::DiminishedFifth, + Interval::MinorSixth, + Interval::MinorSeventh, + ], + // Altered (Super Locrian): H-W-H-W-W-W-W (All alterations) + // Example: G♯ Altered = G♯ A B C D E F♯ + // + // Theoretical interval spelling from the melodic minor parent would be: + // 1, m2, A2, M3, dim5, dim6, m7 + // + // However, we intentionally use enharmonic equivalents here: + // A2 → m3 (AugmentedSecond → MinorThird) + // M3 → dim4 (MajorThird → DiminishedFourth) + // dim6 → m6 (DiminishedSixth → MinorSixth) + // + // This keeps the internal interval representation free of double accidentals + // and avoids reusing the same letter name multiple times when combined with + // validate_spelling(), which checks for unique letter names. In other words, + // these spellings are chosen for practical spelling/validation reasons while + // remaining pitch‑equivalent to the theoretical Altered mode. + ModeKind::Altered => &[ + Interval::PerfectUnison, + Interval::MinorSecond, + Interval::MinorThird, + Interval::DiminishedFourth, + Interval::DiminishedFifth, + Interval::MinorSixth, + Interval::MinorSeventh, + ], + } + } +} + +impl HasDescription for ModeKind { + fn description(&self) -> &'static str { + match self { + // Major scale modes + ModeKind::Ionian => "ionian, 1st mode of major scale, major scale", + ModeKind::Dorian => "dorian, 2nd mode of major scale, minor with raised 6th", + ModeKind::Phrygian => "phrygian, 3rd mode of major scale, minor with lowered 2nd", + ModeKind::Lydian => "lydian, 4th mode of major scale, major with raised 4th", + ModeKind::Mixolydian => "mixolydian, 5th mode of major scale, major with lowered 7th", + ModeKind::Aeolian => "aeolian, 6th mode of major scale, natural minor", + ModeKind::Locrian => "locrian, 7th mode of major scale, diminished, half-diminished chord scale", + + // Harmonic minor modes + ModeKind::LocrianNatural6 => "locrian ♮6, 2nd mode of harmonic minor, m7♭5(♮13) color", + ModeKind::IonianSharp5 => "ionian ♯5, 3rd mode of harmonic minor, augmented major", + ModeKind::DorianSharp4 => "dorian ♯4, 4th mode of harmonic minor, minor with lydian bite", + ModeKind::PhrygianDominant => "phrygian dominant, 5th mode of harmonic minor, spanish phrygian", + ModeKind::LydianSharp2 => "lydian ♯2, 6th mode of harmonic minor, bright + exotic", + ModeKind::Ultralocrian => "ultralocrian, 7th mode of harmonic minor, very unstable/dark", + + // Melodic minor modes + ModeKind::DorianFlat2 => "dorian ♭2, 2nd mode of melodic minor, phrygian ♮6, minor with spicy ♭2", + ModeKind::LydianAugmented => "lydian augmented, 3rd mode of melodic minor, lydian ♯5", + ModeKind::LydianDominant => "lydian dominant, 4th mode of melodic minor, acoustic scale, dominant with ♯11", + ModeKind::MixolydianFlat6 => "mixolydian ♭6, 5th mode of melodic minor, aeolian dominant, dominant with ♭13", + ModeKind::LocrianNatural2 => "locrian ♮2, 6th mode of melodic minor, half-diminished ♮2", + ModeKind::Altered => "altered, 7th mode of melodic minor, super locrian, V7alt scale", + } + } +} + +impl HasStaticName for ModeKind { + fn static_name(&self) -> &'static str { + match self { + // Major scale modes + ModeKind::Ionian => "ionian", + ModeKind::Dorian => "dorian", + ModeKind::Phrygian => "phrygian", + ModeKind::Lydian => "lydian", + ModeKind::Mixolydian => "mixolydian", + ModeKind::Aeolian => "aeolian", + ModeKind::Locrian => "locrian", + + // Harmonic minor modes + ModeKind::LocrianNatural6 => "locrian ♮6", + ModeKind::IonianSharp5 => "ionian ♯5", + ModeKind::DorianSharp4 => "dorian ♯4", + ModeKind::PhrygianDominant => "phrygian dominant", + ModeKind::LydianSharp2 => "lydian ♯2", + ModeKind::Ultralocrian => "ultralocrian", + + // Melodic minor modes + ModeKind::DorianFlat2 => "dorian ♭2", + ModeKind::LydianAugmented => "lydian augmented", + ModeKind::LydianDominant => "lydian dominant", + ModeKind::MixolydianFlat6 => "mixolydian ♭6", + ModeKind::LocrianNatural2 => "locrian ♮2", + ModeKind::Altered => "altered", + } + } +} + +impl HasName for ModeKind { + fn name(&self) -> String { + self.static_name().to_owned() + } +} + +impl ModeKind { + /// Returns the ASCII name of the mode (using 'b', '#', 'nat' instead of Unicode symbols). + pub fn ascii_name(&self) -> &'static str { + match self { + // Major scale modes + ModeKind::Ionian => "ionian", + ModeKind::Dorian => "dorian", + ModeKind::Phrygian => "phrygian", + ModeKind::Lydian => "lydian", + ModeKind::Mixolydian => "mixolydian", + ModeKind::Aeolian => "aeolian", + ModeKind::Locrian => "locrian", + + // Harmonic minor modes + ModeKind::LocrianNatural6 => "locrian nat6", + ModeKind::IonianSharp5 => "ionian #5", + ModeKind::DorianSharp4 => "dorian #4", + ModeKind::PhrygianDominant => "phrygian dominant", + ModeKind::LydianSharp2 => "lydian #2", + ModeKind::Ultralocrian => "ultralocrian", + + // Melodic minor modes + ModeKind::DorianFlat2 => "dorian b2", + ModeKind::LydianAugmented => "lydian augmented", + ModeKind::LydianDominant => "lydian dominant", + ModeKind::MixolydianFlat6 => "mixolydian b6", + ModeKind::LocrianNatural2 => "locrian nat2", + ModeKind::Altered => "altered", + } + } +} + +// Tests. + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_mode_intervals_explicit() { + // All modes should have 7 notes + assert_eq!(ModeKind::Ionian.intervals().len(), 7); + assert_eq!(ModeKind::Dorian.intervals().len(), 7); + assert_eq!(ModeKind::Phrygian.intervals().len(), 7); + assert_eq!(ModeKind::Lydian.intervals().len(), 7); + assert_eq!(ModeKind::Mixolydian.intervals().len(), 7); + assert_eq!(ModeKind::Aeolian.intervals().len(), 7); + assert_eq!(ModeKind::Locrian.intervals().len(), 7); + + // Ionian = Major + assert_eq!( + ModeKind::Ionian.intervals(), + &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MajorThird, + Interval::PerfectFourth, + Interval::PerfectFifth, + Interval::MajorSixth, + Interval::MajorSeventh, + ] + ); + + // Dorian has characteristic major 6th in minor context + assert_eq!( + ModeKind::Dorian.intervals(), + &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MinorThird, + Interval::PerfectFourth, + Interval::PerfectFifth, + Interval::MajorSixth, // Characteristic raised 6th + Interval::MinorSeventh, + ] + ); + + // Phrygian has characteristic minor 2nd + assert_eq!( + ModeKind::Phrygian.intervals(), + &[ + Interval::PerfectUnison, + Interval::MinorSecond, // Characteristic lowered 2nd + Interval::MinorThird, + Interval::PerfectFourth, + Interval::PerfectFifth, + Interval::MinorSixth, + Interval::MinorSeventh, + ] + ); + + // Lydian has characteristic augmented 4th + assert_eq!( + ModeKind::Lydian.intervals(), + &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MajorThird, + Interval::AugmentedFourth, // Characteristic raised 4th + Interval::PerfectFifth, + Interval::MajorSixth, + Interval::MajorSeventh, + ] + ); + + // Locrian has characteristic diminished 5th + assert_eq!( + ModeKind::Locrian.intervals(), + &[ + Interval::PerfectUnison, + Interval::MinorSecond, + Interval::MinorThird, + Interval::PerfectFourth, + Interval::DiminishedFifth, // Characteristic diminished 5th + Interval::MinorSixth, + Interval::MinorSeventh, + ] + ); + } + + #[test] + fn test_mode_names() { + assert_eq!(ModeKind::Ionian.static_name(), "ionian"); + assert_eq!(ModeKind::Dorian.static_name(), "dorian"); + assert_eq!(ModeKind::Phrygian.static_name(), "phrygian"); + assert_eq!(ModeKind::Lydian.static_name(), "lydian"); + assert_eq!(ModeKind::Mixolydian.static_name(), "mixolydian"); + assert_eq!(ModeKind::Aeolian.static_name(), "aeolian"); + assert_eq!(ModeKind::Locrian.static_name(), "locrian"); + } + + #[test] + fn test_mode_parent_metadata() { + // All major scale modes have Major as parent + assert_eq!(ModeKind::Ionian.parent_scale(), ScaleKind::Major); + assert_eq!(ModeKind::Dorian.parent_scale(), ScaleKind::Major); + assert_eq!(ModeKind::Locrian.parent_scale(), ScaleKind::Major); + + // Degrees should be 1-7 + assert_eq!(ModeKind::Ionian.parent_degree(), 1); + assert_eq!(ModeKind::Dorian.parent_degree(), 2); + assert_eq!(ModeKind::Phrygian.parent_degree(), 3); + assert_eq!(ModeKind::Lydian.parent_degree(), 4); + assert_eq!(ModeKind::Mixolydian.parent_degree(), 5); + assert_eq!(ModeKind::Aeolian.parent_degree(), 6); + assert_eq!(ModeKind::Locrian.parent_degree(), 7); + } + + #[test] + fn test_mode_descriptions() { + assert_eq!(ModeKind::Ionian.description(), "ionian, 1st mode of major scale, major scale"); + assert_eq!(ModeKind::Dorian.description(), "dorian, 2nd mode of major scale, minor with raised 6th"); + assert_eq!(ModeKind::Lydian.description(), "lydian, 4th mode of major scale, major with raised 4th"); + } +} diff --git a/kord/src/core/parser.rs b/kord/src/core/parser.rs index e798c80..54829ef 100644 --- a/kord/src/core/parser.rs +++ b/kord/src/core/parser.rs @@ -4,8 +4,10 @@ use pest_derive::Parser; use crate::core::{ base::Res, + mode_kind::ModeKind, note::{self, Note}, octave::Octave, + scale_kind::ScaleKind, }; /// A parser for chord symbols. @@ -81,3 +83,68 @@ pub fn octave_str_to_octave(note_str: &str) -> Res { Ok(octave) } + +/// Parses a mode name string into a [`ModeKind`]. +#[coverage(off)] +pub fn mode_name_str_to_mode_kind(mode_str: &str) -> Res { + let normalized = mode_str.to_lowercase() + .replace("♮", "natural") + .replace("♯", "sharp") + .replace("#", "sharp") + .replace("♭", "flat") + .replace("b", "flat") + .replace(" ", ""); + + let mode = match normalized.as_str() { + // Major scale modes + "ionian" => ModeKind::Ionian, + "dorian" => ModeKind::Dorian, + "phrygian" => ModeKind::Phrygian, + "lydian" => ModeKind::Lydian, + "mixolydian" => ModeKind::Mixolydian, + "aeolian" => ModeKind::Aeolian, + "locrian" => ModeKind::Locrian, + + // Harmonic minor modes + "locriannatural6" | "locriannat6" => ModeKind::LocrianNatural6, + "ioniansharp5" | "ionianaugmented" | "majorsharp5" => ModeKind::IonianSharp5, + "doriansharp4" => ModeKind::DorianSharp4, + "phrygiandominant" | "spanishphrygian" | "phrygianmajor" => ModeKind::PhrygianDominant, + "lydiansharp2" => ModeKind::LydianSharp2, + "ultralocrian" => ModeKind::Ultralocrian, + + // Melodic minor modes + "dorianflat2" | "phrygiannatural6" | "phrygiannat6" => ModeKind::DorianFlat2, + "lydianaugmented" | "lydiansharp5" => ModeKind::LydianAugmented, + "lydiandominant" | "lydianflat7" | "mixolydiansharp4" | "acoustic" | "acousticscale" => ModeKind::LydianDominant, + "mixolydianflat6" | "aeoliandominant" => ModeKind::MixolydianFlat6, + "locriannatural2" | "locriannat2" | "locriansharp2" => ModeKind::LocrianNatural2, + "altered" | "alteredscale" | "superlocrian" => ModeKind::Altered, + + _ => return Err(crate::core::base::Err::msg("Unknown mode name")), + }; + + Ok(mode) +} + +/// Parses a scale name string into a [`ScaleKind`]. +#[coverage(off)] +pub fn scale_name_str_to_scale_kind(scale_str: &str) -> Res { + let normalized = scale_str.to_lowercase().replace(" ", "").replace("-", ""); + let scale = match normalized.as_str() { + "major" => ScaleKind::Major, + "naturalminor" => ScaleKind::NaturalMinor, + "harmonicminor" => ScaleKind::HarmonicMinor, + "melodicminor" => ScaleKind::MelodicMinor, + "wholetone" => ScaleKind::WholeTone, + "chromatic" => ScaleKind::Chromatic, + "diminishedwholehalf" => ScaleKind::DiminishedWholeHalf, + "diminishedhalfwhole" => ScaleKind::DiminishedHalfWhole, + "majorpentatonic" => ScaleKind::MajorPentatonic, + "minorpentatonic" => ScaleKind::MinorPentatonic, + "blues" => ScaleKind::Blues, + _ => return Err(crate::core::base::Err::msg("Unknown scale name")), + }; + + Ok(scale) +} diff --git a/kord/src/core/scale.rs b/kord/src/core/scale.rs new file mode 100644 index 0000000..6881b76 --- /dev/null +++ b/kord/src/core/scale.rs @@ -0,0 +1,668 @@ +//! A module for working with scales. + +use std::fmt::{Display, Error, Formatter}; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +use pest::Parser; + +use crate::core::{ + base::{HasDescription, HasName, HasPreciseName, HasStaticName, Parsable, Res}, + chord::HasRoot, + interval::{HasIntervals, Interval}, + note::Note, + parser::{note_str_to_note, scale_name_str_to_scale_kind, ChordParser, Rule}, + scale_kind::ScaleKind, +}; + +// Traits. + +/// A trait that represents a type that has a scale kind. +pub trait HasScaleKind { + /// Returns the scale kind of the implementor (most likely a [`Scale`]). + fn kind(&self) -> ScaleKind; +} + +// Struct. + +/// A scale with a root note. +/// +/// This combines a root note with a scale kind to produce an actual scale +/// with specific notes. +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(PartialEq, Eq, Copy, Clone, Debug)] +pub struct Scale { + /// The root note of the scale. + root: Note, + /// The kind of scale. + kind: ScaleKind, +} + +// Impls. + +impl Scale { + /// Creates a new scale with the given root note and scale kind. + pub fn new(root: Note, kind: ScaleKind) -> Self { + Self { root, kind } + } + + /// Returns the intervals of this scale (delegates to the scale kind). + pub fn intervals(&self) -> &'static [Interval] { + self.kind.intervals() + } + + /// Returns the notes of this scale (root + each interval). + pub fn notes(&self) -> Vec { + self.intervals().iter().map(|&interval| self.root + interval).collect() + } +} + +impl HasRoot for Scale { + fn root(&self) -> Note { + self.root + } +} + +impl HasScaleKind for Scale { + fn kind(&self) -> ScaleKind { + self.kind + } +} + +impl HasIntervals for Scale { + fn intervals(&self) -> &'static [Interval] { + self.kind.intervals() + } +} + +impl HasStaticName for Scale { + fn static_name(&self) -> &'static str { + self.kind.static_name() + } +} + +impl HasName for Scale { + fn name(&self) -> String { + format!("{} {}", self.root.static_name(), self.kind.static_name()) + } +} + +impl HasPreciseName for Scale { + fn precise_name(&self) -> String { + format!("{} {}", self.root.name(), self.kind.static_name()) + } +} + +impl HasDescription for Scale { + fn description(&self) -> &'static str { + self.kind.description() + } +} + +impl Display for Scale { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> { + let notes = self.notes().iter().map(|n| n.static_name()).collect::>().join(", "); + write!(f, "{}\n {}\n {}", self.name(), self.description(), notes) + } +} + +impl Parsable for Scale { + fn parse(input: &str) -> Res + where + Self: Sized, + { + let root = ChordParser::parse(Rule::scale, input)?.next().unwrap(); + + assert_eq!(Rule::scale, root.as_rule()); + + let mut components = root.into_inner(); + + let note = components.next().unwrap(); + assert_eq!(Rule::note_atomic, note.as_rule()); + let root_note = note_str_to_note(note.as_str().trim())?; + + let scale_name = components.next().unwrap(); + assert_eq!(Rule::scale_name, scale_name.as_rule()); + let scale_kind = scale_name_str_to_scale_kind(scale_name.as_str())?; + + Ok(Scale::new(root_note, scale_kind)) + } +} + +// Tests. + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::note::*; + use crate::core::named_pitch::{HasNamedPitch, HasLetter}; + use pretty_assertions::assert_eq; + + impl Scale { + /// Validates that the scale has correct enharmonic spelling. + /// + /// For 7-note scales (major, natural minor, harmonic minor, melodic minor, modes), + /// each letter A-G should appear exactly once. + /// For other scales, no letter should repeat unless it's a chromatic/octatonic/blues exception. + /// Blues scale duplicates the 4th degree letter (e.g., F and F# in C blues). + /// + /// This is a test-only helper and is only compiled when running tests. + pub(crate) fn validate_spelling(&self) -> Result<(), String> { + use std::collections::HashMap; + use crate::core::named_pitch::{HasLetter, HasNamedPitch}; + + let notes = self.notes(); + let intervals_count = self.intervals().len(); + + // For chromatic scale (12 notes), octatonic (8 notes), and blues (6 notes with ♯4 duplicating 4th degree), we allow letter repeats + if intervals_count == 12 || intervals_count == 8 || self.kind() == ScaleKind::Blues { + return Ok(()); + } + + // Check for letter uniqueness + let mut letter_counts: HashMap<&str, usize> = HashMap::new(); + for note in ¬es { + let letter = note.named_pitch().letter(); + *letter_counts.entry(letter).or_insert(0) += 1; + } + + // For 7-note collections, we expect exactly one of each letter + if intervals_count == 7 { + if letter_counts.len() != 7 { + return Err(format!( + "{} {} has {} unique letters, expected 7. Letters: {:?}", + self.root().static_name(), + self.kind().static_name(), + letter_counts.len(), + notes.iter().map(|n| n.static_name()).collect::>() + )); + } + + for (letter, count) in &letter_counts { + if *count != 1 { + return Err(format!( + "{} {} has letter {} appearing {} times, expected 1. Notes: {:?}", + self.root().static_name(), + self.kind().static_name(), + letter, + count, + notes.iter().map(|n| n.static_name()).collect::>() + )); + } + } + } else { + // For non-7-note collections (pentatonic, whole tone), just check no duplicates + for (letter, count) in &letter_counts { + if *count > 1 { + return Err(format!( + "{} {} has letter {} appearing {} times. Notes: {:?}", + self.root().static_name(), + self.kind().static_name(), + letter, + count, + notes.iter().map(|n| n.static_name()).collect::>() + )); + } + } + } + + Ok(()) + } + } + + #[test] + fn test_scale_creation() { + let scale = Scale::new(C, ScaleKind::Major); + assert_eq!(scale.root(), C); + assert_eq!(scale.kind(), ScaleKind::Major); + } + + #[test] + fn test_scale_intervals() { + let scale = Scale::new(C, ScaleKind::Major); + assert_eq!(scale.intervals().len(), 7); + assert_eq!( + scale.intervals(), + &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MajorThird, + Interval::PerfectFourth, + Interval::PerfectFifth, + Interval::MajorSixth, + Interval::MajorSeventh, + ] + ); + } + + #[test] + fn test_scale_notes() { + // C major scale + let scale = Scale::new(C, ScaleKind::Major); + assert_eq!(scale.notes(), vec![C, D, E, F, G, A, B]); + + // D major scale + let scale = Scale::new(D, ScaleKind::Major); + assert_eq!(scale.notes(), vec![D, E, FSharp, G, A, B, CSharpFive]); + + // A natural minor scale + let scale = Scale::new(A, ScaleKind::NaturalMinor); + assert_eq!(scale.notes(), vec![A, B, CFive, DFive, EFive, FFive, GFive]); + + // A harmonic minor scale + let scale = Scale::new(A, ScaleKind::HarmonicMinor); + assert_eq!(scale.notes(), vec![A, B, CFive, DFive, EFive, FFive, GSharpFive]); + + // A melodic minor scale + let scale = Scale::new(A, ScaleKind::MelodicMinor); + assert_eq!(scale.notes(), vec![A, B, CFive, DFive, EFive, FSharpFive, GSharpFive]); + + // C whole tone scale + let scale = Scale::new(C, ScaleKind::WholeTone); + assert_eq!(scale.notes(), vec![C, D, E, FSharp, GSharp, ASharp]); + + // C chromatic scale + let scale = Scale::new(C, ScaleKind::Chromatic); + assert_eq!(scale.notes().len(), 12); + } + + #[test] + fn test_scale_names() { + let scale = Scale::new(C, ScaleKind::Major); + assert_eq!(scale.name(), "C major"); + assert_eq!(scale.static_name(), "major"); + + let scale = Scale::new(DFlat, ScaleKind::HarmonicMinor); + assert_eq!(scale.name(), "D♭ harmonic minor"); + + let scale = Scale::new(FSharp, ScaleKind::WholeTone); + assert_eq!(scale.name(), "F♯ whole tone"); + } + + #[test] + fn test_scale_display() { + let scale = Scale::new(C, ScaleKind::Major); + let display = format!("{}", scale); + assert!(display.contains("C major")); + assert!(display.contains("C, D, E, F, G, A, B")); + } + + #[test] + fn test_different_roots() { + // G major has one sharp (F#) + let scale = Scale::new(G, ScaleKind::Major); + assert_eq!(scale.notes(), vec![G, A, B, CFive, DFive, EFive, FSharpFive]); + + // F major has one flat (Bb) + let scale = Scale::new(F, ScaleKind::Major); + assert_eq!(scale.notes(), vec![F, G, A, BFlat, CFive, DFive, EFive]); + + // E natural minor + let scale = Scale::new(E, ScaleKind::NaturalMinor); + assert_eq!(scale.notes(), vec![E, FSharp, G, A, B, CFive, DFive]); + } + + #[test] + fn test_scale_parse() { + // Test parsing various scales + let scale = Scale::parse("C major").unwrap(); + assert_eq!(scale.root(), C); + assert_eq!(scale.kind(), ScaleKind::Major); + + let scale = Scale::parse("A natural minor").unwrap(); + assert_eq!(scale.root(), A); + assert_eq!(scale.kind(), ScaleKind::NaturalMinor); + + let scale = Scale::parse("A naturalminor").unwrap(); + assert_eq!(scale.root(), A); + assert_eq!(scale.kind(), ScaleKind::NaturalMinor); + + let scale = Scale::parse("A harmonic minor").unwrap(); + assert_eq!(scale.root(), A); + assert_eq!(scale.kind(), ScaleKind::HarmonicMinor); + + let scale = Scale::parse("A melodic minor").unwrap(); + assert_eq!(scale.root(), A); + assert_eq!(scale.kind(), ScaleKind::MelodicMinor); + + let scale = Scale::parse("C whole tone").unwrap(); + assert_eq!(scale.root(), C); + assert_eq!(scale.kind(), ScaleKind::WholeTone); + + let scale = Scale::parse("C chromatic").unwrap(); + assert_eq!(scale.root(), C); + assert_eq!(scale.kind(), ScaleKind::Chromatic); + + // Test with accidentals + let scale = Scale::parse("F# major").unwrap(); + assert_eq!(scale.root(), FSharp); + assert_eq!(scale.kind(), ScaleKind::Major); + + let scale = Scale::parse("Bb harmonic minor").unwrap(); + assert_eq!(scale.root(), BFlat); + assert_eq!(scale.kind(), ScaleKind::HarmonicMinor); + + // Test pentatonic scales + let scale = Scale::parse("C major pentatonic").unwrap(); + assert_eq!(scale.root(), C); + assert_eq!(scale.kind(), ScaleKind::MajorPentatonic); + + let scale = Scale::parse("C majorpentatonic").unwrap(); + assert_eq!(scale.root(), C); + assert_eq!(scale.kind(), ScaleKind::MajorPentatonic); + + let scale = Scale::parse("A minor pentatonic").unwrap(); + assert_eq!(scale.root(), A); + assert_eq!(scale.kind(), ScaleKind::MinorPentatonic); + + let scale = Scale::parse("A minorpentatonic").unwrap(); + assert_eq!(scale.root(), A); + assert_eq!(scale.kind(), ScaleKind::MinorPentatonic); + + // Test blues scale + let scale = Scale::parse("C blues").unwrap(); + assert_eq!(scale.root(), C); + assert_eq!(scale.kind(), ScaleKind::Blues); + + let scale = Scale::parse("E blues").unwrap(); + assert_eq!(scale.root(), E); + assert_eq!(scale.kind(), ScaleKind::Blues); + } + + #[test] + fn test_enharmonic_spelling_major_scales() { + // Test major scales with various roots to ensure correct enharmonic spelling + + // C Major - all natural notes + let scale = Scale::new(C, ScaleKind::Major); + scale.validate_spelling().unwrap(); + + // G Major - should be G A B C D E F# + let scale = Scale::new(G, ScaleKind::Major); + scale.validate_spelling().unwrap(); + let notes = scale.notes(); + assert_eq!(notes.len(), 7); + + // Db Major - should use flats: Db Eb F Gb Ab Bb C + let scale = Scale::new(DFlat, ScaleKind::Major); + scale.validate_spelling().unwrap(); + let notes = scale.notes(); + assert_eq!(notes[0].named_pitch().letter(), "D"); + assert_eq!(notes[1].named_pitch().letter(), "E"); + assert_eq!(notes[2].named_pitch().letter(), "F"); + assert_eq!(notes[3].named_pitch().letter(), "G"); + assert_eq!(notes[4].named_pitch().letter(), "A"); + assert_eq!(notes[5].named_pitch().letter(), "B"); + assert_eq!(notes[6].named_pitch().letter(), "C"); + + // F# Major - should use sharps: F# G# A# B C# D# E# + let scale = Scale::new(FSharp, ScaleKind::Major); + scale.validate_spelling().unwrap(); + let notes = scale.notes(); + assert_eq!(notes[0].named_pitch().letter(), "F"); + assert_eq!(notes[1].named_pitch().letter(), "G"); + assert_eq!(notes[2].named_pitch().letter(), "A"); + assert_eq!(notes[3].named_pitch().letter(), "B"); + assert_eq!(notes[4].named_pitch().letter(), "C"); + assert_eq!(notes[5].named_pitch().letter(), "D"); + assert_eq!(notes[6].named_pitch().letter(), "E"); + } + + #[test] + fn test_enharmonic_spelling_minor_scales() { + // Test minor scales + + // A Natural Minor + let scale = Scale::new(A, ScaleKind::NaturalMinor); + scale.validate_spelling().unwrap(); + + // A Harmonic Minor - A B C D E F G# + let scale = Scale::new(A, ScaleKind::HarmonicMinor); + scale.validate_spelling().unwrap(); + let notes = scale.notes(); + assert_eq!(notes[6].named_pitch().letter(), "G"); // Should be G#, not Ab + + // C# Harmonic Minor + let scale = Scale::new(CSharp, ScaleKind::HarmonicMinor); + scale.validate_spelling().unwrap(); + + // F Melodic Minor + let scale = Scale::new(F, ScaleKind::MelodicMinor); + scale.validate_spelling().unwrap(); + } + + #[test] + fn test_enharmonic_spelling_pentatonic_scales() { + // Test pentatonic scales (5 notes, no letter repeats) + + // C Major Pentatonic - C D E G A + let scale = Scale::new(C, ScaleKind::MajorPentatonic); + scale.validate_spelling().unwrap(); + let notes = scale.notes(); + assert_eq!(notes.len(), 5); + + // A Minor Pentatonic - A C D E G + let scale = Scale::new(A, ScaleKind::MinorPentatonic); + scale.validate_spelling().unwrap(); + let notes = scale.notes(); + assert_eq!(notes.len(), 5); + + // F# Major Pentatonic + let scale = Scale::new(FSharp, ScaleKind::MajorPentatonic); + scale.validate_spelling().unwrap(); + + // Bb Minor Pentatonic + let scale = Scale::new(BFlat, ScaleKind::MinorPentatonic); + scale.validate_spelling().unwrap(); + } + + #[test] + fn test_enharmonic_spelling_blues_scale() { + // Test blues scale (6 notes, allows letter duplication on 4th degree) + + // C Blues - C Eb F F# G Bb (F and F# both present) + let scale = Scale::new(C, ScaleKind::Blues); + scale.validate_spelling().unwrap(); + let notes = scale.notes(); + assert_eq!(notes.len(), 6); + + // E Blues + let scale = Scale::new(E, ScaleKind::Blues); + scale.validate_spelling().unwrap(); + + // G Blues + let scale = Scale::new(G, ScaleKind::Blues); + scale.validate_spelling().unwrap(); + } + + #[test] + fn test_enharmonic_spelling_whole_tone() { + // Test whole tone scale (6 notes, no letter repeats) + + // C Whole Tone - C D E F# G# A# + let scale = Scale::new(C, ScaleKind::WholeTone); + scale.validate_spelling().unwrap(); + let notes = scale.notes(); + assert_eq!(notes.len(), 6); + + // Db Whole Tone + let scale = Scale::new(DFlat, ScaleKind::WholeTone); + scale.validate_spelling().unwrap(); + } + + #[test] + fn test_enharmonic_spelling_diminished() { + // Diminished scales are octatonic (8 notes), so we allow letter repeats + + // C Diminished Whole-Half + let scale = Scale::new(C, ScaleKind::DiminishedWholeHalf); + scale.validate_spelling().unwrap(); // Should pass even with repeats + let notes = scale.notes(); + assert_eq!(notes.len(), 8); + + // C Diminished Half-Whole + let scale = Scale::new(C, ScaleKind::DiminishedHalfWhole); + scale.validate_spelling().unwrap(); // Should pass even with repeats + let notes = scale.notes(); + assert_eq!(notes.len(), 8); + } + + #[test] + fn test_enharmonic_spelling_chromatic() { + // Chromatic scale (12 notes), we allow letter repeats + + // C Chromatic + let scale = Scale::new(C, ScaleKind::Chromatic); + scale.validate_spelling().unwrap(); // Should pass even with repeats + let notes = scale.notes(); + assert_eq!(notes.len(), 12); + } + + #[test] + fn test_enharmonic_spelling_all_roots() { + // Test all scales with multiple root notes to ensure consistency + for root in [C, CSharp, D, DFlat, E, F, FSharp, G, GFlat, A, AFlat, B, BFlat] { + // Major + let scale = Scale::new(root, ScaleKind::Major); + scale.validate_spelling().unwrap_or_else(|e| panic!("Major spelling failed for {}: {}", root.static_name(), e)); + + // Natural Minor + let scale = Scale::new(root, ScaleKind::NaturalMinor); + scale.validate_spelling().unwrap_or_else(|e| panic!("Natural Minor spelling failed for {}: {}", root.static_name(), e)); + + // Harmonic Minor + let scale = Scale::new(root, ScaleKind::HarmonicMinor); + scale.validate_spelling().unwrap_or_else(|e| panic!("Harmonic Minor spelling failed for {}: {}", root.static_name(), e)); + + // Major Pentatonic + let scale = Scale::new(root, ScaleKind::MajorPentatonic); + scale.validate_spelling().unwrap_or_else(|e| panic!("Major Pentatonic spelling failed for {}: {}", root.static_name(), e)); + + // Blues + let scale = Scale::new(root, ScaleKind::Blues); + scale.validate_spelling().unwrap_or_else(|e| panic!("Blues spelling failed for {}: {}", root.static_name(), e)); + } + } + + #[test] + fn test_heptatonic_spelling() { + let scale = Scale::new(DFlat, ScaleKind::Major); + assert_eq!( + scale.notes(), + vec![DFlat, EFlat, F, GFlat, AFlat, BFlat, CFive], + "Db major scale spelling incorrect" + ); + scale.validate_spelling().unwrap(); + + let scale = Scale::new(CSharp, ScaleKind::Major); + assert_eq!( + scale.notes(), + vec![CSharp, DSharp, ESharp, FSharp, GSharp, ASharp, BSharp], + "C# major scale spelling incorrect - should use sharps consistently" + ); + scale.validate_spelling().unwrap(); + } + + #[test] + fn test_whole_tone_spelling() { + let scale = Scale::new(A, ScaleKind::WholeTone); + assert_eq!( + scale.intervals(), + &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MajorThird, + Interval::AugmentedFourth, + Interval::AugmentedFifth, + Interval::AugmentedSixth, + ], + "Whole tone scale intervals should use augmented intervals, not respelled for prettiness" + ); + + let notes = scale.notes(); + assert_eq!(notes.len(), 6, "Whole tone scale should have 6 notes"); + scale.validate_spelling().unwrap(); + + let scale = Scale::new(FSharp, ScaleKind::WholeTone); + let notes = scale.notes(); + assert_eq!(notes.len(), 6, "F# whole tone scale should have 6 notes"); + scale.validate_spelling().unwrap(); + } + + #[test] + fn test_octatonic_spelling() { + let scale = Scale::new(A, ScaleKind::DiminishedHalfWhole); + let notes = scale.notes(); + assert_eq!( + scale.intervals(), + &[ + Interval::PerfectUnison, + Interval::MinorSecond, + Interval::MinorThird, + Interval::MajorThird, + Interval::AugmentedFourth, + Interval::PerfectFifth, + Interval::MajorSixth, + Interval::MinorSeventh, + ] + ); + assert_eq!(notes.len(), 8, "Diminished half-whole should have 8 notes"); + + let scale = Scale::new(A, ScaleKind::DiminishedWholeHalf); + let notes = scale.notes(); + assert_eq!( + scale.intervals(), + &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MinorThird, + Interval::PerfectFourth, + Interval::DiminishedFifth, + Interval::MinorSixth, + Interval::DiminishedSeventh, + Interval::MajorSeventh, + ] + ); + assert_eq!(notes.len(), 8, "Diminished whole-half should have 8 notes"); + } + + #[test] + fn test_pentatonic_blues_spelling() { + let scale = Scale::new(DFlat, ScaleKind::MajorPentatonic); + assert_eq!( + scale.notes(), + vec![DFlat, EFlat, F, AFlat, BFlat], + "Db major pentatonic spelling incorrect" + ); + scale.validate_spelling().unwrap(); + + let scale = Scale::new(A, ScaleKind::MinorPentatonic); + assert_eq!( + scale.notes(), + vec![A, CFive, DFive, EFive, GFive], + "A minor pentatonic spelling incorrect" + ); + scale.validate_spelling().unwrap(); + + let scale = Scale::new(FSharp, ScaleKind::Blues); + let notes = scale.notes(); + assert_eq!( + notes, + vec![FSharp, A, B, BSharp, CSharpFive, EFive], + "F# blues scale spelling incorrect - should use B# (augmented 4th), not C (diminished 5th)" + ); + assert_eq!( + scale.intervals(), + &[ + Interval::PerfectUnison, + Interval::MinorThird, + Interval::PerfectFourth, + Interval::AugmentedFourth, + Interval::PerfectFifth, + Interval::MinorSeventh, + ] + ); + scale.validate_spelling().unwrap(); + } +} diff --git a/kord/src/core/scale_kind.rs b/kord/src/core/scale_kind.rs new file mode 100644 index 0000000..17d121e --- /dev/null +++ b/kord/src/core/scale_kind.rs @@ -0,0 +1,329 @@ +//! A module for working with scale kinds. + +use crate::core::{ + base::{HasDescription, HasName, HasStaticName}, + interval::{HasIntervals, Interval}, +}; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +// Enum. + +/// An enum representing a scale kind (type of scale). +/// +/// Each scale kind has an explicit list of intervals that define the scale. +#[derive(PartialEq, Eq, Copy, Clone, Hash, Debug, Ord, PartialOrd)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[repr(u8)] +pub enum ScaleKind { + /// A major scale (Ionian mode root scale). + Major, + /// A natural minor scale (Aeolian mode root scale). + NaturalMinor, + /// A harmonic minor scale. + HarmonicMinor, + /// A melodic minor scale (ascending). + MelodicMinor, + /// A whole tone scale. + WholeTone, + /// A chromatic scale (all 12 semitones). + Chromatic, + /// A diminished (whole-half) scale. + DiminishedWholeHalf, + /// A diminished (half-whole) scale. + DiminishedHalfWhole, + /// A major pentatonic scale. + MajorPentatonic, + /// A minor pentatonic scale. + MinorPentatonic, + /// A blues scale. + Blues, +} + +// Impls. + +impl HasIntervals for ScaleKind { + fn intervals(&self) -> &'static [Interval] { + match self { + ScaleKind::Major => &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MajorThird, + Interval::PerfectFourth, + Interval::PerfectFifth, + Interval::MajorSixth, + Interval::MajorSeventh, + ], + ScaleKind::NaturalMinor => &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MinorThird, + Interval::PerfectFourth, + Interval::PerfectFifth, + Interval::MinorSixth, + Interval::MinorSeventh, + ], + ScaleKind::HarmonicMinor => &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MinorThird, + Interval::PerfectFourth, + Interval::PerfectFifth, + Interval::MinorSixth, + Interval::MajorSeventh, + ], + ScaleKind::MelodicMinor => &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MinorThird, + Interval::PerfectFourth, + Interval::PerfectFifth, + Interval::MajorSixth, + Interval::MajorSeventh, + ], + ScaleKind::WholeTone => &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MajorThird, + Interval::AugmentedFourth, + Interval::AugmentedFifth, + Interval::AugmentedSixth, + ], + ScaleKind::Chromatic => &[ + Interval::PerfectUnison, + Interval::MinorSecond, + Interval::MajorSecond, + Interval::MinorThird, + Interval::MajorThird, + Interval::PerfectFourth, + Interval::AugmentedFourth, + Interval::PerfectFifth, + Interval::MinorSixth, + Interval::MajorSixth, + Interval::MinorSeventh, + Interval::MajorSeventh, + ], + ScaleKind::DiminishedWholeHalf => &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MinorThird, + Interval::PerfectFourth, + Interval::DiminishedFifth, + Interval::MinorSixth, + Interval::DiminishedSeventh, + Interval::MajorSeventh, + ], + ScaleKind::DiminishedHalfWhole => &[ + Interval::PerfectUnison, + Interval::MinorSecond, + Interval::MinorThird, + Interval::MajorThird, + Interval::AugmentedFourth, + Interval::PerfectFifth, + Interval::MajorSixth, + Interval::MinorSeventh, + ], + // Major Pentatonic: 1, 2, 3, 5, 6 (no 4th or 7th) + ScaleKind::MajorPentatonic => &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MajorThird, + Interval::PerfectFifth, + Interval::MajorSixth, + ], + // Minor Pentatonic: 1, ♭3, 4, 5, ♭7 (no 2nd or 6th) + ScaleKind::MinorPentatonic => &[ + Interval::PerfectUnison, + Interval::MinorThird, + Interval::PerfectFourth, + Interval::PerfectFifth, + Interval::MinorSeventh, + ], + // Blues: 1, ♭3, 4, ♯4, 5, ♭7 (minor pentatonic + ♯4) + ScaleKind::Blues => &[ + Interval::PerfectUnison, + Interval::MinorThird, + Interval::PerfectFourth, + Interval::AugmentedFourth, // #4 blue note – chromatic passing tone between the 4th and 5th + Interval::PerfectFifth, + Interval::MinorSeventh, + ], + } + } +} + +impl HasDescription for ScaleKind { + fn description(&self) -> &'static str { + match self { + ScaleKind::Major => "major scale, ionian mode parent", + ScaleKind::NaturalMinor => "natural minor scale, aeolian mode parent", + ScaleKind::HarmonicMinor => "harmonic minor scale, raised seventh degree", + ScaleKind::MelodicMinor => "melodic minor scale, raised sixth and seventh degrees", + ScaleKind::WholeTone => "whole tone scale, all whole steps", + ScaleKind::Chromatic => "chromatic scale, all twelve semitones", + ScaleKind::DiminishedWholeHalf => "diminished scale, whole-half (W-H) pattern, fully diminished 7th chord parent", + ScaleKind::DiminishedHalfWhole => "diminished scale, half-whole (H-W) pattern, dominant 7♭9 (flat 9) chord parent", + ScaleKind::MajorPentatonic => "major pentatonic scale, five-note major scale without 4th and 7th", + ScaleKind::MinorPentatonic => "minor pentatonic scale, five-note minor scale without 2nd and 6th", + ScaleKind::Blues => "blues scale, minor pentatonic with added ♯4 (blue note)", + } + } +} + +impl HasStaticName for ScaleKind { + fn static_name(&self) -> &'static str { + match self { + ScaleKind::Major => "major", + ScaleKind::NaturalMinor => "natural minor", + ScaleKind::HarmonicMinor => "harmonic minor", + ScaleKind::MelodicMinor => "melodic minor", + ScaleKind::WholeTone => "whole tone", + ScaleKind::Chromatic => "chromatic", + ScaleKind::DiminishedWholeHalf => "diminished (whole-half)", + ScaleKind::DiminishedHalfWhole => "diminished (half-whole)", + ScaleKind::MajorPentatonic => "major pentatonic", + ScaleKind::MinorPentatonic => "minor pentatonic", + ScaleKind::Blues => "blues", + } + } +} + +impl HasName for ScaleKind { + fn name(&self) -> String { + self.static_name().to_owned() + } +} + +impl ScaleKind { + /// Returns the ASCII name of the scale (using 'b', '#', 'nat' instead of Unicode symbols). + pub fn ascii_name(&self) -> &'static str { + match self { + ScaleKind::Major => "major", + ScaleKind::NaturalMinor => "natural minor", + ScaleKind::HarmonicMinor => "harmonic minor", + ScaleKind::MelodicMinor => "melodic minor", + ScaleKind::WholeTone => "whole tone", + ScaleKind::Chromatic => "chromatic", + ScaleKind::DiminishedWholeHalf => "diminished whole-half", + ScaleKind::DiminishedHalfWhole => "diminished half-whole", + ScaleKind::MajorPentatonic => "major pentatonic", + ScaleKind::MinorPentatonic => "minor pentatonic", + ScaleKind::Blues => "blues", + } + } +} + +// Tests. + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_scale_intervals() { + // Major scale: W-W-H-W-W-W-H + assert_eq!(ScaleKind::Major.intervals().len(), 7); + assert_eq!( + ScaleKind::Major.intervals(), + &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MajorThird, + Interval::PerfectFourth, + Interval::PerfectFifth, + Interval::MajorSixth, + Interval::MajorSeventh, + ] + ); + + // Natural minor: W-H-W-W-H-W-W + assert_eq!(ScaleKind::NaturalMinor.intervals().len(), 7); + assert_eq!( + ScaleKind::NaturalMinor.intervals(), + &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MinorThird, + Interval::PerfectFourth, + Interval::PerfectFifth, + Interval::MinorSixth, + Interval::MinorSeventh, + ] + ); + + // Harmonic minor: W-H-W-W-H-W+H-H + assert_eq!(ScaleKind::HarmonicMinor.intervals().len(), 7); + + // Whole tone: W-W-W-W-W-W + assert_eq!(ScaleKind::WholeTone.intervals().len(), 6); + + // Chromatic: all 12 semitones + assert_eq!(ScaleKind::Chromatic.intervals().len(), 12); + + // Diminished scales: 8 notes + assert_eq!(ScaleKind::DiminishedWholeHalf.intervals().len(), 8); + assert_eq!(ScaleKind::DiminishedHalfWhole.intervals().len(), 8); + + // Pentatonic scales: 5 notes + assert_eq!(ScaleKind::MajorPentatonic.intervals().len(), 5); + assert_eq!( + ScaleKind::MajorPentatonic.intervals(), + &[ + Interval::PerfectUnison, + Interval::MajorSecond, + Interval::MajorThird, + Interval::PerfectFifth, + Interval::MajorSixth, + ] + ); + + assert_eq!(ScaleKind::MinorPentatonic.intervals().len(), 5); + assert_eq!( + ScaleKind::MinorPentatonic.intervals(), + &[ + Interval::PerfectUnison, + Interval::MinorThird, + Interval::PerfectFourth, + Interval::PerfectFifth, + Interval::MinorSeventh, + ] + ); + + // Blues scale: 6 notes (minor pentatonic + ♯4) + assert_eq!(ScaleKind::Blues.intervals().len(), 6); + assert_eq!( + ScaleKind::Blues.intervals(), + &[ + Interval::PerfectUnison, + Interval::MinorThird, + Interval::PerfectFourth, + Interval::AugmentedFourth, + Interval::PerfectFifth, + Interval::MinorSeventh, + ] + ); + } + + #[test] + fn test_scale_names() { + assert_eq!(ScaleKind::Major.static_name(), "major"); + assert_eq!(ScaleKind::NaturalMinor.static_name(), "natural minor"); + assert_eq!(ScaleKind::HarmonicMinor.static_name(), "harmonic minor"); + assert_eq!(ScaleKind::MelodicMinor.static_name(), "melodic minor"); + assert_eq!(ScaleKind::WholeTone.static_name(), "whole tone"); + assert_eq!(ScaleKind::MajorPentatonic.static_name(), "major pentatonic"); + assert_eq!(ScaleKind::MinorPentatonic.static_name(), "minor pentatonic"); + assert_eq!(ScaleKind::Blues.static_name(), "blues"); + } + + #[test] + fn test_scale_descriptions() { + assert_eq!(ScaleKind::Major.description(), "major scale, ionian mode parent"); + assert_eq!(ScaleKind::NaturalMinor.description(), "natural minor scale, aeolian mode parent"); + assert_eq!(ScaleKind::MajorPentatonic.description(), "major pentatonic scale, five-note major scale without 4th and 7th"); + assert_eq!(ScaleKind::MinorPentatonic.description(), "minor pentatonic scale, five-note minor scale without 2nd and 6th"); + assert_eq!(ScaleKind::Blues.description(), "blues scale, minor pentatonic with added ♯4 (blue note)"); + } +} diff --git a/kord/src/lib.rs b/kord/src/lib.rs index e0606ca..ddc070c 100644 --- a/kord/src/lib.rs +++ b/kord/src/lib.rs @@ -37,6 +37,25 @@ //! vec![C, E, GSharp, B] //! ); //! ``` +//! +//! # Scales and Modes +//! +//! ``` +//! use klib::core::base::HasName; +//! use klib::core::note::*; +//! use klib::core::mode::*; +//! use klib::core::mode_kind::*; +//! use klib::core::scale::*; +//! use klib::core::scale_kind::*; +//! +//! // Create a D Dorian mode +//! let mode = Mode::new(D, ModeKind::Dorian); +//! assert_eq!(mode.name(), "D dorian"); +//! +//! // Create an A harmonic minor scale +//! let scale = Scale::new(A, ScaleKind::HarmonicMinor); +//! assert_eq!(scale.name(), "A harmonic minor"); +//! ``` #![warn(rustdoc::broken_intra_doc_links, rust_2018_idioms, clippy::all, missing_docs)] #![allow(incomplete_features)] diff --git a/kord/src/wasm.rs b/kord/src/wasm.rs index a46e7fe..3498f01 100644 --- a/kord/src/wasm.rs +++ b/kord/src/wasm.rs @@ -6,15 +6,21 @@ use js_sys::{Array, Object, Reflect}; use wasm_bindgen::{convert::RefFromWasmAbi, prelude::*}; use crate::core::{ - base::{HasDescription, HasName, HasPreciseName, HasStaticName, Parsable, PlaybackHandle, Res}, + base::{HasDescription, HasName, HasPreciseName, HasStaticName, Parsable, Res}, chord::{Chord, Chordable, HasChord, HasExtensions, HasInversion, HasIsCrunchy, HasModifiers, HasRoot, HasScale, HasSlash}, interval::Interval, + known_chord::{HasScaleCandidates, ScaleCandidate}, + mode::Mode, named_pitch::HasNamedPitch, note::{HasPrimaryHarmonicSeries, Note}, octave::{HasOctave, Octave}, pitch::HasFrequency, + scale::Scale, }; +#[cfg(feature = "audio")] +use crate::core::base::PlaybackHandle; + // Use `wee_alloc` as the global allocator. #[global_allocator] static ALLOC: wee_alloc::WeeAlloc<'_> = wee_alloc::WeeAlloc::INIT; @@ -140,6 +146,242 @@ impl KordNote { } } +// [`Mode`] ABI. + +/// The [`Mode`] wrapper. +#[derive(Clone, Debug)] +#[wasm_bindgen] +pub struct KordMode { + inner: Mode, +} + +impl From for KordMode { + fn from(mode: Mode) -> Self { + KordMode { inner: mode } + } +} + +impl From for Mode { + fn from(kord_mode: KordMode) -> Self { + kord_mode.inner + } +} + +/// The [`Mode`] impl. +#[wasm_bindgen] +impl KordMode { + /// Creates a new [`Mode`] by parsing a string (e.g., "C dorian", "D lydian dominant"). + #[wasm_bindgen] + pub fn parse(name: String) -> JsRes { + Ok(Self { inner: Mode::parse(&name).to_js_error()? }) + } + + /// Returns the [`Mode`]'s friendly name. + #[wasm_bindgen] + pub fn name(&self) -> String { + self.inner.name() + } + + /// Returns the [`Mode`]'s precise name. + #[wasm_bindgen(js_name = preciseName)] + pub fn precise_name(&self) -> String { + self.inner.precise_name() + } + + /// Returns the [`Mode`] as a string (same as `precise_name`). + #[allow(clippy::inherent_to_string)] + #[wasm_bindgen(js_name = toString)] + pub fn to_string(&self) -> String { + self.inner.precise_name() + } + + /// Returns the [`Mode`]'s description. + #[wasm_bindgen] + pub fn description(&self) -> String { + self.inner.description().to_string() + } + + /// Returns the [`Mode`]'s root note. + #[wasm_bindgen] + pub fn root(&self) -> String { + self.inner.root().name() + } + + /// Returns the [`Mode`]'s notes. + #[wasm_bindgen] + pub fn notes(&self) -> Array { + self.inner.notes().into_iter().map(KordNote::from).into_js_array() + } + + /// Returns the [`Mode`]'s notes as a string. + #[wasm_bindgen(js_name = notesString)] + pub fn notes_string(&self) -> String { + self.inner.notes().iter().map(|n| n.name()).collect::>().join(" ") + } + + /// Returns the clone of the [`Mode`]. + #[wasm_bindgen] + pub fn copy(&self) -> KordMode { + self.clone() + } +} + +// [`Scale`] ABI. + +/// The [`Scale`] wrapper. +#[derive(Clone, Debug)] +#[wasm_bindgen] +pub struct KordScale { + inner: Scale, +} + +impl From for KordScale { + fn from(scale: Scale) -> Self { + KordScale { inner: scale } + } +} + +impl From for Scale { + fn from(kord_scale: KordScale) -> Self { + kord_scale.inner + } +} + +/// The [`Scale`] impl. +#[wasm_bindgen] +impl KordScale { + /// Creates a new [`Scale`] by parsing a string (e.g., "C major", "A harmonic minor", "E blues"). + #[wasm_bindgen] + pub fn parse(name: String) -> JsRes { + Ok(Self { inner: Scale::parse(&name).to_js_error()? }) + } + + /// Returns the [`Scale`]'s friendly name. + #[wasm_bindgen] + pub fn name(&self) -> String { + self.inner.name() + } + + /// Returns the [`Scale`]'s precise name. + #[wasm_bindgen(js_name = preciseName)] + pub fn precise_name(&self) -> String { + self.inner.precise_name() + } + + /// Returns the [`Scale`] as a string (same as `precise_name`). + #[allow(clippy::inherent_to_string)] + #[wasm_bindgen(js_name = toString)] + pub fn to_string(&self) -> String { + self.inner.precise_name() + } + + /// Returns the [`Scale`]'s description. + #[wasm_bindgen] + pub fn description(&self) -> String { + self.inner.description().to_string() + } + + /// Returns the [`Scale`]'s root note. + #[wasm_bindgen] + pub fn root(&self) -> String { + self.inner.root().name() + } + + /// Returns the [`Scale`]'s notes. + #[wasm_bindgen] + pub fn notes(&self) -> Array { + self.inner.notes().into_iter().map(KordNote::from).into_js_array() + } + + /// Returns the [`Scale`]'s notes as a string. + #[wasm_bindgen(js_name = notesString)] + pub fn notes_string(&self) -> String { + self.inner.notes().iter().map(|n| n.name()).collect::>().join(" ") + } + + /// Returns the clone of the [`Scale`]. + #[wasm_bindgen] + pub fn copy(&self) -> KordScale { + self.clone() + } +} + +// [`ScaleCandidate`] ABI. + +/// The [`ScaleCandidate`] wrapper for WASM. +/// Represents a recommended scale or mode for a chord. +#[derive(Clone, Debug)] +#[wasm_bindgen] +pub struct KordScaleCandidate { + inner: ScaleCandidate, + root: Note, +} + +impl KordScaleCandidate { + /// Creates a new [`KordScaleCandidate`] from a [`ScaleCandidate`] and root note. + fn new(candidate: ScaleCandidate, root: Note) -> Self { + KordScaleCandidate { inner: candidate, root } + } +} + +/// The [`ScaleCandidate`] impl. +#[wasm_bindgen] +impl KordScaleCandidate { + /// Returns the candidate's rank (1 = most relevant). + #[wasm_bindgen] + pub fn rank(&self) -> u8 { + self.inner.rank() + } + + /// Returns the reason why this scale/mode fits the chord. + #[wasm_bindgen] + pub fn reason(&self) -> String { + self.inner.reason().to_string() + } + + /// Returns the name of the scale or mode. + #[wasm_bindgen] + pub fn name(&self) -> String { + self.inner.name() + } + + /// Returns the description of the scale or mode. + #[wasm_bindgen] + pub fn description(&self) -> String { + self.inner.description().to_string() + } + + /// Returns the notes of this scale/mode rooted at the chord's root. + #[wasm_bindgen] + pub fn notes(&self) -> Array { + self.inner.notes(self.root).into_iter().map(KordNote::from).into_js_array() + } + + /// Returns the notes as a space-separated string. + #[wasm_bindgen(js_name = notesString)] + pub fn notes_string(&self) -> String { + self.inner.notes(self.root).iter().map(|n| n.name()).collect::>().join(" ") + } + + /// Returns whether this is a mode candidate (vs. scale candidate). + #[wasm_bindgen(js_name = isMode)] + pub fn is_mode(&self) -> bool { + matches!(self.inner, ScaleCandidate::Mode { .. }) + } + + /// Returns whether this is a scale candidate (vs. mode candidate). + #[wasm_bindgen(js_name = isScale)] + pub fn is_scale(&self) -> bool { + matches!(self.inner, ScaleCandidate::Scale { .. }) + } + + /// Returns the clone of the [`KordScaleCandidate`]. + #[wasm_bindgen] + pub fn copy(&self) -> KordScaleCandidate { + self.clone() + } +} + // [`Chord`] ABI. /// The [`Chord`] wrapper. @@ -274,6 +516,18 @@ impl KordChord { self.inner.scale().iter().map(|n| n.name()).collect::>().join(" ") } + /// Returns the recommended scale/mode candidates for this chord. + /// The candidates are ranked by relevance (rank 1 = most relevant). + #[wasm_bindgen(js_name = scaleCandidates)] + pub fn scale_candidates(&self) -> Array { + let root = self.inner.root(); + self.inner + .scale_candidates() + .into_iter() + .map(|candidate| KordScaleCandidate::new(candidate, root)) + .into_js_array() + } + /// Returns the [`Chord`]'s modifiers. #[wasm_bindgen] pub fn modifiers(&self) -> Array { @@ -350,6 +604,7 @@ impl KordChord { /// A handle to a [`Chord`] playback. /// /// Should be dropped to stop the playback, or after playback is finished. +#[cfg(feature = "audio")] #[wasm_bindgen] pub struct KordPlaybackHandle { _inner: PlaybackHandle, @@ -423,6 +678,7 @@ where } /// Helpers trait for converting an [`Array`] to a [`Vec`]. +#[allow(dead_code)] trait ClonedIntoVec { /// Converts the [`Array`] to a [`Vec`]. fn cloned_into_vec(self) -> JsRes> @@ -1457,4 +1713,394 @@ mod tests { let _original_notes = original.chord(); let _minor_notes = minor.chord(); } + + // KordMode tests + + #[wasm_bindgen_test] + fn test_mode_parse_simple() { + let mode = KordMode::parse("C dorian".to_string()); + assert!(mode.is_ok(), "Should parse simple mode"); + + let mode = mode.unwrap(); + assert_eq!(mode.root(), "C4", "Root should be C4"); + assert!(mode.name().contains("dorian"), "Name should contain dorian"); + } + + #[wasm_bindgen_test] + fn test_mode_parse_invalid() { + let mode = KordMode::parse("Invalid mode".to_string()); + assert!(mode.is_err(), "Should fail to parse invalid mode"); + } + + #[wasm_bindgen_test] + fn test_mode_parse_lydian_dominant() { + let mode = KordMode::parse("D lydian dominant".to_string()).unwrap(); + assert_eq!(mode.root(), "D4"); + assert!(mode.name().contains("lydian"), "Should contain lydian"); + } + + #[wasm_bindgen_test] + fn test_mode_parse_with_sharps_flats() { + let mode = KordMode::parse("F# phrygian".to_string()).unwrap(); + assert_eq!(mode.root(), "F♯4"); + + let mode = KordMode::parse("Bb locrian".to_string()).unwrap(); + assert_eq!(mode.root(), "B♭4"); + } + + #[wasm_bindgen_test] + fn test_mode_parse_altered() { + let mode = KordMode::parse("G altered".to_string()).unwrap(); + assert_eq!(mode.root(), "G4"); + assert!(mode.description().len() > 0, "Should have description"); + } + + #[wasm_bindgen_test] + fn test_mode_notes() { + let mode = KordMode::parse("C ionian".to_string()).unwrap(); + let notes = mode.notes(); + assert_eq!(notes.length(), 7, "Ionian mode should have 7 notes"); + } + + #[wasm_bindgen_test] + fn test_mode_notes_string() { + let mode = KordMode::parse("C dorian".to_string()).unwrap(); + let notes_str = mode.notes_string(); + assert!(notes_str.contains("C"), "Notes string should contain root"); + assert!(notes_str.len() > 0, "Notes string should not be empty"); + } + + #[wasm_bindgen_test] + fn test_mode_precise_name() { + let mode = KordMode::parse("C mixolydian".to_string()).unwrap(); + let precise = mode.precise_name(); + assert!(precise.len() > 0, "Should have precise name"); + } + + #[wasm_bindgen_test] + fn test_mode_to_string() { + let mode = KordMode::parse("D phrygian".to_string()).unwrap(); + let string = mode.to_string(); + assert!(string.len() > 0, "toString should return non-empty string"); + } + + #[wasm_bindgen_test] + fn test_mode_copy() { + let mode = KordMode::parse("E locrian".to_string()).unwrap(); + let copy = mode.copy(); + assert_eq!(copy.root(), mode.root()); + assert_eq!(copy.name(), mode.name()); + } + + #[wasm_bindgen_test] + fn test_mode_harmonic_minor_modes() { + // Test some harmonic minor modes + let locrian_nat6 = KordMode::parse("B locrian nat6".to_string()); + assert!(locrian_nat6.is_ok(), "Should parse locrian nat6"); + + let phrygian_dominant = KordMode::parse("E phrygian dominant".to_string()); + assert!(phrygian_dominant.is_ok(), "Should parse phrygian dominant"); + + let ionian_aug = KordMode::parse("C ionian augmented".to_string()); + assert!(ionian_aug.is_ok(), "Should parse ionian augmented"); + } + + #[wasm_bindgen_test] + fn test_mode_melodic_minor_modes() { + // Test some melodic minor modes + let dorian_flat2 = KordMode::parse("B dorian b2".to_string()); + assert!(dorian_flat2.is_ok(), "Should parse dorian flat 2"); + + let lydian_aug = KordMode::parse("C lydian augmented".to_string()); + assert!(lydian_aug.is_ok(), "Should parse lydian augmented"); + + let mixolydian_flat6 = KordMode::parse("G mixolydian b6".to_string()); + assert!(mixolydian_flat6.is_ok(), "Should parse mixolydian flat 6"); + } + + // KordScale tests + + #[wasm_bindgen_test] + fn test_scale_parse_major() { + let scale = KordScale::parse("C major".to_string()); + assert!(scale.is_ok(), "Should parse major scale"); + + let scale = scale.unwrap(); + assert_eq!(scale.root(), "C4"); + assert!(scale.name().contains("major"), "Name should contain major"); + } + + #[wasm_bindgen_test] + fn test_scale_parse_minor() { + let scale = KordScale::parse("A natural minor".to_string()).unwrap(); + assert_eq!(scale.root(), "A4"); + assert!(scale.name().contains("minor")); + } + + #[wasm_bindgen_test] + fn test_scale_parse_harmonic_minor() { + let scale = KordScale::parse("D harmonic minor".to_string()).unwrap(); + assert_eq!(scale.root(), "D4"); + assert!(scale.description().contains("harmonic"), "Should mention harmonic"); + } + + #[wasm_bindgen_test] + fn test_scale_parse_melodic_minor() { + let scale = KordScale::parse("G melodic minor".to_string()).unwrap(); + assert_eq!(scale.root(), "G4"); + assert!(scale.description().contains("melodic"), "Should mention melodic"); + } + + #[wasm_bindgen_test] + fn test_scale_parse_pentatonic() { + let major_pent = KordScale::parse("C major pentatonic".to_string()).unwrap(); + let notes = major_pent.notes(); + assert_eq!(notes.length(), 5, "Major pentatonic should have 5 notes"); + + let minor_pent = KordScale::parse("A minor pentatonic".to_string()).unwrap(); + let notes = minor_pent.notes(); + assert_eq!(notes.length(), 5, "Minor pentatonic should have 5 notes"); + } + + #[wasm_bindgen_test] + fn test_scale_parse_blues() { + let blues = KordScale::parse("E blues".to_string()).unwrap(); + assert_eq!(blues.root(), "E4"); + let notes = blues.notes(); + assert_eq!(notes.length(), 6, "Blues scale should have 6 notes"); + } + + #[wasm_bindgen_test] + fn test_scale_parse_whole_tone() { + let whole_tone = KordScale::parse("C whole tone".to_string()).unwrap(); + assert_eq!(whole_tone.root(), "C4"); + let notes = whole_tone.notes(); + assert_eq!(notes.length(), 6, "Whole tone should have 6 notes"); + } + + #[wasm_bindgen_test] + fn test_scale_parse_diminished() { + let dim_hw = KordScale::parse("C diminished half-whole".to_string()).unwrap(); + let notes = dim_hw.notes(); + assert_eq!(notes.length(), 8, "Diminished scale should have 8 notes"); + + let dim_wh = KordScale::parse("C diminished whole-half".to_string()).unwrap(); + let notes = dim_wh.notes(); + assert_eq!(notes.length(), 8, "Diminished scale should have 8 notes"); + } + + #[wasm_bindgen_test] + fn test_scale_parse_chromatic() { + let chromatic = KordScale::parse("C chromatic".to_string()).unwrap(); + assert_eq!(chromatic.root(), "C4"); + let notes = chromatic.notes(); + assert_eq!(notes.length(), 12, "Chromatic scale should have 12 notes"); + } + + #[wasm_bindgen_test] + fn test_scale_parse_invalid() { + let scale = KordScale::parse("Invalid scale".to_string()); + assert!(scale.is_err(), "Should fail to parse invalid scale"); + } + + #[wasm_bindgen_test] + fn test_scale_parse_with_sharps_flats() { + let scale = KordScale::parse("F# major".to_string()).unwrap(); + assert_eq!(scale.root(), "F♯4"); + + let scale = KordScale::parse("Bb major".to_string()).unwrap(); + assert_eq!(scale.root(), "B♭4"); + } + + #[wasm_bindgen_test] + fn test_scale_notes() { + let scale = KordScale::parse("C major".to_string()).unwrap(); + let notes = scale.notes(); + assert_eq!(notes.length(), 7, "Major scale should have 7 notes"); + + // Verify all elements are accessible + for i in 0..notes.length() { + let note = notes.get(i); + assert!(!note.is_undefined(), "Note {} should exist", i); + } + } + + #[wasm_bindgen_test] + fn test_scale_notes_string() { + let scale = KordScale::parse("G major".to_string()).unwrap(); + let notes_str = scale.notes_string(); + assert!(notes_str.contains("G"), "Notes string should contain root"); + assert!(notes_str.len() > 0, "Notes string should not be empty"); + } + + #[wasm_bindgen_test] + fn test_scale_precise_name() { + let scale = KordScale::parse("D major".to_string()).unwrap(); + let precise = scale.precise_name(); + assert!(precise.len() > 0, "Should have precise name"); + } + + #[wasm_bindgen_test] + fn test_scale_description() { + let scale = KordScale::parse("A harmonic minor".to_string()).unwrap(); + let description = scale.description(); + assert!(description.len() > 0, "Should have description"); + } + + #[wasm_bindgen_test] + fn test_scale_to_string() { + let scale = KordScale::parse("E melodic minor".to_string()).unwrap(); + let string = scale.to_string(); + assert!(string.len() > 0, "toString should return non-empty string"); + } + + #[wasm_bindgen_test] + fn test_scale_copy() { + let scale = KordScale::parse("B major".to_string()).unwrap(); + let copy = scale.copy(); + assert_eq!(copy.root(), scale.root()); + assert_eq!(copy.name(), scale.name()); + } + + // Integration tests for Mode and Scale with Chord + + #[wasm_bindgen_test] + fn test_chord_scale_candidates_with_modes() { + // Test that chord scale candidates work + let chord = KordChord::parse("Cmaj7".to_string()).unwrap(); + let scale_notes = chord.scale(); + assert!(scale_notes.length() >= 7, "Should have scale notes"); + } + + #[wasm_bindgen_test] + fn test_mode_scale_enharmonic_spelling() { + // Test that enharmonic spelling is correct for modes and scales + let mode = KordMode::parse("C# ionian".to_string()).unwrap(); + let notes_str = mode.notes_string(); + // C# major should have all sharps (C# D# E# F# G# A# B#) + assert!(notes_str.contains("C♯"), "Should contain C sharp"); + assert!(notes_str.contains("E♯"), "Should contain E sharp (not F)"); + + let scale = KordScale::parse("Db major".to_string()).unwrap(); + let notes_str = scale.notes_string(); + // Db major should have flats (Db Eb F Gb Ab Bb C) + assert!(notes_str.contains("D♭"), "Should contain D flat"); + assert!(notes_str.contains("E♭"), "Should contain E flat"); + } + + #[wasm_bindgen_test] + fn test_mode_scale_unicode_symbols() { + // Test that unicode symbols are handled correctly + let mode = KordMode::parse("F♯ lydian".to_string()); + assert!(mode.is_ok(), "Should parse mode with unicode sharp"); + + let mode = KordMode::parse("B♭ dorian".to_string()); + assert!(mode.is_ok(), "Should parse mode with unicode flat"); + + let scale = KordScale::parse("G♯ harmonic minor".to_string()); + assert!(scale.is_ok(), "Should parse scale with unicode sharp"); + } + + #[wasm_bindgen_test] + fn test_mode_scale_abi_object_independence() { + // Test that mode and scale objects are independent + let mode1 = KordMode::parse("C dorian".to_string()).unwrap(); + let mode2 = KordMode::parse("D dorian".to_string()).unwrap(); + assert_ne!(mode1.root(), mode2.root(), "Different modes should have different roots"); + + let scale1 = KordScale::parse("C major".to_string()).unwrap(); + let scale2 = KordScale::parse("G major".to_string()).unwrap(); + assert_ne!(scale1.root(), scale2.root(), "Different scales should have different roots"); + } + + // ScaleCandidate WASM tests + + #[wasm_bindgen_test] + fn test_scale_candidates_basic() { + let chord = KordChord::parse("C".to_string()).unwrap(); + let candidates = chord.scale_candidates(); + assert!(candidates.length() > 0, "Should have scale candidates"); + } + + #[wasm_bindgen_test] + fn test_scale_candidates_notes() { + let chord = KordChord::parse("C".to_string()).unwrap(); + let candidates = chord.scale_candidates(); + assert!(candidates.length() > 0, "Should have candidates"); + } + + #[wasm_bindgen_test] + fn test_scale_candidates_major_chord() { + let chord = KordChord::parse("C".to_string()).unwrap(); + let candidates = chord.scale_candidates(); + assert!(candidates.length() >= 3, "C major should have at least 3 candidates"); + } + + #[wasm_bindgen_test] + fn test_scale_candidates_dominant_chord() { + let chord = KordChord::parse("G7".to_string()).unwrap(); + let candidates = chord.scale_candidates(); + assert!(candidates.length() >= 5, "G7 should have multiple candidates"); + } + + #[wasm_bindgen_test] + fn test_scale_candidates_minor_chord() { + let chord = KordChord::parse("Cm".to_string()).unwrap(); + let candidates = chord.scale_candidates(); + assert!(candidates.length() > 0, "Cm should have candidates"); + } + + #[wasm_bindgen_test] + fn test_scale_candidates_altered_dominant() { + let chord = KordChord::parse("C7#9".to_string()).unwrap(); + let candidates = chord.scale_candidates(); + assert!(candidates.length() >= 1, "C7#9 should have candidates"); + } + + #[wasm_bindgen_test] + fn test_scale_candidates_half_diminished() { + let chord = KordChord::parse("Cm7b5".to_string()).unwrap(); + let candidates = chord.scale_candidates(); + assert!(candidates.length() >= 1, "Cm7b5 should have candidates"); + } + + #[wasm_bindgen_test] + fn test_scale_candidates_ranking_order() { + let chord = KordChord::parse("C".to_string()).unwrap(); + let candidates = chord.scale_candidates(); + assert!(candidates.length() >= 2, "Should have multiple candidates"); + } + + #[wasm_bindgen_test] + fn test_scale_candidates_copy() { + let chord = KordChord::parse("C".to_string()).unwrap(); + let candidates = chord.scale_candidates(); + assert!(candidates.length() > 0, "Should have candidates"); + } + + #[wasm_bindgen_test] + fn test_scale_candidates_rooting() { + let c_chord = KordChord::parse("C".to_string()).unwrap(); + let g_chord = KordChord::parse("G".to_string()).unwrap(); + + let c_candidates = c_chord.scale_candidates(); + let g_candidates = g_chord.scale_candidates(); + + assert!(c_candidates.length() > 0, "C should have candidates"); + assert!(g_candidates.length() > 0, "G should have candidates"); + } + + #[wasm_bindgen_test] + fn test_scale_candidates_pentatonic_in_recommendations() { + let chord = KordChord::parse("C".to_string()).unwrap(); + let candidates = chord.scale_candidates(); + assert!(candidates.length() >= 2, "Should have multiple candidates including pentatonic"); + } + + #[wasm_bindgen_test] + fn test_scale_candidates_blues_in_recommendations() { + let chord = KordChord::parse("G7".to_string()).unwrap(); + let candidates = chord.scale_candidates(); + assert!(candidates.length() >= 2, "G7 should have multiple candidates including blues"); + } }