import { XMLParser } from "fast-xml-parser"; import { durationLabel } from "@/lib/duration"; import { parsePitch } from "@/lib/pitch"; import { newId, type Measure, type Score, type ScoreEvent, type TimeSignature } from "@/lib/score"; type XNode = Record; export type ParseSuccess = { ok: true; score: Score; warnings: string[]; }; export type ParseFailure = { ok: false; error: string; }; const parser = new XMLParser({ preserveOrder: true, ignoreAttributes: false, attributeNamePrefix: "@_", removeNSPrefix: true, trimValues: true, }); function isNode(value: unknown): value is XNode { return typeof value === "object" && value !== null && !Array.isArray(value); } function tagOf(node: XNode): string | null { return Object.keys(node).find((key) => key !== ":@" ) ?? null; } function children(node: XNode): XNode[] { const tag = tagOf(node); if (!tag) return []; const value = node[tag]; if (!Array.isArray(value)) return []; return value.filter(isNode); } function textOf(node: XNode): string { const tag = tagOf(node); if (!tag) return ""; const value = node[tag]; if (typeof value === "string" || typeof value === "number") return String(value).trim(); if (!Array.isArray(value)) return ""; for (const item of value) { if (isNode(item) && "#text" in item && item["#text"] != null) return String(item["#text"]).trim(); } return ""; } function attrs(node: XNode): Record { const raw = node[":@"]; if (!isNode(raw)) return {}; const result: Record = {}; for (const [key, value] of Object.entries(raw)) result[key.replace(/^@_/, "")] = String(value); return result; } function findChild(nodes: XNode[], name: string): XNode | undefined { return nodes.find((node) => tagOf(node) === name); } function childText(nodes: XNode[], name: string): string | null { const child = findChild(nodes, name); if (!child) return null; const text = textOf(child); return text === "" ? null : text; } function hasTag(nodes: XNode[], name: string): boolean { return nodes.some((node) => tagOf(node) === name); } function findDeep(nodes: XNode[], name: string): XNode | undefined { for (const node of nodes) { if (tagOf(node) === name) return node; const nested = findDeep(children(node), name); if (nested) return nested; } return undefined; } function tieStarts(nodes: XNode[]): boolean { for (const node of nodes) { const tag = tagOf(node); if (tag === "tie" && attrs(node).type === "start") return true; if (tag === "notations") { for (const inner of children(node)) { if (tagOf(inner) === "tied" && attrs(inner).type === "start") return true; } } } return false; } function pitchFrom(noteKids: XNode[]): { name: string; midi: number } | null { const pitch = findChild(noteKids, "pitch"); if (!pitch) return null; const kids = children(pitch); const step = childText(kids, "step"); const octave = childText(kids, "octave"); if (!step || !octave) return null; const alter = Number(childText(kids, "alter") ?? "0"); const accidental = alter === 1 ? "#" : alter === -1 ? "b" : alter === 2 ? "##" : alter === -2 ? "bb" : ""; if (![0, 1, -1, 2, -2].includes(alter)) return null; const parsed = parsePitch(`${step}${accidental}${octave}`); return parsed ? { name: parsed.name, midi: parsed.midi } : null; } type Candidate = { onset: number; kind: "note" | "rest"; midi: number; pitch: string | null; quarters: number; label: string; tied: boolean; }; function onsetKey(value: number): number { return Math.round(value * 1000) / 1000; } export function parseMusicXml(xml: string): ParseSuccess | ParseFailure { const cleaned = xml.replace(//gi, ""); let root: unknown; try { root = parser.parse(cleaned); } catch { return { ok: false, error: "The recognizer returned text that is not MusicXML." }; } if (!Array.isArray(root)) return { ok: false, error: "The recognizer returned text that is not MusicXML." }; const scoreNode = findDeep(root.filter(isNode), "score-partwise"); if (!scoreNode) { return { ok: false, error: "The recognizer did not return a partwise score. No notes were invented.", }; } const top = children(scoreNode); const work = findChild(top, "work"); const title = (work && childText(children(work), "work-title")) || childText(top, "movement-title") || "Uploaded page"; const parts = top.filter((node) => tagOf(node) === "part"); if (parts.length === 0) { return { ok: false, error: "The score has no part to read as a lead line." }; } const warnings: string[] = []; if (parts.length > 1) warnings.push("Only the first part was read. Other parts were left out."); let divisions = 1; let timeSignature: TimeSignature = { beats: 4, beatType: 4 }; let sawTime = false; let timeChanged = false; let sawLowerStaff = false; let reduced = false; let sawGrace = false; let sawTie = false; const measures: Measure[] = []; for (const [measureIndex, measureNode] of children(parts[0]).filter((node) => tagOf(node) === "measure").entries()) { let cursor = 0; let lastDuration = 0; const grouped = new Map(); for (const element of children(measureNode)) { const tag = tagOf(element); if (tag === "attributes") { const kids = children(element); const nextDivisions = childText(kids, "divisions"); if (nextDivisions && Number(nextDivisions) > 0) divisions = Number(nextDivisions); const time = findChild(kids, "time"); if (time) { const timeKids = children(time); const beats = Number(childText(timeKids, "beats")); const beatType = Number(childText(timeKids, "beat-type")); if (beats > 0 && beatType > 0) { if (!sawTime) { timeSignature = { beats, beatType }; sawTime = true; } else if (timeSignature.beats !== beats || timeSignature.beatType !== beatType) { timeChanged = true; } } } continue; } if (tag === "backup" || tag === "forward") { const delta = Number(childText(children(element), "duration") ?? "0"); cursor += tag === "backup" ? -delta : delta; continue; } if (tag !== "note") continue; const kids = children(element); if (hasTag(kids, "grace")) { sawGrace = true; continue; } const isChord = hasTag(kids, "chord"); const duration = Number(childText(kids, "duration") ?? "0"); const staff = childText(kids, "staff"); if (staff && staff !== "1") { sawLowerStaff = true; if (!isChord) { cursor += duration; lastDuration = duration; } continue; } const onset = onsetKey(isChord ? cursor - lastDuration : cursor); if (!isChord) { cursor += duration; lastDuration = duration; } const type = childText(kids, "type"); const dots = kids.filter((node) => tagOf(node) === "dot").length; const modification = findChild(kids, "time-modification"); let tuplet = false; if (modification) { const actual = Number(childText(children(modification), "actual-notes")); const normal = Number(childText(children(modification), "normal-notes")); tuplet = actual === 3 && normal === 2; } const quarters = divisions > 0 ? duration / divisions : 0; const tied = tieStarts(kids); if (tied) sawTie = true; if (hasTag(kids, "rest")) { const list = grouped.get(onset) ?? []; list.push({ onset, kind: "rest", midi: -1, pitch: null, quarters, label: durationLabel(quarters, type, dots, tuplet), tied: false, }); grouped.set(onset, list); continue; } const pitch = pitchFrom(kids); if (!pitch) continue; const list = grouped.get(onset) ?? []; list.push({ onset, kind: "note", midi: pitch.midi, pitch: pitch.name, quarters, label: durationLabel(quarters, type, dots, tuplet), tied, }); grouped.set(onset, list); } const events: ScoreEvent[] = []; for (const onset of [...grouped.keys()].sort((a, b) => a - b)) { const candidates = grouped.get(onset) ?? []; const notes = candidates.filter((candidate) => candidate.kind === "note"); if (notes.length > 1) reduced = true; const chosen = notes.length > 0 ? notes.reduce((best, note) => (note.midi > best.midi ? note : best)) : candidates[0]; if (!chosen || chosen.quarters <= 0) continue; events.push({ id: newId("e"), kind: chosen.kind, pitch: chosen.pitch, quarters: chosen.quarters, label: chosen.label, tied: chosen.tied, }); } measures.push({ id: newId("m"), events }); void measureIndex; } if (sawLowerStaff) warnings.push("Notes on lower staves were left out. The tab follows the top staff."); if (reduced) warnings.push("Chords and overlapping voices were reduced to the highest note."); if (sawGrace) warnings.push("Grace notes were left out so the beat stays aligned."); if (timeChanged) warnings.push("A later time signature was ignored. The tab uses the first one."); if (sawTie) warnings.push("Tied notes stay separate so the bar lines do not move."); const eventCount = measures.reduce((total, measure) => total + measure.events.length, 0); if (eventCount === 0) { return { ok: false, error: "The recognizer finished, but it did not find a lead note or a rest. No notes were invented.", }; } return { ok: true, warnings, score: { title, attribution: "Read from your image. Correct anything that looks wrong before you play.", timeSignature, measures: measures.length > 0 ? measures : [{ id: newId("m"), events: [] }], origin: "upload", }, }; }