< Summary

Information
Class: MusicTheory.Theory.Harmony.ChordRomanizer
Assembly: MusicTheory
File(s): /home/runner/work/MusicTheory/MusicTheory/Theory/Harmony/ChordRomanizer.cs
Line coverage
82%
Covered lines: 312
Uncovered lines: 66
Coverable lines: 378
Total lines: 666
Line coverage: 82.5%
Branch coverage
77%
Covered branches: 262
Total branches: 340
Branch coverage: 77%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
TryRomanizeDominantNinth(...)100%1212100%
TryRomanizeAugmentedSixth(...)100%210%
TryRomanizeAugmentedSixth(...)86.36%222291.66%
contains()100%11100%
TryRomanizeTriad(...)90%1010100%
TryRomanizeTriadMixture(...)91.66%1212100%
TryRomanizeSeventhMixture(...)100%210%
TryRomanizeSeventhMixture(...)83.33%363697.05%
TryRomanizeSecondaryDominant(...)76.08%644679.66%
TryRomanizeSecondaryLeadingTone(...)100%210%
TryRomanizeSecondaryLeadingTone(...)70.19%23310477.14%
DegreeRomanForTarget(...)40.9%1092243.47%
DegreeRootPc(...)100%44100%
TryRomanizeSeventh(...)91.66%1212100%
SeventhQualityInKey(...)88.88%181891.3%
QualityToRoman(...)70.83%492465%
TriadQualityInKey(...)88.88%191888.23%

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
 10public readonly record struct Chord(int RootPc, ChordQuality Quality)
 11{
 12    public IEnumerable<int> PitchClasses()
 13    {
 14        return Quality switch
 15        {
 16            ChordQuality.Major => new[] { RootPc, (RootPc + 4) % 12, (RootPc + 7) % 12 },
 17            ChordQuality.Minor => new[] { RootPc, (RootPc + 3) % 12, (RootPc + 7) % 12 },
 18            ChordQuality.Diminished => new[] { RootPc, (RootPc + 3) % 12, (RootPc + 6) % 12 },
 19            ChordQuality.Augmented => new[] { RootPc, (RootPc + 4) % 12, (RootPc + 8) % 12 },
 20            ChordQuality.DominantSeventh => new[] { RootPc, (RootPc + 4) % 12, (RootPc + 7) % 12, (RootPc + 10) % 12 }, 
 21            ChordQuality.MinorSeventh => new[] { RootPc, (RootPc + 3) % 12, (RootPc + 7) % 12, (RootPc + 10) % 12 },
 22            ChordQuality.MajorSeventh => new[] { RootPc, (RootPc + 4) % 12, (RootPc + 7) % 12, (RootPc + 11) % 12 },
 23            ChordQuality.HalfDiminishedSeventh => new[] { RootPc, (RootPc + 3) % 12, (RootPc + 6) % 12, (RootPc + 10) % 
 24            ChordQuality.DiminishedSeventh => new[] { RootPc, (RootPc + 3) % 12, (RootPc + 6) % 12, (RootPc + 9) % 12 },
 25            _ => Array.Empty<int>()
 26        };
 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    {
 140336        romanText = null;
 37        // Normalize to distinct, ordered set first and base checks on that to avoid flakiness
 1031138        var set = pcs.Select(p => ((p % 12) + 12) % 12).Distinct().OrderBy(x => x).ToArray();
 256539        if (set.Length < 4 || set.Length > 5) return false; // 4 (omit 5th) or 5 (full)
 40        // V root (degree index 4)
 24141        int vRoot = DegreeRootPc(key, 4);
 24142        int vThird = (vRoot + 4) % 12; // major third on V (harmonic minor accounted by leading tone in DegreeRootPc usa
 24143        int vFifth = (vRoot + 7) % 12;
 24144        int vSeventh = (vRoot + 10) % 12; // dominant seventh
 24145        int vNinth = (vRoot + 2) % 12;    // diatonic 9th
 46
 47        // Allowed members and required core for recognition
 24148        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
 24150        bool allAllowed = set.All(allowed.Contains);
 24151        bool hasCore = set.Contains(vRoot) && set.Contains(vThird) && set.Contains(vSeventh) && set.Contains(vNinth);
 24152    if (allAllowed && hasCore)
 53        {
 1954            romanText = "V9";
 1955            return true;
 56        }
 22257        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)
 064        => TryRomanizeAugmentedSixth(pcs, key, HarmonyOptions.Default, voicing, out romanText);
 65
 66    public static bool TryRomanizeAugmentedSixth(int[] pcs, Key key, HarmonyOptions options, FourPartVoicing? voicing, o
 67    {
 38568        romanText = null;
 38769        if (pcs.Length < 3) return false;
 306170        var set = pcs.Select(p => ((p % 12) + 12) % 12).Distinct().OrderBy(x => x).ToArray();
 71
 38372        int tonic = (key.TonicMidi % 12 + 12) % 12;
 38373        int b6 = (tonic + 8) % 12;   // b6
 38374        int sharp4 = (tonic + 6) % 12; // #4
 38375        int one = tonic;
 38376        int two = (tonic + 2) % 12;
 38377        int flat3 = (tonic + 3) % 12;
 78
 15679        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
 38382    if (voicing is null) return false;
 38383        int bass = ((voicing.Value.B % 12) + 12) % 12;
 74084        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.
 2687        int soprano = ((voicing.Value.S % 12) + 12) % 12;
 2688    if (options.DisallowAugmentedSixthWhenSopranoFlat6 && soprano == b6)
 089            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
 2693        if (set.Length == 4)
 94        {
 95            // German sixth (Ger65): {b6, 1, b3, #4} — identical PC set to bVI7 (dominant seventh on bVI)
 2096            if (contains(b6, one, flat3, sharp4))
 97            {
 98                // When preference requests mixture seventh over Aug6 for ambiguous sets, defer labeling here
 599                if (options.PreferMixtureSeventhOverAugmentedSixthWhenAmbiguous)
 100                {
 0101                    return false; // allow bVI7 detection to take precedence
 102                }
 10103                romanText = "Ger65"; return true;
 104            }
 105            // French sixth (Fr43): {b6, 1, 2, #4} — distinct from any mixture seventh; safe to label directly
 17106            if (contains(b6, one, two, sharp4)) { romanText = "Fr43"; return true; }
 107        }
 20108        if (set.Length == 3 && contains(b6, one, sharp4))
 109        {
 2110            romanText = "It6"; return true;
 111        }
 19112        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    {
 293117        rn = default;
 293118        if (pcs.Length == 0) return false;
 2051119        var set = pcs.Select(p => ((p % 12) + 12) % 12).Distinct().OrderBy(x => x).ToArray();
 120        // find best-fitting degree by root match
 2378121        for (int degree = 0; degree < 7; degree++)
 122        {
 1123123            int degPc = DegreeRootPc(key, degree);
 124            // Build expected triad quality in key
 1123125            var quality = TriadQualityInKey(degree, key.IsMajor);
 1123126            var chord = new Chord(degPc, quality);
 4492127            var expected = chord.PitchClasses().OrderBy(x => x).ToArray();
 128            // Require exact triad match (avoid matching subsets of seventh chords)
 2931129            if (expected.All(p => set.Contains(p)) && set.Length == expected.Length)
 130            {
 227131                rn = QualityToRoman(degree, quality, key.IsMajor);
 227132                return true;
 133            }
 134        }
 66135        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    {
 132142        rn = default; romanText = null;
 66143        if (pcs.Length == 0) return false;
 462144        var set = pcs.Select(p => ((p % 12) + 12) % 12).Distinct().OrderBy(x => x).ToArray();
 66145        int tonicPc = (key.TonicMidi % 12 + 12) % 12;
 146
 147        // Candidates: (rootPc, quality, rn, romanText)
 66148        var candidates = new List<(int root, ChordQuality q, RomanNumeral rn, string txt)>();
 149        // Neapolitan (bII) — available in major/minor
 66150        candidates.Add(((tonicPc + 1) % 12, ChordQuality.Major, RomanNumeral.II, "bII"));
 151
 66152        if (key.IsMajor)
 153        {
 64154            candidates.Add((tonicPc, ChordQuality.Minor, RomanNumeral.i, "i"));                 // i (minor tonic)
 64155            candidates.Add(((key.ScaleDegreeMidi(3) % 12 + 12) % 12, ChordQuality.Minor, RomanNumeral.iv, "iv")); // iv 
 64156            candidates.Add(((tonicPc + 3) % 12, ChordQuality.Major, RomanNumeral.III, "bIII")); // bIII
 64157            candidates.Add(((tonicPc + 8) % 12, ChordQuality.Major, RomanNumeral.VI, "bVI"));   // bVI
 64158            candidates.Add(((tonicPc + 10) % 12, ChordQuality.Major, RomanNumeral.VII, "bVII")); // bVII (Mixolydian bor
 159        }
 160
 693161        foreach (var c in candidates)
 162        {
 1180163            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
 711165            if (chord.All(p => set.Contains(p)) && set.Length == chord.Length)
 166            {
 29167                rn = c.rn;
 29168                romanText = c.txt;
 29169                return true;
 170            }
 171        }
 37172        return false;
 29173    }
 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)
 0177        => TryRomanizeSeventhMixture(pcs, key, HarmonyOptions.Default, voicing, out romanText);
 178
 179    public static bool TryRomanizeSeventhMixture(int[] pcs, Key key, HarmonyOptions options, FourPartVoicing? voicing, o
 180    {
 93181        romanText = null;
 93182        if (pcs.Length == 0) return false;
 837183        var set = pcs.Select(p => ((p % 12) + 12) % 12).Distinct().OrderBy(x => x).ToArray();
 93184        int tonicPc = (key.TonicMidi % 12 + 12) % 12;
 185
 93186        var candidates = new List<(int root, ChordQuality q, string txtBase)>();
 93187        if (key.IsMajor)
 188        {
 189            // iv7: minor seventh on degree IV (borrowed from parallel minor)
 93190            int ivRoot = (key.ScaleDegreeMidi(3) % 12 + 12) % 12; // IV degree root pc
 93191            candidates.Add((ivRoot, ChordQuality.MinorSeventh, "iv"));
 192        }
 193        // bVII7: dominant seventh built on bVII (common in major/minor)
 93194        int bVIIroot = (tonicPc + 10) % 12;
 93195        candidates.Add((bVIIroot, ChordQuality.DominantSeventh, "bVII"));
 196    // bII7: dominant seventh on Neapolitan root (also common as tritone sub)
 93197        int bIIroot = (tonicPc + 1) % 12;
 93198        candidates.Add((bIIroot, ChordQuality.DominantSeventh, "bII"));
 199    // bVI7: dominant seventh on bVI (strict exact 4-note match)
 93200    int bVIroot = (tonicPc + 8) % 12;
 93201    candidates.Add((bVIroot, ChordQuality.DominantSeventh, "bVI"));
 202
 815203        foreach (var c in candidates)
 204        {
 1650205            var chord = new Chord(c.root, c.q).PitchClasses().OrderBy(x => x).ToArray();
 206            // require exact-size match (avoid subset/superset confusion with triads)
 993207            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
 31213            if (voicing is null)
 214            {
 8215                romanText = c.txtBase + "7";
 8216                return true;
 217            }
 218
 219            // With voicing: figure the inversion
 46220            int bassPc = voicing.Value.B % 12; if (bassPc < 0) bassPc += 12;
 23221            int root = c.root;
 66222            int third = chord.First(pc => pc != root && (((pc - root + 12) % 12) == 3 || ((pc - root + 12) % 12) == 4));
 23223            int fifth = (root + ((c.q == ChordQuality.MinorSeventh ? 7 : 7))) % 12; // 5th is perfect
 46224            int sevInt = c.q switch { ChordQuality.MajorSeventh => 11, ChordQuality.DominantSeventh or ChordQuality.Mino
 23225            int sev = (root + sevInt) % 12;
 226
 30227            if      (bassPc == root) romanText = c.txtBase + "7";
 21228            else if (bassPc == third) romanText = c.txtBase + "65";
 17229            else if (bassPc == fifth) romanText = c.txtBase + "43";
 10230            else if (bassPc == sev) romanText = c.txtBase + "42";
 0231            else romanText = c.txtBase + "7";
 23232            return true;
 233        }
 234
 62235        return false;
 31236    }
 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    {
 71242        romanText = null;
 71243        if (pcs.Length == 0) return false;
 565244        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.
 71248    if (set.Length == 3)
 249        {
 648250            for (int r = 0; r < 12; r++)
 251            {
 1252252        var majorTri = new Chord(r, ChordQuality.Major).PitchClasses().OrderBy(x => x).ToArray();
 313253        if (majorTri.SequenceEqual(set))
 254                {
 26255                    int secRoot = r;
 26256                    int targetPc = (secRoot + 5) % 12; // -7 mod 12 == +5
 26257                    int degree = -1;
 272258                    for (int d = 0; d < 7; d++)
 259                    {
 188260                        if (DegreeRootPc(key, d) == targetPc) { degree = d; break; }
 261                    }
 26262                    if (degree != -1)
 263                    {
 26264                        string targetTxt = DegreeRomanForTarget(degree, key.IsMajor);
 26265                        if (voicing is FourPartVoicing vTri)
 266                        {
 25267                            int bass = ((vTri.B % 12) + 12) % 12;
 25268                            int root = secRoot;
 25269                            int third = (root + 4) % 12;
 25270                            int fifth = (root + 7) % 12;
 33271                            if      (bass == root)   romanText = $"V/{targetTxt}";
 26272                            else if (bass == third)  romanText = $"V6/{targetTxt}";
 16273                            else if (bass == fifth)  romanText = $"V64/{targetTxt}";
 0274                            else romanText = $"V/{targetTxt}";
 275                        }
 276                        else
 277                        {
 1278                            romanText = $"V/{targetTxt}";
 279                        }
 26280                        return true;
 281                    }
 282                }
 283            }
 284        }
 285        // Prefer common targets first to avoid rare ambiguous picks
 45286        int[] degreeOrder = new[] { 4, 1, 5, 3, 2, 6 }; // V, ii, vi, IV, iii, vii
 532287        foreach (int deg in degreeOrder)
 288        {
 226289            int targetPc = DegreeRootPc(key, deg);
 226290            int secRoot = (targetPc + 7) % 12; // V of target
 904291            var tri = new Chord(secRoot, ChordQuality.Major).PitchClasses().OrderBy(x => x).ToArray();
 1130292            var sev = new Chord(secRoot, ChordQuality.DominantSeventh).PitchClasses().OrderBy(x => x).ToArray();
 293
 226294            string targetTxt = DegreeRomanForTarget(deg, key.IsMajor);
 226295            if (tri.SequenceEqual(set))
 296            {
 297                // Triad case: if voicing provided, add inversion figures (6 or 64) before '/x'
 0298                if (voicing is FourPartVoicing vTri)
 299                {
 0300                    int bass = ((vTri.B % 12) + 12) % 12;
 0301                    int root = secRoot;
 0302                    int third = (root + 4) % 12;
 0303                    int fifth = (root + 7) % 12;
 0304                    if      (bass == root)   romanText = $"V/{targetTxt}";
 0305                    else if (bass == third)  romanText = $"V6/{targetTxt}";
 0306                    else if (bass == fifth)  romanText = $"V64/{targetTxt}";
 0307                    else romanText = $"V/{targetTxt}";
 308                }
 309                else
 310                {
 0311                    romanText = $"V/{targetTxt}";
 312                }
 0313                return true;
 314            }
 226315            if (sev.SequenceEqual(set))
 316            {
 317                // Seventh with optional inversion figures when voicing is present
 10318                string fig = "7";
 10319                if (voicing is FourPartVoicing v)
 320                {
 8321                    int bass = ((v.B % 12) + 12) % 12;
 8322                    int root = secRoot;
 8323                    int third = (root + 4) % 12;
 8324                    int fifth = (root + 7) % 12;
 8325                    int seventh = (root + 10) % 12;
 10326                    if      (bass == root)   fig = "7";
 8327                    else if (bass == third)  fig = "65";
 6328                    else if (bass == fifth)  fig = "43";
 4329                    else if (bass == seventh)fig = "42";
 330                }
 10331                romanText = $"V{fig}/{targetTxt}";
 10332                return true;
 333            }
 334        }
 35335        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
 0340        => TryRomanizeSecondaryLeadingTone(pcs, key, voicing, HarmonyOptions.Default, out romanText);
 341
 342    public static bool TryRomanizeSecondaryLeadingTone(int[] pcs, Key key, FourPartVoicing? voicing, HarmonyOptions opti
 343    {
 35344        romanText = null;
 35345        if (pcs.Length == 0) return false;
 293346        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
 35348        if (TryRomanizeSeventh(pcs, key, out var _rnDia, out var _degDia, out var _qDia))
 0349            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.
 35353        if (set.Length == 4 && options.PreferSecondaryLeadingToneTargetV)
 354        {
 216355            var norm = set.Select(pc => (pc - set[0] + 12) % 12).OrderBy(x => x).ToArray();
 24356            if (norm.SequenceEqual(new[] { 0, 3, 6, 9 }))
 357            {
 19358                int vRoot = DegreeRootPc(key, 4);
 19359                int ltRootV = (vRoot + 11) % 12;
 95360                var dimV = new Chord(ltRootV, ChordQuality.DiminishedSeventh).PitchClasses().OrderBy(x => x).ToArray();
 19361                if (dimV.All(set.Contains))
 362                {
 19363                    if (voicing is FourPartVoicing v)
 364                    {
 16365                        int bass = ((v.B % 12) + 12) % 12;
 16366                        int root = ltRootV;
 16367                        int third = (root + 3) % 12;
 16368                        int fifth = (root + 6) % 12;
 16369                        int seventh = (root + 9) % 12;
 16370                        string fig = bass == root ? "7" : bass == third ? "65" : bass == fifth ? "43" : bass == seventh 
 16371                        romanText = $"vii°{fig}/V";
 372                    }
 373                    else
 374                    {
 3375                        romanText = "vii°7/V";
 376                    }
 19377                    return true;
 378                }
 379            }
 380        }
 381
 382        // Strong preference: try target V first explicitly
 383    {
 16384            int targetPc = DegreeRootPc(key, 4); // V
 16385            int ltRoot = (targetPc + 11) % 12;
 80386            var dim7 = new Chord(ltRoot, ChordQuality.DiminishedSeventh).PitchClasses().OrderBy(x => x).ToArray();
 80387            var halfDim7 = new Chord(ltRoot, ChordQuality.HalfDiminishedSeventh).PitchClasses().OrderBy(x => x).ToArray(
 64388            var tri = new Chord(ltRoot, ChordQuality.Diminished).PitchClasses().OrderBy(x => x).ToArray();
 16389            bool matchDim7 = (dim7.Length == set.Length && dim7.All(set.Contains));
 16390            bool matchHalf7 = (halfDim7.Length == set.Length && halfDim7.All(set.Contains));
 16391            bool matchTri = (tri.Length == set.Length && tri.All(set.Contains));
 16392            if (matchDim7 || matchHalf7 || matchTri)
 393            {
 394                string head;
 6395                if (matchDim7) head = "vii°";
 6396                else if (matchHalf7) head = "viiø";
 6397                else head = "vii°"; // triad case uses °
 6398                string fig = (set.Length == 4) ? "7" : "";
 6399                if (voicing is FourPartVoicing v && set.Length == 4)
 400                {
 0401                    int bass = ((v.B % 12) + 12) % 12;
 0402                    int root = ltRoot;
 0403                    int third = (root + 3) % 12;
 0404                    int fifth = (root + 6) % 12;
 0405                    int seventh = ((dim7.Length == 4 && dim7.All(set.Contains)) ? (root + 9) : (root + 10)) % 12;
 0406                    if      (bass == root)   fig = "7";
 0407                    else if (bass == third)  fig = "65";
 0408                    else if (bass == fifth)  fig = "43";
 0409                    else if (bass == seventh)fig = "42";
 410                }
 6411                else if (voicing is FourPartVoicing vTri && set.Length == 3 && matchTri)
 412                {
 6413                    int bass = ((vTri.B % 12) + 12) % 12;
 6414                    int root = ltRoot;
 6415                    int third = (root + 3) % 12;
 6416                    int fifth = (root + 6) % 12;
 8417                    if      (bass == root)   fig = "";
 6418                    else if (bass == third)  fig = "6";
 4419                    else if (bass == fifth)  fig = "64";
 420                }
 6421                romanText = $"{head}{fig}/V";
 6422                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)
 10427    int[] degreeOrder = new[] { 4, 1, 5, 3, 2, 6 };
 59428    foreach (var deg in degreeOrder)
 429        {
 24430            int targetPc = DegreeRootPc(key, deg);
 24431            int ltRoot = (targetPc + 11) % 12; // leading tone to the target (−1 semitone)
 432
 96433            var tri = new Chord(ltRoot, ChordQuality.Diminished).PitchClasses().OrderBy(x => x).ToArray();
 120434            var halfDim7 = new Chord(ltRoot, ChordQuality.HalfDiminishedSeventh).PitchClasses().OrderBy(x => x).ToArray(
 120435            var dim7 = new Chord(ltRoot, ChordQuality.DiminishedSeventh).PitchClasses().OrderBy(x => x).ToArray();
 436
 24437            string targetTxt = DegreeRomanForTarget(deg, key.IsMajor, includeDiminishedOnVII: true);
 24438            if (tri.Length == set.Length && tri.All(set.Contains))
 439            {
 440                // Triad case: if voicing provided, add inversion figures (6 or 64) before '/x'
 4441                if (voicing is FourPartVoicing vTri)
 442                {
 3443                    int bass = ((vTri.B % 12) + 12) % 12;
 3444                    int root = ltRoot;
 445                    // diminished triad: minor third + diminished fifth
 3446                    int third = (root + 3) % 12;
 3447                    int fifth = (root + 6) % 12;
 3448                    string head = "vii°";
 4449                    if      (bass == root)   romanText = $"{head}/{targetTxt}";
 3450                    else if (bass == third)  romanText = $"{head}6/{targetTxt}";
 2451                    else if (bass == fifth)  romanText = $"{head}64/{targetTxt}";
 0452                    else romanText = $"{head}/{targetTxt}";
 453                }
 454                else
 455                {
 1456                    romanText = $"vii°/{targetTxt}";
 457                }
 4458                return true;
 459            }
 460
 461            // Check fully-diminished first, then half-diminished
 20462            if (dim7.Length == set.Length && dim7.All(set.Contains))
 463            {
 0464                string head = "vii°"; string fig = "7";
 0465                if (voicing is FourPartVoicing v)
 466                {
 0467                    int bass = ((v.B % 12) + 12) % 12;
 0468                    int root = ltRoot;
 0469                    int third = (root + 3) % 12;
 0470                    int fifth = (root + 6) % 12;
 0471                    int seventh = (root + 9) % 12;
 0472                    if      (bass == root)   fig = "7";
 0473                    else if (bass == third)  fig = "65";
 0474                    else if (bass == fifth)  fig = "43";
 0475                    else if (bass == seventh)fig = "42";
 476                }
 0477                romanText = $"{head}{fig}/{targetTxt}";
 0478                return true;
 479            }
 20480            if (halfDim7.Length == set.Length && halfDim7.All(set.Contains))
 481            {
 10482                string head = "viiø"; string fig = "7";
 5483                if (voicing is FourPartVoicing v)
 484                {
 4485                    int bass = ((v.B % 12) + 12) % 12;
 4486                    int root = ltRoot;
 4487                    int third = (root + 3) % 12;
 4488                    int fifth = (root + 6) % 12;
 4489                    int seventh = (root + 10) % 12;
 5490                    if      (bass == root)   fig = "7";
 4491                    else if (bass == third)  fig = "65";
 3492                    else if (bass == fifth)  fig = "43";
 2493                    else if (bass == seventh)fig = "42";
 494                }
 5495                romanText = $"{head}{fig}/{targetTxt}";
 5496                return true;
 497            }
 498    }
 1499        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
 276506        if (isMajor)
 507        {
 276508            return degree switch
 276509            {
 0510                0 => "I",
 57511                1 => "ii",
 36512                2 => "iii",
 36513                3 => "IV",
 56514                4 => "V",
 46515                5 => "vi",
 45516                6 => includeDiminishedOnVII ? "vii°" : "vii",
 0517                _ => "?"
 276518            };
 519        }
 520        else
 521        {
 522            // harmonic minor baseline
 0523            return degree switch
 0524            {
 0525                0 => "i",
 0526                1 => "ii°",  // diminished triad on ii in harmonic minor baseline
 0527                2 => "III",
 0528                3 => "iv",
 0529                4 => "V",
 0530                5 => "VI",
 0531                6 => includeDiminishedOnVII ? "vii°" : "vii",
 0532                _ => "?"
 0533            };
 534        }
 535    }
 536
 537    private static int DegreeRootPc(Key key, int degree)
 538    {
 4844539        if (key.IsMajor)
 540        {
 4661541            return (key.ScaleDegreeMidi(degree) % 12 + 12) % 12;
 542        }
 543        else
 544        {
 545            // Harmonic minor: degree 6 is leading tone (tonic - 1)
 183546            if (degree == 6)
 547            {
 17548                int tonicPc = (key.TonicMidi % 12 + 12) % 12;
 17549                return (tonicPc + 11) % 12;
 550            }
 166551            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    {
 1368558        rn = default; degreeOut = -1; qualityOut = ChordQuality.Unknown;
 456559        if (pcs.Length == 0) return false;
 3498560        var set = pcs.Select(p => ((p % 12) + 12) % 12).Distinct().OrderBy(x => x).ToArray();
 6946561        for (int degree = 0; degree < 7; degree++)
 562        {
 3059563            var q7 = SeventhQualityInKey(degree, key.IsMajor);
 3059564            if (q7 is null) continue;
 3059565            int degPc = DegreeRootPc(key, degree);
 3059566            var chord = new Chord(degPc, q7.Value);
 15295567            var expected = chord.PitchClasses().OrderBy(x => x).ToArray();
 568            // exact match: avoid matching subsets/supersets
 8125569            if (expected.All(p => set.Contains(p)) && set.Length == expected.Length)
 570            {
 42571                var triQ = TriadQualityInKey(degree, key.IsMajor);
 42572                rn = QualityToRoman(degree, triQ, key.IsMajor);
 42573                degreeOut = degree;
 42574                qualityOut = q7.Value;
 42575                return true;
 576            }
 577        }
 414578        return false;
 579    }
 580
 581    private static ChordQuality? SeventhQualityInKey(int degree, bool isMajor)
 582    {
 3059583        if (isMajor)
 584        {
 2942585            return degree switch
 2942586            {
 434587                0 => ChordQuality.MajorSeventh,          // Imaj7
 430588                1 => ChordQuality.MinorSeventh,          // ii7
 425589                2 => ChordQuality.MinorSeventh,          // iii7
 425590                3 => ChordQuality.MajorSeventh,          // IVmaj7
 418591                4 => ChordQuality.DominantSeventh,       // V7
 405592                5 => ChordQuality.MinorSeventh,          // vi7
 405593                6 => ChordQuality.HalfDiminishedSeventh, // viiø7
 0594                _ => null
 2942595            };
 596        }
 597        else
 598        {
 599            // Harmonic minor diatonic sevenths
 117600            return degree switch
 117601            {
 22602                0 => ChordQuality.MinorSeventh,             // i7
 22603                1 => ChordQuality.HalfDiminishedSeventh,     // iiø7
 17604                2 => ChordQuality.MajorSeventh,              // IIImaj7
 15605                3 => ChordQuality.MinorSeventh,              // iv7
 15606                4 => ChordQuality.DominantSeventh,           // V7
 13607                5 => ChordQuality.MajorSeventh,              // VImaj7
 13608                6 => ChordQuality.DiminishedSeventh,         // vii°7
 0609                _ => null
 117610            };
 611        }
 612    }
 613
 614    private static RomanNumeral QualityToRoman(int degree, ChordQuality q, bool isMajor)
 615    {
 269616        bool minorLike = q == ChordQuality.Minor || q == ChordQuality.Diminished;
 269617        var baseIndex = degree; // 0..6
 269618        return (baseIndex, minorLike, isMajor) switch
 269619        {
 103620            (0, false, _) => RomanNumeral.I,
 0621            (1, false, _) => RomanNumeral.II,
 2622            (2, false, _) => RomanNumeral.III,
 70623            (3, false, _) => RomanNumeral.IV,
 69624            (4, false, _) => RomanNumeral.V,
 0625            (5, false, _) => RomanNumeral.VI,
 0626            (6, false, _) => RomanNumeral.VII,
 4627            (0, true, _) => RomanNumeral.i,
 12628            (1, true, _) => RomanNumeral.ii,
 0629            (2, true, _) => RomanNumeral.iii,
 0630            (3, true, _) => RomanNumeral.iv,
 0631            (4, true, _) => RomanNumeral.v,
 3632            (5, true, _) => RomanNumeral.vi,
 6633            (6, true, _) => RomanNumeral.vii,
 0634            _ => RomanNumeral.I
 269635        };
 636    }
 637
 638    private static ChordQuality TriadQualityInKey(int degree, bool isMajor)
 639    {
 640        // Diatonic triad qualities in major/minor (natural minor for start)
 1165641        if (isMajor)
 642        {
 1110643            return degree switch
 1110644            {
 608645                0 or 3 or 4 => ChordQuality.Major, // I, IV, V
 438646                1 or 2 or 5 => ChordQuality.Minor, // ii, iii, vi
 64647                6 => ChordQuality.Diminished,      // vii°
 0648                _ => ChordQuality.Unknown
 1110649            };
 650        }
 651        else
 652        {
 653            // Harmonic minor variant by default:
 654            // i, ii°, III, iv, V, VI, vii°
 55655            return degree switch
 55656            {
 16657                0 or 3 => ChordQuality.Minor,        // i, iv
 12658                2 or 5 => ChordQuality.Major,        // III, VI
 11659                1 => ChordQuality.Diminished,        // ii°
 8660                4 => ChordQuality.Major,             // V (raised 7 as 3rd)
 8661                6 => ChordQuality.Diminished,        // vii° (leading tone chord)
 0662                _ => ChordQuality.Unknown
 55663            };
 664        }
 665    }
 666}

Methods/Properties

TryRomanizeDominantNinth(System.Int32[],MusicTheory.Theory.Harmony.Key,System.String&)
TryRomanizeAugmentedSixth(System.Int32[],MusicTheory.Theory.Harmony.Key,System.Nullable`1<MusicTheory.Theory.Harmony.FourPartVoicing>,System.String&)
TryRomanizeAugmentedSixth(System.Int32[],MusicTheory.Theory.Harmony.Key,MusicTheory.Theory.Harmony.HarmonyOptions,System.Nullable`1<MusicTheory.Theory.Harmony.FourPartVoicing>,System.String&)
contains()
TryRomanizeTriad(System.Int32[],MusicTheory.Theory.Harmony.Key,MusicTheory.Theory.Harmony.RomanNumeral&)
TryRomanizeTriadMixture(System.Int32[],MusicTheory.Theory.Harmony.Key,MusicTheory.Theory.Harmony.RomanNumeral&,System.String&)
TryRomanizeSeventhMixture(System.Int32[],MusicTheory.Theory.Harmony.Key,System.Nullable`1<MusicTheory.Theory.Harmony.FourPartVoicing>,System.String&)
TryRomanizeSeventhMixture(System.Int32[],MusicTheory.Theory.Harmony.Key,MusicTheory.Theory.Harmony.HarmonyOptions,System.Nullable`1<MusicTheory.Theory.Harmony.FourPartVoicing>,System.String&)
TryRomanizeSecondaryDominant(System.Int32[],MusicTheory.Theory.Harmony.Key,System.Nullable`1<MusicTheory.Theory.Harmony.FourPartVoicing>,System.String&)
TryRomanizeSecondaryLeadingTone(System.Int32[],MusicTheory.Theory.Harmony.Key,System.Nullable`1<MusicTheory.Theory.Harmony.FourPartVoicing>,System.String&)
TryRomanizeSecondaryLeadingTone(System.Int32[],MusicTheory.Theory.Harmony.Key,System.Nullable`1<MusicTheory.Theory.Harmony.FourPartVoicing>,MusicTheory.Theory.Harmony.HarmonyOptions,System.String&)
DegreeRomanForTarget(System.Int32,System.Boolean,System.Boolean)
DegreeRootPc(MusicTheory.Theory.Harmony.Key,System.Int32)
TryRomanizeSeventh(System.Int32[],MusicTheory.Theory.Harmony.Key,MusicTheory.Theory.Harmony.RomanNumeral&,System.Int32&,MusicTheory.Theory.Harmony.ChordQuality&)
SeventhQualityInKey(System.Int32,System.Boolean)
QualityToRoman(System.Int32,MusicTheory.Theory.Harmony.ChordQuality,System.Boolean)
TriadQualityInKey(System.Int32,System.Boolean)