HomeMAL' // MANUFACTORUM
datacrypt
Blob Opera MIDI / public / lib / musicxml.js
public/lib/musicxml.js9.8 KB
// Dependency-free MusicXML parser (score-partwise), plus .mxl (zip) support.
//
// Produces the same track shape as the MIDI parser — plus per-note lyrics —
// so the rest of the pipeline is shared:
//   { tracks: [{ name, notes: [{start,end,pitch}], events: [{start,duration,pitch,lyric}] }] }
//
// Uses a small built-in XML parser instead of DOMParser so the module also
// runs under Node for tests.

// --- minimal XML parser ------------------------------------------------------

function decodeEntities(text) {
  return text.replace(/&(#x?[0-9a-fA-F]+|\w+);/g, (m, ent) => {
    if (ent[0] === "#") {
      const code = ent[1] === "x" || ent[1] === "X" ? parseInt(ent.slice(2), 16) : parseInt(ent.slice(1), 10);
      return String.fromCodePoint(code);
    }
    return { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" }[ent] ?? m;
  });
}

function parseXml(source) {
  let pos = 0;
  const root = { tag: "#root", attrs: {}, children: [], text: "" };
  const stack = [root];

  while (pos < source.length) {
    const lt = source.indexOf("<", pos);
    if (lt === -1) break;
    // Text between tags belongs to the current element.
    const text = source.slice(pos, lt);
    if (text.trim()) stack[stack.length - 1].text += decodeEntities(text);

    if (source.startsWith("<!--", lt)) {
      pos = source.indexOf("-->", lt) + 3;
    } else if (source.startsWith("<?", lt)) {
      pos = source.indexOf("?>", lt) + 2;
    } else if (source.startsWith("<!", lt)) {
      // DOCTYPE — may contain an [internal subset].
      let i = lt;
      let depth = 0;
      for (; i < source.length; i++) {
        if (source[i] === "[") depth++;
        else if (source[i] === "]") depth--;
        else if (source[i] === ">" && depth === 0) break;
      }
      pos = i + 1;
    } else if (source[lt + 1] === "/") {
      pos = source.indexOf(">", lt) + 1;
      if (stack.length > 1) stack.pop();
    } else {
      const end = source.indexOf(">", lt);
      let raw = source.slice(lt + 1, end);
      const selfClosing = raw.endsWith("/");
      if (selfClosing) raw = raw.slice(0, -1);
      const space = raw.search(/[\s]/);
      const tag = space === -1 ? raw : raw.slice(0, space);
      const attrs = {};
      if (space !== -1) {
        for (const m of raw.slice(space).matchAll(/([\w:-]+)\s*=\s*"([^"]*)"/g)) {
          attrs[m[1]] = decodeEntities(m[2]);
        }
      }
      const node = { tag, attrs, children: [], text: "" };
      stack[stack.length - 1].children.push(node);
      if (!selfClosing) stack.push(node);
      pos = end + 1;
    }
  }
  return root;
}

const find = (node, tag) => node.children.find((c) => c.tag === tag);
const findAll = (node, tag) => node.children.filter((c) => c.tag === tag);
const textOf = (node, tag) => find(node, tag)?.text.trim();

// --- MusicXML → tracks -------------------------------------------------------

const STEP_SEMITONES = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };

function noteToMidi(pitchNode) {
  const step = textOf(pitchNode, "step");
  const octave = parseInt(textOf(pitchNode, "octave"), 10);
  const alter = parseFloat(textOf(pitchNode, "alter") ?? "0");
  return (octave + 1) * 12 + STEP_SEMITONES[step] + alter;
}

/**
 * Parse a MusicXML document (uncompressed text).
 * Times are in seconds, resolved through the score's tempo directions.
 */
