< Summary

Information
Class: MusicTheory.Theory.Harmony.Chord
Assembly: MusicTheory
File(s): /home/runner/work/MusicTheory/MusicTheory/Theory/Harmony/ChordRomanizer.cs
Line coverage
85%
Covered lines: 12
Uncovered lines: 2
Coverable lines: 14
Total lines: 666
Line coverage: 85.7%
Branch coverage
80%
Covered branches: 8
Total branches: 10
Branch coverage: 80%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_RootPc()100%11100%
PitchClasses()80%101084.61%

File(s)

/home/runner/work/MusicTheory/MusicTheory/Theory/Harmony/ChordRomanizer.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Linq;
 4using MusicTheory.Theory.Pitch;
 5
 6namespace MusicTheory.Theory.Harmony;
 7
 8public enum ChordQuality { Major, Minor, Diminished, Augmented, DominantSeventh, MinorSeventh, MajorSeventh, HalfDiminis
 9
 13417910public readonly record struct Chord(int RootPc, ChordQuality Quality)
 11{
 12    public IEnumerable<int> PitchClasses()
 13    {
 2947414        return Quality switch
 2947415        {
 1176416            ChordQuality.Major => new[] { RootPc, (RootPc + 4) % 12, (RootPc + 7) % 12 },
 131017            ChordQuality.Minor => new[] { RootPc, (RootPc + 3) % 12, (RootPc + 7) % 12 },
 11718            ChordQuality.Diminished => new[] { RootPc, (RootPc + 3) % 12, (RootPc + 6) % 12 },
 019            ChordQuality.Augmented => new[] { RootPc, (RootPc + 4) % 12, (RootPc + 8) % 12 },
 1023620            ChordQuality.DominantSeventh => new[] { RootPc, (RootPc + 4) % 12, (RootPc + 7) % 12, (RootPc + 10) % 12 }, 
 298421            ChordQuality.MinorSeventh => new[] { RootPc, (RootPc + 3) % 12, (RootPc + 7) % 12, (RootPc + 10) % 12 },
 248122            ChordQuality.MajorSeventh => new[] { RootPc, (RootPc + 4) % 12, (RootPc + 7) % 12, (RootPc + 11) % 12 },
 48623            ChordQuality.HalfDiminishedSeventh => new[] { RootPc, (RootPc + 3) % 12, (RootPc + 6) % 12, (RootPc + 10) % 
 9624            ChordQuality.DiminishedSeventh => new[] { RootPc, (RootPc + 3) % 12, (RootPc + 6) % 12, (RootPc + 9) % 12 },
 025            _ => Array.Empty<int>()
 2947426        };
 27    }
 28}
 29
 30public static class ChordRomanizer
 31{
 32    // Detect dominant ninth on V (degree 5). Accepts 5-note (1,3,5,b7,9) or 4-note without 5th (1,3,b7,9).
 33    // Returns romanText "V9" when matched.
 34    public static bool TryRomanizeDominantNinth(int[] pcs, Key key, out string? romanText)
 35    {
 36        romanText = null;
 37        // Normalize to distinct, ordered set first and base checks on that to avoid flakiness
 38        var set = pcs.Select(p => ((p % 12) + 12) % 12).Distinct().OrderBy(x => x).ToArray();
 39        if (set.Length < 4 || set.Length > 5) return false; // 4 (omit 5th) or 5 (full)
 40        // V root (degree index 4)
 41        int vRoot = DegreeRootPc(key, 4);
 42        int vThird = (vRoot + 4) % 12; // major third on V (harmonic minor accounted by leading tone in DegreeRootPc usa
 43        int vFifth = (vRoot + 7) % 12;
 44        int vSeventh = (vRoot + 10) % 12; // dominant seventh
 45        int vNinth = (vRoot + 2) % 12;    // diatonic 9th
 46
 47        // Allowed members and required core for recognition
 48        var allowed = new HashSet<int>(new[] { vRoot, vThird, vFifth, vSeventh, vNinth });
 49        // All notes must be allowed (avoid supersets with alterations), and we need the core tones present
 50        bool allAllowed = set.All(allowed.Contains);
 51        bool hasCore = set.Contains(vRoot) && set.Contains(vThird) && set.Contains(vSeventh) && set.Contains(vNinth);
 52    if (allAllowed && hasCore)
 53        {
 54            romanText = "V9";
 55            return true;
 56        }
 57        return false;
 58    }
 59
 60    // Augmented Sixth chords: Italian (It6), French (Fr43), German (Ger65)
 61    // Detection by pitch-class sets relative to key: {b6, 1, #4} (+ {2} for French, + {b3} for German)
 62    // Returns conventional labels It6 / Fr43 / Ger65. Voicing is ignored for now (canonical figures are emitted).
 63    public static bool TryRomanizeAugmentedSixth(int[] pcs, Key key, FourPartVoicing? voicing, out string? romanText)
 64        => TryRomanizeAugmentedSixth(pcs, key, HarmonyOptions.Default, voicing, out romanText);
 65
 66    public static bool TryRomanizeAugmentedSixth(int[] pcs, Key key, HarmonyOptions options, FourPartVoicing? voicing, o
 67    {
 68        romanText = null;
 69        if (pcs.Length < 3) return false;
 70        var set = pcs.Select(p => ((p % 12) + 12) % 12).Distinct().OrderBy(x => x).ToArray();
 71
 72        int tonic = (key.TonicMidi % 12 + 12) % 12;
 73        int b6 = (tonic + 8) % 12;   // b6
 74        int sharp4 = (tonic + 6) % 12; // #4
 75        int one = tonic;
 76        int two = (tonic + 2) % 12;
 77        int flat3 = (tonic + 3) % 12;
 78
 79        bool contains(params int[] pcsReq) => pcsReq.All(pc => set.Contains(pc));
 80
 81        // Require explicit voicing with b6 in the bass to avoid confusion with mixture bVI7
 82    if (voicing is null) return false;
 83        int bass = ((voicing.Value.B % 12) + 12) % 12;
 84        if (bass != b6) return false;
 85        // Additional disambiguation: avoid labeling as Aug6 when soprano is also b6 (common for Ab7 mixture voicing)
 86        // This preserves bVI7 root-position labels in tests while still preferring Aug6 in other voicings.
 87        int soprano = ((voicing.Value.S % 12) + 12) % 12;
 88    if (options.DisallowAugmentedSixthWhenSopranoFlat6 && soprano == b6)
 89            return false;
 90
 91        // Exact-size matches to avoid subset/superset confusion with seventh chords
 92        // Prefer 4-note variants over 3-note if sizes match
 93        if (set.Length == 4)
 94        {
 95            // German sixth (Ger65): {b6, 1, b3, #4} — identical PC set to bVI7 (dominant seventh on bVI)
 96            if (contains(b6, one, flat3, sharp4))
 97            {
 98                // When preference requests mixture seventh over Aug6 for ambiguous sets, defer labeling here
 99                if (options.PreferMixtureSeventhOverAugmentedSixthWhenAmbiguous)
 100                {
 101                    return false; // allow bVI7 detection to take precedence
 102                }
 103                romanText = "Ger65"; return true;
 104            }
 105            // French sixth (Fr43): {b6, 1, 2, #4} — distinct from any mixture seventh; safe to label directly
 106            if (contains(b6, one, two, sharp4)) { romanText = "Fr43"; return true; }
 107        }
 108        if (set.Length == 3 && contains(b6, one, sharp4))
 109        {
 110            romanText = "It6"; return true;
 111        }
 112        return false;
 113    }
 114    // Very small helper to detect diatonic roman numerals in given key for triads.
 115    public static bool TryRomanizeTriad(int[] pcs, Key key, out RomanNumeral rn)
 116    {
 117        rn = default;
 118        if (pcs.Length == 0) return false;
 119        var set = pcs.Select(p => ((p % 12) + 12) % 12).Distinct().OrderBy(x => x).ToArray();
 120        // find best-fitting degree by root match
 121        for (int degree = 0; degree < 7; degree++)
 122        {
 123            int degPc = DegreeRootPc(key, degree);
 124            // Build expected triad quality in key
 125            var quality = TriadQualityInKey(degree, key.IsMajor);
 126            var chord = new Chord(degPc, quality);
 127            var expected = chord.PitchClasses().OrderBy(x => x).ToArray();
 128            // Require exact triad match (avoid matching subsets of seventh chords)
 129            if (expected.All(p => set.Contains(p)) && set.Length == expected.Length)
 130            {
 131                rn = QualityToRoman(degree, quality, key.IsMajor);
 132                return true;
 133            }
 134        }
 135        return false;
 136    }
 137
 138    // Modal mixture (borrowed chords) — minimal support for major keys:
 139    // i, iv, bIII, bVI, bVII
 140    public static bool TryRomanizeTriadMixture(int[] pcs, Key key, out RomanNumeral rn, out string? romanText)
 141    {
 142        rn = default; romanText = null;
 143        if (pcs.Length == 0) return false;
 144        var set = pcs.Select(p => ((p % 12) + 12) % 12).Distinct().OrderBy(x => x).ToArray();
 145        int tonicPc = (key.TonicMidi % 12 + 12) % 12;
 146
 147        // Candidates: (rootPc, quality, rn, romanText)
 148        var candidates = new List<(int root, ChordQuality q, RomanNumeral rn, string txt)>();
 149        // Neapolitan (bII) — available in major/minor
 150        candidates.Add(((tonicPc + 1) % 12, ChordQuality.Major, RomanNumeral.II, "bII"));
 151
 152        if (key.IsMajor)
 153        {
 154            candidates.Add((tonicPc, ChordQuality.Minor, RomanNumeral.i, "i"));                 // i (minor tonic)
 155            candidates.Add(((key.ScaleDegreeMidi(3) % 12 + 12) % 12, ChordQuality.Minor, RomanNumeral.iv, "iv")); // iv 
 156            candidates.Add(((tonicPc + 3) % 12, ChordQuality.Major, RomanNumeral.III, "bIII")); // bIII
 157            candidates.Add(((tonicPc + 8) % 12, ChordQuality.Major, RomanNumeral.VI, "bVI"));   // bVI
 158            candidates.Add(((tonicPc + 10) % 12, ChordQuality.Major, RomanNumeral.VII, "bVII")); // bVII (Mixolydian bor
 159        }
 160
 161        foreach (var c in candidates)
 162        {
 163            var chord = new Chord(c.root, c.q).PitchClasses().OrderBy(x => x).ToArray();
 164            // Require exact-size match to avoid matching a subset of a seventh chord
 165            if (chord.All(p => set.Contains(p)) && set.Length == chord.Length)
 166            {
 167                rn = c.rn;
 168                romanText = c.txt;
 169                return true;
 170            }
 171        }
 172        return false;
 173    }
 174
 175    // Modal mixture sevenths for major/minor: iv7 (minor seventh), bVII7 (dominant seventh), bII7 (dominant seventh)
 176    public static bool TryRomanizeSeventhMixture(int[] pcs, Key key, FourPartVoicing? voicing, out string? romanText)
 177        => TryRomanizeSeventhMixture(pcs, key, HarmonyOptions.Default, voicing, out romanText);
 178
 179    public static bool TryRomanizeSeventhMixture(int[] pcs, Key key, HarmonyOptions options, FourPartVoicing? voicing, o
 180    {
 181        romanText = null;
 182        if (pcs.Length == 0) return false;
 183        var set = pcs.Select(p => ((p % 12) + 12) % 12).Distinct().OrderBy(x => x).ToArray();
 184        int tonicPc = (key.TonicMidi % 12 + 12) % 12;
 185
 186        var candidates = new List<(int root, ChordQuality q, string txtBase)>();
 187        if (key.IsMajor)
 188        {
 189            // iv7: minor seventh on degree IV (borrowed from parallel minor)
 190            int ivRoot = (key.ScaleDegreeMidi(3) % 12 + 12) % 12; // IV degree root pc
 191            candidates.Add((ivRoot, ChordQuality.MinorSeventh, "iv"));
 192        }
 193        // bVII7: dominant seventh built on bVII (common in major/minor)
 194        int bVIIroot = (tonicPc + 10) % 12;
 195        candidates.Add((bVIIroot, ChordQuality.DominantSeventh, "bVII"));
 196    // bII7: dominant seventh on Neapolitan root (also common as tritone sub)
 197        int bIIroot = (tonicPc + 1) % 12;
 198        candidates.Add((bIIroot, ChordQuality.DominantSeventh, "bII"));
 199    // bVI7: dominant seventh on bVI (strict exact 4-note match)
 200    int bVIroot = (tonicPc + 8) % 12;
 201    candidates.Add((bVIroot, ChordQuality.DominantSeventh, "bVI"));
 202
 203        foreach (var c in candidates)
 204        {
 205            var chord = new Chord(c.root, c.q).PitchClasses().OrderBy(x => x).ToArray();
 206            // require exact-size match (avoid subset/superset confusion with triads)
 207            if (!(chord.All(p => set.Contains(p)) && set.Length == chord.Length)) continue;
 208
 209            // Note: Augmented Sixth 優先は Analyzer 側の順序で一元的に処理します
 210            // (ここでは bVI7 の純粋な一致のみを扱い、Aug6 へのプリエンプトは行いません)。
 211
 212            // No voicing: root-position label
 213            if (voicing is null)
 214            {
 215                romanText = c.txtBase + "7";
 216                return true;
 217            }
 218
 219            // With voicing: figure the inversion
 220            int bassPc = voicing.Value.B % 12; if (bassPc < 0) bassPc += 12;
 221            int root = c.root;
 222            int third = chord.First(pc => pc != root && (((pc - root + 12) % 12) == 3 || ((pc - root + 12) % 12) == 4));
 223            int fifth = (root + ((c.q == ChordQuality.MinorSeventh ? 7 : 7))) % 12; // 5th is perfect
 224            int sevInt = c.q switch { ChordQuality.MajorSeventh => 11, ChordQuality.DominantSeventh or ChordQuality.Mino
 225            int sev = (root + sevInt) % 12;
 226
 227            if      (bassPc == root) romanText = c.txtBase + "7";
 228            else if (bassPc == third) romanText = c.txtBase + "65";
 229            else if (bassPc == fifth) romanText = c.txtBase + "43";
 230            else if (bassPc == sev) romanText = c.txtBase + "42";
 231            else romanText = c.txtBase + "7";
 232            return true;
 233        }
 234
 235        return false;
 236    }
 237
 238    // Secondary dominants: V/x triad and V/x7 (x in {ii, iii, IV, V, vi, vii}) relative to current key
 239    // If voicing is provided and seventh quality matches, include inversion figures (7, 65, 43, 42) before "/x".
 240    public static bool TryRomanizeSecondaryDominant(int[] pcs, Key key, FourPartVoicing? voicing, out string? romanText)
 241    {
 242        romanText = null;
 243        if (pcs.Length == 0) return false;
 244        var set = pcs.Select(p => ((p % 12) + 12) % 12).Distinct().OrderBy(x => x).ToArray();
 245        // Early disambiguation: if the set is exactly a major triad, infer secRoot directly
 246        // and map to its unique target degree (targetPc = secRoot - 7 mod 12). This prevents
 247        // rare mislabeling such as E–G#–B (V/vi) being picked as V/iii.
 248    if (set.Length == 3)
 249        {
 250            for (int r = 0; r < 12; r++)
 251            {
 252        var majorTri = new Chord(r, ChordQuality.Major).PitchClasses().OrderBy(x => x).ToArray();
 253        if (majorTri.SequenceEqual(set))
 254                {
 255                    int secRoot = r;
 256                    int targetPc = (secRoot + 5) % 12; // -7 mod 12 == +5
 257                    int degree = -1;
 258                    for (int d = 0; d < 7; d++)
 259                    {
 260                        if (DegreeRootPc(key, d) == targetPc) { degree = d; break; }
 261                    }
 262                    if (degree != -1)
 263                    {
 264                        string targetTxt = DegreeRomanForTarget(degree, key.IsMajor);
 265                        if (voicing is FourPartVoicing vTri)
 266                        {
 267                            int bass = ((vTri.B % 12) + 12) % 12;
 268                            int root = secRoot;
 269                            int third = (root + 4) % 12;
 270                            int fifth = (root + 7) % 12;
 271                            if      (bass == root)   romanText = $"V/{targetTxt}";
 272                            else if (bass == third)  romanText = $"V6/{targetTxt}";
 273                            else if (bass == fifth)  romanText = $"V64/{targetTxt}";
 274                            else romanText = $"V/{targetTxt}";
 275                        }
 276                        else
 277                        {
 278                            romanText = $"V/{targetTxt}";
 279                        }
 280                        return true;
 281                    }
 282                }
 283            }
 284        }
 285        // Prefer common targets first to avoid rare ambiguous picks
 286        int[] degreeOrder = new[] { 4, 1, 5, 3, 2, 6 }; // V, ii, vi, IV, iii, vii
 287        foreach (int deg in degreeOrder)
 288        {
 289            int targetPc = DegreeRootPc(key, deg);
 290            int secRoot = (targetPc + 7) % 12; // V of target
 291            var tri = new Chord(secRoot, ChordQuality.Major).PitchClasses().OrderBy(x => x).ToArray();
 292            var sev = new Chord(secRoot, ChordQuality.DominantSeventh).PitchClasses().OrderBy(x => x).ToArray();
 293
 294            string targetTxt = DegreeRomanForTarget(deg, key.IsMajor);
 295            if (tri.SequenceEqual(set))
 296            {
 297                // Triad case: if voicing provided, add inversion figures (6 or 64) before '/x'
 298                if (voicing is FourPartVoicing vTri)
 299                {
 300                    int bass = ((vTri.B % 12) + 12) % 12;
 301                    int root = secRoot;
 302                    int third = (root + 4) % 12;
 303                    int fifth = (root + 7) % 12;
 304                    if      (bass == root)   romanText = $"V/{targetTxt}";
 305                    else if (bass == third)  romanText = $"V6/{targetTxt}";
 306                    else if (bass == fifth)  romanText = $"V64/{targetTxt}";
 307                    else romanText = $"V/{targetTxt}";
 308                }
 309                else
 310                {
 311                    romanText = $"V/{targetTxt}";
 312                }
 313                return true;
 314            }
 315            if (sev.SequenceEqual(set))
 316            {
 317                // Seventh with optional inversion figures when voicing is present
 318                string fig = "7";
 319                if (voicing is FourPartVoicing v)
 320                {
 321                    int bass = ((v.B % 12) + 12) % 12;
 322                    int root = secRoot;
 323                    int third = (root + 4) % 12;
 324                    int fifth = (root + 7) % 12;
 325                    int seventh = (root + 10) % 12;
 326                    if      (bass == root)   fig = "7";
 327                    else if (bass == third)  fig = "65";
 328                    else if (bass == fifth)  fig = "43";
 329                    else if (bass == seventh)fig = "42";
 330                }
 331                romanText = $"V{fig}/{targetTxt}";
 332                return true;
 333            }
 334        }
 335        return false;
 336    }
 337
 338    // Secondary leading-tone chords: vii°/x (triad) and vii°7/ x or viiø7/ x (seventh), with optional inversion figures
 339    public static bool TryRomanizeSecondaryLeadingTone(int[] pcs, Key key, FourPartVoicing? voicing, out string? romanTe
 340        => TryRomanizeSecondaryLeadingTone(pcs, key, voicing, HarmonyOptions.Default, out romanText);
 341
 342    public static bool TryRomanizeSecondaryLeadingTone(int[] pcs, Key key, FourPartVoicing? voicing, HarmonyOptions opti
 343    {
 344        romanText = null;
 345        if (pcs.Length == 0) return false;
 346        var set = pcs.Select(p => ((p % 12) + 12) % 12).Distinct().OrderBy(x => x).ToArray();
 347        // Guard: if this chord is already a diatonic seventh, don't treat it as secondary
 348        if (TryRomanizeSeventh(pcs, key, out var _rnDia, out var _degDia, out var _qDia))
 349            return false;
 350
 351        // Ultra-strong preference: if the set is a fully-diminished seventh (0,3,6,9 pattern),
 352        // and it exactly matches vii°7/V, prefer that target. If voicing is provided, include inversion figures.
 353        if (set.Length == 4 && options.PreferSecondaryLeadingToneTargetV)
 354        {
 355            var norm = set.Select(pc => (pc - set[0] + 12) % 12).OrderBy(x => x).ToArray();
 356            if (norm.SequenceEqual(new[] { 0, 3, 6, 9 }))
 357            {
 358                int vRoot = DegreeRootPc(key, 4);
 359                int ltRootV = (vRoot + 11) % 12;
 360                var dimV = new Chord(ltRootV, ChordQuality.DiminishedSeventh).PitchClasses().OrderBy(x => x).ToArray();
 361                if (dimV.All(set.Contains))
 362                {
 363                    if (voicing is FourPartVoicing v)
 364                    {
 365                        int bass = ((v.B % 12) + 12) % 12;
 366                        int root = ltRootV;
 367                        int third = (root + 3) % 12;
 368                        int fifth = (root + 6) % 12;
 369                        int seventh = (root + 9) % 12;
 370                        string fig = bass == root ? "7" : bass == third ? "65" : bass == fifth ? "43" : bass == seventh 
 371                        romanText = $"vii°{fig}/V";
 372                    }
 373                    else
 374                    {
 375                        romanText = "vii°7/V";
 376                    }
 377                    return true;
 378                }
 379            }
 380        }
 381
 382        // Strong preference: try target V first explicitly
 383    {
 384            int targetPc = DegreeRootPc(key, 4); // V
 385            int ltRoot = (targetPc + 11) % 12;
 386            var dim7 = new Chord(ltRoot, ChordQuality.DiminishedSeventh).PitchClasses().OrderBy(x => x).ToArray();
 387            var halfDim7 = new Chord(ltRoot, ChordQuality.HalfDiminishedSeventh).PitchClasses().OrderBy(x => x).ToArray(
 388            var tri = new Chord(ltRoot, ChordQuality.Diminished).PitchClasses().OrderBy(x => x).ToArray();
 389            bool matchDim7 = (dim7.Length == set.Length && dim7.All(set.Contains));
 390            bool matchHalf7 = (halfDim7.Length == set.Length && halfDim7.All(set.Contains));
 391            bool matchTri = (tri.Length == set.Length && tri.All(set.Contains));
 392            if (matchDim7 || matchHalf7 || matchTri)
 393            {
 394                string head;
 395                if (matchDim7) head = "vii°";
 396                else if (matchHalf7) head = "viiø";
 397                else head = "vii°"; // triad case uses °
 398                string fig = (set.Length == 4) ? "7" : "";
 399                if (voicing is FourPartVoicing v && set.Length == 4)
 400                {
 401                    int bass = ((v.B % 12) + 12) % 12;
 402                    int root = ltRoot;
 403                    int third = (root + 3) % 12;
 404                    int fifth = (root + 6) % 12;
 405                    int seventh = ((dim7.Length == 4 && dim7.All(set.Contains)) ? (root + 9) : (root + 10)) % 12;
 406                    if      (bass == root)   fig = "7";
 407                    else if (bass == third)  fig = "65";
 408                    else if (bass == fifth)  fig = "43";
 409                    else if (bass == seventh)fig = "42";
 410                }
 411                else if (voicing is FourPartVoicing vTri && set.Length == 3 && matchTri)
 412                {
 413                    int bass = ((vTri.B % 12) + 12) % 12;
 414                    int root = ltRoot;
 415                    int third = (root + 3) % 12;
 416                    int fifth = (root + 6) % 12;
 417                    if      (bass == root)   fig = "";
 418                    else if (bass == third)  fig = "6";
 419                    else if (bass == fifth)  fig = "64";
 420                }
 421                romanText = $"{head}{fig}/V";
 422                return true;
 423            }
 424        }
 425        // Prefer the dominant target (V) when enharmonic ambiguity exists, then common goals.
 426        // Degree indices: 0..6 (0=I). We'll try: V(4), ii(1), vi(5), IV(3), iii(2), vii(6)
 427    int[] degreeOrder = new[] { 4, 1, 5, 3, 2, 6 };
 428    foreach (var deg in degreeOrder)
 429        {
 430            int targetPc = DegreeRootPc(key, deg);
 431            int ltRoot = (targetPc + 11) % 12; // leading tone to the target (−1 semitone)
 432
 433            var tri = new Chord(ltRoot, ChordQuality.Diminished).PitchClasses().OrderBy(x => x).ToArray();
 434            var halfDim7 = new Chord(ltRoot, ChordQuality.HalfDiminishedSeventh).PitchClasses().OrderBy(x => x).ToArray(
 435            var dim7 = new Chord(ltRoot, ChordQuality.DiminishedSeventh).PitchClasses().OrderBy(x => x).ToArray();
 436
 437            string targetTxt = DegreeRomanForTarget(deg, key.IsMajor, includeDiminishedOnVII: true);
 438            if (tri.Length == set.Length && tri.All(set.Contains))
 439            {
 440                // Triad case: if voicing provided, add inversion figures (6 or 64) before '/x'
 441                if (voicing is FourPartVoicing vTri)
 442                {
 443                    int bass = ((vTri.B % 12) + 12) % 12;
 444                    int root = ltRoot;
 445                    // diminished triad: minor third + diminished fifth
 446                    int third = (root + 3) % 12;
 447                    int fifth = (root + 6) % 12;
 448                    string head = "vii°";
 449                    if      (bass == root)   romanText = $"{head}/{targetTxt}";
 450                    else if (bass == third)  romanText = $"{head}6/{targetTxt}";
 451                    else if (bass == fifth)  romanText = $"{head}64/{targetTxt}";
 452                    else romanText = $"{head}/{targetTxt}";
 453                }
 454                else
 455                {
 456                    romanText = $"vii°/{targetTxt}";
 457                }
 458                return true;
 459            }
 460
 461            // Check fully-diminished first, then half-diminished
 462            if (dim7.Length == set.Length && dim7.All(set.Contains))
 463            {
 464                string head = "vii°"; string fig = "7";
 465                if (voicing is FourPartVoicing v)
 466                {
 467                    int bass = ((v.B % 12) + 12) % 12;
 468                    int root = ltRoot;
 469                    int third = (root + 3) % 12;
 470                    int fifth = (root + 6) % 12;
 471                    int seventh = (root + 9) % 12;
 472                    if      (bass == root)   fig = "7";
 473                    else if (bass == third)  fig = "65";
 474                    else if (bass == fifth)  fig = "43";
 475                    else if (bass == seventh)fig = "42";
 476                }
 477                romanText = $"{head}{fig}/{targetTxt}";
 478                return true;
 479            }
 480            if (halfDim7.Length == set.Length && halfDim7.All(set.Contains))
 481            {
 482                string head = "viiø"; string fig = "7";
 483                if (voicing is FourPartVoicing v)
 484                {
 485                    int bass = ((v.B % 12) + 12) % 12;
 486                    int root = ltRoot;
 487                    int third = (root + 3) % 12;
 488                    int fifth = (root + 6) % 12;
 489                    int seventh = (root + 10) % 12;
 490                    if      (bass == root)   fig = "7";
 491                    else if (bass == third)  fig = "65";
 492                    else if (bass == fifth)  fig = "43";
 493                    else if (bass == seventh)fig = "42";
 494                }
 495                romanText = $"{head}{fig}/{targetTxt}";
 496                return true;
 497            }
 498    }
 499        return false;
 500    }
 501
 502    private static string DegreeRomanForTarget(int degree, bool isMajor, bool includeDiminishedOnVII = false)
 503    {
 504        // degree: 0..6 (0=I)
 505        // Use diatonic triad qualities for case; optionally include ° on VII
 506        if (isMajor)
 507        {
 508            return degree switch
 509            {
 510                0 => "I",
 511                1 => "ii",
 512                2 => "iii",
 513                3 => "IV",
 514                4 => "V",
 515                5 => "vi",
 516                6 => includeDiminishedOnVII ? "vii°" : "vii",
 517                _ => "?"
 518            };
 519        }
 520        else
 521        {
 522            // harmonic minor baseline
 523            return degree switch
 524            {
 525                0 => "i",
 526                1 => "ii°",  // diminished triad on ii in harmonic minor baseline
 527                2 => "III",
 528                3 => "iv",
 529                4 => "V",
 530                5 => "VI",
 531                6 => includeDiminishedOnVII ? "vii°" : "vii",
 532                _ => "?"
 533            };
 534        }
 535    }
 536
 537    private static int DegreeRootPc(Key key, int degree)
 538    {
 539        if (key.IsMajor)
 540        {
 541            return (key.ScaleDegreeMidi(degree) % 12 + 12) % 12;
 542        }
 543        else
 544        {
 545            // Harmonic minor: degree 6 is leading tone (tonic - 1)
 546            if (degree == 6)
 547            {
 548                int tonicPc = (key.TonicMidi % 12 + 12) % 12;
 549                return (tonicPc + 11) % 12;
 550            }
 551            return (key.ScaleDegreeMidi(degree) % 12 + 12) % 12;
 552        }
 553    }
 554
 555    // Diatonic seventh support: recognize diatonic seventh chords in major and (harmonic) minor
 556    public static bool TryRomanizeSeventh(int[] pcs, Key key, out RomanNumeral rn, out int degreeOut, out ChordQuality q
 557    {
 558        rn = default; degreeOut = -1; qualityOut = ChordQuality.Unknown;
 559        if (pcs.Length == 0) return false;
 560        var set = pcs.Select(p => ((p % 12) + 12) % 12).Distinct().OrderBy(x => x).ToArray();
 561        for (int degree = 0; degree < 7; degree++)
 562        {
 563            var q7 = SeventhQualityInKey(degree, key.IsMajor);
 564            if (q7 is null) continue;
 565            int degPc = DegreeRootPc(key, degree);
 566            var chord = new Chord(degPc, q7.Value);
 567            var expected = chord.PitchClasses().OrderBy(x => x).ToArray();
 568            // exact match: avoid matching subsets/supersets
 569            if (expected.All(p => set.Contains(p)) && set.Length == expected.Length)
 570            {
 571                var triQ = TriadQualityInKey(degree, key.IsMajor);
 572                rn = QualityToRoman(degree, triQ, key.IsMajor);
 573                degreeOut = degree;
 574                qualityOut = q7.Value;
 575                return true;
 576            }
 577        }
 578        return false;
 579    }
 580
 581    private static ChordQuality? SeventhQualityInKey(int degree, bool isMajor)
 582    {
 583        if (isMajor)
 584        {
 585            return degree switch
 586            {
 587                0 => ChordQuality.MajorSeventh,          // Imaj7
 588                1 => ChordQuality.MinorSeventh,          // ii7
 589                2 => ChordQuality.MinorSeventh,          // iii7
 590                3 => ChordQuality.MajorSeventh,          // IVmaj7
 591                4 => ChordQuality.DominantSeventh,       // V7
 592                5 => ChordQuality.MinorSeventh,          // vi7
 593                6 => ChordQuality.HalfDiminishedSeventh, // viiø7
 594                _ => null
 595            };
 596        }
 597        else
 598        {
 599            // Harmonic minor diatonic sevenths
 600            return degree switch
 601            {
 602                0 => ChordQuality.MinorSeventh,             // i7
 603                1 => ChordQuality.HalfDiminishedSeventh,     // iiø7
 604                2 => ChordQuality.MajorSeventh,              // IIImaj7
 605                3 => ChordQuality.MinorSeventh,              // iv7
 606                4 => ChordQuality.DominantSeventh,           // V7
 607                5 => ChordQuality.MajorSeventh,              // VImaj7
 608                6 => ChordQuality.DiminishedSeventh,         // vii°7
 609                _ => null
 610            };
 611        }
 612    }
 613
 614    private static RomanNumeral QualityToRoman(int degree, ChordQuality q, bool isMajor)
 615    {
 616        bool minorLike = q == ChordQuality.Minor || q == ChordQuality.Diminished;
 617        var baseIndex = degree; // 0..6
 618        return (baseIndex, minorLike, isMajor) switch
 619        {
 620            (0, false, _) => RomanNumeral.I,
 621            (1, false, _) => RomanNumeral.II,
 622            (2, false, _) => RomanNumeral.III,
 623            (3, false, _) => RomanNumeral.IV,
 624            (4, false, _) => RomanNumeral.V,
 625            (5, false, _) => RomanNumeral.VI,
 626            (6, false, _) => RomanNumeral.VII,
 627            (0, true, _) => RomanNumeral.i,
 628            (1, true, _) => RomanNumeral.ii,
 629            (2, true, _) => RomanNumeral.iii,
 630            (3, true, _) => RomanNumeral.iv,
 631            (4, true, _) => RomanNumeral.v,
 632            (5, true, _) => RomanNumeral.vi,
 633            (6, true, _) => RomanNumeral.vii,
 634            _ => RomanNumeral.I
 635        };
 636    }
 637
 638    private static ChordQuality TriadQualityInKey(int degree, bool isMajor)
 639    {
 640        // Diatonic triad qualities in major/minor (natural minor for start)
 641        if (isMajor)
 642        {
 643            return degree switch
 644            {
 645                0 or 3 or 4 => ChordQuality.Major, // I, IV, V
 646                1 or 2 or 5 => ChordQuality.Minor, // ii, iii, vi
 647                6 => ChordQuality.Diminished,      // vii°
 648                _ => ChordQuality.Unknown
 649            };
 650        }
 651        else
 652        {
 653            // Harmonic minor variant by default:
 654            // i, ii°, III, iv, V, VI, vii°
 655            return degree switch
 656            {
 657                0 or 3 => ChordQuality.Minor,        // i, iv
 658                2 or 5 => ChordQuality.Major,        // III, VI
 659                1 => ChordQuality.Diminished,        // ii°
 660                4 => ChordQuality.Major,             // V (raised 7 as 3rd)
 661                6 => ChordQuality.Diminished,        // vii° (leading tone chord)
 662                _ => ChordQuality.Unknown
 663            };
 664        }
 665    }
 666}

Methods/Properties

get_RootPc()
PitchClasses()