Files
Recorder-Tabber/src/lib/musicxml.ts
T
Cursor Agentandchen 59d65333e3 Add a soprano recorder tab converter as a Linux AppImage.
The app turns a lead line into Baroque fingering tabs with rhythm kept, ships Twinkle and Ode to Joy, and reads photos locally with oemer when the bundled Python is present.

Co-authored-by: catpersonisme <catpersonisme@gmail.com>
2026-10-09 03:35:16 +00:00

320 lines
9.9 KiB
TypeScript

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<string, unknown>;
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<string, string> {
const raw = node[":@"];
if (!isNode(raw)) return {};
const result: Record<string, string> = {};
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(/<!DOCTYPE[\s\S]*?>/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<number, Candidate[]>();
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",
},
};
}