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
45 changes: 39 additions & 6 deletions Semantics.Music/Chord.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace ktsu.Semantics.Music;
using System.Linq;

/// <summary>
/// A chord parsed from a symbol such as "Cmaj7", "Dm7", "E7b9", "Cm7b5", "Cmmaj7", "C6", or "C/G".
/// A chord parsed from a symbol such as "Cmaj7", "Dm7", "E7b9", "Cm7b5", "Cmmaj7", "C6", "C6/9", or "C/G".
/// </summary>
public sealed record Chord
{
Expand Down Expand Up @@ -101,18 +101,51 @@ private static bool TryReadRoot(string symbol, out PitchClass? bass, out string
if (slash >= 0)
{
int bassIndex = 0;
if (!TryParseRoot(symbol[(slash + 1)..], ref bassIndex, out PitchClass? parsedBass))
if (TryParseRoot(symbol[(slash + 1)..], ref bassIndex, out PitchClass? parsedBass))
{
return false;
bass = parsedBass;
head = symbol[..slash];
}
else
{
// Not a bass note. The other thing a slash spells is the "six-nine" idiom, where
// the "/9" stacks an added ninth on a sixth chord instead of overriding the bass.
// Rewrite it and read the result, which may still carry a real slash bass.
return TryRewriteSixNine(symbol, slash, out string? rewritten)
&& TryReadRoot(rewritten, out bass, out head, out index, out root);
}

bass = parsedBass;
head = symbol[..slash];
}

return TryParseRoot(head, ref index, out root);
}

/// <summary>
/// Rewrites the "six-nine" idiom — a bare "9" directly after a "6", as in "C6/9" — into the
/// equivalent "add9" spelling ("C6add9") that the modifier reader already understands. The
/// ninth is an addition there, so it must not imply a seventh the way a bare "9" would.
/// </summary>
/// <param name="symbol">The chord symbol being read.</param>
/// <param name="slash">The index of the slash under consideration.</param>
/// <param name="rewritten">The rewritten symbol, or null when the symbol is not the idiom.</param>
/// <returns><see langword="true"/> when the symbol was rewritten.</returns>
private static bool TryRewriteSixNine(string symbol, int slash, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? rewritten)
{
rewritten = null;

bool sixBeforeSlash = slash > 0 && symbol[slash - 1] == '6';
bool nineAfterSlash = slash + 1 < symbol.Length && symbol[slash + 1] == '9';

// A following digit would make it some other extension ("/91"), not the bare ninth.
bool bareNine = nineAfterSlash && (slash + 2 >= symbol.Length || symbol[slash + 2] is < '0' or > '9');
if (!sixBeforeSlash || !bareNine)
{
return false;
}

rewritten = symbol[..slash] + "add9" + symbol[(slash + 2)..];
return true;
}

private static bool TryParseRoot(string symbol, ref int index, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out PitchClass? root)
{
root = null;
Expand Down
2 changes: 1 addition & 1 deletion Semantics.Music/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Above the single-event types sits an analysis layer that models harmony nested i

- **Pitch and interval types**: `PitchClass`, `Pitch` (MIDI, with name and frequency conversion), `Interval` (signed semitones, cents, folding).
- **Scales and modes**: `Mode` with roughly 29 presets (diatonic, jazz, symmetric, pentatonic, blues), `Scale` rooting a mode at a pitch class, with `Contains` and `DegreeOf`.
- **Chord-symbol parsing**: `Chord.Parse` handles triads, sixths, sevenths (including `m7b5` and `mmaj7`), extensions and altered tensions (`9`/`11`/`13`, `b9`/`#9`/`#11`/`b13`), suspensions, power chords, omissions (`no3`/`no5`), and slash bass.
- **Chord-symbol parsing**: `Chord.Parse` handles triads, sixths, sevenths (including `m7b5` and `mmaj7`), extensions and altered tensions (`9`/`11`/`13`, `b9`/`#9`/`#11`/`b13`), suspensions, power chords, omissions (`no3`/`no5`), the six-nine idiom (`C6/9`), and slash bass.
- **Chord realization**: `ChordTones()` and `Voice(octave)` / `Voice(octave, inversion)`, plus `Transpose`.
- **Roman-numeral analysis both directions**: `Key.RomanNumeralOf(chord)` and `Key.ChordFromRomanNumeral(numeral)`.
- **Rhythm and real time**: rational `Duration`, `TimeSignature`, `Tempo`, and `Note` / `Rest` / `ChordEvent` events that convert to seconds.
Expand Down
1 change: 1 addition & 0 deletions Semantics.Test/Music/ChordRoundTripTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public class ChordRoundTripTests
"C6", "Cm6", "C9", "Cm9", "C11", "C13",
"C7b9", "C7#9", "C7#11", "C7b13", "Cadd9",
"C/G", "Dm7/G", "F#m7b5", "Bbmaj7",
"C6/9", "Cm6/9", "C6/9/G",
];

[TestMethod]
Expand Down
55 changes: 55 additions & 0 deletions Semantics.Test/Music/ChordTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,61 @@ public void Parse_SlashBass()
Assert.AreEqual(7, c.Bass!.Value);
}

[TestMethod]
public void Parse_SixNine_IsAnAddedNinthOverASixth_NotASlashBass()
{
Chord c = Chord.Parse("C6/9");
Assert.AreEqual(0, c.Root.Value);
Assert.IsNull(c.Bass);
Assert.AreEqual(ChordQuality.Major, c.Quality);
Assert.AreEqual(SixthType.Natural, c.Sixth);
Assert.IsTrue(c.Tensions.HasFlag(ChordTensions.Nine));

// The ninth is added, so it must not imply a seventh the way a bare "9" would.
Assert.AreEqual(SeventhType.None, c.Seventh);
}

[TestMethod]
public void ChordTones_SixNine_IsAdd9PlusTheNaturalSixth()
{
int[] expected = [.. Chord.Parse("Cadd9").ChordTones().Append(9).Order()];
int[] actual = [.. Chord.Parse("C6/9").ChordTones()];
Assert.AreSequenceEqual(expected, actual, "C6/9 should be Cadd9 plus the natural sixth.");
}

[TestMethod]
public void Parse_MinorSixNine()
{
Chord c = Chord.Parse("Cm6/9");
Assert.AreEqual(ChordQuality.Minor, c.Quality);
Assert.AreEqual(SixthType.Natural, c.Sixth);
Assert.IsTrue(c.Tensions.HasFlag(ChordTensions.Nine));
Assert.AreEqual(SeventhType.None, c.Seventh);
Assert.IsNull(c.Bass);
}

[TestMethod]
public void Parse_SixNine_OverASlashBass()
{
// The second slash is the bass override; the first is the six-nine idiom.
Chord c = Chord.Parse("C6/9/G");
Assert.AreEqual(SixthType.Natural, c.Sixth);
Assert.IsTrue(c.Tensions.HasFlag(ChordTensions.Nine));
Assert.IsNotNull(c.Bass);
Assert.AreEqual(7, c.Bass!.Value);
}

[TestMethod]
public void Parse_SlashOverANonNoteStillFails()
{
// Only a bare "9" directly after a "6" is the idiom; everything else after a slash is
// still required to be a note letter.
Assert.IsFalse(Chord.TryParse("C/9", out Chord? afterNonSix));
Assert.IsNull(afterNonSix);
Assert.IsFalse(Chord.TryParse("C6/11", out Chord? afterLongerExtension));
Assert.IsNull(afterLongerExtension);
}

[TestMethod]
public void Parse_RejectsEmpty()
{
Expand Down
Loading