HomeMAL' // MANUFACTORUM
datacrypt
Blob Opera MIDI / public / lib / midi.js
public/lib/midi.js4.9 KB
// Dependency-free Standard MIDI File (SMF) parser.
//
// Handles format 0 and 1 files. Produces, per track, a list of notes with
// absolute start/end times in *seconds*, resolved through the file's tempo map.

class Reader {
  constructor(view) {
    this.view = view;
    this.pos = 0;
  }
  u8() {
    return this.view.getUint8(this.pos++);
  }
  u16() {
    const v = this.view.getUint16(this.pos);
    this.pos += 2;
    return v;
  }
  u32() {
    const v = this.view.getUint32(this.pos);
    this.pos += 4;
    return v;
  }
  str(n) {
    let s = "";
    for (let i = 0; i < n; i++) s += String.fromCharCode(this.u8());
    return s;
  }
  bytes(n) {
    const b = new Uint8Array(this.view.buffer, this.view.byteOffset + this.pos, n);
    this.pos += n;
    return b;
  }
  // Variable-length quantity.
  varint() {
    let value = 0;
    for (;;) {
      const b = this.u8();
      value = (value << 7) | (b & 0x7f);
      if (!(b & 0x80)) return value;
    }
  }
}

// Parse one MTrk into raw events carrying absolute tick times.
function parseTrack(reader, length) {
  const end = reader.pos + length;
  const events = [];
  let tick = 0;
  let running = 0;
  while (reader.pos < end) {
    tick += reader.varint();
    let status = reader.u8();
    if (status < 0x80) {
      // Running status: reuse previous status byte, rewind one data byte.
      reader.pos--;
      status = running;
    } else {
      running = status;
    }
    const type = status & 0xf0;
    if (status === 0xff) {
      // Meta event.
      const metaType = reader.u8();
      const len = reader.varint();
      const data = reader.bytes(len);
      if (metaType === 0x51) {
        events.push({ tick, kind: "tempo", usPerQuarter: (data[0] << 16) | (data[1] << 8) | data[2] });
      } else if (metaType === 0x03) {
        events.push({ tick, kind: "name", text: String.fromCharCode(...data) });
      }
    } else if (status === 0xf0 || status === 0xf7) {
      // SysEx: skip.
      const len = reader.varint();
      reader.bytes(len);
    } else if (type === 0x90) {
      const note = reader.u8();
      const velocity = reader.u8();
      events.push({ tick, kind: velocity > 0 ? "on" : "off", note, velocity });
    } else if (type === 0x80) {
      const note = reader.u8();
      reader.u8();
      events.push({ tick, kind: "off", note });
    } else if (type === 0xc0 || type === 0xd0) {
      reader.u8(); // one data byte
    } else {
      reader.u8();
      reader.u8(); // two data bytes (control change, pitch bend, etc.)
    }
  }
  reader.pos = end;
  return events;
}

// Turn an absolute tick into seconds using the (tick-sorted) tempo map.
function makeTickToSeconds(tempoMap, ticksPerQuarter) {
  return (tick) => {
    let seconds = 0;
    let lastTick = 0;
    let usPerQuarter = 500000; // default 120 BPM
    for (const t of tempoMap) {
      if (t.tick >= tick) break;
      seconds += ((t.tick - lastTick) / ticksPerQuarter) * (usPerQuarter / 1e6);
      lastTick = t.tick;
      usPerQuarter = t.usPerQuarter;
    }
    seconds += ((tick - lastTick) / ticksPerQuarter) * (usPerQuarter / 1e6);
    return seconds;
  };
}

/**
 * Parse a MIDI file.
 * @param {ArrayBuffer} buffer
 * @returns {{tracks: Array<{name:string, notes:Array<{start:number,end:number,pitch:number,velocity:number}>}>}}
 */
export function parseMidi(buffer) {
  const reader = new Reader(new DataView(buffer));
  if (reader.str(4) !== "MThd") throw new Error("not a MIDI file (missing MThd header)");
  reader.u32(); // header length (always 6)
  reader.u16(); // format
  const trackCount = reader.u16();
  const division = reader.u16();
  if (division & 0x8000) throw new Error("SMPTE time division is not supported");
  const ticksPerQuarter = division;

  // First pass: collect every track's raw events, and a global tempo map.
  const rawTracks = [];
  const tempoMap = [];
  for (let i = 0; i < trackCount; i++) {
    if (reader.str(4) !== "MTrk") throw new Error(`expected MTrk chunk #${i}`);
    const length = reader.u32();
    const events = parseTrack(reader, length);
    rawTracks.push(events);
    for (const e of events) if (e.kind === "tempo") tempoMap.push(e);
  }
  tempoMap.sort((a, b) => a.tick - b.tick);
  const tickToSeconds = makeTickToSeconds(tempoMap, ticksPerQuarter);

  // Second pass: pair note-ons with note-offs into timed notes.
  const tracks = rawTracks.map((events, i) => {
    let name = `Track ${i + 1}`;
    const notes = [];
    const open = new Map(); // pitch -> start tick (last note-on)
    for (const e of events) {
      if (e.kind === "name" && e.text.trim()) name = e.text.trim();
      else if (e.kind === "on") open.set(e.note, e.tick);
      else if (e.kind === "off" && open.has(e.note)) {
        const startTick = open.get(e.note);
        open.delete(e.note);
        notes.push({
          start: tickToSeconds(startTick),
          end: tickToSeconds(e.tick),
          pitch: e.note,
        });
      }
    }
    return { name, notes };
  });

  return { tracks: tracks.filter((t) => t.notes.length > 0) };
}