| | 1 | | using System.Text; |
| | 2 | |
|
| | 3 | | namespace MusicTheory.Theory.Harmony; |
| | 4 | |
|
| | 5 | | /// <summary> |
| | 6 | | /// High-level cadence classification between two chords. |
| | 7 | | /// </summary> |
| | 8 | | public enum CadenceType { None, Authentic, Plagal, Half, Deceptive } |
| | 9 | |
|
| | 10 | | /// <summary> |
| | 11 | | /// Classification for 6-4 chords when identified in context. |
| | 12 | | /// </summary> |
| | 13 | | public enum SixFourType { None, Cadential, Passing, Pedal } |
| | 14 | |
|
| | 15 | | /// <summary> |
| | 16 | | /// Detailed cadence diagnostics without breaking existing CadenceType usage. |
| | 17 | | /// <para> |
| | 18 | | /// IndexFrom は、直前の和音(prev)から現在の和音(curr)への遷移の「開始位置」を示します(prev のインデックス)。 |
| | 19 | | /// </para> |
| | 20 | | /// </summary> |
| | 21 | | public readonly partial record struct CadenceInfo( |
| 14 | 22 | | int IndexFrom, |
| 168 | 23 | | CadenceType Type, |
| 42 | 24 | | bool IsPerfectAuthentic, |
| 23 | 25 | | bool HasCadentialSixFour, |
| 120 | 26 | | SixFourType SixFour |
| | 27 | | ); |
| | 28 | |
|
| | 29 | | // Improve diagnostics: customize ToString for clearer test failure messages |
| | 30 | | public readonly partial record struct CadenceInfo |
| | 31 | | { |
| | 32 | | private bool PrintMembers(StringBuilder builder) |
| | 33 | | { |
| 1 | 34 | | builder |
| 1 | 35 | | .Append("IndexFrom = ").Append(IndexFrom) |
| 1 | 36 | | .Append(", Type = ").Append(Type) |
| 1 | 37 | | .Append(", IsPerfectAuthentic = ").Append(IsPerfectAuthentic) |
| 1 | 38 | | .Append(", HasCadentialSixFour = ").Append(HasCadentialSixFour) |
| 1 | 39 | | .Append(", SixFour = ").Append(SixFour); |
| 1 | 40 | | return true; |
| | 41 | | } |
| | 42 | | } |
| | 43 | |
|
| | 44 | | public static class CadenceAnalyzer |
| | 45 | | { |
| | 46 | | /// <summary> |
| | 47 | | /// Basic cadence detection (Authentic, Plagal, Half, Deceptive) using roman numeral heads. |
| | 48 | | /// </summary> |
| | 49 | | /// <param name="prev">Previous roman numeral.</param> |
| | 50 | | /// <param name="curr">Current roman numeral.</param> |
| | 51 | | /// <param name="isMajor">Mode flag for tonic-relative checks.</param> |
| | 52 | | /// <returns>CadenceType classification.</returns> |
| | 53 | | public static CadenceType Detect(RomanNumeral? prev, RomanNumeral? curr, bool isMajor) |
| | 54 | | { |
| | 55 | | if (prev is null || curr is null) return CadenceType.None; |
| | 56 | | var p = prev.Value; var c = curr.Value; |
| | 57 | |
|
| | 58 | | // Normalize tonic VI roman per mode for comparison |
| | 59 | | bool isTonic(RomanNumeral r) => isMajor ? r == RomanNumeral.I : r == RomanNumeral.i; |
| | 60 | | bool isSubdominant(RomanNumeral r) => r == RomanNumeral.IV || r == RomanNumeral.iv; |
| | 61 | | bool isDominant(RomanNumeral r) => r == RomanNumeral.V || r == RomanNumeral.v; // v rarely used; include defensi |
| | 62 | | bool isRelativeVI(RomanNumeral r) => isMajor ? r == RomanNumeral.vi : r == RomanNumeral.VI; |
| | 63 | |
|
| | 64 | | if (isDominant(p) && isTonic(c)) return CadenceType.Authentic; |
| | 65 | | if (isSubdominant(p) && isTonic(c)) return CadenceType.Plagal; |
| | 66 | | if (isDominant(p) && isRelativeVI(c)) return CadenceType.Deceptive; |
| | 67 | | if (isDominant(c)) return CadenceType.Half; |
| | 68 | |
|
| | 69 | | return CadenceType.None; |
| | 70 | | } |
| | 71 | |
|
| | 72 | | /// <summary> |
| | 73 | | /// Detailed detection with simple heuristics for PAC/IAC and 6-4 classification. |
| | 74 | | /// RomanText labels (e.g., "V7", "I64") are used to detect inversions. |
| | 75 | | /// </summary> |
| | 76 | | /// <param name="indexFrom">Index where the cadence is considered to start.</param> |
| | 77 | | /// <param name="prev">Previous roman head.</param> |
| | 78 | | /// <param name="curr">Current roman head.</param> |
| | 79 | | /// <param name="isMajor">Mode flag.</param> |
| | 80 | | /// <param name="prevText">RomanText of previous chord.</param> |
| | 81 | | /// <param name="currText">RomanText of current chord.</param> |
| | 82 | | /// <param name="prevPrevText">RomanText of the chord before previous.</param> |
| | 83 | | /// <returns>CadenceInfo including PAC flag, cadential 6-4 flag, and SixFourType.</returns> |
| | 84 | | public static CadenceInfo DetectDetailed(int indexFrom, RomanNumeral? prev, RomanNumeral? curr, bool isMajor, int to |
| | 85 | | string? prevText, string? currText, string? prevPrevText, HarmonyOptions? options = null, |
| | 86 | | FourPartVoicing? prevVoicing = null, FourPartVoicing? currVoicing = null) |
| | 87 | | { |
| | 88 | | options ??= HarmonyOptions.Default; |
| | 89 | | var t = Detect(prev, curr, isMajor); |
| | 90 | | bool isPAC = false; |
| | 91 | | bool has64 = false; |
| | 92 | | SixFourType six4 = SixFourType.None; |
| | 93 | |
|
| | 94 | | static string Head(string? txt) |
| | 95 | | { |
| | 96 | | if (string.IsNullOrEmpty(txt)) return string.Empty; |
| | 97 | | int i = 0; |
| | 98 | | while (i < txt!.Length) |
| | 99 | | { |
| | 100 | | char ch = txt[i]; |
| | 101 | | bool ok = ch == 'b' || ch == '#' || ch == 'i' || ch == 'v' || ch == 'I' || ch == 'V' || ch == '/'; |
| | 102 | | if (!ok) break; |
| | 103 | | i++; |
| | 104 | | } |
| | 105 | | if (i == 0) return txt!; // fallback: return whole |
| | 106 | | return txt!.Substring(0, i); |
| | 107 | | } |
| | 108 | | // Suppress Half cadence if preceded by cadential 6-4 (I64) and followed by an authentic cadence at next step. |
| | 109 | | // We can't look ahead here, but we can avoid emitting Half when pattern I64 -> V is seen; the caller will emit |
| | 110 | | if (t == CadenceType.Half) |
| | 111 | | { |
| | 112 | | if (!string.IsNullOrEmpty(prevText) && prevText.EndsWith("64")) |
| | 113 | | { |
| | 114 | | // Likely cadential 6-4 → V; defer to next step where V→I authentic will be captured. |
| | 115 | | return new CadenceInfo(indexFrom, CadenceType.None, false, true, SixFourType.Cadential); |
| | 116 | | } |
| | 117 | | // Suppress Half cadence for predominant augmented sixth -> V transitions (treat as internal pre-dominant mo |
| | 118 | | // Detect common Aug6 labels: It6, Fr43, Ger65 (case-insensitive start). |
| | 119 | | if (!string.IsNullOrEmpty(prevText)) |
| | 120 | | { |
| | 121 | | var ptxt = prevText; |
| | 122 | | if (ptxt.Contains("It6", StringComparison.OrdinalIgnoreCase) || |
| | 123 | | ptxt.Contains("Fr", StringComparison.OrdinalIgnoreCase) || |
| | 124 | | ptxt.Contains("Ger", StringComparison.OrdinalIgnoreCase)) |
| | 125 | | { |
| | 126 | | return new CadenceInfo(indexFrom, CadenceType.None, false, false, SixFourType.None); |
| | 127 | | } |
| | 128 | | // Also suppress when previous is a mixture seventh acting as predominant: bVI7/bVI65/bVI43/bVI42 (and b |
| | 129 | | static bool IsMixtureSeventhLabel(string s) |
| | 130 | | { |
| | 131 | | if (string.IsNullOrEmpty(s)) return false; |
| | 132 | | // Check longer tokens first |
| | 133 | | if (s.StartsWith("bVI", StringComparison.OrdinalIgnoreCase) || |
| | 134 | | s.StartsWith("bII", StringComparison.OrdinalIgnoreCase) || |
| | 135 | | s.StartsWith("bVII", StringComparison.OrdinalIgnoreCase) || |
| | 136 | | s.StartsWith("iv", StringComparison.OrdinalIgnoreCase)) |
| | 137 | | { |
| | 138 | | return s.EndsWith("7") || s.EndsWith("65") || s.EndsWith("43") || s.EndsWith("42"); |
| | 139 | | } |
| | 140 | | return false; |
| | 141 | | } |
| | 142 | | if (IsMixtureSeventhLabel(ptxt)) |
| | 143 | | { |
| | 144 | | return new CadenceInfo(indexFrom, CadenceType.None, false, false, SixFourType.None); |
| | 145 | | } |
| | 146 | | // Suppress Half also for secondary leading-tone to V: vii°/V or vii°7/V (including ø variants) |
| | 147 | | bool isSecLtToV = ptxt.StartsWith("vii", StringComparison.OrdinalIgnoreCase) && ptxt.Contains("/V"); |
| | 148 | | if (isSecLtToV) |
| | 149 | | { |
| | 150 | | return new CadenceInfo(indexFrom, CadenceType.None, false, false, SixFourType.None); |
| | 151 | | } |
| | 152 | | } |
| | 153 | | } |
| | 154 | |
|
| | 155 | | // Fallback authentic detection: only when prev head is strictly V (of the key) and curr head is I. |
| | 156 | | // Use Head() to avoid misreading strings like "vii°7/V" as starting with 'V'. |
| | 157 | | if (t == CadenceType.None && !string.IsNullOrEmpty(prevText) && !string.IsNullOrEmpty(currText)) |
| | 158 | | { |
| | 159 | | var prevHead = Head(prevText); |
| | 160 | | var currHead = Head(currText); |
| | 161 | | bool prevIsDominantHead = string.Equals(prevHead, "V", StringComparison.Ordinal) |
| | 162 | | || string.Equals(prevHead, "v", StringComparison.Ordinal); |
| | 163 | | bool currIsTonicHead = string.Equals(currHead, isMajor ? "I" : "i", StringComparison.Ordinal); |
| | 164 | | if (prevIsDominantHead && currIsTonicHead) |
| | 165 | | { |
| | 166 | | t = CadenceType.Authentic; // PAC evaluation still governed below by flags |
| | 167 | | } |
| | 168 | | } |
| | 169 | |
|
| | 170 | | // Explicit suppression: secondary leading-tone to V (vii°/V, vii°7/V, viiø7/V) resolving directly to I is not a |
| | 171 | | // This covers the Ger65 enharmonic set path that may romanize as vii°7/V without an intervening V. |
| | 172 | | if (!string.IsNullOrEmpty(prevText) && !string.IsNullOrEmpty(currText)) |
| | 173 | | { |
| | 174 | | var currHead2 = Head(currText); |
| | 175 | | if (currHead2 == (isMajor ? "I" : "i")) |
| | 176 | | { |
| | 177 | | var pt = prevText; |
| | 178 | | bool prevIsSecLtToV = pt.StartsWith("vii", StringComparison.OrdinalIgnoreCase) && pt.Contains("/V", Stri |
| | 179 | | if (prevIsSecLtToV) |
| | 180 | | { |
| | 181 | | t = CadenceType.None; |
| | 182 | | } |
| | 183 | | } |
| | 184 | | } |
| | 185 | | if (t == CadenceType.Authentic) |
| | 186 | | { |
| | 187 | | // Cadential 6-4: I64 → V → I |
| | 188 | | if (!string.IsNullOrEmpty(prevPrevText)) |
| | 189 | | { |
| | 190 | | bool i64 = prevPrevText!.StartsWith(isMajor ? "I" : "i") && prevPrevText!.EndsWith("64"); |
| | 191 | | if (i64) { has64 = true; six4 = SixFourType.Cadential; } |
| | 192 | | } |
| | 193 | | // Perfect authentic (approximation): V(7) in root → I(root or Imaj7), no inversion suffix visible |
| | 194 | | if (!string.IsNullOrEmpty(prevText) && !string.IsNullOrEmpty(currText)) |
| | 195 | | { |
| | 196 | | bool vIsPlainTriad = prevText == "V"; // plain dominant triad root position |
| | 197 | | bool vAllowsExtensions = prevText == "V7" || prevText == "V9" || prevText == "V7(9)"; |
| | 198 | | bool iPlainTriad = currText == (isMajor ? "I" : "i"); |
| | 199 | | bool iAllowsMaj7 = currText == (isMajor ? "Imaj7" : "imaj7"); |
| | 200 | |
|
| | 201 | | bool allowDominantExt = !options.StrictPacDisallowDominantExtensions; |
| | 202 | | bool allowTonicMaj7 = !options.StrictPacPlainTriadsOnly; // triad only when strict |
| | 203 | |
|
| | 204 | | bool dominantOk = vIsPlainTriad || (allowDominantExt && vAllowsExtensions); |
| | 205 | | bool tonicOk = iPlainTriad || (allowTonicMaj7 && iAllowsMaj7); |
| | 206 | |
|
| | 207 | | // Require dominant to be in root position for PAC when voicing is available (covers V9/V7(9) inversions |
| | 208 | | bool dominantRootOk = true; |
| | 209 | | if (prevVoicing is FourPartVoicing vPrevRoot) |
| | 210 | | { |
| | 211 | | int domRootPc = ((tonicPc + 7) % 12 + 12) % 12; // V root relative to tonic |
| | 212 | | int bassPrev = (vPrevRoot.B % 12 + 12) % 12; |
| | 213 | | dominantRootOk = (bassPrev == domRootPc); |
| | 214 | | } |
| | 215 | |
|
| | 216 | | if (dominantOk && tonicOk && dominantRootOk) |
| | 217 | | { |
| | 218 | | // If strict plain triads requested, require both sides plain triads |
| | 219 | | if (options.StrictPacPlainTriadsOnly) |
| | 220 | | isPAC = vIsPlainTriad && iPlainTriad; |
| | 221 | | else |
| | 222 | | isPAC = true; |
| | 223 | |
|
| | 224 | | // Soprano tonic requirement (now absolute PC based) |
| | 225 | | if (isPAC && options.StrictPacRequireSopranoTonic) |
| | 226 | | { |
| | 227 | | if (currVoicing is FourPartVoicing vCurr) |
| | 228 | | { |
| | 229 | | int sopranoPc = (vCurr.S % 12 + 12) % 12; |
| | 230 | | bool sopranoHasTonic = sopranoPc == ((tonicPc % 12) + 12) % 12; |
| | 231 | | if (!sopranoHasTonic) |
| | 232 | | isPAC = false; |
| | 233 | | else if (options.StrictPacRequireSopranoLeadingToneResolution) |
| | 234 | | { |
| | 235 | | if (prevVoicing is FourPartVoicing vPrev) |
| | 236 | | { |
| | 237 | | int prevS = (vPrev.S % 12 + 12) % 12; |
| | 238 | | int currS = sopranoPc; |
| | 239 | | int leadingTonePc = ((tonicPc + 11) % 12 + 12) % 12; // raised 7 in both major & har |
| | 240 | | if (prevS == leadingTonePc) |
| | 241 | | { |
| | 242 | | // Require semitone upward resolution (LT -> T). Allow enharmonic wrap (11->0). |
| | 243 | | bool resolves = currS == ((prevS + 1) % 12) && currS == tonicPc; |
| | 244 | | if (!resolves) |
| | 245 | | isPAC = false; |
| | 246 | | } |
| | 247 | | // If prev soprano not leading tone we only required tonic on goal; no extra demotio |
| | 248 | | } |
| | 249 | | else |
| | 250 | | { |
| | 251 | | // cannot verify resolution, demote |
| | 252 | | isPAC = false; |
| | 253 | | } |
| | 254 | | } |
| | 255 | | } |
| | 256 | | else |
| | 257 | | { |
| | 258 | | isPAC = false; // cannot verify soprano |
| | 259 | | } |
| | 260 | | } |
| | 261 | | } |
| | 262 | | } |
| | 263 | | } |
| | 264 | | // Generic 6-4 classification (Passing / Pedal) is only attached on non-cadential steps |
| | 265 | | // to avoid mixing with proper cadence entries (which may carry Cadential 6-4 info only). |
| | 266 | | if (t == CadenceType.None) |
| | 267 | | { |
| | 268 | | if (!string.IsNullOrEmpty(prevText) && prevText!.EndsWith("64")) |
| | 269 | | { |
| | 270 | | var hPrevPrev = Head(prevPrevText); |
| | 271 | | var hCurr = Head(currText); |
| | 272 | | if (!string.IsNullOrEmpty(hPrevPrev) && !string.IsNullOrEmpty(hCurr)) |
| | 273 | | { |
| | 274 | | if (hPrevPrev == hCurr) |
| | 275 | | { |
| | 276 | | // Same harmony around x64 |
| | 277 | | // Passing patterns: |
| | 278 | | // - Ascending: root → 64 → 6 (curr ends with "6" but not "64") |
| | 279 | | // - Descending: 6 → 64 → root (prevPrev ends with "6" but not "64" and curr is root position) |
| | 280 | | bool currIsFirstInversion = !string.IsNullOrEmpty(currText) && currText!.EndsWith("6") && !currT |
| | 281 | | bool prevPrevIsFirstInversion = !string.IsNullOrEmpty(prevPrevText) && prevPrevText!.EndsWith("6 |
| | 282 | | bool currIsRoot = !string.IsNullOrEmpty(currText) && !currText!.EndsWith("6"); |
| | 283 | |
|
| | 284 | | if (currIsFirstInversion || (prevPrevIsFirstInversion && currIsRoot)) |
| | 285 | | six4 = six4 == SixFourType.None ? SixFourType.Passing : six4; |
| | 286 | | else |
| | 287 | | six4 = six4 == SixFourType.None ? SixFourType.Pedal : six4; |
| | 288 | | } |
| | 289 | | } |
| | 290 | | } |
| | 291 | | } |
| | 292 | |
|
| | 293 | | // Safety: Do not attach generic Passing/Pedal 6-4 labels to cadence entries. |
| | 294 | | // Cadential 6-4 is allowed on Authentic; otherwise force SixFour to None when a cadence is present. |
| | 295 | | if (t != CadenceType.None && six4 != SixFourType.Cadential) |
| | 296 | | { |
| | 297 | | six4 = SixFourType.None; |
| | 298 | | } |
| | 299 | |
|
| | 300 | | return new CadenceInfo(indexFrom, t, isPAC, has64, six4); |
| | 301 | | } |
| | 302 | | } |