import type { Song, Track } from '../types.ts'; import { gmProgramForInstrument } from './gm.ts'; // Standard MIDI file writer, format 1: one conductor track carrying the // tempo and time signature maps, then one MTrk per song track. Drum-kit // tracks are written on channel 10; the rest cycle through the others. // Each track carries its instrument id and knob settings in a // sequencer-specific meta event (FF 7F, payload "MDBT1" + JSON), which // midiboot's own import restores exactly and everything else ignores; a // GM program change is written beside it so foreign players pick a // plausible patch. class Writer { private bytes: number[] = []; u8(v: number) { this.bytes.push(v & 0xff); } u16(v: number) { this.u8(v >> 8); this.u8(v); } u32(v: number) { this.u16(v >>> 16); this.u16(v); } varLen(v: number) { if (v < 0) throw new Error('negative delta time'); const stack: number[] = [v & 0x7f]; let rest = Math.floor(v / 128); while (rest > 0) { stack.push((rest & 0x7f) | 0x80); rest = Math.floor(rest / 128); } for (let i = stack.length - 1; i >= 0; i--) this.u8(stack[i]); } raw(data: Uint8Array | number[]) { for (const b of data) this.u8(b); } toUint8Array(): Uint8Array { return new Uint8Array(this.bytes); } } interface Ev { tick: number; order: number; data: number[] } function trackChunk(events: Ev[], endTick: number): Uint8Array { events.sort((a, b) => a.tick - b.tick || a.order - b.order); const w = new Writer(); let last = 0; for (const e of events) { w.varLen(e.tick - last); last = e.tick; w.raw(e.data); } w.varLen(Math.max(0, endTick - last)); w.raw([0xff, 0x2f, 0x00]); const body = w.toUint8Array(); const out = new Writer(); out.raw([0x4d, 0x54, 0x72, 0x6b]); // MTrk out.u32(body.length); out.raw(body); return out.toUint8Array(); } /** The midiboot sequencer-specific payload prefix, version 1. */ export const INSTRUMENT_META_PREFIX = 'MDBT1'; function instrumentMeta(t: Track): number[] { const payload: { i: string; s?: Record } = { i: t.instrumentId }; const settings: Record = {}; for (const [k, v] of Object.entries(t.settings ?? {})) { if (typeof v === 'number' && Number.isFinite(v)) settings[k] = v; } if (Object.keys(settings).length > 0) payload.s = settings; const enc = new TextEncoder().encode(INSTRUMENT_META_PREFIX + JSON.stringify(payload)); const w = new Writer(); w.raw([0xff, 0x7f]); w.varLen(enc.length); w.raw(enc); return [...w.toUint8Array()]; } function nameMeta(name: string): number[] { const enc = new TextEncoder().encode(name.slice(0, 60)); const out = [0xff, 0x03]; // name length always fits one varlen byte after the slice out.push(enc.length); out.push(...enc); return out; } export function writeMidi(song: Song): Uint8Array { const chunks: Uint8Array[] = []; let endTick = 0; for (const t of song.tracks) for (const n of t.notes) endTick = Math.max(endTick, n.tick + n.duration); const conductor: Ev[] = []; for (const e of song.tempo) { conductor.push({ tick: e.tick, order: 0, data: [0xff, 0x51, 0x03, (e.usPerQuarter >> 16) & 0xff, (e.usPerQuarter >> 8) & 0xff, e.usPerQuarter & 0xff] }); } for (const s of song.timeSig) { const dd = Math.round(Math.log2(s.denominator)); conductor.push({ tick: s.tick, order: 1, data: [0xff, 0x58, 0x04, s.numerator, dd, 24, 8] }); } chunks.push(trackChunk(conductor, endTick)); let nextCh = 0; for (const t of song.tracks) { let ch: number; if (t.instrumentId === 'drumkit') { ch = 9; } else { if (nextCh === 9) nextCh++; ch = nextCh % 16; nextCh++; } const evs: Ev[] = [ { tick: 0, order: -3, data: nameMeta(t.name) }, { tick: 0, order: -2, data: instrumentMeta(t) }, ]; const prog = gmProgramForInstrument(t.instrumentId); if (ch !== 9 && prog !== null) evs.push({ tick: 0, order: -1, data: [0xc0 | ch, prog] }); for (const n of t.notes) { const vel = Math.max(1, Math.min(127, Math.round(n.velocity))); const pitch = Math.max(0, Math.min(127, Math.round(n.pitch))); evs.push({ tick: n.tick + n.duration, order: 0, data: [0x80 | ch, pitch, 0] }); evs.push({ tick: n.tick, order: 1, data: [0x90 | ch, pitch, vel] }); } chunks.push(trackChunk(evs, endTick)); } const w = new Writer(); w.raw([0x4d, 0x54, 0x68, 0x64]); // MThd w.u32(6); w.u16(1); // format 1 w.u16(chunks.length); w.u16(song.ppq); const head = w.toUint8Array(); const total = head.length + chunks.reduce((s, c) => s + c.length, 0); const out = new Uint8Array(total); out.set(head, 0); let off = head.length; for (const c of chunks) { out.set(c, off); off += c.length; } return out; }