From dcca7f5e83385e62ffbb554a44c7ce3cf504b6d0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 11 Oct 2026 09:49:17 +0000 Subject: [PATCH] Show length on a staff under each fingering chart. Dragging no longer selects text. The line box resizes, rests and spaces can be added, and Save PNG writes the row. Each chart keeps the foot at the top, and the treble staff shows the pitch with a notehead, stem, flags, and dots. Version 0.2.1. Co-authored-by: catpersonisme --- README.md | 14 +- package.json | 2 +- src/app/globals.css | 6 + src/app/layout.tsx | 2 +- src/components/editor.tsx | 499 ++++++++++++++++++++------- src/components/fingering-diagram.tsx | 271 +++++++++++++-- src/lib/export-png.ts | 99 ++++++ src/lib/line.ts | 42 ++- src/lib/notation.ts | 104 ++++++ src/lib/png-layout.ts | 55 +++ tests/converter.test.ts | 80 +++++ 11 files changed, 997 insertions(+), 177 deletions(-) create mode 100644 src/lib/export-png.ts create mode 100644 src/lib/notation.ts create mode 100644 src/lib/png-layout.ts diff --git a/README.md b/README.md index 944809d..fad4f5e 100644 --- a/README.md +++ b/README.md @@ -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, and the mouthpiece is at the bottom. 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.1.AppImage +./Recorder-Tab-Converter-0.2.1.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.1.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 the line. Drag a placed item to move it. Choose a duration under it. Remove takes it off. **Shorter**, **Taller**, **Narrower**, **Wider**, and the corner grip change the line box. **Save PNG** downloads the finished row. ## 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.1.AppImage ``` The AppImage is not committed. Keep it under 600MB. @@ -49,4 +49,4 @@ The dev server listens on [http://127.0.0.1:3847](http://127.0.0.1:3847). 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. -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, with the mouthpiece at the bottom. A filled hole is closed. A treble staff under the holes shows the pitch and the length: notehead, stem, flags, dots, and rests. diff --git a/package.json b/package.json index 57b61bd..a6d5a14 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "recorder-tab-converter", - "version": "0.2.0", + "version": "0.2.1", "private": true, "description": "Arrange soprano recorder fingering charts by dragging them onto a line.", "main": "electron/main.cjs", diff --git a/src/app/globals.css b/src/app/globals.css index bb2deba..3c7d2e2 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a0aad8f..dc8d973 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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<"/">) { diff --git a/src/components/editor.tsx b/src/components/editor.tsx index 1972ab9..f00253d 100644 --- a/src/components/editor.tsx +++ b/src/components/editor.tsx @@ -1,61 +1,65 @@ "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 { + clampLineBox, cloneLine, createNote, + createRest, + createSpace, dropIndexFromPoint, insertNote, LINE_EXAMPLES, moveNote, + neighborDuration, removeNote, setDuration, + type LineKind, type LineNote, } from "@/lib/line"; -import { extraBeatArrows } from "@/lib/score"; -import { ChevronLeft, ChevronRight, X } from "lucide-react"; -import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type RefObject } from "react"; +import { 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"; - pitch: string; - pointerId: number; - startX: number; - startY: number; - x: number; - y: number; - armed: boolean; - } - | { - source: "line"; - id: string; - pitch: string; - pointerId: number; - startX: number; - startY: number; - x: number; - y: number; - armed: boolean; - }; +type Drag = { + source: "palette" | "line"; + kind: LineKind; + pitch: string; + id: string; + pointerId: number; + startX: number; + startY: number; + x: number; + y: number; + armed: boolean; +}; export function Editor() { const [notes, setNotes] = useState([]); const [title, setTitle] = useState("Empty line"); const [drag, setDrag] = useState(null); const [dropIndex, setDropIndex] = useState(null); + const [boxWidth, setBoxWidth] = useState(null); + const [boxHeight, setBoxHeight] = useState(520); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); const dragRef = useRef(null); const lineRef = useRef(null); + const frameRef = useRef(null); const paletteRef = useRef(null); + const exportRef = useRef(null); function updateDrag(next: Drag | null) { dragRef.current = next; setDrag(next); + if (!next) document.body.classList.remove("line-drag"); } useEffect(() => { @@ -88,6 +92,7 @@ export function Editor() { } if (Math.hypot(dx, dy) < 8) return; } + window.getSelection()?.removeAllRanges(); if (lineRef.current) { const rect = lineRef.current.getBoundingClientRect(); if (event.clientX > rect.right - 28) lineRef.current.scrollLeft += 14; @@ -105,7 +110,13 @@ export function Editor() { const index = current.armed ? pointIndex(event.clientX, event.clientY) : -1; if (current.armed && index >= 0) { if (current.source === "palette") { - setNotes((existing) => insertNote(existing, index, createNote(current.pitch))); + const note = + current.kind === "rest" + ? createRest() + : current.kind === "space" + ? createSpace() + : createNote(current.pitch); + setNotes((existing) => insertNote(existing, index, note)); setTitle("Your line"); } else { setNotes((existing) => moveNote(existing, current.id, index)); @@ -115,22 +126,91 @@ export function Editor() { setDropIndex(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() { + if (!exportRef.current || notes.length === 0) { + 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 (
@@ -138,8 +218,9 @@ export function Editor() {

Soprano in C · Baroque

Recorder fingering editor

- 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 the line. Drag a placed item sideways to reorder it. + The staff under the holes shows the pitch and the length. Resize the line box, then save the + finished row as a PNG. The foot, with the double holes, stays at the top.

@@ -174,24 +255,65 @@ export function Editor() {

Palette

- 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.

+ + beginDrag(event, { + source: "palette", + kind: "rest", + pitch: "", + id: "", + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + x: event.clientX, + y: event.clientY, + armed: false, + }) + } + > + + + + beginDrag(event, { + source: "palette", + kind: "space", + pitch: "", + id: "", + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + x: event.clientX, + y: event.clientY, + armed: false, + }) + } + > + + {CHART_NOTES.map((pitch) => ( -
beginDrag(event, { source: "palette", + kind: "note", pitch, + id: "", pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, @@ -202,66 +324,110 @@ export function Editor() { } > - {pitch} -
+ ))}
-
+

{title}

- 4/4. An arrow after a chart is one more beat. A dotted value keeps its name under the chart. + Treble staff, 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` : ""}.

-
- {notes.length === 0 ? ( -

- Drop a fingering here. The line is empty. -

- ) : ( -
- {notes.map((note, index) => ( -
- {dropIndex === index ? : null} - - beginDrag(event, { - source: "line", - id: note.id, - pitch: note.pitch, - pointerId: event.pointerId, - startX: event.clientX, - startY: event.clientY, - x: event.clientX, - y: event.clientY, - armed: false, - }) - } - onDuration={(quarters) => { - const preset = DURATION_PRESETS.find((item) => item.quarters === quarters); - if (!preset) return; - setNotes((existing) => setDuration(existing, note.id, preset)); - }} - onRemove={() => setNotes((existing) => removeNote(existing, note.id))} - /> -
- ))} - {dropIndex === notes.length ? : null} -
- )} +
+ + + + + + +
+ {saveError ?

{saveError}

: null} +
+
+ {notes.length === 0 ? ( +

+ Drop a fingering, a rest, or a space here. The line is empty. +

+ ) : ( +
+ {notes.map((note, index) => ( +
+ {dropIndex === index ? : null} + + beginDrag(event, { + source: "line", + kind: note.kind, + id: note.id, + pitch: note.pitch, + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + x: event.clientX, + y: event.clientY, + armed: false, + }) + } + onDuration={(quarters) => { + const preset = DURATION_PRESETS.find((item) => item.quarters === quarters); + if (!preset) return; + setNotes((existing) => setDuration(existing, note.id, preset)); + }} + onRemove={() => setNotes((existing) => removeNote(existing, note.id))} + /> +
+ ))} + {dropIndex === notes.length ? : null} +
+ )} +
+
{ + 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); + }} + > + +
@@ -271,23 +437,76 @@ export function Editor() {
  • Empty hole: open
  • One of a pair filled: half-hole
  • Thumb half-filled: pinch
  • -
  • Arrow: one more beat
  • +
  • Staff under the chart: pitch and length
  • +
  • Rest: silence on the staff
  • +
  • Space: empty room
  • - {ghostPitch ? ( + {ghost ? (
    - + {ghost.kind === "note" ? ( + + ) : null} + {ghost.kind === "rest" ? : null} + {ghost.kind === "space" ? : null}
    ) : null} + +
    + {notes.map((note) => ( +
    + {note.kind === "note" ? ( + + ) : null} + {note.kind === "rest" ? : null} +
    + ))} +
    ); } -function PlacedNote({ +function PaletteToken({ + label, + pitch, + onPointerDown, + children, +}: { + label: string; + pitch?: string; + onPointerDown: (event: ReactPointerEvent) => void; + children: ReactNode; +}) { + return ( +
    event.preventDefault()} + > + {children} + {label} +
    + ); +} + +function PlacedItem({ note, dimmed, onPointerDown, @@ -300,55 +519,77 @@ 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 ( -
    +
    event.preventDefault()} > - - {arrows > 0 ? ( - - {Array.from({ length: arrows }, (_, index) => ( - - ))} - + {note.kind === "note" ? ( + + ) : null} + {note.kind === "rest" ? : null} + {note.kind === "space" ? ( + ) : null}
    -

    {note.pitch}

    - - - +

    {caption}

    +
    + + + +
    ); } +function SpaceGlyph({ width, height }: { width: number; height: number }) { + return ( +
    + ); +} + function DropCaret() { return
    ; } diff --git a/src/components/fingering-diagram.tsx b/src/components/fingering-diagram.tsx index 62509e7..5786efd 100644 --- a/src/components/fingering-diagram.tsx +++ b/src/components/fingering-diagram.tsx @@ -1,8 +1,22 @@ +import { durationLabel } from "@/lib/duration"; import { fingeringMarks, fingeringProse, type Fingering, } from "@/lib/fingering"; +import { + CHART_VB_H, + CHART_VB_W, + chartPixelHeight, + dotIndex, + ledgerIndexes, + noteValue, + restValue, + staffPlacement, + staffY, + STAFF_SPACE, + stemDown, +} from "@/lib/notation"; type HoleState = Fingering["holes"][number]; @@ -43,13 +57,17 @@ 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 width = widthOverride ?? (size === "lead" ? 44 : 40); + const height = chartPixelHeight(width); if (!fingering) { return (
    + + ); } -export function BeatArrow() { +export function RestMark({ quarters = 1, width = 44 }: { quarters?: number; width?: number }) { + const height = chartPixelHeight(width); + const value = restValue(quarters); return ( - - + + + ); } -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 ( - - - {kind === "whole" ? : null} - {kind === "half" ? : null} - {kind === "quarter" ? ( - + {[0, 2, 4, 6, 8].map((index) => ( + + ))} + + + ); +} + +function TrebleClef() { + const y = staffY(2); + return ( + + ); +} + +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 ( + + {ledgerIndexes(placed.index).map((index) => ( + + ))} + {placed.accidental === "sharp" ? : null} + {placed.accidental === "flat" ? : null} + {stem ? ( + + ) : null} + + {stem && flags > 0 ? : null} + {stem && flags > 1 ? : null} + {dots > 0 ? : null} + + ); +} + +function RestOnStaff({ rest, dots }: { rest: "whole" | "half" | "quarter" | "eighth" | "sixteenth"; dots: 0 | 1 }) { + const x = NOTE_X; + return ( + + {rest === "whole" ? ( + + ) : null} + {rest === "half" ? ( + + ) : null} + {rest === "quarter" ? : null} + {rest === "eighth" || rest === "sixteenth" ? : null} + {dots > 0 ? : null} + + ); +} + +function Dot({ x, index }: { x: number; index: number }) { + return ; +} + +function Sharp({ x, y }: { x: number; y: number }) { + return ( + + + + + + + ); +} + +function Flat({ x, y }: { x: number; y: number }) { + return ( + + ); +} + +function Flag({ x, y, down }: { x: number; y: number; down: boolean }) { + const dir = down ? -1 : 1; + return ( + + ); +} + +function QuarterRest({ x }: { x: number }) { + const y = staffY(7); + return ( + + ); +} + +function EighthRest({ x, flags }: { x: number; flags: 1 | 2 }) { + const y = staffY(6); + return ( + + + + {flags > 1 ? ( + ) : null} - {kind === "eighth" || kind === "sixteenth" ? ( - - - - {kind === "sixteenth" ? ( - - ) : null} - - ) : null} - + ); } diff --git a/src/lib/export-png.ts b/src/lib/export-png.ts new file mode 100644 index 0000000..5ec09dd --- /dev/null +++ b/src/lib/export-png.ts @@ -0,0 +1,99 @@ +import { pngItemFromNote, pngLayout, type PngItem } from "@/lib/png-layout"; + +const INK = "#1a1a1a"; +const PAPER = "#fbf6ee"; +const SPACE_STROKE = "#c4b8a8"; + +function loadSvg(svg: SVGElement): Promise { + 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; + }); +} + +export async function renderLinePng(exportRoot: HTMLElement, title: string): Promise { + const slots = [...exportRoot.querySelectorAll("[data-export-slot]")]; + const items: PngItem[] = []; + const images: Array = []; + 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) throw new Error("Add a note, rest, or space before saving a PNG."); + + const layout = pngLayout(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); + + const glyphTop = layout.pad + 56; + let x = layout.pad; + items.forEach((item, index) => { + const slotWidth = layout.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), layout.glyphHeight - 16); + ctx.restore(); + } else if (images[index]) { + ctx.drawImage(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 + layout.glyphHeight + 18); + ctx.textAlign = "start"; + x += slotWidth + layout.gap; + }); + + const blob = await new Promise((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); +} diff --git a/src/lib/line.ts b/src/lib/line.ts index d1434a4..c20a5b9 100644 --- a/src/lib/line.ts +++ b/src/lib/line.ts @@ -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, diff --git a/src/lib/notation.ts b/src/lib/notation.ts new file mode 100644 index 0000000..747d71f --- /dev/null +++ b/src/lib/notation.ts @@ -0,0 +1,104 @@ +import { quartersMatch } from "@/lib/duration"; + +/** Recorder drawing stays in a 48×188 box. The staff is added underneath. */ +export const CHART_VB_W = 48; +const RECORDER_VB_H = 188; + +/** Distance between two staff lines. One diatonic step is half of this. */ +export const STAFF_SPACE = 4.6; +const STEP = STAFF_SPACE / 2; + +const TOP_INDEX = 20; +const LOW_STEM_STEPS = 7; +const PAD_TOP = 4; +const PAD_BOTTOM = 8; + +const ABOVE = TOP_INDEX * STEP; +const BELOW = LOW_STEM_STEPS * STEP - 5 * 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); + +export function chartPixelHeight(width: number): number { + return Math.round((width * 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. Index 0 is the bottom line, E4. C5, the recorder's low C, is 5. */ +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 - 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; +} diff --git a/src/lib/png-layout.ts b/src/lib/png-layout.ts new file mode 100644 index 0000000..04936ac --- /dev/null +++ b/src/lib/png-layout.ts @@ -0,0 +1,55 @@ +import type { LineNote } from "@/lib/line"; +import { 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, chartWidth = PNG_CHART_WIDTH): PngItem { + 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, chartWidth) : chartWidth, + glyphHeight, + }; +} + +export function pngSlotWidth(item: PngItem): number { + const text = item.caption.length * 8; + return Math.max(item.glyphWidth, text, 48); +} + +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`; +} diff --git a/tests/converter.test.ts b/tests/converter.test.ts index 626ec80..1a8def5 100644 --- a/tests/converter.test.ts +++ b/tests/converter.test.ts @@ -2,16 +2,28 @@ 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, moveNote, + neighborDuration, removeNote, setDuration, } from "@/lib/line"; +import { + ledgerIndexes, + noteValue, + restValue, + staffPlacement, + stemDown, +} from "@/lib/notation"; import { parsePitch } from "@/lib/pitch"; +import { pngFilename, pngItemFromNote, pngLayout } from "@/lib/png-layout"; import { extraBeatArrows } from "@/lib/score"; const BAROQUE: Record = { @@ -122,4 +134,72 @@ 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("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("puts low C in the third space and high D on a ledger line", () => { + expect(staffPlacement("C5")).toEqual({ index: 5, accidental: null }); + expect(staffPlacement("C#5")).toEqual({ index: 5, accidental: "sharp" }); + expect(staffPlacement("Eb5")).toEqual({ index: 7, accidental: "flat" }); + expect(staffPlacement("G5")).toEqual({ index: 9, accidental: null }); + expect(staffPlacement("A5")).toEqual({ index: 10, accidental: null }); + expect(staffPlacement("D7")).toEqual({ index: 20, accidental: null }); + expect(ledgerIndexes(5)).toEqual([]); + expect(ledgerIndexes(9)).toEqual([]); + expect(ledgerIndexes(10)).toEqual([10]); + expect(ledgerIndexes(20)).toEqual([10, 12, 14, 16, 18, 20]); + 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 }); + }); });