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
53 changes: 47 additions & 6 deletions Semantics.Music/Chord.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ public static bool TryParse(string? symbol, [System.Diagnostics.CodeAnalysis.Not
return false;
}

string body = RewriteSixNine(new([.. head[index..].Where(c => c is not ('(' or ')'))]));
string body = RewriteHalfDiminished(RewriteSixNine(new([.. head[index..].Where(c => c is not ('(' or ')'))])));
ChordModifiers modifiers = ConsumeModifiers(ref body);
ChordQuality quality = DetermineQuality(body, modifiers.FifthAlteration);
SeventhType seventh = DetermineSeventh(body, quality);
Expand Down Expand Up @@ -170,6 +170,24 @@ private static string RewriteSixNine(string body)
return bareNine ? body[..(at + 1)] + "add9" + body[(at + 2)..] : body;
}

/// <summary>
/// Rewrites the half-diminished sign into the "m7b5" spelling the modifier reader understands.
/// "ø" always denotes the half-diminished seventh, so "Cø" and "Cø7" both read as "Cm7b5".
/// </summary>
/// <param name="body">The chord body, after the root.</param>
/// <returns>The body with the sign rewritten, or the body unchanged.</returns>
private static string RewriteHalfDiminished(string body)
{
int at = body.IndexOf('ø');
if (at < 0)
{
return body;
}

int end = at + 1 < body.Length && body[at + 1] == '7' ? at + 2 : at + 1;
return body[..at] + "m7b5" + body[end..];
}

/// <summary>The words a chord body may still hold once every modifier and extension is consumed.</summary>
private static readonly string[] QualityWords = ["maj", "Maj", "min", "dim", "aug", "sus2", "sus4", "sus"];

Expand Down Expand Up @@ -346,8 +364,9 @@ private static ChordQuality DetermineQuality(string body, int fifthAlteration)

if (fifthAlteration < 0)
{
// A lowered fifth with a (typically minor) third: a diminished/half-diminished colour.
return ChordQuality.Diminished;
// A lowered fifth is diminished only when the body also spells a minor third ("Cm7b5");
// otherwise the third stays major and only the fifth is lowered ("C7b5", "Cmaj7b5").
return IsMinor(body) ? ChordQuality.Diminished : ChordQuality.MajorFlatFive;
}

if (fifthAlteration > 0)
Expand All @@ -367,8 +386,8 @@ private static bool IsMinor(string body) =>
private static SeventhType DetermineSeventh(string body, ChordQuality quality)
{
bool hasSeven = body.Contains('7');
bool hasMaj7 = body.Contains("maj", StringComparison.Ordinal)
|| body.Contains("Maj", StringComparison.Ordinal)
bool hasMaj7 = IsMajorSeventhWord(body, "maj")
|| IsMajorSeventhWord(body, "Maj")
|| body.Contains("M7", StringComparison.Ordinal)
|| body.Contains('Δ');

Expand All @@ -392,6 +411,21 @@ private static SeventhType DetermineSeventh(string body, ChordQuality quality)
return hasSeven ? SeventhType.Dominant : SeventhType.None;
}

/// <summary>
/// Returns whether "maj" in a chord body names a major seventh. It does only when an extension
/// number follows it ("maj7", "maj9", "maj13"): a bare "maj" is a plain major triad, and
/// "maj6" a major sixth chord, neither of which carries a seventh.
/// </summary>
/// <param name="body">The chord body, before the extensions are taken.</param>
/// <param name="word">The spelling of "maj" to look for.</param>
/// <returns><see langword="true"/> when the word is followed by an extension other than 6.</returns>
private static bool IsMajorSeventhWord(string body, string word)
{
int at = body.IndexOf(word, StringComparison.Ordinal);
int next = at + word.Length;
return at >= 0 && next < body.Length && body[next] is >= '0' and <= '9' and not '6';
}

