/** a note to write, timed in beats */ export type OutNote = { start: number; dur: number; pitch: number; vel: number }; export type OutTrack = { name: string; channel: number; program: number; notes: OutNote[] }; export type OutSong = { bpm: number; beatsPerBar: number; /** sharps (+) or flats (−) in the key signature, and whether it's minor */ key?: { accidentals: number; minor: boolean }; tracks: OutTrack[]; }; const PPQ = 480; const u16 = (n: number) => [(n >> 8) & 0xff, n & 0xff]; const u32 = (n: number) => [(n >>> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]; const ascii = (s: string) => [...s].map(c => c.charCodeAt(0)); function varInt(n: number) { const out = [n & 0x7f]; while ((n >>>= 7)) out.unshift((n & 0x7f) | 0x80); return out; } const meta = (type: number, data: number[]) => [0xff, type, ...varInt(data.length), ...data]; /** events at absolute ticks, as a track chunk */ function chunk(events: [tick: number, bytes: number[]][]): number[] { let last = 0; const body = events.flatMap(([tick, bytes]) => { const delta = tick - last; last = tick; return [...varInt(delta), ...bytes]; }); body.push(0, ...meta(0x2f, [])); return [...ascii("MTrk"), ...u32(body.length), ...body]; } /** a standard midi file (format 1): a conductor track, then one per track */ export function writeMidi(song: OutSong): Uint8Array { const tick = (beats: number) => Math.round(beats * PPQ); const conductor: [number, number[]][] = [ [0, meta(0x51, u32(Math.round(60_000_000 / song.bpm)).slice(1))], [0, meta(0x58, [song.beatsPerBar, 2, 24, 8])], ]; if (song.key && Math.abs(song.key.accidentals) <= 7) { conductor.push([0, meta(0x59, [song.key.accidentals & 0xff, song.key.minor ? 1 : 0])]); } const tracks = song.tracks.map(track => { const status = (kind: number) => kind | (track.channel & 0x0f); const events: [number, number[]][] = [ [0, meta(0x03, [...new TextEncoder().encode(track.name)])], [0, [status(0xc0), track.program & 0x7f]], ]; // a repeated pitch cuts the one before it short: overlapping on/offs of one pitch pair up // differently in different software const byPitch = Map.groupBy(track.notes, n => n.pitch); for (const [pitch, notes] of byPitch) { notes.sort((a, b) => a.start - b.start); notes.forEach((n, i) => { const on = tick(n.start); const off = Math.min( tick(n.start + n.dur), notes[i + 1] ? tick(notes[i + 1].start) : Infinity, ); if (off <= on) return; const vel = Math.min(127, Math.max(1, Math.round(n.vel * 127))); events.push([on, [status(0x90), pitch, vel]], [off, [status(0x80), pitch, 0]]); }); } // at any given tick: setup, then note-offs, then note-ons, so a note ending as another // begins can't cut it off const rank = ([status]: number[]) => ({ 0x80: 1, 0x90: 2 })[status & 0xf0] ?? 0; events.sort((a, b) => a[0] - b[0] || rank(a[1]) - rank(b[1])); return chunk(events); }); const header = [ ...ascii("MThd"), ...u32(6), ...u16(1), ...u16(tracks.length + 1), ...u16(PPQ), ]; return new Uint8Array([...header, ...chunk(conductor), ...tracks.flat()]); }