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