char-slop/chordpad.vibed.software

git clone https://git.t4t.associates/char-slop/chordpad.vibed.software

Charlotte Sominitial commit3c7bc6e

main
4.0 KiB98 linesraw
1const ALTERED: Record<number, [name: string, degree: number]> = {
2  1: ["b9", 9],
3  3: ["#9", 9],
4  6: ["#11", 11],
5  8: ["b13", 13],
6};
7
8/**
9 * name a set of intervals above a root, as a chord suffix that `parseChord` reads back, along
10 * with a cost: how unusual that reading is. names may leave out notes they imply (the fifth,
11 * and the 9th under an 11th or 13th) but never include ones that aren't there
12 */
13export function describeTones(
14  intervals: Iterable<number>,
15): { suffix: string; cost: number } | undefined {
16  const tones = new Set([...intervals].map(t => ((t % 12) + 12) % 12));
17  if (!tones.delete(0)) return undefined;
18  if (tones.size < 2) {
19    return tones.size === 1 && tones.has(7) ? { suffix: "5", cost: 0.6 } : undefined;
20  }
21  const take = (t: number) => tones.delete(t);
22
23  const third = take(4) ? "major" : take(3) ? "minor" : undefined;
24  const seventh = take(10) ? 10 : take(11) ? 11 : undefined;
25  let fifth = take(7) ? 7 : undefined;
26  // without a fifth, a tritone in an extended chord is its ♯11, not a flattened fifth
27  const extended = seventh !== undefined && (tones.has(2) || tones.has(9));
28  if (fifth === undefined && third === "minor" && take(6)) fifth = 6;
29  else if (fifth === undefined && third === "major" && take(8)) fifth = 8;
30  else if (fifth === undefined && third === "major" && !extended && take(6)) fifth = 6;
31  const dim7 = !seventh && third === "minor" && fifth === 6 && take(9);
32  const sus = third ? undefined : take(5) ? "sus4" : take(2) ? "sus2" : undefined;
33  const six = !seventh && !dim7 && take(9);
34
35  const natural = new Set<number>();
36  const altered: [name: string, degree: number][] = [];
37  for (const t of tones) {
38    if (t === 2 || t === 5 || t === 9) natural.add(t === 2 ? 9 : t === 5 ? 11 : 13);
39    else if (
40      t in ALTERED &&
41      (t !== 3 || third === "major") &&
42      (t < 6 || (fifth !== 6 && fifth !== 8))
43    ) {
44      altered.push(ALTERED[t]);
45    } else return undefined;
46  }
47
48  let cost = 0;
49  let suffix = "";
50  const alterations: string[] = [];
51
52  if (dim7) [suffix, cost] = ["dim7", 0.6];
53  else if (third === "minor" && fifth === 6 && !seventh) [suffix, cost] = ["dim", 0.3];
54  else if (third === "major" && fifth === 8 && !seventh) [suffix, cost] = ["aug", 0.5];
55  else {
56    suffix = third === "minor" ? "m" : "";
57    if (seventh) {
58      // the highest natural extension names the chord, implying the 9th beneath it. an
59      // alteration replaces its natural when read back, so an altered degree can't be implied
60      const clashes = (degree: number) => altered.some(([, d]) => d === degree);
61      const top = [13, 11, 9].find(d => natural.has(d) && !clashes(d)) ?? 7;
62      suffix += (seventh === 11 ? (third === "minor" ? "Maj" : "maj") : "") + top;
63      cost += (seventh === 11 ? 0.2 : 0.1) + (top === 9 ? 0.3 : top > 9 ? 0.5 : 0);
64      natural.delete(top);
65      if (top > 9 && !clashes(9)) natural.delete(9);
66    }
67    if (fifth === 6) alterations.push("b5");
68    if (fifth === 8) alterations.push("#5");
69    cost += fifth === 6 ? (third === "minor" ? 0.3 : 0.8) : fifth === 8 ? 0.5 : 0;
70  }
71  // a missing fifth is routine in a seventh chord, and suspicious in a triad
72  if (fifth === undefined) cost += seventh ? 0.2 : 1.2;
73
74  if (six) {
75    suffix += natural.delete(9) ? "6/9" : "6";
76    cost += suffix.endsWith("/9") ? 0.7 : 0.3;
77  }
78
79  if (sus) {
80    alterations.push(sus);
81    cost += 0.4;
82  } else if (!third) {
83    alterations.push("no3");
84    cost += 1;
85  }
86
87  // on a seventh chord alterations are written bare ("7♭9"), on anything else they're added
88  const bare = seventh !== undefined || dim7;
89  for (const [name] of altered) alterations.push(bare ? name : "add" + name);
90  cost += altered.length * (bare ? 0.6 : 1.5);
91  // naturals come last, so an alteration of the same degree can't cancel them when read back
92  for (const degree of natural) alterations.push("add" + degree);
93  cost += natural.size * 0.4;
94
95  // a bare "b5" would read as part of the root's name: "Cb5"
96  const tail = alterations.join("");
97  return { suffix: suffix || !/^[b#]/.test(tail) ? suffix + tail : `(${tail})`, cost };
98}