export function parseMusicXml(source) {
  const doc = parseXml(source);
  const score = find(doc, "score-partwise");
  if (!score) throw new Error("not a MusicXML file (expected score-partwise)");

  // Part id → human name.
  const names = {};
  const partList = find(score, "part-list");
  if (partList) {
    for (const sp of findAll(partList, "score-part")) {
      names[sp.attrs.id] = textOf(sp, "part-name") || sp.attrs.id;
    }
  }

  // First pass: walk each part, collecting note events with offsets in
  // *quarter notes*, and a global tempo map.
  const tempoMap = []; // { q, bpm }
  const rawParts = [];

  for (const part of findAll(score, "part")) {
    const events = []; // { q, quarters, pitch, lyric }
    let divisions = 1;
    let q = 0; // current offset in quarter notes
    let firstVoice = null;

    for (const measure of findAll(part, "measure")) {
      for (const el of measure.children) {
        if (el.tag === "attributes") {
          const d = textOf(el, "divisions");
          if (d) divisions = parseInt(d, 10);
        } else if (el.tag === "direction" || el.tag === "sound") {
          const sound = el.tag === "sound" ? el : find(el, "sound");
          const tempo = sound?.attrs.tempo;
          if (tempo) tempoMap.push({ q, bpm: parseFloat(tempo) });
        } else if (el.tag === "backup") {
          q -= parseInt(textOf(el, "duration"), 10) / divisions;
        } else if (el.tag === "forward") {
          q += parseInt(textOf(el, "duration"), 10) / divisions;
        } else if (el.tag === "note") {
          const durText = textOf(el, "duration");
          if (!durText) continue; // grace note
          const quarters = parseInt(durText, 10) / divisions;
          const isChord = !!find(el, "chord");
          const voice = textOf(el, "voice") ?? "1";
          if (firstVoice === null) firstVoice = voice;

          if (voice === firstVoice) {
            const pitchNode = find(el, "pitch");
            if (isChord) {
              // Chord member: keep the highest pitch, don't advance time.
              const prev = events[events.length - 1];
              if (prev && pitchNode) prev.pitch = Math.max(prev.pitch ?? -1, noteToMidi(pitchNode));
            } else if (pitchNode) {
              const tieStop = findAll(el, "tie").some((t) => t.attrs.type === "stop");
              const pitch = noteToMidi(pitchNode);
              const prev = events[events.length - 1];
              if (tieStop && prev && prev.pitch === pitch && Math.abs(prev.q + prev.quarters - q) < 1e-6) {
                prev.quarters += quarters; // merge tied note
              } else {
                const lyricNode = find(el, "lyric");
                const lyric = lyricNode ? textOf(lyricNode, "text") || null : null;
                events.push({ q, quarters, pitch, lyric });
              }
            }
            // Rests are skipped: gaps become silence downstream.
          }
          if (!isChord) q += quarters;
        }
      }
    }
    rawParts.push({ name: names[part.attrs.id] || part.attrs.id, events });
  }

  // Second pass: quarter-note offsets → seconds through the tempo map.
  tempoMap.sort((a, b) => a.q - b.q);
  const toSeconds = (q) => {
    let seconds = 0;
    let lastQ = 0;
    let bpm = 120;
    for (const t of tempoMap) {
      if (t.q >= q) break;
      seconds += ((t.q - lastQ) * 60) / bpm;
      lastQ = t.q;
      bpm = t.bpm;
    }
    return seconds + ((q - lastQ) * 60) / bpm;
  };

  const tracks = rawParts
    .map(({ name, events }) => {
      const timed = events.map((e) => ({
        start: toSeconds(e.q),
        duration: toSeconds(e.q + e.quarters) - toSeconds(e.q),
        pitch: e.pitch,
        lyric: e.lyric,
      }));
      return {
        name,
        events: timed,
        notes: timed.map((e) => ({ start: e.start, end: e.start + e.duration, pitch: e.pitch })),
      };
    })
    .filter((t) => t.notes.length > 0);

  return { tracks };
}

// --- .mxl (zipped MusicXML) --------------------------------------------------

// Minimal zip reader: walks the central directory, inflates with the
// browser/Node built-in DecompressionStream.
async function unzip(bytes) {
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
  // Find end-of-central-directory record (scan backwards for its signature).
  let eocd = -1;
  for (let i = bytes.length - 22; i >= 0; i--) {
    if (view.getUint32(i, true) === 0x06054b50) {
      eocd = i;
      break;
    }
  }
  if (eocd === -1) throw new Error("not a zip file");
  const count = view.getUint16(eocd + 10, true);
  let offset = view.getUint32(eocd + 16, true);

  const files = new Map();
  for (let i = 0; i < count; i++) {
    if (view.getUint32(offset, true) !== 0x02014b50) break;
    const method = view.getUint16(offset + 10, true);
    const compressedSize = view.getUint32(offset + 20, true);
    const nameLength = view.getUint16(offset + 28, true);
    const extraLength = view.getUint16(offset + 30, true);
    const commentLength = view.getUint16(offset + 32, true);
    const localOffset = view.getUint32(offset + 42, true);
    const name = new TextDecoder().decode(bytes.subarray(offset + 46, offset + 46 + nameLength));

    // Local header repeats name/extra with possibly different extra length.
    const localName = view.getUint16(localOffset + 26, true);
    const localExtra = view.getUint16(localOffset + 28, true);
    const dataStart = localOffset + 30 + localName + localExtra;
    const data = bytes.subarray(dataStart, dataStart + compressedSize);

    if (method === 0) {
      files.set(name, data);
    } else if (method === 8) {
      const stream = new Blob([data]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
      files.set(name, new Uint8Array(await new Response(stream).arrayBuffer()));
    }
    offset += 46 + nameLength + extraLength + commentLength;
  }
  return files;
}

/** Extract and parse a compressed .mxl file. */
export async function parseMxl(bytes) {
  const files = await unzip(bytes);
  const decoder = new TextDecoder();

  // META-INF/container.xml names the root score file; fall back to any xml.
  let rootPath = null;
  const container = files.get("META-INF/container.xml");
  if (container) rootPath = decoder.decode(container).match(/full-path="([^"]+)"/)?.[1] ?? null;
  if (!rootPath || !files.has(rootPath)) {
    rootPath = [...files.keys()].find((n) => !n.startsWith("META-INF") && /\.(musicxml|xml)$/i.test(n));
  }
  if (!rootPath) throw new Error("no MusicXML document found inside .mxl");
  return parseMusicXml(decoder.decode(files.get(rootPath)));
}