private static void ApplyExtensions(ref string body, bool hasAdd9, ref SeventhType seventh, ref ChordTensions tensions)
{
// Bare extension numbers (9/11/13) imply a dominant seventh and stack the lower tensions.
Expand Down Expand Up @@ -444,7 +478,7 @@ public IReadOnlyList<int> ChordTones()
{
_ = offsets.Add(Quality switch
{
ChordQuality.Diminished => 6,
ChordQuality.Diminished or ChordQuality.MajorFlatFive => 6,
ChordQuality.Augmented => 8,
_ => 7,
});
Expand Down Expand Up @@ -537,6 +571,13 @@ public override string ToString()
AppendQualityAndSeventh(sb);
AppendSixth(sb);
AppendTensions(sb);
if (Quality == ChordQuality.MajorFlatFive)
{
// Straight after the root, "b5" would be read back as a flat on the root ("Cb5" is a
// C-flat power chord), so the bare triad parenthesises it.
_ = sb.Append(sb.Length == Root.Name.Length ? "(b5)" : "b5");
}

AppendOmissions(sb);
if (Bass is not null)
{
Expand Down
3 changes: 3 additions & 0 deletions Semantics.Music/ChordQuality.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,7 @@ public enum ChordQuality

/// <summary>Power chord (root and perfect fifth only, no third).</summary>
Power,

/// <summary>Major triad with a lowered fifth (major third, diminished fifth), as in "C7b5" or "Cmaj7b5".</summary>
MajorFlatFive,
}
4 changes: 3 additions & 1 deletion Semantics.Music/Key.cs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,8 @@ private static string QualitySuffix(Chord chord)
_ => "",
};

return quality + seventh;
string fifth = chord.Quality == ChordQuality.MajorFlatFive ? "b5" : "";

return quality + seventh + fifth;
}
}
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`), the six-nine idiom (`C6/9`), and slash bass.
- **Chord-symbol parsing**: `Chord.Parse` handles triads, sixths, sevenths (including `m7b5`, the half-diminished `ø7`, `mmaj7`, and a flat fifth over a major third such as `7b5` and `maj7b5`), 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 @@ -16,6 +16,7 @@ public class ChordRoundTripTests
"C/G", "Dm7/G", "F#m7b5", "Bbmaj7",
"C6/9", "Cm6/9", "C6/9/G",
"C69", "Cm69", "Cadd11", "Cadd13", "Cmadd11",
"C7b5", "Cmaj7b5", "C9b5", "C7b5b9", "C(b5)", "Bb(b5)", "C13b5",
];

[TestMethod]
Expand Down
45 changes: 45 additions & 0 deletions Semantics.Test/Music/ChordTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -282,4 +282,49 @@ public void Parse_UnrecognisedTextLeftInTheBody_Fails()
Assert.IsNull(junk);
_ = Assert.ThrowsExactly<FormatException>(() => Chord.Parse("C7add"));
}

[TestMethod]
public void Parse_FlatFifth_KeepsTheThirdTheSymbolSpells()
{
// A b5 lowers only the fifth. It is diminished only when the body also spells a minor third.
(string Symbol, int[] Tones)[] cases =
[
("C7b5", [0, 4, 6, 10]),
("Cmaj7b5", [0, 4, 6, 11]),
("C9b5", [0, 4, 6, 10, 14]),
("C(b5)", [0, 4, 6]),
("C7♭5", [0, 4, 6, 10]),
("Cm7b5", [0, 3, 6, 10]),
("C-7b5", [0, 3, 6, 10]),
("Cmmaj7b5", [0, 3, 6, 11]),
];

foreach ((string symbol, int[] expected) in cases)
{
int[] actual = [.. Chord.Parse(symbol).ChordTones()];
Assert.AreSequenceEqual(expected, actual, $"ChordTones mismatch for '{symbol}'.");
}

Assert.AreEqual(ChordQuality.MajorFlatFive, Chord.Parse("C7b5").Quality);
Assert.AreEqual("C7b5", Chord.Parse("C7b5").ToString());
Assert.AreEqual("Cmaj7b5", Chord.Parse("Cmaj7b5").ToString());
Assert.AreEqual("C(b5)", Chord.Parse("C(b5)").ToString());
}

[TestMethod]
public void Parse_BareMaj_IsNotAMajorSeventh()
{
Assert.AreEqual(Chord.Parse("C"), Chord.Parse("Cmaj"));
Assert.AreEqual(Chord.Parse("C6"), Chord.Parse("Cmaj6"));
Assert.AreEqual(SeventhType.Major, Chord.Parse("Cmaj9").Seventh);
Assert.AreEqual(SeventhType.Major, Chord.Parse("CMaj7").Seventh);
}

[TestMethod]
public void Parse_HalfDiminishedSign_IsTheHalfDiminishedSeventh()
{
Assert.AreEqual(Chord.Parse("Cm7b5"), Chord.Parse("Cø7"));
Assert.AreEqual(Chord.Parse("Cm7b5"), Chord.Parse("Cø"));
Assert.AreSequenceEqual([0, 3, 6, 10], [.. Chord.Parse("Cø7").ChordTones()]);
}
}
9 changes: 9 additions & 0 deletions Semantics.Test/Music/KeyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,13 @@ public void TryParseFailsOnUnknownMode()
Assert.IsFalse(Key.TryParse("C bogus", out Key? result));
Assert.IsNull(result);
}

[TestMethod]
public void RomanNumeral_DominantSeventhFlatFiveRoundTrips()
{
Chord chord = Chord.Parse("G7b5");
string numeral = CMajor.RomanNumeralOf(chord);
Assert.AreEqual("V7b5", numeral);
Assert.AreEqual(chord, CMajor.ChordFromRomanNumeral(numeral));
}
}
Loading