Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion Semantics.Music/Key.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,29 @@ public ScaleDegree FunctionOf(PitchClass root)
/// cased upper for major/augmented and lower for minor/diminished, then a quality suffix.
/// </returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="chord"/> is null.</exception>
/// <exception cref="ArgumentException">
/// 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
/// <see cref="Mode.OctatonicHalfWhole"/>, <see cref="Mode.OctatonicWholeHalf"/> and
/// <see cref="Mode.Chromatic"/> — has no numeral to be given.
/// </exception>
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)
Expand All @@ -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);

Expand Down
4 changes: 4 additions & 0 deletions Semantics.Music/Progression.Analysis.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ public sealed partial record Progression
/// <param name="key">The key to analyze against.</param>
/// <returns>One roman numeral per chord, in order.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="key"/> is null.</exception>
/// <exception cref="ArgumentException">
/// Thrown when a chord's root resolves to a scale degree beyond the seventh, which has no
/// roman numeral. See <see cref="Key.RomanNumeralOf(Chord)"/>.
/// </exception>
public IReadOnlyList<string> RomanNumerals(Key key)
{
Ensure.NotNull(key);
Expand Down
38 changes: 38 additions & 0 deletions Semantics.Test/Music/KeyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> seen = [];

for (int degree = 1; degree <= mode.DegreeCount; degree++)
{
Chord chord = Chord.Parse(key.Scale.PitchClasses[degree - 1].Name);

if (degree > 7)
{
_ = Assert.ThrowsExactly<ArgumentException>(
() => 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()
{
Expand Down
Loading