| | 1 | | using System; |
| | 2 | | using System.Collections.Generic; |
| | 3 | | using System.Linq; |
| | 4 | |
|
| | 5 | | namespace MusicTheory.Theory.Harmony; |
| | 6 | |
|
| | 7 | | public static class KeyEstimator |
| | 8 | | { |
| | 9 | | /// <summary> |
| | 10 | | /// Tunable heuristics for per-chord key estimation. All weights are integer and small by design |
| | 11 | | /// to keep scoring explainable. Defaults aim for mild inertia and cadence awareness. |
| | 12 | | /// </summary> |
| | 13 | | public sealed class Options |
| | 14 | | { |
| | 15 | | /// <summary>Sliding window radius around the current index used for diatonic fit (0 = current only).</summary> |
| 3252 | 16 | | public int Window { get; init; } = 2; // sliding window radius |
| | 17 | | /// <summary>Bias to keep the previous key (inertia). Added when candidate equals previous.</summary> |
| 158 | 18 | | public int PrevKeyBias { get; init; } = 1; // inertia bias towards previous key |
| | 19 | | /// <summary>Bias toward the initial key. Added when candidate equals the initial key.</summary> |
| 144 | 20 | | public int InitialKeyBias { get; init; } = 0; // default: no bias to initial key |
| | 21 | | /// <summary>Bonus when the current chord exactly matches V triad (dominant) of the candidate key.</summary> |
| 167 | 22 | | public int DominantTriadBonus { get; init; } = 2; // if current chord = V triad in candidate key |
| | 23 | | /// <summary>Bonus when the current chord exactly matches V7 (dominant seventh) of the candidate key.</summary> |
| 53 | 24 | | public int DominantSeventhBonus { get; init; } = 3; // if current chord = V7 in candidate key |
| | 25 | | /// <summary>Bonus on V(7) → I(maj7/m7含む) motion in the candidate key.</summary> |
| 72 | 26 | | public int CadenceBonus { get; init; } = 4; // if prev=V(7) and curr=I in candidate key |
| | 27 | | /// <summary>Hysteresis margin: require competitor advantage > margin to switch from the previous key.</summa |
| 215 | 28 | | public int SwitchMargin { get; init; } = 1; // easier to switch when evidence appears |
| | 29 | | /// <summary>Bonus if the current chord is diatonic to both the previous key and the candidate key.</summary> |
| 329 | 30 | | public int PivotChordBonus { get; init; } = 1; // if chord is diatonic to both prev and candidate key |
| | 31 | | /// <summary>Bonus for secondary dominant triads (V/x) relative to the candidate key.</summary> |
| 698 | 32 | | public int SecondaryDominantTriadBonus { get; init; } = 1; // if current chord = V/x triad (x is diatonic degre |
| | 33 | | /// <summary>Bonus for secondary dominant sevenths (V/x7) relative to the candidate key.</summary> |
| 103 | 34 | | public int SecondaryDominantSeventhBonus { get; init; } = 2; // if current chord = V/x7 |
| | 35 | | /// <summary>Bonus when a secondary dominant (V/x or V/x7) resolves immediately to its target chord x.</summary> |
| 1592 | 36 | | public int SecondaryResolutionBonus { get; init; } = 0; // default off for backward compatibility |
| | 37 | | /// <summary>When true, emit a detailed per-chord trace of scoring and diagnostics.</summary> |
| 126 | 38 | | public bool CollectTrace { get; init; } = false; // emit per-chord score breakdown |
| | 39 | | /// <summary>Subtract this amount per non-diatonic pitch class in the current chord for each candidate (0 = disa |
| 1597 | 40 | | public int OutOfKeyPenaltyPerPc { get; init; } = 0; // subtract per non-diatonic pc in current chord (0=disable |
| | 41 | | /// <summary> |
| | 42 | | /// Forbid switching away from the previous key before this chord index. Use 0 for default behavior. |
| | 43 | | /// Example: set to a large value to lock the initial key for a short smoke test. |
| | 44 | | /// </summary> |
| 129 | 45 | | public int MinSwitchIndex { get; init; } = 0; |
| | 46 | | } |
| | 47 | |
|
| | 48 | | // Lightweight per-chord breakdown (for the chosen key) |
| | 49 | | /// <summary> |
| | 50 | | /// A compact per-chord breakdown for the chosen key, including score components, |
| | 51 | | /// competition snapshot, hysteresis flag, and optional voice-leading diagnostics. |
| | 52 | | /// </summary> |
| | 53 | | public sealed class TraceEntry |
| | 54 | | { |
| | 55 | | /// <summary>Chord index in the analyzed sequence.</summary> |
| 85 | 56 | | public int Index { get; init; } |
| | 57 | | /// <summary>The key chosen at this index after tie-break and hysteresis.</summary> |
| 94 | 58 | | public Key ChosenKey { get; init; } |
| | 59 | | /// <summary>Diatonic fit score summed over the sliding window.</summary> |
| 84 | 60 | | public int BaseWindowScore { get; init; } |
| | 61 | | /// <summary>PrevKeyBias applied (if any).</summary> |
| 84 | 62 | | public int PrevKeyBias { get; init; } |
| | 63 | | /// <summary>InitialKeyBias applied (if any).</summary> |
| 84 | 64 | | public int InitialKeyBias { get; init; } |
| | 65 | | /// <summary>Bonus when the current chord matched V triad in the chosen key.</summary> |
| 85 | 66 | | public int DominantTriad { get; init; } |
| | 67 | | /// <summary>Bonus when the current chord matched V7 in the chosen key.</summary> |
| 85 | 68 | | public int DominantSeventh { get; init; } |
| | 69 | | /// <summary>Bonus when the motion prev: V(7) → curr: I(maj7/m7) in the chosen key.</summary> |
| 85 | 70 | | public int Cadence { get; init; } |
| | 71 | | /// <summary>Bonus when the current chord is diatonic to both previous and chosen keys.</summary> |
| 85 | 72 | | public int Pivot { get; init; } |
| | 73 | | /// <summary>Bonus when the current chord matched a secondary dominant triad V/x.</summary> |
| 85 | 74 | | public int SecondaryDominantTriad { get; init; } |
| | 75 | | /// <summary>Bonus when the current chord matched a secondary dominant seventh V/x7.</summary> |
| 85 | 76 | | public int SecondaryDominantSeventh { get; init; } |
| | 77 | | /// <summary>Bonus when a secondary dominant resolved to its target chord (applied at the resolution chord).</summar |
| 82 | 78 | | public int SecondaryResolution { get; init; } |
| | 79 | | /// <summary>Total score for the chosen key (sum of parts and penalty).</summary> |
| 89 | 80 | | public int Total { get; init; } |
| | 81 | | /// <summary>True when the key stayed due to hysteresis/tie logic.</summary> |
| 93 | 82 | | public bool StayedDueToHysteresis { get; init; } |
| | 83 | | // Debug/diagnostic metadata (optional): per-step competition snapshot |
| | 84 | | /// <summary>Maximum score among all candidate keys at this step.</summary> |
| 117 | 85 | | public int MaxScore { get; init; } |
| | 86 | | /// <summary>Second-best score (max among non-chosen candidates, 0 if n/a).</summary> |
| 117 | 87 | | public int SecondBestScore { get; init; } |
| | 88 | | /// <summary>Number of candidates tied for the maximum score.</summary> |
| 85 | 89 | | public int NumTopKeys { get; init; } |
| | 90 | | /// <summary>Compact list of top candidates (e.g., "M@0:5,m@9:5").</summary> |
| 83 | 91 | | public string? TopKeysSummary { get; init; } |
| | 92 | | /// <summary>Penalty (negative) applied for non-diatonic pitch classes relative to the chosen key.</summary> |
| 88 | 93 | | public int OutOfKeyPenalty { get; init; } |
| | 94 | | // Optional: voice-leading diagnostics (if voicings were provided) |
| | 95 | | /// <summary>Voice-leading: any part out of its conventional range.</summary> |
| 86 | 96 | | public bool VLRangeViolation { get; init; } |
| | 97 | | /// <summary>Voice-leading: spacing violations (S-A/A-T larger than allowed).</summary> |
| 86 | 98 | | public bool VLSpacingViolation { get; init; } |
| | 99 | | /// <summary>Voice-leading: overlap between adjacent voices.</summary> |
| 86 | 100 | | public bool VLOverlap { get; init; } |
| | 101 | | /// <summary>Voice-leading: parallel perfect fifths/octaves detected to previous chord.</summary> |
| 87 | 102 | | public bool VLParallelPerfects { get; init; } |
| | 103 | |
|
| | 104 | | public override string ToString() |
| | 105 | | { |
| 2 | 106 | | var m = ChosenKey.IsMajor ? "M" : "m"; |
| 2 | 107 | | int tp = ((ChosenKey.TonicMidi % 12) + 12) % 12; |
| 2 | 108 | | return $"idx={Index} key={m}@{tp} base={BaseWindowScore} prev={PrevKeyBias} init={InitialKeyBias} " + |
| 2 | 109 | | $"V={DominantTriad} V7={DominantSeventh} cad={Cadence} pivot={Pivot} " + |
| 2 | 110 | | $"V/x={SecondaryDominantTriad} V/x7={SecondaryDominantSeventh} out={OutOfKeyPenalty} total={Total} stay={StayedDu |
| 2 | 111 | | $"vl=[r={(VLRangeViolation?1:0)},s={(VLSpacingViolation?1:0)},o={(VLOverlap?1:0)},p={(VLParallelPerfects?1:0) |
| 2 | 112 | | $"max={MaxScore} 2nd={SecondBestScore} tops={NumTopKeys} [{TopKeysSummary}]"; |
| | 113 | | } |
| | 114 | | } |
| | 115 | |
|
| | 116 | | // Backward-compatible API |
| | 117 | | public static List<Key> EstimatePerChord(IReadOnlyList<int[]> pcsList, Key initialKey, int window = 2) |
| 7 | 118 | | => EstimatePerChord(pcsList, initialKey, new Options { Window = window }); |
| | 119 | |
|
| | 120 | | // Enhanced estimator with cadence weighting and hysteresis. |
| | 121 | | public static List<Key> EstimatePerChord(IReadOnlyList<int[]> pcsList, Key initialKey, Options options) |
| 7 | 122 | | => EstimatePerChord(pcsList, initialKey, options, out _); |
| | 123 | |
|
| | 124 | | // Overload returning trace |
| | 125 | | public static List<Key> EstimatePerChord( |
| | 126 | | IReadOnlyList<int[]> pcsList, Key initialKey, Options options, out List<TraceEntry> trace) |
| 22 | 127 | | => EstimatePerChord(pcsList, initialKey, options, out trace, voicings: null); |
| | 128 | |
|
| | 129 | | // Overload that can annotate trace with voice-leading diagnostics if voicings are provided (no scoring impact). |
| | 130 | | public static List<Key> EstimatePerChord( |
| | 131 | | IReadOnlyList<int[]> pcsList, Key initialKey, Options options, out List<TraceEntry> trace, IReadOnlyList<FourPar |
| | 132 | | { |
| 37 | 133 | | trace = new List<TraceEntry>(pcsList?.Count ?? 0); |
| 37 | 134 | | if (pcsList == null || pcsList.Count == 0) return new List<Key>(); |
| | 135 | |
|
| | 136 | | // Early full-lock: if MinSwitchIndex locks beyond the sequence length, |
| | 137 | | // just return the initial key for all chords and (optionally) emit trivial trace. |
| 37 | 138 | | if (options.MinSwitchIndex > 0 && options.MinSwitchIndex >= pcsList.Count) |
| | 139 | | { |
| 6 | 140 | | var locked = new List<Key>(pcsList.Count); |
| 72 | 141 | | for (int i = 0; i < pcsList.Count; i++) locked.Add(initialKey); |
| 6 | 142 | | if (options.CollectTrace) |
| | 143 | | { |
| | 144 | | // Build minimal trace entries to preserve length; scoring fields remain 0, stayed/hysteresis marked. |
| 5 | 145 | | var candidatesLock = BuildAllKeys(); |
| 245 | 146 | | var diatonicMapLock = candidatesLock.ToDictionary(k => k, k => DiatonicSet(k)); |
| 44 | 147 | | for (int i = 0; i < pcsList.Count; i++) |
| | 148 | | { |
| 17 | 149 | | var te = BuildTraceEntry( |
| 17 | 150 | | index: i, |
| 17 | 151 | | chosenKey: initialKey, |
| 17 | 152 | | prevKey: i == 0 ? (Key?)null : initialKey, |
| 17 | 153 | | stayed: i > 0, // stayed from previous |
| 17 | 154 | | pcsList: pcsList, |
| 17 | 155 | | options: options, |
| 17 | 156 | | diatonicMap: diatonicMapLock, |
| 17 | 157 | | initialKey: initialKey, |
| 17 | 158 | | maxScore: 0, |
| 17 | 159 | | secondBestScore: 0, |
| 17 | 160 | | numTopKeys: 1, |
| 17 | 161 | | topKeysSummary: null, |
| 17 | 162 | | voicings: voicings |
| 17 | 163 | | ); |
| 17 | 164 | | trace.Add(te); |
| | 165 | | } |
| | 166 | | } |
| 6 | 167 | | return locked; |
| | 168 | | } |
| | 169 | |
|
| 31 | 170 | | var candidates = BuildAllKeys(); |
| 1519 | 171 | | var diatonicMap = candidates.ToDictionary(k => k, k => DiatonicSet(k)); |
| | 172 | |
|
| 31 | 173 | | var result = new List<Key>(pcsList.Count); |
| 31 | 174 | | Key? prev = null; |
| 250 | 175 | | for (int i = 0; i < pcsList.Count; i++) |
| | 176 | | { |
| 94 | 177 | | if (i == 0) |
| | 178 | | { |
| 31 | 179 | | result.Add(initialKey); |
| 31 | 180 | | prev = initialKey; |
| 31 | 181 | | if (options.CollectTrace) |
| | 182 | | { |
| 20 | 183 | | var te0 = BuildTraceEntry(i, initialKey, prevKey: null, stayed: false, pcsList, options, diatonicMap |
| 20 | 184 | | maxScore: 0, secondBestScore: 0, numTopKeys: 1, topKeysSummary: null, voicings: voicings); |
| 20 | 185 | | trace.Add(te0); |
| | 186 | | } |
| 20 | 187 | | continue; |
| | 188 | | } |
| | 189 | |
|
| 63 | 190 | | var currSet = ToPcSet(pcsList[i]); |
| 63 | 191 | | var prevSet = i > 0 ? ToPcSet(pcsList[i - 1]) : s_emptySet; |
| | 192 | |
|
| 63 | 193 | | var scoreByKey = new Dictionary<Key, int>(); |
| 3150 | 194 | | foreach (var k in candidates) |
| | 195 | | { |
| 1512 | 196 | | int score = 0; |
| 1512 | 197 | | var dia = diatonicMap[k]; |
| 1512 | 198 | | int start = Math.Max(0, i - options.Window); |
| 1512 | 199 | | int end = Math.Min(pcsList.Count - 1, i + options.Window); |
| 9552 | 200 | | for (int j = start; j <= end; j++) |
| | 201 | | { |
| 3264 | 202 | | var set = pcsList[j].Select(NormPc).Distinct(); |
| 26496 | 203 | | foreach (var pc in set) |
| 15808 | 204 | | if (dia.Contains(pc)) score++; |
| | 205 | | } |
| | 206 | |
|
| 1575 | 207 | | if (prev is Key pk && SameKey(pk, k)) score += options.PrevKeyBias; |
| 1575 | 208 | | if (SameKey(initialKey, k)) score += options.InitialKeyBias; |
| | 209 | |
|
| 1512 | 210 | | var (vTri, v7, iTri, i7maj, i7min) = GetKeyReferenceSets(k); |
| 1522 | 211 | | if (SetEquals(currSet, v7)) score += options.DominantSeventhBonus; |
| 1604 | 212 | | else if (SetEquals(currSet, vTri)) score += options.DominantTriadBonus; |
| | 213 | |
|
| 1512 | 214 | | if ((SetEquals(prevSet, v7) || SetEquals(prevSet, vTri)) && |
| 1512 | 215 | | (SetEquals(currSet, iTri) || SetEquals(currSet, (k.IsMajor ? i7maj : i7min)))) |
| | 216 | | { |
| 18 | 217 | | score += options.CadenceBonus; |
| | 218 | | } |
| | 219 | |
|
| 1512 | 220 | | if (prev is Key prevK) |
| | 221 | | { |
| 1512 | 222 | | var prevDia = diatonicMap[prevK]; |
| 4387 | 223 | | bool inCand = currSet.All(pc => dia.Contains(pc)); |
| 5640 | 224 | | bool inPrev = currSet.All(pc => prevDia.Contains(pc)); |
| 1512 | 225 | | if (inCand && inPrev) |
| 244 | 226 | | score += options.PivotChordBonus; |
| | 227 | | } |
| | 228 | |
|
| 1512 | 229 | | int tonicPc = NormPc(k.TonicMidi); |
| 16464 | 230 | | for (int deg = 1; deg <= 6; deg++) |
| | 231 | | { |
| 7392 | 232 | | int degPc = k.IsMajor |
| 7392 | 233 | | ? NormPc(k.ScaleDegreeMidi(deg)) |
| 7392 | 234 | | : (deg == 6 ? (tonicPc + 11) % 12 : NormPc(k.ScaleDegreeMidi(deg))); |
| | 235 | |
|
| 7392 | 236 | | int secRoot = (degPc + 7) % 12; |
| 7392 | 237 | | var secVTri = new Chord(secRoot, ChordQuality.Major).PitchClasses().ToHashSet(); |
| 7392 | 238 | | var secV7 = new Chord(secRoot, ChordQuality.DominantSeventh).PitchClasses().ToHashSet(); |
| 7512 | 239 | | if (SetEquals(currSet, secV7)) { score += options.SecondaryDominantSeventhBonus; break; } |
| 8556 | 240 | | if (SetEquals(currSet, secVTri)) { score += options.SecondaryDominantTriadBonus; break; } |
| | 241 | | } |
| | 242 | |
|
| | 243 | | // Secondary resolution bonus: if previous chord was V/x (triad or 7th) and current chord equals x triad |
| 1512 | 244 | | if (options.SecondaryResolutionBonus != 0 && i > 0) |
| | 245 | | { |
| 0 | 246 | | var prevSetLocal = prevSet; |
| 0 | 247 | | for (int deg = 1; deg <= 6; deg++) |
| | 248 | | { |
| 0 | 249 | | int degPc = k.IsMajor |
| 0 | 250 | | ? NormPc(k.ScaleDegreeMidi(deg)) |
| 0 | 251 | | : (deg == 6 ? (tonicPc + 11) % 12 : NormPc(k.ScaleDegreeMidi(deg))); |
| 0 | 252 | | int secRoot = (degPc + 7) % 12; |
| 0 | 253 | | var secVTri = new Chord(secRoot, ChordQuality.Major).PitchClasses().ToHashSet(); |
| 0 | 254 | | var secV7 = new Chord(secRoot, ChordQuality.DominantSeventh).PitchClasses().ToHashSet(); |
| 0 | 255 | | bool prevWasSec = SetEquals(prevSetLocal, secVTri) || SetEquals(prevSetLocal, secV7); |
| 0 | 256 | | if (!prevWasSec) continue; |
| 0 | 257 | | var targetTri = new Chord(degPc, k.IsMajor ? TriadQualityForDegreeMajor(deg) : TriadQualityForDe |
| 0 | 258 | | var target7Maj = new Chord(degPc, ChordQuality.MajorSeventh).PitchClasses().ToHashSet(); |
| 0 | 259 | | var target7Min = new Chord(degPc, ChordQuality.MinorSeventh).PitchClasses().ToHashSet(); |
| 0 | 260 | | if (SetEquals(currSet, targetTri) || SetEquals(currSet, (k.IsMajor ? target7Maj : target7Min))) |
| | 261 | | { |
| 0 | 262 | | score += options.SecondaryResolutionBonus; |
| 0 | 263 | | break; |
| | 264 | | } |
| | 265 | | } |
| | 266 | | } |
| | 267 | |
|
| | 268 | | // Optional: penalize non-diatonic PCs in the current chord for this candidate key |
| 1512 | 269 | | if (options.OutOfKeyPenaltyPerPc != 0 && currSet.Count > 0) |
| | 270 | | { |
| 0 | 271 | | int nonDiaCount = 0; |
| 0 | 272 | | foreach (var pc in currSet) if (!dia.Contains(pc)) nonDiaCount++; |
| 0 | 273 | | if (nonDiaCount > 0) score -= nonDiaCount * options.OutOfKeyPenaltyPerPc; |
| | 274 | | } |
| | 275 | |
|
| 1512 | 276 | | scoreByKey[k] = score; |
| | 277 | | } |
| | 278 | |
|
| 63 | 279 | | int maxScore = scoreByKey.Values.Max(); |
| 1684 | 280 | | var topKeys = scoreByKey.Where(kv => kv.Value == maxScore).Select(kv => kv.Key).ToList(); |
| 63 | 281 | | bool tieExists = topKeys.Count > 1; |
| | 282 | | // Deterministic tie-break: prefer Major, then lower tonic pitch class |
| 63 | 283 | | Key candidateChoice = topKeys |
| 109 | 284 | | .OrderByDescending(k => k.IsMajor) |
| 80 | 285 | | .ThenBy(k => NormPc(k.TonicMidi)) |
| 63 | 286 | | .First(); |
| | 287 | |
|
| | 288 | | Key chosen; |
| 63 | 289 | | bool stayedDueToHysteresis = false; |
| | 290 | | // Hard lock: don't allow switching before MinSwitchIndex (always stay on previous when available) |
| 63 | 291 | | if (options.MinSwitchIndex > 0 && i < options.MinSwitchIndex && prev is Key prevLock) |
| | 292 | | { |
| 0 | 293 | | chosen = prevLock; |
| | 294 | | // Build trace and continue |
| 0 | 295 | | if (options.CollectTrace) |
| | 296 | | { |
| 0 | 297 | | string? tops = null; |
| 0 | 298 | | if (scoreByKey.Count > 0) |
| | 299 | | { |
| 0 | 300 | | var lockTopKeys = scoreByKey.Where(kv => kv.Value == scoreByKey.Values.Max()).Select(kv => kv.Ke |
| 0 | 301 | | var parts = lockTopKeys |
| 0 | 302 | | .OrderBy(k => NormPc(k.TonicMidi)) |
| 0 | 303 | | .ThenByDescending(k => k.IsMajor) |
| 0 | 304 | | .Select(k => $"{(k.IsMajor ? "M" : "m")}@{NormPc(k.TonicMidi)}:{scoreByKey[k]}"); |
| 0 | 305 | | tops = string.Join(",", parts); |
| | 306 | | } |
| 0 | 307 | | int secondBest = 0; |
| 0 | 308 | | if (scoreByKey.Count > 1) |
| 0 | 309 | | secondBest = scoreByKey.Where(kv => !SameKey(kv.Key, prevLock)).Select(kv => kv.Value).DefaultIf |
| 0 | 310 | | var teLock = BuildTraceEntry(i, prevLock, prev, stayed: true, pcsList, options, diatonicMap, initial |
| 0 | 311 | | maxScore: scoreByKey.Values.Max(), secondBestScore: secondBest, |
| 0 | 312 | | numTopKeys: scoreByKey.Count(kv => kv.Value == scoreByKey.Values.Max()), topKeysSummary: tops, v |
| 0 | 313 | | trace.Add(teLock); |
| | 314 | | } |
| 0 | 315 | | result.Add(chosen); |
| 0 | 316 | | prev = chosen; |
| 0 | 317 | | continue; |
| | 318 | | } |
| 63 | 319 | | if (prev is Key prevKey) |
| | 320 | | { |
| 63 | 321 | | int prevScore = scoreByKey[prevKey]; |
| 63 | 322 | | if (!SameKey(candidateChoice, prevKey) && prevScore + options.SwitchMargin > maxScore) |
| | 323 | | { |
| 0 | 324 | | chosen = prevKey; // stay due to insufficient advantage |
| 0 | 325 | | stayedDueToHysteresis = true; |
| | 326 | | } |
| | 327 | | else |
| | 328 | | { |
| 145 | 329 | | if (topKeys.Any(k => SameKey(k, prevKey))) |
| | 330 | | { |
| 44 | 331 | | chosen = prevKey; |
| | 332 | | // In a tie, explicitly flag that we stayed (even if deterministic tie-break would also pick pre |
| 55 | 333 | | if (tieExists) stayedDueToHysteresis = true; |
| | 334 | | } |
| 56 | 335 | | else if (topKeys.Any(k => SameKey(k, initialKey))) |
| | 336 | | { |
| 2 | 337 | | chosen = initialKey; |
| | 338 | | } |
| | 339 | | else |
| | 340 | | { |
| 17 | 341 | | chosen = candidateChoice; |
| | 342 | | // If candidate equals prev (e.g., tie broken to prev) and tie existed, mark as hysteresis stay |
| 17 | 343 | | if (tieExists && SameKey(chosen, prevKey)) stayedDueToHysteresis = true; |
| | 344 | | } |
| | 345 | |
|
| | 346 | | // Additionally, if we ended up staying on prevKey and there exists another key |
| | 347 | | // with score >= prevScore but the advantage is < SwitchMargin, consider it a hysteresis stay. |
| 63 | 348 | | if (SameKey(chosen, prevKey) && options.SwitchMargin > 0) |
| | 349 | | { |
| 41 | 350 | | int otherMax = int.MinValue; |
| 2050 | 351 | | foreach (var kv in scoreByKey) |
| | 352 | | { |
| 984 | 353 | | if (SameKey(kv.Key, prevKey)) continue; |
| 1029 | 354 | | if (kv.Value > otherMax) otherMax = kv.Value; |
| | 355 | | } |
| 41 | 356 | | if (otherMax != int.MinValue) |
| | 357 | | { |
| 41 | 358 | | int prevScoreLocal = prevScore; |
| 41 | 359 | | if (otherMax >= prevScoreLocal && otherMax < prevScoreLocal + options.SwitchMargin) |
| | 360 | | { |
| 9 | 361 | | stayedDueToHysteresis = true; |
| | 362 | | } |
| | 363 | | } |
| | 364 | | } |
| | 365 | | } |
| | 366 | | } |
| | 367 | | else |
| | 368 | | { |
| 0 | 369 | | chosen = candidateChoice; |
| | 370 | | } |
| | 371 | |
|
| | 372 | | // Finalize hysteresis flag after chosen key is determined: if we stayed on prev |
| | 373 | | // and there was a tie or insufficient advantage or any competitor matched/exceeded prev, |
| | 374 | | // mark as hysteresis for transparency. Also, when SwitchMargin>0, any stay counts as hysteresis. |
| 63 | 375 | | if (prev is Key prevAfter && SameKey(chosen, prevAfter)) |
| | 376 | | { |
| 44 | 377 | | int prevScoreAfter = scoreByKey[prevAfter]; |
| 951 | 378 | | bool competitorWithinMargin = scoreByKey.Any(kv => !SameKey(kv.Key, prevAfter) && kv.Value >= prevScoreA |
| 44 | 379 | | if (tieExists || options.SwitchMargin > 0 || prevScoreAfter + options.SwitchMargin > maxScore || competi |
| | 380 | | { |
| 43 | 381 | | stayedDueToHysteresis = true; |
| | 382 | | } |
| | 383 | | } |
| | 384 | |
|
| | 385 | | // Ensure flag determinism: if we stayed on previous key, and either a tie existed |
| | 386 | | // or hysteresis margin is enabled, reflect it as a hysteresis stay in trace. |
| 63 | 387 | | if (prev is Key prevForFlag && SameKey(prevForFlag, chosen) && (options.SwitchMargin > 0 || tieExists)) |
| | 388 | | { |
| 43 | 389 | | stayedDueToHysteresis = true; |
| | 390 | | } |
| | 391 | |
|
| 63 | 392 | | result.Add(chosen); |
| 63 | 393 | | if (options.CollectTrace) |
| | 394 | | { |
| | 395 | | // Build a short summary of top keys (same max score) |
| 41 | 396 | | string? tops = null; |
| 41 | 397 | | if (scoreByKey.Count > 0) |
| | 398 | | { |
| 41 | 399 | | var parts = topKeys |
| 84 | 400 | | .OrderBy(k => NormPc(k.TonicMidi)) |
| 84 | 401 | | .ThenByDescending(k => k.IsMajor) |
| 125 | 402 | | .Select(k => $"{(k.IsMajor ? "M" : "m")}@{NormPc(k.TonicMidi)}:{scoreByKey[k]}"); |
| 41 | 403 | | tops = string.Join(",", parts); |
| | 404 | | } |
| | 405 | | // second best (largest score among non-chosen keys), or 0 if none |
| 41 | 406 | | int secondBest = 0; |
| 41 | 407 | | if (scoreByKey.Count > 1) |
| | 408 | | { |
| 1968 | 409 | | secondBest = scoreByKey.Where(kv => !SameKey(kv.Key, chosen)).Select(kv => kv.Value).DefaultIfEmpty( |
| | 410 | | } |
| 41 | 411 | | var te = BuildTraceEntry(i, chosen, prev, stayedDueToHysteresis, pcsList, options, diatonicMap, initialK |
| 41 | 412 | | maxScore: maxScore, secondBestScore: secondBest, numTopKeys: topKeys.Count, topKeysSummary: tops, vo |
| 41 | 413 | | trace.Add(te); |
| | 414 | | } |
| 63 | 415 | | prev = chosen; |
| | 416 | | } |
| 31 | 417 | | return result; |
| | 418 | | } |
| | 419 | |
|
| | 420 | | private static TraceEntry BuildTraceEntry( |
| | 421 | | int index, |
| | 422 | | Key chosenKey, |
| | 423 | | Key? prevKey, |
| | 424 | | bool stayed, |
| | 425 | | IReadOnlyList<int[]> pcsList, |
| | 426 | | Options options, |
| | 427 | | Dictionary<Key, HashSet<int>> diatonicMap, |
| | 428 | | Key initialKey, |
| | 429 | | int maxScore, |
| | 430 | | int secondBestScore, |
| | 431 | | int numTopKeys, |
| | 432 | | string? topKeysSummary, |
| | 433 | | IReadOnlyList<FourPartVoicing?>? voicings) |
| | 434 | | { |
| 78 | 435 | | int baseWindow = 0; |
| 78 | 436 | | var dia = diatonicMap[chosenKey]; |
| 78 | 437 | | int start = Math.Max(0, index - options.Window); |
| 78 | 438 | | int end = Math.Min(pcsList.Count - 1, index + options.Window); |
| 452 | 439 | | for (int j = start; j <= end; j++) |
| | 440 | | { |
| 148 | 441 | | var set = pcsList[j].Select(NormPc).Distinct(); |
| 1194 | 442 | | foreach (var pc in set) |
| 885 | 443 | | if (dia.Contains(pc)) baseWindow++; |
| | 444 | | } |
| | 445 | |
|
| 78 | 446 | | int prevBias = (prevKey is Key pk && SameKey(pk, chosenKey)) ? options.PrevKeyBias : 0; |
| 78 | 447 | | int initBias = SameKey(initialKey, chosenKey) ? options.InitialKeyBias : 0; |
| | 448 | |
|
| 78 | 449 | | var curr = pcsList[index].Select(NormPc).ToHashSet(); |
| 78 | 450 | | var prevSetLocal = index > 0 ? pcsList[index - 1].Select(NormPc).ToHashSet() : s_emptySet; |
| 78 | 451 | | var (vTri, v7, iTri, i7maj, i7min) = GetKeyReferenceSets(chosenKey); |
| 78 | 452 | | int dom7 = SetEquals(curr, v7) ? options.DominantSeventhBonus : 0; |
| 78 | 453 | | int domTri = (dom7 == 0 && SetEquals(curr, vTri)) ? options.DominantTriadBonus : 0; |
| | 454 | |
|
| 78 | 455 | | int cadence = 0; |
| 78 | 456 | | if ((SetEquals(prevSetLocal, v7) || SetEquals(prevSetLocal, vTri)) && |
| 78 | 457 | | (SetEquals(curr, iTri) || SetEquals(curr, (chosenKey.IsMajor ? i7maj : i7min)))) |
| | 458 | | { |
| 12 | 459 | | cadence = options.CadenceBonus; |
| | 460 | | } |
| | 461 | |
|
| 78 | 462 | | int pivot = 0; |
| 78 | 463 | | if (prevKey is Key prevK) |
| | 464 | | { |
| 53 | 465 | | var prevDia = diatonicMap[prevK]; |
| 208 | 466 | | bool inCand = curr.All(pc => dia.Contains(pc)); |
| 199 | 467 | | bool inPrev = curr.All(pc => prevDia.Contains(pc)); |
| 96 | 468 | | if (inCand && inPrev) pivot = options.PivotChordBonus; |
| | 469 | | } |
| | 470 | |
|
| 156 | 471 | | int secTri = 0, sec7 = 0; |
| 78 | 472 | | int secResolve = 0; |
| | 473 | | { |
| 78 | 474 | | int tonicPc = NormPc(chosenKey.TonicMidi); |
| 732 | 475 | | for (int deg = 1; deg <= 6; deg++) |
| | 476 | | { |
| 333 | 477 | | int degPc = chosenKey.IsMajor |
| 333 | 478 | | ? NormPc(chosenKey.ScaleDegreeMidi(deg)) |
| 333 | 479 | | : (deg == 6 ? (tonicPc + 11) % 12 : NormPc(chosenKey.ScaleDegreeMidi(deg))); |
| | 480 | |
|
| 333 | 481 | | int secRoot = (degPc + 7) % 12; |
| 333 | 482 | | var secVTri = new Chord(secRoot, ChordQuality.Major).PitchClasses().ToHashSet(); |
| 333 | 483 | | var secV7 = new Chord(secRoot, ChordQuality.DominantSeventh).PitchClasses().ToHashSet(); |
| 335 | 484 | | if (SetEquals(curr, secV7)) { sec7 = options.SecondaryDominantSeventhBonus; break; } |
| 420 | 485 | | if (SetEquals(curr, secVTri)) { secTri = options.SecondaryDominantTriadBonus; break; } |
| | 486 | | } |
| | 487 | | // Resolution bonus (applied at current chord if previous was V/x) |
| 78 | 488 | | if (options.SecondaryResolutionBonus != 0 && index > 0) |
| | 489 | | { |
| 2 | 490 | | var prevSet = pcsList[index - 1].Select(NormPc).ToHashSet(); |
| 16 | 491 | | for (int deg = 1; deg <= 6; deg++) |
| | 492 | | { |
| 7 | 493 | | int degPc = chosenKey.IsMajor |
| 7 | 494 | | ? NormPc(chosenKey.ScaleDegreeMidi(deg)) |
| 7 | 495 | | : (deg == 6 ? (tonicPc + 11) % 12 : NormPc(chosenKey.ScaleDegreeMidi(deg))); |
| 7 | 496 | | int secRoot = (degPc + 7) % 12; |
| 7 | 497 | | var secVTri = new Chord(secRoot, ChordQuality.Major).PitchClasses().ToHashSet(); |
| 7 | 498 | | var secV7 = new Chord(secRoot, ChordQuality.DominantSeventh).PitchClasses().ToHashSet(); |
| 7 | 499 | | bool prevWasSec = SetEquals(prevSet, secVTri) || SetEquals(prevSet, secV7); |
| 7 | 500 | | if (!prevWasSec) continue; |
| 2 | 501 | | var targetTri = new Chord(degPc, chosenKey.IsMajor ? TriadQualityForDegreeMajor(deg) : TriadQualityF |
| 2 | 502 | | var target7Maj = new Chord(degPc, ChordQuality.MajorSeventh).PitchClasses().ToHashSet(); |
| 2 | 503 | | var target7Min = new Chord(degPc, ChordQuality.MinorSeventh).PitchClasses().ToHashSet(); |
| 2 | 504 | | if (SetEquals(curr, targetTri) || SetEquals(curr, (chosenKey.IsMajor ? target7Maj : target7Min))) |
| | 505 | | { |
| 1 | 506 | | secResolve = options.SecondaryResolutionBonus; |
| 1 | 507 | | break; |
| | 508 | | } |
| | 509 | | } |
| | 510 | | } |
| | 511 | | } |
| | 512 | |
|
| | 513 | | // Out-of-key penalty for transparency (applied only against chosenKey for trace) |
| 78 | 514 | | int outPenalty = 0; |
| 78 | 515 | | if (options.OutOfKeyPenaltyPerPc != 0 && curr.Count > 0) |
| | 516 | | { |
| 9 | 517 | | int nonDiaCount = 0; |
| 111 | 518 | | foreach (var pc in curr) if (!dia.Contains(pc)) nonDiaCount++; |
| 12 | 519 | | if (nonDiaCount > 0) outPenalty = -nonDiaCount * options.OutOfKeyPenaltyPerPc; |
| | 520 | | } |
| | 521 | |
|
| 78 | 522 | | int total = baseWindow + prevBias + initBias + domTri + dom7 + cadence + pivot + secTri + sec7 + secResolve + outPen |
| | 523 | | // Ensure tie/hysteresis-driven stays are always reflected in the trace even if earlier logic didn't set it |
| | 524 | | bool stayedFlag; |
| 78 | 525 | | if (prevKey is Key pk2 && SameKey(pk2, chosenKey)) |
| | 526 | | { |
| | 527 | | // If we stayed on previous key, mark as hysteresis when either: |
| | 528 | | // - a tie existed among top keys, or |
| | 529 | | // - hysteresis is active (SwitchMargin > 0), which can implicitly bias staying |
| 46 | 530 | | stayedFlag = stayed || numTopKeys > 1 || options.SwitchMargin > 0; |
| | 531 | | } |
| | 532 | | else |
| | 533 | | { |
| 32 | 534 | | stayedFlag = stayed; |
| | 535 | | } |
| | 536 | |
|
| | 537 | | // Voice-leading diagnostics (if voicings provided) |
| 312 | 538 | | bool vlRange = false, vlSpace = false, vlOverlap = false, vlPar = false; |
| 78 | 539 | | if (voicings != null && index < voicings.Count) |
| | 540 | | { |
| 7 | 541 | | var v = voicings[index]; |
| 7 | 542 | | if (v is FourPartVoicing vx) |
| | 543 | | { |
| 3 | 544 | | vlRange = VoiceLeadingRules.HasRangeViolation(vx); |
| 3 | 545 | | vlSpace = VoiceLeadingRules.HasSpacingViolations(vx); |
| 3 | 546 | | if (index > 0) |
| | 547 | | { |
| 2 | 548 | | var pv = voicings[index - 1]; |
| 2 | 549 | | if (pv is FourPartVoicing pvx) |
| | 550 | | { |
| 1 | 551 | | vlOverlap = VoiceLeadingRules.HasOverlap(pvx, vx); |
| 1 | 552 | | vlPar = VoiceLeadingRules.HasParallelPerfects(pvx, vx); |
| | 553 | | } |
| | 554 | | } |
| | 555 | | } |
| | 556 | | } |
| 78 | 557 | | return new TraceEntry |
| 78 | 558 | | { |
| 78 | 559 | | Index = index, |
| 78 | 560 | | ChosenKey = chosenKey, |
| 78 | 561 | | BaseWindowScore = baseWindow, |
| 78 | 562 | | PrevKeyBias = prevBias, |
| 78 | 563 | | InitialKeyBias = initBias, |
| 78 | 564 | | DominantTriad = domTri, |
| 78 | 565 | | DominantSeventh = dom7, |
| 78 | 566 | | Cadence = cadence, |
| 78 | 567 | | Pivot = pivot, |
| 78 | 568 | | SecondaryDominantTriad = secTri, |
| 78 | 569 | | SecondaryDominantSeventh = sec7, |
| 78 | 570 | | SecondaryResolution = secResolve, |
| 78 | 571 | | Total = total, |
| 78 | 572 | | StayedDueToHysteresis = stayedFlag, |
| 78 | 573 | | MaxScore = maxScore, |
| 78 | 574 | | SecondBestScore = secondBestScore, |
| 78 | 575 | | NumTopKeys = numTopKeys, |
| 78 | 576 | | TopKeysSummary = topKeysSummary, |
| 78 | 577 | | OutOfKeyPenalty = outPenalty, |
| 78 | 578 | | VLRangeViolation = vlRange, |
| 78 | 579 | | VLSpacingViolation = vlSpace, |
| 78 | 580 | | VLOverlap = vlOverlap, |
| 78 | 581 | | VLParallelPerfects = vlPar, |
| 78 | 582 | | }; |
| | 583 | | } |
| | 584 | |
|
| | 585 | | private static List<Key> BuildAllKeys() |
| | 586 | | { |
| 36 | 587 | | var keys = new List<Key>(24); |
| 936 | 588 | | for (int pc = 0; pc < 12; pc++) |
| | 589 | | { |
| 432 | 590 | | keys.Add(new Key(60 + pc, true)); // majors |
| 432 | 591 | | keys.Add(new Key(60 + pc, false)); // minors |
| | 592 | | } |
| 36 | 593 | | return keys; |
| | 594 | | } |
| | 595 | |
|
| | 596 | | private static HashSet<int> DiatonicSet(Key key) |
| | 597 | | { |
| 864 | 598 | | int tonicPc = NormPc(key.TonicMidi); |
| 864 | 599 | | int[] intervals = key.IsMajor |
| 864 | 600 | | ? new[] { 0, 2, 4, 5, 7, 9, 11 } |
| 864 | 601 | | : new[] { 0, 2, 3, 5, 7, 8, 11 }; // harmonic minor baseline (raised 7th) |
| 6912 | 602 | | return intervals.Select(i => (tonicPc + i) % 12).ToHashSet(); |
| | 603 | | } |
| | 604 | |
|
| 29714 | 605 | | private static int NormPc(int midiOrPc) => ((midiOrPc % 12) + 12) % 12; |
| 1 | 606 | | private static readonly HashSet<int> s_emptySet = new(); |
| 126 | 607 | | private static HashSet<int> ToPcSet(int[] pcs) => pcs.Select(NormPc).ToHashSet(); |
| | 608 | |
|
| | 609 | | private static (HashSet<int> vTri, HashSet<int> v7, HashSet<int> iTri, HashSet<int> i7maj, HashSet<int> i7min) GetKe |
| | 610 | | { |
| 1590 | 611 | | int tonic = NormPc(key.TonicMidi); |
| 1590 | 612 | | int dom = (tonic + 7) % 12; |
| 1590 | 613 | | var vTri = new Chord(dom, ChordQuality.Major).PitchClasses().ToHashSet(); |
| 1590 | 614 | | var v7 = new Chord(dom, ChordQuality.DominantSeventh).PitchClasses().ToHashSet(); |
| 1590 | 615 | | var iTri = new Chord(tonic, key.IsMajor ? ChordQuality.Major : ChordQuality.Minor).PitchClasses().ToHashSet(); |
| 1590 | 616 | | var i7maj = new Chord(tonic, ChordQuality.MajorSeventh).PitchClasses().ToHashSet(); |
| 1590 | 617 | | var i7min = new Chord(tonic, ChordQuality.MinorSeventh).PitchClasses().ToHashSet(); |
| 1590 | 618 | | return (vTri, v7, iTri, i7maj, i7min); |
| | 619 | | } |
| | 620 | |
|
| | 621 | | private static bool SetEquals(HashSet<int> a, HashSet<int> b) |
| | 622 | | { |
| 32953 | 623 | | if (a.Count != b.Count) return false; |
| 67801 | 624 | | foreach (var x in a) if (!b.Contains(x)) return false; |
| 1024 | 625 | | return true; |
| 10003 | 626 | | } |
| | 627 | |
|
| 6462 | 628 | | private static bool SameKey(Key a, Key b) => a.IsMajor == b.IsMajor && NormPc(a.TonicMidi) == NormPc(b.TonicMidi); |
| | 629 | |
|
| | 630 | | // Helpers: diatonic triad qualities by degree (0..6) for scoring secondary resolution targets |
| | 631 | | private static ChordQuality TriadQualityForDegreeMajor(int degree) |
| 2 | 632 | | => degree switch |
| 2 | 633 | | { |
| 1 | 634 | | 0 or 3 or 4 => ChordQuality.Major, |
| 1 | 635 | | 1 or 2 or 5 => ChordQuality.Minor, |
| 0 | 636 | | 6 => ChordQuality.Diminished, |
| 0 | 637 | | _ => ChordQuality.Unknown |
| 2 | 638 | | }; |
| | 639 | |
|
| | 640 | | private static ChordQuality TriadQualityForDegreeMinorHarm(int degree) |
| 0 | 641 | | => degree switch |
| 0 | 642 | | { |
| 0 | 643 | | 0 or 3 => ChordQuality.Minor, |
| 0 | 644 | | 2 or 5 => ChordQuality.Major, |
| 0 | 645 | | 1 => ChordQuality.Diminished, |
| 0 | 646 | | 4 => ChordQuality.Major, // V |
| 0 | 647 | | 6 => ChordQuality.Diminished, |
| 0 | 648 | | _ => ChordQuality.Unknown |
| 0 | 649 | | }; |
| | 650 | | } |