jam-buddy / apps /web /lib /midi /generator.ts
salgadev's picture
Sync from GitHub 6feaf31d
b2e4883 verified
Raw
History Blame Contribute Delete
3.3 kB
/**
* MIDI file generator.
*
* Wraps @tonejs/midi to convert MidiEvent[] into a Uint8Array (.mid file bytes)
* suitable for download or for the drag-and-drop-into-Reaper flow.
*
* Source: docs/03-data-model.md §MIDI generation.
*/
import { Midi } from "@tonejs/midi";
import {
LIMB_TO_GM,
type Limb,
type MidiEvent,
} from "@patterntalk/shared-types";
import { bpmToMicrosecondsPerQuarter } from "../patterns/engine";
export interface MidiMeta {
patternId: string;
patternName: string;
bars: number;
bpm: number;
}
/**
* Convert MidiEvent[] to a SMF (Standard MIDI File) byte buffer.
* One track, channel 10 (the GM drum channel).
*/
export function eventsToMidi(events: MidiEvent[], meta: MidiMeta): Uint8Array {
const midi = new Midi();
// Tempo + time signature on the master track header.
midi.header.setTempo(bpmToMicrosecondsPerQuarter(meta.bpm));
midi.header.timeSignatures = [{ ticks: 0, timeSignature: [4, 4] }];
const drums = midi.addTrack();
drums.name = `${meta.patternName} (Jam Buddy)`;
drums.channel = 9; // 0-indexed; SMF channel 10 is the GM drum channel.
for (const ev of events) {
const midiNote = LIMB_TO_GM[ev.limb];
drums.addNote({
midi: midiNote,
ticks: ev.tick,
durationTicks: ev.duration,
velocity: ev.velocity / 127,
});
}
return midi.toArray();
}
/**
* Build a filename per docs/07-reaper-integration.md §File naming.
* Example: jambuddy-d-beat-4bars-180bpm-2026-08-22T1430Z.mid
*/
export function midiFilename(meta: MidiMeta, when: Date = new Date()): string {
const ts = when.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
return `jambuddy-${meta.patternId}-${meta.bars}bars-${meta.bpm}bpm-${ts}.mid`;
}
/**
* Trigger a browser download of the MIDI file. Returns the filename used.
*
* The caller is responsible for the Reaper drag-and-drop UX (see
* docs/07-reaper-integration.md §Drag-from-browser directly into Reaper)
* — this is the simpler click-to-download path, kept as the reliable fallback.
*/
export function downloadMidi(events: MidiEvent[], meta: MidiMeta): string {
const bytes = eventsToMidi(events, meta);
// `new Uint8Array(...)` re-wraps as a plain (non-SharedArrayBuffer-backed)
// ArrayBuffer — required by lib.dom.d.ts in TS 5.7+.
const blob = new Blob([new Uint8Array(bytes)], { type: "audio/midi" });
const url = URL.createObjectURL(blob);
const filename = midiFilename(meta);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Revoke after a tick so the download has time to start.
setTimeout(() => URL.revokeObjectURL(url), 1000);
return filename;
}
/**
* Inverse of downloadMidi: build a File object suitable for the HTML5
* drag-and-drop API (DataTransfer.files). Used for drag-from-browser
* directly into a Reaper track.
*/
export function midiAsFile(events: MidiEvent[], meta: MidiMeta): File {
const bytes = eventsToMidi(events, meta);
return new File(
[new Uint8Array(bytes)],
midiFilename(meta),
{ type: "audio/midi" },
);
}
// Re-export Limb for convenience — keeps consumers from digging into shared-types.
export type { Limb };