< Summary

Information
Class: MusicTheory.Theory.Harmony.CadenceInfo
Assembly: MusicTheory
File(s): /home/runner/work/MusicTheory/MusicTheory/Theory/Harmony/CadenceAnalyzer.cs
Line coverage
100%
Covered lines: 12
Uncovered lines: 0
Coverable lines: 12
Total lines: 302
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_IndexFrom()100%11100%
get_Type()100%11100%
get_IsPerfectAuthentic()100%11100%
get_HasCadentialSixFour()100%11100%
get_SixFour()100%11100%
PrintMembers(...)100%11100%

File(s)

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

#LineLine coverage
 1using System.Text;
 2
 3namespace MusicTheory.Theory.Harmony;
 4
 5/// <summary>
 6/// High-level cadence classification between two chords.
 7/// </summary>
 8public enum CadenceType { None, Authentic, Plagal, Half, Deceptive }
 9
 10/// <summary>
 11/// Classification for 6-4 chords when identified in context.
 12/// </summary>
 13public 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>
 21public readonly partial record struct CadenceInfo(
 1422    int IndexFrom,
 16823    CadenceType Type,
 4224    bool IsPerfectAuthentic,
 2325    bool HasCadentialSixFour,
 12026    SixFourType SixFour
 27);
 28
 29// Improve diagnostics: customize ToString for clearer test failure messages
 30public readonly partial record struct CadenceInfo
 31{
 32    private bool PrintMembers(StringBuilder builder)
 33    {
 134        builder
 135            .Append("IndexFrom = ").Append(IndexFrom)
 136            .Append(", Type = ").Append(Type)
 137            .Append(", IsPerfectAuthentic = ").Append(IsPerfectAuthentic)
 138            .Append(", HasCadentialSixFour = ").Append(HasCadentialSixFour)
 139            .Append(", SixFour = ").Append(SixFour);
 140        return true;
 41    }
 42}
 43
 44public 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}