| | 1 | | using System; |
| | 2 | | using System.Collections.Generic; |
| | 3 | | using System.Linq; |
| | 4 | | using MusicTheory.Theory.Chord; |
| | 5 | | using MusicTheory.Theory.Scale; |
| | 6 | | using MusicTheory.Theory.Interval; |
| | 7 | |
|
| | 8 | | namespace MusicTheory.Theory.Analysis; |
| | 9 | |
|
| | 10 | | public 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 | |
|
| | 28 | | public 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 | |
|
| | 51 | | public 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 | |
|
| | 64 | | public record struct AlternativeScaleRankInfo( |
| 0 | 65 | | string ScaleName, |
| 2 | 66 | | double SharedRatio, |
| 2 | 67 | | double TensionPresence, |
| 214 | 68 | | int SharedCount, |
| 0 | 69 | | int CoveredTensionCount, |
| 0 | 70 | | int TotalTensionCount, |
| 0 | 71 | | double ExactCoverBonus, |
| 216 | 72 | | double Priority |
| | 73 | | ); |
| | 74 | |
|
| | 75 | | public 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 | | } |