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>
This commit is contained in:
Cursor Agentandchen committed 2026-10-09 03:35:16 +00:00
1 parent 72c2f509f6
commit 59d65333e3
55 files changed
+17536

No files matched your search

+201
View File
@@ -0,0 +1,201 @@
import { spawn } from "node:child_process";
import { existsSync, readdirSync } from "node:fs";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { parseMusicXml } from "@/lib/musicxml";
import type { Score } from "@/lib/score";
export type RecognizerInfo = {
available: boolean;
name: string;
detail: string;
};
export type RecognizeSuccess = {
ok: true;
score: Score;
warnings: string[];
recognizer: string;
};
export type RecognizeFailure = {
ok: false;
error: string;
status: number;
recognizer: string;
};
export function resolvePython(): string | null {
const fromEnv = process.env.OEMER_PYTHON;
if (fromEnv && existsSync(fromEnv)) return fromEnv;
return null;
}
function modelsReady(python: string): boolean {
const lib = path.resolve(path.dirname(python), "..", "lib");
if (!existsSync(lib)) return false;
for (const entry of readdirSync(lib)) {
const model = path.join(
lib,
entry,
"site-packages",
"oemer",
"checkpoints",
"unet_big",
"model.onnx",
);
if (existsSync(model)) return true;
}
return false;
}
export function recognizerInfo(): RecognizerInfo {
const python = resolvePython();
if (!python) {
return {
available: false,
name: "oemer",
detail:
"The local reader (oemer) is not installed. Sample tunes and the note editor still work. No photo is turned into guessed notes.",
};
}
if (!modelsReady(python)) {
return {
available: false,
name: "oemer 0.1.8",
detail:
"oemer is installed, but its model files are missing, so a photo cannot be read. Sample tunes and the editor still work.",
};
}
return {
available: true,
name: "oemer 0.1.8",
detail:
"Photos are read on this computer by oemer. It misses notes. Correct the list; nothing is sent away, and a failed read does not invent notes.",
};
}
function scriptPath(): string {
const candidates = [
path.join(process.cwd(), "scripts", "run_oemer.py"),
path.join(process.cwd(), "..", "scripts", "run_oemer.py"),
];
return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
}
function runProcess(
command: string,
args: string[],
timeoutMs: number,
): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
env: {
...process.env,
MPLBACKEND: "Agg",
PYTHONUNBUFFERED: "1",
},
});
let stdout = "";
let stderr = "";
const timer = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error("timeout"));
}, timeoutMs);
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", (error) => {
clearTimeout(timer);
reject(error);
});
child.on("close", (code) => {
clearTimeout(timer);
resolve({ code: code ?? 1, stdout, stderr });
});
});
}
function shortFailure(stderr: string, stdout: string): string {
const lines = `${stderr}\n${stdout}`
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.filter((line) => !line.startsWith("MUSICXML="));
const errorLine = [...lines].reverse().find((line) => /Error|Exception|assert|too small|could not/i.test(line));
const text = errorLine ?? lines.at(-1) ?? "The recognizer stopped without a result.";
return text.slice(0, 280);
}
export async function recognizeImage(file: File): Promise<RecognizeSuccess | RecognizeFailure> {
const info = recognizerInfo();
if (!info.available) {
return { ok: false, error: info.detail, status: 503, recognizer: info.name };
}
const python = resolvePython();
if (!python) {
return { ok: false, error: info.detail, status: 503, recognizer: info.name };
}
const directory = await mkdtemp(path.join(tmpdir(), "recorder-tab-"));
try {
const inputPath = path.join(directory, "upload.bin");
await writeFile(inputPath, Buffer.from(await file.arrayBuffer()));
const script = scriptPath();
let result: { code: number; stdout: string; stderr: string };
try {
result = await runProcess(python, [script, inputPath, directory], 240_000);
} catch (error) {
const timedOut = error instanceof Error && error.message === "timeout";
return {
ok: false,
status: 422,
recognizer: info.name,
error: timedOut
? "Reading this page took too long and was stopped. No notes were invented. Try a clearer crop of one line, or enter the notes by hand."
: "The local reader could not be started. No notes were invented.",
};
}
const marker = result.stdout
.split("\n")
.map((line) => line.trim())
.find((line) => line.startsWith("MUSICXML="));
if (result.code !== 0 || !marker) {
return {
ok: false,
status: 422,
recognizer: info.name,
error: `This image could not be read. ${shortFailure(result.stderr, result.stdout)} No notes were invented.`,
};
}
const xmlPath = marker.slice("MUSICXML=".length);
const xml = await readFile(xmlPath, "utf8");
const parsed = parseMusicXml(xml);
if (!parsed.ok) {
return { ok: false, status: 422, recognizer: info.name, error: parsed.error };
}
const title = file.name.replace(/\.[^.]+$/, "").trim();
return {
ok: true,
recognizer: info.name,
warnings: parsed.warnings,
score: {
...parsed.score,
title: title || parsed.score.title,
origin: "upload",
attribution: `Read locally by ${info.name}. Correct any wrong pitch or rhythm before you play.`,
},
};
} finally {
await rm(directory, { recursive: true, force: true });
}
}