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>
272 lines
10 KiB
TypeScript
272 lines
10 KiB
TypeScript
"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>
|
|
);
|
|
}
|