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