< Summary

Information
Class: MusicTheory.Theory.Harmony.KeyEstimator
Assembly: MusicTheory
File(s): /home/runner/work/MusicTheory/MusicTheory/Theory/Harmony/KeyEstimator.cs
Line coverage
85%
Covered lines: 308
Uncovered lines: 53
Coverable lines: 361
Total lines: 650
Line coverage: 85.3%
Branch coverage
78%
Covered branches: 245
Total branches: 314
Branch coverage: 78%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Window()100%11100%
get_PrevKeyBias()100%11100%
get_InitialKeyBias()100%11100%
get_DominantTriadBonus()100%11100%
get_DominantSeventhBonus()100%11100%
get_CadenceBonus()100%11100%
get_SwitchMargin()100%11100%
get_PivotChordBonus()100%11100%
get_SecondaryDominantTriadBonus()100%11100%
get_SecondaryDominantSeventhBonus()100%11100%
get_SecondaryResolutionBonus()100%11100%
get_CollectTrace()100%11100%
get_OutOfKeyPenaltyPerPc()100%11100%
get_MinSwitchIndex()100%11100%
get_Index()100%11100%
get_ChosenKey()100%11100%
get_BaseWindowScore()100%11100%
get_PrevKeyBias()100%11100%
get_InitialKeyBias()100%11100%
get_DominantTriad()100%11100%
get_DominantSeventh()100%11100%
get_Cadence()100%11100%
get_Pivot()100%11100%
get_SecondaryDominantTriad()100%11100%
get_SecondaryDominantSeventh()100%11100%
get_SecondaryResolution()100%11100%
get_Total()100%11100%
get_StayedDueToHysteresis()100%11100%
get_MaxScore()100%11100%
get_SecondBestScore()100%11100%
get_NumTopKeys()100%11100%
get_TopKeysSummary()100%11100%
get_OutOfKeyPenalty()100%11100%
get_VLRangeViolation()100%11100%
get_VLSpacingViolation()100%11100%
get_VLOverlap()100%11100%
get_VLParallelPerfects()100%11100%
ToString()50%22100%
EstimatePerChord(...)100%11100%
EstimatePerChord(...)100%11100%
EstimatePerChord(...)100%11100%
EstimatePerChord(...)74.71%63917875.58%
BuildTraceEntry(...)88.23%102102100%
BuildAllKeys()100%22100%
DiatonicSet(...)100%22100%
NormPc(...)100%11100%
.cctor()100%11100%
ToPcSet(...)50%22100%
GetKeyReferenceSets(...)100%22100%
SetEquals(...)100%66100%
SameKey(...)100%22100%
TriadQualityForDegreeMajor(...)75%9871.42%
TriadQualityForDegreeMinorHarm(...)0%7280%

