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
21.5 KiB570 linesraw
1import { assert, assertEquals } from "@std/assert";
2import { analyze, origin, palette, romanOf } from "./analysis.ts";
3import {
4  chordMatches,
5  chordName,
6  chordPcs,
7  detectChord,
8  detectChords,
9  normalizeTones,
10  parseChord,
11  prettySymbol,
12} from "./chord.ts";
13import { describeTones } from "./describe.ts";
14import { type Key, MODES, type ModeId, stepInScale } from "./key.ts";
15import { parseNote, pcOf, spellPc } from "./pitch.ts";
16import { invertVoicing, voiceChord } from "./voicing.ts";
17
18const key = (tonic: string, mode: ModeId = "ionian"): Key => ({
19  tonic: parseNote(tonic)!.note,
20  mode,
21});
22const C = key("C");
23
24const name = (src: string, k = C) => {
25  const chord = parseChord(src, k);
26  return chord && chordName(chord);
27};
28const pcs = (src: string, k = C) => {
29  const chord = parseChord(src, k);
30  return chord && chordPcs(chord);
31};
32
33Deno.test("letter chord symbols in their many spellings", () => {
34  assertEquals(name("C"), "C");
35  assertEquals(name("am"), "Am");
36  assertEquals(name("Cmin7"), "Cm7");
37  assertEquals(name("C-7"), "Cm7");
38  assertEquals(name("C-7b5"), "Cm7♭5");
39  assertEquals(name("Cø"), "Cm7♭5");
40  assertEquals(name("C°7"), "Cdim7");
41  assertEquals(name("CΔ"), "Cmaj7");
42  assertEquals(name("CM7"), "Cmaj7");
43  assertEquals(name("Cmaj"), "C");
44  assertEquals(name("Cm(maj7)"), "CmMaj7");
45  assertEquals(name("C+"), "Caug");
46  assertEquals(name("Bbmaj9"), "B♭maj9");
47  assertEquals(name("F#m7/C#"), "F♯m7/C♯");
48  assertEquals(name("C6/9"), "C6/9");
49  assertEquals(name("C6/9/E"), "C6/9/E");
50  assertEquals(name("Csus"), "Csus4");
51  assertEquals(name("C7sus4"), "C7sus4");
52  assertEquals(name("C/C"), "C");
53  assertEquals(pcs("C7(b9,#11)"), [0, 1, 4, 6, 7, 10]);
54  assertEquals(pcs("C7alt"), [0, 1, 3, 4, 6, 8, 10]);
55  assertEquals(pcs("C/D"), [0, 2, 4, 7]);
56});
57
58Deno.test("rejects nonsense", () => {
59  for (const bad of ["", "H", "Cfoo", "C7x", "8", "vii/"]) {
60    assertEquals(parseChord(bad, C), undefined, bad);
61  }
62});
63
64Deno.test("every named chord quality parses and detects as itself", () => {
65  // prettier-ignore
66  const qualities = [
67    "",
68    "m",
69    "dim",
70    "aug",
71    "sus4",
72    "sus2",
73    "5",
74    "6",
75    "m6",
76    "6/9",
77    "add9",
78    "madd9",
79    "7",
80    "maj7",
81    "m7",
82    "m7b5",
83    "dim7",
84    "mMaj7",
85    "7sus4",
86    "7b5",
87    "7#5",
88    "maj7#5",
89    "9",
90    "maj9",
91    "m9",
92    "9sus4",
93    "7b9",
94    "7#9",
95    "maj7#11",
96    "9#11",
97    "7b9#11",
98    "11",
99    "m11",
100    "maj11",
101    "13",
102    "maj13",
103    "m13",
104    "13b9",
105    "13#11",
106    "7b13",
107  ];
108  for (const suffix of qualities) {
109    const chord = parseChord("C" + suffix, C)!;
110    assertEquals(chordName(chord), "C" + prettySymbol(suffix), suffix);
111    const midis = chordPcs(chord).map(pc => 60 + pc);
112    assertEquals(chordName(detectChord(midis, C)!), "C" + prettySymbol(suffix), suffix);
113  }
114});
115
116Deno.test("roman numerals are relative to the major scale of the key", () => {
117  assertEquals(name("bVI"), "A♭");
118  assertEquals(name("iv"), "Fm");
119  assertEquals(name("bVIImaj7"), "B♭maj7");
120  assertEquals(name("viiø7"), "Bm7♭5");
121  assertEquals(name("vii°7"), "Bdim7");
122  assertEquals(name("I/E"), "C/E");
123  assertEquals(name("bVI", key("Eb")), "C♭");
124  assertEquals(name("ii7", key("F#")), "G♯m7");
125  assertEquals(name("V", key("A", "aeolian")), "E");
126});
127
128Deno.test("roman numerals go by pitch, whatever the spelling", () => {
129  const roman = (src: string, k: Key) => romanOf(parseChord(src, k)!, k);
130  assertEquals(roman("G#m", key("Ab", "dorian")), "i");
131  assertEquals(roman("Abm", key("G#", "dorian")), "i");
132  assertEquals(roman("C#7", key("Db")), "I7");
133  assertEquals(roman("F#", key("Gb", "lydian")), "I");
134  assertEquals(roman("A#m7", key("Bb", "aeolian")), "i7");
135  assertEquals(roman("C#m", key("Ab", "dorian")), "iv");
136  assertEquals(roman("D#m", key("Ab", "dorian")), "v");
137  assertEquals(roman("F#", key("Ab", "dorian")), "VII");
138  // one usual name for most intervals, however they're spelled
139  assertEquals([roman("D#m", C), roman("Ebm", C)], ["biii", "biii"]);
140  assertEquals([roman("Ab", C), roman("G#", C)], ["bVI", "bVI"]);
141  assertEquals([roman("Fb", C), roman("B#", C), roman("Cb", C)], ["III", "I", "VII"]);
142  // but the spelling decides at the tritone, and for diminished chords
143  assertEquals([roman("F#", C), roman("Gb", C)], ["#IV", "bV"]);
144  assertEquals([roman("C#dim7", C), roman("Dbdim7", C)], ["#i°7", "bii°7"]);
145  assertEquals([roman("G#dim", C), roman("Abdim", C)], ["#v°", "bvi°"]);
146});
147
148Deno.test("in other modes, roman numerals count that mode's degrees", () => {
149  const Am = key("A", "aeolian");
150  const roman = (src: string, k: Key) => romanOf(parseChord(src, k)!, k);
151  // what the chords are called
152  assertEquals(
153    ["Am", "Bdim", "C", "Dm", "Em", "F", "G"].map(c => roman(c, Am)),
154    ["i", "ii°", "III", "iv", "v", "VI", "VII"],
155  );
156  assertEquals(
157    [roman("E", Am), roman("G#dim", Am), roman("F#m", Am), roman("D", Am)],
158    ["V", "#vii°", "#vi", "IV"],
159  );
160  assertEquals(
161    [roman("F", key("C", "lydian")), roman("Bb", key("C", "mixolydian"))],
162    ["bIV", "VII"],
163  );
164  assertEquals(roman("Db", key("C", "phrygian")), "II");
165  // and what typing them means
166  assertEquals(
167    ["III", "VI", "VII", "V", "#vi", "bIII"].map(r => name(r, Am)),
168    ["C", "F", "G", "E", "F♯m", "C♭"],
169  );
170  assertEquals(name("VII", key("C", "mixolydian")), "B♭");
171  assertEquals(name("bIV", key("C", "lydian")), "F");
172  // secondaries still count in the major scale of their target: V/III in A minor is G
173  assertEquals(name("V7/III", Am), "G7");
174});
175
176Deno.test("secondary numerals tonicise their target", () => {
177  assertEquals(name("V7/V"), "D7");
178  assertEquals(name("V/ii"), "A");
179  assertEquals(name("vii°7/V"), "F♯dim7");
180  assertEquals(name("V7/IV", key("Bb")), "B♭7");
181});
182
183Deno.test("scale degrees follow the key's mode, and altered degrees borrow", () => {
184  assertEquals(name("1"), "C");
185  assertEquals(name("2"), "Dm");
186  assertEquals(name("57"), "G7");
187  assertEquals(name("17"), "Cmaj7");
188  assertEquals(name("6", key("C", "aeolian")), "A♭");
189  assertEquals(name("b6"), "A♭");
190  assertEquals(name("b7"), "B♭");
191  assertEquals(name("b2"), "D♭");
192  assertEquals(name("#4"), "F♯dim");
193});
194
195Deno.test("chord detection from notes", () => {
196  const detect = (midis: number[], k = C) => {
197    const chord = detectChord(midis, k);
198    return chord && chordName(chord);
199  };
200  assertEquals(detect([48, 64, 67, 70]), "C7");
201  assertEquals(detect([48, 52, 55, 57]), "C6");
202  assertEquals(detect([45, 60, 64, 67]), "Am7");
203  assertEquals(detect([52, 60, 67]), "C/E");
204  assertEquals(detect([48, 52, 58]), "C7");
205  assertEquals(detect([56, 60, 63]), "A♭");
206  assertEquals(detect([61, 65, 68], key("E")), "C♯");
207  assertEquals(detect([60]), undefined);
208  assertEquals(detect([]), undefined);
209});
210
211Deno.test("11ths, 13ths, and chords over a foreign bass", () => {
212  const detect = (midis: number[], k = C) => {
213    const chord = detectChord(midis, k);
214    return chord && chordName(chord);
215  };
216  const alternatives = (midis: number[]) => detectChords(midis, C).map(chordName);
217  // e♭ with d f b♭ over it: a b♭ triad on an e♭ bass, or e♭ maj7 with a 2nd for its 3rd
218  assertEquals(detect([39, 62, 65, 70]), "E♭maj7sus2");
219  assert(alternatives([39, 62, 65, 70]).includes("B♭/E♭"));
220  assertEquals(detect([36, 58, 62, 65]), "C9sus4"); // c + b♭ d f: "B♭/C", properly
221  assertEquals(detect([36, 52, 58, 62, 65]), "C11"); // with the 3rd too
222  assertEquals(detect([36, 52, 58, 62, 69]), "C13");
223  assertEquals(detect([36, 52, 58, 69]), "C13"); // shell voicing: 3rd, 7th, 13th
224  assertEquals(detect([36, 51, 58, 62, 65]), "Cm11");
225  assertEquals(detect([36, 52, 58, 61, 69]), "C13♭9");
226  assertEquals(detect([36, 52, 59, 62, 66]), "Cmaj9♯11");
227  assertEquals(detect([36, 52, 58, 63]), "C7♯9");
228  assertEquals(detect([36, 52, 58, 68]), "C7♯5");
229  assertEquals(detect([36, 55, 58, 62, 64]), "C9");
230  assertEquals(detect([38, 60, 64, 67]), "D9sus4"); // "C/D"
231  assertEquals(detect([36, 51, 55, 56]), "A♭maj7/C");
232});
233
234Deno.test("every set of notes that gets a name reads back as those notes", () => {
235  // all 2048 sets of intervals above a root. a name may imply a fifth, or a 9th under an 11th
236  // or 13th, but must never leave out or add anything else
237  for (let bits = 0; bits < 1 << 11; bits++) {
238    const tones = [0, ...[...Array(11).keys()].filter(i => bits & (1 << i)).map(i => i + 1)];
239    const described = describeTones(tones);
240    if (!described) continue;
241    const back = parseChord("C" + described.suffix, C);
242    assert(back, `C${described.suffix} doesn't parse (for ${tones})`);
243    const extra = back.tones.filter(t => !tones.includes(t));
244    const missing = tones.filter(t => !back.tones.includes(t));
245    assertEquals(missing, [], `C${described.suffix} loses ${missing} of ${tones}`);
246    assert(
247      extra.every(t => t === 7 || t === 2),
248      `C${described.suffix} adds ${extra} to ${tones}`,
249    );
250  }
251});
252
253Deno.test("common shapes all get a name", () => {
254  const named = (tones: number[]) => describeTones(normalizeTones(tones)) !== undefined;
255  for (const tones of [
256    [0, 4, 7],
257    [0, 3, 7],
258    [0, 4, 10],
259    [0, 3, 10],
260    [0, 4, 11],
261    [0, 5, 10],
262    [0, 2, 7, 11],
263    [0, 4, 9, 10],
264    [0, 1, 4, 10],
265  ]) {
266    assert(named(tones), String(tones));
267  }
268});
269
270Deno.test("notes match a chord only with the same pitches and bass", () => {
271  const c = parseChord("C", C)!;
272  assert(chordMatches(c, [48, 64, 67, 72]));
273  assert(!chordMatches(c, [52, 60, 67]));
274  assert(!chordMatches(c, [48, 64, 67, 70]));
275  assert(chordMatches(parseChord("C/E", C)!, [52, 60, 67]));
276});
277
278Deno.test("spelling favours the usual names of chromatic degrees", () => {
279  const spell = (pc: number, k = C) =>
280    chordName({ root: spellPc(pc, k.tonic), tones: [0], suffix: "" });
281  assertEquals(
282    [1, 3, 6, 8, 10].map(pc => spell(pc)),
283    ["D♭", "E♭", "F♯", "A♭", "B♭"],
284  );
285  assertEquals(spell(3, key("E")), "D♯");
286  assertEquals(spell(11, key("Gb")), "C♭");
287  assertEquals(spell(4, key("Gb")), "F♭");
288  assertEquals(spell(7, key("Gb")), "G");
289});
290
291Deno.test(
292  "analysis: the key's axis first, then parallel modes, and secondary dominants",
293  () => {
294    const analysis = (src: string, k = C) => analyze(parseChord(src, k)!, k);
295    assertEquals(analysis("Am"), { roman: "vi", diatonic: true });
296    // ♭VI, iv and ♭VII are in E♭ major, the flat vertex: but that's no more than C minor, so
297    // they're borrowed from that. G♭ major (the inverse) has ♭II
298    assertEquals(analysis("Ab"), { roman: "bVI", diatonic: false, borrowedFrom: "aeolian" });
299    assertEquals(analysis("Fm").vertex, undefined);
300    assertEquals(analysis("Bb").vertex, undefined);
301    assertEquals(analysis("Db").vertex, "inverse");
302    // A major (the sharp vertex) has II and III
303    assertEquals(analysis("D").vertex, "sharp");
304    // E on its own is A major's: it's only a V/vi on its way to Am
305    assertEquals(analysis("E"), { roman: "III", diatonic: false, vertex: "sharp" });
306    // nor is D7, with nothing after it: lydian's II7
307    assertEquals(analysis("D7"), { roman: "II7", diatonic: false, borrowedFrom: "lydian" });
308    assertEquals(analysis("F#dim"), { roman: "#iv°", diatonic: false, borrowedFrom: "lydian" });
309    assertEquals(analysis("Eb+"), {
310      roman: "bIII+",
311      diatonic: false,
312      borrowedFrom: "melodicMinor",
313    });
314    // in a minor key, from its own axis: C minor's are E♭ and G♭ minor, and A minor, which is
315    // C major's notes, so borrowing from that
316    assertEquals(analysis("G", key("C", "aeolian")).borrowedFrom, "ionian");
317    assertEquals(analysis("G", key("C", "aeolian")).vertex, undefined);
318    assertEquals(analysis("Db", key("C", "aeolian")).vertex, "flat");
319  },
320);
321
322Deno.test("palette romans read back as the same chords, in every key and mode", () => {
323  for (const tonic of ["C", "Eb", "F#", "Bb", "Db", "B"]) {
324    for (const mode of Object.keys(MODES) as ModeId[]) {
325      const k = key(tonic, mode);
326      for (const sevenths of [false, true]) {
327        for (const row of palette(k, sevenths)) {
328          for (const { chord, symbol } of row.entries) {
329            const back = parseChord(symbol, k);
330            assertEquals(
331              back && chordPcs(back),
332              chordPcs(chord),
333              `${symbol} in ${tonic} ${mode}`,
334            );
335            assertEquals(back && pcOf(back.root), pcOf(chord.root));
336            assertEquals(back && romanOf(back, k), symbol);
337          }
338        }
339      }
340    }
341  }
342});
343
344Deno.test("what to point out: the near vertices, then parallel modes, then the inverse", () => {
345  const from = (src: string, k = C) => origin(analyze(parseChord(src, k)!, k));
346  assertEquals(
347    ["C", "Am", "G7"].map(c => from(c)),
348    ["home", "home", "home"],
349  );
350  // A♭ is in the parallel minor; D is in the sharp vertex (A major), but lydian explains it
351  assertEquals([from("Ab"), from("Fm"), from("D")], ["aeolian", "aeolian", "lydian"]);
352  // D♭ is in the inverse vertex (G♭) and in phrygian: the mode wins
353  assertEquals(from("Db"), "phrygian");
354  // G♭ is on the inverse vertex and in locrian; B is only on the inverse vertex (G♭ major's C♭)
355  assertEquals([from("Gb"), from("B")], ["locrian", "inverse"]);
356  assertEquals(from("F#dim"), "lydian");
357  assertEquals(from("E+"), undefined);
358});
359
360Deno.test("borrowing is from the parallel major or minor first", () => {
361  const borrowed = (src: string, k: Key) => analyze(parseChord(src, k)!, k).borrowedFrom;
362  // ♭VII in major: aeolian has it as well as mixolydian
363  assertEquals(borrowed("Bb", C), "aeolian");
364  // IV in minor: ionian has it as well as dorian
365  assertEquals(borrowed("F", key("C", "aeolian")), "ionian");
366  // in neither: the nearest other mode
367  assertEquals([borrowed("D", C), borrowed("Db", C)], ["lydian", "phrygian"]);
368  // a mode in neither: its parallel minor (D minor’s ♭VI), then major
369  assertEquals(borrowed("Bb", key("D", "dorian")), "aeolian");
370  assertEquals(borrowed("F#m", key("D", "dorian")), "ionian");
371});
372
373Deno.test("the palette: the key, the vertices of its axis, then its parallel modes", () => {
374  const rows = (k: Key) =>
375    palette(k, false).map(r => `${r.relation} ${chordName(r.entries[0].chord)} ${r.key.mode}`);
376  // the vertex that's only the parallel minor (or major) is that, in its place on the axis
377  assertEquals(rows(key("C")).slice(0, 4), [
378    "home C ionian",
379    "parallel Cm aeolian",
380    "sharp A ionian",
381    "inverse G♭ ionian",
382  ]);
383  assertEquals(palette(key("C"), false)[1].vertex, "flat");
384  assertEquals(rows(key("A", "aeolian")).slice(0, 4), [
385    "home Am aeolian",
386    "flat Cm aeolian",
387    "parallel A ionian",
388    "inverse E♭m aeolian",
389  ]);
390  // and isn't there again among the other modes
391  assertEquals(palette(key("C"), false).filter(r => r.key.mode === "aeolian").length, 1);
392  // where none of them is, they're all there
393  assertEquals(
394    palette(key("D", "dorian"), false)
395      .slice(1, 4)
396      .map(r => r.relation),
397    ["flat", "sharp", "inverse"],
398  );
399  // no double accidentals in anything reached from the key
400  for (const tonic of ["C", "G", "F", "A", "Eb", "B", "F#", "Db"]) {
401    for (const mode of ["ionian", "aeolian", "dorian"] as ModeId[]) {
402      for (const row of palette(key(tonic, mode), true)) {
403        for (const { symbol } of row.entries)
404          assert(!/bb|##/.test(symbol), `${tonic} ${mode}: ${symbol}`);
405      }
406    }
407  }
408  // chords from the vertices are written relative to the key, so they follow it
409  const sharp = palette(key("C"), false)[2].entries.map(e => e.symbol);
410  assertEquals(sharp, ["VI", "vii", "bii", "II", "III", "#iv", "#v°"]);
411});
412
413Deno.test("voicings contain the chord, with the bass at the bottom", () => {
414  for (const src of ["C", "Am7", "F/A", "G13", "Dm7b5", "E7alt", "C5"]) {
415    const chord = parseChord(src, C)!;
416    const notes = voiceChord(chord);
417    const bass = pcOf(chord.bass ?? chord.root);
418    assertEquals(((notes[0] % 12) + 12) % 12, bass, src);
419    assert(
420      notes.every((n, i) => i === 0 || n > notes[0]),
421      src,
422    );
423    const sounding = new Set(notes.map(n => n % 12));
424    const essential = chordPcs(chord).filter(pc => pc !== (pcOf(chord.root) + 7) % 12);
425    assert(
426      essential.every(pc => sounding.has(pc)),
427      `${src}: ${notes}`,
428    );
429  }
430});
431
432Deno.test("auto voicings move smoothly through a progression", () => {
433  let prev: number[] | undefined;
434  for (const src of ["C", "Am", "F", "G7", "C", "Ab", "Bb", "C"]) {
435    const notes = voiceChord(parseChord(src, C)!, prev);
436    if (prev) {
437      const [, ...a] = prev;
438      const [, ...b] = notes;
439      const worst = Math.max(...b.map(x => Math.min(...a.map(y => Math.abs(x - y)))));
440      assert(worst <= 4, `${src}: ${a} -> ${b}`);
441    }
442    prev = notes;
443  }
444});
445
446Deno.test("inverting a voicing keeps its pitch classes and bass", () => {
447  const notes = voiceChord(parseChord("Cmaj7", C)!);
448  const up = invertVoicing(notes, 1);
449  const down = invertVoicing(notes, -1);
450  for (const v of [up, down]) {
451    assertEquals(v[0], notes[0]);
452    assertEquals(new Set(v.map(n => n % 12)), new Set(notes.map(n => n % 12)));
453  }
454  assert(up.at(-1)! > notes.at(-1)!);
455  assert(down.at(-1)! < notes.at(-1)!);
456});
457
458Deno.test("stepping through the scale", () => {
459  const up = (pitch: number, k: Key, by = 1) => stepInScale(pitch, k, by);
460  // C major, from middle C: D, E, … and back
461  assertEquals(
462    [1, 2, 3, 6, 7].map(n => up(60, C, n)),
463    [62, 64, 65, 71, 72],
464  );
465  assertEquals(
466    [-1, -2, -7].map(n => up(60, C, n)),
467    [59, 57, 48],
468  );
469  assertEquals([up(64, C), up(71, C), up(72, C, -1)], [65, 72, 71]);
470  // in the key's mode, from its tonic
471  assertEquals(up(69, key("A", "aeolian"), 2), 72);
472  assertEquals(up(62, key("D", "dorian"), 2), 65);
473  assertEquals(up(68, key("Ab", "ionian"), 3), 73);
474  // out of the scale: keeps its inflection
475  assertEquals([up(61, C), up(61, C, -1), up(68, key("A", "aeolian"))], [63, 60, 70]);
476  // low and high
477  assertEquals([up(0, C), up(1, key("B", "locrian"), -1)], [2, 0]);
478});
479
480Deno.test(
481  "dominants are named for where they lead, and coloured for where their notes are from",
482  () => {
483    const read = (k: Key, progression: string[]) => {
484      const chords = progression.map(s => parseChord(s, k)!);
485      return chords.map((c, i) => {
486        const a = analyze(c, k, chords[i + 1]);
487        return [origin(a), a.secondary].filter(Boolean).join(" ");
488      });
489    };
490    // stardust speedway: a sus dominant into VI, with phrygian's B♭; a tritone sub down into i,
491    // with the flat vertex's A♭; and the ii9, with the parallel major's F♯ and C♯
492    assertEquals(
493      read(key("A", "aeolian"), [
494        "Am9",
495        "C11no3",
496        "Fmaj7",
497        "Bb9add13",
498        "Am9",
499        "Bm9",
500        "Bb9add13",
501        "Am9",
502      ]),
503      ["home", "phrygian V7/VI", "home", "flat subV7", "home", "ionian", "flat subV7", "home"],
504    );
505    // in C, E7 on its way to Am has the sharp vertex's G♯ (A major's: A minor's parallel major),
506    // D7 on its way to G lydian's F♯, and D♭7 on its way home the inverse's D♭ and C♭
507    assertEquals(read(C, ["D7", "G7", "C", "E7", "Am", "Db7", "C"]), [
508      "lydian V7/V",
509      "home",
510      "home",
511      "sharp V7/vi",
512      "home",
513      "inverse subV7",
514      "home",
515    ]);
516    // a dominant only when it goes where it leads, seventh or not: B♭7 back to C is the minor
517    // key's ♭VII7, A that doesn't go on to Dm is the sharp vertex's VI, and in C minor, D7 sliding
518    // down to D♭7 is lydian's II7
519    assertEquals(read(C, ["E", "Am", "Bb7", "C", "A", "F"]), [
520      "sharp V/vi",
521      "home",
522      "aeolian",
523      "home",
524      "sharp",
525      "home",
526    ]);
527    assertEquals(read(key("C", "aeolian"), ["Cm", "Eb7", "D7", "Db7", "Cm"]), [
528      "home",
529      "phrygian",
530      "lydian",
531      "flat subV7",
532      "home",
533    ]);
534    // a minor key's major V is the parallel major's, and needs no name as a dominant
535    assertEquals(read(key("D", "aeolian"), ["Dm", "A", "Dm", "A7", "Dm"]), [
536      "home",
537      "ionian",
538      "home",
539      "ionian",
540      "home",
541    ]);
542  },
543);
544
545// the examples from 8-bit music theory's video on relative axis interchange
546Deno.test("modal interchange first; the axis for what it can't explain", () => {
547  const from = (src: string, k: Key) => origin(analyze(parseChord(src, k)!, k));
548  // "vii minor 7 is modal interchange from lydian, but vii minor 9 is axis interchange"
549  assertEquals([from("Bm7", C), from("Bm9", C)], ["lydian", "sharp"]);
550  // sharp vertex chords in major: kirby's ♯iv minor 9, and VI major 7
551  assertEquals([from("F#m9", C), from("Amaj7", C)], ["sharp", "sharp"]);
552  // stardust speedway, in A minor: ii minor 9 "is pretty much only found in our parallel major";
553  // in a minor key that's the sharp vertex, so it goes by that
554  const am = key("A", "aeolian");
555  assertEquals(from("Bm9", am), "ionian");
556  // fire emblem, in G minor: ♭II "from the slightly darker phrygian", not the flat vertex
557  assertEquals(from("Ab", key("G", "aeolian")), "phrygian");
558  // death mountain, in A minor: ♭iii minor "could technically be thought of as modal interchange
559  // from parallel locrian", but is heard as the flat vertex's tonic
560  assertEquals(from("Cm", am), "flat");
561  // slay the spire, in F minor: A♭ minor from the flat vertex, B minor the inverse's tonic
562  const fm = key("F", "aeolian");
563  assertEquals([from("Abm", fm), from("Bm", fm)], ["flat", "inverse"]);
564  // napple tale, in E♭: C major the sharp vertex's tonic, D major the inverse's IV
565  const eb = key("Eb");
566  assertEquals([from("C", eb), from("D", eb)], ["sharp", "inverse"]);
567  // locrian still comes before the inverse: ♭V maj7 is "most often heard as modal interchange
568  // from parallel locrian"
569  assertEquals(from("Gbmaj7", C), "locrian");
570});