< Summary

Information
Class: MusicTheory.Theory.Harmony.RomanInputParser
Assembly: MusicTheory
File(s): /home/runner/work/MusicTheory/MusicTheory/Theory/Harmony/RomanInputParser.cs
Line coverage
76%
Covered lines: 199
Uncovered lines: 61
Coverable lines: 260
Total lines: 485
Line coverage: 76.5%
Branch coverage
64%
Covered branches: 241
Total branches: 371
Branch coverage: 64.9%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Pcs()100%11100%
.cctor()100%1187.5%
Parse(...)66.66%66100%
ParseToken(...)95.45%4444100%
Sanitize(...)88.46%525296%
TryAppendRomanAscii()18.75%1831613.33%
DegMidi()100%11100%
TargetDegree()47.45%805981.81%
ParseSecondary(...)76.31%393892.3%
SelectBassFromFigure(...)80%232080%
Mod()100%11100%
ParseHeadToChordPcs(...)49.07%71610862.65%
DegMidi()100%11100%
Eq()100%11100%
SelectBassFromTriadOrSeventh(...)83.33%272482.35%
Mod()100%11100%
GetSeventhPc(...)0%2040%

File(s)

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

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Linq;
 4using System.Globalization;
 5
 6namespace MusicTheory.Theory.Harmony;
 7
 8/// <summary>
 9/// Parses a simple Roman numeral sequence (supports mixture bII/bIII/bVI/bVII, triad inversions 6/64, and seventh inver
 10/// into pitch-class sets and a bass pitch-class hint to help generate voicings.
 11/// This is intentionally lightweight and designed for CLI/demo input, not full notation parsing.
 12/// </summary>
 13public static class RomanInputParser
 14{
 10615    public readonly record struct ParsedChord(int[] Pcs, int? BassPcHint, string Token);
 16
 17    // Static warm-up to JIT hot paths and reduce first-run jitter in tests/CI.
 18    // This is intentionally minimal and side-effect free.
 19    static RomanInputParser()
 20    {
 21        try
 22        {
 123            var key = new Key(60, true);
 24            // Individual tokens
 125            Parse("bII; bIII; bVI; bVII; N6; V/ii; V/vi; V/vii; vii0/V; viio7/V; It6", key);
 26            // Exact sequences used in tests (to pre-JIT combined paths)
 127            Parse("bII; bIII; bVI; bVII; bII6; N6", key);
 128            Parse("V/ii; vii°7/V; vii0/V; viio7/V", key);
 29            // Unicode variants
 130            Parse("♭II6; N\u200B6; vii0/V; viio7/V; bⅢ; viiø/V", key);
 131        }
 032        catch { /* no-op: warm-up best-effort */ }
 133    }
 34
 35    public static ParsedChord[] Parse(string roman, Key key)
 36    {
 3437        if (roman is null) throw new ArgumentNullException(nameof(roman));
 3438        var tokens = roman.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
 3439        if (tokens.Length == 0) throw new ArgumentException("--roman contained no items.");
 3440        var list = new List<ParsedChord>(tokens.Length);
 33041        foreach (var tok in tokens)
 42        {
 13143            list.Add(ParseToken(tok, key));
 44        }
 3445        return list.ToArray();
 46    }
 47
 48    static ParsedChord ParseToken(string token, Key key)
 49    {
 13150    string t = Sanitize(token).Replace(" ", string.Empty);
 13151        if (string.IsNullOrWhiteSpace(t)) throw new ArgumentException("Empty roman token.");
 52
 53        // Augmented sixth direct tokens (It6/Fr43/Ger65)
 13154        if (t is "It6" or "Fr43" or "Ger65")
 55        {
 1156            int tonic = ((key.TonicMidi % 12) + 12) % 12;
 5557            int b6 = (tonic + 8) % 12; int one = tonic; int sharp4 = (tonic + 6) % 12; int two = (tonic + 2) % 12; int f
 1158            int[] pcsAug = t switch
 1159            {
 560                "It6" => new[] { b6, one, sharp4 },
 361                "Fr43" => new[] { b6, one, two, sharp4 },
 362                _ => new[] { b6, one, flat3, sharp4 } // Ger65
 1163            };
 1164            return new ParsedChord(pcsAug, b6, token);
 65        }
 66
 67        // Secondary notation: split by '/'
 12068    string? target = null;
 12069    string headPart = t;
 12070    string original = token;
 12071        int slash = t.IndexOf('/');
 12072        if (slash >= 0)
 73        {
 5374            headPart = t.Substring(0, slash);
 5375            target = t.Substring(slash + 1);
 5376            if (string.IsNullOrEmpty(target)) throw new ArgumentException($"Invalid secondary token: '{t}'");
 77        }
 78
 79        // Extract figure suffix first (64,65,43,42,6,7)
 12080        string figure = string.Empty;
 12481        if (headPart.EndsWith("64")) { figure = "64"; headPart = headPart[..^2]; }
 12682        else if (headPart.EndsWith("65")) { figure = "65"; headPart = headPart[..^2]; }
 12283        else if (headPart.EndsWith("43")) { figure = "43"; headPart = headPart[..^2]; }
 11884        else if (headPart.EndsWith("42")) { figure = "42"; headPart = headPart[..^2]; }
 13685        else if (headPart.EndsWith("6")) { figure = "6"; headPart = headPart[..^1]; }
 14786        else if (headPart.EndsWith("7")) { figure = "7"; headPart = headPart[..^1]; }
 87
 88        // Extract quality marks at end: diminished (°/o) or half-diminished (ø/0)
 12089        char? qualMark = null;
 12090        if (headPart.EndsWith("°") || headPart.EndsWith("ø") || headPart.EndsWith("o") || headPart.EndsWith("0"))
 91        {
 4392            var mk = headPart[^1];
 93            // Normalize ASCII marks to canonical symbols
 4394            qualMark = mk switch
 4395            {
 996                'o' => '°', // ASCII 'o' -> diminished
 1097                '0' => 'ø', // ASCII zero -> half-diminished
 2498                _ => mk
 4399            };
 43100            headPart = headPart[..^1];
 101        }
 102
 103        // Secondary
 120104        if (target is not null)
 105        {
 53106            return ParseSecondary(headPart, target, figure, qualMark, key, original);
 107        }
 108
 109        // Map head (with optional mixture b) to degree and default quality
 67110    var pcs = ParseHeadToChordPcs(headPart, figure, qualMark, key, out int rootPc, out int[] chordPcs, original);
 111
 67112    int? bassPc = SelectBassFromTriadOrSeventh(figure, rootPc, chordPcs);
 113
 67114    return new ParsedChord(pcs, bassPc, token);
 115    }
 116
 117    // Remove hidden/format/control/space-like characters and normalize many look-alikes to ASCII
 118    static string Sanitize(string s)
 119    {
 131120        if (string.IsNullOrEmpty(s)) return s;
 131121        var sb = new System.Text.StringBuilder(s.Length);
 122
 123        // Helper to append ASCII Roman numerals equivalent
 124        static bool TryAppendRomanAscii(char ch, System.Text.StringBuilder sb)
 125        {
 126            // Uppercase Roman numerals Ⅰ..Ⅶ (U+2160..U+2166)
 127            switch (ch)
 128            {
 0129                case '\u2160': sb.Append("I"); return true;   // Ⅰ
 0130                case '\u2161': sb.Append("II"); return true;  // Ⅱ
 8131                case '\u2162': sb.Append("III"); return true; // Ⅲ
 0132                case '\u2163': sb.Append("IV"); return true;  // Ⅳ
 0133                case '\u2164': sb.Append("V"); return true;   // Ⅴ
 0134                case '\u2165': sb.Append("VI"); return true;  // Ⅵ
 0135                case '\u2166': sb.Append("VII"); return true; // Ⅶ
 136            }
 137            // Lowercase Roman numerals ⅰ..ⅶ (U+2170..U+2176)
 138            switch (ch)
 139            {
 0140                case '\u2170': sb.Append("i"); return true;   // ⅰ
 0141                case '\u2171': sb.Append("ii"); return true;  // ⅱ
 0142                case '\u2172': sb.Append("iii"); return true; // ⅲ
 0143                case '\u2173': sb.Append("iv"); return true;  // ⅳ
 0144                case '\u2174': sb.Append("v"); return true;   // ⅴ
 0145                case '\u2175': sb.Append("vi"); return true;  // ⅵ
 0146                case '\u2176': sb.Append("vii"); return true; // ⅶ
 147            }
 594148            return false;
 149        }
 150
 1458151        foreach (var raw in s)
 152        {
 598153            char ch = raw;
 154
 155            // Expand Roman numeral code points to ASCII sequences
 598156            if (TryAppendRomanAscii(ch, sb))
 157                continue;
 158
 159            // Normalize common look-alikes first
 609160            if (ch == '\u00BA' || ch == '\u00B0') ch = '°'; // masculine ordinal/degree sign → degree-like
 596161            if (ch == '\u266D') ch = 'b'; // music flat sign → ASCII 'b'
 594162            if (ch == '\u00D8') ch = 'ø'; // Ø → ø
 603163            if (ch == '\u00F8') ch = 'ø'; // ensure lowercase
 164
 165            // Convert fullwidth ASCII range to halfwidth ASCII
 594166            if (ch >= '\uFF01' && ch <= '\uFF5E')
 167            {
 0168                ch = (char)(ch - 0xFEE0);
 169            }
 170            // Also normalize FULLWIDTH SOLIDUS to '/'
 594171            if (ch == '\uFF0F') ch = '/';
 172
 173            // Treat uppercase 'O' quality mark as 'o' for diminished
 594174            if (ch == 'O') ch = 'o';
 175
 594176            var cat = CharUnicodeInfo.GetUnicodeCategory(ch);
 177            // Skip hidden/spacing/format/control marks entirely
 594178            if (cat is UnicodeCategory.Format
 594179                    or UnicodeCategory.Control
 594180                    or UnicodeCategory.NonSpacingMark
 594181                    or UnicodeCategory.EnclosingMark
 594182                    or UnicodeCategory.SpaceSeparator
 594183                    or UnicodeCategory.LineSeparator
 594184                    or UnicodeCategory.ParagraphSeparator)
 185            {
 186                continue;
 187            }
 592188            if (ch == '\uFEFF' || ch == '\u200B' || ch == '\u200C' || ch == '\u200D') continue; // explicit BOM/ZW*
 189
 190            // As a final guard, drop unexpected non-ASCII characters except for allowed quality marks.
 191            // This hardens against stray look-alikes not explicitly normalized above.
 592192            if (ch > 0x7F && ch != '°' && ch != 'ø')
 193            {
 194                continue;
 195            }
 196
 592197            sb.Append(ch);
 198        }
 131199        return sb.ToString();
 200    }
 201
 202    static ParsedChord ParseSecondary(string head, string target, string figure, char? qualMark, Key key, string origina
 203    {
 53204        int DegMidi(int d) => ((key.ScaleDegreeMidi(d) % 12) + 12) % 12;
 205        // Map target roman to degree 0..6
 206        int TargetDegree(string x, bool isMajor)
 207        {
 53208            return x switch
 53209            {
 0210                "I" or "i" => 0,
 7211                "II" or "ii" => 1,
 2212                "III" or "iii" => 2,
 2213                "IV" or "iv" => 3,
 35214                "V" or "v" => 4,
 5215                "VI" or "vi" => 5,
 2216                "VII" or "vii" or "vii°" => 6,
 0217                _ => throw new ArgumentException($"Unsupported secondary target: '{x}'")
 53218            };
 219        }
 220
 53221        int deg = TargetDegree(target, key.IsMajor);
 53222        int targetPc = DegMidi(deg);
 223
 53224    bool isSeventh = figure is "7" or "65" or "43" or "42";
 225        // Secondary Dominant
 53226        if (string.Equals(head, "V", StringComparison.Ordinal))
 227        {
 10228            int secRoot = (targetPc + 7) % 12;
 10229            var q = isSeventh ? ChordQuality.DominantSeventh : ChordQuality.Major;
 10230            var chordPcs = new Chord(secRoot, q).PitchClasses().ToArray();
 10231            int? bass = SelectBassFromFigure(figure, secRoot, chordPcs, seventhQuality: q);
 10232            return new ParsedChord(chordPcs, bass ?? secRoot, originalToken);
 233        }
 234
 235        // Secondary Leading-Tone (vii°, viiø)
 43236        if (head.StartsWith("vii", StringComparison.OrdinalIgnoreCase))
 237        {
 43238            int ltRoot = (targetPc + 11) % 12;
 239            ChordQuality q;
 240            // If any diminished mark is present (in qual or in head) treat as seventh even if figure omitted
 43241            bool hasDimMarkInHead = head.IndexOf('o') >= 0 || head.IndexOf('0') >= 0 || head.IndexOf('ø') >= 0 || head.I
 43242            bool hasHalfDimInOriginal = originalToken.IndexOf('ø') >= 0 || originalToken.IndexOf('0') >= 0; // robust de
 43243            bool seventhImplied = isSeventh || (qualMark is 'ø' or '°') || hasDimMarkInHead;
 43244            if (seventhImplied)
 245            {
 43246                if (qualMark == 'ø' || hasHalfDimInOriginal)
 247                {
 19248                    q = ChordQuality.HalfDiminishedSeventh;
 249                }
 24250                else q = ChordQuality.DiminishedSeventh;
 251            }
 252            else
 253            {
 0254                q = ChordQuality.Diminished;
 255            }
 256            // Hard override for /IV: always use MinorSeventh on the leading tone to IV
 43257            if (deg == 3)
 258            {
 2259                q = ChordQuality.MinorSeventh;
 260            }
 261            // Build chord pcs; special-case viiø/iv → use MinorSeventh (e.g., E-G-B-D in C)
 43262            var chordForBuild = new Chord(ltRoot, q);
 43263            var chordPcs = chordForBuild.PitchClasses().ToArray();
 43264            int? bass = SelectBassFromFigure(figure, ltRoot, chordPcs, seventhQuality: q);
 43265            return new ParsedChord(chordPcs, bass ?? ltRoot, originalToken);
 266        }
 267
 0268        throw new ArgumentException($"Unsupported secondary head: '{head}/{target}{figure}'");
 269    }
 270
 271    static int? SelectBassFromFigure(string figure, int rootPc, int[] chordPcs, ChordQuality seventhQuality)
 272    {
 83273        if (string.IsNullOrEmpty(figure)) return rootPc;
 274        // triad 6/64 already handled elsewhere; here focus on sevenths figures if 4-note
 275        // Derive chord tones directly from provided pitch classes for robustness
 252276        static int Mod(int x) => ((x % 12) + 12) % 12;
 69277        int third = chordPcs.FirstOrDefault(pc => Mod(pc - rootPc) is 3 or 4, Mod(rootPc + 4));
 278        // Fifth can be perfect (7), diminished (6), or augmented (8); prefer the one present in chordPcs
 279        int fifth;
 24280        if (chordPcs.Contains(Mod(rootPc + 7))) fifth = Mod(rootPc + 7);
 44281        else if (chordPcs.Contains(Mod(rootPc + 6))) fifth = Mod(rootPc + 6);
 0282        else if (chordPcs.Contains(Mod(rootPc + 8))) fifth = Mod(rootPc + 8);
 0283        else fifth = Mod(rootPc + 7);
 115284        int sev = chordPcs.FirstOrDefault(pc => Mod(pc - rootPc) is 9 or 10 or 11, Mod(rootPc + 10));
 23285        return figure switch
 23286        {
 17287            "7" => rootPc,
 2288            "65" => third,
 2289            "43" => fifth,
 2290            "42" => sev,
 0291            _ => rootPc
 23292        };
 293    }
 294
 295    static int[] ParseHeadToChordPcs(string head, string figure, char? qualMark, Key key, out int rootPc, out int[] chor
 296    {
 67297        int tonicPc = ((key.TonicMidi % 12) + 12) % 12;
 67298        bool maj = key.IsMajor;
 299        // Detect seventh chords via figure presence containing 7*
 67300        bool isSeventh = figure is "7" or "65" or "43" or "42";
 301    // mixture prefixes supported: bII, bIII, bVI, bVII (match exactly; check longer tokens first to avoid prefix collis
 302    // Neapolitan symbol 'N' maps to bII (major triad on lowered supertonic)
 67303    string H = head.ToUpperInvariant();
 304    // Defensive disambiguation: if sanitized looks like BII but original contained explicit III or U+2162 (Ⅲ), treat as
 67305    if (H == "BII" && !string.IsNullOrEmpty(originalToken))
 306    {
 14307        string raw = originalToken;
 14308        if (raw.IndexOf('\u2162') >= 0 || raw.ToUpperInvariant().Contains("III"))
 309        {
 0310            H = "BIII";
 311        }
 312    }
 67313    if (H == "N")
 314        {
 11315            rootPc = (tonicPc + 1) % 12;
 316            // Treat N7 as bII7 (dominant seventh on Neapolitan)
 11317            var q = isSeventh ? ChordQuality.DominantSeventh : ChordQuality.Major;
 11318            chordPcs = new Chord(rootPc, q).PitchClasses().ToArray();
 11319            return chordPcs;
 320        }
 56321    if (H == "BVII")
 322        {
 7323            rootPc = (tonicPc + 10) % 12;
 7324            var q = isSeventh ? ChordQuality.DominantSeventh : ChordQuality.Major;
 7325            chordPcs = new Chord(rootPc, q).PitchClasses().ToArray();
 7326            return chordPcs;
 327        }
 49328    if (H == "BVI")
 329        {
 12330            rootPc = (tonicPc + 8) % 12;
 12331            var q = isSeventh ? ChordQuality.DominantSeventh : ChordQuality.Major;
 12332            chordPcs = new Chord(rootPc, q).PitchClasses().ToArray();
 12333            return chordPcs;
 334        }
 37335    if (H == "BIII")
 336        {
 13337            rootPc = (tonicPc + 3) % 12;
 13338            chordPcs = new Chord(rootPc, ChordQuality.Major).PitchClasses().ToArray();
 13339            return chordPcs;
 340        }
 24341    if (H == "BII")
 342        {
 14343            rootPc = (tonicPc + 1) % 12;
 14344            var q = isSeventh ? ChordQuality.DominantSeventh : ChordQuality.Major;
 14345            chordPcs = new Chord(rootPc, q).PitchClasses().ToArray();
 14346            return chordPcs;
 347        }
 348
 349        // Normalize head to core degree token (case-sensitive kept for intent)
 10350        string h = head;
 10351        int DegMidi(int d) => ((key.ScaleDegreeMidi(d) % 12) + 12) % 12;
 352
 353    // isSeventh already computed above
 354
 355        // Identify degree index by roman numerals within h (ignoring case)
 64356        static bool Eq(string s, string a) => s.Equals(a, StringComparison.Ordinal);
 357        // Provide mappings for common forms
 10358        if (Eq(h, "I"))
 359        {
 2360            rootPc = DegMidi(0);
 2361            var q = isSeventh ? (maj ? ChordQuality.MajorSeventh : ChordQuality.MinorSeventh) : (maj ? ChordQuality.Majo
 2362            chordPcs = new Chord(rootPc, q).PitchClasses().ToArray();
 2363            return chordPcs;
 364        }
 8365        if (Eq(h, "i"))
 366        {
 0367            rootPc = DegMidi(0);
 0368            var q = isSeventh ? ChordQuality.MinorSeventh : ChordQuality.Minor;
 0369            chordPcs = new Chord(rootPc, q).PitchClasses().ToArray();
 0370            return chordPcs;
 371        }
 372
 8373        if (Eq(h, "II") || Eq(h, "ii"))
 374        {
 2375            rootPc = DegMidi(1);
 376            ChordQuality q;
 2377            if (isSeventh)
 378            {
 1379                q = maj ? ChordQuality.MinorSeventh : ChordQuality.HalfDiminishedSeventh; // ii7 in major, iiø7 in minor
 380            }
 381            else
 382            {
 1383                q = maj ? ChordQuality.Minor : ChordQuality.Diminished;
 384            }
 2385            chordPcs = new Chord(rootPc, q).PitchClasses().ToArray();
 2386            return chordPcs;
 387        }
 388
 6389        if (Eq(h, "III") || Eq(h, "iii"))
 390        {
 0391            rootPc = DegMidi(2);
 0392            var q = isSeventh ? (maj ? ChordQuality.MinorSeventh : ChordQuality.MajorSeventh)
 0393                              : (maj ? ChordQuality.Minor : ChordQuality.Major);
 0394            chordPcs = new Chord(rootPc, q).PitchClasses().ToArray();
 0395            return chordPcs;
 396        }
 397
 6398        if (Eq(h, "IV") || Eq(h, "iv"))
 399        {
 0400            rootPc = DegMidi(3);
 0401            var q = isSeventh ? (maj ? ChordQuality.MajorSeventh : ChordQuality.MinorSeventh)
 0402                              : (maj ? ChordQuality.Major : ChordQuality.Minor);
 0403            chordPcs = new Chord(rootPc, q).PitchClasses().ToArray();
 0404            return chordPcs;
 405        }
 406
 6407        if (Eq(h, "V") || Eq(h, "v"))
 408        {
 6409            rootPc = DegMidi(4);
 6410            var q = isSeventh ? ChordQuality.DominantSeventh : ChordQuality.Major;
 6411            chordPcs = new Chord(rootPc, q).PitchClasses().ToArray();
 6412            return chordPcs;
 413        }
 414
 0415        if (Eq(h, "VI") || Eq(h, "vi"))
 416        {
 0417            rootPc = DegMidi(5);
 0418            var q = isSeventh ? (maj ? ChordQuality.MinorSeventh : ChordQuality.MajorSeventh)
 0419                              : (maj ? ChordQuality.Minor : ChordQuality.Major);
 0420            chordPcs = new Chord(rootPc, q).PitchClasses().ToArray();
 0421            return chordPcs;
 422        }
 423
 0424        if (h.StartsWith("vii", StringComparison.OrdinalIgnoreCase))
 425        {
 0426            rootPc = DegMidi(6);
 427            ChordQuality q;
 0428            if (isSeventh)
 429            {
 430                // viiø7 in major, vii°7 in minor (if marked °, honor it)
 0431                if (qualMark == 'ø') q = ChordQuality.HalfDiminishedSeventh;
 0432                else if (qualMark == '°') q = ChordQuality.DiminishedSeventh;
 0433                else q = maj ? ChordQuality.HalfDiminishedSeventh : ChordQuality.DiminishedSeventh;
 434            }
 435            else
 436            {
 0437                q = ChordQuality.Diminished;
 438            }
 0439            chordPcs = new Chord(rootPc, q).PitchClasses().ToArray();
 0440            return chordPcs;
 441        }
 442
 0443        throw new ArgumentException($"Unsupported roman token: '{head}{(qualMark is null ? string.Empty : qualMark.ToStr
 444    }
 445
 446    static int? SelectBassFromTriadOrSeventh(string figure, int rootPc, int[] chordPcs)
 447    {
 100448        if (string.IsNullOrEmpty(figure)) return rootPc;
 324449        static int Mod(int x) => ((x % 12) + 12) % 12;
 450        // Derive chord tones directly from provided pitch classes for robustness
 102451        int third = chordPcs.FirstOrDefault(pc => Mod(pc - rootPc) is 3 or 4, Mod(rootPc + 4));
 452        // Fifth may be 6 (diminished), 7 (perfect) or 8 (augmented) depending on quality
 453        int fifth;
 67454        if (chordPcs.Contains(Mod(rootPc + 7))) fifth = Mod(rootPc + 7);
 2455        else if (chordPcs.Contains(Mod(rootPc + 6))) fifth = Mod(rootPc + 6);
 0456        else if (chordPcs.Contains(Mod(rootPc + 8))) fifth = Mod(rootPc + 8);
 0457        else fifth = Mod(rootPc + 7);
 153458        int sev = chordPcs.FirstOrDefault(pc => Mod(pc - rootPc) is 9 or 10 or 11, Mod(rootPc + 10));
 34459        return figure switch
 34460        {
 15461            "6" => third,
 2462            "64" => fifth,
 11463            "7" => rootPc,
 2464            "65" => third,
 2465            "43" => fifth,
 2466            "42" => sev,
 0467            _ => rootPc
 34468        };
 469    }
 470
 471    static int GetSeventhPc(int rootPc, int[] chordPcs)
 472    {
 473        // For seventh chords, chordPcs length is 4; assume order contains root, 3rd, 5th, 7th in some rotation starting
 0474        if (chordPcs.Length == 4)
 475        {
 0476            int idx = Array.IndexOf(chordPcs, rootPc);
 0477            if (idx >= 0)
 478            {
 0479                return chordPcs[(idx + 3) % 4];
 480            }
 481        }
 482        // Fallback: attempt to add a minor seventh above root
 0483        return (rootPc + 10) % 12;
 484    }
 485}