/** * reading Hookpad's song format (as served for TheoryTabs), ported from how Hooktheory's own * player turns it into pitches. chords and melody are written in scale degrees of the key in * force at the time; beats count from 1 */ import { chordFrom, chordSymbol } from "../theory/chord.ts"; import { formatNote, mod, parseNote, pcOf, plainestTonic, type Spelled, spellDegree, spellPc, withoutDoubles, } from "../theory/pitch.ts"; export type HookpadChord = { beat: number; duration: number; /** scale degree, 1–7. for a secondary chord, the degree it's secondary to */ root: number; /** 5, 7, 9, 11, 13: how far the stack of thirds goes */ type: number; inversion: number; /** for a secondary chord, its function there (the 5 in V/x), or 0 */ applied: number; adds: number[]; omits: number[]; alterations: string[]; suspensions: number[]; /** a mode's name, or a scale as semitones above the tonic */ borrowed: string | number[] | null; pedal: number | string | null; substitutions: string[] | null; isRest: boolean; }; export type HookpadNote = { beat: number; duration: number; /** scale degree with accidentals, e.g. "5", "b3", "#4" */ sd: string; octave: number; isRest: boolean; }; export type HookpadSong = { keys: { beat: number; scale: string; tonic: string }[]; meters: { beat: number; numBeats: number; beatUnit: number }[]; tempos: { beat: number; bpm: number }[]; chords: HookpadChord[]; notes: HookpadNote[]; endBeat: number; }; const SCALES: Record = { major: [0, 2, 4, 5, 7, 9, 11], dorian: [0, 2, 3, 5, 7, 9, 10], phrygian: [0, 1, 3, 5, 7, 8, 10], lydian: [0, 2, 4, 6, 7, 9, 11], mixolydian: [0, 2, 4, 5, 7, 9, 10], minor: [0, 2, 3, 5, 7, 8, 10], locrian: [0, 1, 3, 5, 6, 8, 10], harmonicMinor: [0, 2, 3, 5, 7, 8, 11], phrygianDominant: [0, 1, 4, 5, 7, 8, 10], }; const MAJOR = SCALES.major; function scale(name: string | readonly number[]): readonly number[] { if (typeof name !== "string") return name; const steps = SCALES[name]; if (!steps) throw new Error(`unknown Hookpad scale "${name}"`); return steps; } const accidentals = (s: string) => [...s].reduce((n, c) => n + (c === "#" ? 1 : c === "b" ? -1 : c === "x" ? 2 : 0), 0); const writeAccidentals = (n: number) => (n > 0 ? "#" : "b").repeat(Math.abs(n)); /** fold an interval into -6..6 */ const nearest = (semis: number) => (semis > 6 ? semis - 12 : semis < -6 ? semis + 12 : semis); /** midi pitch of a scale degree: octave 0 starts at the tonic in the 4th octave (C4 for C) */ export function degreeToMidi( sd: string, tonic: string, scaleName: string | readonly number[], octave = 0, ): number { const note = parseNote(tonic); if (!note || note.rest) throw new Error(`unknown tonic "${tonic}"`); // not wrapped into an octave: C♭ sits below C4, as in Hookpad const tonicMidi = MAJOR[note.note.letter] + note.note.acc + 12 * (octave + 5); return tonicMidi + scale(scaleName)[Number(sd.at(-1)) - 1] + accidentals(sd.slice(0, -1)); } /** the chord's tones as numbers above its root (1, 3, 5, …) with accidentals, bass first */ function chordTones(c: HookpadChord) { const numbers: number[] = []; const accs: number[] = []; for (let n = 1; n <= c.type; n += 2) (numbers.push(n), accs.push(0)); const third = numbers.indexOf(3); if (third !== -1) { c.suspensions.forEach((sus, i) => { if (i === 0) numbers[third] = sus; else (numbers.splice(third + i, 0, sus), accs.splice(third + i, 0, 0)); }); } let inversion = c.inversion; if (c.suspensions.length === 2 && c.inversion > 1) inversion++; const rotate = (xs: T[]) => xs.map((_, i) => xs[(i + inversion) % xs.length]); numbers.splice(0, numbers.length, ...rotate(numbers)); accs.splice(0, accs.length, ...rotate(accs)); for (const add of c.adds) if (!numbers.includes(add)) (numbers.push(add), accs.push(0)); for (const omit of c.omits) { const i = numbers.indexOf(omit); if (i !== -1) (numbers.splice(i, 1), accs.splice(i, 1)); } for (const alteration of c.alterations) { const n = Number(alteration.replace(/[#b]/g, "")); const acc = accidentals(alteration.replace(/[0-9]/g, "")); const i = numbers.indexOf(n); if (i !== -1) accs[i] += acc; else (numbers.push(n), accs.push(acc)); } return { numbers, accs }; } /** a tritone substitution of a dominant */ function isTritoneSub(c: HookpadChord, scaleName: string) { if (!c.substitutions?.includes("tri")) return false; const degree = c.applied !== 0 ? c.applied : c.root; const majorContext = c.borrowed === "major" || (!c.borrowed && scaleName === "major"); return (c.applied !== 0 ? c.root : 0) === 5 || (degree === 5 && majorContext); } /** the chord's tones as scale degrees of the key ("b3", "5"), bass first */ export function chordDegrees(c: HookpadChord, scaleName: string): string[] { const { numbers, accs } = chordTones(c); const key = scale(scaleName); const source = c.borrowed ? scale(c.borrowed) : key; const root = c.root - 1; const tritone = c.applied !== 0 && isTritoneSub(c, scaleName); return numbers.map((number, i) => { const interval = number - 1; if (c.applied !== 0) { // secondary chords are built from the major scale on their target let target = source[c.applied - 1]; if (tritone) target = mod(target + 6, 12); let degree = mod(root + interval + (c.applied - 1), 7); if (tritone) degree = mod(degree + 4, 7); let pitch = mod(target + MAJOR[mod(root + interval, 7)], 12); if (c.root === 7 && c.type > 5 && interval === 6) pitch = mod(pitch - 1, 12); return writeAccidentals(nearest(pitch - key[degree]) + accs[i]) + (degree + 1); } const degree = mod(root + interval, 7); let alteration = 0; if (accs[i] !== 0) { const pitch = mod(source[root] + MAJOR[mod(interval, 7)] + accs[i], 12); alteration = nearest(pitch - key[degree]); } else if (c.borrowed) alteration = source[degree] - key[degree]; return writeAccidentals(alteration) + (degree + 1); }); } export type ImportedSection = { key: { tonic: string; scale: string }; bpm: number; beatsPerBar: number; /** in beats */ length: number; /** a symbol where the chord has a name, otherwise its notes */ chords: { start: number; dur: number; symbol?: string; notes?: number[] }[]; notes: { start: number; dur: number; pitch: number }[]; }; /** turn a section into chord symbols and melody pitches, with time starting at 0 */ export function readSection(song: HookpadSong): ImportedSection { const keys = [...song.keys].sort((a, b) => a.beat - b.beat); const keyAt = (beat: number) => keys.findLast(k => k.beat <= beat) ?? keys[0]; const chords = song.chords .filter(c => !c.isRest) .map(stored => { // computed with root and applied the other way round from how they're stored const c = stored.applied ? { ...stored, root: stored.applied, applied: stored.root } : stored; const key = keyAt(c.beat); const pcs = chordDegrees(c, key.scale).map(sd => mod(degreeToMidi(sd, key.tonic, key.scale), 12), ); const tones = chordTones(c).numbers; const rootPc = pcs[Math.max(0, tones.indexOf(1))]; const pedal = c.pedal !== null && degreeToMidi(String(c.pedal), key.tonic, c.borrowed || key.scale); const bassPc = pedal !== false ? mod(pedal, 12) : pcs[0]; // spelled from the degrees hookpad gives, so vii°/ii in C is C♯ rather than D♭ const tonic = plainestTonic(parseNote(key.tonic)!.note, scale(key.scale)); const above = (from: Spelled, pc: number) => mod(pc - pcOf(from), 12); let root: Spelled; if (c.applied && !isTritoneSub(c, key.scale)) { const targetPc = mod(pcOf(tonic) + scale(c.borrowed || key.scale)[c.applied - 1], 12); const target = spellDegree(tonic, c.applied - 1, above(tonic, targetPc)); root = spellDegree(target, c.root - 1, above(target, rootPc)); } else if (c.applied) root = spellPc(rootPc, tonic); else root = spellDegree(tonic, c.root - 1, above(tonic, rootPc)); const bass = pedal !== false ? spellPc(bassPc, tonic) : spellDegree(root, (tones[0] - 1) % 7, above(root, bassPc)); const chord = chordFrom( withoutDoubles(root), pcs.map(pc => pc - rootPc), withoutDoubles(bass), ); const timing = { start: c.beat - 1, dur: c.duration }; if (chord) return { ...timing, symbol: chordSymbol(chord) }; // no name for it: keep the notes, stacked up from the bass const low = 36 + bassPc; return { ...timing, notes: [low, ...pcs.map(pc => low + 12 + mod(pc - bassPc, 12))] }; }); const notes = song.notes .filter(n => !n.isRest) .map(n => { const key = keyAt(n.beat); return { start: n.beat - 1, dur: n.duration, pitch: degreeToMidi(n.sd, key.tonic, key.scale, n.octave), }; }); const first = keys[0] ?? { tonic: "C", scale: "major" }; return { key: { tonic: formatNote(plainestTonic(parseNote(first.tonic)!.note, scale(first.scale)), true), scale: first.scale, }, bpm: song.tempos[0]?.bpm ?? 120, beatsPerBar: song.meters[0]?.numBeats ?? 4, length: Math.max(0, song.endBeat - 1), chords, notes, }; }