import type { Chord } from "./chord.ts"; import { mod, pcOf } from "./pitch.ts"; const UPPER_CENTER = 65; const BASS_CENTER = 41; /** distance between two voicings, pairing every note with its nearest neighbour on the other side */ function motion(from: readonly number[], to: readonly number[]): number { const nearest = (x: number, ys: readonly number[]) => Math.min(...ys.map(y => Math.abs(x - y))); return ( (to.reduce((s, x) => s + nearest(x, from), 0) + from.reduce((s, y) => s + nearest(y, to), 0)) / 2 ); } const mean = (xs: readonly number[]) => xs.reduce((a, b) => a + b, 0) / xs.length; /** stack the pitch classes in close position, starting from `low` */ function closeStack(low: number, pcs: readonly number[]): number[] { return pcs.map(pc => low + mod(pc - low, 12)).sort((a, b) => a - b); } /** * a piano voicing: one bass note, plus the chord's upper structure led smoothly from `prev` * (the previous chord's voicing, bass first) */ export function voiceChord(chord: Chord, prev?: readonly number[]): number[] { const root = pcOf(chord.root); const bassPc = chord.bass ? pcOf(chord.bass) : root; let upper = chord.tones.map(t => mod(root + t, 12)); // thin out dense chords: the fifth goes first, then the root when the bass already has it if (upper.length > 4 && chord.tones.includes(7)) upper = upper.filter(p => p !== mod(root + 7, 12)); if (upper.length > 4 && bassPc === root) upper = upper.filter(p => p !== root); const prevBass = prev?.[0]; const prevUpper = prev?.slice(1); let bass = 0; let bassCost = Infinity; for (let m = 33; m <= 52; m++) { if (mod(m, 12) !== bassPc) continue; const cost = Math.abs(m - (prevBass ?? BASS_CENTER)) + 0.5 * Math.abs(m - BASS_CENTER); if (cost < bassCost) [bass, bassCost] = [m, cost]; } let best: number[] = []; let bestCost = Infinity; for (let low = 50; low <= 70; low++) { if (!upper.includes(mod(low, 12))) continue; const close = closeStack(low, upper); const drop2 = close.length >= 4 ? [...close.slice(0, -2), close.at(-2)! - 12, close.at(-1)!].sort((a, b) => a - b) : undefined; for (const [candidate, penalty] of [ [close, 0], [drop2, 1], ] as const) { if (!candidate || candidate[0] - bass < 5) continue; const cost = (prevUpper?.length ? motion(prevUpper, candidate) : 0) + 0.25 * Math.abs(mean(candidate) - UPPER_CENTER) + penalty; if (cost < bestCost) [best, bestCost] = [candidate, cost]; } } return [bass, ...best]; } /** move the upper structure to its next inversion up (+1) or down (-1), keeping the bass */ export function invertVoicing(notes: readonly number[], direction: 1 | -1): number[] { const [bass, ...upper] = [...notes].sort((a, b) => a - b); if (!upper.length) return [bass + 12 * direction]; if (direction > 0) upper.push(upper.shift()! + 12); else if (upper.at(-1)! - 12 > bass) upper.unshift(upper.pop()! - 12); return [bass, ...upper].sort((a, b) => a - b); }