Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
e089614
Initial plan
Copilot Jan 14, 2026
cd6b149
Implement core Mode and Scale types with explicit intervals
Copilot Jan 14, 2026
dd90f8a
Add Scale and Mode documentation examples to lib.rs
Copilot Jan 14, 2026
fc06344
Deduplicate HasRoot trait - use existing trait from chord.rs
Copilot Jan 14, 2026
63de454
Add parsing support for Mode and Scale from text strings
Copilot Jan 14, 2026
bb98fd0
Add harmonic minor and melodic minor modes with full parsing support
Copilot Jan 14, 2026
2b85ea1
Add scale/mode candidates enumeration to chord descriptions
Copilot Jan 15, 2026
4920a0f
Add MajorPentatonic, MinorPentatonic, and Blues scales with full pars…
Copilot Jan 15, 2026
4b60389
Add scale/mode notes in parentheses to chord display recommendations
Copilot Jan 15, 2026
b59a42e
Enforce correct enharmonic spelling with validation and fix interval …
Copilot Jan 15, 2026
961962e
Change Blues scale to use ♯4 instead of ♭5 for blue note
Copilot Jan 15, 2026
7b6343a
Add comprehensive golden tests for enharmonic spelling and chord-to-c…
Copilot Jan 15, 2026
43cf1a3
Merge pull request #33 from twitchax/main
twitchax Jan 15, 2026
ec2f7bb
Enforce separation of concerns: remove scale/mode info from descripti…
Copilot Jan 15, 2026
a16810b
Refactor scale candidate storage to use static storage per KnownChord
Copilot Jan 15, 2026
e8ab3bc
Remove backup files
Copilot Jan 15, 2026
5d300ce
Address PR review comments: add helper method, move imports to top, f…
Copilot Jan 15, 2026
f045c2e
Simplify map closures to use method reference syntax
Copilot Jan 15, 2026
9bfae6f
Fix scale candidate tables for DominantSharp9, MinorDominantFlat9Flat…
Copilot Jan 15, 2026
89a3096
Expand scale candidate lists with additional high-value options
Copilot Jan 16, 2026
ec13da5
Rewrite reason strings for clearer musical context and usage guidance
Copilot Jan 16, 2026
4af6e48
Expand reason strings with richer musical context and usage guidance
Copilot Jan 16, 2026
de3739f
Simplify mode/scale grammar to reduce duplication while keeping Rust …
Copilot Jan 16, 2026
e83a29b
Add WASM bindings for Mode and Scale types with comprehensive tests
Copilot Jan 16, 2026
8167f56
Add WASM bindings for scale_candidates with 13 comprehensive tests
Copilot Jan 16, 2026
1680991
Remove chord-quality aliases from mode parser (augmentedmajor, half-d…
Copilot Jan 17, 2026
b5604c5
Remove chord-quality aliases from grammar (augmented major, half-dimi…
Copilot Jan 17, 2026
d6e005a
Fix terminology and theory accuracy in scale candidate reason strings
Copilot Jan 20, 2026
044ba1e
Rename tests and remove redundant comments
Copilot Jan 20, 2026
f239f6d
Update kord/src/core/scale_kind.rs
twitchax Jan 21, 2026
00c71c5
Update kord/src/core/scale_kind.rs
twitchax Jan 21, 2026
713d7b0
Update kord/src/core/scale.rs
twitchax Jan 21, 2026
41055e8
Update kord/src/core/mode_kind.rs
twitchax Jan 21, 2026
6e55929
Fix WASM build by replacing dyn_into with unchecked_into
Copilot Jan 21, 2026
7f5f034
Fix WASM tests by adding RefFromJsValue impl for KordScaleCandidate
Copilot Jan 21, 2026
6e7ed84
Fix WASM tests by using unchecked_into correctly for array items
Copilot Jan 21, 2026
114a856
Merge pull request #34 from twitchax/main
twitchax Jan 21, 2026
e81eaad
Add type annotations to unchecked_into calls in WASM tests
Copilot Jan 21, 2026
52915c5
Simplify WASM scale_candidates tests to avoid type conversion issues
Copilot Jan 21, 2026
1e76849
Fix diminished scale parsing to allow format without parentheses
Copilot Jan 21, 2026
f2d5aeb
Fix diminished scale parsing in Rust parser
Copilot Jan 21, 2026
9d2b023
Refactor code organization: move statics, add ascii_name methods, mov…
Copilot Jan 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions kord/chord.pest
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ accidental = { "#" | "♯" | "b" | "♭" }

note = { letter ~ accidental? ~ accidental? }

note_atomic = @{ letter ~ accidental? ~ accidental? }

digit = { ASCII_DIGIT }

note_with_octave = { note ~ digit? }
Expand Down Expand Up @@ -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 = {
Expand Down
2 changes: 1 addition & 1 deletion kord/src/bin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
111 changes: 104 additions & 7 deletions kord/src/core/chord.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -624,7 +624,12 @@ impl Display for Chord {
let scale = self.scale().iter().map(HasStaticName::static_name).collect::<Vec<_>>().join(", ");
let chord = self.chord().iter().map(HasStaticName::static_name).collect::<Vec<_>>().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(())
}
}

Expand Down Expand Up @@ -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<ScaleCandidate> {
self.scale_interval_candidates()
.iter()
.map(IntervalCandidate::to_scale_candidate)
.collect()
}
}

impl HasRelativeScale for Chord {
fn relative_scale(&self) -> Vec<Interval> {
self.known_chord().relative_scale()
Expand Down Expand Up @@ -1092,7 +1113,21 @@ impl HasRelativeChord for Chord {

impl HasScale for Chord {
fn scale(&self) -> Vec<Note> {
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 {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use that helper here too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the helper here now. Commit: 5d300ce

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()
Comment thread
twitchax marked this conversation as resolved.
}
}
}

Expand Down Expand Up @@ -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::<Vec<_>>().join(", ");
let chord = self.chord().iter().map(HasStaticName::static_name).collect::<Vec<_>>().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::<Vec<_>>().join(", ");
writeln!(&mut result, " {}. {} - {} ({})", candidate.rank(), candidate.name(), candidate.reason(), notes_str).unwrap();
writeln!(&mut result, " {}", candidate.description()).unwrap();
}
}

result
}
}

// Tests.

#[cfg(test)]
Expand All @@ -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]
Expand Down
6 changes: 6 additions & 0 deletions kord/src/core/interval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading