< Summary

Information
Class: MusicTheory.Theory.Analysis.AlternativeScaleRankInfo
Assembly: MusicTheory
File(s): /home/runner/work/MusicTheory/MusicTheory/Theory/Analysis/ChordAnalyzer.cs
Line coverage
50%
Covered lines: 4
Uncovered lines: 4
Coverable lines: 8
Total lines: 400
Line coverage: 50%
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_ScaleName()100%210%
get_SharedRatio()100%11100%
get_TensionPresence()100%11100%
get_SharedCount()100%11100%
get_CoveredTensionCount()100%210%
get_TotalTensionCount()100%210%
get_ExactCoverBonus()100%210%
get_Priority()100%11100%

File(s)

/home/runner/work/MusicTheory/MusicTheory/Theory/Analysis/ChordAnalyzer.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Linq;
 4using MusicTheory.Theory.Chord;
 5using MusicTheory.Theory.Scale;
 6using MusicTheory.Theory.Interval;
 7
 8namespace MusicTheory.Theory.Analysis;
 9
 10public record struct ScaleFitDetail(
 11    string ScaleName,
 12    IReadOnlyList<int> Covered,
 13    IReadOnlyList<int> Uncovered,
 14    IReadOnlyList<int> AvoidNotes,
 15    IReadOnlyList<int> ColorNotes,
 16    double RawCoverage,
 17    double AvoidPenalty,
 18    double ColorBoost,
 19    double TensionCoverage,
 20    IReadOnlyList<int> PresentTensions,
 21    IReadOnlyList<int> MissingTensions,
 22    double CoreCoverage,
 23    IReadOnlyList<int> PresentCores,
 24    IReadOnlyList<int> MissingCores,
 25    IReadOnlyList<string> AlternativeScaleNames
 26);
 27
 28public record struct ChordCandidate(
 29    string RootName,
 30    ChordFormula Formula,
 31    int CoverageScore,
 32    double CompletionRatio,
 33    ModalScale? SuggestedScale,
 34    IReadOnlyList<ModalScale> AlternativeScales,
 35    IReadOnlyList<AlternativeScaleRankInfo> AlternativeScaleRanks,
 36    int[] NormalizedSet,
 37    string FormulaSignature,
 38    string InputSignature,
 39    string? ScaleName,
 40    double ScaleFitScore,
 41    IReadOnlyList<int> MissingIntervals,
 42    IReadOnlyList<string> MissingIntervalNames,
 43    IReadOnlyList<int> MissingCoreIntervals,
 44    IReadOnlyList<int> MissingTensionIntervals,
 45    double AvoidPenalty,
 46    double ColorBoost,
 47    double TotalScore,
 48    ScaleFitDetail? ScaleDetail
 49);
 50
 51public record struct ChordAnalyzerOptions(
 52    bool RankResults = true,
 53    double MinCompletion = 0.5,
 54    bool AllowInversions = true,
 55    double CoverageWeight = 1.0,
 56    double ScaleFitWeight = 1.0,
 57    double AvoidPenaltyWeight = 1.0,
 58    double ColorBoostWeight = 1.0,
 59    double CoreCoverageWeight = 0.5,
 60        double TensionCoverageWeight = 0.5,
 61        int[]? ScaleRankingCoreSemitones = null
 62);
 63
 64public record struct AlternativeScaleRankInfo(
 065    string ScaleName,
 266    double SharedRatio,
 267    double TensionPresence,
 21468    int SharedCount,
 069    int CoveredTensionCount,
 070    int TotalTensionCount,
 071        double ExactCoverBonus,
 21672    double Priority
 73);
 74
 75public static class ChordAnalyzer
 76{
 77    private static readonly string[] RootNames = {"C","C#","D","Eb","E","F","F#","G","Ab","A","Bb","B"};
 78
 79    private static readonly (ChordFormula Formula, string Signature, HashSet<int> IntervalSet)[] FormulaSignatures =
 80        ChordFormulas.All.Select(f => (f,
 81            SignatureFromIntervals(f.CoreIntervals.Concat(f.Tensions)),
 82            f.CoreIntervals.Concat(f.Tensions).Select(i => i.Semitones % 12).Where(s=>s!=0).ToHashSet()
 83        )).ToArray();
 84
 85    private static string SignatureFromIntervals(IEnumerable<FunctionalInterval> intervals)
 86        => AnalysisUtils.Signature(intervals.Select(i => i.Semitones));
 87
 88    private static readonly Dictionary<string, ModalScale[]> ScaleHints = new()
 89    {
 90        {"7", new[]{ ExtendedScales.Mixolydian, ExtendedScales.LydianDominant, ExtendedScales.Altered, ExtendedScales.Wh
 91        {"m7", new[]{ ExtendedScales.Dorian, ExtendedScales.Aeolian }},
 92        {"maj7", new[]{ ExtendedScales.Ionian, ExtendedScales.Lydian, ExtendedScales.BebopMajor }},
 93        {"7alt", new[]{ ExtendedScales.Altered }},
 94        {"m7b5", new[]{ ExtendedScales.Locrian, ExtendedScales.DiminishedHalfWhole }},
 95        {"dim7", new[]{ ExtendedScales.DiminishedWholeHalf }},
 96        {"9", new[]{ ExtendedScales.Mixolydian, ExtendedScales.LydianDominant }},
 97        {"13", new[]{ ExtendedScales.Mixolydian, ExtendedScales.LydianDominant, ExtendedScales.BebopDominant }},
 98        {"maj13", new[]{ ExtendedScales.Ionian, ExtendedScales.Lydian }},
 99    };
 100
 101    public static IEnumerable<ChordCandidate> Analyze(IEnumerable<int> pitchClasses, ChordAnalyzerOptions? options = nul
 102    {
 103        var opt = options ?? new ChordAnalyzerOptions();
 104        var pcs = pitchClasses.Select(AnalysisUtils.Mod12).Distinct().ToArray();
 105        if (pcs.Length == 0) return Enumerable.Empty<ChordCandidate>();
 106
 107        var candidates = new List<ChordCandidate>();
 108        foreach (var root in pcs)
 109        {
 110            if (!opt.AllowInversions && root != pcs.Min()) continue;
 111            var normalized = AnalysisUtils.NormalizeToRoot(pcs, root);
 112            var sig = AnalysisUtils.Signature(normalized.Where(pc => pc != 0));
 113            foreach (var (formula, fullSig, intervalSet) in FormulaSignatures)
 114            {
 115                var formulaSig = AnalysisUtils.Signature(intervalSet);
 116                var presentSet = sig.Split('-', StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToHashSet();
 117                // 入力 ⊆ フォーミュラ (不足は可)
 118                if (!presentSet.All(intervalSet.Contains)) continue;
 119                // コア (除: 完全5度) はすべて含まれること
 120                var coreSemis = formula.CoreIntervals.Select(i=>i.Semitones %12).Where(s=>s!=0).ToHashSet();
 121                var essentialCore = coreSemis.Where(s=>s!=7).ToList();
 122                if (!essentialCore.All(presentSet.Contains)) continue;
 123
 124                int coverage = presentSet.Count(intervalSet.Contains);
 125                double completion = intervalSet.Count == 0 ? 1.0 : coverage / (double)intervalSet.Count;
 126                if (completion < opt.MinCompletion) continue;
 127                var (alternativeScales, altRankInfos) = RankAlternativeScales(formula.Symbol, normalized, opt);
 128                var scale = alternativeScales.FirstOrDefault();
 129                double avoidPenalty = 0.0; double colorBoost = 0.0;
 130                var scaleFit = scale == null ? 0.0 : ScaleFitScore(scale, normalized, out avoidPenalty, out colorBoost);
 131                var missing = intervalSet.Except(presentSet).ToList();
 132                var coreSet = coreSemis;
 133                var tensionSet = formula.Tensions.Select(i=>i.Semitones%12).Where(s=>s!=0).ToHashSet();
 134                var missingCore = missing.Where(coreSet.Contains).ToList();
 135                var missingTension = missing.Where(tensionSet.Contains).ToList();
 136                var missingNames = missing.Select(ToIntervalDisplayName).ToList();
 137                double coreCoverageScore = 0.0; double tensionCoverageScore = 0.0;
 138                if (opt.CoreCoverageWeight > 0 || opt.TensionCoverageWeight > 0)
 139                {
 140                    var presentTensionsForScore = tensionSet.Count==0 ? 0 : tensionSet.Count(t => presentSet.Contains(t)
 141                    var presentCoresForScore = coreSemis.Count==0 ? 0 : coreSemis.Count(c => presentSet.Contains(c));
 142                    var coreCoverageRatio = coreSemis.Count==0 ? 1.0 : presentCoresForScore / (double)coreSemis.Count;
 143                    var tensionCoverageRatio = tensionSet.Count==0 ? 1.0 : presentTensionsForScore / (double)tensionSet.
 144                    coreCoverageScore = coreCoverageRatio * opt.CoreCoverageWeight;
 145                    tensionCoverageScore = tensionCoverageRatio * opt.TensionCoverageWeight;
 146                }
 147                double total = (completion * coverage * opt.CoverageWeight)
 148                               + (scaleFit * opt.ScaleFitWeight)
 149                               - (avoidPenalty * opt.AvoidPenaltyWeight)
 150                               + (colorBoost * opt.ColorBoostWeight)
 151                               + coreCoverageScore + tensionCoverageScore;
 152                ScaleFitDetail? scaleDetail = null;
 153                if (scale != null)
 154                {
 155                    var semis = scale.GetSemitoneSet();
 156                    var covered = normalized.Where(semis.Contains).ToHashSet();
 157                    var uncovered = normalized.Where(n => !semis.Contains(n)).ToList();
 158                    var detailAvoid = new List<int>();
 159                    var detailColor = new List<int>();
 160                    if (avoidPenalty > 0 && normalized.Contains(5)) detailAvoid.Add(5);
 161                    if (colorBoost > 0 && normalized.Contains(6)) detailColor.Add(6);
 162                    var presentTensions = formula.Tensions.Select(i=>i.Semitones%12).Where(s=>s!=0 && presentSet.Contain
 163                    var missingTensions = tensionSet.Intersect(missing).ToList();
 164                    double tensionCoverage = tensionSet.Count==0 ? 1.0 : presentTensions.Count / (double)tensionSet.Coun
 165                    var presentCores = coreSemis.Where(presentSet.Contains).ToList();
 166                    var missingCores = coreSemis.Where(missing.Contains).ToList();
 167                    double coreCoverage = coreSemis.Count==0 ? 1.0 : presentCores.Count / (double)coreSemis.Count;
 168                    scaleDetail = new ScaleFitDetail(
 169                        scale.Name,
 170                        covered.ToList(),
 171                        uncovered,
 172                        detailAvoid,
 173                        detailColor,
 174                        covered.Count / (double)normalized.Length,
 175                        avoidPenalty,
 176                        colorBoost,
 177                        tensionCoverage,
 178                        presentTensions,
 179                        missingTensions,
 180                        coreCoverage,
 181                        presentCores,
 182                        missingCores,
 183                        alternativeScales.Select(s=>s.Name).ToList()
 184                    );
 185                }
 186                candidates.Add(new ChordCandidate(
 187                    RootNames[root],
 188                    formula,
 189                    coverage,
 190                    completion,
 191                    scale,
 192                    alternativeScales,
 193                    altRankInfos.Cast<AlternativeScaleRankInfo>().ToList(),
 194                    normalized,
 195                    formulaSig,
 196                    sig,
 197                    scale?.Name,
 198                    scaleFit,
 199                    missing,
 200                    missingNames,
 201                    missingCore,
 202                    missingTension,
 203                    avoidPenalty,
 204                    colorBoost,
 205                    total,
 206                    scaleDetail
 207                ));
 208            }
 209
 210        }
 211        if (opt.RankResults)
 212            return candidates.OrderByDescending(c => c.TotalScore);
 213        return candidates;
 214    }
 215
 216    public static IEnumerable<ChordCandidate> AnalyzeRanked(IEnumerable<int> pitchClasses, ChordAnalyzerOptions? options
 217        => Analyze(pitchClasses, options ?? new ChordAnalyzerOptions()).OrderByDescending(c => c.TotalScore);
 218
 219    public static List<ChordCandidate> ToListRanked(IEnumerable<int> pitchClasses, ChordAnalyzerOptions? options = null)
 220        => AnalyzeRanked(pitchClasses, options).ToList();
 221
 222    private static bool FormulaMatches(string candidateSig, HashSet<int> formulaIntervals)
 223    {
 224        var candidateSet = candidateSig.Split('-', StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToHashSet();
 225        // 条件: フォーミュラ側の全インターバルが候補内に含まれていれば許容(候補に追加ノート可)
 226        return formulaIntervals.All(candidateSet.Contains);
 227    }
 228
 229    private static int CoverageScore(string candidateSig, string formulaSig)
 230    {
 231        var c = candidateSig.Split('-', StringSplitOptions.RemoveEmptyEntries);
 232        var f = formulaSig.Split('-', StringSplitOptions.RemoveEmptyEntries).ToHashSet();
 233        return c.Count(f.Contains);
 234    }
 235
 236    private static ModalScale? SuggestScale(string symbol, int[] normalizedSet)
 237        => SuggestScales(symbol, normalizedSet).FirstOrDefault();
 238
 239    public static IEnumerable<ModalScale> SuggestScales(string symbol, int[] normalizedSet)
 240    {
 241        if (!ScaleHints.TryGetValue(symbol, out var list)) return Enumerable.Empty<ModalScale>();
 242        // 完全包含するものを全部返し、なければヒントリスト全体を返す
 243        var matches = list.Where(scale => {
 244            var semis = scale.GetSemitoneSet();
 245            return normalizedSet.All(pc => semis.Contains(pc));
 246        }).ToList();
 247        return matches.Any() ? matches : list;
 248    }
 249
 250    private static (List<ModalScale> Scales, List<AlternativeScaleRankInfo> Infos) RankAlternativeScales(string symbol, 
 251    {
 252        var scales = SuggestScales(symbol, normalizedSet).ToList();
 253        var normSet = normalizedSet.Where(x=>x!=0).ToHashSet();
 254        // 実入力ベース tension: オプション指定コア集合以外の音をテンション扱い
 255        var defaultCore = new HashSet<int>{3,4,7,10,11};
 256        var coreLike = new HashSet<int>(opt.ScaleRankingCoreSemitones ?? defaultCore.ToArray());
 257        var inputTensions = normSet.Where(n => !coreLike.Contains(n)).ToHashSet();
 258        int totalTension = inputTensions.Count;
 259        var ranked = new List<(ModalScale Scale, AlternativeScaleRankInfo Info)>();
 260        foreach (var s in scales)
 261        {
 262            var semis = s.GetSemitoneSet();
 263            int shared = normSet.Count(semis.Contains);
 264            double sharedRatio = shared / (double)(normSet.Count==0?1:normSet.Count);
 265            int coveredTension = totalTension==0 ? 0 : inputTensions.Count(t=>semis.Contains(t));
 266            double tensionPresence = totalTension==0 ? 1.0 : coveredTension / (double)totalTension;
 267            double exactCoverBonus = sharedRatio==1 ? 2 : 0;
 268            double priority = exactCoverBonus + tensionPresence + sharedRatio;
 269            ranked.Add((s, new AlternativeScaleRankInfo(s.Name, sharedRatio, tensionPresence, shared, coveredTension, to
 270        }
 271        var ordered = ranked.OrderByDescending(r=>r.Info.Priority).ThenByDescending(r=>r.Info.SharedCount).ToList();
 272        return (ordered.Select(r=>r.Scale).ToList(), ordered.Select(r=>r.Info).ToList());
 273    }
 274
 275
 276    // JSON DTO (循環参照 / 冗長オブジェクト防止)
 277    public record struct ChordCandidateDto(
 278        string Root,
 279        string Symbol,
 280        double TotalScore,
 281        double CompletionRatio,
 282        double ScaleFitScore,
 283        double AvoidPenalty,
 284        double ColorBoost,
 285        IReadOnlyList<int> MissingCore,
 286        IReadOnlyList<int> MissingTension,
 287        IReadOnlyList<string> MissingNames,
 288        string? PrimaryScale,
 289        IReadOnlyList<string> AlternativeScales,
 290        IReadOnlyList<AlternativeScaleRankInfo> AlternativeScaleRanks,
 291        ScaleDetailDto? ScaleDetail
 292    );
 293
 294    public record struct ScaleDetailDto(
 295        string ScaleName,
 296        double RawCoverage,
 297        double TensionCoverage,
 298        double CoreCoverage,
 299        IReadOnlyList<int> Covered,
 300        IReadOnlyList<int> Uncovered,
 301        IReadOnlyList<int> AvoidNotes,
 302        IReadOnlyList<int> ColorNotes,
 303        IReadOnlyList<int> PresentTensions,
 304        IReadOnlyList<int> MissingTensions,
 305        IReadOnlyList<int> PresentCores,
 306        IReadOnlyList<int> MissingCores,
 307        IReadOnlyList<string> AlternativeScaleNames
 308    );
 309
 310    public static ChordCandidateDto ToDto(this ChordCandidate c)
 311        => new(
 312            c.RootName,
 313            c.Formula.Symbol,
 314            c.TotalScore,
 315            c.CompletionRatio,
 316            c.ScaleFitScore,
 317            c.AvoidPenalty,
 318            c.ColorBoost,
 319            c.MissingCoreIntervals,
 320            c.MissingTensionIntervals,
 321            c.MissingIntervalNames,
 322            c.ScaleName,
 323            c.AlternativeScales.Select(s=>s.Name).ToList(),
 324            c.AlternativeScaleRanks,
 325            c.ScaleDetail.HasValue ? new ScaleDetailDto(
 326                c.ScaleDetail.Value.ScaleName,
 327                c.ScaleDetail.Value.RawCoverage,
 328                c.ScaleDetail.Value.TensionCoverage,
 329                c.ScaleDetail.Value.CoreCoverage,
 330                c.ScaleDetail.Value.Covered,
 331                c.ScaleDetail.Value.Uncovered,
 332                c.ScaleDetail.Value.AvoidNotes,
 333                c.ScaleDetail.Value.ColorNotes,
 334                c.ScaleDetail.Value.PresentTensions,
 335                c.ScaleDetail.Value.MissingTensions,
 336                c.ScaleDetail.Value.PresentCores,
 337                c.ScaleDetail.Value.MissingCores,
 338                c.ScaleDetail.Value.AlternativeScaleNames
 339            ) : null
 340        );
 341
 342    public static IEnumerable<ChordCandidateDto> ToDtos(this IEnumerable<ChordCandidate> source)
 343        => source.Select(c=>c.ToDto()).ToList();
 344
 345    private static double ScaleFitScore(ModalScale scale, int[] normalizedSet, out double avoidPenalty, out double color
 346    {
 347        var semis = scale.GetSemitoneSet();
 348        int covered = normalizedSet.Count(semis.Contains);
 349        bool hasMajorThird = normalizedSet.Contains(4);
 350        bool hasNatural11 = normalizedSet.Contains(5);
 351        bool hasSharp11 = normalizedSet.Contains(6);
 352        bool hasMinorThird = normalizedSet.Contains(3);
 353        bool hasMinorSeventh = normalizedSet.Contains(10);
 354        bool hasMajorSix = normalizedSet.Contains(9);
 355        bool hasMinorSix = normalizedSet.Contains(8);
 356        avoidPenalty = 0.0;
 357        colorBoost = 0.0;
 358        if (hasMajorThird && hasNatural11 && !hasSharp11 && scale.Name.Contains("Ionian", StringComparison.OrdinalIgnore
 359            avoidPenalty += 0.2;
 360        if (hasSharp11 && scale.Name.Contains("Lydian", StringComparison.OrdinalIgnoreCase))
 361            colorBoost += 0.15;
 362        if (hasMinorThird && hasMinorSeventh && hasNatural11)
 363        {
 364            if (scale.Name.Contains("Dorian", StringComparison.OrdinalIgnoreCase) && hasMajorSix)
 365                colorBoost += 0.12;
 366            if (scale.Name.Contains("Aeolian", StringComparison.OrdinalIgnoreCase) && hasMajorSix)
 367                avoidPenalty += 0.15;
 368            if (scale.Name.Contains("Dorian", StringComparison.OrdinalIgnoreCase) && hasMinorSix)
 369                avoidPenalty += 0.12;
 370        }
 371        if (hasMajorThird && hasMinorSeventh)
 372        {
 373            if (hasMajorSix && scale.Name.Contains("Mixolydian", StringComparison.OrdinalIgnoreCase))
 374                colorBoost += 0.1;
 375            if (hasMinorSix && scale.Name.Contains("Mixolydian", StringComparison.OrdinalIgnoreCase))
 376                avoidPenalty += 0.1;
 377        }
 378        return covered / (double)normalizedSet.Length;
 379    }
 380
 381    private static string ToIntervalDisplayName(int semitone)
 382    {
 383        int s = ((semitone % 12) + 12) % 12;
 384        return s switch
 385        {
 386            1 => "♭9",
 387            2 => "9",
 388            3 => "m3",
 389            4 => "M3",
 390            5 => "11",
 391            6 => "#11",
 392            7 => "5",
 393            8 => "♭13",
 394            9 => "13",
 395            10 => "♭7",
 396            11 => "M7",
 397            _ => s.ToString()
 398        };
 399    }
 400}