diff --git a/Semantics.Music/Chord.cs b/Semantics.Music/Chord.cs
index 274be80..e200921 100644
--- a/Semantics.Music/Chord.cs
+++ b/Semantics.Music/Chord.cs
@@ -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);
@@ -170,6 +170,24 @@ private static string RewriteSixNine(string body)
return bareNine ? body[..(at + 1)] + "add9" + body[(at + 2)..] : body;
}
+ ///
+ /// 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".
+ ///
+ /// The chord body, after the root.
+ /// The body with the sign rewritten, or the body unchanged.
+ 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..];
+ }
+
/// The words a chord body may still hold once every modifier and extension is consumed.
private static readonly string[] QualityWords = ["maj", "Maj", "min", "dim", "aug", "sus2", "sus4", "sus"];
@@ -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)
@@ -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('Δ');
@@ -392,6 +411,21 @@ private static SeventhType DetermineSeventh(string body, ChordQuality quality)
return hasSeven ? SeventhType.Dominant : SeventhType.None;
}
+ ///
+ /// 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.
+ ///
+ /// The chord body, before the extensions are taken.
+ /// The spelling of "maj" to look for.
+ /// when the word is followed by an extension other than 6.
+ 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.
@@ -444,7 +478,7 @@ public IReadOnlyList ChordTones()
{
_ = offsets.Add(Quality switch
{
- ChordQuality.Diminished => 6,
+ ChordQuality.Diminished or ChordQuality.MajorFlatFive => 6,
ChordQuality.Augmented => 8,
_ => 7,
});
@@ -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)
{
diff --git a/Semantics.Music/ChordQuality.cs b/Semantics.Music/ChordQuality.cs
index 09eaa01..50c5222 100644
--- a/Semantics.Music/ChordQuality.cs
+++ b/Semantics.Music/ChordQuality.cs
@@ -25,4 +25,7 @@ public enum ChordQuality
/// Power chord (root and perfect fifth only, no third).
Power,
+
+ /// Major triad with a lowered fifth (major third, diminished fifth), as in "C7b5" or "Cmaj7b5".
+ MajorFlatFive,
}
diff --git a/Semantics.Music/Key.cs b/Semantics.Music/Key.cs
index e9b625f..825cb6a 100644
--- a/Semantics.Music/Key.cs
+++ b/Semantics.Music/Key.cs
@@ -249,6 +249,8 @@ private static string QualitySuffix(Chord chord)
_ => "",
};
- return quality + seventh;
+ string fifth = chord.Quality == ChordQuality.MajorFlatFive ? "b5" : "";
+
+ return quality + seventh + fifth;
}
}
diff --git a/Semantics.Music/README.md b/Semantics.Music/README.md
index 4fa8319..06ae364 100644
--- a/Semantics.Music/README.md
+++ b/Semantics.Music/README.md
@@ -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.
diff --git a/Semantics.Test/Music/ChordRoundTripTests.cs b/Semantics.Test/Music/ChordRoundTripTests.cs
index 95787ea..bfa7231 100644
--- a/Semantics.Test/Music/ChordRoundTripTests.cs
+++ b/Semantics.Test/Music/ChordRoundTripTests.cs
@@ -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]
diff --git a/Semantics.Test/Music/ChordTests.cs b/Semantics.Test/Music/ChordTests.cs
index b243300..cd35218 100644
--- a/Semantics.Test/Music/ChordTests.cs
+++ b/Semantics.Test/Music/ChordTests.cs
@@ -282,4 +282,49 @@ public void Parse_UnrecognisedTextLeftInTheBody_Fails()
Assert.IsNull(junk);
_ = Assert.ThrowsExactly(() => 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()]);
+ }
}
diff --git a/Semantics.Test/Music/KeyTests.cs b/Semantics.Test/Music/KeyTests.cs
index 39de609..421d505 100644
--- a/Semantics.Test/Music/KeyTests.cs
+++ b/Semantics.Test/Music/KeyTests.cs
@@ -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));
+ }
}