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:
55 files changed
+17536
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user