import { type Chord, chordPcs, degreeChord, ROMANS } from "./chord.ts"; import { type Key, MODES, type ModeId, modesFrom } from "./key.ts"; import { mod, pcOf, plainestTonic, spellDegree } from "./pitch.ts"; const ROMAN_SUFFIX: Record = { m: "", dim: "°", aug: "+", m7: "7", m7b5: "ø7", dim7: "°7", mMaj7: "maj7", m6: "6", madd9: "add9", m9: "9", m11: "11", m13: "13", }; // which degree each interval above the tonic counts as: its usual one (the ♭2 ♭3 ♯4 ♭6 ♭7 of // modal mixture), then the other a chord's spelling might ask for. the accidentals are for the // major scale; a numeral's own accidental comes from the key's mode // prettier-ignore const DEGREES: [degree: number, acc: number][][] = [ [[0, 0]], [[1, -1], [0, 1]], [[1, 0]], [[2, -1], [1, 1]], [[2, 0]], [[3, 0]], [[3, 1], [4, -1]], [[4, 0]], [[5, -1], [4, 1]], [[5, 0]], [[6, -1], [5, 1]], [[6, 0]], ]; /** * roman numeral counted in the key's mode, as Hooktheory does, in a form `parseChord` reads back: * in A minor, C is III, G♯ is ♯VII. it goes by pitch, so G♯m in A♭ minor is i. the spelling only * has a say where both names are in real use: the tritone (F♯ is ♯IV in C, G♭ is ♭V), and * diminished chords, which are named for where they lead (C♯°7 is ♯i°7, on its way to ii) */ export function romanOf(chord: Chord, key: Key): string { const interval = mod(pcOf(chord.root) - pcOf(key.tonic), 12); const names = DEGREES[interval]; const diminished = chord.tones.includes(3) && chord.tones.includes(6) && !chord.tones.includes(7); const lettered = mod(chord.root.letter - key.tonic.letter, 7); const spelled = (interval === 6 || diminished) && names.find(([d]) => d === lettered); const [degree] = spelled || names[0]; const acc = mod(interval - MODES[key.mode].steps[degree] + 6, 12) - 6; const lower = chord.tones.includes(3) && !chord.tones.includes(4); const numeral = lower ? ROMANS[degree].toLowerCase() : ROMANS[degree]; const suffix = ROMAN_SUFFIX[chord.suffix] ?? (lower ? chord.suffix.replace(/^m(?!aj)/, "") : chord.suffix); return (acc > 0 ? "#" : "b").repeat(Math.abs(acc)) + numeral + suffix; } export const prettyRoman = (roman: string): string => roman .replace(/^[b#]+/, a => a.replaceAll("b", "♭").replaceAll("#", "♯")) .replace(/b(?=\d)/g, "♭") .replace(/#(?=\d)/g, "♯"); /** * where a chord's notes come from, relative to the key: the key itself, the other keys on its * axis (`vertex`), or the parallel mode it'd be borrowed from. and, apart from that, what it does: * whether it's a dominant leading into a chord of the key (`secondary`, named for what it leads * to). E7 on its way to Am in C does both: its G♯ is the sharp vertex's, and it's a V7/vi */ export type Analysis = { roman: string; diatonic: boolean; vertex?: Vertex; borrowedFrom?: ModeId; secondary?: string; }; /** `next`, the chord that follows, tells a dominant that resolves from one that only looks like one */ export function analyze(chord: Chord, key: Key, next?: Chord): Analysis { const roman = romanOf(chord, key); const pcs = chordPcs(chord); const inKey = (k: Key) => pcs.every(p => MODES[k.mode].steps.includes(mod(p - pcOf(k.tonic), 12))); if (inKey(key)) return { roman, diatonic: true }; // a vertex that's only the parallel major or minor goes by that name, as plainer const vertex = vertices(key).find(v => !v.parallel && inKey(v.key))?.vertex; const borrowedFrom = modesFrom(key.mode).find(mode => inKey({ tonic: key.tonic, mode })); const dominant = dominantFunction(chord, key, next); return { roman, diatonic: false, ...(vertex && { vertex }), ...(borrowedFrom && { borrowedFrom }), ...(dominant?.name && { secondary: dominant.name }), }; } /** * where a chord's notes come from, the one source worth pointing out, as relative axis * interchange has it: the key; a parallel mode, as modal interchange is the plainer explanation * (vii7 in C is lydian's, though vii9 can only be the sharp vertex's ii9); the sharp or flat * vertex; locrian, which is heard as a vertex where one fits (♭iii in minor is the flat vertex's * i); and only then the inverse vertex, the furthest from the key */ export function origin(a: Analysis): "home" | Vertex | ModeId | undefined { if (a.diatonic) return "home"; if (a.borrowedFrom && a.borrowedFrom !== "locrian") return a.borrowedFrom; if (a.vertex === "sharp" || a.vertex === "flat") return a.vertex; return a.borrowedFrom ?? a.vertex; } /** * a chord as a dominant leading into one of the key's chords: a fifth above it, or (as its * tritone substitute) a semitone above. it only does when it's followed there: a seventh makes a * chord look like a dominant, but where it goes makes it one. D7 in C minor that slides down to * 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. * and a major V of the key itself is borrowed, from the parallel major or harmonic minor: there's * no other chord it's leading the key into */ function dominantFunction(chord: Chord, key: Key, next?: Chord): { name: string } | undefined { const t = chord.tones; // a major third (with a ♯9 or not), or a suspended fourth instead of any third const third = t.includes(4) || (t.includes(5) && !t.includes(3)); const seventh = third && t.includes(10) && !t.includes(11); if (!seventh && t.join() !== "0,4,7") return undefined; const root = mod(pcOf(chord.root) - pcOf(key.tonic), 12); const resolves = (to: number) => !!next && mod(pcOf(next.root) - pcOf(key.tonic), 12) === to; for (const [above, sub] of [ [7, false], [1, true], ] as const) { if (sub && !seventh) continue; const to = mod(root - above, 12); const degree = MODES[key.mode].steps.indexOf(to); if (degree < 0) continue; const target = degreeChord(key, key.mode, degree, false); if (target.suffix === "dim") continue; const leads = resolves(to) && (sub || degree !== 0); if (!leads) continue; const name = `${sub ? "subV" : "V"}${seventh ? "7" : ""}`; return { name: degree ? `${name}/${romanOf(target, key)}` : name }; } return undefined; } export type PaletteEntry = { chord: Chord; symbol: string; diatonic: boolean }; /** * how a palette row's scale relates to the key. the vertices are the key's own scale moved a * minor third up (flat) or down (sharp), or a tritone (inverse): the other keys on its axis */ export type Relation = "home" | "sharp" | "flat" | "inverse" | "parallel"; export type PaletteRow = { key: Key; relation: Relation; entries: PaletteEntry[]; /** for a parallel mode, the vertex it's the same scale as */ vertex?: Vertex; }; export type Vertex = "sharp" | "flat" | "inverse"; /** * each vertex's tonic as a degree above the key's, and the semitones to it. named for where they * are on the circle of fifths: a minor third up is three steps flatwards (C to E♭), and down is * three steps sharpwards (C to A) */ const VERTICES: [vertex: Vertex, degree: number, semis: number][] = [ ["flat", 2, 3], ["sharp", 5, 9], // a diminished fifth rather than an augmented fourth, when both are as plain ["inverse", 4, 6], ]; /** * the other keys on the key's axis: its own scale a minor third up, down, and a tritone away. * in major and minor keys, one of them is no more than the parallel minor or major (C major's * flat vertex, E♭ major, has the notes of C minor), which is said to be its `parallel` */ export function vertices(home: Key): { vertex: Vertex; key: Key; parallel?: ModeId }[] { const steps = MODES[home.mode].steps; const pcs = (k: Key) => MODES[k.mode].steps.map(s => mod(pcOf(k.tonic) + s, 12)).sort(); return VERTICES.map(([vertex, degree, semis]) => { const key = { tonic: plainestTonic(spellDegree(home.tonic, degree, semis), steps), mode: home.mode, }; const parallel = (["ionian", "aeolian"] as const).find( mode => mode !== home.mode && pcs({ tonic: home.tonic, mode }).join() === pcs(key).join(), ); return { vertex, key, ...(parallel && { parallel }) }; }); } /** the key's chords, then its vertices', then its parallel modes' (nearest first) */ export function palette(home: Key, sevenths: boolean): PaletteRow[] { const entries = (key: Key) => [0, 1, 2, 3, 4, 5, 6].map(degree => { const chord = degreeChord(key, key.mode, degree, sevenths); return { chord, symbol: romanOf(chord, home), diatonic: analyze(chord, home).diatonic }; }); const axis = vertices(home); return [ { key: home, relation: "home", entries: entries(home) }, // the vertex that's the parallel major or minor, as that, keeping its place on the axis ...axis.map(({ vertex, key, parallel }): PaletteRow => { if (!parallel) return { key, relation: vertex, entries: entries(key) }; const same = { tonic: home.tonic, mode: parallel }; return { key: same, relation: "parallel", vertex, entries: entries(same) }; }), ...modesFrom(home.mode) .slice(1) .filter(mode => !axis.some(v => v.parallel === mode)) .map(mode => ({ key: { tonic: home.tonic, mode }, relation: "parallel" as const, entries: entries({ tonic: home.tonic, mode }), })), ]; }