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>
320 lines
9.9 KiB
TypeScript
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",
|
|
},
|
|
};
|
|
}
|