Replace sheet reading with a fingering editor.
AppImage release / appimage (push) Successful in 53s

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:
Cursor Agentandchen committed 2026-10-11 09:06:33 +00:00
1 parent 229a8d4723
commit b354ea9f88
22 files changed
+569 -2231

No files matched your search

-52
View File
@@ -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
View File
@@ -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
View File
@@ -1,9 +1,9 @@
import { Converter } from "@/components/converter";
import { Editor } from "@/components/editor";
export default function Home() {
return (
<main>
<Converter />
<Editor />
</main>
);
}
-272
View File
@@ -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>
);
}
+385
View File
@@ -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>
);
}
-285
View File
@@ -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>
);
}
-273
View File
@@ -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>
);
}
+99
View File
@@ -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;
-338
View File
@@ -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",
},
};
}
-201
View File
@@ -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 });
}
}
-92
View File
@@ -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;
-38
View File
@@ -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");
}