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)));
}
