import { describeTones } from "./describe.ts"; import { type Key, MODES, type ModeId, modesFrom } from "./key.ts"; import { accidentalValue, formatNote, mod, parseNote, type Pc, pcOf, type Spelled, spellDegree, spellPc, } from "./pitch.ts"; export type Chord = { root: Spelled; /** semitones above the root: sorted, unique, within one octave, starting with 0 */ tones: number[]; /** only present when it differs from the root */ bass?: Spelled; suffix: string; }; export const ROMANS = ["I", "II", "III", "IV", "V", "VI", "VII"]; const ROMAN_RE = /^([b#♭♯]*)(VII|VI|V|IV|III|II|I|vii|vi|v|iv|iii|ii|i)/; const DEGREE_RE = /^([b#♭♯]?)([1-7])(7?)$/; /** whether a symbol is written relative to the key (a roman numeral or scale degree) */ export const isKeyRelative = (symbol: string): boolean => ROMAN_RE.test(symbol.trim()) || /^[b#♭♯]?[1-7]/.test(symbol.trim()); export const normalizeTones = (tones: Iterable): number[] => [...new Set([...tones].map(t => mod(t, 12)))].sort((a, b) => a - b); const sameList = (a: readonly number[], b: readonly number[]) => a.length === b.length && a.every((x, i) => x === b[i]); function makeChord(root: Spelled, tones: Iterable, typedSuffix = ""): Chord { const normal = normalizeTones(tones); // "alt" is shorthand no spelled-out name improves on const suffix = typedSuffix.includes("alt") ? typedSuffix : describeTones(normal)?.suffix; return { root, tones: normal, suffix: suffix ?? typedSuffix }; } /** a chord from its root and the semitones above it, if the shape has a name */ export function chordFrom(root: Spelled, tones: number[], bass?: Spelled): Chord | undefined { if (!describeTones(tones)) return undefined; const chord = makeChord(root, tones); return bass ? withBass(chord, bass) : chord; } function withBass(chord: Chord, bass: Spelled): Chord { return pcOf(bass) === pcOf(chord.root) ? chord : { ...chord, bass }; } /** absolute pitch classes, including the bass */ export function chordPcs(chord: Chord): Pc[] { const root = pcOf(chord.root); const pcs = chord.tones.map(t => mod(root + t, 12)); if (chord.bass) pcs.push(pcOf(chord.bass)); return normalizeTones(pcs); } export const prettySymbol = (s: string): string => s.replace(/b(?=\d)/g, "♭").replace(/#/g, "♯"); /** in a form `parseChord` reads back */ export const chordSymbol = (chord: Chord): string => formatNote(chord.root, true) + chord.suffix + (chord.bass ? "/" + formatNote(chord.bass, true) : ""); export const chordName = (chord: Chord): string => formatNote(chord.root) + prettySymbol(chord.suffix) + (chord.bass ? "/" + formatNote(chord.bass) : ""); const EXTENSION_SEMIS: Record = { "2": 2, "4": 5, "5": 7, "6": 9, "9": 2, "11": 5, "13": 9, }; function parseSuffix(src: string, minor: boolean): number[] | undefined { let s = src.replace(/[()\s,]/g, ""); const eat = (re: RegExp) => { const m = re.exec(s); if (m) s = s.slice(m[0].length); return m ?? undefined; }; let third: number | undefined = minor ? 3 : 4; let fifth: number | undefined = 7; let seventh: number | undefined; let major7 = false; // a bare triangle means maj7, unlike a bare "maj" or "M" let triangle = false; const eatMajor = () => { const m = eat(/^(maj|Maj|MAJ|M|Δ|\^)/); if (m) [major7, triangle] = [true, /[Δ^]/.test(m[0])]; }; const ext = new Set(); if (eat(/^ø7?/)) [third, fifth, seventh] = [3, 6, 10]; else if (eat(/^(dim|°|o(?!mit))/)) { [third, fifth] = [3, 6]; if (eat(/^7/)) seventh = 9; } else if (eat(/^(aug|\+)/)) { fifth = 8; eatMajor(); } else if (eat(/^(min|mi|m|-)(?!aj)/)) { third = 3; eatMajor(); } else eatMajor(); const num = eat(/^(6\/9|69|13|11|9|7|6|5)/)?.[0]; const extensions: Record = { "9": [2], "11": [2, 5], "13": [2, 9] }; if (num === "5") third = undefined; else if (num === "6") ext.add(9); else if (num === "6/9" || num === "69") ext.add(9).add(2); else if (num) { seventh ??= major7 ? 11 : 10; for (const t of extensions[num] ?? []) ext.add(t); } else if (triangle) seventh = 11; while (s) { let m; if ((m = eat(/^sus([24])?/))) third = m[1] === "2" ? 2 : 5; else if ((m = eat(/^add([#♯b♭]?)(13|11|9|6|4|2)/))) { ext.add(EXTENSION_SEMIS[m[2]] + accidentalValue(m[1])); } else if ((m = eat(/^([#♯b♭+-])(13|11|9|5)/))) { const semis = EXTENSION_SEMIS[m[2]] + ("#♯+".includes(m[1]) ? 1 : -1); if (m[2] === "5") fifth = semis; else { ext.delete(EXTENSION_SEMIS[m[2]]); ext.add(semis); } } else if (eat(/^alt/)) { seventh ??= 10; fifth = undefined; for (const t of [1, 3, 6, 8]) ext.add(t); } else if ((m = eat(/^(no|omit)([35])/))) { if (m[2] === "3") third = undefined; else fifth = undefined; } else return undefined; } return normalizeTones( [0, third, fifth, seventh, ...ext].filter((t): t is number => t !== undefined), ); } /** the diatonic triad (or seventh chord) of `mode` built on `degree` (0-based), spelled in `key` */ export function degreeChord(key: Key, mode: ModeId, degree: number, seventh: boolean): Chord { const steps = MODES[mode].steps; const at = (k: number) => steps[(degree + k) % 7] + 12 * Math.floor((degree + k) / 7); const root = spellDegree(key.tonic, degree, steps[degree]); const stack = seventh ? [0, 2, 4, 6] : [0, 2, 4]; return makeChord( root, stack.map(k => at(k) - steps[degree]), ); } /** * parse a chord symbol. accepts letter names (`Cm7b5`, `F#/C#`), and roman numerals (`III`, * `bVI`, `V7/V`) and scale degrees (`4`, `57`, `b7`) counted in the key's mode: in A minor, `III` * is C and `#vi` is F♯m. a secondary's roman counts in the major scale of its target */ export function parseChord(text: string, key: Key): Chord | undefined { const src = text.trim(); if (!src) return undefined; const slash = src.lastIndexOf("/"); if (slash > 0) { const left = src.slice(0, slash); const right = src.slice(slash + 1); const bass = parseNote(right); if (bass && bass.rest === "") { const chord = parseChord(left, key); return chord && withBass(chord, bass.note); } if (ROMAN_RE.test(right) || DEGREE_RE.test(right)) { const target = parseChord(right, key); return target && parseChord(left, { tonic: target.root, mode: "ionian" }); } } const roman = ROMAN_RE.exec(src); if (roman) { const degree = ROMANS.indexOf(roman[2].toUpperCase()); const minor = roman[2] !== roman[2].toUpperCase(); const semis = MODES[key.mode].steps[degree] + accidentalValue(roman[1]); const root = spellDegree(key.tonic, degree, semis); const rest = src.slice(roman[0].length); const tones = parseSuffix(rest, minor); const fallback = (minor && !/^(dim|°|o|ø)/.test(rest) ? "m" : "") + rest; return tones && makeChord(root, tones, fallback); } const scaleDegree = DEGREE_RE.exec(src); if (scaleDegree) { const degree = Number(scaleDegree[2]) - 1; const semis = MODES[key.mode].steps[degree] + accidentalValue(scaleDegree[1]); // a bare degree is diatonic; an altered one borrows from whichever mode has it const mode = scaleDegree[1] ? modesFrom(key.mode).find(m => MODES[m].steps[degree] === semis) : key.mode; return mode && degreeChord(key, mode, degree, scaleDegree[3] === "7"); } const letter = parseNote(src); if (!letter) return undefined; const tones = parseSuffix(letter.rest, false); return tones && makeChord(letter.note, tones, letter.rest); } const INVERTED = 1; const FOREIGN_BASS = 1.3; /** * readings of a set of midi notes, likeliest first. each note can be the root, with the lowest * as an inversion's bass; or the lowest can be a separate bass under a chord made of the rest */ export function detectChords(midis: readonly number[], key: Key): Chord[] { if (!midis.length) return []; const sorted = [...midis].sort((a, b) => a - b); const bass = mod(sorted[0], 12); const pcs = normalizeTones(sorted); const upper = normalizeTones(sorted.slice(1)); const readings: { cost: number; root: Pc; tones: number[] }[] = []; const consider = (root: Pc, notes: Pc[], penalty: number) => { const tones = notes.map(p => p - root); const described = describeTones(tones); if (described) readings.push({ cost: described.cost + penalty, root, tones }); }; for (const root of pcs) consider(root, pcs, root === bass ? 0 : INVERTED); if (!upper.includes(bass) && upper.length >= 3) { for (const root of upper) consider(root, upper, FOREIGN_BASS); } const spelledBass = spellPc(bass, key.tonic); return readings .sort((a, b) => a.cost - b.cost) .map(r => withBass(makeChord(spellPc(r.root, key.tonic), r.tones), spelledBass)); } export const detectChord = (midis: readonly number[], key: Key): Chord | undefined => detectChords(midis, key)[0]; /** whether the notes sound the chord: same pitch classes, same bass */ export function chordMatches(chord: Chord, midis: readonly number[]): boolean { if (!midis.length) return false; const bass = chord.bass ?? chord.root; return ( sameList(chordPcs(chord), normalizeTones(midis)) && mod(Math.min(...midis), 12) === pcOf(bass) ); }