Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e397b62757 | ||
|
|
837fcb2751 | ||
|
|
7c988e9347 | ||
|
|
e51fdf8216 | ||
|
|
dcca7f5e83 |
No files matched your search
@@ -1,23 +1,23 @@
|
||||
# Recorder fingering editor
|
||||
|
||||
A soprano recorder tool for arranging fingering charts. Drag a chart onto a line, reorder it, set how long the note lasts, or take it off. Baroque fingering is the chart. The foot, with the double holes, is at the top of each chart, and the mouthpiece is at the bottom.
|
||||
A soprano recorder tool for arranging fingering charts. Drag a chart, a rest, or a space onto a line, reorder it, set how long it lasts, or take it off. Resize the line box, then save the finished row as a PNG. Baroque fingering is the chart. The foot, with the double holes, is at the top of each chart. The tube ends at the thumb hole.
|
||||
|
||||
The usual way to run it is one Linux AppImage. That file contains the window and the fingering charts. It does not read sheet music, and it does not include oemer, ONNX, or model files.
|
||||
|
||||
## Run the AppImage
|
||||
|
||||
```bash
|
||||
chmod +x Recorder-Tab-Converter-0.2.0.AppImage
|
||||
./Recorder-Tab-Converter-0.2.0.AppImage
|
||||
chmod +x Recorder-Tab-Converter-0.2.5.AppImage
|
||||
./Recorder-Tab-Converter-0.2.5.AppImage
|
||||
```
|
||||
|
||||
If the file will not start because FUSE is missing:
|
||||
|
||||
```bash
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 ./Recorder-Tab-Converter-0.2.0.AppImage
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 ./Recorder-Tab-Converter-0.2.5.AppImage
|
||||
```
|
||||
|
||||
Open **Empty line**, or start from **Twinkle, opening** or **Ode, held note**. Drag any chart from C5 to D7 onto the line. Drag a placed chart to move it. Choose a duration under the chart. Remove takes a note off.
|
||||
Open **Empty line**, or start from **Twinkle, opening** or **Ode, held note**. Drag any chart from C5 to D7, or drag **Rest** or **Space**, onto a line. Drag a placed item to move it, including onto another line. **New line** adds a row underneath. Plus and minus change the length. Remove takes an item off. **Shorter**, **Taller**, **Narrower**, **Wider**, and the corner grip change the box. **Save PNG** downloads every line.
|
||||
|
||||
## Build the AppImage
|
||||
|
||||
@@ -30,7 +30,7 @@ From a checkout, one command:
|
||||
That installs Node packages, builds the app, and writes:
|
||||
|
||||
```text
|
||||
dist/Recorder-Tab-Converter-0.2.0.AppImage
|
||||
dist/Recorder-Tab-Converter-0.2.5.AppImage
|
||||
```
|
||||
|
||||
The AppImage is not committed. Keep it under 600MB.
|
||||
@@ -47,6 +47,6 @@ The dev server listens on [http://127.0.0.1:3847](http://127.0.0.1:3847).
|
||||
|
||||
## Fingerings
|
||||
|
||||
Baroque (English) fingerings are the first choice on the [Dolmetsch Online chart for recorders in C](https://dolmetsch.com/textfingeringchart.htm) (Brian Blood). The soprano is treated in concert pitch: it sounds as written, low C is C5, and the chart stops at D7. Double holes 6 and 7, forked F, and the second-octave thumb pinch are included. Alternate fingerings on that chart are not shown.
|
||||
Baroque (English) fingerings are the first choice on the [Dolmetsch Online chart for recorders in C](https://dolmetsch.com/textfingeringchart.htm) (Brian Blood). The soprano is labeled in concert pitch: low C is C5, and the chart stops at D7. Double holes 6 and 7, forked F, and the second-octave thumb pinch are included. Alternate fingerings on that chart are not shown.
|
||||
|
||||
Each chart is drawn foot-up: holes 7 and 6 at the top, then 5 through 1, then the thumb, with the mouthpiece at the bottom. A filled hole is closed. An arrow after a placed chart is one more beat in 4/4.
|
||||
Each chart is drawn foot-up: holes 7 and 6 at the top, then 5 through 1, then the thumb. The tube ends there. A filled hole is closed. A treble staff under the holes is written one octave below the sounding pitch. The staff opens with a filled treble clef, spiral on the G line. Low C, labeled C5, sits on the ledger line below the staff. Sounding C6 is written C5, and sounding C7 is written C6. Length is a notehead, stem, flags, dots, and rests. Labels keep the sounding names.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "recorder-tab-converter",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.5",
|
||||
"private": true,
|
||||
"description": "Arrange soprano recorder fingering charts by dragging them onto a line.",
|
||||
"main": "electron/main.cjs",
|
||||
|
||||
@@ -117,6 +117,12 @@
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
body.line-drag,
|
||||
body.line-drag * {
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ const display = Fraunces({
|
||||
export const metadata: Metadata = {
|
||||
title: "Recorder fingering editor",
|
||||
description:
|
||||
"Drag soprano recorder fingering charts onto a line, reorder them, and set how long each note lasts.",
|
||||
"Drag soprano recorder fingering charts, rests, and spaces onto a line, then save the row as a PNG.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
|
||||
+427
-108
@@ -1,41 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { BeatArrow, FingeringDiagram } from "@/components/fingering-diagram";
|
||||
import { FingeringDiagram, RestMark } from "@/components/fingering-diagram";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DURATION_PRESETS } from "@/lib/duration";
|
||||
import { downloadBlob, renderLinePng } from "@/lib/export-png";
|
||||
import { CHART_NOTES, fingeringFor } from "@/lib/fingering";
|
||||
import {
|
||||
appendLine,
|
||||
clampLineBox,
|
||||
cloneLine,
|
||||
createNote,
|
||||
createRest,
|
||||
createSpace,
|
||||
dropIndexFromPoint,
|
||||
insertNote,
|
||||
LINE_EXAMPLES,
|
||||
moveNote,
|
||||
neighborDuration,
|
||||
placeOnLine,
|
||||
removeLine,
|
||||
removeNote,
|
||||
setDuration,
|
||||
transferNote,
|
||||
type LineKind,
|
||||
type LineNote,
|
||||
type ScoreLine,
|
||||
} from "@/lib/line";
|
||||
import { extraBeatArrows } from "@/lib/score";
|
||||
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type RefObject } from "react";
|
||||
import { chartOuterWidth, chartPixelHeight } from "@/lib/notation";
|
||||
import { pngFilename, PNG_CHART_WIDTH } from "@/lib/png-layout";
|
||||
import { ChevronLeft, ChevronRight, Minus, Plus, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject } from "react";
|
||||
|
||||
const FOUR_FOUR = { beats: 4, beatType: 4 };
|
||||
const CHART_WIDTH = 44;
|
||||
|
||||
type Drag =
|
||||
| {
|
||||
source: "palette";
|
||||
type Drag = {
|
||||
source: "palette" | "line";
|
||||
kind: LineKind;
|
||||
pitch: string;
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
x: number;
|
||||
y: number;
|
||||
armed: boolean;
|
||||
}
|
||||
| {
|
||||
source: "line";
|
||||
id: string;
|
||||
pitch: string;
|
||||
lineId: string;
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
@@ -44,30 +45,46 @@ type Drag =
|
||||
armed: boolean;
|
||||
};
|
||||
|
||||
type DropTarget = { lineId: string; index: number };
|
||||
|
||||
export function Editor() {
|
||||
const [notes, setNotes] = useState<LineNote[]>([]);
|
||||
const [lines, setLines] = useState<ScoreLine[]>([{ id: "line-1", notes: [] }]);
|
||||
const [title, setTitle] = useState("Empty line");
|
||||
const [drag, setDrag] = useState<Drag | null>(null);
|
||||
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
|
||||
const [boxWidth, setBoxWidth] = useState<number | null>(null);
|
||||
const [boxHeight, setBoxHeight] = useState(520);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const dragRef = useRef<Drag | null>(null);
|
||||
const lineRef = useRef<HTMLDivElement>(null);
|
||||
const frameRef = useRef<HTMLDivElement>(null);
|
||||
const paletteRef = useRef<HTMLDivElement>(null);
|
||||
const exportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
function updateDrag(next: Drag | null) {
|
||||
dragRef.current = next;
|
||||
setDrag(next);
|
||||
if (!next) document.body.classList.remove("line-drag");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function pointIndex(clientX: number, clientY: number) {
|
||||
const line = lineRef.current;
|
||||
if (!line) return -1;
|
||||
const rect = line.getBoundingClientRect();
|
||||
const centers = [...line.querySelectorAll<HTMLElement>("[data-note-id]")].map((element) => {
|
||||
function pointTarget(clientX: number, clientY: number): DropTarget | null {
|
||||
const rows = [...document.querySelectorAll<HTMLElement>("[data-line-id]")];
|
||||
let best: (DropTarget & { distance: number }) | null = null;
|
||||
for (const row of rows) {
|
||||
const lineId = row.dataset.lineId;
|
||||
if (!lineId) continue;
|
||||
const rect = row.getBoundingClientRect();
|
||||
const centers = [...row.querySelectorAll<HTMLElement>("[data-note-id]")].map((element) => {
|
||||
const box = element.getBoundingClientRect();
|
||||
return box.left + box.width / 2;
|
||||
});
|
||||
return dropIndexFromPoint(clientX, clientY, rect, centers);
|
||||
const index = dropIndexFromPoint(clientX, clientY, rect, centers);
|
||||
if (index < 0) continue;
|
||||
const distance = Math.abs(clientY - (rect.top + rect.bottom) / 2);
|
||||
if (!best || distance < best.distance) best = { lineId, index, distance };
|
||||
}
|
||||
return best ? { lineId: best.lineId, index: best.index } : null;
|
||||
}
|
||||
|
||||
function onMove(event: PointerEvent) {
|
||||
@@ -83,54 +100,133 @@ export function Editor() {
|
||||
Math.abs(dx) > Math.abs(dy);
|
||||
if (touchScroll) {
|
||||
updateDrag(null);
|
||||
setDropIndex(null);
|
||||
setDropTarget(null);
|
||||
return;
|
||||
}
|
||||
if (Math.hypot(dx, dy) < 8) return;
|
||||
}
|
||||
if (lineRef.current) {
|
||||
const rect = lineRef.current.getBoundingClientRect();
|
||||
if (event.clientX > rect.right - 28) lineRef.current.scrollLeft += 14;
|
||||
if (event.clientX < rect.left + 28) lineRef.current.scrollLeft -= 14;
|
||||
window.getSelection()?.removeAllRanges();
|
||||
const rows = [...document.querySelectorAll<HTMLElement>("[data-line-id]")];
|
||||
for (const candidate of rows) {
|
||||
const rect = candidate.getBoundingClientRect();
|
||||
const verticallyNear = event.clientY >= rect.top - 36 && event.clientY <= rect.bottom + 36;
|
||||
if (!verticallyNear) continue;
|
||||
if (event.clientX > rect.right - 28) candidate.scrollLeft += 14;
|
||||
if (event.clientX < rect.left + 28) candidate.scrollLeft -= 14;
|
||||
}
|
||||
const next = { ...current, x: event.clientX, y: event.clientY, armed: true };
|
||||
updateDrag(next);
|
||||
const index = pointIndex(event.clientX, event.clientY);
|
||||
setDropIndex(index >= 0 ? index : null);
|
||||
setDropTarget(pointTarget(event.clientX, event.clientY));
|
||||
}
|
||||
|
||||
function onUp(event: PointerEvent) {
|
||||
const current = dragRef.current;
|
||||
if (!current || event.pointerId !== current.pointerId) return;
|
||||
const index = current.armed ? pointIndex(event.clientX, event.clientY) : -1;
|
||||
if (current.armed && index >= 0) {
|
||||
const target = current.armed ? pointTarget(event.clientX, event.clientY) : null;
|
||||
if (current.armed && target) {
|
||||
if (current.source === "palette") {
|
||||
setNotes((existing) => insertNote(existing, index, createNote(current.pitch)));
|
||||
const note =
|
||||
current.kind === "rest"
|
||||
? createRest()
|
||||
: current.kind === "space"
|
||||
? createSpace()
|
||||
: createNote(current.pitch);
|
||||
setLines((existing) => placeOnLine(existing, target.lineId, target.index, note));
|
||||
setTitle("Your line");
|
||||
} else {
|
||||
setNotes((existing) => moveNote(existing, current.id, index));
|
||||
setLines((existing) => transferNote(existing, current.lineId, current.id, target.lineId, target.index));
|
||||
}
|
||||
}
|
||||
updateDrag(null);
|
||||
setDropIndex(null);
|
||||
setDropTarget(null);
|
||||
}
|
||||
|
||||
function onSelectStart(event: Event) {
|
||||
if (dragRef.current) event.preventDefault();
|
||||
}
|
||||
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onUp);
|
||||
window.addEventListener("pointercancel", onUp);
|
||||
document.addEventListener("selectstart", onSelectStart);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
window.removeEventListener("pointercancel", onUp);
|
||||
document.removeEventListener("selectstart", onSelectStart);
|
||||
document.body.classList.remove("line-drag");
|
||||
};
|
||||
}, []);
|
||||
|
||||
function beginDrag(event: ReactPointerEvent, next: Drag) {
|
||||
if (event.button !== 0 && event.pointerType === "mouse") return;
|
||||
if (event.pointerType === "mouse" && event.button !== 0) return;
|
||||
if (event.pointerType === "mouse") event.preventDefault();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
document.body.classList.add("line-drag");
|
||||
updateDrag(next);
|
||||
}
|
||||
|
||||
const ghostPitch = drag?.armed ? drag.pitch : null;
|
||||
function pageLimit() {
|
||||
return frameRef.current?.parentElement?.clientWidth ?? 960;
|
||||
}
|
||||
|
||||
function nudgeBox(dw: number, dh: number) {
|
||||
const maxWidth = pageLimit();
|
||||
const current = boxWidth ?? frameRef.current?.getBoundingClientRect().width ?? maxWidth;
|
||||
const next = clampLineBox(current + dw, boxHeight + dh, maxWidth);
|
||||
setBoxWidth(next.width);
|
||||
setBoxHeight(next.height);
|
||||
}
|
||||
|
||||
function startResize(event: ReactPointerEvent) {
|
||||
if (event.pointerType === "mouse" && event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
document.body.classList.add("line-drag");
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const startW = frameRef.current?.getBoundingClientRect().width ?? 640;
|
||||
const startH = boxHeight;
|
||||
const maxWidth = pageLimit();
|
||||
function move(moveEvent: PointerEvent) {
|
||||
const next = clampLineBox(
|
||||
startW + (moveEvent.clientX - startX),
|
||||
startH + (moveEvent.clientY - startY),
|
||||
maxWidth,
|
||||
);
|
||||
setBoxWidth(next.width);
|
||||
setBoxHeight(next.height);
|
||||
}
|
||||
function up() {
|
||||
document.body.classList.remove("line-drag");
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
window.removeEventListener("pointercancel", up);
|
||||
}
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
window.addEventListener("pointercancel", up);
|
||||
}
|
||||
|
||||
async function savePng() {
|
||||
const filled = lines.some((line) => line.notes.length > 0);
|
||||
if (!exportRef.current || !filled) {
|
||||
setSaveError("Add a note, rest, or space before saving a PNG.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const blob = await renderLinePng(exportRef.current, title);
|
||||
downloadBlob(blob, pngFilename(title));
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : "Could not save the PNG.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const ghost = drag?.armed ? drag : null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 py-6 md:px-8 md:py-10">
|
||||
@@ -138,17 +234,19 @@ export function Editor() {
|
||||
<p className="text-sm tracking-[0.16em] text-[#6a4328] uppercase">Soprano in C · Baroque</p>
|
||||
<h1 className="font-heading text-4xl leading-tight md:text-5xl">Recorder fingering editor</h1>
|
||||
<p className="max-w-2xl text-base leading-7 text-muted-foreground">
|
||||
Drag a chart from the palette onto the line. Drag a placed chart sideways to reorder it, set
|
||||
how long it lasts, or take it off. The foot, with the double holes, stays at the top.
|
||||
Drag a chart, a rest, or a space onto a line. Drag a placed item sideways to reorder it, or
|
||||
onto another line. New line adds a row underneath. The staff under the holes shows the pitch
|
||||
and the length. Resize the box, then save every line as a PNG. The foot, with the double holes,
|
||||
stays at the top.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={notes.length === 0 ? "default" : "outline"}
|
||||
variant={lines.length === 1 && lines[0].notes.length === 0 ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
setNotes([]);
|
||||
setLines([{ id: "line-1", notes: [] }]);
|
||||
setTitle("Empty line");
|
||||
}}
|
||||
>
|
||||
@@ -160,7 +258,7 @@ export function Editor() {
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setNotes(cloneLine([...example.notes]));
|
||||
setLines([{ id: "line-1", notes: cloneLine([...example.notes]) }]);
|
||||
setTitle(example.title);
|
||||
}}
|
||||
>
|
||||
@@ -174,24 +272,68 @@ export function Editor() {
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl">Palette</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
C5 through D7. Drag a chart downward onto the line. Swipe the row sideways to see the rest.
|
||||
C5 through D7, plus a rest and a space. Drag downward onto the line. Swipe the row sideways
|
||||
to see the rest of the charts.
|
||||
</p>
|
||||
</div>
|
||||
<ScrollButtons target={paletteRef} label="palette" />
|
||||
</div>
|
||||
<div
|
||||
ref={paletteRef}
|
||||
className="flex gap-2 overflow-x-auto rounded-2xl bg-card p-3 ring-1 ring-foreground/10"
|
||||
className="flex gap-2 overflow-x-auto rounded-2xl bg-card p-3 ring-1 ring-foreground/10 select-none"
|
||||
>
|
||||
{CHART_NOTES.map((pitch) => (
|
||||
<div
|
||||
key={pitch}
|
||||
data-palette-pitch={pitch}
|
||||
className="flex w-14 shrink-0 cursor-grab touch-pan-x flex-col items-center gap-1 active:cursor-grabbing"
|
||||
<PaletteToken
|
||||
label="Rest"
|
||||
onPointerDown={(event) =>
|
||||
beginDrag(event, {
|
||||
source: "palette",
|
||||
kind: "rest",
|
||||
pitch: "",
|
||||
id: "",
|
||||
lineId: "",
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
armed: false,
|
||||
})
|
||||
}
|
||||
>
|
||||
<RestMark quarters={1} width={40} />
|
||||
</PaletteToken>
|
||||
<PaletteToken
|
||||
label="Space"
|
||||
onPointerDown={(event) =>
|
||||
beginDrag(event, {
|
||||
source: "palette",
|
||||
kind: "space",
|
||||
pitch: "",
|
||||
id: "",
|
||||
lineId: "",
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
armed: false,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SpaceGlyph width={chartOuterWidth(40)} height={chartPixelHeight(40)} />
|
||||
</PaletteToken>
|
||||
{CHART_NOTES.map((pitch) => (
|
||||
<PaletteToken
|
||||
key={pitch}
|
||||
label={pitch}
|
||||
pitch={pitch}
|
||||
onPointerDown={(event) =>
|
||||
beginDrag(event, {
|
||||
source: "palette",
|
||||
kind: "note",
|
||||
pitch,
|
||||
id: "",
|
||||
lineId: "",
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
@@ -202,45 +344,106 @@ export function Editor() {
|
||||
}
|
||||
>
|
||||
<FingeringDiagram fingering={fingeringFor(pitch, "baroque")} pitch={pitch} size="sm" />
|
||||
<span className="text-[11px] leading-none font-medium">{pitch}</span>
|
||||
</div>
|
||||
</PaletteToken>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl">{title}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
4/4. An arrow after a chart is one more beat. A dotted value keeps its name under the chart.
|
||||
Treble staff written an octave below the sounding pitch, 4/4. Noteheads, stems, flags, and dots show the length. Plus and minus
|
||||
change it. A space is empty room you can still drag. The box is {boxHeight}px tall
|
||||
{boxWidth ? `, ${boxWidth}px wide` : ""}.
|
||||
</p>
|
||||
</div>
|
||||
<ScrollButtons target={lineRef} label="line" />
|
||||
<ScrollButtons
|
||||
label="lines"
|
||||
onLeft={() =>
|
||||
document.querySelectorAll<HTMLElement>("[data-line-id]").forEach((row) => row.scrollBy({ left: -180, behavior: "smooth" }))
|
||||
}
|
||||
onRight={() =>
|
||||
document.querySelectorAll<HTMLElement>("[data-line-id]").forEach((row) => row.scrollBy({ left: 180, behavior: "smooth" }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => nudgeBox(0, -48)}>
|
||||
Shorter
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => nudgeBox(0, 48)}>
|
||||
Taller
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => nudgeBox(-80, 0)}>
|
||||
Narrower
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => nudgeBox(80, 0)}>
|
||||
Wider
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setBoxWidth(null)}>
|
||||
Fit width
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setLines((existing) => appendLine(existing));
|
||||
setBoxHeight((height) => clampLineBox(400, height + 260, 960).height);
|
||||
}}
|
||||
>
|
||||
New line
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={savePng} disabled={lines.every((line) => line.notes.length === 0) || saving}>
|
||||
{saving ? "Saving…" : "Save PNG"}
|
||||
</Button>
|
||||
</div>
|
||||
{saveError ? <p className="text-sm text-destructive">{saveError}</p> : null}
|
||||
<div ref={frameRef} className="relative max-w-full" style={{ width: boxWidth ?? "100%", height: boxHeight }}>
|
||||
<div
|
||||
ref={lineRef}
|
||||
data-line
|
||||
className={`min-h-64 overflow-x-auto rounded-none border-2 bg-card px-3 py-4 ${
|
||||
dropIndex !== null ? "border-primary" : "border-foreground"
|
||||
className={`flex h-full flex-col gap-4 overflow-auto rounded-none border-2 bg-card px-3 py-4 select-none ${
|
||||
dropTarget !== null ? "border-primary" : "border-foreground"
|
||||
}`}
|
||||
>
|
||||
{notes.length === 0 ? (
|
||||
<p className="px-2 py-16 text-center text-sm text-muted-foreground">
|
||||
Drop a fingering here. The line is empty.
|
||||
{lines.map((line, lineIndex) => (
|
||||
<div key={line.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium">Line {lineIndex + 1}</p>
|
||||
{lines.length > 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
aria-label={`Remove line ${lineIndex + 1}`}
|
||||
onClick={() => setLines((existing) => removeLine(existing, line.id))}
|
||||
>
|
||||
<X />
|
||||
Remove line
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div data-line-id={line.id} className="overflow-x-auto">
|
||||
{line.notes.length === 0 ? (
|
||||
<p className="px-2 py-10 text-center text-sm text-muted-foreground">
|
||||
Drop a fingering, a rest, or a space here. The line is empty.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex w-max min-w-full items-end gap-2">
|
||||
{notes.map((note, index) => (
|
||||
{line.notes.map((note, index) => (
|
||||
<div key={note.id} className="flex items-end gap-2">
|
||||
{dropIndex === index ? <DropCaret /> : null}
|
||||
<PlacedNote
|
||||
{dropTarget?.lineId === line.id && dropTarget.index === index ? <DropCaret /> : null}
|
||||
<PlacedItem
|
||||
note={note}
|
||||
dimmed={drag?.source === "line" && drag.armed && drag.id === note.id}
|
||||
onPointerDown={(event) =>
|
||||
beginDrag(event, {
|
||||
source: "line",
|
||||
kind: note.kind,
|
||||
id: note.id,
|
||||
lineId: line.id,
|
||||
pitch: note.pitch,
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
@@ -253,41 +456,131 @@ export function Editor() {
|
||||
onDuration={(quarters) => {
|
||||
const preset = DURATION_PRESETS.find((item) => item.quarters === quarters);
|
||||
if (!preset) return;
|
||||
setNotes((existing) => setDuration(existing, note.id, preset));
|
||||
setLines((existing) =>
|
||||
existing.map((row) =>
|
||||
row.id === line.id ? { ...row, notes: setDuration(row.notes, note.id, preset) } : row,
|
||||
),
|
||||
);
|
||||
}}
|
||||
onRemove={() => setNotes((existing) => removeNote(existing, note.id))}
|
||||
onRemove={() =>
|
||||
setLines((existing) =>
|
||||
existing.map((row) =>
|
||||
row.id === line.id ? { ...row, notes: removeNote(row.notes, note.id) } : row,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{dropIndex === notes.length ? <DropCaret /> : null}
|
||||
{dropTarget?.lineId === line.id && dropTarget.index === line.notes.length ? <DropCaret /> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label="Drag to resize the line box"
|
||||
aria-valuemin={200}
|
||||
aria-valuemax={880}
|
||||
aria-valuenow={boxHeight}
|
||||
aria-valuetext={`${boxWidth ? `${boxWidth} pixels wide` : "full width"}, ${boxHeight} pixels tall`}
|
||||
className="absolute right-0 bottom-0 z-10 flex h-8 w-8 cursor-nwse-resize touch-none items-end justify-end p-1"
|
||||
onPointerDown={startResize}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowUp") nudgeBox(0, -48);
|
||||
if (event.key === "ArrowDown") nudgeBox(0, 48);
|
||||
if (event.key === "ArrowLeft") nudgeBox(-80, 0);
|
||||
if (event.key === "ArrowRight") nudgeBox(80, 0);
|
||||
}}
|
||||
>
|
||||
<span className="block h-3 w-3 border-r-2 border-b-2 border-foreground" aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<li>Foot at the top, mouthpiece at the bottom</li>
|
||||
<li>Foot at the top. The tube ends at the thumb hole</li>
|
||||
<li>Filled hole: closed</li>
|
||||
<li>Empty hole: open</li>
|
||||
<li>One of a pair filled: half-hole</li>
|
||||
<li>Thumb half-filled: pinch</li>
|
||||
<li>Arrow: one more beat</li>
|
||||
<li>Staff under the chart: pitch and length</li>
|
||||
<li>Rest: silence on the staff</li>
|
||||
<li>Space: empty room</li>
|
||||
</ul>
|
||||
|
||||
{ghostPitch ? (
|
||||
{ghost ? (
|
||||
<div
|
||||
className="pointer-events-none fixed z-50 opacity-90"
|
||||
style={{ left: drag!.x - 22, top: drag!.y - 80 }}
|
||||
className="pointer-events-none fixed z-50 opacity-90 select-none"
|
||||
style={{ left: ghost.x - 22, top: ghost.y - 80 }}
|
||||
aria-hidden
|
||||
>
|
||||
<FingeringDiagram fingering={fingeringFor(ghostPitch, "baroque")} pitch={ghostPitch} size="sm" />
|
||||
{ghost.kind === "note" ? (
|
||||
<FingeringDiagram fingering={fingeringFor(ghost.pitch, "baroque")} pitch={ghost.pitch} size="sm" />
|
||||
) : null}
|
||||
{ghost.kind === "rest" ? <RestMark quarters={1} width={40} /> : null}
|
||||
{ghost.kind === "space" ? <SpaceGlyph width={chartOuterWidth(40)} height={chartPixelHeight(40)} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div ref={exportRef} className="pointer-events-none absolute h-px w-px overflow-hidden" aria-hidden>
|
||||
{lines.map((line) => (
|
||||
<div key={line.id} data-export-line={line.id}>
|
||||
{line.notes.map((note) => (
|
||||
<div
|
||||
key={note.id}
|
||||
data-export-slot
|
||||
data-kind={note.kind}
|
||||
data-quarters={note.quarters}
|
||||
data-duration={note.label}
|
||||
data-pitch={note.pitch}
|
||||
>
|
||||
{note.kind === "note" ? (
|
||||
<FingeringDiagram
|
||||
fingering={fingeringFor(note.pitch, "baroque")}
|
||||
pitch={note.pitch}
|
||||
quarters={note.quarters}
|
||||
width={PNG_CHART_WIDTH}
|
||||
/>
|
||||
) : null}
|
||||
{note.kind === "rest" ? <RestMark quarters={note.quarters} width={PNG_CHART_WIDTH} /> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlacedNote({
|
||||
function PaletteToken({
|
||||
label,
|
||||
pitch,
|
||||
onPointerDown,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
pitch?: string;
|
||||
onPointerDown: (event: ReactPointerEvent) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-palette-pitch={pitch}
|
||||
className="flex w-14 shrink-0 cursor-grab touch-pan-x flex-col items-center gap-1 select-none active:cursor-grabbing"
|
||||
onPointerDown={onPointerDown}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
>
|
||||
{children}
|
||||
<span className="text-[11px] leading-none font-medium">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlacedItem({
|
||||
note,
|
||||
dimmed,
|
||||
onPointerDown,
|
||||
@@ -300,45 +593,55 @@ function PlacedNote({
|
||||
onDuration: (quarters: number) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const fingering = fingeringFor(note.pitch, "baroque");
|
||||
const arrows = extraBeatArrows(note.quarters, FOUR_FOUR);
|
||||
const fingering = note.kind === "note" ? fingeringFor(note.pitch, "baroque") : null;
|
||||
const caption = note.kind === "note" ? note.pitch : note.kind === "rest" ? "Rest" : "Space";
|
||||
const chartHeight = chartPixelHeight(CHART_WIDTH);
|
||||
const shorter = neighborDuration(note.quarters, -1);
|
||||
const longer = neighborDuration(note.quarters, 1);
|
||||
return (
|
||||
<div data-note-id={note.id} className={`flex flex-col items-center gap-1 ${dimmed ? "opacity-40" : ""}`}>
|
||||
<div data-note-id={note.id} className={`flex flex-col items-center gap-1 select-none ${dimmed ? "opacity-40" : ""}`}>
|
||||
<div
|
||||
className="flex cursor-grab touch-none items-center gap-1 active:cursor-grabbing"
|
||||
onPointerDown={onPointerDown}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
>
|
||||
<FingeringDiagram fingering={fingering} pitch={note.pitch} />
|
||||
{arrows > 0 ? (
|
||||
<span className="flex items-center gap-1" aria-hidden>
|
||||
{Array.from({ length: arrows }, (_, index) => (
|
||||
<BeatArrow key={index} />
|
||||
))}
|
||||
</span>
|
||||
{note.kind === "note" ? (
|
||||
<FingeringDiagram fingering={fingering} pitch={note.pitch} quarters={note.quarters} />
|
||||
) : null}
|
||||
{note.kind === "rest" ? <RestMark quarters={note.quarters} width={CHART_WIDTH} /> : null}
|
||||
{note.kind === "space" ? (
|
||||
<SpaceGlyph width={Math.max(36, Math.round(chartOuterWidth(CHART_WIDTH) * note.quarters))} height={chartHeight} />
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-[11px] leading-none font-medium">{note.pitch}</p>
|
||||
<label className="sr-only" htmlFor={`duration-${note.id}`}>
|
||||
Duration for {note.pitch}
|
||||
</label>
|
||||
<select
|
||||
id={`duration-${note.id}`}
|
||||
className="h-8 w-[4.5rem] rounded-md border border-input bg-card px-1 text-[11px]"
|
||||
value={String(note.quarters)}
|
||||
onChange={(event) => onDuration(Number(event.target.value))}
|
||||
<p className="text-[11px] leading-none font-medium">{caption}</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
aria-label={`Make ${caption} shorter`}
|
||||
disabled={!shorter}
|
||||
onClick={() => shorter && onDuration(shorter.quarters)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{DURATION_PRESETS.map((preset) => (
|
||||
<option key={preset.label} value={preset.quarters}>
|
||||
{preset.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Minus />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
aria-label={`Make ${caption} longer`}
|
||||
disabled={!longer}
|
||||
onClick={() => longer && onDuration(longer.quarters)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
aria-label={`Remove ${note.pitch}`}
|
||||
aria-label={`Remove ${caption}`}
|
||||
onClick={onRemove}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
@@ -346,6 +649,18 @@ function PlacedNote({
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpaceGlyph({ width, height }: { width: number; height: number }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border border-dashed border-foreground/35 bg-muted/50"
|
||||
style={{ width, height }}
|
||||
role="img"
|
||||
aria-label="Space"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -356,9 +671,13 @@ function DropCaret() {
|
||||
function ScrollButtons({
|
||||
target,
|
||||
label,
|
||||
onLeft,
|
||||
onRight,
|
||||
}: {
|
||||
target: RefObject<HTMLDivElement | null>;
|
||||
target?: RefObject<HTMLDivElement | null>;
|
||||
label: string;
|
||||
onLeft?: () => void;
|
||||
onRight?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex shrink-0 gap-1">
|
||||
@@ -367,7 +686,7 @@ function ScrollButtons({
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={`Scroll ${label} left`}
|
||||
onClick={() => target.current?.scrollBy({ left: -180, behavior: "smooth" })}
|
||||
onClick={() => onLeft?.() ?? target?.current?.scrollBy({ left: -180, behavior: "smooth" })}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
@@ -376,7 +695,7 @@ function ScrollButtons({
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={`Scroll ${label} right`}
|
||||
onClick={() => target.current?.scrollBy({ left: 180, behavior: "smooth" })}
|
||||
onClick={() => onRight?.() ?? target?.current?.scrollBy({ left: 180, behavior: "smooth" })}
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
import { durationLabel } from "@/lib/duration";
|
||||
import {
|
||||
fingeringMarks,
|
||||
fingeringProse,
|
||||
type Fingering,
|
||||
} from "@/lib/fingering";
|
||||
import {
|
||||
CHART_VB_H,
|
||||
CHART_VB_W,
|
||||
chartOuterWidth,
|
||||
chartPixelHeight,
|
||||
CLEF_GUTTER,
|
||||
dotIndex,
|
||||
ledgerIndexes,
|
||||
noteValue,
|
||||
restValue,
|
||||
staffPlacement,
|
||||
staffY,
|
||||
STAFF_SPACE,
|
||||
stemDown,
|
||||
} from "@/lib/notation";
|
||||
|
||||
type HoleState = Fingering["holes"][number];
|
||||
|
||||
@@ -10,8 +26,8 @@ const TUBE = "#9deaf3";
|
||||
const INK = "#1a1a1a";
|
||||
|
||||
/**
|
||||
* Foot at the top, mouthpiece at the bottom.
|
||||
* The drawing is the reverse of a held recorder. Hole states are not changed.
|
||||
* Foot at the top. The tube ends at the thumb hole.
|
||||
* Hole states are not changed.
|
||||
*/
|
||||
function Hole({
|
||||
cx,
|
||||
@@ -43,13 +59,18 @@ export function FingeringDiagram({
|
||||
fingering,
|
||||
pitch,
|
||||
size = "lead",
|
||||
width: widthOverride,
|
||||
quarters = 1,
|
||||
}: {
|
||||
fingering: Fingering | null;
|
||||
pitch: string;
|
||||
size?: "lead" | "sm";
|
||||
width?: number;
|
||||
quarters?: number;
|
||||
}) {
|
||||
const width = size === "lead" ? 44 : 40;
|
||||
const height = Math.round(width * (188 / 48));
|
||||
const bodyWidth = widthOverride ?? (size === "lead" ? 44 : 40);
|
||||
const width = chartOuterWidth(bodyWidth);
|
||||
const height = chartPixelHeight(bodyWidth);
|
||||
if (!fingering) {
|
||||
return (
|
||||
<div
|
||||
@@ -64,19 +85,21 @@ export function FingeringDiagram({
|
||||
}
|
||||
|
||||
const [h1, h2, h3, h4, h5, h6a, h6b, h7a, h7b] = fingering.holes;
|
||||
const label = `${pitch}. Foot at the top, mouthpiece at the bottom. ${fingeringProse(fingering)}. ${fingeringMarks(fingering).join(", ")}`;
|
||||
const value = noteValue(quarters);
|
||||
const label = `${pitch}, written one octave lower, ${durationLabel(quarters)} on the treble staff. Foot at the top. The tube ends at the thumb hole. ${fingeringProse(fingering)}. ${fingeringMarks(fingering).join(", ")}`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 48 188"
|
||||
viewBox={`0 0 ${CHART_VB_W} ${CHART_VB_H}`}
|
||||
width={width}
|
||||
height={height}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
className="shrink-0"
|
||||
>
|
||||
<g transform={`translate(${CLEF_GUTTER} 0)`}>
|
||||
<path
|
||||
d="M9 3 H33 V148 H44 V184 H9 Z"
|
||||
d="M9 3 H33 V150 H9 Z"
|
||||
fill={TUBE}
|
||||
stroke={INK}
|
||||
strokeWidth="1.4"
|
||||
@@ -95,56 +118,222 @@ export function FingeringDiagram({
|
||||
<Hole cx={21} cy={118} r={4.7} state={h1} />
|
||||
<line x1="9" y1="130" x2="33" y2="130" stroke={INK} strokeWidth="1" />
|
||||
<Hole cx={21} cy={140} r={4.5} state={fingering.thumb} />
|
||||
<line x1="9" y1="156" x2="44" y2="156" stroke={INK} strokeWidth="1" />
|
||||
<line x1="9" y1="166" x2="44" y2="166" stroke={INK} strokeWidth="1" />
|
||||
<line x1="9" y1="176" x2="44" y2="176" stroke={INK} strokeWidth="1" />
|
||||
<ellipse cx="26" cy="166" rx="3.2" ry="4.4" fill={TUBE} stroke={INK} strokeWidth="1.2" transform="rotate(-24 26 166)" />
|
||||
</g>
|
||||
<Staff pitch={pitch} />
|
||||
<g transform={`translate(${CLEF_GUTTER} 0)`}>
|
||||
<NoteOnStaff pitch={pitch} head={value.head} stem={value.stem} flags={value.flags} dots={value.dots} />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BeatArrow() {
|
||||
export function RestMark({ quarters = 1, width: bodyWidth = 44 }: { quarters?: number; width?: number }) {
|
||||
const width = chartOuterWidth(bodyWidth);
|
||||
const height = chartPixelHeight(bodyWidth);
|
||||
const value = restValue(quarters);
|
||||
return (
|
||||
<svg viewBox="0 0 28 22" width={26} height={20} aria-hidden className="shrink-0">
|
||||
<path d="M1 7h12V2l14 9-14 9v-5H1z" fill={INK} />
|
||||
<svg
|
||||
viewBox={`0 0 ${CHART_VB_W} ${CHART_VB_H}`}
|
||||
width={width}
|
||||
height={height}
|
||||
className="shrink-0"
|
||||
role="img"
|
||||
aria-label={`${durationLabel(quarters)} rest on the treble staff`}
|
||||
data-rest={value.rest}
|
||||
data-dots={value.dots}
|
||||
>
|
||||
<Staff />
|
||||
<g transform={`translate(${CLEF_GUTTER} 0)`}>
|
||||
<RestOnStaff rest={value.rest} dots={value.dots} />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function RestMark({ label, width = 44 }: { label: string; width?: number }) {
|
||||
const height = Math.round(width * (188 / 48));
|
||||
const kind = label.includes("whole")
|
||||
? "whole"
|
||||
: label.includes("half")
|
||||
? "half"
|
||||
: label.includes("sixteenth")
|
||||
? "sixteenth"
|
||||
: label.includes("eighth")
|
||||
? "eighth"
|
||||
: "quarter";
|
||||
const NOTE_X = 31;
|
||||
|
||||
function Staff({ pitch }: { pitch?: string }) {
|
||||
return (
|
||||
<g data-staff={pitch ?? "rest"} aria-hidden>
|
||||
{[0, 2, 4, 6, 8].map((index) => (
|
||||
<line
|
||||
key={index}
|
||||
x1={1}
|
||||
x2={CHART_VB_W - 1.5}
|
||||
y1={staffY(index)}
|
||||
y2={staffY(index)}
|
||||
stroke={INK}
|
||||
strokeWidth={0.7}
|
||||
/>
|
||||
))}
|
||||
<TrebleClef />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/** Filled treble clef, drawn larger than the staff. The spiral is centered on the G line. */
|
||||
function TrebleClef() {
|
||||
const y = staffY(2);
|
||||
return (
|
||||
<g transform={`translate(0.7 ${y})`} data-clef="treble">
|
||||
<path d="M 9.53 -3.48 C 8.82 -3.28 8.18 -2.84 7.58 -2.16 C 6.99 -1.47 6.70 -0.71 6.70 0.11 C 6.70 0.62 6.87 1.20 7.21 1.82 C 7.55 2.45 8.06 2.91 8.73 3.20 C 8.95 3.25 9.06 3.37 9.06 3.53 C 9.06 3.59 8.97 3.65 8.78 3.69 C 7.71 3.42 6.84 2.85 6.16 2.00 C 5.48 1.13 5.13 0.15 5.11 -0.97 C 5.14 -2.17 5.50 -3.30 6.19 -4.32 C 6.89 -5.36 7.79 -6.10 8.89 -6.53 L 8.09 -10.68 C 6.29 -9.18 4.83 -7.63 3.69 -5.99 C 2.56 -4.37 1.97 -2.61 1.93 -0.70 C 1.95 0.15 2.13 0.98 2.45 1.78 C 2.78 2.58 3.27 3.31 3.93 3.97 C 5.25 5.29 6.96 5.98 9.06 6.05 C 9.77 6.01 10.53 5.88 11.35 5.67 L 9.53 -3.48 z M 10.38 -3.60 L 12.20 5.39 C 14.00 4.66 14.90 3.08 14.90 0.68 C 14.79 -0.13 14.56 -0.85 14.16 -1.50 C 13.78 -2.15 13.26 -2.66 12.61 -3.04 C 11.95 -3.41 11.22 -3.60 10.38 -3.60 z M 7.98 -15.75 C 8.37 -15.99 8.81 -16.39 9.29 -16.97 C 9.77 -17.53 10.24 -18.19 10.68 -18.94 C 11.14 -19.70 11.50 -20.47 11.77 -21.26 C 12.04 -22.03 12.16 -22.76 12.16 -23.44 C 12.16 -23.73 12.14 -24.02 12.08 -24.28 C 12.04 -24.70 11.91 -25.03 11.69 -25.25 C 11.46 -25.46 11.18 -25.58 10.83 -25.58 C 10.13 -25.58 9.50 -25.15 8.94 -24.28 C 8.51 -23.53 8.14 -22.65 7.89 -21.64 C 7.62 -20.62 7.47 -19.62 7.44 -18.60 C 7.50 -17.45 7.69 -16.50 7.98 -15.75 z M 7.24 -15.07 C 6.72 -16.97 6.43 -18.89 6.37 -20.86 C 6.38 -22.12 6.51 -23.30 6.75 -24.40 C 6.99 -25.50 7.33 -26.44 7.77 -27.26 C 8.20 -28.08 8.71 -28.70 9.27 -29.12 C 9.77 -29.49 10.13 -29.69 10.33 -29.69 C 10.48 -29.69 10.61 -29.63 10.73 -29.53 C 10.84 -29.42 11.00 -29.25 11.18 -29.01 C 12.57 -27.04 13.27 -24.66 13.27 -21.87 C 13.27 -20.55 13.10 -19.27 12.75 -17.98 C 12.41 -16.71 11.91 -15.49 11.24 -14.36 C 10.56 -13.22 9.77 -12.22 8.85 -11.37 L 9.79 -6.77 C 10.31 -6.82 10.66 -6.87 10.86 -6.87 C 11.74 -6.87 12.54 -6.68 13.27 -6.31 C 14.01 -5.94 14.64 -5.43 15.16 -4.79 C 15.67 -4.16 16.07 -3.44 16.35 -2.62 C 16.62 -1.80 16.77 -0.95 16.77 -0.06 C 16.77 1.32 16.41 2.58 15.68 3.72 C 14.96 4.85 13.87 5.68 12.41 6.22 C 12.50 6.79 12.67 7.62 12.91 8.68 C 13.15 9.76 13.32 10.61 13.44 11.24 C 13.55 11.87 13.60 12.48 13.60 13.08 C 13.60 14.00 13.38 14.82 12.94 15.54 C 12.48 16.27 11.87 16.83 11.10 17.22 C 10.34 17.62 9.50 17.82 8.59 17.82 C 7.30 17.82 6.18 17.46 5.22 16.74 C 4.27 16.02 3.75 15.05 3.70 13.81 C 3.74 13.26 3.87 12.75 4.10 12.26 C 4.34 11.77 4.65 11.37 5.06 11.07 C 5.46 10.75 5.94 10.59 6.49 10.55 C 6.94 10.55 7.37 10.68 7.78 10.93 C 8.18 11.18 8.51 11.52 8.75 11.95 C 8.99 12.39 9.11 12.87 9.11 13.38 C 9.11 14.07 8.88 14.65 8.41 15.13 C 7.95 15.61 7.35 15.86 6.64 15.86 L 6.37 15.86 C 6.82 16.56 7.57 16.92 8.61 16.92 C 9.14 16.92 9.68 16.80 10.21 16.59 C 10.76 16.37 11.22 16.08 11.60 15.70 C 11.99 15.33 12.25 14.93 12.35 14.51 C 12.55 14.03 12.64 13.37 12.64 12.54 C 12.64 11.98 12.58 11.42 12.48 10.86 C 12.37 10.31 12.21 9.57 11.99 8.66 C 11.77 7.76 11.60 7.06 11.51 6.58 C 10.81 6.75 10.08 6.85 9.32 6.85 C 8.05 6.85 6.85 6.59 5.71 6.06 C 4.58 5.54 3.59 4.81 2.72 3.88 C 1.87 2.94 1.20 1.89 0.72 0.70 C 0.26 -0.48 0.01 -1.72 0.00 -3.00 C 0.05 -4.19 0.27 -5.34 0.69 -6.42 C 1.11 -7.50 1.65 -8.53 2.31 -9.49 C 2.98 -10.45 3.67 -11.32 4.38 -12.11 C 5.11 -12.88 6.05 -13.87 7.24 -15.07 z" fill={INK} fillRule="evenodd" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteOnStaff({
|
||||
pitch,
|
||||
head,
|
||||
stem,
|
||||
flags,
|
||||
dots,
|
||||
}: {
|
||||
pitch: string;
|
||||
head: "open" | "filled";
|
||||
stem: boolean;
|
||||
flags: 0 | 1 | 2;
|
||||
dots: 0 | 1;
|
||||
}) {
|
||||
const placed = staffPlacement(pitch);
|
||||
if (!placed) return null;
|
||||
const y = staffY(placed.index);
|
||||
const down = stemDown(placed.index);
|
||||
const stemX = NOTE_X + (down ? -1.55 : 1.55);
|
||||
const stemEnd = y + (down ? 1 : -1) * STAFF_SPACE * 3.5;
|
||||
return (
|
||||
<g data-pitch={pitch} data-head={head} data-flags={flags} data-dots={dots} data-stem={stem ? (down ? "down" : "up") : "none"}>
|
||||
{ledgerIndexes(placed.index).map((index) => (
|
||||
<line
|
||||
key={index}
|
||||
x1={NOTE_X - 5.2}
|
||||
x2={NOTE_X + 5.2}
|
||||
y1={staffY(index)}
|
||||
y2={staffY(index)}
|
||||
stroke={INK}
|
||||
strokeWidth={0.7}
|
||||
/>
|
||||
))}
|
||||
{placed.accidental === "sharp" ? <Sharp x={NOTE_X - 7.2} y={y} /> : null}
|
||||
{placed.accidental === "flat" ? <Flat x={NOTE_X - 7.4} y={y} /> : null}
|
||||
{stem ? (
|
||||
<line x1={stemX} y1={y} x2={stemX} y2={stemEnd} stroke={INK} strokeWidth={0.7} strokeLinecap="butt" />
|
||||
) : null}
|
||||
<ellipse
|
||||
cx={NOTE_X}
|
||||
cy={y}
|
||||
rx={3.15}
|
||||
ry={2.05}
|
||||
transform={`rotate(-18 ${NOTE_X} ${y})`}
|
||||
fill={head === "filled" ? INK : "var(--card)"}
|
||||
stroke={INK}
|
||||
strokeWidth={0.75}
|
||||
/>
|
||||
{stem && flags > 0 ? <Flag x={stemX} y={stemEnd} down={down} /> : null}
|
||||
{stem && flags > 1 ? <Flag x={stemX} y={stemEnd + (down ? -STAFF_SPACE * 0.85 : STAFF_SPACE * 0.85)} down={down} /> : null}
|
||||
{dots > 0 ? <Dot x={NOTE_X + 5.4} index={dotIndex(placed.index)} /> : null}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function RestOnStaff({ rest, dots }: { rest: "whole" | "half" | "quarter" | "eighth" | "sixteenth"; dots: 0 | 1 }) {
|
||||
const x = NOTE_X;
|
||||
return (
|
||||
<g data-rest={rest} data-dots={dots}>
|
||||
{rest === "whole" ? (
|
||||
<rect x={x - 2.4} y={staffY(6)} width={4.8} height={STAFF_SPACE * 0.48} fill={INK} />
|
||||
) : null}
|
||||
{rest === "half" ? (
|
||||
<rect x={x - 2.4} y={staffY(4) - STAFF_SPACE * 0.48} width={4.8} height={STAFF_SPACE * 0.48} fill={INK} />
|
||||
) : null}
|
||||
{rest === "quarter" ? <QuarterRest x={x} /> : null}
|
||||
{rest === "eighth" || rest === "sixteenth" ? <EighthRest x={x} flags={rest === "sixteenth" ? 2 : 1} /> : null}
|
||||
{dots > 0 ? <Dot x={x + 6.2} index={5} /> : null}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function Dot({ x, index }: { x: number; index: number }) {
|
||||
return <circle cx={x} cy={staffY(index)} r={0.72} fill={INK} />;
|
||||
}
|
||||
|
||||
function Sharp({ x, y }: { x: number; y: number }) {
|
||||
return (
|
||||
<g stroke={INK} strokeLinecap="square">
|
||||
<line x1={x - 1.15} y1={y - 3.3} x2={x - 1.15} y2={y + 3.1} strokeWidth={0.55} />
|
||||
<line x1={x + 1.15} y1={y - 3.7} x2={x + 1.15} y2={y + 2.7} strokeWidth={0.55} />
|
||||
<line x1={x - 2.5} y1={y - 0.55} x2={x + 2.5} y2={y - 1.45} strokeWidth={0.95} />
|
||||
<line x1={x - 2.5} y1={y + 1.55} x2={x + 2.5} y2={y + 0.65} strokeWidth={0.95} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function Flat({ x, y }: { x: number; y: number }) {
|
||||
return (
|
||||
<svg viewBox="0 0 48 188" width={width} height={height} className="shrink-0" role="img" aria-label={`${label} rest`}>
|
||||
<line x1="8" y1="94" x2="40" y2="94" stroke="#8a8174" strokeWidth="1.5" />
|
||||
{kind === "whole" ? <rect x="14" y="78" width="20" height="12" rx="1" fill={INK} /> : null}
|
||||
{kind === "half" ? <rect x="14" y="94" width="20" height="12" rx="1" fill={INK} /> : null}
|
||||
{kind === "quarter" ? (
|
||||
<path
|
||||
d="M26 62c7 7 7 12 0 18s-7 10 2 17 8 8 3 15"
|
||||
d={`M ${x} ${y - 4.8} V ${y + 1.7}
|
||||
C ${x + 2.8} ${y + 1.5} ${x + 3.1} ${y - 1.5} ${x} ${y - 1.15} Z`}
|
||||
fill={INK}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Flag({ x, y, down }: { x: number; y: number; down: boolean }) {
|
||||
const dir = down ? -1 : 1;
|
||||
return (
|
||||
<path
|
||||
d={`M ${x} ${y}
|
||||
c ${3.2} ${dir * 0.2} ${5.4} ${dir * 2.4} ${4.2} ${dir * 5.2}
|
||||
c ${-0.6} ${dir * 1.4} ${-2.2} ${dir * 1.8} ${-3.4} ${dir * 0.6}
|
||||
c ${1.2} ${dir * -1.6} ${1.4} ${dir * -3.2} ${-0.8} ${dir * -3.6}
|
||||
z`}
|
||||
fill={INK}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function QuarterRest({ x }: { x: number }) {
|
||||
const y = staffY(7);
|
||||
return (
|
||||
<path
|
||||
d={`M ${x + 1.6} ${y}
|
||||
c ${-2.4} ${1.6} ${-1.2} ${3.2} ${0.8} ${3.6}
|
||||
c ${2.2} ${0.4} ${1.2} ${2.2} ${-0.8} ${3}
|
||||
c ${-1.8} ${0.8} ${-0.4} ${2.2} ${1.2} ${2.6}
|
||||
c ${1.4} ${0.4} ${1.2} ${1.6} ${-0.2} ${2.4}
|
||||
c ${-1.6} ${0.8} ${-3.2} ${0.2} ${-2.2} ${-1.2}`}
|
||||
fill="none"
|
||||
stroke={INK}
|
||||
strokeWidth="3.2"
|
||||
strokeWidth={1.15}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
) : null}
|
||||
{kind === "eighth" || kind === "sixteenth" ? (
|
||||
);
|
||||
}
|
||||
|
||||
function EighthRest({ x, flags }: { x: number; flags: 1 | 2 }) {
|
||||
const y = staffY(6);
|
||||
return (
|
||||
<g fill={INK}>
|
||||
<circle cx="27" cy="112" r="5" />
|
||||
<path d="M27 107c8-12 13-16 6-30" fill="none" stroke={INK} strokeWidth="2.6" />
|
||||
{kind === "sixteenth" ? (
|
||||
<path d="M23 100c8-8 11-12 5-22" fill="none" stroke={INK} strokeWidth="2.6" />
|
||||
<path
|
||||
d={`M ${x + 1.4} ${y}
|
||||
c ${-0.4} ${2.2} ${0.2} ${3.4} ${1.2} ${4.2}
|
||||
L ${x - 2.2} ${y + 9.2}
|
||||
c ${0.8} ${0.2} ${1.6} ${-0.6} ${1} ${-1.4}
|
||||
L ${x + 1.6} ${y + 4.4}
|
||||
c ${-0.6} ${-0.8} ${-1} ${-1.8} ${-0.6} ${-3.2}
|
||||
z`}
|
||||
/>
|
||||
<circle cx={x - 1.5} cy={y + 10.2} r={0.85} />
|
||||
{flags > 1 ? (
|
||||
<path
|
||||
d={`M ${x + 0.4} ${y + 2.6}
|
||||
c ${-0.2} ${1.6} ${0.4} ${2.4} ${1} ${3}
|
||||
L ${x - 2.4} ${y + 11.4}
|
||||
c ${0.7} ${0.2} ${1.4} ${-0.5} ${0.8} ${-1.2}
|
||||
L ${x + 0.6} ${y + 5.6}
|
||||
z`}
|
||||
/>
|
||||
) : null}
|
||||
</g>
|
||||
) : null}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { pngItemFromNote, pngStackLayout, type PngItem } from "@/lib/png-layout";
|
||||
|
||||
const INK = "#1a1a1a";
|
||||
const PAPER = "#fbf6ee";
|
||||
const SPACE_STROKE = "#c4b8a8";
|
||||
|
||||
function loadSvg(svg: SVGElement): Promise<HTMLImageElement> {
|
||||
const clone = svg.cloneNode(true) as SVGElement;
|
||||
if (!clone.getAttribute("xmlns")) clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
||||
const xml = new XMLSerializer().serializeToString(clone);
|
||||
const url = URL.createObjectURL(new Blob([xml], { type: "image/svg+xml;charset=utf-8" }));
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error("Could not draw a chart."));
|
||||
};
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
async function readRow(line: HTMLElement): Promise<{ items: PngItem[]; images: Array<HTMLImageElement | null> } | null> {
|
||||
const slots = [...line.querySelectorAll<HTMLElement>(":scope > [data-export-slot]")];
|
||||
const items: PngItem[] = [];
|
||||
const images: Array<HTMLImageElement | null> = [];
|
||||
for (const slot of slots) {
|
||||
const kind = slot.dataset.kind;
|
||||
if (kind !== "note" && kind !== "rest" && kind !== "space") continue;
|
||||
items.push(
|
||||
pngItemFromNote({
|
||||
kind,
|
||||
pitch: slot.dataset.pitch ?? "",
|
||||
quarters: Number(slot.dataset.quarters),
|
||||
label: "",
|
||||
}),
|
||||
);
|
||||
const svg = slot.querySelector("svg");
|
||||
images.push(svg ? await loadSvg(svg) : null);
|
||||
}
|
||||
if (items.length === 0) return null;
|
||||
return { items, images };
|
||||
}
|
||||
|
||||
export async function renderLinePng(exportRoot: HTMLElement, title: string): Promise<Blob> {
|
||||
const lineNodes = [...exportRoot.querySelectorAll<HTMLElement>("[data-export-line]")];
|
||||
const sources = lineNodes.length > 0 ? lineNodes : [exportRoot];
|
||||
const rows: Array<{ items: PngItem[]; images: Array<HTMLImageElement | null> }> = [];
|
||||
for (const line of sources) {
|
||||
const row = await readRow(line);
|
||||
if (row) rows.push(row);
|
||||
}
|
||||
if (rows.length === 0) throw new Error("Add a note, rest, or space before saving a PNG.");
|
||||
|
||||
const layout = pngStackLayout(
|
||||
rows.map((row) => row.items),
|
||||
title,
|
||||
);
|
||||
const scale = 2;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.ceil(layout.width * scale);
|
||||
canvas.height = Math.ceil(layout.height * scale);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Could not start a canvas.");
|
||||
ctx.scale(scale, scale);
|
||||
ctx.fillStyle = PAPER;
|
||||
ctx.fillRect(0, 0, layout.width, layout.height);
|
||||
ctx.fillStyle = "#6a4328";
|
||||
ctx.font = "600 12px Georgia, serif";
|
||||
ctx.fillText("Soprano in C · Baroque · 4/4", layout.pad, layout.pad + 14);
|
||||
ctx.fillStyle = INK;
|
||||
ctx.font = "600 22px Georgia, serif";
|
||||
ctx.fillText(title, layout.pad, layout.pad + 40);
|
||||
|
||||
let glyphTop = layout.pad + 56;
|
||||
rows.forEach((row, rowIndex) => {
|
||||
const laid = layout.rows[rowIndex];
|
||||
let x = layout.pad;
|
||||
row.items.forEach((item, index) => {
|
||||
const slotWidth = laid?.slots[index]?.width ?? item.glyphWidth;
|
||||
if (item.kind === "space") {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = SPACE_STROKE;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.strokeRect(x + 6, glyphTop + 8, Math.max(8, item.glyphWidth - 12), laid.glyphHeight - 16);
|
||||
ctx.restore();
|
||||
} else if (row.images[index]) {
|
||||
ctx.drawImage(row.images[index], x, glyphTop, item.glyphWidth, item.glyphHeight);
|
||||
}
|
||||
const center = x + item.glyphWidth / 2;
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillStyle = INK;
|
||||
ctx.font = "600 13px sans-serif";
|
||||
ctx.fillText(item.caption, center, glyphTop + laid.glyphHeight + 18);
|
||||
ctx.textAlign = "start";
|
||||
x += slotWidth + laid.gap;
|
||||
});
|
||||
glyphTop += laid.glyphHeight + 28 + layout.rowGap;
|
||||
});
|
||||
|
||||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
|
||||
if (!blob) throw new Error("Could not encode the PNG.");
|
||||
return blob;
|
||||
}
|
||||
|
||||
export function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1500);
|
||||
}
|
||||
+87
-1
@@ -1,17 +1,48 @@
|
||||
import { DURATION_PRESETS, type DurationPreset } from "@/lib/duration";
|
||||
import { newId } from "@/lib/score";
|
||||
|
||||
export type LineKind = "note" | "rest" | "space";
|
||||
|
||||
export type LineNote = {
|
||||
id: string;
|
||||
kind: LineKind;
|
||||
pitch: string;
|
||||
quarters: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const LINE_BOX = {
|
||||
minWidth: 280,
|
||||
minHeight: 200,
|
||||
maxHeight: 880,
|
||||
};
|
||||
|
||||
const QUARTER = DURATION_PRESETS.find((preset) => preset.quarters === 1) ?? DURATION_PRESETS[3];
|
||||
|
||||
export function createNote(pitch: string, preset: DurationPreset = QUARTER, id = newId("n")): LineNote {
|
||||
return { id, pitch, quarters: preset.quarters, label: preset.label };
|
||||
return { id, kind: "note", pitch, quarters: preset.quarters, label: preset.label };
|
||||
}
|
||||
|
||||
export function createRest(preset: DurationPreset = QUARTER, id = newId("r")): LineNote {
|
||||
return { id, kind: "rest", pitch: "", quarters: preset.quarters, label: preset.label };
|
||||
}
|
||||
|
||||
export function createSpace(preset: DurationPreset = QUARTER, id = newId("s")): LineNote {
|
||||
return { id, kind: "space", pitch: "", quarters: preset.quarters, label: preset.label };
|
||||
}
|
||||
|
||||
/** Keep a resized line box on the page. `maxWidth` is the room the page actually has. */
|
||||
export function clampLineBox(
|
||||
width: number,
|
||||
height: number,
|
||||
maxWidth: number,
|
||||
): { width: number; height: number } {
|
||||
const limit = Math.max(160, maxWidth);
|
||||
const minWidth = Math.min(LINE_BOX.minWidth, limit);
|
||||
return {
|
||||
width: Math.round(Math.min(limit, Math.max(minWidth, width))),
|
||||
height: Math.round(Math.min(LINE_BOX.maxHeight, Math.max(LINE_BOX.minHeight, height))),
|
||||
};
|
||||
}
|
||||
|
||||
export function insertNote(notes: LineNote[], index: number, note: LineNote): LineNote[] {
|
||||
@@ -36,6 +67,15 @@ export function removeNote(notes: LineNote[], id: string): LineNote[] {
|
||||
return notes.filter((note) => note.id !== id);
|
||||
}
|
||||
|
||||
const DURATION_ORDER = [...DURATION_PRESETS].sort((a, b) => a.quarters - b.quarters);
|
||||
|
||||
/** The next longer or shorter preset. Null at either end of the list. */
|
||||
export function neighborDuration(quarters: number, direction: -1 | 1): DurationPreset | null {
|
||||
const index = DURATION_ORDER.findIndex((preset) => Math.abs(preset.quarters - quarters) < 0.001);
|
||||
if (index < 0) return null;
|
||||
return DURATION_ORDER[index + direction] ?? null;
|
||||
}
|
||||
|
||||
export function setDuration(notes: LineNote[], id: string, preset: DurationPreset): LineNote[] {
|
||||
return notes.map((note) =>
|
||||
note.id === id ? { ...note, quarters: preset.quarters, label: preset.label } : note,
|
||||
@@ -46,6 +86,52 @@ export function cloneLine(notes: LineNote[]): LineNote[] {
|
||||
return notes.map((note) => ({ ...note, id: newId("n") }));
|
||||
}
|
||||
|
||||
export type ScoreLine = {
|
||||
id: string;
|
||||
notes: LineNote[];
|
||||
};
|
||||
|
||||
export function appendLine(lines: ScoreLine[], id = newId("line")): ScoreLine[] {
|
||||
return [...lines, { id, notes: [] }];
|
||||
}
|
||||
|
||||
/** Leave the last line in place. An empty score still has one row. */
|
||||
export function removeLine(lines: ScoreLine[], id: string): ScoreLine[] {
|
||||
if (lines.length <= 1) return lines;
|
||||
const next = lines.filter((line) => line.id !== id);
|
||||
return next.length > 0 ? next : lines;
|
||||
}
|
||||
|
||||
function mapLine(lines: ScoreLine[], id: string, update: (notes: LineNote[]) => LineNote[]): ScoreLine[] {
|
||||
return lines.map((line) => (line.id === id ? { ...line, notes: update(line.notes) } : line));
|
||||
}
|
||||
|
||||
export function placeOnLine(lines: ScoreLine[], lineId: string, index: number, note: LineNote): ScoreLine[] {
|
||||
return mapLine(lines, lineId, (notes) => insertNote(notes, index, note));
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a note onto a line. When it stays on the same line, `toIndex` counts the moving note.
|
||||
* When it changes lines, `toIndex` is the slot in the destination.
|
||||
*/
|
||||
export function transferNote(
|
||||
lines: ScoreLine[],
|
||||
fromLineId: string,
|
||||
noteId: string,
|
||||
toLineId: string,
|
||||
toIndex: number,
|
||||
): ScoreLine[] {
|
||||
const source = lines.find((line) => line.id === fromLineId);
|
||||
const note = source?.notes.find((item) => item.id === noteId);
|
||||
if (!source || !note) return lines;
|
||||
if (fromLineId === toLineId) return mapLine(lines, fromLineId, (notes) => moveNote(notes, noteId, toIndex));
|
||||
return lines.map((line) => {
|
||||
if (line.id === fromLineId) return { ...line, notes: removeNote(line.notes, noteId) };
|
||||
if (line.id === toLineId) return { ...line, notes: insertNote(line.notes, toIndex, note) };
|
||||
return line;
|
||||
});
|
||||
}
|
||||
|
||||
/** -1 when the pointer is outside the line. Otherwise the insertion slot from left to right. */
|
||||
export function dropIndexFromPoint(
|
||||
pointerX: number,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { quartersMatch } from "@/lib/duration";
|
||||
|
||||
/** The tube ends just below the thumb hole. The staff is added underneath. */
|
||||
const CHART_BODY_W = 48;
|
||||
/** Extra width on the left so the treble clef can sit on the G line. */
|
||||
export const CLEF_GUTTER = 6;
|
||||
export const CHART_VB_W = CHART_BODY_W + CLEF_GUTTER;
|
||||
const RECORDER_VB_H = 152;
|
||||
|
||||
/** Distance between two staff lines. One diatonic step is half of this. */
|
||||
export const STAFF_SPACE = 4.6;
|
||||
const STEP = STAFF_SPACE / 2;
|
||||
|
||||
/** Highest written note is sounding D7, drawn as D6. */
|
||||
const TOP_INDEX = 13;
|
||||
/** Lowest written note is sounding C5, drawn as C4 on the ledger line below. */
|
||||
const LOW_INDEX = -2;
|
||||
const PAD_TOP = 6;
|
||||
const PAD_BOTTOM = 14;
|
||||
|
||||
const ABOVE = TOP_INDEX * STEP;
|
||||
const BELOW = -LOW_INDEX * STEP + PAD_BOTTOM;
|
||||
|
||||
export const BOTTOM_LINE_Y = RECORDER_VB_H + 3 + PAD_TOP + ABOVE;
|
||||
export const CHART_VB_H = Math.ceil(BOTTOM_LINE_Y + BELOW);
|
||||
|
||||
/** Pixel width of a chart once the clef gutter is included. Holes keep their old size. */
|
||||
export function chartOuterWidth(bodyWidth: number): number {
|
||||
return Math.ceil((bodyWidth * CHART_VB_W) / CHART_BODY_W);
|
||||
}
|
||||
|
||||
export function chartPixelHeight(bodyWidth: number): number {
|
||||
return Math.round((chartOuterWidth(bodyWidth) * CHART_VB_H) / CHART_VB_W);
|
||||
}
|
||||
|
||||
export function staffY(index: number): number {
|
||||
return BOTTOM_LINE_Y - index * STEP;
|
||||
}
|
||||
|
||||
const LETTERS = ["C", "D", "E", "F", "G", "A", "B"] as const;
|
||||
|
||||
export type Accidental = "sharp" | "flat";
|
||||
|
||||
/**
|
||||
* Treble staff, written one octave below sounding pitch.
|
||||
* Index 0 is the bottom line, E4. Sounding C5 is written C4, index -2.
|
||||
*/
|
||||
export function staffPlacement(pitch: string): { index: number; accidental: Accidental | null } | null {
|
||||
const match = /^([A-Ga-g])(#|b|♯|♭)?(-?\d+)$/.exec(pitch.trim());
|
||||
if (!match) return null;
|
||||
const letter = match[1].toUpperCase();
|
||||
const mark = match[2] ?? "";
|
||||
const accidental: Accidental | null =
|
||||
mark === "#" || mark === "♯" ? "sharp" : mark === "b" || mark === "♭" ? "flat" : null;
|
||||
const octave = Number(match[3]);
|
||||
if (!Number.isInteger(octave)) return null;
|
||||
const diatonic = (octave - 1 - 4) * 7 + LETTERS.indexOf(letter as (typeof LETTERS)[number]);
|
||||
return { index: diatonic - 2, accidental };
|
||||
}
|
||||
|
||||
/** Ledger lines a note needs outside the five-line staff. */
|
||||
export function ledgerIndexes(index: number): number[] {
|
||||
const lines: number[] = [];
|
||||
if (index > 8) {
|
||||
for (let line = 10; line <= index; line += 2) lines.push(line);
|
||||
}
|
||||
if (index < 0) {
|
||||
const start = index % 2 === 0 ? index : index - 1;
|
||||
for (let line = -2; line >= start; line -= 2) lines.push(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** On the middle line or higher, the stem goes down. */
|
||||
export function stemDown(index: number): boolean {
|
||||
return index >= 4;
|
||||
}
|
||||
|
||||
export type NoteValue = {
|
||||
head: "open" | "filled";
|
||||
stem: boolean;
|
||||
flags: 0 | 1 | 2;
|
||||
dots: 0 | 1;
|
||||
};
|
||||
|
||||
export type RestValue = {
|
||||
rest: "whole" | "half" | "quarter" | "eighth" | "sixteenth";
|
||||
dots: 0 | 1;
|
||||
};
|
||||
|
||||
/** One chart has its own staff, so a short note wears flags instead of a beam. */
|
||||
export function noteValue(quarters: number): NoteValue {
|
||||
if (quartersMatch(quarters, 4)) return { head: "open", stem: false, flags: 0, dots: 0 };
|
||||
if (quartersMatch(quarters, 3)) return { head: "open", stem: true, flags: 0, dots: 1 };
|
||||
if (quartersMatch(quarters, 2)) return { head: "open", stem: true, flags: 0, dots: 0 };
|
||||
if (quartersMatch(quarters, 1.5)) return { head: "filled", stem: true, flags: 0, dots: 1 };
|
||||
if (quartersMatch(quarters, 0.75)) return { head: "filled", stem: true, flags: 1, dots: 1 };
|
||||
if (quartersMatch(quarters, 0.5)) return { head: "filled", stem: true, flags: 1, dots: 0 };
|
||||
if (quartersMatch(quarters, 0.25)) return { head: "filled", stem: true, flags: 2, dots: 0 };
|
||||
return { head: "filled", stem: true, flags: 0, dots: 0 };
|
||||
}
|
||||
|
||||
export function restValue(quarters: number): RestValue {
|
||||
if (quartersMatch(quarters, 4)) return { rest: "whole", dots: 0 };
|
||||
if (quartersMatch(quarters, 3)) return { rest: "half", dots: 1 };
|
||||
if (quartersMatch(quarters, 2)) return { rest: "half", dots: 0 };
|
||||
if (quartersMatch(quarters, 1.5)) return { rest: "quarter", dots: 1 };
|
||||
if (quartersMatch(quarters, 0.75)) return { rest: "eighth", dots: 1 };
|
||||
if (quartersMatch(quarters, 0.5)) return { rest: "eighth", dots: 0 };
|
||||
if (quartersMatch(quarters, 0.25)) return { rest: "sixteenth", dots: 0 };
|
||||
return { rest: "quarter", dots: 0 };
|
||||
}
|
||||
|
||||
/** A dot sits in a space. A note on a line takes the space above. */
|
||||
export function dotIndex(index: number): number {
|
||||
return index % 2 === 0 ? index + 1 : index;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { LineNote } from "@/lib/line";
|
||||
import { chartOuterWidth, chartPixelHeight } from "@/lib/notation";
|
||||
|
||||
export const PNG_CHART_WIDTH = 88;
|
||||
const GAP = 18;
|
||||
const PAD = 28;
|
||||
|
||||
export { chartPixelHeight };
|
||||
|
||||
/** A quarter space is about one chart wide. Longer spaces take more room. */
|
||||
export function spacePixelWidth(quarters: number, chartWidth: number): number {
|
||||
return Math.max(Math.round(chartWidth * 0.45), Math.round(chartWidth * Math.max(quarters, 0)));
|
||||
}
|
||||
|
||||
export type PngItem = {
|
||||
kind: LineNote["kind"];
|
||||
caption: string;
|
||||
glyphWidth: number;
|
||||
glyphHeight: number;
|
||||
};
|
||||
|
||||
export function pngItemFromNote(note: Pick<LineNote, "kind" | "pitch" | "quarters" | "label">, chartWidth = PNG_CHART_WIDTH): PngItem {
|
||||
const drawn = chartOuterWidth(chartWidth);
|
||||
const glyphHeight = chartPixelHeight(chartWidth);
|
||||
const caption = note.kind === "note" ? note.pitch : note.kind === "rest" ? "Rest" : "Space";
|
||||
return {
|
||||
kind: note.kind,
|
||||
caption,
|
||||
glyphWidth: note.kind === "space" ? spacePixelWidth(note.quarters, drawn) : drawn,
|
||||
glyphHeight,
|
||||
};
|
||||
}
|
||||
|
||||
export function pngSlotWidth(item: PngItem): number {
|
||||
const text = item.caption.length * 8;
|
||||
return Math.max(item.glyphWidth, text, 48);
|
||||
}
|
||||
|
||||
export const PNG_ROW_GAP = 36;
|
||||
|
||||
/** One header, then every fingering line stacked underneath. */
|
||||
export function pngStackLayout(rows: PngItem[][], title: string) {
|
||||
const laid = rows.map((items) => pngLayout(items, title));
|
||||
const width = Math.max(1, ...laid.map((row) => row.width));
|
||||
const blocks = laid.reduce((sum, row) => sum + row.glyphHeight + 28, 0);
|
||||
const height = PAD + 44 + blocks + PNG_ROW_GAP * Math.max(0, laid.length - 1) + PAD;
|
||||
return { width, height, rows: laid, pad: PAD, rowGap: PNG_ROW_GAP };
|
||||
}
|
||||
|
||||
export function pngLayout(items: PngItem[], title: string) {
|
||||
const slots = items.map((item) => ({ item, width: pngSlotWidth(item) }));
|
||||
const content =
|
||||
slots.reduce((sum, slot) => sum + slot.width, 0) + GAP * Math.max(0, slots.length - 1);
|
||||
const titleWidth = Math.max(220, title.length * 13 + 8);
|
||||
const width = Math.max(titleWidth, content, 1) + PAD * 2;
|
||||
const glyphHeight = chartPixelHeight(PNG_CHART_WIDTH);
|
||||
const height = PAD + 44 + glyphHeight + 28 + PAD;
|
||||
return { width, height, slots, glyphHeight, pad: PAD, gap: GAP };
|
||||
}
|
||||
|
||||
export function pngFilename(title: string): string {
|
||||
const slug = title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
return `${slug || "line"}.png`;
|
||||
}
|
||||
@@ -2,16 +2,31 @@ import { describe, expect, it } from "vitest";
|
||||
import { fingeringCode, fingeringFor, fingeringMarks, CHART_NOTES } from "@/lib/fingering";
|
||||
import { DURATION_PRESETS } from "@/lib/duration";
|
||||
import {
|
||||
clampLineBox,
|
||||
cloneLine,
|
||||
createNote,
|
||||
createRest,
|
||||
createSpace,
|
||||
dropIndexFromPoint,
|
||||
insertNote,
|
||||
LINE_EXAMPLES,
|
||||
appendLine,
|
||||
moveNote,
|
||||
neighborDuration,
|
||||
removeLine,
|
||||
removeNote,
|
||||
setDuration,
|
||||
transferNote,
|
||||
} from "@/lib/line";
|
||||
import {
|
||||
ledgerIndexes,
|
||||
noteValue,
|
||||
restValue,
|
||||
staffPlacement,
|
||||
stemDown,
|
||||
} from "@/lib/notation";
|
||||
import { parsePitch } from "@/lib/pitch";
|
||||
import { pngFilename, pngItemFromNote, pngLayout, pngStackLayout } from "@/lib/png-layout";
|
||||
import { extraBeatArrows } from "@/lib/score";
|
||||
|
||||
const BAROQUE: Record<string, string> = {
|
||||
@@ -122,4 +137,96 @@ describe("fingering line", () => {
|
||||
expect(extraBeatArrows(LINE_EXAMPLES[0].notes[6].quarters, { beats: 4, beatType: 4 })).toBe(1);
|
||||
expect(extraBeatArrows(LINE_EXAMPLES[1].notes[0].quarters, { beats: 4, beatType: 4 })).toBe(0);
|
||||
});
|
||||
|
||||
it("inserts a rest and a space without changing the notes beside them", () => {
|
||||
const note = createNote("C5", quarter, "c");
|
||||
const rest = createRest(half, "r");
|
||||
const space = createSpace(quarter, "s");
|
||||
const line = insertNote(insertNote([note], 1, rest), 2, space);
|
||||
expect(line.map((item) => item.kind)).toEqual(["note", "rest", "space"]);
|
||||
expect(moveNote(line, "r", 0).map((item) => item.kind)).toEqual(["rest", "note", "space"]);
|
||||
expect(setDuration(line, "s", half)[2]).toMatchObject({ kind: "space", quarters: 2, label: "half" });
|
||||
expect(removeNote(line, "s").map((item) => item.kind)).toEqual(["note", "rest"]);
|
||||
});
|
||||
|
||||
it("keeps a resized line box inside the page", () => {
|
||||
expect(clampLineBox(10, 10, 800)).toEqual({ width: 280, height: 200 });
|
||||
expect(clampLineBox(900, 2000, 640)).toEqual({ width: 640, height: 880 });
|
||||
expect(clampLineBox(400, 360, 200)).toEqual({ width: 200, height: 360 });
|
||||
});
|
||||
|
||||
it("lays out a PNG with a rest, a space, and a filename", () => {
|
||||
const note = pngItemFromNote(createNote("G5", half, "g"));
|
||||
const rest = pngItemFromNote(createRest(quarter, "r"));
|
||||
const space = pngItemFromNote(createSpace(quarter, "s"));
|
||||
expect(note.caption).toBe("G5");
|
||||
expect(rest.kind).toBe("rest");
|
||||
expect(space.glyphWidth).toBeGreaterThan(40);
|
||||
expect(note.glyphHeight).toBeGreaterThan(note.glyphWidth);
|
||||
const layout = pngLayout([note, rest, space], "Your line");
|
||||
expect(layout.width).toBeGreaterThan(layout.slots[0].width + layout.slots[2].width);
|
||||
expect(layout.height).toBeGreaterThan(layout.glyphHeight);
|
||||
expect(pngFilename("Twinkle, opening")).toBe("twinkle-opening.png");
|
||||
expect(pngFilename(" ")).toBe("line.png");
|
||||
});
|
||||
|
||||
it("appends a line and can move a note onto it", () => {
|
||||
const top = { id: "a", notes: [createNote("C5", quarter, "c"), createNote("E5", quarter, "e")] };
|
||||
const next = appendLine([top], "b");
|
||||
expect(next.map((line) => line.id)).toEqual(["a", "b"]);
|
||||
expect(next[1].notes).toEqual([]);
|
||||
expect(next[0].notes.map((note) => note.pitch)).toEqual(["C5", "E5"]);
|
||||
const moved = transferNote(next, "a", "e", "b", 0);
|
||||
expect(moved[0].notes.map((note) => note.pitch)).toEqual(["C5"]);
|
||||
expect(moved[1].notes.map((note) => note.pitch)).toEqual(["E5"]);
|
||||
expect(removeLine(moved, "b").map((line) => line.id)).toEqual(["a"]);
|
||||
expect(removeLine([{ id: "a", notes: [] }], "a")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("stacks every fingering line in the PNG", () => {
|
||||
const top = [pngItemFromNote(createNote("C5", quarter, "c"))];
|
||||
const bottom = [pngItemFromNote(createNote("G5", quarter, "g"))];
|
||||
const one = pngStackLayout([top], "Your line");
|
||||
const two = pngStackLayout([top, bottom], "Your line");
|
||||
expect(two.rows).toHaveLength(2);
|
||||
expect(two.height).toBeGreaterThan(one.height);
|
||||
expect(pngLayout(top, "Your line").glyphHeight).toBe(one.rows[0].glyphHeight);
|
||||
});
|
||||
|
||||
it("steps duration without skipping a preset", () => {
|
||||
expect(neighborDuration(1, 1)?.quarters).toBe(1.5);
|
||||
expect(neighborDuration(1, -1)?.quarters).toBe(0.75);
|
||||
expect(neighborDuration(0.25, -1)).toBeNull();
|
||||
expect(neighborDuration(4, 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("treble staff", () => {
|
||||
it("writes each sounding pitch one octave lower", () => {
|
||||
expect(staffPlacement("C5")).toEqual({ index: -2, accidental: null });
|
||||
expect(staffPlacement("C#5")).toEqual({ index: -2, accidental: "sharp" });
|
||||
expect(staffPlacement("Eb5")).toEqual({ index: 0, accidental: "flat" });
|
||||
expect(staffPlacement("C6")).toEqual({ index: 5, accidental: null });
|
||||
expect(staffPlacement("C7")).toEqual({ index: 12, accidental: null });
|
||||
expect(staffPlacement("D7")).toEqual({ index: 13, accidental: null });
|
||||
expect(ledgerIndexes(-2)).toEqual([-2]);
|
||||
expect(ledgerIndexes(5)).toEqual([]);
|
||||
expect(ledgerIndexes(12)).toEqual([10, 12]);
|
||||
expect(ledgerIndexes(13)).toEqual([10, 12]);
|
||||
expect(stemDown(-2)).toBe(false);
|
||||
expect(stemDown(5)).toBe(true);
|
||||
});
|
||||
|
||||
it("writes length as noteheads, stems, flags, and dots", () => {
|
||||
expect(noteValue(4)).toEqual({ head: "open", stem: false, flags: 0, dots: 0 });
|
||||
expect(noteValue(2)).toEqual({ head: "open", stem: true, flags: 0, dots: 0 });
|
||||
expect(noteValue(1.5)).toEqual({ head: "filled", stem: true, flags: 0, dots: 1 });
|
||||
expect(noteValue(1)).toEqual({ head: "filled", stem: true, flags: 0, dots: 0 });
|
||||
expect(noteValue(0.5)).toEqual({ head: "filled", stem: true, flags: 1, dots: 0 });
|
||||
expect(noteValue(0.25)).toEqual({ head: "filled", stem: true, flags: 2, dots: 0 });
|
||||
expect(restValue(2)).toEqual({ rest: "half", dots: 0 });
|
||||
expect(restValue(3)).toEqual({ rest: "half", dots: 1 });
|
||||
expect(restValue(0.5)).toEqual({ rest: "eighth", dots: 0 });
|
||||
expect(restValue(0.25)).toEqual({ rest: "sixteenth", dots: 0 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user