char-slop/chordpad.vibed.software

git clone https://git.t4t.associates/char-slop/chordpad.vibed.software

Charlotte Sominitial commit3c7bc6e

main
3.0 KiB82 linesraw
1import type { Chord } from "./chord.ts";
2import { mod, pcOf } from "./pitch.ts";
3
4const UPPER_CENTER = 65;
5const BASS_CENTER = 41;
6
7/** distance between two voicings, pairing every note with its nearest neighbour on the other side */
8function motion(from: readonly number[], to: readonly number[]): number {
9  const nearest = (x: number, ys: readonly number[]) =>
10    Math.min(...ys.map(y => Math.abs(x - y)));
11  return (
12    (to.reduce((s, x) => s + nearest(x, from), 0) +
13      from.reduce((s, y) => s + nearest(y, to), 0)) /
14    2
15  );
16}
17
18const mean = (xs: readonly number[]) => xs.reduce((a, b) => a + b, 0) / xs.length;
19
20/** stack the pitch classes in close position, starting from `low` */
21function closeStack(low: number, pcs: readonly number[]): number[] {
22  return pcs.map(pc => low + mod(pc - low, 12)).sort((a, b) => a - b);
23}
24
25/**
26 * a piano voicing: one bass note, plus the chord's upper structure led smoothly from `prev`
27 * (the previous chord's voicing, bass first)
28 */
29export function voiceChord(chord: Chord, prev?: readonly number[]): number[] {
30  const root = pcOf(chord.root);
31  const bassPc = chord.bass ? pcOf(chord.bass) : root;
32
33  let upper = chord.tones.map(t => mod(root + t, 12));
34  // thin out dense chords: the fifth goes first, then the root when the bass already has it
35  if (upper.length > 4 && chord.tones.includes(7))
36    upper = upper.filter(p => p !== mod(root + 7, 12));
37  if (upper.length > 4 && bassPc === root) upper = upper.filter(p => p !== root);
38
39  const prevBass = prev?.[0];
40  const prevUpper = prev?.slice(1);
41
42  let bass = 0;
43  let bassCost = Infinity;
44  for (let m = 33; m <= 52; m++) {
45    if (mod(m, 12) !== bassPc) continue;
46    const cost = Math.abs(m - (prevBass ?? BASS_CENTER)) + 0.5 * Math.abs(m - BASS_CENTER);
47    if (cost < bassCost) [bass, bassCost] = [m, cost];
48  }
49
50  let best: number[] = [];
51  let bestCost = Infinity;
52  for (let low = 50; low <= 70; low++) {
53    if (!upper.includes(mod(low, 12))) continue;
54    const close = closeStack(low, upper);
55    const drop2 =
56      close.length >= 4
57        ? [...close.slice(0, -2), close.at(-2)! - 12, close.at(-1)!].sort((a, b) => a - b)
58        : undefined;
59    for (const [candidate, penalty] of [
60      [close, 0],
61      [drop2, 1],
62    ] as const) {
63      if (!candidate || candidate[0] - bass < 5) continue;
64      const cost =
65        (prevUpper?.length ? motion(prevUpper, candidate) : 0) +
66        0.25 * Math.abs(mean(candidate) - UPPER_CENTER) +
67        penalty;
68      if (cost < bestCost) [best, bestCost] = [candidate, cost];
69    }
70  }
71
72  return [bass, ...best];
73}
74
75/** move the upper structure to its next inversion up (+1) or down (-1), keeping the bass */
76export function invertVoicing(notes: readonly number[], direction: 1 | -1): number[] {
77  const [bass, ...upper] = [...notes].sort((a, b) => a - b);
78  if (!upper.length) return [bass + 12 * direction];
79  if (direction > 0) upper.push(upper.shift()! + 12);
80  else if (upper.at(-1)! - 12 > bass) upper.unshift(upper.pop()! - 12);
81  return [bass, ...upper].sort((a, b) => a - b);
82}