File size: 3,295 Bytes
b2e4883
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
/**
 * 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 };