diff --git a/Semantics.Music/Chord.cs b/Semantics.Music/Chord.cs index 8e0c6e4..e33aa2b 100644 --- a/Semantics.Music/Chord.cs +++ b/Semantics.Music/Chord.cs @@ -4,7 +4,6 @@ namespace ktsu.Semantics.Music; using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; /// @@ -135,32 +134,41 @@ public Chord Transpose(int semitones) => this with /// Voices the chord at the given octave and inversion. /// The octave for the root (e.g. 4 places the root at C4 for a C chord). /// - /// The inversion: 0 root position, 1 first inversion, and so on. Each step raises the next-lowest - /// chord tone by an octave; the value wraps modulo the number of chord tones. + /// The inversion: 0 root position, 1 first inversion, and so on. Inversion k puts the + /// k-th entry of lowest, raising every tone below it by whole octaves + /// until it sits above that bass; the value wraps modulo the number of chord tones. /// /// The pitches, lowest first; a slash bass (if any) sounds one octave below the root. + /// + /// Thrown when puts any pitch of the voicing outside MIDI 0..127. + /// public IReadOnlyList Voice(int octave, int inversion) { List tones = [.. ChordTones()]; int count = tones.Count; if (count > 0) { + // Raising only the tones before the bass by one octave is not enough once tensions are + // involved: a raised root at 12 still sits below a ninth at 14, and would sort back under it. int rotation = ((inversion % count) + count) % count; + int bass = tones[rotation]; for (int i = 0; i < rotation; i++) { - tones[i] += 12; + while (tones[i] <= bass) + { + tones[i] += 12; + } } tones.Sort(); } - Pitch rootPitch = Pitch.Parse(Root.Name + octave.ToString(CultureInfo.InvariantCulture)); - List pitches = [.. tones.Select(offset => rootPitch.Transpose(offset))]; + long rootMidi = (((long)octave + 1) * 12) + Root.Value; + List pitches = [.. tones.Select(offset => PitchAt(rootMidi + offset, octave))]; if (Bass is not null) { - Pitch bassPitch = Pitch.Parse(Bass.Name + (octave - 1).ToString(CultureInfo.InvariantCulture)); - pitches.Insert(0, bassPitch); + pitches.Insert(0, PitchAt(((long)octave * 12) + Bass.Value, octave)); } return pitches; @@ -171,6 +179,11 @@ public IReadOnlyList Voice(int octave, int inversion) /// Parse(ToString()) == this for every expressible chord. public override string ToString() => ChordSymbolWriter.Format(this); + private static Pitch PitchAt(long midi, int octave) => + midi is >= 0 and <= 127 + ? Pitch.Create((int)midi) + : throw new ArgumentOutOfRangeException(nameof(octave), octave, "The voicing would leave MIDI 0..127."); + private void AddTension(SortedSet offsets, ChordTensions flag, int semitones) { if (Tensions.HasFlag(flag)) diff --git a/Semantics.Music/ChordSymbolReader.cs b/Semantics.Music/ChordSymbolReader.cs index 9e7f4d0..0b411f4 100644 --- a/Semantics.Music/ChordSymbolReader.cs +++ b/Semantics.Music/ChordSymbolReader.cs @@ -6,6 +6,7 @@ internal sealed class ChordSymbolReader(string text) { private readonly string text = text; private int index; + private int groupDepth; internal static bool TryRead(string text, out Chord? chord) { @@ -32,6 +33,27 @@ internal static bool TryRead(string text, out Chord? chord) return true; } + /// Reads a chord body, with no root and no slash bass, against a root already resolved. + /// + /// A roman numeral resolves its own root, so its suffix has to be read as a body. Gluing the suffix + /// onto the root's name instead would let a leading "b" or "#" become the root's accidental: "Vb9" + /// in C major would read as F#9 rather than G with a flat nine. + /// + internal static bool TryReadBody(PitchClass root, string body, out Chord? chord) + { + ChordSymbolReader reader = new(body); + chord = null; + if (!reader.TryReadBody(out ChordBuilder builder) + || reader.index != body.Length + || !builder.TryBuild(root, null, out chord)) + { + chord = null; + return false; + } + + return true; + } + private bool TryReadRoot([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out PitchClass? root) { root = null; @@ -109,7 +131,10 @@ private bool ReadDiminishedQuality(ChordBuilder builder) private void ReadAugmentedQuality(ChordBuilder builder) { - if (TryTake("aug") || (Peek('+') && !IsDigit(PeekNext()) && TryTake("+"))) + // At the start of the body a "+" is the augmented mark even before a number, so "C+7" is an + // augmented seventh. Only "+5" is left to the modifiers, where it reads as a raised fifth and + // means the same chord; anywhere later a "+" before a number is a sharp ("C7+9" is C7#9). + if (TryTake("aug") || (Peek('+') && PeekNext() != '5' && TryTake("+"))) { builder.Augmented = true; } @@ -256,21 +281,29 @@ private bool TryReadModifierGroup(ChordBuilder builder) } bool any = false; - while (!Peek(')')) + groupDepth++; + try { - if (!TryReadModifier(builder)) + while (!Peek(')')) { - index = start; - return false; - } + if (!TryReadModifier(builder)) + { + index = start; + return false; + } - any = true; - if (TryTake(",") && Peek(')')) - { - index = start; - return false; + any = true; + if (TryTake(",") && Peek(')')) + { + index = start; + return false; + } } } + finally + { + groupDepth--; + } if (!any) { @@ -333,6 +366,53 @@ private bool TryReadModifier(ChordBuilder builder) } index = start; + if (groupDepth > 0 && TryReadGroupedTone(builder)) + { + return true; + } + + index = start; + return false; + } + + /// Reads the lead-sheet spellings only a parenthesised group allows. + /// + /// A bare tension in parentheses is an added tone, as on a lead sheet: "C7(13)" is C7add13, not C13. + /// A major-seventh mark in parentheses supplies the seventh a body without a number lacks, so + /// "Cm(maj7)" is Cmmaj7; after a number there is already a seventh, and "C7(maj7)" is refused. + /// + private bool TryReadGroupedTone(ChordBuilder builder) + { + if (TryTakeEither("maj7", "Maj7") || TryTakeEither("M7", "Δ7")) + { + if (builder.ExtendedNumber != 0 || builder.MajorMarker || builder.Sixth != SixthType.None) + { + return false; + } + + builder.MajorMarker = true; + builder.ExtendedNumber = 7; + return true; + } + + if (TryTake("9")) + { + return builder.ExtendedNumber < 9 + && builder.SetTension(ChordTensions.Nine, ChordTensions.FlatNine | ChordTensions.SharpNine); + } + + if (TryTake("11")) + { + return builder.ExtendedNumber < 11 + && builder.SetTension(ChordTensions.Eleven, ChordTensions.SharpEleven); + } + + if (TryTake("13")) + { + return builder.ExtendedNumber < 13 + && builder.SetTension(ChordTensions.Thirteen, ChordTensions.FlatThirteen); + } + return false; } @@ -612,7 +692,8 @@ private bool HasValidShape() => && !(FifthAlteration < 0 && Augmented); private bool HasInvalidSuspension() => - Sus is not null && (Minor || Diminished || Augmented || MajorMarker || Power || FifthAlteration != 0); + Sus is not null && (Minor || Diminished || Augmented || Power || FifthAlteration != 0 + || (MajorMarker && !DeltaMarker && (ExtendedNumber is 0 or 6))); private bool HasInvalidMajorMarker() => (MajorMarker && !DeltaMarker && ExtendedNumber == 0 && (Minor || Diminished || Augmented)) diff --git a/Semantics.Music/ChordSymbolWriter.cs b/Semantics.Music/ChordSymbolWriter.cs index 0c23259..f2e28ea 100644 --- a/Semantics.Music/ChordSymbolWriter.cs +++ b/Semantics.Music/ChordSymbolWriter.cs @@ -124,11 +124,6 @@ private static bool HasSupportedStructure(Chord chord) return false; } - if ((chord.Quality is ChordQuality.Sus2 or ChordQuality.Sus4) && chord.Seventh == SeventhType.Major) - { - return false; - } - return chord.Quality != ChordQuality.Power || (chord.Seventh == SeventhType.None && chord.Sixth == SixthType.None diff --git a/Semantics.Music/ChromaticKind.cs b/Semantics.Music/ChromaticKind.cs index 1c5a34d..401025c 100644 --- a/Semantics.Music/ChromaticKind.cs +++ b/Semantics.Music/ChromaticKind.cs @@ -15,8 +15,10 @@ public enum ChromaticKind Neapolitan, /// - /// An augmented-sixth chord. Reserved: detection is not implemented because the chord model does - /// not carry the interval spelling needed to identify Italian/French/German sixths reliably. + /// An augmented-sixth chord on the lowered sixth degree: Italian ("It+6", the shape of a dominant + /// seventh with no fifth), French ("Fr+6", a dominant seventh with a flat fifth) or German ("Ger+6", + /// a dominant seventh, reported only when it resolves to the dominant or to a cadential six-four, + /// since otherwise it is indistinguishable from the dominant seventh it sounds like). /// AugmentedSixth, diff --git a/Semantics.Music/Key.cs b/Semantics.Music/Key.cs index 7fc4383..89fd234 100644 --- a/Semantics.Music/Key.cs +++ b/Semantics.Music/Key.cs @@ -3,6 +3,7 @@ namespace ktsu.Semantics.Music; using System; +using System.Linq; using System.Text; /// @@ -84,6 +85,31 @@ public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNu /// The transposed key. public Key Transpose(int semitones) => Create(PitchClass.Create(Tonic.Value + semitones), Mode); + /// + /// Gets the seven-degree scale that roman numerals, functions and cadences are spelled against: + /// the key's own scale when it has seven degrees, otherwise major or natural minor on the same + /// tonic, chosen by the mode's third. + /// + /// + /// A pentatonic, hexatonic or whole-tone mode's own degree index is not a harmonic degree: in C + /// major pentatonic G is the fourth note, yet it is the dominant. Spelling against the heptatonic + /// reference makes G "V" there, and C "III" in A minor pentatonic. Modes with more than seven + /// degrees keep their own scale, so still refuses their eighth degree. + /// + internal Scale AnalysisScale + { + get + { + if (Mode.DegreeCount >= 7) + { + return Scale; + } + + bool minor = Mode.Intervals.Contains(3) && !Mode.Intervals.Contains(4); + return Scale.Create(Tonic, minor ? Mode.Aeolian : Mode.Major); + } + } + /// Resolves a pitch class to its scale-degree function in this key. /// The pitch class to resolve. /// The scale degree with any chromatic alteration. @@ -111,7 +137,7 @@ public string RomanNumeralOf(Chord chord) { Ensure.NotNull(chord); - ScaleDegree degree = Scale.DegreeOf(chord.Root); + ScaleDegree degree = AnalysisScale.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 @@ -171,7 +197,7 @@ public Chord ChordFromRomanNumeral(string numeral) index += length; - System.Collections.Generic.IReadOnlyList pitchClasses = Scale.PitchClasses; + System.Collections.Generic.IReadOnlyList pitchClasses = AnalysisScale.PitchClasses; if (degree > pitchClasses.Count) { throw new FormatException($"Degree {degree} is out of range for this key."); @@ -213,7 +239,9 @@ public Chord ChordFromRomanNumeral(string numeral) quality = upper ? string.Empty : "m"; } - return Chord.Parse(root.Name + quality + suffix); + return ChordSymbolReader.TryReadBody(root, quality + suffix, out Chord? chord) + ? chord! + : throw new FormatException($"Invalid roman numeral '{numeral}'."); } private static (int Degree, int Length, bool Upper) ParseNumeral(string text, int start) @@ -234,37 +262,32 @@ private static (int Degree, int Length, bool Upper) ParseNumeral(string text, in return (0, 0, false); } + /// Writes the chord's body in roman-numeral form: the chord symbol after its root, with the + /// quality carried by the numeral's case or by °, ø and +. + /// + /// The body is the chord symbol's own, so sixths, tensions and suspensions survive the trip and + /// reads back the chord it was given. The slash bass is dropped: + /// "I6" already means an added sixth, so figured-bass inversions would collide with it. + /// private static string QualitySuffix(Chord chord) { - string seventh = chord.Seventh switch - { - SeventhType.Major => "maj7", - SeventhType.Dominant or SeventhType.Diminished => "7", - _ => "", - }; - - // A diminished triad under a minor seventh is half-diminished (viiø7), which has to be told - // apart from the fully diminished seventh (vii°7): ChordFromRomanNumeral reads ° as dim, so - // spelling both with ° would turn a half-diminished chord into a fully diminished one. - bool halfDiminished = chord.Quality == ChordQuality.Diminished && chord.Seventh == SeventhType.Dominant; - - string quality = chord.Quality switch + string body = ChordSymbolWriter.Format(chord with { Bass = null })[chord.Root.Name.Length..]; + switch (chord.Quality) { - ChordQuality.Diminished => halfDiminished ? "ø" : "°", - ChordQuality.Augmented => "+", - ChordQuality.Sus2 => "sus2", - ChordQuality.Sus4 => "sus4", - ChordQuality.Power => "5", - _ => "", - }; - - string fifth = chord.Quality switch - { - ChordQuality.MajorFlatFive => "b5", - ChordQuality.MinorSharpFive => "#5", - _ => "", - }; - - return quality + seventh + fifth; + case ChordQuality.Minor: + case ChordQuality.MinorSharpFive: + return body[1..]; + case ChordQuality.Diminished when chord.Seventh == SeventhType.Dominant: + // The writer spells a half-diminished chord "m7b5"; the numeral says "ø7", which + // ChordFromRomanNumeral passes through, rather than "°7", which it reads as fully diminished. + int flatFive = body.IndexOf("b5", StringComparison.Ordinal); + return "ø" + body[1..flatFive] + body[(flatFive + 2)..]; + case ChordQuality.Diminished: + return "°" + body["dim".Length..]; + case ChordQuality.Augmented: + return "+" + body["aug".Length..]; + default: + return body; + } } } diff --git a/Semantics.Music/Progression.Analysis.cs b/Semantics.Music/Progression.Analysis.cs index 3d74ff7..4ea3642 100644 --- a/Semantics.Music/Progression.Analysis.cs +++ b/Semantics.Music/Progression.Analysis.cs @@ -34,7 +34,7 @@ public IReadOnlyList Functions(Key key) /// Classifies a single chord's function in a key by its root scale degree. private static HarmonicFunction FunctionOf(Key key, Chord chord) { - ScaleDegree degree = key.FunctionOf(chord.Root); + ScaleDegree degree = key.AnalysisScale.DegreeOf(chord.Root); if (degree.Alteration != 0) { return HarmonicFunction.Chromatic; diff --git a/Semantics.Music/Progression.Cadences.cs b/Semantics.Music/Progression.Cadences.cs index daeb4af..1015109 100644 --- a/Semantics.Music/Progression.Cadences.cs +++ b/Semantics.Music/Progression.Cadences.cs @@ -14,10 +14,11 @@ public IReadOnlyList Cadences(Key key) { Ensure.NotNull(key); List result = []; + Scale scale = key.AnalysisScale; for (int i = 0; i + 1 < Chords.Count; i++) { - ScaleDegree from = key.FunctionOf(Chords[i].Chord.Root); - ScaleDegree to = key.FunctionOf(Chords[i + 1].Chord.Root); + ScaleDegree from = scale.DegreeOf(Chords[i].Chord.Root); + ScaleDegree to = scale.DegreeOf(Chords[i + 1].Chord.Root); if (from.Alteration != 0 || to.Alteration != 0) { continue; @@ -38,6 +39,10 @@ public IReadOnlyList Cadences(Key key) (5, 1) => Cadence.Authentic, (5, 6) => Cadence.Deceptive, (4, 1) => Cadence.Plagal, + + // V to V prolongs the dominant rather than arriving on it, so a held dominant is one half + // cadence where it arrives, not one per bar. + (5, 5) => null, (_, 5) => Cadence.Half, _ => null, }; diff --git a/Semantics.Music/Progression.Chromatic.cs b/Semantics.Music/Progression.Chromatic.cs index fb0bba5..21fd0f3 100644 --- a/Semantics.Music/Progression.Chromatic.cs +++ b/Semantics.Music/Progression.Chromatic.cs @@ -23,7 +23,8 @@ public IReadOnlyList ChromaticChords(Key key) continue; } - (ChromaticKind kind, string? detail) = ClassifyChromatic(key, chord); + Chord? next = i + 1 < Chords.Count ? Chords[i + 1].Chord : null; + (ChromaticKind kind, string? detail) = ClassifyChromatic(key, chord, next); result.Add(ChromaticAnalysis.Create(i, kind, detail)); } @@ -33,10 +34,33 @@ public IReadOnlyList ChromaticChords(Key key) private static bool AllTonesInScale(Chord chord, Scale scale) => chord.ChordTones().All(offset => scale.Contains(PitchClass.Create(chord.Root.Value + offset))); - private static bool IsDiatonic(Key key, Chord chord) + private static bool IsDiatonic(Key key, Chord chord) => + AllTonesInScale(chord, key.Scale) || IsHarmonicMinorDominant(key, chord); + + // A minor key's dominant raises its leading tone: E and E7 in A minor are the key's own V and V7, + // not chords borrowed from A major, and every other analysis already treats them that way. A key + // counts as minor here when its scale has a minor third and a minor seventh, so the raised + // seventh is the one tone the dominant adds. + private static bool IsHarmonicMinorDominant(Key key, Chord chord) { + int tonic = key.Tonic.Value; Scale scale = key.Scale; - return AllTonesInScale(chord, scale); + if (!scale.Contains(PitchClass.Create(tonic + 3)) + || !scale.Contains(PitchClass.Create(tonic + 10)) + || scale.Contains(PitchClass.Create(tonic + 11)) + || chord.Root.Value != PitchClass.Create(tonic + 7).Value + || chord.Quality != ChordQuality.Major + || chord.Seventh is not (SeventhType.None or SeventhType.Dominant)) + { + return false; + } + + int leadingTone = PitchClass.Create(tonic + 11).Value; + return chord.ChordTones().All(offset => + { + PitchClass tone = PitchClass.Create(chord.Root.Value + offset); + return tone.Value == leadingTone || scale.Contains(tone); + }); } // Names the degree a secondary dominant resolves to by the key's own triad on that degree, so every @@ -57,14 +81,63 @@ private static bool IsDiatonic(Key key, Chord chord) return null; } + // Only a stable triad can be tonicized: a diminished or augmented degree (vii° in major, ii° or + // III+ in minor) is no local tonic, so a chord a fifth above it is merely chromatic. ChordQuality? quality = DiatonicTriadQuality(key.Scale, targetRoot); - return quality is null - ? null - : key.RomanNumeralOf(new Chord { Root = targetRoot, Quality = quality.Value }); + return quality is ChordQuality.Major or ChordQuality.Minor + ? key.RomanNumeralOf(new Chord { Root = targetRoot, Quality = quality.Value }) + : null; } - private static (ChromaticKind Kind, string? Detail) ClassifyChromatic(Key key, Chord chord) + // Italian, French and German sixths, all built on the lowered sixth degree. A chord has no + // spelling, only pitch classes, so the augmented sixth (F# over Ab in C) is the chord's minor + // seventh (Gb) and each variant has the shape of a dominant seventh on that root: + // It+6 Ab C F# Ab7no5 + // Fr+6 Ab C D F# Ab7b5 + // Ger+6 Ab C Eb F# Ab7 + // The Italian and French shapes are not otherwise idiomatic there. The German one is exactly a + // dominant seventh, so it is read as an augmented sixth only where it resolves the way one does: + // to the dominant, or to a tonic chord over the dominant in the bass (the cadential six-four). + private static string? AugmentedSixthVariant(Key key, Chord chord, Chord? next) { + int tonic = key.Tonic.Value; + if (chord.Root.Value != PitchClass.Create(tonic + 8).Value + || chord.Seventh != SeventhType.Dominant + || chord.Sixth != SixthType.None + || chord.Tensions != ChordTensions.None + || chord.Omissions.HasFlag(ChordOmissions.Third)) + { + return null; + } + + bool fifthOmitted = chord.Omissions.HasFlag(ChordOmissions.Fifth); + if (chord.Quality == ChordQuality.Major && fifthOmitted) + { + return "It+6"; + } + + if (chord.Quality == ChordQuality.MajorFlatFive && !fifthOmitted) + { + return "Fr+6"; + } + + int dominant = PitchClass.Create(tonic + 7).Value; + bool resolvesToDominant = next is not null + && (next.Root.Value == dominant + || (next.Root.Value == tonic && next.Bass?.Value == dominant)); + return chord.Quality == ChordQuality.Major && !fifthOmitted && resolvesToDominant + ? "Ger+6" + : null; + } + + private static (ChromaticKind Kind, string? Detail) ClassifyChromatic(Key key, Chord chord, Chord? next) + { + string? augmentedSixth = AugmentedSixthVariant(key, chord, next); + if (augmentedSixth is not null) + { + return (ChromaticKind.AugmentedSixth, augmentedSixth); + } + // Secondary dominant: a major triad or dominant seventh a perfect fifth above a diatonic degree. bool dominantQuality = chord.Quality == ChordQuality.Major && chord.Seventh is SeventhType.None or SeventhType.Dominant; diff --git a/Semantics.Music/Progression.KeyInference.cs b/Semantics.Music/Progression.KeyInference.cs index fc0b267..92232c2 100644 --- a/Semantics.Music/Progression.KeyInference.cs +++ b/Semantics.Music/Progression.KeyInference.cs @@ -40,10 +40,17 @@ .. matches ]; } - // Scores one chord's fit to a key: 1.0 when its root is diatonic AND its triad quality matches the - // diatonic triad at that degree, 0.5 when the root is diatonic but the quality differs (or has no - // comparable third), 0.0 when the root is not in the scale. Quality-weighting is what distinguishes - // a key from its relative/parallel neighbours, which share the same chord roots. + // Scores one chord's fit to a key in two parts, multiplied together. + // + // The root and its triad: 0.0 when the root is not in the scale; 1.0 when the chord's triad quality + // matches the diatonic triad on that degree, or has no third to compare (sus, power and altered + // fifths) or the degree forms no recognised triad; 0.5 when the qualities differ. Quality-weighting + // is what separates a key from its relative and parallel neighbours, which share the same roots. + // + // Every other tone: the fraction of the chord's pitch classes, slash bass included, that are in the + // scale. Without it C7 and Cmaj7 score alike everywhere, and the Bb that makes C7 the dominant of F + // costs C major nothing. A minor key's dominant (E or E7 in A minor) scores as diatonic, raised + // leading tone and all, since that is the key's own V. private static double ScoreChord(Key key, Chord chord) { Scale scale = key.Scale; @@ -52,15 +59,28 @@ private static double ScoreChord(Key key, Chord chord) return 0.0; } + if (IsHarmonicMinorDominant(key, chord)) + { + return ToneFit(scale, chord, PitchClass.Create(key.Tonic.Value + 11)); + } + ChordQuality? diatonic = DiatonicTriadQuality(scale, chord.Root); bool comparable = chord.Quality is ChordQuality.Major or ChordQuality.Minor or ChordQuality.Diminished or ChordQuality.Augmented; - if (!comparable || diatonic is null) + double triad = !comparable || diatonic is null || chord.Quality == diatonic.Value ? 1.0 : 0.5; + return triad * ToneFit(scale, chord, null); + } + + private static double ToneFit(Scale scale, Chord chord, PitchClass? alsoDiatonic) + { + List tones = [.. chord.ChordTones().Select(offset => PitchClass.Create(chord.Root.Value + offset).Value)]; + if (chord.Bass is not null && !tones.Contains(chord.Bass.Value)) { - return 1.0; + tones.Add(chord.Bass.Value); } - return chord.Quality == diatonic.Value ? 1.0 : 0.5; + int inScale = tones.Count(tone => tone == alsoDiatonic?.Value || scale.Contains(PitchClass.Create(tone))); + return inScale / (double)tones.Count; } // Returns the diatonic triad quality built on a scale degree by stacking scale thirds, or null when diff --git a/Semantics.Music/README.md b/Semantics.Music/README.md index 529a58d..f0d9861 100644 --- a/Semantics.Music/README.md +++ b/Semantics.Music/README.md @@ -24,7 +24,7 @@ Above the single-event types sits an analysis layer that models harmony nested i - **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`, 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)`. +- **Roman-numeral analysis both directions**: `Key.RomanNumeralOf(chord)` and `Key.ChordFromRomanNumeral(numeral)`. A numeral carries the chord's whole body, sixths, tensions and suspensions included (`I6`, `V13`, `V7b9`, `Imaj7sus4`), so the two are inverses for any chord without a slash bass. The bass is dropped, because `I6` already means an added sixth. - **Rhythm and real time**: rational `Duration`, `TimeSignature`, `Tempo`, and `Note` / `Rest` / `ChordEvent` events that convert to seconds. - **Analysis layer**: `Progression` (roman numerals, functions, cadences, key inference, chromatic chords), `Section`, `Arrangement`, and `Form` with named-form recognition. @@ -97,7 +97,8 @@ extension = [ majorWord ] number | number "M" | "Δ" number = "7" | "9" | "11" | "13" suspension = "sus2" | "sus4" | "sus" modifier = alteration | add | omit | "b6" | "♭6" - | "(" modifier { [ "," ] modifier } ")" + | "(" grouped { [ "," ] grouped } ")" +grouped = modifier | "9" | "11" | "13" | majorWord "7" alteration = ("b" | "♭" | "-") ( "5" | "9" | "13" ) | ("#" | "♯" | "+") ( "5" | "9" | "11" ) add = "add" ( "9" | "11" | "13" | "6" | "b6" ) @@ -109,7 +110,11 @@ implies a seventh and its natural lower stack tones unless an alteration replace `addN` adds only that tone. `M`/`maj` before an extension or an `M` suffix on its number marks a major seventh. A small compatibility reader also accepts previously printed forms such as `C79`, `Cmaj79`, `Csus47`, and `C6add9`; a bare `+` after a numbered chord remains an -augmentation mark. `ToString()` emits the canonical form, for example `C13b9`, `C7sus4`, `C6/9`, +augmentation mark. A `+` at the start of the body is the augmented mark before a number too, so +`C+7` is `Caug7` and `Cm+7` is `Cm7#5`; only `C+5` reads as a raised fifth. A suspension can follow +a major seventh (`Cmaj7sus4`, `Cmaj9sus4`). Inside parentheses, a bare `9`, `11` or `13` is an added +tone as on a lead sheet (`C7(13)` is `C7add13`), and a major-seventh mark supplies the seventh a body +without a number lacks (`Cm(maj7)` is `Cmmaj7`). `ToString()` emits the canonical form, for example `C13b9`, `C7sus4`, `C6/9`, and `C(#11)`. ### Roman numerals, both directions @@ -132,12 +137,12 @@ using ktsu.Semantics.Music; // extends the preceding chord by one beat (so "Cmaj7 / / /" is a whole bar of 4/4). Progression prog = Progression.Parse("4/4 Dm7 / / / | G7 / / / | Cmaj7 / / / | Cmaj7 / / /"); -Key key = prog.InferKey()!; // C major (quality-weighted fit) +Key key = prog.InferKey()!; // C major (quality- and chord-tone-weighted fit) IReadOnlyList roman = prog.RomanNumerals(key); // ii7, V7, Imaj7, Imaj7 IReadOnlyList fns = prog.Functions(key); // Predominant, Dominant, Tonic, Tonic IReadOnlyList cadences = prog.Cadences(key); // Authentic at the resolution -// Chromatic analysis: secondary dominants, borrowed chords, Neapolitan +// Chromatic analysis: secondary dominants, borrowed chords, Neapolitan, augmented sixths IReadOnlyList chromatic = Progression.Parse("4/4 C / / / | A7 / / / | Dm / / / | G7 / / /").ChromaticChords(key); // A7 -> secondary dominant @@ -206,6 +211,14 @@ bool ok = Interval.TryParse("-5", out Interval? descendingFourth); | `ChromaticAnalysis` | A non-diatonic chord classification. | `Index`, `Kind` (`ChromaticKind`), `Detail` | | `NamedForm` | enum: ThirtyTwoBarAABA, TwelveBarBlues, Binary, Ternary, Rondo, Strophic, ... | | +Roman numerals, functions and cadences in a mode with fewer than seven degrees (pentatonic, blues, +whole tone) are spelled against major or natural minor on the same tonic, chosen by the mode's third, +so G in C major pentatonic is `V` and G → C is authentic. A minor key's own dominant (E or E7 in +A minor) is diatonic to the chromatic analysis and to key inference. Augmented sixths sit on the +lowered sixth degree and are told apart by shape, since a chord holds pitch classes rather than +spellings: `Ab7no5` is `It+6` and `Ab7b5` is `Fr+6` in C, while `Ab7` is `Ger+6` only when it resolves +to the dominant or to a cadential six-four. + `InferKey()` returns `Key?` (null for all-chromatic input), while `InferKeys()` returns the full ranked `IReadOnlyList`. See the [complete library guide](../docs/complete-library-guide.md) for a wider tour. ## Contributing diff --git a/Semantics.Test/Music/AnalysisFixesTests.cs b/Semantics.Test/Music/AnalysisFixesTests.cs new file mode 100644 index 0000000..d264228 --- /dev/null +++ b/Semantics.Test/Music/AnalysisFixesTests.cs @@ -0,0 +1,136 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Test.Music; + +using System.Collections.Generic; +using System.Linq; +using ktsu.Semantics.Music; + +[TestClass] +public class AnalysisFixesTests +{ + private static Key CMajor => Key.Create(PitchClass.Create(0), Mode.Major); + private static Key AMinor => Key.Create(PitchClass.Create(9), Mode.Aeolian); + private static Key CMajorPentatonic => Key.Create(PitchClass.Create(0), Mode.MajorPentatonic); + private static Key AMinorPentatonic => Key.Create(PitchClass.Create(9), Mode.MinorPentatonic); + + private static readonly string[] PentatonicNumerals = ["I", "vi", "V", "I"]; + + private static readonly HarmonicFunction[] PentatonicFunctions = + [HarmonicFunction.Tonic, HarmonicFunction.Tonic, HarmonicFunction.Dominant, HarmonicFunction.Tonic]; + + [TestMethod] + public void Cadences_AHeldDominantIsOneHalfCadence() + { + IReadOnlyList cadences = Progression.Parse("4/4 C | G | G | G").Cadences(CMajor); + Assert.HasCount(1, cadences); + Assert.AreEqual(Cadence.Half, cadences[0].Type); + Assert.AreEqual(1, cadences[0].Index); + } + + [TestMethod] + public void Cadences_VToV7IsNotAHalfCadence() + { + IReadOnlyList cadences = Progression.Parse("4/4 C | G | G7 | C").Cadences(CMajor); + Assert.DoesNotContain(cadence => cadence.Index == 2, cadences, string.Join(", ", cadences)); + Assert.AreEqual(Cadence.Authentic, cadences[^1].Type); + } + + [TestMethod] + public void Pentatonic_SpellsAgainstTheMajorScale() + { + Progression progression = Progression.Parse("4/4 C | Am | G | C"); + Assert.AreSequenceEqual(PentatonicNumerals, [.. progression.RomanNumerals(CMajorPentatonic)]); + Assert.AreSequenceEqual(PentatonicFunctions, [.. progression.Functions(CMajorPentatonic)]); + + IReadOnlyList cadences = progression.Cadences(CMajorPentatonic); + Assert.DoesNotContain(cadence => cadence.Type == Cadence.Plagal, cadences); + Assert.AreEqual(Cadence.Authentic, cadences.Single(cadence => cadence.Index == 3).Type); + Assert.AreEqual(Cadence.Half, cadences.Single(cadence => cadence.Index == 2).Type); + } + + [TestMethod] + public void Pentatonic_MinorSpellsAgainstNaturalMinor() + { + Assert.AreEqual("III", AMinorPentatonic.RomanNumeralOf(Chord.Parse("C"))); + Assert.AreEqual("VII", AMinorPentatonic.RomanNumeralOf(Chord.Parse("G"))); + Assert.AreEqual(Chord.Parse("G"), CMajorPentatonic.ChordFromRomanNumeral("V")); + Assert.AreEqual( + HarmonicFunction.Tonic, + Progression.Parse("4/4 C").Functions(AMinorPentatonic)[0]); + } + + [TestMethod] + [DataRow("4/4 C | F#7 | Bdim | C", "C major")] + [DataRow("4/4 Am | F# | Bdim | Am", "A aeolian")] + [DataRow("4/4 Am | G | Am", "A harmonic_minor")] + public void ChromaticChords_ADominantOfADiminishedTriadIsNotSecondary(string chart, string key) + { + IReadOnlyList analyses = Progression.Parse(chart).ChromaticChords(Key.Parse(key)); + Assert.DoesNotContain(a => a.Kind == ChromaticKind.SecondaryDominant, analyses, string.Join(", ", analyses)); + } + + [TestMethod] + [DataRow("4/4 C | D7 | G", "C major", "V/V")] + [DataRow("4/4 C | A7 | Dm", "C major", "V/ii")] + [DataRow("4/4 C | E7 | Am", "C major", "V/vi")] + [DataRow("4/4 Am | A7 | Dm", "A aeolian", "V/iv")] + public void ChromaticChords_SecondaryDominantsOfStableTriadsAreUnchanged(string chart, string key, string detail) + { + ChromaticAnalysis analysis = Progression.Parse(chart).ChromaticChords(Key.Parse(key)).Single(); + Assert.AreEqual(ChromaticKind.SecondaryDominant, analysis.Kind); + Assert.AreEqual(detail, analysis.Detail); + } + + [TestMethod] + [DataRow("4/4 Am | Dm | E7 | Am")] + [DataRow("4/4 Am | E | Am")] + [DataRow("4/4 Am | E7b9 | Am")] + public void ChromaticChords_AMinorKeysOwnDominantIsDiatonic(string chart) => + Assert.IsEmpty(Progression.Parse(chart).ChromaticChords(AMinor)); + + [TestMethod] + public void ChromaticChords_BorrowedChordsAreUnchanged() + { + ChromaticAnalysis fm = Progression.Parse("4/4 C | Fm | C").ChromaticChords(CMajor).Single(); + Assert.AreEqual(ChromaticKind.BorrowedChord, fm.Kind); + Assert.AreEqual("from parallel minor", fm.Detail); + } + + [TestMethod] + [DataRow("4/4 C | Ab7no5 | G | C", "C major", "It+6")] + [DataRow("4/4 C | Ab7b5 | G | C", "C major", "Fr+6")] + [DataRow("4/4 C | Ab7 | G | C", "C major", "Ger+6")] + [DataRow("4/4 C | Ab7 | C/G | G | C", "C major", "Ger+6")] + [DataRow("4/4 Am | F7no5 | E | Am", "A aeolian", "It+6")] + [DataRow("4/4 Am | F7b5 | E | Am", "A aeolian", "Fr+6")] + [DataRow("4/4 Am | F7 | E | Am", "A aeolian", "Ger+6")] + public void ChromaticChords_DetectsAugmentedSixths(string chart, string key, string detail) + { + ChromaticAnalysis analysis = Progression.Parse(chart).ChromaticChords(Key.Parse(key)).Single(a => a.Index == 1); + Assert.AreEqual(ChromaticKind.AugmentedSixth, analysis.Kind); + Assert.AreEqual(detail, analysis.Detail); + } + + [TestMethod] + public void ChromaticChords_ADominantSeventhOnFlatSixIsGermanOnlyWhenItResolvesLikeOne() + { + ChromaticAnalysis analysis = Progression.Parse("4/4 C | Ab7 | Db | C").ChromaticChords(CMajor).Single(a => a.Index == 1); + Assert.AreNotEqual(ChromaticKind.AugmentedSixth, analysis.Kind); + } + + [TestMethod] + [DataRow("4/4 C7 | Fmaj7 | C7", 5, "major")] + [DataRow("4/4 Fmaj7 | C7", 5, "major")] + [DataRow("4/4 Bbmaj7 | F7", 10, "major")] + [DataRow("4/4 Am | E7 | Am", 9, "aeolian")] + [DataRow("4/4 Dm7 | G7 | Cmaj7", 0, "major")] + [DataRow("4/4 Am | Dm | Em | Am", 9, "aeolian")] + public void InferKey_WeighsEveryChordTone(string chart, int tonic, string mode) + { + Key? key = Progression.Parse(chart).InferKey(); + Assert.IsNotNull(key); + Assert.AreEqual(tonic, key.Tonic.Value, key.ToString()); + Assert.AreEqual(mode, key.Mode.Name, key.ToString()); + } +} diff --git a/Semantics.Test/Music/ChordGrammarTests.cs b/Semantics.Test/Music/ChordGrammarTests.cs index 81eb45f..9ca0a6f 100644 --- a/Semantics.Test/Music/ChordGrammarTests.cs +++ b/Semantics.Test/Music/ChordGrammarTests.cs @@ -51,6 +51,30 @@ public class ChordGrammarTests [DataRow("C7+", "Caug7", new[] { 0, 4, 8, 10 })] [DataRow("C7+5", "Caug7", new[] { 0, 4, 8, 10 })] [DataRow("Cadd9(#11)", "C(#11)add9", new[] { 0, 4, 7, 14, 18 })] + [DataRow("C+7", "Caug7", new[] { 0, 4, 8, 10 })] + [DataRow("C+9", "Caug9", new[] { 0, 4, 8, 10, 14 })] + [DataRow("C+11", "Caug11", new[] { 0, 4, 8, 10, 14, 17 })] + [DataRow("C+13", "Caug13", new[] { 0, 4, 8, 10, 14, 17, 21 })] + [DataRow("Cm+7", "Cm7#5", new[] { 0, 3, 8, 10 })] + [DataRow("C+5", "Caug", new[] { 0, 4, 8 })] + [DataRow("C9+", "Caug9", new[] { 0, 4, 8, 10, 14 })] + [DataRow("Cmaj7sus4", "Cmaj7sus4", new[] { 0, 5, 7, 11 })] + [DataRow("CM7sus4", "Cmaj7sus4", new[] { 0, 5, 7, 11 })] + [DataRow("Cmaj7sus", "Cmaj7sus4", new[] { 0, 5, 7, 11 })] + [DataRow("Cmaj9sus4", "Cmaj9sus4", new[] { 0, 5, 7, 11, 14 })] + [DataRow("CM7sus2", "Cmaj7sus2", new[] { 0, 2, 7, 11 })] + [DataRow("Cm(maj7)", "Cmmaj7", new[] { 0, 3, 7, 11 })] + [DataRow("C-(maj7)", "Cmmaj7", new[] { 0, 3, 7, 11 })] + [DataRow("Cm(Δ7)", "Cmmaj7", new[] { 0, 3, 7, 11 })] + [DataRow("C7(9)", "C9", new[] { 0, 4, 7, 10, 14 })] + [DataRow("C7(13)", "C7add13", new[] { 0, 4, 7, 10, 21 })] + [DataRow("Cm7(9)", "Cm9", new[] { 0, 3, 7, 10, 14 })] + [DataRow("Cm7(11)", "Cm7add11", new[] { 0, 3, 7, 10, 17 })] + [DataRow("Cmaj7(9)", "Cmaj9", new[] { 0, 4, 7, 11, 14 })] + [DataRow("C7(9,13)", "C9add13", new[] { 0, 4, 7, 10, 14, 21 })] + [DataRow("C7(b9,13)", "C7b9add13", new[] { 0, 4, 7, 10, 13, 21 })] + [DataRow("C(9)", "Cadd9", new[] { 0, 4, 7, 14 })] + [DataRow("C6(9)", "C6/9", new[] { 0, 4, 7, 9, 14 })] public void Parse_GrammarSpellings_KeepTheirMeaning(string symbol, string canonical, int[] tones) { Chord chord = Chord.Parse(symbol); @@ -93,6 +117,15 @@ public void Parse_GrammarSpellings_KeepTheirMeaning(string symbol, string canoni [DataRow("C7b9#9no5")] [DataRow("C7b9#9add13")] [DataRow("C7(b9#9add13)")] + [DataRow("C7(9,9)")] + [DataRow("C9(9)")] + [DataRow("C7(maj7)")] + [DataRow("Cmaj(maj7)")] + [DataRow("C6(maj7)")] + [DataRow("C7(9")] + [DataRow("C(5)")] + [DataRow("Cm+sus4")] + [DataRow("CMsus4")] public void Parse_RejectsInvalidGrammar(string symbol) { Assert.IsFalse(Chord.TryParse(symbol, out Chord? chord), symbol); diff --git a/Semantics.Test/Music/ChordRoundTripTests.cs b/Semantics.Test/Music/ChordRoundTripTests.cs index d3a43cc..6388c74 100644 --- a/Semantics.Test/Music/ChordRoundTripTests.cs +++ b/Semantics.Test/Music/ChordRoundTripTests.cs @@ -18,6 +18,7 @@ public class ChordRoundTripTests "C6/9", "Cm6/9", "C6/9/G", "C7sus4", "C9sus4", "Cadd11", "Cadd13", "Cmadd11", "Cm#5", "C(#11)", "C(b9)", "C(b6)", "C7b5", "Cmaj7b5", "C9b5", "C7b5b9", "C(b5)", "A#(b5)", "C13b5", + "Cmaj7sus4", "Cmaj9sus4", "Cmaj7sus2", "Caug11", "Caug13", ]; [TestMethod] diff --git a/Semantics.Test/Music/ChromaticAnalysisTests.cs b/Semantics.Test/Music/ChromaticAnalysisTests.cs index a926e43..852a919 100644 --- a/Semantics.Test/Music/ChromaticAnalysisTests.cs +++ b/Semantics.Test/Music/ChromaticAnalysisTests.cs @@ -73,7 +73,7 @@ public void ChromaticAnalysis_Create_RejectsNegativeIndex() => [DataRow("D dorian", "4/4 Dm | B | Em", "V/ii")] [DataRow("A aeolian", "4/4 Am | A7 | Dm", "V/iv")] [DataRow("C major", "4/4 C | E7 | Am", "V/vi")] - [DataRow("A harmonic_minor", "4/4 Am | G | Am", "V/III+")] + [DataRow("A harmonic_minor", "4/4 Am | A7 | Dm", "V/iv")] public void ChromaticChords_NamesSecondaryDominantTargetByTheModesOwnTriad(string key, string progression, string expected) { System.Collections.Generic.IReadOnlyList analyses = diff --git a/Semantics.Test/Music/InversionTransposeTests.cs b/Semantics.Test/Music/InversionTransposeTests.cs index d93ded5..36e8366 100644 --- a/Semantics.Test/Music/InversionTransposeTests.cs +++ b/Semantics.Test/Music/InversionTransposeTests.cs @@ -31,6 +31,46 @@ public void Voice_SecondInversionRaisesRootAndThird() Assert.AreSequenceEqual(expected, midi); } + [TestMethod] + [DataRow("C9", 4, new[] { 74, 76, 79, 82, 84 })] + [DataRow("C13", 4, new[] { 74, 76, 77, 79, 81, 82, 84 })] + [DataRow("C13", 6, new[] { 81, 82, 84, 86, 88, 89, 91 })] + public void Voice_InversionsReachTheTensions(string symbol, int inversion, int[] expected) + { + int[] midi = [.. Chord.Parse(symbol).Voice(4, inversion).Select(p => p.Midi)]; + Assert.AreSequenceEqual(expected, midi); + } + + [TestMethod] + public void Voice_EveryInversionPutsItsChordToneLowest() + { + string[] symbols = ["C", "Cmaj7", "Cm9", "C11", "C13", "C13b9#11", "C6/9", "Cmaj7#11", "C7sus4", "Cadd9", "F#m7b5"]; + foreach (string symbol in symbols) + { + Chord chord = Chord.Parse(symbol); + IReadOnlyList tones = chord.ChordTones(); + for (int inversion = 0; inversion < tones.Count; inversion++) + { + IReadOnlyList voicing = chord.Voice(4, inversion); + Assert.AreEqual(PitchClass.Create(chord.Root.Value + tones[inversion]), voicing[0].PitchClass, $"{symbol} inversion {inversion}"); + Assert.HasCount(tones.Count, voicing, $"{symbol} inversion {inversion}"); + for (int i = 1; i < voicing.Count; i++) + { + Assert.IsGreaterThan(voicing[i - 1].Midi, voicing[i].Midi, $"{symbol} inversion {inversion}"); + } + } + } + } + + [TestMethod] + public void Voice_OutOfMidiRangeThrowsOneException() + { + _ = Assert.ThrowsExactly(() => Chord.Parse("B").Voice(9)); + _ = Assert.ThrowsExactly(() => Chord.Parse("G").Voice(9)); + _ = Assert.ThrowsExactly(() => Chord.Parse("C/G").Voice(-1)); + _ = Assert.ThrowsExactly(() => Chord.Parse("C").Voice(int.MaxValue)); + } + [TestMethod] public void Chord_TransposePreservesQualityAndMovesRootAndBass() { diff --git a/Semantics.Test/Music/RomanNumeralParseTests.cs b/Semantics.Test/Music/RomanNumeralParseTests.cs index 630f86f..bca02d2 100644 --- a/Semantics.Test/Music/RomanNumeralParseTests.cs +++ b/Semantics.Test/Music/RomanNumeralParseTests.cs @@ -86,6 +86,64 @@ public void Parse_IsInverseOfRomanNumeralOf() } } + [TestMethod] + [DataRow("Vb9", 7, ChordTensions.FlatNine)] + [DataRow("V#9", 7, ChordTensions.SharpNine)] + [DataRow("Vb13", 7, ChordTensions.FlatThirteen)] + [DataRow("Ib5", 0, ChordTensions.None)] + public void Parse_AnAlterationAfterAnUpperCaseNumeralKeepsTheRoot(string numeral, int root, ChordTensions tensions) + { + Chord chord = CMajor.ChordFromRomanNumeral(numeral); + Assert.AreEqual(root, chord.Root.Value, numeral); + Assert.AreEqual(tensions, chord.Tensions, numeral); + } + + [TestMethod] + public void Parse_IbFiveIsAMajorFlatFiveTriad() => + Assert.AreEqual(ChordQuality.MajorFlatFive, CMajor.ChordFromRomanNumeral("Ib5").Quality); + + [TestMethod] + public void RomanNumeralOf_KeepsSixthsTensionsAndSuspensions() + { + (string Symbol, string Numeral)[] cases = + [ + ("C6", "I6"), ("Am6", "vi6"), ("C6/9", "I6/9"), ("Cadd9", "Iadd9"), ("C9", "I9"), + ("C7b9", "I7b9"), ("G13", "V13"), ("Cmaj7#11", "Imaj7#11"), ("Cmaj7sus4", "Imaj7sus4"), + ("G7sus4", "V7sus4"), ("C(b5)", "I(b5)"), ("Bm7b5", "viiø7"), ("Bm9b5", "viiø9"), + ("Bdim7", "vii°7"), ("Caug7", "I+7"), ("Cm#5", "i#5"), + ]; + + foreach ((string symbol, string numeral) in cases) + { + Assert.AreEqual(numeral, CMajor.RomanNumeralOf(Chord.Parse(symbol)), symbol); + } + } + + [TestMethod] + public void FromRomanNumeral_InvertsRomanNumeralOf_ForEveryChordWithoutABass() + { + Key[] keys = [CMajor, CMinor]; + string[] bodies = + [ + "", "m", "dim", "aug", "sus2", "sus4", "5", "(b5)", "m#5", "6", "m6", "6/9", "add9", "madd11", + "7", "maj7", "m7", "mmaj7", "dim7", "m7b5", "m9b5", "aug7", "7b5", "maj7b5", "m7#5", + "9", "m9", "maj9", "11", "13", "7b9", "7#9", "7#11", "7b13", "13b9#11", "maj7#11", + "7sus4", "9sus4", "maj7sus4", "maj9sus4", "maj7sus2", "7add13", "(b9)", "(#11)", "7no3", "dimmaj7", + ]; + + foreach (Key key in keys) + { + foreach (PitchClass degree in key.Scale.PitchClasses) + { + foreach (Chord chord in bodies.Select(body => Chord.Parse(degree.Name + body))) + { + string numeral = key.RomanNumeralOf(chord); + Assert.AreEqual(chord, key.ChordFromRomanNumeral(numeral), $"{chord} in {key} -> {numeral}"); + } + } + } + } + [TestMethod] public void Parse_RejectsGarbage() {