< Summary

Information
Class: MusicTheory.Theory.Harmony.HarmonyAnalysisResult
Assembly: MusicTheory
File(s): /home/runner/work/MusicTheory/MusicTheory/Theory/Harmony/HarmonyAnalyzer.cs
Line coverage
100%
Covered lines: 6
Uncovered lines: 0
Coverable lines: 6
Total lines: 853
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Success()100%11100%
get_Roman()100%11100%
get_Function()100%11100%
get_RomanText()100%11100%
get_Warnings()100%11100%
get_Errors()100%11100%

File(s)

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

#LineLine coverage
 1using System.Collections.Generic;
 2using System.Linq;
 3
 4namespace MusicTheory.Theory.Harmony;
 5
 6/// <summary>
 7/// Result of a single harmony analysis.
 8/// </summary>
 9/// <param name="Success">True if any roman numeral labeling was determined.</param>
 10/// <param name="Roman">Structured roman numeral head (nullable for special cases like Aug6 only text).</param>
 11/// <param name="Function">Approximate tonal function (Tonic/Subdominant/Dominant/Unknown).</param>
 12/// <param name="RomanText">Display text including accidentals and inversion figures (e.g., "bII6", "V65/V", "Ger65").</
 13/// <param name="Warnings">Non-blocking diagnostics (voice-leading hints, Neapolitan recommendations, etc.).</param>
 14/// <param name="Errors">Blocking or structural issues (range/order violations, overlaps, etc.).</param>
 15public readonly record struct HarmonyAnalysisResult(
 13516    bool Success,
 54717    RomanNumeral? Roman,
 118    TonalFunction Function,
 43819    string? RomanText,
 1120    List<string> Warnings,
 421    List<string> Errors
 22);
 23
 24public static class HarmonyAnalyzer
 25{
 26    private static void AddMixtureSeventhWarnings(List<string> warnings, string mix7Label, Key key, FourPartVoicing? voi
 27    {
 28        try
 29        {
 30            if (string.IsNullOrEmpty(mix7Label)) return;
 31            // Generic, non-blocking hints about common resolutions
 32            // Check longer tokens first to avoid prefix collisions (e.g., bVII vs bVI)
 33            if (mix7Label.StartsWith("bVII", System.StringComparison.Ordinal))
 34            {
 35                warnings.Add("Mixture: bVII7 often resolves to I (backdoor)");
 36            }
 37            else if (mix7Label.StartsWith("bVI", System.StringComparison.Ordinal))
 38            {
 39                warnings.Add("Mixture: bVI7 typically resolves to V");
 40            }
 41            else if (mix7Label.StartsWith("bII", System.StringComparison.Ordinal))
 42            {
 43                warnings.Add("Mixture: bII7 (Neapolitan 7) often resolves to V or I6");
 44            }
 45            else if (mix7Label.StartsWith("iv", System.StringComparison.Ordinal))
 46            {
 47                warnings.Add("Mixture: iv7 typically resolves to V");
 48            }
 49        }
 50        catch { /* diagnostics must not throw */ }
 51    }
 52    /// <summary>
 53    /// Analyze a chord snapshot as triad/seventh harmony and return roman numeral labeling.
 54    /// </summary>
 55    /// <param name="pcs">All sounding pitch classes (MIDI modulo 12 recommended).</param>
 56    /// <param name="key">The harmonic context.</param>
 57    /// <param name="voicing">Optional four-part voicing for inversion and voice-leading diagnostics.</param>
 58    /// <param name="prev">Optional previous four-part voicing for motion diagnostics (parallels/overlaps).</param>
 59    /// <returns>HarmonyAnalysisResult with roman text, function and diagnostics.</returns>
 60    public static HarmonyAnalysisResult AnalyzeTriad(int[] pcs, Key key, FourPartVoicing? voicing = null, FourPartVoicin
 61        => AnalyzeTriad(pcs, key, HarmonyOptions.Default, voicing, prev);
 62
 63    /// <summary>
 64    /// Analyze with custom <see cref="HarmonyOptions"/> for disambiguation preferences.
 65    /// </summary>
 66    /// <param name="pcs">All sounding pitch classes.</param>
 67    /// <param name="key">The harmonic context.</param>
 68    /// <param name="options">Preference toggles (Aug6 vs mixture, Neapolitan enforcement, etc.).</param>
 69    /// <param name="voicing">Optional four-part voicing.</param>
 70    /// <param name="prev">Optional previous four-part voicing for motion checks.</param>
 71    public static HarmonyAnalysisResult AnalyzeTriad(int[] pcs, Key key, HarmonyOptions options, FourPartVoicing? voicin
 72    {
 73        var warnings = new List<string>();
 74        var errors = new List<string>();
 75
 76        // Normalize inputs
 77        int[] raw = pcs.Select(p => ((p % 12) + 12) % 12).ToArray();
 78        int[] distinct = raw.Distinct().ToArray();
 79    int[] ordered = distinct.OrderBy(x => x).ToArray();
 80    bool preferSeventh = ordered.Length >= 4; // 4音以上は7th/テンション系を最優先(V9 等も含む)
 81
 82        // 1) V9 は常に最優先で即時リターン(distinct/raw の両方で判定)
 83    if (ChordRomanizer.TryRomanizeDominantNinth(ordered, key, out var v9Early)
 84     || ChordRomanizer.TryRomanizeDominantNinth(raw, key, out v9Early))
 85        {
 86        var v9label = options is not null && options.PreferV7Paren9OverV9 ? "V7(9)" : v9Early;
 87        var funcV = RomanNumeralUtils.FunctionOf(RomanNumeral.V);
 88        return new HarmonyAnalysisResult(true, RomanNumeral.V, funcV, v9label, warnings, errors);
 89        }
 90
 91    // (moved) Augmented Sixth detection occurs after mixture sevenths, to preserve bVI7 labeling unless bass=b6 voicing
 92
 93        // 2) ダイアトニック7th(4音): ボイシングがあれば反転を付与(最優先で、曖昧な二次導七等より優先)
 94        //    ただし、voicingがあり bass=b6 の正規Augmented Sixth (It6/Fr43/Ger65) に一致する場合は、
 95        //    先にAug6を優先して即時リターン(まれな誤認を防ぐための早期チェック)。
 96        if (preferSeventh && voicing is FourPartVoicing vAugEarly && !options.PreferMixtureSeventhOverAugmentedSixthWhen
 97        {
 98            int tonicPcE = (key.TonicMidi % 12 + 12) % 12;
 99            int b6pcE = (tonicPcE + 8) % 12;
 100            int sopranoPcE = (vAugEarly.S % 12 + 12) % 12;
 101            bool suppressAug6E = options.DisallowAugmentedSixthWhenSopranoFlat6 && sopranoPcE == b6pcE;
 102            if (!suppressAug6E && ChordRomanizer.TryRomanizeAugmentedSixth(pcs, key, options, vAugEarly, out var aug6Ear
 103            {
 104                return new HarmonyAnalysisResult(true, null, TonalFunction.Subdominant, aug6EarlyPre, warnings, errors);
 105            }
 106        }
 107            if (preferSeventh && ChordRomanizer.TryRomanizeSeventh(pcs, key, out var rn7, out var degree7, out var q7))
 108        {
 109            // Guard: if this voicing also forms an Augmented Sixth and suppression is not requested,
 110            // prefer Aug6 over a diatonic seventh label (safety for rare ambiguous edge cases).
 111            if (voicing is FourPartVoicing vAug2 && !options.PreferMixtureSeventhOverAugmentedSixthWhenAmbiguous)
 112            {
 113                int tonicPcG = (key.TonicMidi % 12 + 12) % 12;
 114                int b6pcG = (tonicPcG + 8) % 12;
 115                int sopranoPcG = (vAug2.S % 12 + 12) % 12;
 116                bool suppressAug6OnSop = options.DisallowAugmentedSixthWhenSopranoFlat6 && sopranoPcG == b6pcG;
 117                if (!suppressAug6OnSop && ChordRomanizer.TryRomanizeAugmentedSixth(pcs, key, options, vAug2, out var aug
 118                {
 119                    return new HarmonyAnalysisResult(true, null, TonalFunction.Subdominant, aug6OverDia, warnings, error
 120                }
 121            }
 122            string? label;
 123            if (voicing.HasValue)
 124            {
 125                var v7 = voicing.Value;
 126                label = BuildSeventhLabel(rn7, degree7, q7, key, v7, options);
 127            }
 128            else
 129            {
 130                label = rn7 + SeventhRootSuffix(q7);
 131                label = EnsureSeventhAccidental(rn7, q7, label);
 132            }
 133            var func7 = RomanNumeralUtils.FunctionOf(rn7);
 134            // Final sanitation: ensure °/ø prefix on diminished-type sevenths
 135            if ((q7 == ChordQuality.DiminishedSeventh || q7 == ChordQuality.HalfDiminishedSeventh) && label is string l0
 136            {
 137                if (!l0.Contains('°') && !l0.Contains('ø'))
 138                {
 139                    string sym = q7 == ChordQuality.DiminishedSeventh ? "°" : "ø";
 140                    if (l0.StartsWith("vii")) l0 = "vii" + sym + l0.Substring(3);
 141                    else if (l0.StartsWith("VII")) l0 = "VII" + sym + l0.Substring(3);
 142                    label = l0;
 143                }
 144            }
 145            return new HarmonyAnalysisResult(true, rn7, func7, label, warnings, errors);
 146        }
 147
 148        // 3) 借用7th(iv7/bVII7/bII7/bVI7)と Aug6 の優先関係はオプションで切替
 149        //    PreferMixtureSeventhOverAugmentedSixthWhenAmbiguous=true の場合、Mixture7th を先に試し、
 150        //    そうでなければ Aug6 を先に試す(従来挙動)。いずれも S=b6 抑制を尊重。
 151        if (preferSeventh && voicing is FourPartVoicing vMix)
 152        {
 153            // Guard: if options disallow Aug6 when soprano is b6, avoid early Aug6 return here
 154            int tonicPc0 = (key.TonicMidi % 12 + 12) % 12;
 155            int b6pc0 = (tonicPc0 + 8) % 12;
 156            int sopranoPc0 = (vMix.S % 12 + 12) % 12;
 157            bool suppressAug6Early = options.DisallowAugmentedSixthWhenSopranoFlat6 && sopranoPc0 == b6pc0;
 158            if (options.PreferMixtureSeventhOverAugmentedSixthWhenAmbiguous)
 159            {
 160                if (ChordRomanizer.TryRomanizeSeventhMixture(pcs, key, options, vMix, out var mix7))
 161                {
 162                    RomanNumeral baseRn = mix7!.StartsWith("iv") ? RomanNumeral.iv
 163                        : mix7!.StartsWith("bII") ? RomanNumeral.II
 164                        : mix7!.StartsWith("bVI") ? RomanNumeral.VI
 165                        : RomanNumeral.VII;
 166                    var funcMix = RomanNumeralUtils.FunctionOf(baseRn);
 167                    AddMixtureSeventhWarnings(warnings, mix7!, key, vMix);
 168                    return new HarmonyAnalysisResult(true, baseRn, funcMix, mix7, warnings, errors);
 169                }
 170                if (!suppressAug6Early && ChordRomanizer.TryRomanizeAugmentedSixth(pcs, key, options, vMix, out var aug6
 171                {
 172                    return new HarmonyAnalysisResult(true, null, TonalFunction.Subdominant, aug6Early, warnings, errors)
 173                }
 174            }
 175            else
 176            {
 177                if (!suppressAug6Early && ChordRomanizer.TryRomanizeAugmentedSixth(pcs, key, options, vMix, out var aug6
 178                {
 179                    return new HarmonyAnalysisResult(true, null, TonalFunction.Subdominant, aug6Early, warnings, errors)
 180                }
 181                if (ChordRomanizer.TryRomanizeSeventhMixture(pcs, key, options, vMix, out var mix7))
 182                {
 183                    RomanNumeral baseRn = mix7!.StartsWith("iv") ? RomanNumeral.iv
 184                        : mix7!.StartsWith("bII") ? RomanNumeral.II
 185                        : mix7!.StartsWith("bVI") ? RomanNumeral.VI
 186                        : RomanNumeral.VII;
 187                    var funcMix = RomanNumeralUtils.FunctionOf(baseRn);
 188                    AddMixtureSeventhWarnings(warnings, mix7!, key, vMix);
 189                    return new HarmonyAnalysisResult(true, baseRn, funcMix, mix7, warnings, errors);
 190                }
 191            }
 192        }
 193
 194        // 4) 借用7th(iv7/bVII7): ボイシング無し(root position 表記)
 195    if (preferSeventh && ChordRomanizer.TryRomanizeSeventhMixture(pcs, key, options, null, out var mix7NoVoicing))
 196        {
 197            RomanNumeral baseRn = mix7NoVoicing!.StartsWith("iv") ? RomanNumeral.iv
 198                : mix7NoVoicing!.StartsWith("bII") ? RomanNumeral.II
 199                : mix7NoVoicing!.StartsWith("bVI") ? RomanNumeral.VI
 200                : RomanNumeral.VII;
 201            var funcMix = RomanNumeralUtils.FunctionOf(baseRn);
 202            AddMixtureSeventhWarnings(warnings, mix7NoVoicing!, key, null);
 203            return new HarmonyAnalysisResult(true, baseRn, funcMix, mix7NoVoicing, warnings, errors);
 204        }
 205
 206        // 4.2) Augmented Sixth (requires bass=b6 in voicing to disambiguate from bVI7)
 207        //      ただし、ソプラノも b6 かつ抑制オプション有効時はここでもスキップして bVI7 を優先
 208        if (voicing is FourPartVoicing vAug)
 209        {
 210            int tonicPc1 = (key.TonicMidi % 12 + 12) % 12;
 211            int b6pc1 = (tonicPc1 + 8) % 12;
 212            int sopranoPc1 = (vAug.S % 12 + 12) % 12;
 213            bool suppressAug6 = options.DisallowAugmentedSixthWhenSopranoFlat6 && sopranoPc1 == b6pc1;
 214            if (!options.PreferMixtureSeventhOverAugmentedSixthWhenAmbiguous)
 215            {
 216                if (!suppressAug6 && ChordRomanizer.TryRomanizeAugmentedSixth(pcs, key, options, vAug, out var aug6))
 217                {
 218                    return new HarmonyAnalysisResult(true, null, TonalFunction.Subdominant, aug6, warnings, errors);
 219                }
 220            }
 221        }
 222
 223        // 4.5) Minor key safeguard: prefer diatonic iiø7 over any secondary interpretation
 224    if (preferSeventh && !key.IsMajor && options.PreferDiatonicIiHalfDimInMinor)
 225        {
 226            int degPc = DegreeRootPcLocal(key, 1); // ii root in minor
 227            var iiDim7 = new Chord(degPc, ChordQuality.HalfDiminishedSeventh).PitchClasses().OrderBy(x => x).ToArray();
 228            var set = ordered;
 229            if (iiDim7.Length == set.Length && iiDim7.All(set.Contains))
 230            {
 231                string label = "iiø7";
 232                if (voicing is FourPartVoicing vMinor)
 233                {
 234                    int bass = (vMinor.B % 12 + 12) % 12;
 235                    int third = (degPc + 3) % 12;
 236                    int fifth = (degPc + 6) % 12;
 237                    int seventh = (degPc + 10) % 12;
 238                    if      (bass == degPc)   label = "iiø7";
 239                    else if (bass == third)   label = "iiø65";
 240                    else if (bass == fifth)   label = "iiø43";
 241                    else if (bass == seventh) label = "iiø42";
 242                }
 243                return new HarmonyAnalysisResult(true, RomanNumeral.ii, TonalFunction.Subdominant, label, warnings, erro
 244            }
 245        }
 246
 247        // 5) Secondary dominants / secondary leading-tone sevenths(root or inversion when voicingあり)
 248        if (preferSeventh)
 249        {
 250            if (voicing is FourPartVoicing vSec7)
 251            {
 252                if (ChordRomanizer.TryRomanizeSecondaryDominant(pcs, key, vSec7, out var secDom7))
 253                    return new HarmonyAnalysisResult(true, RomanNumeral.V, TonalFunction.Dominant, secDom7, warnings, er
 254                if (ChordRomanizer.TryRomanizeSecondaryLeadingTone(pcs, key, vSec7, options, out var secLt7))
 255                {
 256                    // Prefer diatonic seventh if also matches (e.g., iiø7 in minor vs viiø7/III)
 257                    if (ChordRomanizer.TryRomanizeSeventh(pcs, key, out var rnDia7, out var degDia7, out var qDia7))
 258                    {
 259                        // Preserve inversion when voicing is available
 260                        string diatxt = BuildSeventhLabel(rnDia7, degDia7, qDia7, key, vSec7, options);
 261                        return new HarmonyAnalysisResult(true, rnDia7, RomanNumeralUtils.FunctionOf(rnDia7), diatxt, war
 262                    }
 263                    return new HarmonyAnalysisResult(true, RomanNumeral.vii, TonalFunction.Dominant, secLt7, warnings, e
 264                }
 265            }
 266            else
 267            {
 268                if (ChordRomanizer.TryRomanizeSecondaryDominant(pcs, key, null, out var secDom7NoV))
 269                    return new HarmonyAnalysisResult(true, RomanNumeral.V, TonalFunction.Dominant, secDom7NoV, warnings,
 270                if (ChordRomanizer.TryRomanizeSecondaryLeadingTone(pcs, key, null, options, out var secLt7NoV))
 271                {
 272                    if (ChordRomanizer.TryRomanizeSeventh(pcs, key, out var rnDia7b, out var degDia7b, out var qDia7b))
 273                    {
 274                        string diatxt = rnDia7b + SeventhRootSuffix(qDia7b);
 275                        return new HarmonyAnalysisResult(true, rnDia7b, RomanNumeralUtils.FunctionOf(rnDia7b), diatxt, w
 276                    }
 277                    return new HarmonyAnalysisResult(true, RomanNumeral.vii, TonalFunction.Dominant, secLt7NoV, warnings
 278                }
 279            }
 280        }
 281
 282    // 6) 三和音(ダイアトニック→借用→二次属/二次導)。4音以上(テンション/7th系)は三和音ローマナイズをスキップ
 283        RomanNumeral rn = default; bool hasRn = false; string? romanText = null;
 284        string? mixtureText = null;
 285    // Triad pathway: only when distinct pitch classes count is exactly 3.
 286    // (Previously <=3 allowed degenerate dyads; tighten per spec: distinct=3 音時のみ)
 287    if (ordered.Length == 3)
 288        {
 289            if (ChordRomanizer.TryRomanizeTriad(pcs, key, out rn))
 290            {
 291                hasRn = true; romanText = rn.ToString();
 292            }
 293            else if (ChordRomanizer.TryRomanizeTriadMixture(pcs, key, out rn, out mixtureText))
 294            {
 295                hasRn = true; romanText = mixtureText ?? rn.ToString();
 296            }
 297            else if (ChordRomanizer.TryRomanizeSecondaryDominant(pcs, key, voicing, out var secDomTri))
 298            {
 299                hasRn = true; romanText = secDomTri;
 300                // Immediate reinforcement: add inversion 6/64 for secondary dominant triads if missing
 301                if (voicing is FourPartVoicing vSecImm && ordered.Length == 3 && romanText is string rtImm && rtImm.Star
 302                {
 303                    int slash = rtImm.IndexOf('/');
 304                    if (slash > 0 && slash < rtImm.Length - 1)
 305                    {
 306                        string target = rtImm[(slash + 1)..];
 307                        int? deg = target switch { "ii" => 1, "iii" => 2, "IV" => 3, "V" => 4, "vi" => 5, "vii" or "vii°
 308                        if (deg is int d)
 309                        {
 310                            int targetPc = DegreeRootPcLocal(key, d);
 311                            int root = (targetPc + 7) % 12; // V/x root
 312                            int bassPc = (vSecImm.B % 12 + 12) % 12;
 313                            int third = (root + 4) % 12;
 314                            int fifth = (root + 7) % 12;
 315                            if (bassPc == third) romanText = $"V6/{target}";
 316                            else if (bassPc == fifth) romanText = $"V64/{target}";
 317                        }
 318                    }
 319                }
 320            }
 321            else if (ChordRomanizer.TryRomanizeSecondaryLeadingTone(pcs, key, voicing, options, out var secLtTri))
 322            {
 323                hasRn = true; romanText = secLtTri;
 324                // Immediate reinforcement: add inversion 6/64 for secondary leading-tone triads if missing
 325                if (voicing is FourPartVoicing vSecImm2 && ordered.Length == 3 && romanText is string rtImm2 && rtImm2.S
 326                {
 327                    int slash = rtImm2.IndexOf('/');
 328                    if (slash > 0 && slash < rtImm2.Length - 1)
 329                    {
 330                        string target = rtImm2[(slash + 1)..];
 331                        int? deg = target switch { "ii" => 1, "iii" => 2, "IV" => 3, "V" => 4, "vi" => 5, "vii" or "vii°
 332                        if (deg is int d)
 333                        {
 334                            int targetPc = DegreeRootPcLocal(key, d);
 335                            int root = (targetPc + 11) % 12; // leading-tone root to target
 336                            int bassPc = (vSecImm2.B % 12 + 12) % 12;
 337                            int third = (root + 3) % 12;
 338                            int fifth = (root + 6) % 12;
 339                            if (bassPc == third) romanText = $"vii°6/{target}";
 340                            else if (bassPc == fifth) romanText = $"vii°64/{target}";
 341                        }
 342                    }
 343                }
 344            }
 345
 346            if (hasRn)
 347            {
 348                // diminished の度数記号
 349                var triQ0 = GetTriadQualityFromRoman(rn, key.IsMajor);
 350                if (triQ0 == ChordQuality.Diminished && (romanText == null || !romanText.Contains("°")))
 351                    romanText = (romanText ?? rn.ToString()) + "°";
 352
 353                // 反転は3音のときのみ
 354                if (voicing is FourPartVoicing vTri && ordered.Length == 3)
 355                {
 356                    // Mixture triads like bII/bVI/bVII use non-diatonic roots; handle their inversion figures explicitl
 357                    bool appliedMixtureInversion = false;
 358                    if (!string.IsNullOrEmpty(mixtureText))
 359                    {
 360                        int tonic = (key.TonicMidi % 12 + 12) % 12;
 361                        int? mixRoot = null; ChordQuality mixQ = ChordQuality.Unknown;
 362                        string mt = mixtureText!;
 363                        // Check longer tokens first to avoid prefix collisions (e.g., bVII vs bVI, bIII vs bII, iv vs i
 364                        if (mt.StartsWith("bVII")) { mixRoot = (tonic + 10) % 12; mixQ = ChordQuality.Major; }
 365                        else if (mt.StartsWith("bIII")) { mixRoot = (tonic + 3) % 12; mixQ = ChordQuality.Major; }
 366                        else if (mt.StartsWith("bVI")) { mixRoot = (tonic + 8) % 12; mixQ = ChordQuality.Major; }
 367                        else if (mt.StartsWith("bII")) { mixRoot = (tonic + 1) % 12; mixQ = ChordQuality.Major; }
 368                        else if (mt.StartsWith("iv")) { mixRoot = DegreeRootPcLocal(key, 3); mixQ = ChordQuality.Minor; 
 369                        else if (mt.StartsWith("i")) { mixRoot = tonic; mixQ = ChordQuality.Minor; }
 370
 371                        if (mixRoot is int mr && mixQ != ChordQuality.Unknown)
 372                        {
 373                            var tri = new Chord(mr, mixQ).PitchClasses().ToArray();
 374                            var set = ordered.ToHashSet();
 375                            if (tri.All(set.Contains))
 376                            {
 377                                int bassPc = (vTri.B % 12 + 12) % 12;
 378                                int thirdInt = (mixQ == ChordQuality.Minor || mixQ == ChordQuality.Diminished) ? 3 : 4;
 379                                int fifthInt = mixQ switch { ChordQuality.Diminished => 6, ChordQuality.Augmented => 8, 
 380                                int thirdPc = (mr + thirdInt) % 12;
 381                                int fifthPc = (mr + fifthInt) % 12;
 382                                string baseRoman = mt; // already includes accidental (e.g., bII)
 383                                if      (bassPc == mr) romanText = baseRoman;
 384                                else if (bassPc == thirdPc) romanText = baseRoman + "6";
 385                                else if (bassPc == fifthPc) romanText = baseRoman + "64";
 386                                appliedMixtureInversion = true;
 387                            }
 388                        }
 389                    }
 390
 391                    if (!appliedMixtureInversion)
 392                    {
 393                        int degree = DegreeFromRoman(rn, key.IsMajor) ?? -1;
 394                        if (degree >= 0 && triQ0.HasValue)
 395                        {
 396                            int degPc = DegreeRootPcLocal(key, degree);
 397                            var tri = new Chord(degPc, triQ0.Value).PitchClasses().ToArray();
 398                            var set = ordered.ToHashSet();
 399                            if (tri.All(set.Contains))
 400                            {
 401                                int bassPc = (vTri.B % 12 + 12) % 12;
 402                                int thirdInt = (triQ0 == ChordQuality.Minor || triQ0 == ChordQuality.Diminished) ? 3 : 4
 403                                int fifthInt = triQ0 switch { ChordQuality.Diminished => 6, ChordQuality.Augmented => 8,
 404                                int thirdPc = (degPc + thirdInt) % 12;
 405                                int fifthPc = (degPc + fifthInt) % 12;
 406                                // Preserve mixture accidental (e.g., bII) when present
 407                                string baseRoman = (mixtureText ?? rn.ToString());
 408                                if (triQ0 == ChordQuality.Diminished && !baseRoman.Contains("°")) baseRoman += "°";
 409                                if      (bassPc == degPc) romanText = baseRoman;
 410                                else if (bassPc == thirdPc) romanText = baseRoman + "6";
 411                                else if (bassPc == fifthPc) romanText = baseRoman + "64";
 412                            }
 413                        }
 414
 415                        // Fallback: if mixtureText was not available but current romanText indicates a mixture triad
 416                        // (e.g., "bVII"), still compute inversion figures from voicing.
 417                        if (voicing is FourPartVoicing vExtra && !string.IsNullOrEmpty(romanText))
 418                        {
 419                            string rt = romanText!;
 420                            int tonic = (key.TonicMidi % 12 + 12) % 12;
 421                            int? mixRoot2 = null; ChordQuality mixQ2 = ChordQuality.Unknown;
 422                            // Same longer-first ordering
 423                            if (rt.StartsWith("bVII")) { mixRoot2 = (tonic + 10) % 12; mixQ2 = ChordQuality.Major; }
 424                            else if (rt.StartsWith("bIII")) { mixRoot2 = (tonic + 3) % 12; mixQ2 = ChordQuality.Major; }
 425                            else if (rt.StartsWith("bVI")) { mixRoot2 = (tonic + 8) % 12; mixQ2 = ChordQuality.Major; }
 426                            else if (rt.StartsWith("bII")) { mixRoot2 = (tonic + 1) % 12; mixQ2 = ChordQuality.Major; }
 427                            else if (rt.StartsWith("iv")) { mixRoot2 = DegreeRootPcLocal(key, 3); mixQ2 = ChordQuality.M
 428                            else if (rt.StartsWith("i")) { mixRoot2 = tonic; mixQ2 = ChordQuality.Minor; }
 429
 430                            if (mixRoot2 is int mr2 && mixQ2 != ChordQuality.Unknown)
 431                            {
 432                                var tri2 = new Chord(mr2, mixQ2).PitchClasses().ToArray();
 433                                var set2 = ordered.ToHashSet();
 434                                if (tri2.All(set2.Contains))
 435                                {
 436                                    int bassPc2 = (vExtra.B % 12 + 12) % 12;
 437                                    int thirdInt2 = (mixQ2 == ChordQuality.Minor || mixQ2 == ChordQuality.Diminished) ? 
 438                                    int fifthInt2 = mixQ2 switch { ChordQuality.Diminished => 6, ChordQuality.Augmented 
 439                                    int thirdPc2 = (mr2 + thirdInt2) % 12;
 440                                    int fifthPc2 = (mr2 + fifthInt2) % 12;
 441                                    if      (bassPc2 == mr2) { /* keep base */ }
 442                                    else if (bassPc2 == thirdPc2 && !rt.EndsWith("6")) romanText = rt + "6";
 443                                    else if (bassPc2 == fifthPc2 && !rt.EndsWith("64")) romanText = rt + "64";
 444                                }
 445                            }
 446                        }
 447
 448                        // Supplement: add inversion to secondary triads (V/x or vii°/x) when voicing indicates it
 449                        if (voicing is FourPartVoicing vSec && !string.IsNullOrEmpty(romanText))
 450                        {
 451                            string rt2 = romanText!;
 452                            bool isSecondaryV = rt2.StartsWith("V/");
 453                            bool isSecondaryVii = rt2.StartsWith("vii°/");
 454                            if ((isSecondaryV || isSecondaryVii) && ordered.Length == 3 && !(rt2.Contains("6")))
 455                            {
 456                                // parse target after '/'
 457                                int slash = rt2.IndexOf('/');
 458                                if (slash > 0 && slash < rt2.Length - 1)
 459                                {
 460                                    string target = rt2[(slash + 1)..];
 461                                    int? deg = target switch
 462                                    {
 463                                        "ii" => 1,
 464                                        "iii" => 2,
 465                                        "IV" => 3,
 466                                        "V" => 4,
 467                                        "vi" => 5,
 468                                        "vii" or "vii°" => 6,
 469                                        _ => null
 470                                    };
 471                                    if (deg is int d)
 472                                    {
 473                                        int targetPc = DegreeRootPcLocal(key, d);
 474                                        int root = isSecondaryV ? (targetPc + 7) % 12 : (targetPc + 11) % 12; // V/x or 
 475                                        int bassPc = (vSec.B % 12 + 12) % 12;
 476                                        int third = (root + (isSecondaryV ? 4 : 3)) % 12;
 477                                        int fifth = (root + (isSecondaryV ? 7 : 6)) % 12;
 478                                        if (bassPc == third) romanText = (isSecondaryV ? "V6/" : "vii°6/") + target;
 479                                        else if (bassPc == fifth) romanText = (isSecondaryV ? "V64/" : "vii°64/") + targ
 480                                    }
 481                                }
 482                            }
 483                        }
 484                    }
 485                }
 486            }
 487        }
 488
 489        // 7) 最終セーフティ: V9 を常に再確認(上書き許可)
 490    if (ChordRomanizer.TryRomanizeDominantNinth(ordered, key, out var v9b)
 491     || ChordRomanizer.TryRomanizeDominantNinth(raw, key, out v9b))
 492        {
 493        var funcV2 = RomanNumeralUtils.FunctionOf(RomanNumeral.V);
 494        var v9label2 = options is not null && options.PreferV7Paren9OverV9 ? "V7(9)" : v9b;
 495        return new HarmonyAnalysisResult(true, RomanNumeral.V, funcV2, v9label2, warnings, errors);
 496        }
 497
 498    // 8) 最終セーフティ: 4音以上かつボイシングありで借用7th/二次属7th/二次導七に一致するなら上書き
 499    if (voicing is FourPartVoicing vFinal && ordered.Length >= 4 && ChordRomanizer.TryRomanizeSeventhMixture(pcs, key, o
 500        {
 501            RomanNumeral baseRn = mix7Final!.StartsWith("iv") ? RomanNumeral.iv
 502                : mix7Final!.StartsWith("bII") ? RomanNumeral.II
 503                : mix7Final!.StartsWith("bVI") ? RomanNumeral.VI
 504                : RomanNumeral.VII;
 505            var funcMix = RomanNumeralUtils.FunctionOf(baseRn);
 506            AddMixtureSeventhWarnings(warnings, mix7Final!, key, vFinal);
 507            return new HarmonyAnalysisResult(true, baseRn, funcMix, mix7Final, warnings, errors);
 508        }
 509        if (voicing is FourPartVoicing vFinal2 && ordered.Length >= 4)
 510        {
 511            if (ChordRomanizer.TryRomanizeSecondaryDominant(pcs, key, vFinal2, out var secDomFinal))
 512                return new HarmonyAnalysisResult(true, RomanNumeral.V, TonalFunction.Dominant, secDomFinal, warnings, er
 513            if (ChordRomanizer.TryRomanizeSecondaryLeadingTone(pcs, key, vFinal2, options, out var secLtFinal))
 514                return new HarmonyAnalysisResult(true, RomanNumeral.vii, TonalFunction.Dominant, secLtFinal, warnings, e
 515        }
 516
 517        // 8) ボイシング診断(任意)
 518        if (voicing is FourPartVoicing v)
 519        {
 520            if (VoiceLeadingRules.HasRangeViolation(v)) errors.Add("Range violation");
 521            foreach (var (voice, midi) in v.Notes())
 522            {
 523                var range = VoiceRanges.ForVoice(voice);
 524                if (!range.InHardRange(midi) && range.InWarnRange(midi)) warnings.Add($"{voice} near range (±M3)");
 525            }
 526            if (VoiceLeadingRules.HasSpacingViolations(v)) warnings.Add("Wide spacing S-A or A-T");
 527            if (!v.IsOrderedTopDown()) errors.Add("Voices not ordered S>=A>=T>=B");
 528            if (prev is FourPartVoicing p)
 529            {
 530                if (VoiceLeadingRules.HasOverlap(p, v)) errors.Add("Voice overlap");
 531                if (VoiceLeadingRules.HasParallelPerfects(p, v)) warnings.Add("Parallel perfects detected");
 532            }
 533        }
 534
 535        // 8.5) セーフティ: 一般ダイアトニック7thの再判定(万一ここまで未検出の場合)
 536        if (ChordRomanizer.TryRomanizeSeventh(pcs, key, out var rn7Late, out var degree7Late, out var q7Late))
 537        {
 538            string? labelLate;
 539            if (voicing is FourPartVoicing v7Late)
 540            {
 541                int rootLate = DegreeRootPcLocal(key, degree7Late);
 542                var chord7Late = new Chord(rootLate, q7Late).PitchClasses().ToArray();
 543                int bassPcLate = (v7Late.B % 12 + 12) % 12;
 544                int thirdLate = chord7Late.First(pc => pc != rootLate && (((pc - rootLate + 12) % 12) == 3 || ((pc - roo
 545                int fifthIntLate = q7Late is ChordQuality.DiminishedSeventh or ChordQuality.HalfDiminishedSeventh ? 6 : 
 546                int fifthLate = (rootLate + fifthIntLate) % 12;
 547                int sevIntLate = q7Late switch { ChordQuality.MajorSeventh => 11, ChordQuality.DiminishedSeventh => 9, _
 548                int sevLate = (rootLate + sevIntLate) % 12;
 549                string headAccL = q7Late switch
 550                {
 551                    ChordQuality.MajorSeventh when options.IncludeMajInSeventhInversions => rn7Late + "maj",
 552                    ChordQuality.HalfDiminishedSeventh => rn7Late + "ø",
 553                    ChordQuality.DiminishedSeventh => rn7Late + "°",
 554                    _ => rn7Late.ToString()
 555                };
 556                if      (bassPcLate == rootLate) labelLate = rn7Late + SeventhRootSuffix(q7Late);
 557                else if (bassPcLate == thirdLate) labelLate = headAccL + "65";
 558                else if (bassPcLate == fifthLate) labelLate = headAccL + "43";
 559                else if (bassPcLate == sevLate)   labelLate = headAccL + "42";
 560                else                              labelLate = rn7Late + SeventhRootSuffix(q7Late);
 561                labelLate = EnsureSeventhAccidental(rn7Late, q7Late, labelLate);
 562                // Strong enforcement for diminished-type sevenths: recompute figure unconditionally from voicing
 563                if (q7Late == ChordQuality.DiminishedSeventh || q7Late == ChordQuality.HalfDiminishedSeventh)
 564                {
 565                    string head = rn7Late + (q7Late == ChordQuality.DiminishedSeventh ? "°" : "ø");
 566                    if      (bassPcLate == rootLate) labelLate = head + "7";
 567                    else if (bassPcLate == thirdLate) labelLate = head + "65";
 568                    else if (bassPcLate == fifthLate) labelLate = head + "43";
 569                    else if (bassPcLate == sevLate)   labelLate = head + "42";
 570                }
 571                if ((q7Late == ChordQuality.DiminishedSeventh || q7Late == ChordQuality.HalfDiminishedSeventh)
 572                    && labelLate.EndsWith("7") && !labelLate.EndsWith("maj7"))
 573                {
 574                    int bpc = bassPcLate;
 575                    int thirdPc = (rootLate + 3) % 12;
 576                    int fifthPc = (rootLate + (q7Late == ChordQuality.DiminishedSeventh || q7Late == ChordQuality.HalfDi
 577                    int sevPc = (rootLate + (q7Late == ChordQuality.DiminishedSeventh ? 9 : 10)) % 12;
 578                    string head = rn7Late + (q7Late == ChordQuality.DiminishedSeventh ? "°" : (q7Late == ChordQuality.Ha
 579                    if      (bpc == thirdPc) labelLate = head + "65";
 580                    else if (bpc == fifthPc) labelLate = head + "43";
 581                    else if (bpc == sevPc)   labelLate = head + "42";
 582                }
 583                if ((q7Late == ChordQuality.DiminishedSeventh || q7Late == ChordQuality.HalfDiminishedSeventh)
 584                    && !labelLate.Contains('°') && !labelLate.Contains('ø') && labelLate.StartsWith(rn7Late.ToString()))
 585                {
 586                    labelLate = labelLate.Insert(rn7Late.ToString().Length, q7Late == ChordQuality.DiminishedSeventh ? "
 587                }
 588            }
 589            else
 590            {
 591                labelLate = rn7Late + SeventhRootSuffix(q7Late);
 592                labelLate = EnsureSeventhAccidental(rn7Late, q7Late, labelLate);
 593            }
 594            var func7Late = RomanNumeralUtils.FunctionOf(rn7Late);
 595            if ((q7Late == ChordQuality.DiminishedSeventh || q7Late == ChordQuality.HalfDiminishedSeventh) && labelLate 
 596            {
 597                if (!l1.Contains('°') && !l1.Contains('ø'))
 598                {
 599                    string sym = q7Late == ChordQuality.DiminishedSeventh ? "°" : "ø";
 600                    if (l1.StartsWith("vii")) l1 = "vii" + sym + l1.Substring(3);
 601                    else if (l1.StartsWith("VII")) l1 = "VII" + sym + l1.Substring(3);
 602                    labelLate = l1;
 603                }
 604            }
 605            return new HarmonyAnalysisResult(true, rn7Late, func7Late, labelLate, warnings, errors);
 606        }
 607
 608        if (hasRn)
 609        {
 610            // Final safety: if this is a mixture triad and inversion suffix is missing, enforce based on voicing
 611            try
 612            {
 613                if (ordered.Length == 3 && voicing is FourPartVoicing vMixt && !string.IsNullOrEmpty(romanText))
 614                {
 615                    string rt = romanText!;
 616                    // Target only mixture heads where triad inversion figures apply
 617                    int tonic = (key.TonicMidi % 12 + 12) % 12;
 618                    int? mixRootF = null; ChordQuality mixQF = ChordQuality.Unknown;
 619                    if (rt.StartsWith("bVII")) { mixRootF = (tonic + 10) % 12; mixQF = ChordQuality.Major; }
 620                    else if (rt.StartsWith("bIII")) { mixRootF = (tonic + 3) % 12; mixQF = ChordQuality.Major; }
 621                    else if (rt.StartsWith("bVI")) { mixRootF = (tonic + 8) % 12; mixQF = ChordQuality.Major; }
 622                    else if (rt.StartsWith("bII")) { mixRootF = (tonic + 1) % 12; mixQF = ChordQuality.Major; }
 623                    else if (rt.StartsWith("iv")) { mixRootF = DegreeRootPcLocal(key, 3); mixQF = ChordQuality.Minor; }
 624                    else if (rt.StartsWith("i")) { mixRootF = tonic; mixQF = ChordQuality.Minor; }
 625
 626                    if (mixRootF is int mrF && mixQF != ChordQuality.Unknown)
 627                    {
 628                        var triF = new Chord(mrF, mixQF).PitchClasses().ToArray();
 629                        var setF = ordered.ToHashSet();
 630                        if (triF.All(setF.Contains))
 631                        {
 632                            int bassF = (vMixt.B % 12 + 12) % 12;
 633                            int thirdIntF = (mixQF == ChordQuality.Minor || mixQF == ChordQuality.Diminished) ? 3 : 4;
 634                            int fifthIntF = mixQF switch { ChordQuality.Diminished => 6, ChordQuality.Augmented => 8, _ 
 635                            int thirdPcF = (mrF + thirdIntF) % 12;
 636                            int fifthPcF = (mrF + fifthIntF) % 12;
 637                            bool hasFig6 = rt.EndsWith("6");
 638                            bool hasFig64 = rt.EndsWith("64");
 639                            if (!hasFig64 && !hasFig6)
 640                            {
 641                                if (bassF == thirdPcF) romanText = rt + "6";
 642                                else if (bassF == fifthPcF) romanText = rt + "64";
 643                            }
 644                        }
 645                    }
 646                }
 647            }
 648            catch { /* safety must not throw */ }
 649            var func = RomanNumeralUtils.FunctionOf(rn);
 650            // Neapolitan (bII) diagnostics & optional enforcement
 651            try
 652            {
 653                // Only triads (distinct 3 PCs) participate in inversion diagnostics here
 654                if (ordered.Length == 3)
 655                {
 656                    var rt = romanText ?? rn.ToString();
 657                    if (!string.IsNullOrEmpty(rt) && rt.StartsWith("bII"))
 658                    {
 659                        // Resolution hint (informational)
 660                        warnings.Add("Neapolitan: typical resolution to V");
 661
 662                        // Prefer bII6 over root or 64 when voicing indicates those
 663                        if (voicing is FourPartVoicing vNeap)
 664                        {
 665                            int tonicPcN = (key.TonicMidi % 12 + 12) % 12;
 666                            int neapRoot = (tonicPcN + 1) % 12; // bII root
 667                            int bassPcN = (vNeap.B % 12 + 12) % 12;
 668                            bool isRootPos = bassPcN == neapRoot;
 669                            // compute fifth pc for completeness
 670                            int fifthPcN = (neapRoot + 7) % 12;
 671                            bool isSecondInv = bassPcN == fifthPcN;
 672                            if ((rt == "bII" && isRootPos) || (rt == "bII64" && isSecondInv))
 673                            {
 674                                warnings.Add("Neapolitan: prefer bII6 (first inversion)");
 675                            }
 676
 677                            // Optional enforcement: relabel to bII6 when enabled
 678                            if (options.EnforceNeapolitanFirstInversion)
 679                            {
 680                                // Enforce only on plain triads (not sevenths), which we are in
 681                                // If already 6, leave as is. Otherwise coerce to 6 when head is bII
 682                                if (!rt.EndsWith("6") || rt.EndsWith("64"))
 683                                {
 684                                    romanText = "bII6";
 685                                }
 686                            }
 687                        }
 688                        else if (options.EnforceNeapolitanFirstInversion)
 689                        {
 690                            // Without voicing, still coerce to bII6 when enabled (pedagogical style)
 691                            if (rt == "bII" || rt == "bII64") romanText = "bII6";
 692                        }
 693                    }
 694                }
 695            }
 696            catch { /* diagnostics must not break analysis */ }
 697            return new HarmonyAnalysisResult(true, rn, func, romanText, warnings, errors);
 698        }
 699
 700        // 9) 何も一致しない
 701        return new HarmonyAnalysisResult(false, null, TonalFunction.Unknown, null, warnings, errors);
 702    }
 703
 704    private static ChordQuality? GetTriadQualityFromRoman(RomanNumeral rn, bool isMajor)
 705    {
 706        return rn switch
 707        {
 708            RomanNumeral.I or RomanNumeral.IV or RomanNumeral.V => ChordQuality.Major,
 709            RomanNumeral.ii or RomanNumeral.iii or RomanNumeral.vi => ChordQuality.Minor,
 710            RomanNumeral.vii => ChordQuality.Diminished,
 711            RomanNumeral.i or RomanNumeral.iv or RomanNumeral.v => ChordQuality.Minor,
 712            RomanNumeral.III or RomanNumeral.VII => isMajor ? null : ChordQuality.Major,
 713            _ => null
 714        };
 715    }
 716
 717    private static string BuildSeventhLabel(RomanNumeral rn7, int degree, ChordQuality q7, Key key, FourPartVoicing voic
 718    {
 719        int root = DegreeRootPcLocal(key, degree);
 720        int bassPc = (voicing.B % 12 + 12) % 12;
 721        int thirdInt = q7 switch
 722        {
 723            ChordQuality.MajorSeventh or ChordQuality.DominantSeventh => 4,
 724            _ => 3
 725        };
 726        int fifthInt = q7 is ChordQuality.DiminishedSeventh or ChordQuality.HalfDiminishedSeventh ? 6 : 7;
 727        int sevInt = q7 switch { ChordQuality.MajorSeventh => 11, ChordQuality.DiminishedSeventh => 9, _ => 10 };
 728        int delta = (bassPc - root + 12) % 12;
 729        string head = q7 switch
 730        {
 731            ChordQuality.MajorSeventh when options.IncludeMajInSeventhInversions => rn7 + "maj",
 732            ChordQuality.HalfDiminishedSeventh => rn7 + "ø",
 733            ChordQuality.DiminishedSeventh => rn7 + "°",
 734            _ => rn7.ToString()
 735        };
 736        string fig = delta switch
 737        {
 738            0 => SeventhRootSuffix(q7),
 739            var d when d == thirdInt => "65",
 740            var d when d == fifthInt => "43",
 741            var d when d == sevInt => "42",
 742            _ => SeventhRootSuffix(q7)
 743        };
 744        // Avoid duplicating accidentals: when fig already includes '°7' or 'ø7', do not prepend head with symbol.
 745        string label = fig switch
 746        {
 747            "7" => rn7 + fig,
 748            "maj7" => rn7 + fig,
 749            "°7" => rn7 + fig,
 750            "ø7" => rn7 + fig,
 751            _ => head + fig
 752        };
 753        label = EnsureSeventhAccidental(rn7, q7, label);
 754        return label;
 755    }
 756
 757    private static int? DegreeFromRoman(RomanNumeral rn, bool isMajor)
 758    {
 759        return rn switch
 760        {
 761            RomanNumeral.I or RomanNumeral.i => 0,
 762            RomanNumeral.II or RomanNumeral.ii => 1,
 763            RomanNumeral.III or RomanNumeral.iii => 2,
 764            RomanNumeral.IV or RomanNumeral.iv => 3,
 765            RomanNumeral.V or RomanNumeral.v => 4,
 766            RomanNumeral.VI or RomanNumeral.vi => 5,
 767            RomanNumeral.VII or RomanNumeral.vii => 6,
 768            _ => null
 769        };
 770    }
 771
 772    private static ChordQuality? SeventhQualityInMajor(int degree)
 773    {
 774        return degree switch
 775        {
 776            0 => ChordQuality.MajorSeventh,     // Imaj7
 777            1 => ChordQuality.MinorSeventh,     // ii7
 778            2 => ChordQuality.MinorSeventh,     // iii7
 779            3 => ChordQuality.MajorSeventh,     // IVmaj7
 780            4 => ChordQuality.DominantSeventh,  // V7
 781            5 => ChordQuality.MinorSeventh,     // vi7
 782            6 => ChordQuality.HalfDiminishedSeventh, // viiø7
 783            _ => null
 784        };
 785    }
 786
 787    private static ChordQuality? SeventhQualityInMinor(int degree)
 788    {
 789        // Harmonic minor diatonic sevenths:
 790        // i7, iiø7, IIImaj7, iv7, V7, VImaj7, vii°7 (or ø7 depending). We'll use fully diminished for leading-tone seve
 791        return degree switch
 792        {
 793            0 => ChordQuality.MinorSeventh,             // i7
 794            1 => ChordQuality.HalfDiminishedSeventh,     // iiø7
 795            2 => ChordQuality.MajorSeventh,              // IIImaj7
 796            3 => ChordQuality.MinorSeventh,              // iv7
 797            4 => ChordQuality.DominantSeventh,           // V7
 798            5 => ChordQuality.MajorSeventh,              // VImaj7
 799            6 => ChordQuality.DiminishedSeventh,         // vii°7
 800            _ => null
 801        };
 802    }
 803
 804    private static string SeventhRootSuffix(ChordQuality q)
 805    {
 806        return q switch
 807        {
 808            ChordQuality.MajorSeventh => "maj7",
 809            ChordQuality.HalfDiminishedSeventh => "ø7",
 810            ChordQuality.DiminishedSeventh => "°7",
 811            _ => "7"
 812        };
 813    }
 814
 815    private static int DegreeRootPcLocal(Key key, int degree)
 816    {
 817        if (key.IsMajor)
 818        {
 819            return (key.ScaleDegreeMidi(degree) % 12 + 12) % 12;
 820        }
 821        else
 822        {
 823            if (degree == 6)
 824            {
 825                int tonicPc = (key.TonicMidi % 12 + 12) % 12;
 826                return (tonicPc + 11) % 12;
 827            }
 828            return (key.ScaleDegreeMidi(degree) % 12 + 12) % 12;
 829        }
 830    }
 831
 832    private static string EnsureSeventhAccidental(RomanNumeral rn, ChordQuality q, string label)
 833    {
 834        // Ensure '°' or 'ø' is present before inversion figures for diminished/half-diminished sevenths
 835        if (q != ChordQuality.DiminishedSeventh && q != ChordQuality.HalfDiminishedSeventh) return label;
 836        if (label.Contains('°') || label.Contains('ø')) return label;
 837        var head = rn.ToString();
 838        int idx = label.IndexOf(head);
 839        if (idx < 0)
 840        {
 841            // Fallback: insert after the initial roman head letters (e.g., 'vii' in 'vii65')
 842            int pos = 0;
 843            while (pos < label.Length && char.IsLetter(label[pos])) pos++;
 844            if (pos == 0) return label; // no letters found; give up
 845            int insertPosFallback = pos;
 846            string symFallback = q == ChordQuality.DiminishedSeventh ? "°" : "ø";
 847            return label.Insert(insertPosFallback, symFallback);
 848        }
 849        int insertPos = idx + head.Length;
 850        string sym = q == ChordQuality.DiminishedSeventh ? "°" : "ø";
 851        return label.Insert(insertPos, sym);
 852    }
 853}