char-slop/chordpad.vibed.software
git clone https://git.t4t.associates/char-slop/chordpad.vibed.software
3c7bc6e
main
1/** a note to write, timed in beats */ 2export type OutNote = { start :number ; dur :number ; pitch :number ; vel :number }; 3export type OutTrack = { name :string ; channel :number ; program :number ; notes :OutNote [] }; 4export type OutSong = { 5bpm :number ; 6beatsPerBar :number ; 7/** sharps (+) or flats (−) in the key signature, and whether it's minor */ 8key ?:{ accidentals :number ; minor :boolean }; 9tracks :OutTrack []; 10}; 11 12const PPQ = 480 ; 13 14const u16 = ( n :number ) => [( n >> 8 ) & 0xff , n & 0xff ]; 15const u32 = ( n :number ) => [( n >>> 24 ) & 0xff , ( n >> 16 ) & 0xff , ( n >> 8 ) & 0xff , n & 0xff ]; 16const ascii = ( s :string ) => [ ...s ]. map ( c => c . charCodeAt ( 0 )); 17function varInt ( n :number ) { 18const out = [ n & 0x7f ]; 19while (( n >>>= 7 )) out . unshift (( n & 0x7f ) | 0x80 ); 20return out ; 21} 22const meta = ( type :number , data :number []) => [ 0xff , type , ...varInt ( data . length ), ...data ]; 23 24/** events at absolute ticks, as a track chunk */ 25function chunk ( events :[ tick :number , bytes :number []][]) :number [] { 26let last = 0 ; 27const body = events . flatMap (([ tick , bytes ]) => { 28const delta = tick - last ; 29last = tick ; 30return [ ...varInt ( delta ), ...bytes ]; 31}); 32body . push ( 0 , ...meta ( 0x2f , [])); 33return [ ...ascii ( "MTrk" ), ...u32 ( body . length ), ...body ]; 34} 35 36/** a standard midi file (format 1): a conductor track, then one per track */ 37export function writeMidi ( song :OutSong ) :Uint8Array < ArrayBuffer > { 38const tick = ( beats :number ) => Math . round ( beats * PPQ ); 39 40const conductor :[ number , number []][] = [ 41[ 0 , meta ( 0x51 , u32 ( Math . round ( 60_000_000 / song . bpm )). slice ( 1 ))], 42[ 0 , meta ( 0x58 , [ song . beatsPerBar , 2 , 24 , 8 ])], 43]; 44if ( song . key && Math . abs ( song . key . accidentals ) <= 7 ) { 45conductor . push ([ 0 , meta ( 0x59 , [ song . key . accidentals & 0xff , song . key . minor ?1 :0 ])]); 46} 47 48const tracks = song . tracks . map ( track => { 49const status = ( kind :number ) => kind | ( track . channel & 0x0f ); 50const events :[ number , number []][] = [ 51[ 0 , meta ( 0x03 , [ ...new TextEncoder (). encode ( track . name )])], 52[ 0 , [ status ( 0xc0 ), track . program & 0x7f ]], 53]; 54 55// a repeated pitch cuts the one before it short: overlapping on/offs of one pitch pair up 56// differently in different software 57const byPitch = Map . groupBy ( track . notes , n => n . pitch ); 58for ( const [ pitch , notes ] of byPitch ) { 59notes . sort (( a , b ) => a . start - b . start ); 60notes . forEach (( n , i ) => { 61const on = tick ( n . start ); 62const off = Math . min ( 63tick ( n . start + n . dur ), 64notes [ i + 1 ] ?tick ( notes [ i + 1 ]. start ) :Infinity , 65); 66if ( off <= on ) return ; 67const vel = Math . min ( 127 , Math . max ( 1 , Math . round ( n . vel * 127 ))); 68events . push ([ on , [ status ( 0x90 ), pitch , vel ]], [ off , [ status ( 0x80 ), pitch , 0 ]]); 69}); 70} 71// at any given tick: setup, then note-offs, then note-ons, so a note ending as another 72// begins can't cut it off 73const rank = ([ status ] :number []) => ({ 0x80 :1 , 0x90 :2 })[ status & 0xf0 ] ?? 0 ; 74events . sort (( a , b ) => a [ 0 ] - b [ 0 ] || rank ( a [ 1 ]) - rank ( b [ 1 ])); 75return chunk ( events ); 76}); 77 78const header = [ 79 ...ascii ( "MThd" ), 80 ...u32 ( 6 ), 81 ...u16 ( 1 ), 82 ...u16 ( tracks . length + 1 ), 83 ...u16 ( PPQ ), 84]; 85return new Uint8Array ([ ...header , ...chunk ( conductor ), ...tracks . flat ()]); 86}