char-slop/chordpad.vibed.software

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

Charlotte Sominitial commit3c7bc6e

main
9.2 KiB254 linesraw
1/**
2 * reading Hookpad's song format (as served for TheoryTabs), ported from how Hooktheory's own
3 * player turns it into pitches. chords and melody are written in scale degrees of the key in
4 * force at the time; beats count from 1
5 */
6import { chordFrom, chordSymbol } from "../theory/chord.ts";
7import {
8  formatNote,
9  mod,
10  parseNote,
11  pcOf,
12  plainestTonic,
13  type Spelled,
14  spellDegree,
15  spellPc,
16  withoutDoubles,
17} from "../theory/pitch.ts";
18
19export type HookpadChord = {
20  beat: number;
21  duration: number;
22  /** scale degree, 1–7. for a secondary chord, the degree it's secondary to */
23  root: number;
24  /** 5, 7, 9, 11, 13: how far the stack of thirds goes */
25  type: number;
26  inversion: number;
27  /** for a secondary chord, its function there (the 5 in V/x), or 0 */
28  applied: number;
29  adds: number[];
30  omits: number[];
31  alterations: string[];
32  suspensions: number[];
33  /** a mode's name, or a scale as semitones above the tonic */
34  borrowed: string | number[] | null;
35  pedal: number | string | null;
36  substitutions: string[] | null;
37  isRest: boolean;
38};
39
40export type HookpadNote = {
41  beat: number;
42  duration: number;
43  /** scale degree with accidentals, e.g. "5", "b3", "#4" */
44  sd: string;
45  octave: number;
46  isRest: boolean;
47};
48
49export type HookpadSong = {
50  keys: { beat: number; scale: string; tonic: string }[];
51  meters: { beat: number; numBeats: number; beatUnit: number }[];
52  tempos: { beat: number; bpm: number }[];
53  chords: HookpadChord[];
54  notes: HookpadNote[];
55  endBeat: number;
56};
57
58const SCALES: Record<string, readonly number[]> = {
59  major: [0, 2, 4, 5, 7, 9, 11],
60  dorian: [0, 2, 3, 5, 7, 9, 10],
61  phrygian: [0, 1, 3, 5, 7, 8, 10],
62  lydian: [0, 2, 4, 6, 7, 9, 11],
63  mixolydian: [0, 2, 4, 5, 7, 9, 10],
64  minor: [0, 2, 3, 5, 7, 8, 10],
65  locrian: [0, 1, 3, 5, 6, 8, 10],
66  harmonicMinor: [0, 2, 3, 5, 7, 8, 11],
67  phrygianDominant: [0, 1, 4, 5, 7, 8, 10],
68};
69const MAJOR = SCALES.major;
70
71function scale(name: string | readonly number[]): readonly number[] {
72  if (typeof name !== "string") return name;
73  const steps = SCALES[name];
74  if (!steps) throw new Error(`unknown Hookpad scale "${name}"`);
75  return steps;
76}
77
78const accidentals = (s: string) =>
79  [...s].reduce((n, c) => n + (c === "#" ? 1 : c === "b" ? -1 : c === "x" ? 2 : 0), 0);
80const writeAccidentals = (n: number) => (n > 0 ? "#" : "b").repeat(Math.abs(n));
81/** fold an interval into -6..6 */
82const nearest = (semis: number) => (semis > 6 ? semis - 12 : semis < -6 ? semis + 12 : semis);
83
84/** midi pitch of a scale degree: octave 0 starts at the tonic in the 4th octave (C4 for C) */
85export function degreeToMidi(
86  sd: string,
87  tonic: string,
88  scaleName: string | readonly number[],
89  octave = 0,
90): number {
91  const note = parseNote(tonic);
92  if (!note || note.rest) throw new Error(`unknown tonic "${tonic}"`);
93  // not wrapped into an octave: C♭ sits below C4, as in Hookpad
94  const tonicMidi = MAJOR[note.note.letter] + note.note.acc + 12 * (octave + 5);
95  return tonicMidi + scale(scaleName)[Number(sd.at(-1)) - 1] + accidentals(sd.slice(0, -1));
96}
97
98/** the chord's tones as numbers above its root (1, 3, 5, …) with accidentals, bass first */
99function chordTones(c: HookpadChord) {
100  const numbers: number[] = [];
101  const accs: number[] = [];
102  for (let n = 1; n <= c.type; n += 2) (numbers.push(n), accs.push(0));
103
104  const third = numbers.indexOf(3);
105  if (third !== -1) {
106    c.suspensions.forEach((sus, i) => {
107      if (i === 0) numbers[third] = sus;
108      else (numbers.splice(third + i, 0, sus), accs.splice(third + i, 0, 0));
109    });
110  }
111
112  let inversion = c.inversion;
113  if (c.suspensions.length === 2 && c.inversion > 1) inversion++;
114  const rotate = <T>(xs: T[]) => xs.map((_, i) => xs[(i + inversion) % xs.length]);
115  numbers.splice(0, numbers.length, ...rotate(numbers));
116  accs.splice(0, accs.length, ...rotate(accs));
117
118  for (const add of c.adds) if (!numbers.includes(add)) (numbers.push(add), accs.push(0));
119  for (const omit of c.omits) {
120    const i = numbers.indexOf(omit);
121    if (i !== -1) (numbers.splice(i, 1), accs.splice(i, 1));
122  }
123  for (const alteration of c.alterations) {
124    const n = Number(alteration.replace(/[#b]/g, ""));
125    const acc = accidentals(alteration.replace(/[0-9]/g, ""));
126    const i = numbers.indexOf(n);
127    if (i !== -1) accs[i] += acc;
128    else (numbers.push(n), accs.push(acc));
129  }
130  return { numbers, accs };
131}
132
133/** a tritone substitution of a dominant */
134function isTritoneSub(c: HookpadChord, scaleName: string) {
135  if (!c.substitutions?.includes("tri")) return false;
136  const degree = c.applied !== 0 ? c.applied : c.root;
137  const majorContext = c.borrowed === "major" || (!c.borrowed && scaleName === "major");
138  return (c.applied !== 0 ? c.root : 0) === 5 || (degree === 5 && majorContext);
139}
140
141/** the chord's tones as scale degrees of the key ("b3", "5"), bass first */
142export function chordDegrees(c: HookpadChord, scaleName: string): string[] {
143  const { numbers, accs } = chordTones(c);
144  const key = scale(scaleName);
145  const source = c.borrowed ? scale(c.borrowed) : key;
146  const root = c.root - 1;
147  const tritone = c.applied !== 0 && isTritoneSub(c, scaleName);
148
149  return numbers.map((number, i) => {
150    const interval = number - 1;
151    if (c.applied !== 0) {
152      // secondary chords are built from the major scale on their target
153      let target = source[c.applied - 1];
154      if (tritone) target = mod(target + 6, 12);
155      let degree = mod(root + interval + (c.applied - 1), 7);
156      if (tritone) degree = mod(degree + 4, 7);
157      let pitch = mod(target + MAJOR[mod(root + interval, 7)], 12);
158      if (c.root === 7 && c.type > 5 && interval === 6) pitch = mod(pitch - 1, 12);
159      return writeAccidentals(nearest(pitch - key[degree]) + accs[i]) + (degree + 1);
160    }
161    const degree = mod(root + interval, 7);
162    let alteration = 0;
163    if (accs[i] !== 0) {
164      const pitch = mod(source[root] + MAJOR[mod(interval, 7)] + accs[i], 12);
165      alteration = nearest(pitch - key[degree]);
166    } else if (c.borrowed) alteration = source[degree] - key[degree];
167    return writeAccidentals(alteration) + (degree + 1);
168  });
169}
170
171export type ImportedSection = {
172  key: { tonic: string; scale: string };
173  bpm: number;
174  beatsPerBar: number;
175  /** in beats */
176  length: number;
177  /** a symbol where the chord has a name, otherwise its notes */
178  chords: { start: number; dur: number; symbol?: string; notes?: number[] }[];
179  notes: { start: number; dur: number; pitch: number }[];
180};
181
182/** turn a section into chord symbols and melody pitches, with time starting at 0 */
183export function readSection(song: HookpadSong): ImportedSection {
184  const keys = [...song.keys].sort((a, b) => a.beat - b.beat);
185  const keyAt = (beat: number) => keys.findLast(k => k.beat <= beat) ?? keys[0];
186
187  const chords = song.chords
188    .filter(c => !c.isRest)
189    .map(stored => {
190      // computed with root and applied the other way round from how they're stored
191      const c = stored.applied
192        ? { ...stored, root: stored.applied, applied: stored.root }
193        : stored;
194      const key = keyAt(c.beat);
195      const pcs = chordDegrees(c, key.scale).map(sd =>
196        mod(degreeToMidi(sd, key.tonic, key.scale), 12),
197      );
198      const tones = chordTones(c).numbers;
199      const rootPc = pcs[Math.max(0, tones.indexOf(1))];
200      const pedal =
201        c.pedal !== null && degreeToMidi(String(c.pedal), key.tonic, c.borrowed || key.scale);
202      const bassPc = pedal !== false ? mod(pedal, 12) : pcs[0];
203
204      // spelled from the degrees hookpad gives, so vii°/ii in C is C♯ rather than D♭
205      const tonic = plainestTonic(parseNote(key.tonic)!.note, scale(key.scale));
206      const above = (from: Spelled, pc: number) => mod(pc - pcOf(from), 12);
207      let root: Spelled;
208      if (c.applied && !isTritoneSub(c, key.scale)) {
209        const targetPc = mod(pcOf(tonic) + scale(c.borrowed || key.scale)[c.applied - 1], 12);
210        const target = spellDegree(tonic, c.applied - 1, above(tonic, targetPc));
211        root = spellDegree(target, c.root - 1, above(target, rootPc));
212      } else if (c.applied) root = spellPc(rootPc, tonic);
213      else root = spellDegree(tonic, c.root - 1, above(tonic, rootPc));
214      const bass =
215        pedal !== false
216          ? spellPc(bassPc, tonic)
217          : spellDegree(root, (tones[0] - 1) % 7, above(root, bassPc));
218
219      const chord = chordFrom(
220        withoutDoubles(root),
221        pcs.map(pc => pc - rootPc),
222        withoutDoubles(bass),
223      );
224      const timing = { start: c.beat - 1, dur: c.duration };
225      if (chord) return { ...timing, symbol: chordSymbol(chord) };
226      // no name for it: keep the notes, stacked up from the bass
227      const low = 36 + bassPc;
228      return { ...timing, notes: [low, ...pcs.map(pc => low + 12 + mod(pc - bassPc, 12))] };
229    });
230
231  const notes = song.notes
232    .filter(n => !n.isRest)
233    .map(n => {
234      const key = keyAt(n.beat);
235      return {
236        start: n.beat - 1,
237        dur: n.duration,
238        pitch: degreeToMidi(n.sd, key.tonic, key.scale, n.octave),
239      };
240    });
241
242  const first = keys[0] ?? { tonic: "C", scale: "major" };
243  return {
244    key: {
245      tonic: formatNote(plainestTonic(parseNote(first.tonic)!.note, scale(first.scale)), true),
246      scale: first.scale,
247    },
248    bpm: song.tempos[0]?.bpm ?? 120,
249    beatsPerBar: song.meters[0]?.numBeats ?? 4,
250    length: Math.max(0, song.endBeat - 1),
251    chords,
252    notes,
253  };
254}