From 14bfdf8b0aa2714d1504fc778a7ea43db369ff1e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 19:32:47 +0000 Subject: [PATCH] Parse dimmaj7 as a diminished-major seventh, not a dim7 DetermineSeventh tested the diminished-seventh branch before the major seventh. That branch asks only whether the body contains "dim" and a '7', which "dimmaj7" does, so the diminished-major seventh the formatter emits parsed back as a fully-diminished seventh. The round trip was broken and ChordTones placed the bb7 (9) where the major seventh (11) belongs, putting Voice() and playback a semitone out. The major-seventh check now runs first. "dim7" is unaffected: it carries no "maj". Fixes ktsu-dev/Semantics#269 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UscjStBJdW3uHm5NR289DX --- Semantics.Music/Chord.cs | 13 +++++++--- Semantics.Test/Music/ChordRoundTripTests.cs | 18 ++++++++++++++ Semantics.Test/Music/ChordTests.cs | 27 +++++++++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/Semantics.Music/Chord.cs b/Semantics.Music/Chord.cs index 1f37c03..fb74390 100644 --- a/Semantics.Music/Chord.cs +++ b/Semantics.Music/Chord.cs @@ -312,14 +312,19 @@ private static SeventhType DetermineSeventh(string body, ChordQuality quality) bool hasSeven = body.Contains('7'); bool hasMaj7 = body.Contains("maj", StringComparison.Ordinal) || body.Contains("M7", StringComparison.Ordinal) || body.Contains('Δ'); - if (quality == ChordQuality.Diminished && body.Contains("dim", StringComparison.Ordinal) && hasSeven) + // The major seventh is tested first because one body can carry both spellings: "dimmaj7" + // is the diminished-major seventh, a chord distinct from "dim7", and it is what the + // formatter emits for Diminished + Major. Testing the diminished branch first swallowed + // it, since "dimmaj7" contains "dim" and a '7' exactly as "dim7" does. "dim7" itself is + // unaffected: it carries no "maj". + if (hasMaj7) { - return SeventhType.Diminished; + return SeventhType.Major; } - if (hasMaj7) + if (quality == ChordQuality.Diminished && body.Contains("dim", StringComparison.Ordinal) && hasSeven) { - return SeventhType.Major; + return SeventhType.Diminished; } return hasSeven ? SeventhType.Dominant : SeventhType.None; diff --git a/Semantics.Test/Music/ChordRoundTripTests.cs b/Semantics.Test/Music/ChordRoundTripTests.cs index fb990dd..6b81264 100644 --- a/Semantics.Test/Music/ChordRoundTripTests.cs +++ b/Semantics.Test/Music/ChordRoundTripTests.cs @@ -28,6 +28,24 @@ public void CanonicalOutputRoundTrips() } } + [TestMethod] + public void DiminishedMajorSeventhRoundTrips() + { + // The corpus above cannot cover this one. It starts from a symbol, and "Cdimmaj7" parsed + // wrongly as a diminished seventh still formats and re-parses consistently as "Cdim7" — + // a stable round trip of the wrong chord. The chord object is the only honest starting + // point, since it is what the formatter is being asked to be the inverse of. + Chord chord = new() { Quality = ChordQuality.Diminished, Seventh = SeventhType.Major }; + + Assert.AreEqual("Cdimmaj7", chord.ToString()); + + Chord reparsed = Chord.Parse(chord.ToString()); + + Assert.AreEqual(SeventhType.Major, reparsed.Seventh); + Assert.AreEqual(ChordQuality.Diminished, reparsed.Quality); + Assert.AreEqual(chord, reparsed); + } + [TestMethod] public void TryParseReturnsFalseOnEmpty() { diff --git a/Semantics.Test/Music/ChordTests.cs b/Semantics.Test/Music/ChordTests.cs index edc323f..e83466f 100644 --- a/Semantics.Test/Music/ChordTests.cs +++ b/Semantics.Test/Music/ChordTests.cs @@ -42,6 +42,33 @@ public void Parse_MinorMajorSeventh_IsBothMinorAndMaj7() Assert.AreEqual(SeventhType.Major, c.Seventh); } + [TestMethod] + public void Parse_DiminishedMajorSeventh_IsDiminishedTriadWithMajorSeventh() + { + Chord c = Chord.Parse("Cdimmaj7"); + Assert.AreEqual(ChordQuality.Diminished, c.Quality); + Assert.AreEqual(SeventhType.Major, c.Seventh); + } + + [TestMethod] + public void Parse_DiminishedSeventh_StillHasTheDiminishedSeventh() + { + // The guard on the reordered checks: "dim7" carries no "maj", so it must not be swept + // into the major-seventh branch that now runs first. + Chord c = Chord.Parse("Cdim7"); + Assert.AreEqual(ChordQuality.Diminished, c.Quality); + Assert.AreEqual(SeventhType.Diminished, c.Seventh); + } + + [TestMethod] + public void ChordTones_DiminishedMajorSeventh_HasTheMajorSeventhNotTheDoubleFlatSeventh() + { + // The audible half of the bug: a misparse puts the bb7 (9) where the major 7th (11) + // belongs, so Voice() and playback are a semitone out on that tone. + int[] actual = [.. Chord.Parse("Cdimmaj7").ChordTones()]; + Assert.AreSequenceEqual([0, 3, 6, 11], actual, "Cdimmaj7 should be a diminished triad with a major seventh."); + } + [TestMethod] public void Parse_HalfDiminished_IsDiminishedTriadWithDominantSeventh() {