< Summary

Information
Class: MusicTheory.Theory.Analysis.ChordAnalyzer
Assembly: MusicTheory
File(s): /home/runner/work/MusicTheory/MusicTheory/Theory/Analysis/ChordAnalyzer.cs
Line coverage
84%
Covered lines: 211
Uncovered lines: 40
Coverable lines: 251
Total lines: 400
Line coverage: 84%
Branch coverage
81%
Covered branches: 96
Total branches: 118
Branch coverage: 81.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

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(
 65    string ScaleName,
 66    double SharedRatio,
 67    double TensionPresence,
 68    int SharedCount,
 69    int CoveredTensionCount,
 70    int TotalTensionCount,
 71        double ExactCoverBonus,
 72    double Priority
 73);
 74
 75public static class ChordAnalyzer
 76{
 177    private static readonly string[] RootNames = {"C","C#","D","Eb","E","F","F#","G","Ab","A","Bb","B"};
 78
 179    private static readonly (ChordFormula Formula, string Signature, HashSet<int> IntervalSet)[] FormulaSignatures =
 1480        ChordFormulas.All.Select(f => (f,
 1481            SignatureFromIntervals(f.CoreIntervals.Concat(f.Tensions)),
 9282            f.CoreIntervals.Concat(f.Tensions).Select(i => i.Semitones % 12).Where(s=>s!=0).ToHashSet()
 1483        )).ToArray();
 84
 85    private static string SignatureFromIntervals(IEnumerable<FunctionalInterval> intervals)
 5986        => AnalysisUtils.Signature(intervals.Select(i => i.Semitones));
 87
 188    private static readonly Dictionary<string, ModalScale[]> ScaleHints = new()
 189    {
 190        {"7", new[]{ ExtendedScales.Mixolydian, ExtendedScales.LydianDominant, ExtendedScales.Altered, ExtendedScales.Wh
 191        {"m7", new[]{ ExtendedScales.Dorian, ExtendedScales.Aeolian }},
 192        {"maj7", new[]{ ExtendedScales.Ionian, ExtendedScales.Lydian, ExtendedScales.BebopMajor }},
 193        {"7alt", new[]{ ExtendedScales.Altered }},
 194        {"m7b5", new[]{ ExtendedScales.Locrian, ExtendedScales.DiminishedHalfWhole }},
 195        {"dim7", new[]{ ExtendedScales.DiminishedWholeHalf }},
 196        {"9", new[]{ ExtendedScales.Mixolydian, ExtendedScales.LydianDominant }},
 197        {"13", new[]{ ExtendedScales.Mixolydian, ExtendedScales.LydianDominant, ExtendedScales.BebopDominant }},
 198        {"maj13", new[]{ ExtendedScales.Ionian, ExtendedScales.Lydian }},
 199    };
 100
 101    public static IEnumerable<ChordCandidate> Analyze(IEnumerable<int> pitchClasses, ChordAnalyzerOptions? options = nul
 102    {
 57103        var opt = options ?? new ChordAnalyzerOptions();
 57104        var pcs = pitchClasses.Select(AnalysisUtils.Mod12).Distinct().ToArray();
 59105        if (pcs.Length == 0) return Enumerable.Empty<ChordCandidate>();
 106
 55107        var candidates = new List<ChordCandidate>();
 566108        foreach (var root in pcs)
 109        {
 228110            if (!opt.AllowInversions && root != pcs.Min()) continue;
 106111            var normalized = AnalysisUtils.NormalizeToRoot(pcs, root);
 580112            var sig = AnalysisUtils.Signature(normalized.Where(pc => pc != 0));
 2968113            foreach (var (formula, fullSig, intervalSet) in FormulaSignatures)
 114            {
 1378115                var formulaSig = AnalysisUtils.Signature(intervalSet);
 1378116                var presentSet = sig.Split('-', StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToHashSet();
 117                // 入力 ⊆ フォーミュラ (不足は可)
 1378118                if (!presentSet.All(intervalSet.Contains)) continue;
 119                // コア (除: 完全5度) はすべて含まれること
 1287120                var coreSemis = formula.CoreIntervals.Select(i=>i.Semitones %12).Where(s=>s!=0).ToHashSet();
 737121                var essentialCore = coreSemis.Where(s=>s!=7).ToList();
 187122                if (!essentialCore.All(presentSet.Contains)) continue;
 123
 110124                int coverage = presentSet.Count(intervalSet.Contains);
 110125                double completion = intervalSet.Count == 0 ? 1.0 : coverage / (double)intervalSet.Count;
 110126                if (completion < opt.MinCompletion) continue;
 105127                var (alternativeScales, altRankInfos) = RankAlternativeScales(formula.Symbol, normalized, opt);
 105128                var scale = alternativeScales.FirstOrDefault();
 210129                double avoidPenalty = 0.0; double colorBoost = 0.0;
 105130                var scaleFit = scale == null ? 0.0 : ScaleFitScore(scale, normalized, out avoidPenalty, out colorBoost);
 105131                var missing = intervalSet.Except(presentSet).ToList();
 105132                var coreSet = coreSemis;
 471133                var tensionSet = formula.Tensions.Select(i=>i.Semitones%12).Where(s=>s!=0).ToHashSet();
 105134                var missingCore = missing.Where(coreSet.Contains).ToList();
 105135                var missingTension = missing.Where(tensionSet.Contains).ToList();
 105136                var missingNames = missing.Select(ToIntervalDisplayName).ToList();
 210137                double coreCoverageScore = 0.0; double tensionCoverageScore = 0.0;
 105138                if (opt.CoreCoverageWeight > 0 || opt.TensionCoverageWeight > 0)
 139                {
 77140                    var presentTensionsForScore = tensionSet.Count==0 ? 0 : tensionSet.Count(t => presentSet.Contains(t)
 114141                    var presentCoresForScore = coreSemis.Count==0 ? 0 : coreSemis.Count(c => presentSet.Contains(c));
 29142                    var coreCoverageRatio = coreSemis.Count==0 ? 1.0 : presentCoresForScore / (double)coreSemis.Count;
 29143                    var tensionCoverageRatio = tensionSet.Count==0 ? 1.0 : presentTensionsForScore / (double)tensionSet.
 29144                    coreCoverageScore = coreCoverageRatio * opt.CoreCoverageWeight;
 29145                    tensionCoverageScore = tensionCoverageRatio * opt.TensionCoverageWeight;
 146                }
 105147                double total = (completion * coverage * opt.CoverageWeight)
 105148                               + (scaleFit * opt.ScaleFitWeight)
 105149                               - (avoidPenalty * opt.AvoidPenaltyWeight)
 105150                               + (colorBoost * opt.ColorBoostWeight)
 105151                               + coreCoverageScore + tensionCoverageScore;
 105152                ScaleFitDetail? scaleDetail = null;
 105153                if (scale != null)
 154                {
 98155                    var semis = scale.GetSemitoneSet();
 98156                    var covered = normalized.Where(semis.Contains).ToHashSet();
 511157                    var uncovered = normalized.Where(n => !semis.Contains(n)).ToList();
 98158                    var detailAvoid = new List<int>();
 98159                    var detailColor = new List<int>();
 102160                    if (avoidPenalty > 0 && normalized.Contains(5)) detailAvoid.Add(5);
 98161                    if (colorBoost > 0 && normalized.Contains(6)) detailColor.Add(6);
 464162                    var presentTensions = formula.Tensions.Select(i=>i.Semitones%12).Where(s=>s!=0 && presentSet.Contain
 98163                    var missingTensions = tensionSet.Intersect(missing).ToList();
 98164                    double tensionCoverage = tensionSet.Count==0 ? 1.0 : presentTensions.Count / (double)tensionSet.Coun
 98165                    var presentCores = coreSemis.Where(presentSet.Contains).ToList();
 98166                    var missingCores = coreSemis.Where(missing.Contains).ToList();
 98167                    double coreCoverage = coreSemis.Count==0 ? 1.0 : presentCores.Count / (double)coreSemis.Count;
 98168                    scaleDetail = new ScaleFitDetail(
 98169                        scale.Name,
 98170                        covered.ToList(),
 98171                        uncovered,
 98172                        detailAvoid,
 98173                        detailColor,
 98174                        covered.Count / (double)normalized.Length,
 98175                        avoidPenalty,
 98176                        colorBoost,
 98177                        tensionCoverage,
 98178                        presentTensions,
 98179                        missingTensions,
 98180                        coreCoverage,
 98181                        presentCores,
 98182                        missingCores,
 214183                        alternativeScales.Select(s=>s.Name).ToList()
 98184                    );
 185                }
 105186                candidates.Add(new ChordCandidate(
 105187                    RootNames[root],
 105188                    formula,
 105189                    coverage,
 105190                    completion,
 105191                    scale,
 105192                    alternativeScales,
 105193                    altRankInfos.Cast<AlternativeScaleRankInfo>().ToList(),
 105194                    normalized,
 105195                    formulaSig,
 105196                    sig,
 105197                    scale?.Name,
 105198                    scaleFit,
 105199                    missing,
 105200                    missingNames,
 105201                    missingCore,
 105202                    missingTension,
 105203                    avoidPenalty,
 105204                    colorBoost,
 105205                    total,
 105206                    scaleDetail
 105207                ));
 208            }
 209
 210        }
 55211        if (opt.RankResults)
 46212            return candidates.OrderByDescending(c => c.TotalScore);
 38213        return candidates;
 214    }
 215
 216    public static IEnumerable<ChordCandidate> AnalyzeRanked(IEnumerable<int> pitchClasses, ChordAnalyzerOptions? options
 132217        => Analyze(pitchClasses, options ?? new ChordAnalyzerOptions()).OrderByDescending(c => c.TotalScore);
 218
 219    public static List<ChordCandidate> ToListRanked(IEnumerable<int> pitchClasses, ChordAnalyzerOptions? options = null)
 39220        => AnalyzeRanked(pitchClasses, options).ToList();
 221
 222    private static bool FormulaMatches(string candidateSig, HashSet<int> formulaIntervals)
 223    {
 0224        var candidateSet = candidateSig.Split('-', StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToHashSet();
 225        // 条件: フォーミュラ側の全インターバルが候補内に含まれていれば許容(候補に追加ノート可)
 0226        return formulaIntervals.All(candidateSet.Contains);
 227    }
 228
 229    private static int CoverageScore(string candidateSig, string formulaSig)
 230    {
 0231        var c = candidateSig.Split('-', StringSplitOptions.RemoveEmptyEntries);
 0232        var f = formulaSig.Split('-', StringSplitOptions.RemoveEmptyEntries).ToHashSet();
 0233        return c.Count(f.Contains);
 234    }
 235
 236    private static ModalScale? SuggestScale(string symbol, int[] normalizedSet)
 0237        => SuggestScales(symbol, normalizedSet).FirstOrDefault();
 238
 239    public static IEnumerable<ModalScale> SuggestScales(string symbol, int[] normalizedSet)
 240    {
 116241        if (!ScaleHints.TryGetValue(symbol, out var list)) return Enumerable.Empty<ModalScale>();
 242        // 完全包含するものを全部返し、なければヒントリスト全体を返す
 100243        var matches = list.Where(scale => {
 252244            var semis = scale.GetSemitoneSet();
 1254245            return normalizedSet.All(pc => semis.Contains(pc));
 100246        }).ToList();
 100247        return matches.Any() ? matches : list;
 248    }
 249
 250    private static (List<ModalScale> Scales, List<AlternativeScaleRankInfo> Infos) RankAlternativeScales(string symbol, 
 251    {
 105252        var scales = SuggestScales(symbol, normalizedSet).ToList();
 539253        var normSet = normalizedSet.Where(x=>x!=0).ToHashSet();
 254        // 実入力ベース tension: オプション指定コア集合以外の音をテンション扱い
 105255        var defaultCore = new HashSet<int>{3,4,7,10,11};
 105256        var coreLike = new HashSet<int>(opt.ScaleRankingCoreSemitones ?? defaultCore.ToArray());
 434257        var inputTensions = normSet.Where(n => !coreLike.Contains(n)).ToHashSet();
 105258        int totalTension = inputTensions.Count;
 105259        var ranked = new List<(ModalScale Scale, AlternativeScaleRankInfo Info)>();
 638260        foreach (var s in scales)
 261        {
 214262            var semis = s.GetSemitoneSet();
 214263            int shared = normSet.Count(semis.Contains);
 214264            double sharedRatio = shared / (double)(normSet.Count==0?1:normSet.Count);
 283265            int coveredTension = totalTension==0 ? 0 : inputTensions.Count(t=>semis.Contains(t));
 214266            double tensionPresence = totalTension==0 ? 1.0 : coveredTension / (double)totalTension;
 214267            double exactCoverBonus = sharedRatio==1 ? 2 : 0;
 214268            double priority = exactCoverBonus + tensionPresence + sharedRatio;
 214269            ranked.Add((s, new AlternativeScaleRankInfo(s.Name, sharedRatio, tensionPresence, shared, coveredTension, to
 270        }
 533271        var ordered = ranked.OrderByDescending(r=>r.Info.Priority).ThenByDescending(r=>r.Info.SharedCount).ToList();
 533272        return (ordered.Select(r=>r.Scale).ToList(), ordered.Select(r=>r.Info).ToList());
 273    }
 274
 275
 276    // JSON DTO (循環参照 / 冗長オブジェクト防止)
 277    public record struct ChordCandidateDto(
 1278        string Root,
 1279        string Symbol,
 1280        double TotalScore,
 1281        double CompletionRatio,
 0282        double ScaleFitScore,
 0283        double AvoidPenalty,
 0284        double ColorBoost,
 0285        IReadOnlyList<int> MissingCore,
 0286        IReadOnlyList<int> MissingTension,
 0287        IReadOnlyList<string> MissingNames,
 0288        string? PrimaryScale,
 0289        IReadOnlyList<string> AlternativeScales,
 0290        IReadOnlyList<AlternativeScaleRankInfo> AlternativeScaleRanks,
 2291        ScaleDetailDto? ScaleDetail
 292    );
 293
 294    public record struct ScaleDetailDto(
 1295        string ScaleName,
 0296        double RawCoverage,
 0297        double TensionCoverage,
 0298        double CoreCoverage,
 0299        IReadOnlyList<int> Covered,
 0300        IReadOnlyList<int> Uncovered,
 0301        IReadOnlyList<int> AvoidNotes,
 0302        IReadOnlyList<int> ColorNotes,
 0303        IReadOnlyList<int> PresentTensions,
 0304        IReadOnlyList<int> MissingTensions,
 0305        IReadOnlyList<int> PresentCores,
 0306        IReadOnlyList<int> MissingCores,
 0307        IReadOnlyList<string> AlternativeScaleNames
 308    );
 309
 310    public static ChordCandidateDto ToDto(this ChordCandidate c)
 2311        => new(
 2312            c.RootName,
 2313            c.Formula.Symbol,
 2314            c.TotalScore,
 2315            c.CompletionRatio,
 2316            c.ScaleFitScore,
 2317            c.AvoidPenalty,
 2318            c.ColorBoost,
 2319            c.MissingCoreIntervals,
 2320            c.MissingTensionIntervals,
 2321            c.MissingIntervalNames,
 2322            c.ScaleName,
 3323            c.AlternativeScales.Select(s=>s.Name).ToList(),
 2324            c.AlternativeScaleRanks,
 2325            c.ScaleDetail.HasValue ? new ScaleDetailDto(
 2326                c.ScaleDetail.Value.ScaleName,
 2327                c.ScaleDetail.Value.RawCoverage,
 2328                c.ScaleDetail.Value.TensionCoverage,
 2329                c.ScaleDetail.Value.CoreCoverage,
 2330                c.ScaleDetail.Value.Covered,
 2331                c.ScaleDetail.Value.Uncovered,
 2332                c.ScaleDetail.Value.AvoidNotes,
 2333                c.ScaleDetail.Value.ColorNotes,
 2334                c.ScaleDetail.Value.PresentTensions,
 2335                c.ScaleDetail.Value.MissingTensions,
 2336                c.ScaleDetail.Value.PresentCores,
 2337                c.ScaleDetail.Value.MissingCores,
 2338                c.ScaleDetail.Value.AlternativeScaleNames
 2339            ) : null
 2340        );
 341
 342    public static IEnumerable<ChordCandidateDto> ToDtos(this IEnumerable<ChordCandidate> source)
 0343        => source.Select(c=>c.ToDto()).ToList();
 344
 345    private static double ScaleFitScore(ModalScale scale, int[] normalizedSet, out double avoidPenalty, out double color
 346    {
 98347        var semis = scale.GetSemitoneSet();
 98348        int covered = normalizedSet.Count(semis.Contains);
 98349        bool hasMajorThird = normalizedSet.Contains(4);
 98350        bool hasNatural11 = normalizedSet.Contains(5);
 98351        bool hasSharp11 = normalizedSet.Contains(6);
 98352        bool hasMinorThird = normalizedSet.Contains(3);
 98353        bool hasMinorSeventh = normalizedSet.Contains(10);
 98354        bool hasMajorSix = normalizedSet.Contains(9);
 98355        bool hasMinorSix = normalizedSet.Contains(8);
 98356        avoidPenalty = 0.0;
 98357        colorBoost = 0.0;
 98358        if (hasMajorThird && hasNatural11 && !hasSharp11 && scale.Name.Contains("Ionian", StringComparison.OrdinalIgnore
 4359            avoidPenalty += 0.2;
 98360        if (hasSharp11 && scale.Name.Contains("Lydian", StringComparison.OrdinalIgnoreCase))
 0361            colorBoost += 0.15;
 98362        if (hasMinorThird && hasMinorSeventh && hasNatural11)
 363        {
 0364            if (scale.Name.Contains("Dorian", StringComparison.OrdinalIgnoreCase) && hasMajorSix)
 0365                colorBoost += 0.12;
 0366            if (scale.Name.Contains("Aeolian", StringComparison.OrdinalIgnoreCase) && hasMajorSix)
 0367                avoidPenalty += 0.15;
 0368            if (scale.Name.Contains("Dorian", StringComparison.OrdinalIgnoreCase) && hasMinorSix)
 0369                avoidPenalty += 0.12;
 370        }
 98371        if (hasMajorThird && hasMinorSeventh)
 372        {
 61373            if (hasMajorSix && scale.Name.Contains("Mixolydian", StringComparison.OrdinalIgnoreCase))
 5374                colorBoost += 0.1;
 61375            if (hasMinorSix && scale.Name.Contains("Mixolydian", StringComparison.OrdinalIgnoreCase))
 0376                avoidPenalty += 0.1;
 377        }
 98378        return covered / (double)normalizedSet.Length;
 379    }
 380
 381    private static string ToIntervalDisplayName(int semitone)
 382    {
 162383        int s = ((semitone % 12) + 12) % 12;
 162384        return s switch
 162385        {
 10386            1 => "♭9",
 43387            2 => "9",
 10388            3 => "m3",
 0389            4 => "M3",
 34390            5 => "11",
 10391            6 => "#11",
 12392            7 => "5",
 10393            8 => "♭13",
 33394            9 => "13",
 0395            10 => "♭7",
 0396            11 => "M7",
 0397            _ => s.ToString()
 162398        };
 399    }
 400}

Methods/Properties

.cctor()
SignatureFromIntervals(System.Collections.Generic.IEnumerable`1<MusicTheory.Theory.Interval.FunctionalInterval>)
Analyze(System.Collections.Generic.IEnumerable`1<System.Int32>,System.Nullable`1<MusicTheory.Theory.Analysis.ChordAnalyzerOptions>)
AnalyzeRanked(System.Collections.Generic.IEnumerable`1<System.Int32>,System.Nullable`1<MusicTheory.Theory.Analysis.ChordAnalyzerOptions>)
ToListRanked(System.Collections.Generic.IEnumerable`1<System.Int32>,System.Nullable`1<MusicTheory.Theory.Analysis.ChordAnalyzerOptions>)
FormulaMatches(System.String,System.Collections.Generic.HashSet`1<System.Int32>)
CoverageScore(System.String,System.String)
SuggestScale(System.String,System.Int32[])
SuggestScales(System.String,System.Int32[])
RankAlternativeScales(System.String,System.Int32[],MusicTheory.Theory.Analysis.ChordAnalyzerOptions)
get_Root()
get_Symbol()
get_TotalScore()
get_CompletionRatio()
get_ScaleFitScore()
get_AvoidPenalty()
get_ColorBoost()
get_MissingCore()
get_MissingTension()
get_MissingNames()
get_PrimaryScale()
get_AlternativeScales()
get_AlternativeScaleRanks()
get_ScaleDetail()
get_ScaleName()
get_RawCoverage()
get_TensionCoverage()
get_CoreCoverage()
get_Covered()
get_Uncovered()
get_AvoidNotes()
get_ColorNotes()
get_PresentTensions()
get_MissingTensions()
get_PresentCores()
get_MissingCores()
get_AlternativeScaleNames()
ToDto(MusicTheory.Theory.Analysis.ChordCandidate)
ToDtos(System.Collections.Generic.IEnumerable`1<MusicTheory.Theory.Analysis.ChordCandidate>)
ScaleFitScore(MusicTheory.Theory.Scale.ModalScale,System.Int32[],System.Double&,System.Double&)
ToIntervalDisplayName(System.Int32)