const ALTERED: Record = { 1: ["b9", 9], 3: ["#9", 9], 6: ["#11", 11], 8: ["b13", 13], }; /** * name a set of intervals above a root, as a chord suffix that `parseChord` reads back, along * with a cost: how unusual that reading is. names may leave out notes they imply (the fifth, * and the 9th under an 11th or 13th) but never include ones that aren't there */ export function describeTones( intervals: Iterable, ): { suffix: string; cost: number } | undefined { const tones = new Set([...intervals].map(t => ((t % 12) + 12) % 12)); if (!tones.delete(0)) return undefined; if (tones.size < 2) { return tones.size === 1 && tones.has(7) ? { suffix: "5", cost: 0.6 } : undefined; } const take = (t: number) => tones.delete(t); const third = take(4) ? "major" : take(3) ? "minor" : undefined; const seventh = take(10) ? 10 : take(11) ? 11 : undefined; let fifth = take(7) ? 7 : undefined; // without a fifth, a tritone in an extended chord is its ♯11, not a flattened fifth const extended = seventh !== undefined && (tones.has(2) || tones.has(9)); if (fifth === undefined && third === "minor" && take(6)) fifth = 6; else if (fifth === undefined && third === "major" && take(8)) fifth = 8; else if (fifth === undefined && third === "major" && !extended && take(6)) fifth = 6; const dim7 = !seventh && third === "minor" && fifth === 6 && take(9); const sus = third ? undefined : take(5) ? "sus4" : take(2) ? "sus2" : undefined; const six = !seventh && !dim7 && take(9); const natural = new Set(); const altered: [name: string, degree: number][] = []; for (const t of tones) { if (t === 2 || t === 5 || t === 9) natural.add(t === 2 ? 9 : t === 5 ? 11 : 13); else if ( t in ALTERED && (t !== 3 || third === "major") && (t < 6 || (fifth !== 6 && fifth !== 8)) ) { altered.push(ALTERED[t]); } else return undefined; } let cost = 0; let suffix = ""; const alterations: string[] = []; if (dim7) [suffix, cost] = ["dim7", 0.6]; else if (third === "minor" && fifth === 6 && !seventh) [suffix, cost] = ["dim", 0.3]; else if (third === "major" && fifth === 8 && !seventh) [suffix, cost] = ["aug", 0.5]; else { suffix = third === "minor" ? "m" : ""; if (seventh) { // the highest natural extension names the chord, implying the 9th beneath it. an // alteration replaces its natural when read back, so an altered degree can't be implied const clashes = (degree: number) => altered.some(([, d]) => d === degree); const top = [13, 11, 9].find(d => natural.has(d) && !clashes(d)) ?? 7; suffix += (seventh === 11 ? (third === "minor" ? "Maj" : "maj") : "") + top; cost += (seventh === 11 ? 0.2 : 0.1) + (top === 9 ? 0.3 : top > 9 ? 0.5 : 0); natural.delete(top); if (top > 9 && !clashes(9)) natural.delete(9); } if (fifth === 6) alterations.push("b5"); if (fifth === 8) alterations.push("#5"); cost += fifth === 6 ? (third === "minor" ? 0.3 : 0.8) : fifth === 8 ? 0.5 : 0; } // a missing fifth is routine in a seventh chord, and suspicious in a triad if (fifth === undefined) cost += seventh ? 0.2 : 1.2; if (six) { suffix += natural.delete(9) ? "6/9" : "6"; cost += suffix.endsWith("/9") ? 0.7 : 0.3; } if (sus) { alterations.push(sus); cost += 0.4; } else if (!third) { alterations.push("no3"); cost += 1; } // on a seventh chord alterations are written bare ("7♭9"), on anything else they're added const bare = seventh !== undefined || dim7; for (const [name] of altered) alterations.push(bare ? name : "add" + name); cost += altered.length * (bare ? 0.6 : 1.5); // naturals come last, so an alteration of the same degree can't cancel them when read back for (const degree of natural) alterations.push("add" + degree); cost += natural.size * 0.4; // a bare "b5" would read as part of the root's name: "Cb5" const tail = alterations.join(""); return { suffix: suffix || !/^[b#]/.test(tail) ? suffix + tail : `(${tail})`, cost }; }