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)); + } }