import { assert, assertEquals } from "@std/assert"; import { analyze, origin, palette, romanOf } from "./analysis.ts"; import { chordMatches, chordName, chordPcs, detectChord, detectChords, normalizeTones, parseChord, prettySymbol, } from "./chord.ts"; import { describeTones } from "./describe.ts"; import { type Key, MODES, type ModeId, stepInScale } from "./key.ts"; import { parseNote, pcOf, spellPc } from "./pitch.ts"; import { invertVoicing, voiceChord } from "./voicing.ts"; const key = (tonic: string, mode: ModeId = "ionian"): Key => ({ tonic: parseNote(tonic)!.note, mode, }); const C = key("C"); const name = (src: string, k = C) => { const chord = parseChord(src, k); return chord && chordName(chord); }; const pcs = (src: string, k = C) => { const chord = parseChord(src, k); return chord && chordPcs(chord); }; Deno.test("letter chord symbols in their many spellings", () => { assertEquals(name("C"), "C"); assertEquals(name("am"), "Am"); assertEquals(name("Cmin7"), "Cm7"); assertEquals(name("C-7"), "Cm7"); assertEquals(name("C-7b5"), "Cm7♭5"); assertEquals(name("Cø"), "Cm7♭5"); assertEquals(name("C°7"), "Cdim7"); assertEquals(name("CΔ"), "Cmaj7"); assertEquals(name("CM7"), "Cmaj7"); assertEquals(name("Cmaj"), "C"); assertEquals(name("Cm(maj7)"), "CmMaj7"); assertEquals(name("C+"), "Caug"); assertEquals(name("Bbmaj9"), "B♭maj9"); assertEquals(name("F#m7/C#"), "F♯m7/C♯"); assertEquals(name("C6/9"), "C6/9"); assertEquals(name("C6/9/E"), "C6/9/E"); assertEquals(name("Csus"), "Csus4"); assertEquals(name("C7sus4"), "C7sus4"); assertEquals(name("C/C"), "C"); assertEquals(pcs("C7(b9,#11)"), [0, 1, 4, 6, 7, 10]); assertEquals(pcs("C7alt"), [0, 1, 3, 4, 6, 8, 10]); assertEquals(pcs("C/D"), [0, 2, 4, 7]); }); Deno.test("rejects nonsense", () => { for (const bad of ["", "H", "Cfoo", "C7x", "8", "vii/"]) { assertEquals(parseChord(bad, C), undefined, bad); } }); Deno.test("every named chord quality parses and detects as itself", () => { // prettier-ignore const qualities = [ "", "m", "dim", "aug", "sus4", "sus2", "5", "6", "m6", "6/9", "add9", "madd9", "7", "maj7", "m7", "m7b5", "dim7", "mMaj7", "7sus4", "7b5", "7#5", "maj7#5", "9", "maj9", "m9", "9sus4", "7b9", "7#9", "maj7#11", "9#11", "7b9#11", "11", "m11", "maj11", "13", "maj13", "m13", "13b9", "13#11", "7b13", ]; for (const suffix of qualities) { const chord = parseChord("C" + suffix, C)!; assertEquals(chordName(chord), "C" + prettySymbol(suffix), suffix); const midis = chordPcs(chord).map(pc => 60 + pc); assertEquals(chordName(detectChord(midis, C)!), "C" + prettySymbol(suffix), suffix); } }); Deno.test("roman numerals are relative to the major scale of the key", () => { assertEquals(name("bVI"), "A♭"); assertEquals(name("iv"), "Fm"); assertEquals(name("bVIImaj7"), "B♭maj7"); assertEquals(name("viiø7"), "Bm7♭5"); assertEquals(name("vii°7"), "Bdim7"); assertEquals(name("I/E"), "C/E"); assertEquals(name("bVI", key("Eb")), "C♭"); assertEquals(name("ii7", key("F#")), "G♯m7"); assertEquals(name("V", key("A", "aeolian")), "E"); }); Deno.test("roman numerals go by pitch, whatever the spelling", () => { const roman = (src: string, k: Key) => romanOf(parseChord(src, k)!, k); assertEquals(roman("G#m", key("Ab", "dorian")), "i"); assertEquals(roman("Abm", key("G#", "dorian")), "i"); assertEquals(roman("C#7", key("Db")), "I7"); assertEquals(roman("F#", key("Gb", "lydian")), "I"); assertEquals(roman("A#m7", key("Bb", "aeolian")), "i7"); assertEquals(roman("C#m", key("Ab", "dorian")), "iv"); assertEquals(roman("D#m", key("Ab", "dorian")), "v"); assertEquals(roman("F#", key("Ab", "dorian")), "VII"); // one usual name for most intervals, however they're spelled assertEquals([roman("D#m", C), roman("Ebm", C)], ["biii", "biii"]); assertEquals([roman("Ab", C), roman("G#", C)], ["bVI", "bVI"]); assertEquals([roman("Fb", C), roman("B#", C), roman("Cb", C)], ["III", "I", "VII"]); // but the spelling decides at the tritone, and for diminished chords assertEquals([roman("F#", C), roman("Gb", C)], ["#IV", "bV"]); assertEquals([roman("C#dim7", C), roman("Dbdim7", C)], ["#i°7", "bii°7"]); assertEquals([roman("G#dim", C), roman("Abdim", C)], ["#v°", "bvi°"]); }); Deno.test("in other modes, roman numerals count that mode's degrees", () => { const Am = key("A", "aeolian"); const roman = (src: string, k: Key) => romanOf(parseChord(src, k)!, k); // what the chords are called assertEquals( ["Am", "Bdim", "C", "Dm", "Em", "F", "G"].map(c => roman(c, Am)), ["i", "ii°", "III", "iv", "v", "VI", "VII"], ); assertEquals( [roman("E", Am), roman("G#dim", Am), roman("F#m", Am), roman("D", Am)], ["V", "#vii°", "#vi", "IV"], ); assertEquals( [roman("F", key("C", "lydian")), roman("Bb", key("C", "mixolydian"))], ["bIV", "VII"], ); assertEquals(roman("Db", key("C", "phrygian")), "II"); // and what typing them means assertEquals( ["III", "VI", "VII", "V", "#vi", "bIII"].map(r => name(r, Am)), ["C", "F", "G", "E", "F♯m", "C♭"], ); assertEquals(name("VII", key("C", "mixolydian")), "B♭"); assertEquals(name("bIV", key("C", "lydian")), "F"); // secondaries still count in the major scale of their target: V/III in A minor is G assertEquals(name("V7/III", Am), "G7"); }); Deno.test("secondary numerals tonicise their target", () => { assertEquals(name("V7/V"), "D7"); assertEquals(name("V/ii"), "A"); assertEquals(name("vii°7/V"), "F♯dim7"); assertEquals(name("V7/IV", key("Bb")), "B♭7"); }); Deno.test("scale degrees follow the key's mode, and altered degrees borrow", () => { assertEquals(name("1"), "C"); assertEquals(name("2"), "Dm"); assertEquals(name("57"), "G7"); assertEquals(name("17"), "Cmaj7"); assertEquals(name("6", key("C", "aeolian")), "A♭"); assertEquals(name("b6"), "A♭"); assertEquals(name("b7"), "B♭"); assertEquals(name("b2"), "D♭"); assertEquals(name("#4"), "F♯dim"); }); Deno.test("chord detection from notes", () => { const detect = (midis: number[], k = C) => { const chord = detectChord(midis, k); return chord && chordName(chord); }; assertEquals(detect([48, 64, 67, 70]), "C7"); assertEquals(detect([48, 52, 55, 57]), "C6"); assertEquals(detect([45, 60, 64, 67]), "Am7"); assertEquals(detect([52, 60, 67]), "C/E"); assertEquals(detect([48, 52, 58]), "C7"); assertEquals(detect([56, 60, 63]), "A♭"); assertEquals(detect([61, 65, 68], key("E")), "C♯"); assertEquals(detect([60]), undefined); assertEquals(detect([]), undefined); }); Deno.test("11ths, 13ths, and chords over a foreign bass", () => { const detect = (midis: number[], k = C) => { const chord = detectChord(midis, k); return chord && chordName(chord); }; const alternatives = (midis: number[]) => detectChords(midis, C).map(chordName); // e♭ with d f b♭ over it: a b♭ triad on an e♭ bass, or e♭ maj7 with a 2nd for its 3rd assertEquals(detect([39, 62, 65, 70]), "E♭maj7sus2"); assert(alternatives([39, 62, 65, 70]).includes("B♭/E♭")); assertEquals(detect([36, 58, 62, 65]), "C9sus4"); // c + b♭ d f: "B♭/C", properly assertEquals(detect([36, 52, 58, 62, 65]), "C11"); // with the 3rd too assertEquals(detect([36, 52, 58, 62, 69]), "C13"); assertEquals(detect([36, 52, 58, 69]), "C13"); // shell voicing: 3rd, 7th, 13th assertEquals(detect([36, 51, 58, 62, 65]), "Cm11"); assertEquals(detect([36, 52, 58, 61, 69]), "C13♭9"); assertEquals(detect([36, 52, 59, 62, 66]), "Cmaj9♯11"); assertEquals(detect([36, 52, 58, 63]), "C7♯9"); assertEquals(detect([36, 52, 58, 68]), "C7♯5"); assertEquals(detect([36, 55, 58, 62, 64]), "C9"); assertEquals(detect([38, 60, 64, 67]), "D9sus4"); // "C/D" assertEquals(detect([36, 51, 55, 56]), "A♭maj7/C"); }); Deno.test("every set of notes that gets a name reads back as those notes", () => { // all 2048 sets of intervals above a root. a name may imply a fifth, or a 9th under an 11th // or 13th, but must never leave out or add anything else for (let bits = 0; bits < 1 << 11; bits++) { const tones = [0, ...[...Array(11).keys()].filter(i => bits & (1 << i)).map(i => i + 1)]; const described = describeTones(tones); if (!described) continue; const back = parseChord("C" + described.suffix, C); assert(back, `C${described.suffix} doesn't parse (for ${tones})`); const extra = back.tones.filter(t => !tones.includes(t)); const missing = tones.filter(t => !back.tones.includes(t)); assertEquals(missing, [], `C${described.suffix} loses ${missing} of ${tones}`); assert( extra.every(t => t === 7 || t === 2), `C${described.suffix} adds ${extra} to ${tones}`, ); } }); Deno.test("common shapes all get a name", () => { const named = (tones: number[]) => describeTones(normalizeTones(tones)) !== undefined; for (const tones of [ [0, 4, 7], [0, 3, 7], [0, 4, 10], [0, 3, 10], [0, 4, 11], [0, 5, 10], [0, 2, 7, 11], [0, 4, 9, 10], [0, 1, 4, 10], ]) { assert(named(tones), String(tones)); } }); Deno.test("notes match a chord only with the same pitches and bass", () => { const c = parseChord("C", C)!; assert(chordMatches(c, [48, 64, 67, 72])); assert(!chordMatches(c, [52, 60, 67])); assert(!chordMatches(c, [48, 64, 67, 70])); assert(chordMatches(parseChord("C/E", C)!, [52, 60, 67])); }); Deno.test("spelling favours the usual names of chromatic degrees", () => { const spell = (pc: number, k = C) => chordName({ root: spellPc(pc, k.tonic), tones: [0], suffix: "" }); assertEquals( [1, 3, 6, 8, 10].map(pc => spell(pc)), ["D♭", "E♭", "F♯", "A♭", "B♭"], ); assertEquals(spell(3, key("E")), "D♯"); assertEquals(spell(11, key("Gb")), "C♭"); assertEquals(spell(4, key("Gb")), "F♭"); assertEquals(spell(7, key("Gb")), "G"); }); Deno.test( "analysis: the key's axis first, then parallel modes, and secondary dominants", () => { const analysis = (src: string, k = C) => analyze(parseChord(src, k)!, k); assertEquals(analysis("Am"), { roman: "vi", diatonic: true }); // ♭VI, iv and ♭VII are in E♭ major, the flat vertex: but that's no more than C minor, so // they're borrowed from that. G♭ major (the inverse) has ♭II assertEquals(analysis("Ab"), { roman: "bVI", diatonic: false, borrowedFrom: "aeolian" }); assertEquals(analysis("Fm").vertex, undefined); assertEquals(analysis("Bb").vertex, undefined); assertEquals(analysis("Db").vertex, "inverse"); // A major (the sharp vertex) has II and III assertEquals(analysis("D").vertex, "sharp"); // E on its own is A major's: it's only a V/vi on its way to Am assertEquals(analysis("E"), { roman: "III", diatonic: false, vertex: "sharp" }); // nor is D7, with nothing after it: lydian's II7 assertEquals(analysis("D7"), { roman: "II7", diatonic: false, borrowedFrom: "lydian" }); assertEquals(analysis("F#dim"), { roman: "#iv°", diatonic: false, borrowedFrom: "lydian" }); assertEquals(analysis("Eb+"), { roman: "bIII+", diatonic: false, borrowedFrom: "melodicMinor", }); // in a minor key, from its own axis: C minor's are E♭ and G♭ minor, and A minor, which is // C major's notes, so borrowing from that assertEquals(analysis("G", key("C", "aeolian")).borrowedFrom, "ionian"); assertEquals(analysis("G", key("C", "aeolian")).vertex, undefined); assertEquals(analysis("Db", key("C", "aeolian")).vertex, "flat"); }, ); Deno.test("palette romans read back as the same chords, in every key and mode", () => { for (const tonic of ["C", "Eb", "F#", "Bb", "Db", "B"]) { for (const mode of Object.keys(MODES) as ModeId[]) { const k = key(tonic, mode); for (const sevenths of [false, true]) { for (const row of palette(k, sevenths)) { for (const { chord, symbol } of row.entries) { const back = parseChord(symbol, k); assertEquals( back && chordPcs(back), chordPcs(chord), `${symbol} in ${tonic} ${mode}`, ); assertEquals(back && pcOf(back.root), pcOf(chord.root)); assertEquals(back && romanOf(back, k), symbol); } } } } } }); Deno.test("what to point out: the near vertices, then parallel modes, then the inverse", () => { const from = (src: string, k = C) => origin(analyze(parseChord(src, k)!, k)); assertEquals( ["C", "Am", "G7"].map(c => from(c)), ["home", "home", "home"], ); // A♭ is in the parallel minor; D is in the sharp vertex (A major), but lydian explains it assertEquals([from("Ab"), from("Fm"), from("D")], ["aeolian", "aeolian", "lydian"]); // D♭ is in the inverse vertex (G♭) and in phrygian: the mode wins assertEquals(from("Db"), "phrygian"); // G♭ is on the inverse vertex and in locrian; B is only on the inverse vertex (G♭ major's C♭) assertEquals([from("Gb"), from("B")], ["locrian", "inverse"]); assertEquals(from("F#dim"), "lydian"); assertEquals(from("E+"), undefined); }); Deno.test("borrowing is from the parallel major or minor first", () => { const borrowed = (src: string, k: Key) => analyze(parseChord(src, k)!, k).borrowedFrom; // ♭VII in major: aeolian has it as well as mixolydian assertEquals(borrowed("Bb", C), "aeolian"); // IV in minor: ionian has it as well as dorian assertEquals(borrowed("F", key("C", "aeolian")), "ionian"); // in neither: the nearest other mode assertEquals([borrowed("D", C), borrowed("Db", C)], ["lydian", "phrygian"]); // a mode in neither: its parallel minor (D minor’s ♭VI), then major assertEquals(borrowed("Bb", key("D", "dorian")), "aeolian"); assertEquals(borrowed("F#m", key("D", "dorian")), "ionian"); }); Deno.test("the palette: the key, the vertices of its axis, then its parallel modes", () => { const rows = (k: Key) => palette(k, false).map(r => `${r.relation} ${chordName(r.entries[0].chord)} ${r.key.mode}`); // the vertex that's only the parallel minor (or major) is that, in its place on the axis assertEquals(rows(key("C")).slice(0, 4), [ "home C ionian", "parallel Cm aeolian", "sharp A ionian", "inverse G♭ ionian", ]); assertEquals(palette(key("C"), false)[1].vertex, "flat"); assertEquals(rows(key("A", "aeolian")).slice(0, 4), [ "home Am aeolian", "flat Cm aeolian", "parallel A ionian", "inverse E♭m aeolian", ]); // and isn't there again among the other modes assertEquals(palette(key("C"), false).filter(r => r.key.mode === "aeolian").length, 1); // where none of them is, they're all there assertEquals( palette(key("D", "dorian"), false) .slice(1, 4) .map(r => r.relation), ["flat", "sharp", "inverse"], ); // no double accidentals in anything reached from the key for (const tonic of ["C", "G", "F", "A", "Eb", "B", "F#", "Db"]) { for (const mode of ["ionian", "aeolian", "dorian"] as ModeId[]) { for (const row of palette(key(tonic, mode), true)) { for (const { symbol } of row.entries) assert(!/bb|##/.test(symbol), `${tonic} ${mode}: ${symbol}`); } } } // chords from the vertices are written relative to the key, so they follow it const sharp = palette(key("C"), false)[2].entries.map(e => e.symbol); assertEquals(sharp, ["VI", "vii", "bii", "II", "III", "#iv", "#v°"]); }); Deno.test("voicings contain the chord, with the bass at the bottom", () => { for (const src of ["C", "Am7", "F/A", "G13", "Dm7b5", "E7alt", "C5"]) { const chord = parseChord(src, C)!; const notes = voiceChord(chord); const bass = pcOf(chord.bass ?? chord.root); assertEquals(((notes[0] % 12) + 12) % 12, bass, src); assert( notes.every((n, i) => i === 0 || n > notes[0]), src, ); const sounding = new Set(notes.map(n => n % 12)); const essential = chordPcs(chord).filter(pc => pc !== (pcOf(chord.root) + 7) % 12); assert( essential.every(pc => sounding.has(pc)), `${src}: ${notes}`, ); } }); Deno.test("auto voicings move smoothly through a progression", () => { let prev: number[] | undefined; for (const src of ["C", "Am", "F", "G7", "C", "Ab", "Bb", "C"]) { const notes = voiceChord(parseChord(src, C)!, prev); if (prev) { const [, ...a] = prev; const [, ...b] = notes; const worst = Math.max(...b.map(x => Math.min(...a.map(y => Math.abs(x - y))))); assert(worst <= 4, `${src}: ${a} -> ${b}`); } prev = notes; } }); Deno.test("inverting a voicing keeps its pitch classes and bass", () => { const notes = voiceChord(parseChord("Cmaj7", C)!); const up = invertVoicing(notes, 1); const down = invertVoicing(notes, -1); for (const v of [up, down]) { assertEquals(v[0], notes[0]); assertEquals(new Set(v.map(n => n % 12)), new Set(notes.map(n => n % 12))); } assert(up.at(-1)! > notes.at(-1)!); assert(down.at(-1)! < notes.at(-1)!); }); Deno.test("stepping through the scale", () => { const up = (pitch: number, k: Key, by = 1) => stepInScale(pitch, k, by); // C major, from middle C: D, E, … and back assertEquals( [1, 2, 3, 6, 7].map(n => up(60, C, n)), [62, 64, 65, 71, 72], ); assertEquals( [-1, -2, -7].map(n => up(60, C, n)), [59, 57, 48], ); assertEquals([up(64, C), up(71, C), up(72, C, -1)], [65, 72, 71]); // in the key's mode, from its tonic assertEquals(up(69, key("A", "aeolian"), 2), 72); assertEquals(up(62, key("D", "dorian"), 2), 65); assertEquals(up(68, key("Ab", "ionian"), 3), 73); // out of the scale: keeps its inflection assertEquals([up(61, C), up(61, C, -1), up(68, key("A", "aeolian"))], [63, 60, 70]); // low and high assertEquals([up(0, C), up(1, key("B", "locrian"), -1)], [2, 0]); }); Deno.test( "dominants are named for where they lead, and coloured for where their notes are from", () => { const read = (k: Key, progression: string[]) => { const chords = progression.map(s => parseChord(s, k)!); return chords.map((c, i) => { const a = analyze(c, k, chords[i + 1]); return [origin(a), a.secondary].filter(Boolean).join(" "); }); }; // stardust speedway: a sus dominant into VI, with phrygian's B♭; a tritone sub down into i, // with the flat vertex's A♭; and the ii9, with the parallel major's F♯ and C♯ assertEquals( read(key("A", "aeolian"), [ "Am9", "C11no3", "Fmaj7", "Bb9add13", "Am9", "Bm9", "Bb9add13", "Am9", ]), ["home", "phrygian V7/VI", "home", "flat subV7", "home", "ionian", "flat subV7", "home"], ); // in C, E7 on its way to Am has the sharp vertex's G♯ (A major's: A minor's parallel major), // D7 on its way to G lydian's F♯, and D♭7 on its way home the inverse's D♭ and C♭ assertEquals(read(C, ["D7", "G7", "C", "E7", "Am", "Db7", "C"]), [ "lydian V7/V", "home", "home", "sharp V7/vi", "home", "inverse subV7", "home", ]); // a dominant only when it goes where it leads, seventh or not: B♭7 back to C is the minor // key's ♭VII7, A that doesn't go on to Dm is the sharp vertex's VI, and in C minor, D7 sliding // down to D♭7 is lydian's II7 assertEquals(read(C, ["E", "Am", "Bb7", "C", "A", "F"]), [ "sharp V/vi", "home", "aeolian", "home", "sharp", "home", ]); assertEquals(read(key("C", "aeolian"), ["Cm", "Eb7", "D7", "Db7", "Cm"]), [ "home", "phrygian", "lydian", "flat subV7", "home", ]); // a minor key's major V is the parallel major's, and needs no name as a dominant assertEquals(read(key("D", "aeolian"), ["Dm", "A", "Dm", "A7", "Dm"]), [ "home", "ionian", "home", "ionian", "home", ]); }, ); // the examples from 8-bit music theory's video on relative axis interchange Deno.test("modal interchange first; the axis for what it can't explain", () => { const from = (src: string, k: Key) => origin(analyze(parseChord(src, k)!, k)); // "vii minor 7 is modal interchange from lydian, but vii minor 9 is axis interchange" assertEquals([from("Bm7", C), from("Bm9", C)], ["lydian", "sharp"]); // sharp vertex chords in major: kirby's ♯iv minor 9, and VI major 7 assertEquals([from("F#m9", C), from("Amaj7", C)], ["sharp", "sharp"]); // stardust speedway, in A minor: ii minor 9 "is pretty much only found in our parallel major"; // in a minor key that's the sharp vertex, so it goes by that const am = key("A", "aeolian"); assertEquals(from("Bm9", am), "ionian"); // fire emblem, in G minor: ♭II "from the slightly darker phrygian", not the flat vertex assertEquals(from("Ab", key("G", "aeolian")), "phrygian"); // death mountain, in A minor: ♭iii minor "could technically be thought of as modal interchange // from parallel locrian", but is heard as the flat vertex's tonic assertEquals(from("Cm", am), "flat"); // slay the spire, in F minor: A♭ minor from the flat vertex, B minor the inverse's tonic const fm = key("F", "aeolian"); assertEquals([from("Abm", fm), from("Bm", fm)], ["flat", "inverse"]); // napple tale, in E♭: C major the sharp vertex's tonic, D major the inverse's IV const eb = key("Eb"); assertEquals([from("C", eb), from("D", eb)], ["sharp", "inverse"]); // locrian still comes before the inverse: ♭V maj7 is "most often heard as modal interchange // from parallel locrian" assertEquals(from("Gbmaj7", C), "locrian"); });