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

+271
View File
@@ -0,0 +1,271 @@
"use client";
import { NoteEditor } from "@/components/note-editor";
import { ScoreViews } from "@/components/tab-score";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import type { FingeringSystem } from "@/lib/fingering";
import { SAMPLES } from "@/lib/samples";
import { blankScore, type Score } from "@/lib/score";
import { Loader2, Music2, Upload } from "lucide-react";
import { useEffect, useId, useState } from "react";
type Status = "empty" | "loading" | "ready" | "error";
type RecognizerInfo = {
available: boolean;
name: string;
detail: string;
};
export function Converter() {
const inputId = useId();
const [score, setScore] = useState<Score | null>(null);
const [system, setSystem] = useState<FingeringSystem>("baroque");
const [status, setStatus] = useState<Status>("empty");
const [error, setError] = useState<string | null>(null);
const [warnings, setWarnings] = useState<string[]>([]);
const [recognizer, setRecognizer] = useState<RecognizerInfo | null>(null);
const [dragOver, setDragOver] = useState(false);
useEffect(() => {
let cancelled = false;
fetch("/api/recognize")
.then((response) => response.json())
.then((info: RecognizerInfo) => {
if (!cancelled) setRecognizer(info);
})
.catch(() => {
if (!cancelled) {
setRecognizer({
available: false,
name: "oemer",
detail: "Could not check the local reader. Samples and the editor still work.",
});
}
});
return () => {
cancelled = true;
};
}, []);
function loadScore(next: Score) {
setScore(structuredClone(next));
setWarnings([]);
setError(null);
setStatus("ready");
}
async function onFile(file: File) {
setError(null);
const allowed =
["image/png", "image/jpeg", "image/webp"].includes(file.type) ||
/\.(png|jpe?g|webp)$/i.test(file.name);
if (file.size === 0) {
setError("That file is empty. No notes were invented.");
setScore((current) => current ?? blankScore());
setStatus("error");
return;
}
if (!allowed) {
setError("Use a PNG, JPG, or WEBP photo. That file is not one of those. No notes were invented.");
setScore((current) => current ?? blankScore());
setStatus("error");
return;
}
if (file.size > 8 * 1024 * 1024) {
setError("That image is over 8 MB. Crop the line or export a smaller JPEG.");
setScore((current) => current ?? blankScore());
setStatus("error");
return;
}
setStatus("loading");
try {
const body = new FormData();
body.set("image", file);
const response = await fetch("/api/recognize", { method: "POST", body });
const data = (await response.json()) as {
error?: string;
score?: Score;
warnings?: string[];
};
if (!response.ok || data.error || !data.score) {
setError(data.error ?? "This image could not be read. No notes were invented.");
setWarnings([]);
setScore((current) => current ?? blankScore());
setStatus("error");
return;
}
setScore(data.score);
setWarnings(data.warnings ?? []);
setStatus("ready");
} catch {
setError("The reader did not answer. No notes were invented. You can still enter the line by hand.");
setScore((current) => current ?? blankScore());
setStatus("error");
}
}
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 py-6 md:px-8 md:py-10">
<header className="space-y-3">
<p className="text-sm tracking-[0.16em] text-[#6a4328] uppercase">Soprano in C · concert pitch</p>
<h1 className="font-heading text-4xl leading-tight md:text-5xl">Recorder Tab Converter</h1>
<p className="max-w-2xl text-base leading-7 text-muted-foreground">
Turn a lead melody into fingering tabs a soprano player can read: thumb and holes, the note
name, and the rhythm, lined up in bars. Chords keep the top note. A second staff is ignored.
</p>
</header>
<section
className={`rounded-3xl bg-card p-4 ring-1 ring-foreground/10 md:p-6 ${dragOver ? "ring-2 ring-primary" : ""}`}
onDragOver={(event) => {
event.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(event) => {
event.preventDefault();
setDragOver(false);
const file = event.dataTransfer.files[0];
if (file) void onFile(file);
}}
>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h2 className="font-heading text-2xl">Photo or scan</h2>
<p className="max-w-xl text-sm leading-6 text-muted-foreground">
{recognizer?.detail ?? "Checking the local reader…"}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
disabled={status === "loading"}
nativeButton={false}
render={<label htmlFor={inputId} />}
>
{status === "loading" ? <Loader2 className="animate-spin" /> : <Upload />}
{status === "loading" ? "Reading the page" : "Choose image"}
</Button>
<input
id={inputId}
className="sr-only"
type="file"
accept="image/png,image/jpeg,image/webp,.png,.jpg,.jpeg,.webp"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void onFile(file);
}}
/>
</div>
</div>
{status === "loading" ? (
<p className="mt-4 text-sm text-muted-foreground" role="status">
oemer is reading the page on this computer. A dense photo can take a few minutes. Nothing
is uploaded.
</p>
) : (
<p className="mt-4 text-sm text-muted-foreground">
Drop a PNG, JPG, or WEBP here, or open a sample below if you want to see the tabs before a
photo works.
</p>
)}
</section>
{error ? (
<Alert variant="destructive">
<AlertTitle>The page was not read</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{warnings.length > 0 ? (
<Alert>
<AlertTitle>What the reader kept</AlertTitle>
<AlertDescription>
<ul className="list-disc pl-4">
{warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
</AlertDescription>
</Alert>
) : null}
<section className="grid gap-3 sm:grid-cols-2">
{SAMPLES.map((sample) => (
<button
key={sample.id}
type="button"
onClick={() => loadScore(sample.score)}
className="rounded-2xl bg-card p-4 text-left ring-1 ring-foreground/10 transition hover:ring-primary"
>
<span className="flex items-center gap-2 font-heading text-xl">
<Music2 className="size-4 text-[#6a4328]" />
{sample.score.title}
</span>
<span className="mt-2 block text-sm leading-6 text-muted-foreground">{sample.blurb}</span>
</button>
))}
</section>
{score ? (
<>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="font-heading text-3xl">{score.title}</h2>
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted-foreground">{score.attribution}</p>
</div>
<div className="space-y-1">
<p className="text-xs tracking-wide text-muted-foreground uppercase">Fingering</p>
<ToggleGroup
value={[system]}
spacing={0}
variant="outline"
onValueChange={(values) => {
const picked = values[values.length - 1];
if (picked === "baroque" || picked === "german") setSystem(picked);
}}
aria-label="Fingering system"
>
<ToggleGroupItem value="baroque">Baroque</ToggleGroupItem>
<ToggleGroupItem value="german">German</ToggleGroupItem>
</ToggleGroup>
<p className="max-w-sm text-xs leading-5 text-muted-foreground">
Baroque is the Dolmetsch chart and the default. German only changes F, F♯, and B♭.
</p>
</div>
</div>
<div className="grid items-start gap-6 lg:grid-cols-[minmax(18rem,22rem)_minmax(0,1fr)]">
<div className="order-2 space-y-3 lg:order-1 lg:sticky lg:top-4 lg:max-h-[calc(100vh-2rem)] lg:overflow-y-auto lg:pr-1">
<h2 className="font-heading text-2xl">Note list</h2>
<p className="text-sm leading-6 text-muted-foreground">
Pitch, rest, and duration. Change anything the photo got wrong. The tabs follow this
list, including the bar lines.
</p>
<NoteEditor score={score} onChange={setScore} />
</div>
<div className="order-1 min-w-0 lg:order-2">
<ScoreViews score={score} system={system} />
</div>
</div>
</>
) : (
<div className="rounded-3xl border border-dashed border-foreground/15 px-4 py-10 text-center">
<p className="font-heading text-2xl">No line loaded yet</p>
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-muted-foreground">
Open Twinkle or Ode to Joy to see fingerings and rhythm immediately, or start an empty
bar and type the notes yourself.
</p>
<Button type="button" className="mt-4" variant="secondary" onClick={() => loadScore(blankScore())}>
Start an empty line
</Button>
</div>
)}
</div>
);
}