char-slop/chordpad.vibed.software

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

Charlotte Somcolor all non-diatonic chords (even applied functional harmony)c0bb435

main
9.1 KiB216 linesraw
1import { type Chord, chordPcs, degreeChord, ROMANS } from "./chord.ts";
2import { type Key, MODES, type ModeId, modesFrom } from "./key.ts";
3import { mod, pcOf, plainestTonic, spellDegree } from "./pitch.ts";
4
5const ROMAN_SUFFIX: Record<string, string> = {
6  m: "",
7  dim: "°",
8  aug: "+",
9  m7: "7",
10  m7b5: "ø7",
11  dim7: "°7",
12  mMaj7: "maj7",
13  m6: "6",
14  madd9: "add9",
15  m9: "9",
16  m11: "11",
17  m13: "13",
18};
19
20// which degree each interval above the tonic counts as: its usual one (the ♭2 ♭3 ♯4 ♭6 ♭7 of
21// modal mixture), then the other a chord's spelling might ask for. the accidentals are for the
22// major scale; a numeral's own accidental comes from the key's mode
23// prettier-ignore
24const DEGREES: [degree: number, acc: number][][] = [
25  [[0, 0]], [[1, -1], [0, 1]], [[1, 0]], [[2, -1], [1, 1]], [[2, 0]], [[3, 0]],
26  [[3, 1], [4, -1]], [[4, 0]], [[5, -1], [4, 1]], [[5, 0]], [[6, -1], [5, 1]], [[6, 0]],
27];
28
29/**
30 * roman numeral counted in the key's mode, as Hooktheory does, in a form `parseChord` reads back:
31 * in A minor, C is III, G♯ is ♯VII. it goes by pitch, so G♯m in A♭ minor is i. the spelling only
32 * has a say where both names are in real use: the tritone (F♯ is ♯IV in C, G♭ is ♭V), and
33 * diminished chords, which are named for where they lead (C♯°7 is ♯i°7, on its way to ii)
34 */
35export function romanOf(chord: Chord, key: Key): string {
36  const interval = mod(pcOf(chord.root) - pcOf(key.tonic), 12);
37  const names = DEGREES[interval];
38  const diminished =
39    chord.tones.includes(3) && chord.tones.includes(6) && !chord.tones.includes(7);
40  const lettered = mod(chord.root.letter - key.tonic.letter, 7);
41  const spelled = (interval === 6 || diminished) && names.find(([d]) => d === lettered);
42  const [degree] = spelled || names[0];
43  const acc = mod(interval - MODES[key.mode].steps[degree] + 6, 12) - 6;
44  const lower = chord.tones.includes(3) && !chord.tones.includes(4);
45  const numeral = lower ? ROMANS[degree].toLowerCase() : ROMANS[degree];
46  const suffix =
47    ROMAN_SUFFIX[chord.suffix] ?? (lower ? chord.suffix.replace(/^m(?!aj)/, "") : chord.suffix);
48  return (acc > 0 ? "#" : "b").repeat(Math.abs(acc)) + numeral + suffix;
49}
50
51export const prettyRoman = (roman: string): string =>
52  roman
53    .replace(/^[b#]+/, a => a.replaceAll("b", "♭").replaceAll("#", "♯"))
54    .replace(/b(?=\d)/g, "♭")
55    .replace(/#(?=\d)/g, "♯");
56
57/**
58 * where a chord's notes come from, relative to the key: the key itself, the other keys on its
59 * axis (`vertex`), or the parallel mode it'd be borrowed from. and, apart from that, what it does:
60 * whether it's a dominant leading into a chord of the key (`secondary`, named for what it leads
61 * to). E7 on its way to Am in C does both: its G♯ is the sharp vertex's, and it's a V7/vi
62 */
63export type Analysis = {
64  roman: string;
65  diatonic: boolean;
66  vertex?: Vertex;
67  borrowedFrom?: ModeId;
68  secondary?: string;
69};
70
71/** `next`, the chord that follows, tells a dominant that resolves from one that only looks like one */
72export function analyze(chord: Chord, key: Key, next?: Chord): Analysis {
73  const roman = romanOf(chord, key);
74  const pcs = chordPcs(chord);
75  const inKey = (k: Key) =>
76    pcs.every(p => MODES[k.mode].steps.includes(mod(p - pcOf(k.tonic), 12)));
77  if (inKey(key)) return { roman, diatonic: true };
78
79  // a vertex that's only the parallel major or minor goes by that name, as plainer
80  const vertex = vertices(key).find(v => !v.parallel && inKey(v.key))?.vertex;
81  const borrowedFrom = modesFrom(key.mode).find(mode => inKey({ tonic: key.tonic, mode }));
82  const dominant = dominantFunction(chord, key, next);
83  return {
84    roman,
85    diatonic: false,
86    ...(vertex && { vertex }),
87    ...(borrowedFrom && { borrowedFrom }),
88    ...(dominant?.name && { secondary: dominant.name }),
89  };
90}
91
92/**
93 * where a chord's notes come from, the one source worth pointing out, as relative axis
94 * interchange has it: the key; a parallel mode, as modal interchange is the plainer explanation
95 * (vii7 in C is lydian's, though vii9 can only be the sharp vertex's ii9); the sharp or flat
96 * vertex; locrian, which is heard as a vertex where one fits (♭iii in minor is the flat vertex's
97 * i); and only then the inverse vertex, the furthest from the key
98 */
99export function origin(a: Analysis): "home" | Vertex | ModeId | undefined {
100  if (a.diatonic) return "home";
101  if (a.borrowedFrom && a.borrowedFrom !== "locrian") return a.borrowedFrom;
102  if (a.vertex === "sharp" || a.vertex === "flat") return a.vertex;
103  return a.borrowedFrom ?? a.vertex;
104}
105
106/**
107 * a chord as a dominant leading into one of the key's chords: a fifth above it, or (as its
108 * tritone substitute) a semitone above. it only does when it's followed there: a seventh makes a
109 * chord look like a dominant, but where it goes makes it one. D7 in C minor that slides down to
110 * D♭7 is lydian's II7, not a V7/v; B♭7 in C is as often a ♭VII7 back home as on its way to Am.
111 * and a major V of the key itself is borrowed, from the parallel major or harmonic minor: there's
112 * no other chord it's leading the key into
113 */
114function dominantFunction(chord: Chord, key: Key, next?: Chord): { name: string } | undefined {
115  const t = chord.tones;
116  // a major third (with a ♯9 or not), or a suspended fourth instead of any third
117  const third = t.includes(4) || (t.includes(5) && !t.includes(3));
118  const seventh = third && t.includes(10) && !t.includes(11);
119  if (!seventh && t.join() !== "0,4,7") return undefined;
120
121  const root = mod(pcOf(chord.root) - pcOf(key.tonic), 12);
122  const resolves = (to: number) => !!next && mod(pcOf(next.root) - pcOf(key.tonic), 12) === to;
123  for (const [above, sub] of [
124    [7, false],
125    [1, true],
126  ] as const) {
127    if (sub && !seventh) continue;
128    const to = mod(root - above, 12);
129    const degree = MODES[key.mode].steps.indexOf(to);
130    if (degree < 0) continue;
131    const target = degreeChord(key, key.mode, degree, false);
132    if (target.suffix === "dim") continue;
133    const leads = resolves(to) && (sub || degree !== 0);
134    if (!leads) continue;
135    const name = `${sub ? "subV" : "V"}${seventh ? "7" : ""}`;
136    return { name: degree ? `${name}/${romanOf(target, key)}` : name };
137  }
138  return undefined;
139}
140
141export type PaletteEntry = { chord: Chord; symbol: string; diatonic: boolean };
142
143/**
144 * how a palette row's scale relates to the key. the vertices are the key's own scale moved a
145 * minor third up (flat) or down (sharp), or a tritone (inverse): the other keys on its axis
146 */
147export type Relation = "home" | "sharp" | "flat" | "inverse" | "parallel";
148export type PaletteRow = {
149  key: Key;
150  relation: Relation;
151  entries: PaletteEntry[];
152  /** for a parallel mode, the vertex it's the same scale as */
153  vertex?: Vertex;
154};
155
156export type Vertex = "sharp" | "flat" | "inverse";
157
158/**
159 * each vertex's tonic as a degree above the key's, and the semitones to it. named for where they
160 * are on the circle of fifths: a minor third up is three steps flatwards (C to E♭), and down is
161 * three steps sharpwards (C to A)
162 */
163const VERTICES: [vertex: Vertex, degree: number, semis: number][] = [
164  ["flat", 2, 3],
165  ["sharp", 5, 9],
166  // a diminished fifth rather than an augmented fourth, when both are as plain
167  ["inverse", 4, 6],
168];
169
170/**
171 * the other keys on the key's axis: its own scale a minor third up, down, and a tritone away.
172 * in major and minor keys, one of them is no more than the parallel minor or major (C major's
173 * flat vertex, E♭ major, has the notes of C minor), which is said to be its `parallel`
174 */
175export function vertices(home: Key): { vertex: Vertex; key: Key; parallel?: ModeId }[] {
176  const steps = MODES[home.mode].steps;
177  const pcs = (k: Key) => MODES[k.mode].steps.map(s => mod(pcOf(k.tonic) + s, 12)).sort();
178  return VERTICES.map(([vertex, degree, semis]) => {
179    const key = {
180      tonic: plainestTonic(spellDegree(home.tonic, degree, semis), steps),
181      mode: home.mode,
182    };
183    const parallel = (["ionian", "aeolian"] as const).find(
184      mode => mode !== home.mode && pcs({ tonic: home.tonic, mode }).join() === pcs(key).join(),
185    );
186    return { vertex, key, ...(parallel && { parallel }) };
187  });
188}
189
190/** the key's chords, then its vertices', then its parallel modes' (nearest first) */
191export function palette(home: Key, sevenths: boolean): PaletteRow[] {
192  const entries = (key: Key) =>
193    [0, 1, 2, 3, 4, 5, 6].map(degree => {
194      const chord = degreeChord(key, key.mode, degree, sevenths);
195      return { chord, symbol: romanOf(chord, home), diatonic: analyze(chord, home).diatonic };
196    });
197
198  const axis = vertices(home);
199  return [
200    { key: home, relation: "home", entries: entries(home) },
201    // the vertex that's the parallel major or minor, as that, keeping its place on the axis
202    ...axis.map(({ vertex, key, parallel }): PaletteRow => {
203      if (!parallel) return { key, relation: vertex, entries: entries(key) };
204      const same = { tonic: home.tonic, mode: parallel };
205      return { key: same, relation: "parallel", vertex, entries: entries(same) };
206    }),
207    ...modesFrom(home.mode)
208      .slice(1)
209      .filter(mode => !axis.some(v => v.parallel === mode))
210      .map(mode => ({
211        key: { tonic: home.tonic, mode },
212        relation: "parallel" as const,
213        entries: entries({ tonic: home.tonic, mode }),
214      })),
215  ];
216}