File(s)

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

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Linq;
 4
 5namespace MusicTheory.Theory.Harmony;
 6
 7public 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>
 325216        public int Window { get; init; } = 2;              // sliding window radius
 17        /// <summary>Bias to keep the previous key (inertia). Added when candidate equals previous.</summary>
 15818        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>
 14420        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>
 16722        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>
 5324        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>
 7226        public int CadenceBonus { get; init; } = 4;         // if prev=V(7) and curr=I in candidate key
 27        /// <summary>Hysteresis margin: require competitor advantage &gt; margin to switch from the previous key.</summa
 21528        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>
 32930        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>
 69832        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>
 10334        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>
 159236    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>
 12638        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
 159740        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>
 12945    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>
 8556        public int Index { get; init; }
 57        /// <summary>The key chosen at this index after tie-break and hysteresis.</summary>
 9458        public Key ChosenKey { get; init; }
 59        /// <summary>Diatonic fit score summed over the sliding window.</summary>
 8460        public int BaseWindowScore { get; init; }
 61        /// <summary>PrevKeyBias applied (if any).</summary>
 8462        public int PrevKeyBias { get; init; }
 63        /// <summary>InitialKeyBias applied (if any).</summary>
 8464        public int InitialKeyBias { get; init; }
 65        /// <summary>Bonus when the current chord matched V triad in the chosen key.</summary>
 8566        public int DominantTriad { get; init; }
 67        /// <summary>Bonus when the current chord matched V7 in the chosen key.</summary>
 8568        public int DominantSeventh { get; init; }
 69        /// <summary>Bonus when the motion prev: V(7) → curr: I(maj7/m7) in the chosen key.</summary>
 8570        public int Cadence { get; init; }
 71        /// <summary>Bonus when the current chord is diatonic to both previous and chosen keys.</summary>
 8572        public int Pivot { get; init; }
 73        /// <summary>Bonus when the current chord matched a secondary dominant triad V/x.</summary>
 8574        public int SecondaryDominantTriad { get; init; }
 75        /// <summary>Bonus when the current chord matched a secondary dominant seventh V/x7.</summary>
 8576        public int SecondaryDominantSeventh { get; init; }
 77    /// <summary>Bonus when a secondary dominant resolved to its target chord (applied at the resolution chord).</summar
 8278    public int SecondaryResolution { get; init; }
 79        /// <summary>Total score for the chosen key (sum of parts and penalty).</summary>
 8980        public int Total { get; init; }
 81        /// <summary>True when the key stayed due to hysteresis/tie logic.</summary>
 9382        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>
 11785        public int MaxScore { get; init; }
 86        /// <summary>Second-best score (max among non-chosen candidates, 0 if n/a).</summary>
 11787        public int SecondBestScore { get; init; }
 88        /// <summary>Number of candidates tied for the maximum score.</summary>
 8589        public int NumTopKeys { get; init; }
 90        /// <summary>Compact list of top candidates (e.g., "M@0:5,m@9:5").</summary>
 8391        public string? TopKeysSummary { get; init; }
 92        /// <summary>Penalty (negative) applied for non-diatonic pitch classes relative to the chosen key.</summary>
 8893        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>
 8696        public bool VLRangeViolation { get; init; }
 97        /// <summary>Voice-leading: spacing violations (S-A/A-T larger than allowed).</summary>
 8698        public bool VLSpacingViolation { get; init; }
 99        /// <summary>Voice-leading: overlap between adjacent voices.</summary>
 86100        public bool VLOverlap { get; init; }
 101        /// <summary>Voice-leading: parallel perfect fifths/octaves detected to previous chord.</summary>
 87102        public bool VLParallelPerfects { get; init; }
 103
 104        public override string ToString()
 105        {
 2106            var m = ChosenKey.IsMajor ? "M" : "m";
 2107            int tp = ((ChosenKey.TonicMidi % 12) + 12) % 12;
 2108            return $"idx={Index} key={m}@{tp} base={BaseWindowScore} prev={PrevKeyBias} init={InitialKeyBias} " +
 2109                   $"V={DominantTriad} V7={DominantSeventh} cad={Cadence} pivot={Pivot} " +
 2110       $"V/x={SecondaryDominantTriad} V/x7={SecondaryDominantSeventh} out={OutOfKeyPenalty} total={Total} stay={StayedDu
 2111           $"vl=[r={(VLRangeViolation?1:0)},s={(VLSpacingViolation?1:0)},o={(VLOverlap?1:0)},p={(VLParallelPerfects?1:0)
 2112                   $"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)
 7118        => 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)
 7122        => 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)
 22127        => 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    {
 37133        trace = new List<TraceEntry>(pcsList?.Count ?? 0);
 37134        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.
 37138        if (options.MinSwitchIndex > 0 && options.MinSwitchIndex >= pcsList.Count)
 139        {
 6140            var locked = new List<Key>(pcsList.Count);
 72141            for (int i = 0; i < pcsList.Count; i++) locked.Add(initialKey);
 6142            if (options.CollectTrace)
 143            {
 144                // Build minimal trace entries to preserve length; scoring fields remain 0, stayed/hysteresis marked.
 5145                var candidatesLock = BuildAllKeys();
 245146                var diatonicMapLock = candidatesLock.ToDictionary(k => k, k => DiatonicSet(k));
 44147                for (int i = 0; i < pcsList.Count; i++)
 148                {
 17149                    var te = BuildTraceEntry(
 17150                        index: i,
 17151                        chosenKey: initialKey,
 17152                        prevKey: i == 0 ? (Key?)null : initialKey,
 17153                        stayed: i > 0, // stayed from previous
 17154                        pcsList: pcsList,
 17155                        options: options,
 17156                        diatonicMap: diatonicMapLock,
 17157                        initialKey: initialKey,
 17158                        maxScore: 0,
 17159                        secondBestScore: 0,
 17160                        numTopKeys: 1,
 17161                        topKeysSummary: null,
 17162                        voicings: voicings
 17163                    );
 17164                    trace.Add(te);
 165                }
 166            }
 6167            return locked;
 168        }
 169
 31170        var candidates = BuildAllKeys();
 1519171        var diatonicMap = candidates.ToDictionary(k => k, k => DiatonicSet(k));
 172
 31173        var result = new List<Key>(pcsList.Count);
 31174        Key? prev = null;
 250175        for (int i = 0; i < pcsList.Count; i++)
 176        {
 94177            if (i == 0)
 178            {
 31179                result.Add(initialKey);
 31180                prev = initialKey;
 31181                if (options.CollectTrace)
 182                {
 20183                    var te0 = BuildTraceEntry(i, initialKey, prevKey: null, stayed: false, pcsList, options, diatonicMap
 20184                        maxScore: 0, secondBestScore: 0, numTopKeys: 1, topKeysSummary: null, voicings: voicings);
 20185                    trace.Add(te0);
 186                }
 20187                continue;
 188            }
 189
 63190            var currSet = ToPcSet(pcsList[i]);
 63191            var prevSet = i > 0 ? ToPcSet(pcsList[i - 1]) : s_emptySet;
 192
 63193            var scoreByKey = new Dictionary<Key, int>();
 3150194            foreach (var k in candidates)
 195            {
 1512196                int score = 0;
 1512197                var dia = diatonicMap[k];
 1512198                int start = Math.Max(0, i - options.Window);
 1512199                int end = Math.Min(pcsList.Count - 1, i + options.Window);
 9552200                for (int j = start; j <= end; j++)
 201                {
 3264202                    var set = pcsList[j].Select(NormPc).Distinct();
 26496203                    foreach (var pc in set)
 15808204                        if (dia.Contains(pc)) score++;
 205                }
 206
 1575207                if (prev is Key pk && SameKey(pk, k)) score += options.PrevKeyBias;
 1575208                if (SameKey(initialKey, k)) score += options.InitialKeyBias;
 209
 1512210                var (vTri, v7, iTri, i7maj, i7min) = GetKeyReferenceSets(k);
 1522211                if (SetEquals(currSet, v7)) score += options.DominantSeventhBonus;
 1604212                else if (SetEquals(currSet, vTri)) score += options.DominantTriadBonus;
 213
 1512214                if ((SetEquals(prevSet, v7) || SetEquals(prevSet, vTri)) &&
 1512215                    (SetEquals(currSet, iTri) || SetEquals(currSet, (k.IsMajor ? i7maj : i7min))))
 216                {
 18217                    score += options.CadenceBonus;
 218                }
 219
 1512220                if (prev is Key prevK)
 221                {
 1512222                    var prevDia = diatonicMap[prevK];
 4387223                    bool inCand = currSet.All(pc => dia.Contains(pc));
 5640224                    bool inPrev = currSet.All(pc => prevDia.Contains(pc));
 1512225                    if (inCand && inPrev)
 244226                        score += options.PivotChordBonus;
 227                }
 228
 1512229                int tonicPc = NormPc(k.TonicMidi);
 16464230                for (int deg = 1; deg <= 6; deg++)
 231                {
 7392232                    int degPc = k.IsMajor
 7392233                        ? NormPc(k.ScaleDegreeMidi(deg))
 7392234                        : (deg == 6 ? (tonicPc + 11) % 12 : NormPc(k.ScaleDegreeMidi(deg)));
 235
 7392236                    int secRoot = (degPc + 7) % 12;
 7392237                    var secVTri = new Chord(secRoot, ChordQuality.Major).PitchClasses().ToHashSet();
 7392238                    var secV7 = new Chord(secRoot, ChordQuality.DominantSeventh).PitchClasses().ToHashSet();
 7512239                    if (SetEquals(currSet, secV7)) { score += options.SecondaryDominantSeventhBonus; break; }
 8556240                    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
 1512244                if (options.SecondaryResolutionBonus != 0 && i > 0)
 245                {
 0246                    var prevSetLocal = prevSet;
 0247                    for (int deg = 1; deg <= 6; deg++)
 248                    {
 0249                        int degPc = k.IsMajor
 0250                            ? NormPc(k.ScaleDegreeMidi(deg))
 0251                            : (deg == 6 ? (tonicPc + 11) % 12 : NormPc(k.ScaleDegreeMidi(deg)));
 0252                        int secRoot = (degPc + 7) % 12;
 0253                        var secVTri = new Chord(secRoot, ChordQuality.Major).PitchClasses().ToHashSet();
 0254                        var secV7 = new Chord(secRoot, ChordQuality.DominantSeventh).PitchClasses().ToHashSet();
 0255                        bool prevWasSec = SetEquals(prevSetLocal, secVTri) || SetEquals(prevSetLocal, secV7);
 0256                        if (!prevWasSec) continue;
 0257                        var targetTri = new Chord(degPc, k.IsMajor ? TriadQualityForDegreeMajor(deg) : TriadQualityForDe
 0258                        var target7Maj = new Chord(degPc, ChordQuality.MajorSeventh).PitchClasses().ToHashSet();
 0259                        var target7Min = new Chord(degPc, ChordQuality.MinorSeventh).PitchClasses().ToHashSet();
 0260                        if (SetEquals(currSet, targetTri) || SetEquals(currSet, (k.IsMajor ? target7Maj : target7Min)))
 261                        {
 0262                            score += options.SecondaryResolutionBonus;
 0263                            break;
 264                        }
 265                    }
 266                }
 267
 268                // Optional: penalize non-diatonic PCs in the current chord for this candidate key
 1512269                if (options.OutOfKeyPenaltyPerPc != 0 && currSet.Count > 0)
 270                {
 0271                    int nonDiaCount = 0;
 0272                    foreach (var pc in currSet) if (!dia.Contains(pc)) nonDiaCount++;
 0273                    if (nonDiaCount > 0) score -= nonDiaCount * options.OutOfKeyPenaltyPerPc;
 274                }
 275
 1512276                scoreByKey[k] = score;
 277            }
 278
 63279            int maxScore = scoreByKey.Values.Max();
 1684280            var topKeys = scoreByKey.Where(kv => kv.Value == maxScore).Select(kv => kv.Key).ToList();
 63281            bool tieExists = topKeys.Count > 1;
 282            // Deterministic tie-break: prefer Major, then lower tonic pitch class
 63283            Key candidateChoice = topKeys
 109284                .OrderByDescending(k => k.IsMajor)
 80285                .ThenBy(k => NormPc(k.TonicMidi))
 63286                .First();
 287
 288            Key chosen;
 63289            bool stayedDueToHysteresis = false;
 290            // Hard lock: don't allow switching before MinSwitchIndex (always stay on previous when available)
 63291            if (options.MinSwitchIndex > 0 && i < options.MinSwitchIndex && prev is Key prevLock)
 292            {
 0293                chosen = prevLock;
 294                // Build trace and continue
 0295                if (options.CollectTrace)
 296                {
 0297                    string? tops = null;
 0298                    if (scoreByKey.Count > 0)
 299                    {
 0300                        var lockTopKeys = scoreByKey.Where(kv => kv.Value == scoreByKey.Values.Max()).Select(kv => kv.Ke
 0301                        var parts = lockTopKeys
 0302                            .OrderBy(k => NormPc(k.TonicMidi))
 0303                            .ThenByDescending(k => k.IsMajor)
 0304                            .Select(k => $"{(k.IsMajor ? "M" : "m")}@{NormPc(k.TonicMidi)}:{scoreByKey[k]}");
 0305                        tops = string.Join(",", parts);
 306                    }
 0307                    int secondBest = 0;
 0308                    if (scoreByKey.Count > 1)
 0309                        secondBest = scoreByKey.Where(kv => !SameKey(kv.Key, prevLock)).Select(kv => kv.Value).DefaultIf
 0310                    var teLock = BuildTraceEntry(i, prevLock, prev, stayed: true, pcsList, options, diatonicMap, initial
 0311                        maxScore: scoreByKey.Values.Max(), secondBestScore: secondBest,
 0312                        numTopKeys: scoreByKey.Count(kv => kv.Value == scoreByKey.Values.Max()), topKeysSummary: tops, v
 0313                    trace.Add(teLock);
 314                }
 0315                result.Add(chosen);
 0316                prev = chosen;
 0317                continue;
 318            }
 63319            if (prev is Key prevKey)
 320            {
 63321                int prevScore = scoreByKey[prevKey];
 63322                if (!SameKey(candidateChoice, prevKey) && prevScore + options.SwitchMargin > maxScore)
 323                {
 0324                    chosen = prevKey; // stay due to insufficient advantage
 0325                    stayedDueToHysteresis = true;
 326                }
 327                else
 328                {
 145329                    if (topKeys.Any(k => SameKey(k, prevKey)))
 330                    {
 44331                        chosen = prevKey;
 332                        // In a tie, explicitly flag that we stayed (even if deterministic tie-break would also pick pre
 55333                        if (tieExists) stayedDueToHysteresis = true;
 334                    }
 56335                    else if (topKeys.Any(k => SameKey(k, initialKey)))
 336                    {
 2337                        chosen = initialKey;
 338                    }
 339                    else
 340                    {
 17341                        chosen = candidateChoice;
 342                        // If candidate equals prev (e.g., tie broken to prev) and tie existed, mark as hysteresis stay
 17343                        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.
 63348                    if (SameKey(chosen, prevKey) && options.SwitchMargin > 0)
 349                    {
 41350                        int otherMax = int.MinValue;
 2050351                        foreach (var kv in scoreByKey)
 352                        {
 984353                            if (SameKey(kv.Key, prevKey)) continue;
 1029354                            if (kv.Value > otherMax) otherMax = kv.Value;
 355                        }
 41356                        if (otherMax != int.MinValue)
 357                        {
 41358                            int prevScoreLocal = prevScore;
 41359                            if (otherMax >= prevScoreLocal && otherMax < prevScoreLocal + options.SwitchMargin)
 360                            {
 9361                                stayedDueToHysteresis = true;
 362                            }
 363                        }
 364                    }
 365                }
 366            }
 367            else
 368            {
 0369                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.
 63375            if (prev is Key prevAfter && SameKey(chosen, prevAfter))
 376            {
 44377                int prevScoreAfter = scoreByKey[prevAfter];
 951378                bool competitorWithinMargin = scoreByKey.Any(kv => !SameKey(kv.Key, prevAfter) && kv.Value >= prevScoreA
 44379                if (tieExists || options.SwitchMargin > 0 || prevScoreAfter + options.SwitchMargin > maxScore || competi
 380                {
 43381                    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.
 63387            if (prev is Key prevForFlag && SameKey(prevForFlag, chosen) && (options.SwitchMargin > 0 || tieExists))
 388            {
 43389                stayedDueToHysteresis = true;
 390            }
 391
 63392            result.Add(chosen);
 63393            if (options.CollectTrace)
 394            {
 395                // Build a short summary of top keys (same max score)
 41396                string? tops = null;
 41397                if (scoreByKey.Count > 0)
 398                {
 41399                    var parts = topKeys
 84400                        .OrderBy(k => NormPc(k.TonicMidi))
 84401                        .ThenByDescending(k => k.IsMajor)
 125402                        .Select(k => $"{(k.IsMajor ? "M" : "m")}@{NormPc(k.TonicMidi)}:{scoreByKey[k]}");
 41403                    tops = string.Join(",", parts);
 404                }
 405                // second best (largest score among non-chosen keys), or 0 if none
 41406                int secondBest = 0;
 41407                if (scoreByKey.Count > 1)
 408                {
 1968409                    secondBest = scoreByKey.Where(kv => !SameKey(kv.Key, chosen)).Select(kv => kv.Value).DefaultIfEmpty(
 410                }
 41411                var te = BuildTraceEntry(i, chosen, prev, stayedDueToHysteresis, pcsList, options, diatonicMap, initialK
 41412                    maxScore: maxScore, secondBestScore: secondBest, numTopKeys: topKeys.Count, topKeysSummary: tops, vo
 41413                trace.Add(te);
 414            }
 63415            prev = chosen;
 416        }
 31417        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    {
 78435        int baseWindow = 0;
 78436        var dia = diatonicMap[chosenKey];
 78437        int start = Math.Max(0, index - options.Window);
 78438        int end = Math.Min(pcsList.Count - 1, index + options.Window);
 452439        for (int j = start; j <= end; j++)
 440        {
 148441            var set = pcsList[j].Select(NormPc).Distinct();
 1194442            foreach (var pc in set)
 885443                if (dia.Contains(pc)) baseWindow++;
 444        }
 445
 78446        int prevBias = (prevKey is Key pk && SameKey(pk, chosenKey)) ? options.PrevKeyBias : 0;
 78447        int initBias = SameKey(initialKey, chosenKey) ? options.InitialKeyBias : 0;
 448
 78449        var curr = pcsList[index].Select(NormPc).ToHashSet();
 78450        var prevSetLocal = index > 0 ? pcsList[index - 1].Select(NormPc).ToHashSet() : s_emptySet;
 78451        var (vTri, v7, iTri, i7maj, i7min) = GetKeyReferenceSets(chosenKey);
 78452        int dom7 = SetEquals(curr, v7) ? options.DominantSeventhBonus : 0;
 78453        int domTri = (dom7 == 0 && SetEquals(curr, vTri)) ? options.DominantTriadBonus : 0;
 454
 78455        int cadence = 0;
 78456        if ((SetEquals(prevSetLocal, v7) || SetEquals(prevSetLocal, vTri)) &&
 78457            (SetEquals(curr, iTri) || SetEquals(curr, (chosenKey.IsMajor ? i7maj : i7min))))
 458        {
 12459            cadence = options.CadenceBonus;
 460        }
 461
 78462        int pivot = 0;
 78463        if (prevKey is Key prevK)
 464        {
 53465            var prevDia = diatonicMap[prevK];
 208466            bool inCand = curr.All(pc => dia.Contains(pc));
 199467            bool inPrev = curr.All(pc => prevDia.Contains(pc));
 96468            if (inCand && inPrev) pivot = options.PivotChordBonus;
 469        }
 470
 156471        int secTri = 0, sec7 = 0;
 78472        int secResolve = 0;
 473        {
 78474            int tonicPc = NormPc(chosenKey.TonicMidi);
 732475            for (int deg = 1; deg <= 6; deg++)
 476            {
 333477                int degPc = chosenKey.IsMajor
 333478                    ? NormPc(chosenKey.ScaleDegreeMidi(deg))
 333479                    : (deg == 6 ? (tonicPc + 11) % 12 : NormPc(chosenKey.ScaleDegreeMidi(deg)));
 480
 333481                int secRoot = (degPc + 7) % 12;
 333482                var secVTri = new Chord(secRoot, ChordQuality.Major).PitchClasses().ToHashSet();
 333483                var secV7 = new Chord(secRoot, ChordQuality.DominantSeventh).PitchClasses().ToHashSet();
 335484                if (SetEquals(curr, secV7)) { sec7 = options.SecondaryDominantSeventhBonus; break; }
 420485                if (SetEquals(curr, secVTri)) { secTri = options.SecondaryDominantTriadBonus; break; }
 486            }
 487            // Resolution bonus (applied at current chord if previous was V/x)
 78488            if (options.SecondaryResolutionBonus != 0 && index > 0)
 489            {
 2490                var prevSet = pcsList[index - 1].Select(NormPc).ToHashSet();
 16491                for (int deg = 1; deg <= 6; deg++)
 492                {
 7493                    int degPc = chosenKey.IsMajor
 7494                        ? NormPc(chosenKey.ScaleDegreeMidi(deg))
 7495                        : (deg == 6 ? (tonicPc + 11) % 12 : NormPc(chosenKey.ScaleDegreeMidi(deg)));
 7496                    int secRoot = (degPc + 7) % 12;
 7497                    var secVTri = new Chord(secRoot, ChordQuality.Major).PitchClasses().ToHashSet();
 7498                    var secV7 = new Chord(secRoot, ChordQuality.DominantSeventh).PitchClasses().ToHashSet();
 7499                    bool prevWasSec = SetEquals(prevSet, secVTri) || SetEquals(prevSet, secV7);
 7500                    if (!prevWasSec) continue;
 2501                    var targetTri = new Chord(degPc, chosenKey.IsMajor ? TriadQualityForDegreeMajor(deg) : TriadQualityF
 2502                    var target7Maj = new Chord(degPc, ChordQuality.MajorSeventh).PitchClasses().ToHashSet();
 2503                    var target7Min = new Chord(degPc, ChordQuality.MinorSeventh).PitchClasses().ToHashSet();
 2504                    if (SetEquals(curr, targetTri) || SetEquals(curr, (chosenKey.IsMajor ? target7Maj : target7Min)))
 505                    {
 1506                        secResolve = options.SecondaryResolutionBonus;
 1507                        break;
 508                    }
 509                }
 510            }
 511        }
 512
 513        // Out-of-key penalty for transparency (applied only against chosenKey for trace)
 78514        int outPenalty = 0;
 78515        if (options.OutOfKeyPenaltyPerPc != 0 && curr.Count > 0)
 516        {
 9517            int nonDiaCount = 0;
 111518            foreach (var pc in curr) if (!dia.Contains(pc)) nonDiaCount++;
 12519            if (nonDiaCount > 0) outPenalty = -nonDiaCount * options.OutOfKeyPenaltyPerPc;
 520        }
 521
 78522    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;
 78525    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
 46530        stayedFlag = stayed || numTopKeys > 1 || options.SwitchMargin > 0;
 531    }
 532    else
 533    {
 32534        stayedFlag = stayed;
 535    }
 536
 537        // Voice-leading diagnostics (if voicings provided)
 312538        bool vlRange = false, vlSpace = false, vlOverlap = false, vlPar = false;
 78539        if (voicings != null && index < voicings.Count)
 540        {
 7541            var v = voicings[index];
 7542            if (v is FourPartVoicing vx)
 543            {
 3544                vlRange = VoiceLeadingRules.HasRangeViolation(vx);
 3545                vlSpace = VoiceLeadingRules.HasSpacingViolations(vx);
 3546                if (index > 0)
 547                {
 2548                    var pv = voicings[index - 1];
 2549                    if (pv is FourPartVoicing pvx)
 550                    {
 1551                        vlOverlap = VoiceLeadingRules.HasOverlap(pvx, vx);
 1552                        vlPar = VoiceLeadingRules.HasParallelPerfects(pvx, vx);
 553                    }
 554                }
 555            }
 556        }
 78557        return new TraceEntry
 78558        {
 78559            Index = index,
 78560            ChosenKey = chosenKey,
 78561            BaseWindowScore = baseWindow,
 78562            PrevKeyBias = prevBias,
 78563            InitialKeyBias = initBias,
 78564            DominantTriad = domTri,
 78565            DominantSeventh = dom7,
 78566            Cadence = cadence,
 78567            Pivot = pivot,
 78568            SecondaryDominantTriad = secTri,
 78569            SecondaryDominantSeventh = sec7,
 78570            SecondaryResolution = secResolve,
 78571            Total = total,
 78572            StayedDueToHysteresis = stayedFlag,
 78573            MaxScore = maxScore,
 78574            SecondBestScore = secondBestScore,
 78575            NumTopKeys = numTopKeys,
 78576            TopKeysSummary = topKeysSummary,
 78577            OutOfKeyPenalty = outPenalty,
 78578            VLRangeViolation = vlRange,
 78579            VLSpacingViolation = vlSpace,
 78580            VLOverlap = vlOverlap,
 78581            VLParallelPerfects = vlPar,
 78582        };
 583    }
 584
 585    private static List<Key> BuildAllKeys()
 586    {
 36587        var keys = new List<Key>(24);
 936588        for (int pc = 0; pc < 12; pc++)
 589        {
 432590            keys.Add(new Key(60 + pc, true));  // majors
 432591            keys.Add(new Key(60 + pc, false)); // minors
 592        }
 36593        return keys;
 594    }
 595
 596    private static HashSet<int> DiatonicSet(Key key)
 597    {
 864598        int tonicPc = NormPc(key.TonicMidi);
 864599        int[] intervals = key.IsMajor
 864600            ? new[] { 0, 2, 4, 5, 7, 9, 11 }
 864601            : new[] { 0, 2, 3, 5, 7, 8, 11 }; // harmonic minor baseline (raised 7th)
 6912602        return intervals.Select(i => (tonicPc + i) % 12).ToHashSet();
 603    }
 604
 29714605    private static int NormPc(int midiOrPc) => ((midiOrPc % 12) + 12) % 12;
 1606    private static readonly HashSet<int> s_emptySet = new();
 126607    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    {
 1590611        int tonic = NormPc(key.TonicMidi);
 1590612        int dom = (tonic + 7) % 12;
 1590613        var vTri = new Chord(dom, ChordQuality.Major).PitchClasses().ToHashSet();
 1590614        var v7 = new Chord(dom, ChordQuality.DominantSeventh).PitchClasses().ToHashSet();
 1590615        var iTri = new Chord(tonic, key.IsMajor ? ChordQuality.Major : ChordQuality.Minor).PitchClasses().ToHashSet();
 1590616        var i7maj = new Chord(tonic, ChordQuality.MajorSeventh).PitchClasses().ToHashSet();
 1590617        var i7min = new Chord(tonic, ChordQuality.MinorSeventh).PitchClasses().ToHashSet();
 1590618        return (vTri, v7, iTri, i7maj, i7min);
 619    }
 620
 621    private static bool SetEquals(HashSet<int> a, HashSet<int> b)
 622    {
 32953623        if (a.Count != b.Count) return false;
 67801624        foreach (var x in a) if (!b.Contains(x)) return false;
 1024625        return true;
 10003626    }
 627
 6462628    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)
 2632        => degree switch
 2633        {
 1634            0 or 3 or 4 => ChordQuality.Major,
 1635            1 or 2 or 5 => ChordQuality.Minor,
 0636            6 => ChordQuality.Diminished,
 0637            _ => ChordQuality.Unknown
 2638        };
 639
 640    private static ChordQuality TriadQualityForDegreeMinorHarm(int degree)
 0641        => degree switch
 0642        {
 0643            0 or 3 => ChordQuality.Minor,
 0644            2 or 5 => ChordQuality.Major,
 0645            1 => ChordQuality.Diminished,
 0646            4 => ChordQuality.Major,  // V
 0647            6 => ChordQuality.Diminished,
 0648            _ => ChordQuality.Unknown
 0649        };
 650}

Methods/Properties

get_Window()
get_PrevKeyBias()
get_InitialKeyBias()
get_DominantTriadBonus()
get_DominantSeventhBonus()
get_CadenceBonus()
get_SwitchMargin()
get_PivotChordBonus()
get_SecondaryDominantTriadBonus()
get_SecondaryDominantSeventhBonus()
get_SecondaryResolutionBonus()
get_CollectTrace()
get_OutOfKeyPenaltyPerPc()
get_MinSwitchIndex()
get_Index()
get_ChosenKey()
get_BaseWindowScore()
get_PrevKeyBias()
get_InitialKeyBias()
get_DominantTriad()
get_DominantSeventh()
get_Cadence()
get_Pivot()
get_SecondaryDominantTriad()
get_SecondaryDominantSeventh()
get_SecondaryResolution()
get_Total()
get_StayedDueToHysteresis()
get_MaxScore()
get_SecondBestScore()
get_NumTopKeys()
get_TopKeysSummary()
get_OutOfKeyPenalty()
get_VLRangeViolation()
get_VLSpacingViolation()
get_VLOverlap()
get_VLParallelPerfects()
ToString()
EstimatePerChord(System.Collections.Generic.IReadOnlyList`1<System.Int32[]>,MusicTheory.Theory.Harmony.Key,System.Int32)
EstimatePerChord(System.Collections.Generic.IReadOnlyList`1<System.Int32[]>,MusicTheory.Theory.Harmony.Key,MusicTheory.Theory.Harmony.KeyEstimator/Options)
EstimatePerChord(System.Collections.Generic.IReadOnlyList`1<System.Int32[]>,MusicTheory.Theory.Harmony.Key,MusicTheory.Theory.Harmony.KeyEstimator/Options,System.Collections.Generic.List`1<MusicTheory.Theory.Harmony.KeyEstimator/TraceEntry>&)
EstimatePerChord(System.Collections.Generic.IReadOnlyList`1<System.Int32[]>,MusicTheory.Theory.Harmony.Key,MusicTheory.Theory.Harmony.KeyEstimator/Options,System.Collections.Generic.List`1<MusicTheory.Theory.Harmony.KeyEstimator/TraceEntry>&,System.Collections.Generic.IReadOnlyList`1<System.Nullable`1<MusicTheory.Theory.Harmony.FourPartVoicing>>)
BuildTraceEntry(System.Int32,MusicTheory.Theory.Harmony.Key,System.Nullable`1<MusicTheory.Theory.Harmony.Key>,System.Boolean,System.Collections.Generic.IReadOnlyList`1<System.Int32[]>,MusicTheory.Theory.Harmony.KeyEstimator/Options,System.Collections.Generic.Dictionary`2<MusicTheory.Theory.Harmony.Key,System.Collections.Generic.HashSet`1<System.Int32>>,MusicTheory.Theory.Harmony.Key,System.Int32,System.Int32,System.Int32,System.String,System.Collections.Generic.IReadOnlyList`1<System.Nullable`1<MusicTheory.Theory.Harmony.FourPartVoicing>>)
BuildAllKeys()
DiatonicSet(MusicTheory.Theory.Harmony.Key)
NormPc(System.Int32)
.cctor()
ToPcSet(System.Int32[])
GetKeyReferenceSets(MusicTheory.Theory.Harmony.Key)
SetEquals(System.Collections.Generic.HashSet`1<System.Int32>,System.Collections.Generic.HashSet`1<System.Int32>)
SameKey(MusicTheory.Theory.Harmony.Key,MusicTheory.Theory.Harmony.Key)
TriadQualityForDegreeMajor(System.Int32)
TriadQualityForDegreeMinorHarm(System.Int32)