diff --git a/Semantics.Music/Key.cs b/Semantics.Music/Key.cs index f42bcdec..e9b625fd 100644 --- a/Semantics.Music/Key.cs +++ b/Semantics.Music/Key.cs @@ -101,11 +101,29 @@ public ScaleDegree FunctionOf(PitchClass root) /// cased upper for major/augmented and lower for minor/diminished, then a quality suffix. /// /// Thrown when is null. + /// + /// Thrown when the chord's root resolves to a scale degree beyond the seventh. Roman-numeral + /// analysis only spells seven degrees, so the eighth degree onwards — reachable in + /// , and + /// — has no numeral to be given. + /// public string RomanNumeralOf(Chord chord) { Ensure.NotNull(chord); ScaleDegree degree = Scale.DegreeOf(chord.Root); + + // Wrapping the degree onto the seven-entry table would hand two distinct degrees the same + // numeral, silently. Refuse instead, which is also what ChordFromRomanNumeral already does + // in the other direction rather than wrapping. + if (degree.Degree > RomanNumerals.Length) + { + throw new ArgumentException( + $"Degree {degree.Degree} of {Mode.Name} has no roman numeral: roman-numeral analysis " + + $"spells only {RomanNumerals.Length} degrees.", + nameof(chord)); + } + StringBuilder sb = new(); if (degree.Alteration < 0) @@ -117,7 +135,7 @@ public string RomanNumeralOf(Chord chord) _ = sb.Append('#', degree.Alteration); } - string numeral = RomanNumerals[(degree.Degree - 1) % RomanNumerals.Length]; + string numeral = RomanNumerals[degree.Degree - 1]; bool lowerCase = chord.Quality is ChordQuality.Minor or ChordQuality.Diminished; _ = sb.Append(lowerCase ? numeral.ToLowerInvariant() : numeral); diff --git a/Semantics.Music/Progression.Analysis.cs b/Semantics.Music/Progression.Analysis.cs index f0187e7f..3d74ff7c 100644 --- a/Semantics.Music/Progression.Analysis.cs +++ b/Semantics.Music/Progression.Analysis.cs @@ -11,6 +11,10 @@ public sealed partial record Progression /// The key to analyze against. /// One roman numeral per chord, in order. /// Thrown when is null. + /// + /// Thrown when a chord's root resolves to a scale degree beyond the seventh, which has no + /// roman numeral. See . + /// public IReadOnlyList RomanNumerals(Key key) { Ensure.NotNull(key); diff --git a/Semantics.Test/Music/KeyTests.cs b/Semantics.Test/Music/KeyTests.cs index 23e674f2..ed365e3f 100644 --- a/Semantics.Test/Music/KeyTests.cs +++ b/Semantics.Test/Music/KeyTests.cs @@ -36,6 +36,44 @@ public void RomanNumeral_ChromaticGetsAccidental() Assert.AreEqual("bII", CMajor.RomanNumeralOf(Chord.Parse("Db"))); } + [TestMethod] + public void RomanNumeral_DistinctDegreesNeverShareALabel() + { + // The roman numeral table only spells seven degrees, so a mode with more of them has no + // label left for the eighth onwards. Wrapping would hand two distinct degrees the same + // string; the guard refuses instead, matching ChordFromRomanNumeral's own bounds check. + foreach (Mode mode in new[] { Mode.Chromatic, Mode.OctatonicHalfWhole, Mode.OctatonicWholeHalf }) + { + Key key = Key.Create(PitchClass.Create(0), mode); + HashSet seen = []; + + for (int degree = 1; degree <= mode.DegreeCount; degree++) + { + Chord chord = Chord.Parse(key.Scale.PitchClasses[degree - 1].Name); + + if (degree > 7) + { + _ = Assert.ThrowsExactly( + () => key.RomanNumeralOf(chord), + $"{mode.Name} degree {degree} has no roman numeral, so it must not be labelled."); + continue; + } + + string numeral = key.RomanNumeralOf(chord); + Assert.IsTrue(seen.Add(numeral), $"{mode.Name} degree {degree} reuses the label '{numeral}'."); + } + } + } + + [TestMethod] + public void RomanNumeral_ChromaticKeyStillLabelsTheFirstSevenDegrees() + { + Key chromatic = Key.Create(PitchClass.Create(0), Mode.Chromatic); + Assert.AreEqual("I", chromatic.RomanNumeralOf(Chord.Parse("C"))); + Assert.AreEqual("II", chromatic.RomanNumeralOf(Chord.Parse("C#"))); + Assert.AreEqual("VII", chromatic.RomanNumeralOf(Chord.Parse("F#"))); + } + [TestMethod] public void FunctionOf_ReturnsScaleDegree() {