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