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()
{