From 23af506a123ade3c984c2901616283c6aecaf068 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 17:26:03 +0000 Subject: [PATCH] [patch] Spell the raised seventh above a flat seventh as #7, not a lowered tonic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scale.DegreeOf broke the chromatic tie toward the flat spelling even when the flat candidate only existed by wrapping round the octave, so G# in A aeolian came out as degree 1 lowered and Key.RomanNumeralOf labelled the minor-key leading-tone chord "bi°". When the flat would wrap, the tie now goes to the sharp spelling of the last degree; every other tie still prefers the flat. Fixes #304 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0176ydtLncnCjKchHXEPCh7H --- Semantics.Music/Scale.cs | 11 +++++++++-- Semantics.Test/Music/KeyTests.cs | 17 +++++++++++++++++ Semantics.Test/Music/ScaleTests.cs | 11 +++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/Semantics.Music/Scale.cs b/Semantics.Music/Scale.cs index a11c64ca..739f1116 100644 --- a/Semantics.Music/Scale.cs +++ b/Semantics.Music/Scale.cs @@ -72,7 +72,9 @@ public ScaleDegree DegreeOf(PitchClass pitchClass) } // Chromatic: spell against the lower neighbour (raised/sharp) or the upper - // neighbour (lowered/flat), whichever is closer; tie prefers the flat. + // neighbour (lowered/flat), whichever is closer; tie prefers the flat, except + // above the last degree, where a flat would wrap to a lowered tonic and the + // raised last degree (the minor-key leading tone, #7) is the conventional reading. int lowerIndex = -1; for (int i = 0; i < offsets.Count; i++) { @@ -98,7 +100,12 @@ public ScaleDegree DegreeOf(PitchClass pitchClass) int flatDegree = upperIndex >= 0 ? upperIndex + 1 : 1; int flatAlteration = semitone - (upperIndex >= 0 ? offsets[upperIndex] : offsets[0] + 12); - return Math.Abs(flatAlteration) <= Math.Abs(sharpAlteration) + bool flatWraps = upperIndex < 0; + bool preferFlat = flatWraps + ? Math.Abs(flatAlteration) < Math.Abs(sharpAlteration) + : Math.Abs(flatAlteration) <= Math.Abs(sharpAlteration); + + return preferFlat ? new ScaleDegree(flatDegree, flatAlteration) : new ScaleDegree(sharpDegree, sharpAlteration); } diff --git a/Semantics.Test/Music/KeyTests.cs b/Semantics.Test/Music/KeyTests.cs index 421d505a..f0dfd64c 100644 --- a/Semantics.Test/Music/KeyTests.cs +++ b/Semantics.Test/Music/KeyTests.cs @@ -36,6 +36,23 @@ public void RomanNumeral_ChromaticGetsAccidental() Assert.AreEqual("bII", CMajor.RomanNumeralOf(Chord.Parse("Db"))); } + [TestMethod] + public void RomanNumeral_MinorLeadingToneChordIsSharpSeven() + { + Key aMinor = Key.Parse("A aeolian"); + Assert.AreEqual("#vii°", aMinor.RomanNumeralOf(Chord.Parse("G#dim"))); + Assert.AreEqual("#vii°", Key.Parse("G mixolydian").RomanNumeralOf(Chord.Parse("F#dim"))); + } + + [TestMethod] + public void RomanNumeral_MinorLeadingToneChordRoundTrips() + { + Key aMinor = Key.Parse("A aeolian"); + Chord leadingTone = Chord.Parse("G#dim"); + Chord roundTripped = aMinor.ChordFromRomanNumeral(aMinor.RomanNumeralOf(leadingTone)); + Assert.AreEqual(leadingTone, roundTripped); + } + [TestMethod] public void RomanNumeral_DistinctDegreesNeverShareALabel() { diff --git a/Semantics.Test/Music/ScaleTests.cs b/Semantics.Test/Music/ScaleTests.cs index 08b46444..4a1fbeec 100644 --- a/Semantics.Test/Music/ScaleTests.cs +++ b/Semantics.Test/Music/ScaleTests.cs @@ -50,6 +50,17 @@ public void DegreeOf_SharpFourIsCloserToFourthRaised() Assert.AreEqual(-1, tritone.Alteration); } + [TestMethod] + public void DegreeOf_RaisedSeventhAboveAFlatSeventhIsSharpSeven() + { + // G# in A aeolian sits between the flat seventh (G) and the octave; it is the + // leading tone (#7), not a lowered tonic wrapped round the octave. + Scale aMinor = Scale.Create(PitchClass.Parse("A"), Mode.Aeolian); + ScaleDegree leadingTone = aMinor.DegreeOf(PitchClass.Parse("G#")); + Assert.AreEqual(7, leadingTone.Degree); + Assert.AreEqual(1, leadingTone.Alteration); + } + [TestMethod] public void ToStringIsRootSpaceMode() {