From d39a43de4ada870e3c4126fbd5e24b257ade5757 Mon Sep 17 00:00:00 2001 From: Matthew Edmondson Date: Mon, 28 Sep 2026 04:25:03 +0000 Subject: [PATCH] Parse "ionian" as the major mode, so a parsed Ionian key equals C major [patch] Mode.TryParse built a mode named "ionian", which is not equal to Mode.Ionian (an alias of Major), so a key parsed from "C ionian" was analyzed with the minor-key tables. Aliases now map to their canonical mode when parsed. Fixes ktsu-dev/Semantics#290 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PHrrj7bWzA48Gf3akqi476 --- Semantics.Music/Mode.cs | 14 +++++++++++++- .../Music/ChromaticAnalysisTests.cs | 15 +++++++++++++++ Semantics.Test/Music/KeyTests.cs | 7 +++++++ Semantics.Test/Music/ModeTests.cs | 19 +++++++++++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/Semantics.Music/Mode.cs b/Semantics.Music/Mode.cs index 681db413..0ab05e6b 100644 --- a/Semantics.Music/Mode.cs +++ b/Semantics.Music/Mode.cs @@ -14,7 +14,6 @@ public sealed record Mode { // Diatonic modes. ["major"] = [0, 2, 4, 5, 7, 9, 11], - ["ionian"] = [0, 2, 4, 5, 7, 9, 11], ["dorian"] = [0, 2, 3, 5, 7, 9, 10], ["phrygian"] = [0, 1, 3, 5, 7, 8, 10], ["lydian"] = [0, 2, 4, 6, 7, 9, 11], @@ -55,6 +54,13 @@ public sealed record Mode ["blues_major"] = [0, 2, 3, 4, 7, 9], }; + // Alternative spellings that name the same mode as a shape above. Parsing maps them to the + // canonical name, so a parsed alias equals the static instance it aliases. + private static readonly Dictionary Aliases = new(StringComparer.OrdinalIgnoreCase) + { + ["ionian"] = "major", + }; + /// Gets the canonical lower-case name of the mode. public string Name { get; private init; } = "major"; @@ -168,11 +174,17 @@ public static Mode Parse(string name) } /// Tries to parse a mode by name, case-insensitively. + /// An alias such as "ionian" parses to its canonical mode (). /// The mode name. /// The matching mode, or null on failure. /// when the name is a known mode. public static bool TryParse(string? name, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Mode? result) { + if (name is not null && Aliases.TryGetValue(name, out string? canonical)) + { + name = canonical; + } + if (name is not null && Shapes.ContainsKey(name)) { result = new() { Name = name.ToLowerInvariant() }; diff --git a/Semantics.Test/Music/ChromaticAnalysisTests.cs b/Semantics.Test/Music/ChromaticAnalysisTests.cs index 05cedee3..50360e75 100644 --- a/Semantics.Test/Music/ChromaticAnalysisTests.cs +++ b/Semantics.Test/Music/ChromaticAnalysisTests.cs @@ -48,6 +48,21 @@ public void ChromaticChords_DetectsBorrowedMinorSubdominant() Assert.AreEqual("from parallel minor", analyses[0].Detail); } + [TestMethod] + public void ChromaticChords_ParsedIonianKey_IsAnalyzedAsMajor() + { + Key ionian = Key.Parse("C ionian"); + + System.Collections.Generic.IReadOnlyList secondary = + Progression.Parse("4/4 C | D7 | G7 | C").ChromaticChords(ionian); + Assert.AreEqual("V/V", secondary[0].Detail); + + System.Collections.Generic.IReadOnlyList borrowed = + Progression.Parse("4/4 C | Fm | C").ChromaticChords(ionian); + Assert.AreEqual(ChromaticKind.BorrowedChord, borrowed[0].Kind); + Assert.AreEqual("from parallel minor", borrowed[0].Detail); + } + [TestMethod] public void ChromaticAnalysis_Create_RejectsNegativeIndex() => _ = Assert.ThrowsExactly(() => ChromaticAnalysis.Create(-1, ChromaticKind.Chromatic, null)); diff --git a/Semantics.Test/Music/KeyTests.cs b/Semantics.Test/Music/KeyTests.cs index ed365e3f..39de6097 100644 --- a/Semantics.Test/Music/KeyTests.cs +++ b/Semantics.Test/Music/KeyTests.cs @@ -96,6 +96,13 @@ public void RoundTrip() Assert.AreEqual(k, Key.Parse(k.ToString())); } + [TestMethod] + public void Parse_IonianSpelling_IsTheSameKeyAsMajor() + { + Assert.AreEqual(Key.Parse("C major"), Key.Parse("C ionian")); + Assert.AreEqual(CMajor, Key.Parse("C ionian")); + } + [TestMethod] public void TryParseFailsOnUnknownMode() { diff --git a/Semantics.Test/Music/ModeTests.cs b/Semantics.Test/Music/ModeTests.cs index bf8ea911..4b0475c2 100644 --- a/Semantics.Test/Music/ModeTests.cs +++ b/Semantics.Test/Music/ModeTests.cs @@ -27,6 +27,25 @@ public void Parse_IsCaseInsensitive_AndEqualsStaticInstance() Assert.AreEqual(Mode.Lydian, Mode.Parse("LYDIAN")); } + [TestMethod] + [DataRow("ionian")] + [DataRow("Ionian")] + [DataRow("IONIAN")] + public void Parse_IonianAlias_EqualsMajor(string name) + { + Mode parsed = Mode.Parse(name); + Assert.AreEqual(Mode.Ionian, parsed); + Assert.AreEqual(Mode.Major, parsed); + Assert.AreEqual("major", parsed.ToString()); + } + + [TestMethod] + public void TryParse_IonianAlias_EqualsMajor() + { + Assert.IsTrue(Mode.TryParse("ionian", out Mode? result)); + Assert.AreEqual(Mode.Major, result); + } + [TestMethod] public void Parse_RejectsUnknown() {