Add a soprano recorder tab converter as a Linux AppImage.
The app turns a lead line into Baroque fingering tabs with rhythm kept, ships Twinkle and Ode to Joy, and reads photos locally with oemer when the bundled Python is present. Co-authored-by: catpersonisme <catpersonisme@gmail.com>
This commit is contained in:
55 files changed
+17536
No files matched your search
@@ -0,0 +1,52 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,130 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-source);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-heading: var(--font-display);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(0.955 0.02 85);
|
||||
--foreground: oklch(0.27 0.03 55);
|
||||
--card: oklch(0.985 0.012 92);
|
||||
--card-foreground: oklch(0.27 0.03 55);
|
||||
--popover: oklch(0.985 0.012 92);
|
||||
--popover-foreground: oklch(0.27 0.03 55);
|
||||
--primary: oklch(0.42 0.07 48);
|
||||
--primary-foreground: oklch(0.98 0.01 90);
|
||||
--secondary: oklch(0.93 0.02 85);
|
||||
--secondary-foreground: oklch(0.32 0.04 50);
|
||||
--muted: oklch(0.94 0.015 85);
|
||||
--muted-foreground: oklch(0.45 0.03 55);
|
||||
--accent: oklch(0.9 0.035 150);
|
||||
--accent-foreground: oklch(0.28 0.04 155);
|
||||
--destructive: oklch(0.5 0.16 28);
|
||||
--border: oklch(0.86 0.025 75);
|
||||
--input: oklch(0.86 0.025 75);
|
||||
--ring: oklch(0.55 0.07 48);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.8rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Fraunces, Source_Sans_3 } from "next/font/google";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import "./globals.css";
|
||||
|
||||
const sans = Source_Sans_3({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-source",
|
||||
weight: ["400", "500", "600", "700"],
|
||||
});
|
||||
|
||||
const display = Fraunces({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-display",
|
||||
weight: ["500", "600", "700"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Recorder Tab Converter",
|
||||
description:
|
||||
"Turn a lead melody into soprano recorder fingering tabs, with rhythm, rests, and bar lines kept.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
return (
|
||||
<html lang="en" className={`${sans.variable} ${display.variable} h-full antialiased`}>
|
||||
<body className="min-h-full flex flex-col font-sans">
|
||||
{children}
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Converter } from "@/components/converter";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main>
|
||||
<Converter />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
"use client";
|
||||
|
||||
import { NoteEditor } from "@/components/note-editor";
|
||||
import { ScoreViews } from "@/components/tab-score";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import type { FingeringSystem } from "@/lib/fingering";
|
||||
import { SAMPLES } from "@/lib/samples";
|
||||
import { blankScore, type Score } from "@/lib/score";
|
||||
import { Loader2, Music2, Upload } from "lucide-react";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
|
||||
type Status = "empty" | "loading" | "ready" | "error";
|
||||
|
||||
type RecognizerInfo = {
|
||||
available: boolean;
|
||||
name: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export function Converter() {
|
||||
const inputId = useId();
|
||||
const [score, setScore] = useState<Score | null>(null);
|
||||
const [system, setSystem] = useState<FingeringSystem>("baroque");
|
||||
const [status, setStatus] = useState<Status>("empty");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
const [recognizer, setRecognizer] = useState<RecognizerInfo | null>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch("/api/recognize")
|
||||
.then((response) => response.json())
|
||||
.then((info: RecognizerInfo) => {
|
||||
if (!cancelled) setRecognizer(info);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setRecognizer({
|
||||
available: false,
|
||||
name: "oemer",
|
||||
detail: "Could not check the local reader. Samples and the editor still work.",
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
function loadScore(next: Score) {
|
||||
setScore(structuredClone(next));
|
||||
setWarnings([]);
|
||||
setError(null);
|
||||
setStatus("ready");
|
||||
}
|
||||
|
||||
async function onFile(file: File) {
|
||||
setError(null);
|
||||
const allowed =
|
||||
["image/png", "image/jpeg", "image/webp"].includes(file.type) ||
|
||||
/\.(png|jpe?g|webp)$/i.test(file.name);
|
||||
if (file.size === 0) {
|
||||
setError("That file is empty. No notes were invented.");
|
||||
setScore((current) => current ?? blankScore());
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
if (!allowed) {
|
||||
setError("Use a PNG, JPG, or WEBP photo. That file is not one of those. No notes were invented.");
|
||||
setScore((current) => current ?? blankScore());
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
if (file.size > 8 * 1024 * 1024) {
|
||||
setError("That image is over 8 MB. Crop the line or export a smaller JPEG.");
|
||||
setScore((current) => current ?? blankScore());
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("loading");
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.set("image", file);
|
||||
const response = await fetch("/api/recognize", { method: "POST", body });
|
||||
const data = (await response.json()) as {
|
||||
error?: string;
|
||||
score?: Score;
|
||||
warnings?: string[];
|
||||
};
|
||||
if (!response.ok || data.error || !data.score) {
|
||||
setError(data.error ?? "This image could not be read. No notes were invented.");
|
||||
setWarnings([]);
|
||||
setScore((current) => current ?? blankScore());
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
setScore(data.score);
|
||||
setWarnings(data.warnings ?? []);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setError("The reader did not answer. No notes were invented. You can still enter the line by hand.");
|
||||
setScore((current) => current ?? blankScore());
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 py-6 md:px-8 md:py-10">
|
||||
<header className="space-y-3">
|
||||
<p className="text-sm tracking-[0.16em] text-[#6a4328] uppercase">Soprano in C · concert pitch</p>
|
||||
<h1 className="font-heading text-4xl leading-tight md:text-5xl">Recorder Tab Converter</h1>
|
||||
<p className="max-w-2xl text-base leading-7 text-muted-foreground">
|
||||
Turn a lead melody into fingering tabs a soprano player can read: thumb and holes, the note
|
||||
name, and the rhythm, lined up in bars. Chords keep the top note. A second staff is ignored.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section
|
||||
className={`rounded-3xl bg-card p-4 ring-1 ring-foreground/10 md:p-6 ${dragOver ? "ring-2 ring-primary" : ""}`}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setDragOver(false);
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file) void onFile(file);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h2 className="font-heading text-2xl">Photo or scan</h2>
|
||||
<p className="max-w-xl text-sm leading-6 text-muted-foreground">
|
||||
{recognizer?.detail ?? "Checking the local reader…"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={status === "loading"}
|
||||
nativeButton={false}
|
||||
render={<label htmlFor={inputId} />}
|
||||
>
|
||||
{status === "loading" ? <Loader2 className="animate-spin" /> : <Upload />}
|
||||
{status === "loading" ? "Reading the page" : "Choose image"}
|
||||
</Button>
|
||||
<input
|
||||
id={inputId}
|
||||
className="sr-only"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,.png,.jpg,.jpeg,.webp"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (file) void onFile(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{status === "loading" ? (
|
||||
<p className="mt-4 text-sm text-muted-foreground" role="status">
|
||||
oemer is reading the page on this computer. A dense photo can take a few minutes. Nothing
|
||||
is uploaded.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
Drop a PNG, JPG, or WEBP here, or open a sample below if you want to see the tabs before a
|
||||
photo works.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>The page was not read</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{warnings.length > 0 ? (
|
||||
<Alert>
|
||||
<AlertTitle>What the reader kept</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="list-disc pl-4">
|
||||
{warnings.map((warning) => (
|
||||
<li key={warning}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-3 sm:grid-cols-2">
|
||||
{SAMPLES.map((sample) => (
|
||||
<button
|
||||
key={sample.id}
|
||||
type="button"
|
||||
onClick={() => loadScore(sample.score)}
|
||||
className="rounded-2xl bg-card p-4 text-left ring-1 ring-foreground/10 transition hover:ring-primary"
|
||||
>
|
||||
<span className="flex items-center gap-2 font-heading text-xl">
|
||||
<Music2 className="size-4 text-[#6a4328]" />
|
||||
{sample.score.title}
|
||||
</span>
|
||||
<span className="mt-2 block text-sm leading-6 text-muted-foreground">{sample.blurb}</span>
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{score ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 className="font-heading text-3xl">{score.title}</h2>
|
||||
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted-foreground">{score.attribution}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs tracking-wide text-muted-foreground uppercase">Fingering</p>
|
||||
<ToggleGroup
|
||||
value={[system]}
|
||||
spacing={0}
|
||||
variant="outline"
|
||||
onValueChange={(values) => {
|
||||
const picked = values[values.length - 1];
|
||||
if (picked === "baroque" || picked === "german") setSystem(picked);
|
||||
}}
|
||||
aria-label="Fingering system"
|
||||
>
|
||||
<ToggleGroupItem value="baroque">Baroque</ToggleGroupItem>
|
||||
<ToggleGroupItem value="german">German</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<p className="max-w-sm text-xs leading-5 text-muted-foreground">
|
||||
Baroque is the Dolmetsch chart and the default. German only changes F, F♯, and B♭.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid items-start gap-6 lg:grid-cols-[minmax(18rem,22rem)_minmax(0,1fr)]">
|
||||
<div className="order-2 space-y-3 lg:order-1 lg:sticky lg:top-4 lg:max-h-[calc(100vh-2rem)] lg:overflow-y-auto lg:pr-1">
|
||||
<h2 className="font-heading text-2xl">Note list</h2>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
Pitch, rest, and duration. Change anything the photo got wrong. The tabs follow this
|
||||
list, including the bar lines.
|
||||
</p>
|
||||
<NoteEditor score={score} onChange={setScore} />
|
||||
</div>
|
||||
<div className="order-1 min-w-0 lg:order-2">
|
||||
<ScoreViews score={score} system={system} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-3xl border border-dashed border-foreground/15 px-4 py-10 text-center">
|
||||
<p className="font-heading text-2xl">No line loaded yet</p>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-muted-foreground">
|
||||
Open Twinkle or Ode to Joy to see fingerings and rhythm immediately, or start an empty
|
||||
bar and type the notes yourself.
|
||||
</p>
|
||||
<Button type="button" className="mt-4" variant="secondary" onClick={() => loadScore(blankScore())}>
|
||||
Start an empty line
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
fingeringMarks,
|
||||
fingeringProse,
|
||||
type Fingering,
|
||||
} from "@/lib/fingering";
|
||||
|
||||
type HoleState = Fingering["holes"][number];
|
||||
|
||||
function Hole({
|
||||
cx,
|
||||
cy,
|
||||
r,
|
||||
state,
|
||||
}: {
|
||||
cx: number;
|
||||
cy: number;
|
||||
r: number;
|
||||
state: HoleState;
|
||||
}) {
|
||||
return (
|
||||
<g>
|
||||
<circle cx={cx} cy={cy} r={r} fill="#fbf6ec" />
|
||||
{state === "closed" ? <circle cx={cx} cy={cy} r={r - 1.1} fill="#1c3830" /> : null}
|
||||
{state === "half" ? (
|
||||
<path
|
||||
d={`M ${cx} ${cy - r + 1} A ${r - 1} ${r - 1} 0 0 0 ${cx} ${cy + r - 1} Z`}
|
||||
fill="#1c3830"
|
||||
/>
|
||||
) : null}
|
||||
<circle cx={cx} cy={cy} r={r} fill="none" stroke="#5c3d28" strokeWidth="1.6" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function FingeringDiagram({
|
||||
fingering,
|
||||
pitch,
|
||||
size = "lg",
|
||||
}: {
|
||||
fingering: Fingering | null;
|
||||
pitch: string;
|
||||
size?: "lg" | "sm";
|
||||
}) {
|
||||
const width = size === "lg" ? 112 : 88;
|
||||
if (!fingering) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center rounded-2xl border border-dashed border-destructive/50 bg-destructive/5 px-2 text-center text-xs leading-snug text-destructive"
|
||||
style={{ width, height: size === "lg" ? 220 : 172 }}
|
||||
role="img"
|
||||
aria-label={`${pitch} is outside the C5 to D7 chart. No fingering is shown.`}
|
||||
>
|
||||
Outside C5–D7. No fingering.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const [h1, h2, h3, h4, h5, h6a, h6b, h7a, h7b] = fingering.holes;
|
||||
const label = `${pitch}. ${fingeringProse(fingering)}. ${fingeringMarks(fingering).join(", ")}`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 104 228"
|
||||
width={width}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
className="h-auto shrink-0"
|
||||
>
|
||||
<rect x="34" y="8" width="46" height="208" rx="20" fill="#e4cfae" stroke="#6a4328" strokeWidth="2" />
|
||||
<path d="M46 28c8-16 22-16 30 0" fill="none" stroke="#6a4328" strokeWidth="2" />
|
||||
<Hole cx={18} cy={40} r={8} state={fingering.thumb} />
|
||||
<text x={14} y={62} textAnchor="middle" fontSize="8" fill="#6a4328">
|
||||
T
|
||||
</text>
|
||||
<Hole cx={57} cy={58} r={8} state={h1} />
|
||||
<Hole cx={57} cy={80} r={8} state={h2} />
|
||||
<Hole cx={57} cy={102} r={8} state={h3} />
|
||||
<Hole cx={57} cy={132} r={8} state={h4} />
|
||||
<Hole cx={57} cy={154} r={8} state={h5} />
|
||||
<Hole cx={48} cy={178} r={6.5} state={h6a} />
|
||||
<Hole cx={66} cy={178} r={6.5} state={h6b} />
|
||||
<Hole cx={48} cy={198} r={6.5} state={h7a} />
|
||||
<Hole cx={66} cy={198} r={6.5} state={h7b} />
|
||||
<text x={84} y={61} fontSize="8" fill="#7a5a3c">1</text>
|
||||
<text x={84} y={83} fontSize="8" fill="#7a5a3c">2</text>
|
||||
<text x={84} y={105} fontSize="8" fill="#7a5a3c">3</text>
|
||||
<text x={84} y={135} fontSize="8" fill="#7a5a3c">4</text>
|
||||
<text x={84} y={157} fontSize="8" fill="#7a5a3c">5</text>
|
||||
<text x={84} y={181} fontSize="8" fill="#7a5a3c">6</text>
|
||||
<text x={84} y={201} fontSize="8" fill="#7a5a3c">7</text>
|
||||
{fingering.bell ? (
|
||||
<text x={57} y={222} textAnchor="middle" fontSize="8" fill="#6a4328">
|
||||
bell
|
||||
</text>
|
||||
) : null}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function RestMark({ label }: { label: string }) {
|
||||
const kind = label.includes("whole")
|
||||
? "whole"
|
||||
: label.includes("half")
|
||||
? "half"
|
||||
: label.includes("sixteenth")
|
||||
? "sixteenth"
|
||||
: label.includes("eighth")
|
||||
? "eighth"
|
||||
: "quarter";
|
||||
return (
|
||||
<svg viewBox="0 0 64 120" width={72} className="h-auto" role="img" aria-label={`${label} rest`}>
|
||||
<line x1="8" y1="60" x2="56" y2="60" stroke="#c4b39a" strokeWidth="2" />
|
||||
{kind === "whole" ? <rect x="20" y="36" width="24" height="14" rx="1" fill="#2a2118" /> : null}
|
||||
{kind === "half" ? <rect x="20" y="60" width="24" height="14" rx="1" fill="#2a2118" /> : null}
|
||||
{kind === "quarter" ? (
|
||||
<path
|
||||
d="M34 28c8 8 8 14 0 20s-8 12 2 20 10 10 4 18"
|
||||
fill="none"
|
||||
stroke="#2a2118"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
) : null}
|
||||
{kind === "eighth" || kind === "sixteenth" ? (
|
||||
<g fill="#2a2118">
|
||||
<circle cx="36" cy="78" r="6" />
|
||||
<path d="M36 72c10-14 16-20 8-36" fill="none" stroke="#2a2118" strokeWidth="3" />
|
||||
{kind === "sixteenth" ? (
|
||||
<path d="M32 64c10-10 14-14 6-26" fill="none" stroke="#2a2118" strokeWidth="3" />
|
||||
) : null}
|
||||
</g>
|
||||
) : null}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { 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,
|
||||
fingeringMarks,
|
||||
germanDiffers,
|
||||
type FingeringSystem,
|
||||
} from "@/lib/fingering";
|
||||
import { formatBeat, signatureKey, startBeats, type Score } 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 cell is wide in proportion to its length, so a half note takes twice the room of a
|
||||
quarter. Scroll a bar sideways if the fingerings need more room than the screen.
|
||||
</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">
|
||||
{score.measures.map((measure, index) => {
|
||||
const beats = startBeats(measure.events, score.timeSignature);
|
||||
return (
|
||||
<section key={measure.id} className="rounded-2xl bg-card p-3 ring-1 ring-foreground/10">
|
||||
<h3 className="mb-2 font-heading text-lg">Bar {index + 1}</h3>
|
||||
{measure.events.length === 0 ? (
|
||||
<p className="px-2 py-8 text-sm text-muted-foreground">Empty bar. Add a note or a rest in the editor.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto pb-2">
|
||||
<div className="flex w-max min-w-full border-x-[3px] border-foreground">
|
||||
{measure.events.map((event, eventIndex) => {
|
||||
const fingering =
|
||||
event.kind === "note" && event.pitch
|
||||
? fingeringFor(event.pitch, system)
|
||||
: null;
|
||||
const marks = fingering ? fingeringMarks(fingering) : [];
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className="flex flex-col items-center gap-1 border-r border-foreground/15 px-2 py-2 last:border-r-0"
|
||||
style={{ minWidth: Math.max(124, event.quarters * 124) }}
|
||||
>
|
||||
<div className="flex min-h-[230px] items-center">
|
||||
{event.kind === "rest" ? (
|
||||
<RestMark label={event.label} />
|
||||
) : (
|
||||
<FingeringDiagram
|
||||
fingering={fingering}
|
||||
pitch={event.pitch ?? "?"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="font-heading text-xl leading-none">
|
||||
{event.kind === "rest" ? "rest" : event.pitch}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{event.label}</p>
|
||||
{marks.length > 0 ? (
|
||||
<p className="text-xs tracking-wide text-[#6a4328] uppercase">{marks.join(" · ")}</p>
|
||||
) : null}
|
||||
{event.tied ? <p className="text-xs text-muted-foreground">ties forward</p> : null}
|
||||
<p className="mt-1 text-xs text-muted-foreground">beat {formatBeat(beats[eventIndex] ?? 1)}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</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. {system === "baroque" ? "Baroque is showing." : "German changes are badged."}
|
||||
</p>
|
||||
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-4">
|
||||
{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 p-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 FingeringLegend() {
|
||||
return (
|
||||
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<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>
|
||||
</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,75 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
|
||||
const alertVariants = cva(
|
||||
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-action"
|
||||
className={cn("absolute top-2 right-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||
@@ -0,0 +1,51 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,102 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-(--card-spacing)", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
import { cn } from "cn"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Root.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Scrollbar.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
import { cn } from "cn"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
<CircleCheckIcon className="size-4" />
|
||||
),
|
||||
info: (
|
||||
<InfoIcon className="size-4" />
|
||||
),
|
||||
warning: (
|
||||
<TriangleAlertIcon className="size-4" />
|
||||
),
|
||||
error: (
|
||||
<OctagonXIcon className="size-4" />
|
||||
),
|
||||
loading: (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
),
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: TabsPrimitive.Root.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Panel
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
spacing: 2,
|
||||
orientation: "horizontal",
|
||||
})
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 2,
|
||||
orientation = "horizontal",
|
||||
children,
|
||||
...props
|
||||
}: ToggleGroupPrimitive.Props &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
data-orientation={orientation}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider
|
||||
value={{ variant, size, spacing, orientation }}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<TogglePrimitive
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
className={cn(
|
||||
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TogglePrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client"
|
||||
|
||||
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border border-input bg-transparent hover:bg-muted",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
@@ -0,0 +1,55 @@
|
||||
export type DurationPreset = {
|
||||
quarters: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const DURATION_PRESETS: DurationPreset[] = [
|
||||
{ quarters: 4, label: "whole" },
|
||||
{ quarters: 3, label: "dotted half" },
|
||||
{ quarters: 2, label: "half" },
|
||||
{ quarters: 1.5, label: "dotted quarter" },
|
||||
{ quarters: 1, label: "quarter" },
|
||||
{ quarters: 0.75, label: "dotted eighth" },
|
||||
{ quarters: 0.5, label: "eighth" },
|
||||
{ quarters: 0.25, label: "sixteenth" },
|
||||
];
|
||||
|
||||
const TYPE_NAMES: Record<string, string> = {
|
||||
breve: "breve",
|
||||
whole: "whole",
|
||||
half: "half",
|
||||
quarter: "quarter",
|
||||
eighth: "eighth",
|
||||
"16th": "sixteenth",
|
||||
"32nd": "thirty-second",
|
||||
"64th": "sixty-fourth",
|
||||
};
|
||||
|
||||
export function quartersMatch(a: number, b: number): boolean {
|
||||
return Math.abs(a - b) < 0.001;
|
||||
}
|
||||
|
||||
export function durationLabel(
|
||||
quarters: number,
|
||||
type?: string | null,
|
||||
dots = 0,
|
||||
tuplet = false,
|
||||
): string {
|
||||
if (type && TYPE_NAMES[type]) {
|
||||
let name = TYPE_NAMES[type];
|
||||
if (tuplet) name = `triplet ${name}`;
|
||||
if (dots >= 2) name = `double-dotted ${name}`;
|
||||
else if (dots === 1) name = `dotted ${name}`;
|
||||
return name;
|
||||
}
|
||||
const preset = DURATION_PRESETS.find((item) => quartersMatch(item.quarters, quarters));
|
||||
if (preset) return preset.label;
|
||||
const rounded = Math.round(quarters * 1000) / 1000;
|
||||
return `${rounded} quarters`;
|
||||
}
|
||||
|
||||
export function formatQuarterLengths(quarters: number): string {
|
||||
const rounded = Math.round(quarters * 1000) / 1000;
|
||||
const text = Number.isInteger(rounded) ? String(rounded) : String(rounded);
|
||||
return `${text} quarter-length${rounded === 1 ? "" : "s"}`;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { parsePitch } from "@/lib/pitch";
|
||||
|
||||
export type HoleState = "open" | "closed" | "half";
|
||||
|
||||
export type Fingering = {
|
||||
thumb: HoleState;
|
||||
/** Holes 1, 2, 3, 4, 5, 6a, 6b, 7a, 7b. */
|
||||
holes: HoleState[];
|
||||
bell: boolean;
|
||||
};
|
||||
|
||||
export type FingeringSystem = "baroque" | "german";
|
||||
|
||||
export const RANGE_LABEL = "C5–D7";
|
||||
export const LOW_MIDI = 72;
|
||||
export const HIGH_MIDI = 98;
|
||||
|
||||
const HOLE_IDS = ["1", "2", "3", "4", "5", "6a", "6b", "7a", "7b"] as const;
|
||||
|
||||
type ThumbCode = "0" | "x" | "-";
|
||||
type Row = [pitch: string, thumb: ThumbCode, holes: string, bell?: boolean];
|
||||
|
||||
/**
|
||||
* First-choice Baroque (English) fingerings from the Dolmetsch Online chart
|
||||
* for recorders in C (Brian Blood). Octave 1 is concert C5.
|
||||
* A listed hole is closed. 6a without 6b, or 7a without 7b, is the half-hole.
|
||||
* Thumb 0 is closed, x is pinched, - is open.
|
||||
* Alternates on that chart (second B, all-closed D, and so on) are not encoded.
|
||||
*/
|
||||
const BAROQUE_ROWS: Row[] = [
|
||||
["C5", "0", "123456a6b7a7b"],
|
||||
["C#5", "0", "123456a6b7a"],
|
||||
["D5", "0", "123456a6b"],
|
||||
["Eb5", "0", "123456a"],
|
||||
["E5", "0", "12345"],
|
||||
["F5", "0", "12346a6b7a7b"],
|
||||
["F#5", "0", "12356a6b"],
|
||||
["G5", "0", "123"],
|
||||
["Ab5", "0", "12456a"],
|
||||
["A5", "0", "12"],
|
||||
["Bb5", "0", "134"],
|
||||
["B5", "0", "1"],
|
||||
["C6", "0", "2"],
|
||||
["C#6", "-", "12"],
|
||||
["D6", "-", "2"],
|
||||
["Eb6", "-", "23456a6b"],
|
||||
["E6", "x", "12345"],
|
||||
["F6", "x", "12346a6b"],
|
||||
["F#6", "x", "1235"],
|
||||
["G6", "x", "123"],
|
||||
["Ab6", "x", "124"],
|
||||
["A6", "x", "12"],
|
||||
["Bb6", "x", "1256a6b"],
|
||||
["B6", "x", "1245"],
|
||||
["C7", "x", "145"],
|
||||
["C#7", "x", "1346a6b", true],
|
||||
["D7", "x", "1346a6b"],
|
||||
];
|
||||
|
||||
/**
|
||||
* German differs on F, F♯, and B♭.
|
||||
* Low F is Dolmetsch’s German fingering: 0 123 4 (holes 5–7 open).
|
||||
* F♯ is thumb, 1, 2, 3, 5, 6, and half-hole 7.
|
||||
* B♭ is thumb, 1, 3, 4, 6.
|
||||
* Those two accidentals are the school German pattern described by
|
||||
* Zach VanderGraaff (Dynamic Music Room). The octave above keeps the same
|
||||
* front holes and uses the Dolmetsch second-octave thumb.
|
||||
*/
|
||||
const GERMAN_ROWS: Row[] = [
|
||||
["F5", "0", "1234"],
|
||||
["F#5", "0", "12356a6b7a"],
|
||||
["Bb5", "0", "1346a6b"],
|
||||
["F6", "x", "1234"],
|
||||
["F#6", "x", "12356a6b7a"],
|
||||
["Bb6", "x", "1346a6b"],
|
||||
];
|
||||
|
||||
export const CHART_NOTES = [
|
||||
"C5",
|
||||
"C#5",
|
||||
"D5",
|
||||
"Eb5",
|
||||
"E5",
|
||||
"F5",
|
||||
"F#5",
|
||||
"G5",
|
||||
"Ab5",
|
||||
"A5",
|
||||
"Bb5",
|
||||
"B5",
|
||||
"C6",
|
||||
"C#6",
|
||||
"D6",
|
||||
"Eb6",
|
||||
"E6",
|
||||
"F6",
|
||||
"F#6",
|
||||
"G6",
|
||||
"Ab6",
|
||||
"A6",
|
||||
"Bb6",
|
||||
"B6",
|
||||
"C7",
|
||||
"C#7",
|
||||
"D7",
|
||||
] as const;
|
||||
|
||||
function closedSet(spec: string): Set<string> {
|
||||
const closed = new Set<string>();
|
||||
let index = 0;
|
||||
while (index < spec.length) {
|
||||
const pair = spec.slice(index, index + 2);
|
||||
if (pair === "6a" || pair === "6b" || pair === "7a" || pair === "7b") {
|
||||
closed.add(pair);
|
||||
index += 2;
|
||||
} else {
|
||||
closed.add(spec[index]);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
|
||||
function make(thumb: ThumbCode, spec: string, bell = false): Fingering {
|
||||
const closed = closedSet(spec);
|
||||
return {
|
||||
thumb: thumb === "0" ? "closed" : thumb === "x" ? "half" : "open",
|
||||
holes: HOLE_IDS.map((id) => (closed.has(id) ? "closed" : "open")),
|
||||
bell,
|
||||
};
|
||||
}
|
||||
|
||||
function table(rows: Row[]): Map<number, Fingering> {
|
||||
const map = new Map<number, Fingering>();
|
||||
for (const [pitch, thumb, holes, bell] of rows) {
|
||||
const parsed = parsePitch(pitch);
|
||||
if (!parsed) throw new Error(`Bad chart pitch ${pitch}`);
|
||||
map.set(parsed.midi, make(thumb, holes, bell ?? false));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const BAROQUE = table(BAROQUE_ROWS);
|
||||
const GERMAN = table(GERMAN_ROWS);
|
||||
|
||||
export function fingeringFor(pitch: string, system: FingeringSystem): Fingering | null {
|
||||
const parsed = parsePitch(pitch);
|
||||
if (!parsed || parsed.midi < LOW_MIDI || parsed.midi > HIGH_MIDI) return null;
|
||||
if (system === "german") {
|
||||
const german = GERMAN.get(parsed.midi);
|
||||
if (german) return german;
|
||||
}
|
||||
return BAROQUE.get(parsed.midi) ?? null;
|
||||
}
|
||||
|
||||
export function fingeringCode(fingering: Fingering): string {
|
||||
const thumb = fingering.thumb === "closed" ? "0" : fingering.thumb === "half" ? "x" : "-";
|
||||
const closed = HOLE_IDS.filter((_, index) => fingering.holes[index] === "closed");
|
||||
const bell = fingering.bell ? " bell" : "";
|
||||
return `${thumb} ${closed.join(" ")}${bell}`;
|
||||
}
|
||||
|
||||
export function fingeringMarks(fingering: Fingering): string[] {
|
||||
const marks: string[] = [];
|
||||
if (fingering.thumb === "half") marks.push("pinch");
|
||||
const [hole1, hole2, hole3, hole4, hole5, hole6a, hole6b, hole7a, hole7b] = fingering.holes;
|
||||
if (hole6a !== hole6b) marks.push("half 6");
|
||||
if (hole7a !== hole7b) marks.push("half 7");
|
||||
const lowerRightClosed = hole6a === "closed" || hole6b === "closed" || hole7a === "closed" || hole7b === "closed";
|
||||
if (hole4 === "closed" && hole5 === "open" && lowerRightClosed) marks.push("fork");
|
||||
if (hole1 === "closed" && hole2 === "open" && hole3 === "closed") marks.push("fork");
|
||||
if (fingering.bell) marks.push("cover bell");
|
||||
return marks;
|
||||
}
|
||||
|
||||
export function fingeringProse(fingering: Fingering): string {
|
||||
const thumb =
|
||||
fingering.thumb === "closed"
|
||||
? "thumb closed"
|
||||
: fingering.thumb === "half"
|
||||
? "thumb pinched"
|
||||
: "thumb open";
|
||||
const names = ["1", "2", "3", "4", "5", "6a", "6b", "7a", "7b"];
|
||||
const closed = names.filter((_, index) => fingering.holes[index] === "closed");
|
||||
const holes = closed.length > 0 ? `holes ${closed.join(" ")} closed` : "no front holes closed";
|
||||
const bell = fingering.bell ? ", cover the bell" : "";
|
||||
return `${thumb}, ${holes}${bell}`;
|
||||
}
|
||||
|
||||
export function germanDiffers(pitch: string): boolean {
|
||||
const baroque = fingeringFor(pitch, "baroque");
|
||||
const german = fingeringFor(pitch, "german");
|
||||
if (!baroque || !german) return false;
|
||||
return fingeringCode(baroque) !== fingeringCode(german);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
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;
|
||||
const grouped = new Map<number, Candidate[]>();
|
||||
|
||||
for (const element of children(measureNode)) {
|
||||
const tag = tagOf(element);
|
||||
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 });
|
||||
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",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
const STEPS: Record<string, number> = {
|
||||
C: 0,
|
||||
D: 2,
|
||||
E: 4,
|
||||
F: 5,
|
||||
G: 7,
|
||||
A: 9,
|
||||
B: 11,
|
||||
};
|
||||
|
||||
export type ParsedPitch = {
|
||||
midi: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
/** Scientific pitch, concert pitch. C4 is MIDI 60, so soprano low C is C5. */
|
||||
export function parsePitch(raw: string): ParsedPitch | null {
|
||||
const match = /^([A-Ga-g])(##|bb|#|b|♯|♭)?(-?\d+)$/.exec(raw.trim());
|
||||
if (!match) return null;
|
||||
const step = match[1].toUpperCase();
|
||||
const accidental = match[2] ?? "";
|
||||
const alter =
|
||||
accidental === "#" || accidental === "♯"
|
||||
? 1
|
||||
: accidental === "b" || accidental === "♭"
|
||||
? -1
|
||||
: accidental === "##"
|
||||
? 2
|
||||
: accidental === "bb"
|
||||
? -2
|
||||
: 0;
|
||||
const octave = Number(match[3]);
|
||||
if (!Number.isInteger(octave)) return null;
|
||||
const pretty =
|
||||
alter === 1 ? "#" : alter === -1 ? "b" : alter === 2 ? "##" : alter === -2 ? "bb" : "";
|
||||
return {
|
||||
midi: (octave + 1) * 12 + STEPS[step] + alter,
|
||||
name: `${step}${pretty}${octave}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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;
|
||||
@@ -0,0 +1,97 @@
|
||||
import { formatQuarterLengths, quartersMatch } from "@/lib/duration";
|
||||
|
||||
export type TimeSignature = {
|
||||
beats: number;
|
||||
beatType: number;
|
||||
};
|
||||
|
||||
export type ScoreEvent = {
|
||||
id: string;
|
||||
kind: "note" | "rest";
|
||||
pitch: string | null;
|
||||
quarters: number;
|
||||
label: string;
|
||||
tied: boolean;
|
||||
};
|
||||
|
||||
export type Measure = {
|
||||
id: string;
|
||||
events: ScoreEvent[];
|
||||
};
|
||||
|
||||
export type ScoreOrigin = "sample" | "upload" | "blank";
|
||||
|
||||
export type Score = {
|
||||
title: string;
|
||||
attribution: string;
|
||||
timeSignature: TimeSignature;
|
||||
measures: Measure[];
|
||||
origin: ScoreOrigin;
|
||||
};
|
||||
|
||||
export const TIME_SIGNATURES: TimeSignature[] = [
|
||||
{ beats: 2, beatType: 4 },
|
||||
{ beats: 3, beatType: 4 },
|
||||
{ beats: 4, beatType: 4 },
|
||||
{ beats: 6, beatType: 8 },
|
||||
{ beats: 3, beatType: 8 },
|
||||
{ beats: 2, beatType: 2 },
|
||||
];
|
||||
|
||||
export function newId(prefix: string): string {
|
||||
return `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function signatureKey(timeSignature: TimeSignature): string {
|
||||
return `${timeSignature.beats}/${timeSignature.beatType}`;
|
||||
}
|
||||
|
||||
export function parseSignature(key: string): TimeSignature {
|
||||
const [beats, beatType] = key.split("/").map(Number);
|
||||
return { beats, beatType };
|
||||
}
|
||||
|
||||
export function expectedQuarters(timeSignature: TimeSignature): number {
|
||||
return (timeSignature.beats * 4) / timeSignature.beatType;
|
||||
}
|
||||
|
||||
export function barStatus(
|
||||
measure: Measure,
|
||||
timeSignature: TimeSignature,
|
||||
): { ok: boolean; text: string } {
|
||||
const sum = measure.events.reduce((total, event) => total + event.quarters, 0);
|
||||
const expected = expectedQuarters(timeSignature);
|
||||
const signature = signatureKey(timeSignature);
|
||||
if (quartersMatch(sum, expected)) {
|
||||
return { ok: true, text: `${signature} · ${formatQuarterLengths(sum)}` };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
text: `${formatQuarterLengths(sum)} in this bar; ${signature} holds ${formatQuarterLengths(expected)}.`,
|
||||
};
|
||||
}
|
||||
|
||||
export function startBeats(events: ScoreEvent[], timeSignature: TimeSignature): number[] {
|
||||
const quartersPerBeat = 4 / timeSignature.beatType;
|
||||
let cursor = 0;
|
||||
return events.map((event) => {
|
||||
const beat = cursor / quartersPerBeat + 1;
|
||||
cursor += event.quarters;
|
||||
return beat;
|
||||
});
|
||||
}
|
||||
|
||||
export function formatBeat(beat: number): string {
|
||||
const rounded = Math.round(beat * 100) / 100;
|
||||
return Number.isInteger(rounded) ? String(rounded) : String(rounded);
|
||||
}
|
||||
|
||||
export function blankScore(): Score {
|
||||
return {
|
||||
title: "Untitled line",
|
||||
attribution: "Hand-entered lead line. Add notes, then read the tabs.",
|
||||
timeSignature: { beats: 4, beatType: 4 },
|
||||
origin: "blank",
|
||||
measures: [{ id: newId("m"), events: [] }],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { cn } from "cn"
|
||||
Reference in new issue
Block a user