Drag Baroque charts onto a line, reorder them, set a duration, or remove a note. The AppImage no longer packages oemer, ONNX, or the models. Version 0.2.0. Co-authored-by: catpersonisme <catpersonisme@gmail.com>
This commit is contained in:
22 files changed
+569
-2231
No files matched your search
@@ -1,52 +0,0 @@
|
||||
import { connection } from "next/server";
|
||||
import { recognizeImage, recognizerInfo } from "@/lib/recognize";
|
||||
|
||||
const TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
|
||||
|
||||
export async function GET() {
|
||||
await connection();
|
||||
return Response.json(recognizerInfo());
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let form: FormData;
|
||||
try {
|
||||
form = await request.formData();
|
||||
} catch {
|
||||
return Response.json({ error: "The upload could not be read. No notes were invented." }, { status: 400 });
|
||||
}
|
||||
|
||||
const image = form.get("image");
|
||||
if (!(image instanceof File)) {
|
||||
return Response.json({ error: "Choose a PNG, JPG, or WEBP image first." }, { status: 400 });
|
||||
}
|
||||
if (image.size === 0) {
|
||||
return Response.json(
|
||||
{ error: "That file is empty. No notes were invented." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const named = /\.(png|jpe?g|webp)$/i.test(image.name);
|
||||
if (image.type && !TYPES.has(image.type) && !named) {
|
||||
return Response.json(
|
||||
{ error: "Use a PNG, JPG, or WEBP photo. That file is not one of those. No notes were invented." },
|
||||
{ status: 415 },
|
||||
);
|
||||
}
|
||||
if (image.size > 8 * 1024 * 1024) {
|
||||
return Response.json(
|
||||
{ error: "That image is over 8 MB. Crop the line or export a smaller JPEG." },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await recognizeImage(image);
|
||||
if (!result.ok) {
|
||||
return Response.json({ error: result.error, recognizer: result.recognizer }, { status: result.status });
|
||||
}
|
||||
return Response.json({
|
||||
score: result.score,
|
||||
warnings: result.warnings,
|
||||
recognizer: result.recognizer,
|
||||
});
|
||||
}
|
||||
+2
-2
@@ -16,9 +16,9 @@ const display = Fraunces({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Recorder Tab Converter",
|
||||
title: "Recorder fingering editor",
|
||||
description:
|
||||
"Turn a lead melody into soprano recorder fingering tabs, with rhythm, rests, and bar lines kept.",
|
||||
"Drag soprano recorder fingering charts onto a line, reorder them, and set how long each note lasts.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
import { Converter } from "@/components/converter";
|
||||
import { Editor } from "@/components/editor";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main>
|
||||
<Converter />
|
||||
<Editor />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
"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. Each chart has the foot at
|
||||
the top and the mouthpiece at the bottom, with 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
"use client";
|
||||
|
||||
import { BeatArrow, FingeringDiagram } from "@/components/fingering-diagram";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DURATION_PRESETS } from "@/lib/duration";
|
||||
import { CHART_NOTES, fingeringFor } from "@/lib/fingering";
|
||||
import {
|
||||
cloneLine,
|
||||
createNote,
|
||||
dropIndexFromPoint,
|
||||
insertNote,
|
||||
LINE_EXAMPLES,
|
||||
moveNote,
|
||||
removeNote,
|
||||
setDuration,
|
||||
type LineNote,
|
||||
} from "@/lib/line";
|
||||
import { extraBeatArrows } from "@/lib/score";
|
||||
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type RefObject } from "react";
|
||||
|
||||
const FOUR_FOUR = { beats: 4, beatType: 4 };
|
||||
|
||||
type Drag =
|
||||
| {
|
||||
source: "palette";
|
||||
pitch: string;
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
x: number;
|
||||
y: number;
|
||||
armed: boolean;
|
||||
}
|
||||
| {
|
||||
source: "line";
|
||||
id: string;
|
||||
pitch: string;
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
x: number;
|
||||
y: number;
|
||||
armed: boolean;
|
||||
};
|
||||
|
||||
export function Editor() {
|
||||
const [notes, setNotes] = useState<LineNote[]>([]);
|
||||
const [title, setTitle] = useState("Empty line");
|
||||
const [drag, setDrag] = useState<Drag | null>(null);
|
||||
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||
const dragRef = useRef<Drag | null>(null);
|
||||
const lineRef = useRef<HTMLDivElement>(null);
|
||||
const paletteRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
function updateDrag(next: Drag | null) {
|
||||
dragRef.current = next;
|
||||
setDrag(next);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function pointIndex(clientX: number, clientY: number) {
|
||||
const line = lineRef.current;
|
||||
if (!line) return -1;
|
||||
const rect = line.getBoundingClientRect();
|
||||
const centers = [...line.querySelectorAll<HTMLElement>("[data-note-id]")].map((element) => {
|
||||
const box = element.getBoundingClientRect();
|
||||
return box.left + box.width / 2;
|
||||
});
|
||||
return dropIndexFromPoint(clientX, clientY, rect, centers);
|
||||
}
|
||||
|
||||
function onMove(event: PointerEvent) {
|
||||
const current = dragRef.current;
|
||||
if (!current || event.pointerId !== current.pointerId) return;
|
||||
const dx = event.clientX - current.startX;
|
||||
const dy = event.clientY - current.startY;
|
||||
if (!current.armed) {
|
||||
const touchScroll =
|
||||
event.pointerType === "touch" &&
|
||||
current.source === "palette" &&
|
||||
Math.abs(dx) > 12 &&
|
||||
Math.abs(dx) > Math.abs(dy);
|
||||
if (touchScroll) {
|
||||
updateDrag(null);
|
||||
setDropIndex(null);
|
||||
return;
|
||||
}
|
||||
if (Math.hypot(dx, dy) < 8) return;
|
||||
}
|
||||
if (lineRef.current) {
|
||||
const rect = lineRef.current.getBoundingClientRect();
|
||||
if (event.clientX > rect.right - 28) lineRef.current.scrollLeft += 14;
|
||||
if (event.clientX < rect.left + 28) lineRef.current.scrollLeft -= 14;
|
||||
}
|
||||
const next = { ...current, x: event.clientX, y: event.clientY, armed: true };
|
||||
updateDrag(next);
|
||||
const index = pointIndex(event.clientX, event.clientY);
|
||||
setDropIndex(index >= 0 ? index : null);
|
||||
}
|
||||
|
||||
function onUp(event: PointerEvent) {
|
||||
const current = dragRef.current;
|
||||
if (!current || event.pointerId !== current.pointerId) return;
|
||||
const index = current.armed ? pointIndex(event.clientX, event.clientY) : -1;
|
||||
if (current.armed && index >= 0) {
|
||||
if (current.source === "palette") {
|
||||
setNotes((existing) => insertNote(existing, index, createNote(current.pitch)));
|
||||
setTitle("Your line");
|
||||
} else {
|
||||
setNotes((existing) => moveNote(existing, current.id, index));
|
||||
}
|
||||
}
|
||||
updateDrag(null);
|
||||
setDropIndex(null);
|
||||
}
|
||||
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onUp);
|
||||
window.addEventListener("pointercancel", onUp);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
window.removeEventListener("pointercancel", onUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function beginDrag(event: ReactPointerEvent, next: Drag) {
|
||||
if (event.button !== 0 && event.pointerType === "mouse") return;
|
||||
updateDrag(next);
|
||||
}
|
||||
|
||||
const ghostPitch = drag?.armed ? drag.pitch : null;
|
||||
|
||||
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 · Baroque</p>
|
||||
<h1 className="font-heading text-4xl leading-tight md:text-5xl">Recorder fingering editor</h1>
|
||||
<p className="max-w-2xl text-base leading-7 text-muted-foreground">
|
||||
Drag a chart from the palette onto the line. Drag a placed chart sideways to reorder it, set
|
||||
how long it lasts, or take it off. The foot, with the double holes, stays at the top.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={notes.length === 0 ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
setNotes([]);
|
||||
setTitle("Empty line");
|
||||
}}
|
||||
>
|
||||
Empty line
|
||||
</Button>
|
||||
{LINE_EXAMPLES.map((example) => (
|
||||
<Button
|
||||
key={example.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setNotes(cloneLine([...example.notes]));
|
||||
setTitle(example.title);
|
||||
}}
|
||||
>
|
||||
{example.title}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl">Palette</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
C5 through D7. Drag a chart downward onto the line. Swipe the row sideways to see the rest.
|
||||
</p>
|
||||
</div>
|
||||
<ScrollButtons target={paletteRef} label="palette" />
|
||||
</div>
|
||||
<div
|
||||
ref={paletteRef}
|
||||
className="flex gap-2 overflow-x-auto rounded-2xl bg-card p-3 ring-1 ring-foreground/10"
|
||||
>
|
||||
{CHART_NOTES.map((pitch) => (
|
||||
<div
|
||||
key={pitch}
|
||||
data-palette-pitch={pitch}
|
||||
className="flex w-14 shrink-0 cursor-grab touch-pan-x flex-col items-center gap-1 active:cursor-grabbing"
|
||||
onPointerDown={(event) =>
|
||||
beginDrag(event, {
|
||||
source: "palette",
|
||||
pitch,
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
armed: false,
|
||||
})
|
||||
}
|
||||
>
|
||||
<FingeringDiagram fingering={fingeringFor(pitch, "baroque")} pitch={pitch} size="sm" />
|
||||
<span className="text-[11px] leading-none font-medium">{pitch}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl">{title}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
4/4. An arrow after a chart is one more beat. A dotted value keeps its name under the chart.
|
||||
</p>
|
||||
</div>
|
||||
<ScrollButtons target={lineRef} label="line" />
|
||||
</div>
|
||||
<div
|
||||
ref={lineRef}
|
||||
data-line
|
||||
className={`min-h-64 overflow-x-auto rounded-none border-2 bg-card px-3 py-4 ${
|
||||
dropIndex !== null ? "border-primary" : "border-foreground"
|
||||
}`}
|
||||
>
|
||||
{notes.length === 0 ? (
|
||||
<p className="px-2 py-16 text-center text-sm text-muted-foreground">
|
||||
Drop a fingering here. The line is empty.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex w-max min-w-full items-end gap-2">
|
||||
{notes.map((note, index) => (
|
||||
<div key={note.id} className="flex items-end gap-2">
|
||||
{dropIndex === index ? <DropCaret /> : null}
|
||||
<PlacedNote
|
||||
note={note}
|
||||
dimmed={drag?.source === "line" && drag.armed && drag.id === note.id}
|
||||
onPointerDown={(event) =>
|
||||
beginDrag(event, {
|
||||
source: "line",
|
||||
id: note.id,
|
||||
pitch: note.pitch,
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
armed: false,
|
||||
})
|
||||
}
|
||||
onDuration={(quarters) => {
|
||||
const preset = DURATION_PRESETS.find((item) => item.quarters === quarters);
|
||||
if (!preset) return;
|
||||
setNotes((existing) => setDuration(existing, note.id, preset));
|
||||
}}
|
||||
onRemove={() => setNotes((existing) => removeNote(existing, note.id))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{dropIndex === notes.length ? <DropCaret /> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<li>Foot at the top, mouthpiece at the bottom</li>
|
||||
<li>Filled hole: closed</li>
|
||||
<li>Empty hole: open</li>
|
||||
<li>One of a pair filled: half-hole</li>
|
||||
<li>Thumb half-filled: pinch</li>
|
||||
<li>Arrow: one more beat</li>
|
||||
</ul>
|
||||
|
||||
{ghostPitch ? (
|
||||
<div
|
||||
className="pointer-events-none fixed z-50 opacity-90"
|
||||
style={{ left: drag!.x - 22, top: drag!.y - 80 }}
|
||||
aria-hidden
|
||||
>
|
||||
<FingeringDiagram fingering={fingeringFor(ghostPitch, "baroque")} pitch={ghostPitch} size="sm" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlacedNote({
|
||||
note,
|
||||
dimmed,
|
||||
onPointerDown,
|
||||
onDuration,
|
||||
onRemove,
|
||||
}: {
|
||||
note: LineNote;
|
||||
dimmed: boolean;
|
||||
onPointerDown: (event: ReactPointerEvent) => void;
|
||||
onDuration: (quarters: number) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const fingering = fingeringFor(note.pitch, "baroque");
|
||||
const arrows = extraBeatArrows(note.quarters, FOUR_FOUR);
|
||||
return (
|
||||
<div data-note-id={note.id} className={`flex flex-col items-center gap-1 ${dimmed ? "opacity-40" : ""}`}>
|
||||
<div
|
||||
className="flex cursor-grab touch-none items-center gap-1 active:cursor-grabbing"
|
||||
onPointerDown={onPointerDown}
|
||||
>
|
||||
<FingeringDiagram fingering={fingering} pitch={note.pitch} />
|
||||
{arrows > 0 ? (
|
||||
<span className="flex items-center gap-1" aria-hidden>
|
||||
{Array.from({ length: arrows }, (_, index) => (
|
||||
<BeatArrow key={index} />
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-[11px] leading-none font-medium">{note.pitch}</p>
|
||||
<label className="sr-only" htmlFor={`duration-${note.id}`}>
|
||||
Duration for {note.pitch}
|
||||
</label>
|
||||
<select
|
||||
id={`duration-${note.id}`}
|
||||
className="h-8 w-[4.5rem] rounded-md border border-input bg-card px-1 text-[11px]"
|
||||
value={String(note.quarters)}
|
||||
onChange={(event) => onDuration(Number(event.target.value))}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{DURATION_PRESETS.map((preset) => (
|
||||
<option key={preset.label} value={preset.quarters}>
|
||||
{preset.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
aria-label={`Remove ${note.pitch}`}
|
||||
onClick={onRemove}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<X />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DropCaret() {
|
||||
return <div className="w-1 self-stretch rounded-full bg-primary" aria-hidden />;
|
||||
}
|
||||
|
||||
function ScrollButtons({
|
||||
target,
|
||||
label,
|
||||
}: {
|
||||
target: RefObject<HTMLDivElement | null>;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={`Scroll ${label} left`}
|
||||
onClick={() => target.current?.scrollBy({ left: -180, behavior: "smooth" })}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={`Scroll ${label} right`}
|
||||
onClick={() => target.current?.scrollBy({ left: 180, behavior: "smooth" })}
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { DURATION_PRESETS, quartersMatch } from "@/lib/duration";
|
||||
import { CHART_NOTES } from "@/lib/fingering";
|
||||
import {
|
||||
barStatus,
|
||||
newId,
|
||||
parseSignature,
|
||||
signatureKey,
|
||||
TIME_SIGNATURES,
|
||||
type Score,
|
||||
type ScoreEvent,
|
||||
} from "@/lib/score";
|
||||
import { ChevronDown, ChevronUp, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
const fieldClass =
|
||||
"h-10 min-w-0 rounded-lg border border-input bg-card px-2 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50";
|
||||
|
||||
export function NoteEditor({
|
||||
score,
|
||||
onChange,
|
||||
}: {
|
||||
score: Score;
|
||||
onChange: (score: Score) => void;
|
||||
}) {
|
||||
const signatures = TIME_SIGNATURES.some(
|
||||
(item) =>
|
||||
item.beats === score.timeSignature.beats && item.beatType === score.timeSignature.beatType,
|
||||
)
|
||||
? TIME_SIGNATURES
|
||||
: [score.timeSignature, ...TIME_SIGNATURES];
|
||||
|
||||
function updateEvent(measureId: string, eventId: string, patch: Partial<ScoreEvent>) {
|
||||
onChange({
|
||||
...score,
|
||||
measures: score.measures.map((measure) =>
|
||||
measure.id !== measureId
|
||||
? measure
|
||||
: {
|
||||
...measure,
|
||||
events: measure.events.map((event) =>
|
||||
event.id === eventId ? { ...event, ...patch } : event,
|
||||
),
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function move(measureIndex: number, eventIndex: number, direction: -1 | 1) {
|
||||
const measures = score.measures.map((measure) => ({ ...measure, events: [...measure.events] }));
|
||||
const measure = measures[measureIndex];
|
||||
const target = eventIndex + direction;
|
||||
if (target >= 0 && target < measure.events.length) {
|
||||
const [item] = measure.events.splice(eventIndex, 1);
|
||||
measure.events.splice(target, 0, item);
|
||||
} else if (direction === -1 && measureIndex > 0) {
|
||||
const [item] = measure.events.splice(eventIndex, 1);
|
||||
measures[measureIndex - 1].events.push(item);
|
||||
} else if (direction === 1 && measureIndex < measures.length - 1) {
|
||||
const [item] = measure.events.splice(eventIndex, 1);
|
||||
measures[measureIndex + 1].events.unshift(item);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
onChange({ ...score, measures });
|
||||
}
|
||||
|
||||
function add(measureId: string, kind: "note" | "rest") {
|
||||
const measure = score.measures.find((item) => item.id === measureId);
|
||||
const previous = [...(measure?.events ?? [])].reverse().find((event) => event.kind === "note");
|
||||
const event: ScoreEvent = {
|
||||
id: newId("e"),
|
||||
kind,
|
||||
pitch: kind === "note" ? (previous?.pitch ?? "C5") : null,
|
||||
quarters: 1,
|
||||
label: "quarter",
|
||||
tied: false,
|
||||
};
|
||||
onChange({
|
||||
...score,
|
||||
measures: score.measures.map((item) =>
|
||||
item.id === measureId ? { ...item, events: [...item.events, event] } : item,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="score-title">Title</Label>
|
||||
<Input
|
||||
id="score-title"
|
||||
value={score.title}
|
||||
onChange={(event) => onChange({ ...score, title: event.target.value })}
|
||||
className="h-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="time-signature">Time signature</Label>
|
||||
<Select
|
||||
value={signatureKey(score.timeSignature)}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
onChange({ ...score, timeSignature: parseSignature(value) });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="time-signature" className="w-full data-[size=default]:h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{signatures.map((signature) => (
|
||||
<SelectItem key={signatureKey(signature)} value={signatureKey(signature)}>
|
||||
{signatureKey(signature)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{score.measures.map((measure, measureIndex) => {
|
||||
const status = barStatus(measure, score.timeSignature);
|
||||
return (
|
||||
<fieldset key={measure.id} className="space-y-3 rounded-2xl bg-card p-3 ring-1 ring-foreground/10">
|
||||
<legend className="px-1 font-medium">Bar {measureIndex + 1}</legend>
|
||||
<p className={status.ok ? "text-xs text-muted-foreground" : "text-xs text-[#8a4b12]"}>
|
||||
{status.text}
|
||||
</p>
|
||||
{measure.events.map((event, eventIndex) => (
|
||||
<EventRow
|
||||
key={event.id}
|
||||
event={event}
|
||||
onPitch={(value) => {
|
||||
if (value === "rest") {
|
||||
updateEvent(measure.id, event.id, { kind: "rest", pitch: null });
|
||||
} else {
|
||||
updateEvent(measure.id, event.id, { kind: "note", pitch: value });
|
||||
}
|
||||
}}
|
||||
onDuration={(quarters) => {
|
||||
const preset = DURATION_PRESETS.find((item) => quartersMatch(item.quarters, quarters));
|
||||
updateEvent(measure.id, event.id, {
|
||||
quarters,
|
||||
label: preset?.label ?? event.label,
|
||||
});
|
||||
}}
|
||||
onDelete={() =>
|
||||
onChange({
|
||||
...score,
|
||||
measures: score.measures.map((item) =>
|
||||
item.id === measure.id
|
||||
? { ...item, events: item.events.filter((row) => row.id !== event.id) }
|
||||
: item,
|
||||
),
|
||||
})
|
||||
}
|
||||
onUp={() => move(measureIndex, eventIndex, -1)}
|
||||
onDown={() => move(measureIndex, eventIndex, 1)}
|
||||
/>
|
||||
))}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => add(measure.id, "note")}>
|
||||
<Plus />
|
||||
Note
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => add(measure.id, "rest")}>
|
||||
<Plus />
|
||||
Rest
|
||||
</Button>
|
||||
{score.measures.length > 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...score,
|
||||
measures: score.measures.filter((item) => item.id !== measure.id),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete bar
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...score,
|
||||
measures: [...score.measures, { id: newId("m"), events: [] }],
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus />
|
||||
Add bar
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventRow({
|
||||
event,
|
||||
onPitch,
|
||||
onDuration,
|
||||
onDelete,
|
||||
onUp,
|
||||
onDown,
|
||||
}: {
|
||||
event: ScoreEvent;
|
||||
onPitch: (value: string) => void;
|
||||
onDuration: (quarters: number) => void;
|
||||
onDelete: () => void;
|
||||
onUp: () => void;
|
||||
onDown: () => void;
|
||||
}) {
|
||||
const pitches = [...CHART_NOTES] as string[];
|
||||
if (event.pitch && !pitches.includes(event.pitch)) pitches.unshift(event.pitch);
|
||||
const durations = [...DURATION_PRESETS];
|
||||
if (!durations.some((item) => quartersMatch(item.quarters, event.quarters))) {
|
||||
durations.push({ quarters: event.quarters, label: event.label });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="sr-only" htmlFor={`${event.id}-pitch`}>
|
||||
Pitch or rest
|
||||
</label>
|
||||
<select
|
||||
id={`${event.id}-pitch`}
|
||||
className={`${fieldClass} w-[7.5rem]`}
|
||||
value={event.kind === "rest" ? "rest" : (event.pitch ?? "C5")}
|
||||
onChange={(change) => onPitch(change.target.value)}
|
||||
>
|
||||
<option value="rest">rest</option>
|
||||
{pitches.map((pitch) => (
|
||||
<option key={pitch} value={pitch}>
|
||||
{pitch}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="sr-only" htmlFor={`${event.id}-duration`}>
|
||||
Duration
|
||||
</label>
|
||||
<select
|
||||
id={`${event.id}-duration`}
|
||||
className={`${fieldClass} w-[9.5rem]`}
|
||||
value={String(event.quarters)}
|
||||
onChange={(change) => onDuration(Number(change.target.value))}
|
||||
>
|
||||
{durations.map((duration) => (
|
||||
<option key={`${duration.label}-${duration.quarters}`} value={String(duration.quarters)}>
|
||||
{duration.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="ml-auto flex gap-1">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onUp} aria-label="Move earlier">
|
||||
<ChevronUp />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onDown} aria-label="Move later">
|
||||
<ChevronDown />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onDelete} aria-label="Delete note">
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { BeatArrow, FingeringDiagram, RestMark } from "@/components/fingering-diagram";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
CHART_NOTES,
|
||||
fingeringCode,
|
||||
fingeringFor,
|
||||
germanDiffers,
|
||||
type FingeringSystem,
|
||||
} from "@/lib/fingering";
|
||||
import {
|
||||
extraBeatArrows,
|
||||
formatBeat,
|
||||
leadRows,
|
||||
signatureKey,
|
||||
startBeats,
|
||||
type Measure,
|
||||
type Score,
|
||||
type ScoreEvent,
|
||||
type TimeSignature,
|
||||
} from "@/lib/score";
|
||||
import { formatTextTab } from "@/lib/text-tab";
|
||||
import { Copy } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function ScoreViews({ score, system }: { score: Score; system: FingeringSystem }) {
|
||||
const text = formatTextTab(score, system);
|
||||
const outside = score.measures
|
||||
.flatMap((measure) => measure.events)
|
||||
.filter((event) => event.kind === "note" && event.pitch && !fingeringFor(event.pitch, system)).length;
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="player" className="min-w-0 gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<TabsList>
|
||||
<TabsTrigger value="player">Player tabs</TabsTrigger>
|
||||
<TabsTrigger value="text">Text</TabsTrigger>
|
||||
<TabsTrigger value="chart">Chart</TabsTrigger>
|
||||
</TabsList>
|
||||
<p className="text-sm text-muted-foreground">{signatureKey(score.timeSignature)}</p>
|
||||
</div>
|
||||
|
||||
<TabsContent value="player" className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Each note is a narrow recorder with the foot at the top and the mouthpiece at the bottom.
|
||||
Filled holes are closed. Notes sit in a row, grouped by bar. An arrow is one more beat on
|
||||
the note before it. The duration is also written under the chart.
|
||||
</p>
|
||||
{outside > 0 ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{outside} {outside === 1 ? "note is" : "notes are"} outside C5–D7, so {outside === 1 ? "it has" : "they have"} no
|
||||
fingering.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="space-y-4">
|
||||
{leadRows(score.measures).map((row, rowIndex) => (
|
||||
<div key={row[0]?.id ?? rowIndex} className="overflow-x-auto">
|
||||
<div className="flex w-max min-w-full items-stretch gap-3 border-2 border-foreground bg-card px-3 py-3">
|
||||
{row.map((measure) => (
|
||||
<MeasureGroup
|
||||
key={measure.id}
|
||||
measure={measure}
|
||||
system={system}
|
||||
timeSignature={score.timeSignature}
|
||||
barNumber={score.measures.findIndex((item) => item.id === measure.id) + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<FingeringLegend />
|
||||
{score.measures.some((measure) =>
|
||||
measure.events.some((event) => event.pitch === "D7"),
|
||||
) ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dolmetsch’s primary top D leaves hole 7 open. The same page notes that covering 7a and 7b
|
||||
is a common fix if that D is sharp.
|
||||
</p>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="text" className="space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(text).then(
|
||||
() => toast.success("Text tab copied"),
|
||||
() => toast.error("Could not copy from this browser"),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Copy />
|
||||
Copy text tab
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded-2xl bg-[#2a2118] p-4 text-sm leading-6 text-[#f6efe2] whitespace-pre-wrap">
|
||||
{text}
|
||||
</pre>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="chart" className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Every note from C5 to D7, foot at the top. {system === "baroque" ? "Baroque is showing." : "German changes are badged."}
|
||||
</p>
|
||||
<ul className="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8">
|
||||
{CHART_NOTES.map((note) => {
|
||||
const fingering = fingeringFor(note, system);
|
||||
return (
|
||||
<li key={note} className="flex flex-col items-center gap-1 rounded-2xl bg-card px-2 py-3 ring-1 ring-foreground/10">
|
||||
<FingeringDiagram fingering={fingering} pitch={note} size="sm" />
|
||||
<p className="font-heading text-lg">{note}</p>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{fingering ? fingeringCode(fingering) : "—"}
|
||||
</p>
|
||||
{system === "german" && germanDiffers(note) ? <Badge variant="secondary">German</Badge> : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Top D (D7) is the chart’s primary fingering, with hole 7 open. Dolmetsch notes that covering
|
||||
7a and 7b is a common fix if the note is sharp on your instrument.
|
||||
</p>
|
||||
<FingeringSources />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
function MeasureGroup({
|
||||
measure,
|
||||
system,
|
||||
timeSignature,
|
||||
barNumber,
|
||||
}: {
|
||||
measure: Measure;
|
||||
system: FingeringSystem;
|
||||
timeSignature: TimeSignature;
|
||||
barNumber: number;
|
||||
}) {
|
||||
const beats = startBeats(measure.events, timeSignature);
|
||||
return (
|
||||
<section
|
||||
className="flex items-end gap-2 border-l-2 border-foreground pl-3 first:border-l-0 first:pl-0"
|
||||
aria-label={`Bar ${barNumber}`}
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
{measure.events.length === 0 ? (
|
||||
<p className="px-2 py-8 text-sm text-muted-foreground">Empty bar</p>
|
||||
) : (
|
||||
groupByBeat(measure.events, beats).map((group) => (
|
||||
<div key={group[0]?.event.id} className="flex items-end gap-1">
|
||||
{group.map(({ event, beat }) => (
|
||||
<LeadNote
|
||||
key={event.id}
|
||||
event={event}
|
||||
beat={beat}
|
||||
system={system}
|
||||
timeSignature={timeSignature}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{measure.repeatTimes ? (
|
||||
<p
|
||||
className="self-center px-1 font-heading text-4xl leading-none tracking-tight"
|
||||
aria-label={`Repeat this passage ${measure.repeatTimes} times`}
|
||||
>
|
||||
×{measure.repeatTimes}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function groupByBeat(events: ScoreEvent[], beats: number[]) {
|
||||
const groups: { event: ScoreEvent; beat: number }[][] = [];
|
||||
events.forEach((event, index) => {
|
||||
const beat = beats[index] ?? 1;
|
||||
const bucket = Math.floor(beat - 1 + 1e-6);
|
||||
const current = groups[groups.length - 1];
|
||||
const currentBucket = current ? Math.floor(current[0].beat - 1 + 1e-6) : -1;
|
||||
if (!current || currentBucket !== bucket) groups.push([{ event, beat }]);
|
||||
else current.push({ event, beat });
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
function LeadNote({
|
||||
event,
|
||||
beat,
|
||||
system,
|
||||
timeSignature,
|
||||
}: {
|
||||
event: ScoreEvent;
|
||||
beat: number;
|
||||
system: FingeringSystem;
|
||||
timeSignature: TimeSignature;
|
||||
}) {
|
||||
const fingering = event.kind === "note" && event.pitch ? fingeringFor(event.pitch, system) : null;
|
||||
const arrows = extraBeatArrows(event.quarters, timeSignature);
|
||||
const name = event.kind === "rest" ? "rest" : (event.pitch ?? "?");
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
{event.kind === "rest" ? (
|
||||
<RestMark label={event.label} />
|
||||
) : (
|
||||
<FingeringDiagram fingering={fingering} pitch={event.pitch ?? "?"} />
|
||||
)}
|
||||
{arrows > 0 ? (
|
||||
<span className="flex items-center gap-1" aria-hidden>
|
||||
{Array.from({ length: arrows }, (_, index) => (
|
||||
<BeatArrow key={index} />
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 w-11 text-center text-[11px] leading-none font-medium">{name}</p>
|
||||
<p className="mt-0.5 w-11 text-center text-[10px] leading-tight text-muted-foreground">
|
||||
{event.label}
|
||||
{event.tied ? " · tie" : ""}
|
||||
</p>
|
||||
<p className="w-11 text-center text-[10px] leading-tight text-muted-foreground">beat {formatBeat(beat)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FingeringLegend() {
|
||||
return (
|
||||
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<li>Foot at the top, mouthpiece at the bottom</li>
|
||||
<li>Filled hole: closed</li>
|
||||
<li>Empty hole: open</li>
|
||||
<li>One of a pair filled: half-hole</li>
|
||||
<li>Thumb half-filled: pinch</li>
|
||||
<li>Arrow: one more beat</li>
|
||||
<li>×2 only when the music repeats</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function FingeringSources() {
|
||||
return (
|
||||
<details className="rounded-2xl bg-card p-4 ring-1 ring-foreground/10">
|
||||
<summary className="cursor-pointer font-medium">Where these fingerings come from</summary>
|
||||
<div className="mt-3 space-y-3 text-sm leading-6 text-muted-foreground">
|
||||
<p>
|
||||
Baroque (English) fingerings are the first row on the Dolmetsch Online chart for recorders
|
||||
in C, prepared by Brian Blood. Holes are numbered 0 (thumb) through 7, and 6 and 7 are the
|
||||
double holes on a modern soprano. This app uses concert pitch: the instrument sounds as
|
||||
written, so low C is C5 and the encoded range stops at D7.
|
||||
</p>
|
||||
<p>
|
||||
German fingering changes three notes. Low F is Dolmetsch’s German fingering, thumb plus
|
||||
holes 1–4, with 5, 6, and 7 open. F♯ is thumb plus 1, 2, 3, 5, 6, and half-hole 7. B♭ is
|
||||
thumb plus 1, 3, 4, and 6. Those two accidentals follow the school German pattern described
|
||||
by Zach VanderGraaff in Dynamic Music Room. The octave above uses the same front holes with
|
||||
the Dolmetsch second-octave thumb. Every other note stays on the Baroque chart.
|
||||
</p>
|
||||
<p>Alternate fingerings from the Dolmetsch chart are not shown, and nothing outside C5–D7 is guessed.</p>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { DURATION_PRESETS, type DurationPreset } from "@/lib/duration";
|
||||
import { newId } from "@/lib/score";
|
||||
|
||||
export type LineNote = {
|
||||
id: string;
|
||||
pitch: string;
|
||||
quarters: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const QUARTER = DURATION_PRESETS.find((preset) => preset.quarters === 1) ?? DURATION_PRESETS[3];
|
||||
|
||||
export function createNote(pitch: string, preset: DurationPreset = QUARTER, id = newId("n")): LineNote {
|
||||
return { id, pitch, quarters: preset.quarters, label: preset.label };
|
||||
}
|
||||
|
||||
export function insertNote(notes: LineNote[], index: number, note: LineNote): LineNote[] {
|
||||
const next = notes.slice();
|
||||
const at = Math.max(0, Math.min(index, next.length));
|
||||
next.splice(at, 0, note);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** `toIndex` is the slot in the current list, counting the note that is moving. */
|
||||
export function moveNote(notes: LineNote[], id: string, toIndex: number): LineNote[] {
|
||||
const from = notes.findIndex((note) => note.id === id);
|
||||
if (from < 0) return notes;
|
||||
const next = notes.filter((note) => note.id !== id);
|
||||
const adjusted = toIndex > from ? toIndex - 1 : toIndex;
|
||||
const at = Math.max(0, Math.min(adjusted, next.length));
|
||||
next.splice(at, 0, notes[from]);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function removeNote(notes: LineNote[], id: string): LineNote[] {
|
||||
return notes.filter((note) => note.id !== id);
|
||||
}
|
||||
|
||||
export function setDuration(notes: LineNote[], id: string, preset: DurationPreset): LineNote[] {
|
||||
return notes.map((note) =>
|
||||
note.id === id ? { ...note, quarters: preset.quarters, label: preset.label } : note,
|
||||
);
|
||||
}
|
||||
|
||||
export function cloneLine(notes: LineNote[]): LineNote[] {
|
||||
return notes.map((note) => ({ ...note, id: newId("n") }));
|
||||
}
|
||||
|
||||
/** -1 when the pointer is outside the line. Otherwise the insertion slot from left to right. */
|
||||
export function dropIndexFromPoint(
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
line: { left: number; right: number; top: number; bottom: number },
|
||||
noteCenters: number[],
|
||||
slack = 36,
|
||||
): number {
|
||||
const inside =
|
||||
pointerX >= line.left - slack &&
|
||||
pointerX <= line.right + slack &&
|
||||
pointerY >= line.top - slack &&
|
||||
pointerY <= line.bottom + slack;
|
||||
if (!inside) return -1;
|
||||
for (let index = 0; index < noteCenters.length; index += 1) {
|
||||
if (pointerX < noteCenters[index]) return index;
|
||||
}
|
||||
return noteCenters.length;
|
||||
}
|
||||
|
||||
function preset(quarters: number, label: string): DurationPreset {
|
||||
return { quarters, label };
|
||||
}
|
||||
|
||||
/** Two short lines to edit. They are not a full tune. */
|
||||
export const LINE_EXAMPLES = [
|
||||
{
|
||||
id: "twinkle",
|
||||
title: "Twinkle, opening",
|
||||
blurb: "Seven notes. The last one is held.",
|
||||
notes: [
|
||||
createNote("C5", preset(1, "quarter"), "twinkle-1"),
|
||||
createNote("C5", preset(1, "quarter"), "twinkle-2"),
|
||||
createNote("G5", preset(1, "quarter"), "twinkle-3"),
|
||||
createNote("G5", preset(1, "quarter"), "twinkle-4"),
|
||||
createNote("A5", preset(1, "quarter"), "twinkle-5"),
|
||||
createNote("A5", preset(1, "quarter"), "twinkle-6"),
|
||||
createNote("G5", preset(2, "half"), "twinkle-7"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ode",
|
||||
title: "Ode, held note",
|
||||
blurb: "A dotted quarter, an eighth, and a half note.",
|
||||
notes: [
|
||||
createNote("E5", preset(1.5, "dotted quarter"), "ode-1"),
|
||||
createNote("D5", preset(0.5, "eighth"), "ode-2"),
|
||||
createNote("D5", preset(2, "half"), "ode-3"),
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
@@ -1,338 +0,0 @@
|
||||
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;
|
||||
let repeatForward = false;
|
||||
let repeatTimes: number | undefined;
|
||||
const grouped = new Map<number, Candidate[]>();
|
||||
|
||||
for (const element of children(measureNode)) {
|
||||
const tag = tagOf(element);
|
||||
if (tag === "barline") {
|
||||
const repeat = findChild(children(element), "repeat");
|
||||
if (repeat) {
|
||||
const repeatAttrs = attrs(repeat);
|
||||
if (repeatAttrs.direction === "forward") repeatForward = true;
|
||||
if (repeatAttrs.direction === "backward") {
|
||||
const times = Number(repeatAttrs.times);
|
||||
repeatTimes = Number.isFinite(times) && times >= 2 ? times : 2;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
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,
|
||||
...(repeatForward ? { repeatForward: true } : {}),
|
||||
...(repeatTimes ? { repeatTimes } : {}),
|
||||
});
|
||||
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",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import type { Score, ScoreEvent } from "@/lib/score";
|
||||
|
||||
const DURATION: Record<string, { quarters: number; label: string }> = {
|
||||
w: { quarters: 4, label: "whole" },
|
||||
dh: { quarters: 3, label: "dotted half" },
|
||||
h: { quarters: 2, label: "half" },
|
||||
dq: { quarters: 1.5, label: "dotted quarter" },
|
||||
q: { quarters: 1, label: "quarter" },
|
||||
e: { quarters: 0.5, label: "eighth" },
|
||||
s: { quarters: 0.25, label: "sixteenth" },
|
||||
};
|
||||
|
||||
function events(barId: string, specs: string[]): ScoreEvent[] {
|
||||
return specs.map((spec, index) => {
|
||||
const [token, durationKey = "q"] = spec.split(":");
|
||||
const duration = DURATION[durationKey] ?? DURATION.q;
|
||||
const rest = token === "r";
|
||||
return {
|
||||
id: `${barId}-e${index + 1}`,
|
||||
kind: rest ? "rest" : "note",
|
||||
pitch: rest ? null : token,
|
||||
quarters: duration.quarters,
|
||||
label: duration.label,
|
||||
tied: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function measures(prefix: string, bars: string[][]) {
|
||||
return bars.map((bar, index) => {
|
||||
const id = `${prefix}-m${index + 1}`;
|
||||
return { id, events: events(id, bar) };
|
||||
});
|
||||
}
|
||||
|
||||
/** Traditional melody at concert pitch, starting on the soprano’s low C. */
|
||||
export const twinkle: Score = {
|
||||
title: "Twinkle, Twinkle, Little Star",
|
||||
attribution: "Traditional, public domain. First verse, concert pitch for soprano in C.",
|
||||
timeSignature: { beats: 4, beatType: 4 },
|
||||
origin: "sample",
|
||||
measures: measures("twinkle", [
|
||||
["C5", "C5", "G5", "G5"],
|
||||
["A5", "A5", "G5:h"],
|
||||
["F5", "F5", "E5", "E5"],
|
||||
["D5", "D5", "C5:h"],
|
||||
["G5", "G5", "F5", "F5"],
|
||||
["E5", "E5", "D5:h"],
|
||||
["G5", "G5", "F5", "F5"],
|
||||
["E5", "E5", "D5:h"],
|
||||
["C5", "C5", "G5", "G5"],
|
||||
["A5", "A5", "G5:h"],
|
||||
["F5", "F5", "E5", "E5"],
|
||||
["D5", "D5", "C5:h"],
|
||||
]),
|
||||
};
|
||||
|
||||
/**
|
||||
* Ode to Joy theme in C major at concert pitch.
|
||||
* The symphony states it in D; this is the familiar C-major teaching key,
|
||||
* which sits on the soprano without leaving the first octave.
|
||||
*/
|
||||
export const odeToJoy: Score = {
|
||||
title: "Ode to Joy",
|
||||
attribution:
|
||||
"Ludwig van Beethoven, public domain. Theme in C major, concert pitch for soprano in C.",
|
||||
timeSignature: { beats: 4, beatType: 4 },
|
||||
origin: "sample",
|
||||
measures: measures("ode", [
|
||||
["E5", "E5", "F5", "G5"],
|
||||
["G5", "F5", "E5", "D5"],
|
||||
["C5", "C5", "D5", "E5"],
|
||||
["E5:dq", "D5:e", "D5:h"],
|
||||
["E5", "E5", "F5", "G5"],
|
||||
["G5", "F5", "E5", "D5"],
|
||||
["C5", "C5", "D5", "E5"],
|
||||
["D5:dq", "C5:e", "C5:h"],
|
||||
]),
|
||||
};
|
||||
|
||||
export const SAMPLES = [
|
||||
{
|
||||
id: "twinkle",
|
||||
score: twinkle,
|
||||
blurb: "First verse in C. Forked F, half notes, and four-square bars.",
|
||||
},
|
||||
{
|
||||
id: "ode",
|
||||
score: odeToJoy,
|
||||
blurb: "The hymn theme in C, with dotted quarters and held notes.",
|
||||
},
|
||||
] as const;
|
||||
@@ -1,38 +0,0 @@
|
||||
import { fingeringCode, fingeringFor, type FingeringSystem } from "@/lib/fingering";
|
||||
import { formatBeat, signatureKey, startBeats, type Score } from "@/lib/score";
|
||||
|
||||
export function formatTextTab(score: Score, system: FingeringSystem): string {
|
||||
const lines: string[] = [
|
||||
score.title,
|
||||
"Soprano recorder in C · concert pitch · sounds as written",
|
||||
system === "baroque"
|
||||
? "Fingering: Baroque (English), Dolmetsch Online chart for recorders in C"
|
||||
: "Fingering: German on F, F♯, and B♭; other notes stay on the Dolmetsch Baroque chart",
|
||||
`Time: ${signatureKey(score.timeSignature)}`,
|
||||
"",
|
||||
];
|
||||
|
||||
score.measures.forEach((measure, measureIndex) => {
|
||||
const beats = startBeats(measure.events, score.timeSignature);
|
||||
lines.push(`Bar ${measureIndex + 1}`);
|
||||
if (measure.events.length === 0) lines.push(" (empty)");
|
||||
measure.events.forEach((event, eventIndex) => {
|
||||
const beat = formatBeat(beats[eventIndex] ?? 1).padEnd(4, " ");
|
||||
const name = (event.kind === "rest" ? "rest" : (event.pitch ?? "?")).padEnd(6, " ");
|
||||
const duration = event.label.padEnd(16, " ");
|
||||
let fingers = "—";
|
||||
if (event.kind === "note" && event.pitch) {
|
||||
const fingering = fingeringFor(event.pitch, system);
|
||||
fingers = fingering ? fingeringCode(fingering) : "outside C5–D7 chart";
|
||||
}
|
||||
const tie = event.tied ? " tie" : "";
|
||||
lines.push(` beat ${beat} ${name} ${duration} ${fingers}${tie}`);
|
||||
});
|
||||
lines.push("");
|
||||
});
|
||||
|
||||
lines.push("Legend: 0 closed thumb, x pinched thumb, - open thumb.");
|
||||
lines.push("Digits are closed holes. 6a without 6b, or 7a without 7b, is a half-hole.");
|
||||
lines.push("bell means cover the foot. Notes outside C5–D7 have no fingering.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
Reference in new issue
Block a user