1 Commits
Author SHA1 Message Date
Cursor Agentandchen e397b62757 Draw a filled treble clef on each staff.
AppImage release / appimage (push) Successful in 55s
The spiral sits on the G line, with the hook under the staff. The chart is a little wider so the holes stay the same size. Version 0.2.5.

Co-authored-by: catpersonisme <catpersonisme@gmail.com>
2026-10-11 11:35:07 +00:00
6 changed files with 46 additions and 39 deletions

No files matched your search

+5 -5
View File
@@ -7,14 +7,14 @@ The usual way to run it is one Linux AppImage. That file contains the window and
## Run the AppImage ## Run the AppImage
```bash ```bash
chmod +x Recorder-Tab-Converter-0.2.4.AppImage chmod +x Recorder-Tab-Converter-0.2.5.AppImage
./Recorder-Tab-Converter-0.2.4.AppImage ./Recorder-Tab-Converter-0.2.5.AppImage
``` ```
If the file will not start because FUSE is missing: If the file will not start because FUSE is missing:
```bash ```bash
APPIMAGE_EXTRACT_AND_RUN=1 ./Recorder-Tab-Converter-0.2.4.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, 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. 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.
@@ -30,7 +30,7 @@ From a checkout, one command:
That installs Node packages, builds the app, and writes: That installs Node packages, builds the app, and writes:
```text ```text
dist/Recorder-Tab-Converter-0.2.4.AppImage dist/Recorder-Tab-Converter-0.2.5.AppImage
``` ```
The AppImage is not committed. Keep it under 600MB. 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 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. 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. The tube ends there. A filled hole is closed. A treble staff under the holes is written one octave below the sounding pitch. 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. 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
View File
@@ -1,6 +1,6 @@
{ {
"name": "recorder-tab-converter", "name": "recorder-tab-converter",
"version": "0.2.4", "version": "0.2.5",
"private": true, "private": true,
"description": "Arrange soprano recorder fingering charts by dragging them onto a line.", "description": "Arrange soprano recorder fingering charts by dragging them onto a line.",
"main": "electron/main.cjs", "main": "electron/main.cjs",
+4 -4
View File
@@ -24,7 +24,7 @@ import {
type LineNote, type LineNote,
type ScoreLine, type ScoreLine,
} from "@/lib/line"; } from "@/lib/line";
import { chartPixelHeight } from "@/lib/notation"; import { chartOuterWidth, chartPixelHeight } from "@/lib/notation";
import { pngFilename, PNG_CHART_WIDTH } from "@/lib/png-layout"; import { pngFilename, PNG_CHART_WIDTH } from "@/lib/png-layout";
import { ChevronLeft, ChevronRight, Minus, Plus, X } from "lucide-react"; import { ChevronLeft, ChevronRight, Minus, Plus, X } from "lucide-react";
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject } from "react"; import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject } from "react";
@@ -320,7 +320,7 @@ export function Editor() {
}) })
} }
> >
<SpaceGlyph width={40} height={chartPixelHeight(40)} /> <SpaceGlyph width={chartOuterWidth(40)} height={chartPixelHeight(40)} />
</PaletteToken> </PaletteToken>
{CHART_NOTES.map((pitch) => ( {CHART_NOTES.map((pitch) => (
<PaletteToken <PaletteToken
@@ -522,7 +522,7 @@ export function Editor() {
<FingeringDiagram fingering={fingeringFor(ghost.pitch, "baroque")} pitch={ghost.pitch} size="sm" /> <FingeringDiagram fingering={fingeringFor(ghost.pitch, "baroque")} pitch={ghost.pitch} size="sm" />
) : null} ) : null}
{ghost.kind === "rest" ? <RestMark quarters={1} width={40} /> : null} {ghost.kind === "rest" ? <RestMark quarters={1} width={40} /> : null}
{ghost.kind === "space" ? <SpaceGlyph width={40} height={chartPixelHeight(40)} /> : null} {ghost.kind === "space" ? <SpaceGlyph width={chartOuterWidth(40)} height={chartPixelHeight(40)} /> : null}
</div> </div>
) : null} ) : null}
@@ -610,7 +610,7 @@ function PlacedItem({
) : null} ) : null}
{note.kind === "rest" ? <RestMark quarters={note.quarters} width={CHART_WIDTH} /> : null} {note.kind === "rest" ? <RestMark quarters={note.quarters} width={CHART_WIDTH} /> : null}
{note.kind === "space" ? ( {note.kind === "space" ? (
<SpaceGlyph width={Math.max(36, Math.round(CHART_WIDTH * note.quarters))} height={chartHeight} /> <SpaceGlyph width={Math.max(36, Math.round(chartOuterWidth(CHART_WIDTH) * note.quarters))} height={chartHeight} />
) : null} ) : null}
</div> </div>
<p className="text-[11px] leading-none font-medium">{caption}</p> <p className="text-[11px] leading-none font-medium">{caption}</p>
+22 -24
View File
@@ -7,7 +7,9 @@ import {
import { import {
CHART_VB_H, CHART_VB_H,
CHART_VB_W, CHART_VB_W,
chartOuterWidth,
chartPixelHeight, chartPixelHeight,
CLEF_GUTTER,
dotIndex, dotIndex,
ledgerIndexes, ledgerIndexes,
noteValue, noteValue,
@@ -66,8 +68,9 @@ export function FingeringDiagram({
width?: number; width?: number;
quarters?: number; quarters?: number;
}) { }) {
const width = widthOverride ?? (size === "lead" ? 44 : 40); const bodyWidth = widthOverride ?? (size === "lead" ? 44 : 40);
const height = chartPixelHeight(width); const width = chartOuterWidth(bodyWidth);
const height = chartPixelHeight(bodyWidth);
if (!fingering) { if (!fingering) {
return ( return (
<div <div
@@ -94,6 +97,7 @@ export function FingeringDiagram({
aria-label={label} aria-label={label}
className="shrink-0" className="shrink-0"
> >
<g transform={`translate(${CLEF_GUTTER} 0)`}>
<path <path
d="M9 3 H33 V150 H9 Z" d="M9 3 H33 V150 H9 Z"
fill={TUBE} fill={TUBE}
@@ -114,14 +118,18 @@ export function FingeringDiagram({
<Hole cx={21} cy={118} r={4.7} state={h1} /> <Hole cx={21} cy={118} r={4.7} state={h1} />
<line x1="9" y1="130" x2="33" y2="130" stroke={INK} strokeWidth="1" /> <line x1="9" y1="130" x2="33" y2="130" stroke={INK} strokeWidth="1" />
<Hole cx={21} cy={140} r={4.5} state={fingering.thumb} /> <Hole cx={21} cy={140} r={4.5} state={fingering.thumb} />
</g>
<Staff pitch={pitch} /> <Staff pitch={pitch} />
<NoteOnStaff pitch={pitch} head={value.head} stem={value.stem} flags={value.flags} dots={value.dots} /> <g transform={`translate(${CLEF_GUTTER} 0)`}>
<NoteOnStaff pitch={pitch} head={value.head} stem={value.stem} flags={value.flags} dots={value.dots} />
</g>
</svg> </svg>
); );
} }
export function RestMark({ quarters = 1, width = 44 }: { quarters?: number; width?: number }) { export function RestMark({ quarters = 1, width: bodyWidth = 44 }: { quarters?: number; width?: number }) {
const height = chartPixelHeight(width); const width = chartOuterWidth(bodyWidth);
const height = chartPixelHeight(bodyWidth);
const value = restValue(quarters); const value = restValue(quarters);
return ( return (
<svg <svg
@@ -135,7 +143,9 @@ export function RestMark({ quarters = 1, width = 44 }: { quarters?: number; widt
data-dots={value.dots} data-dots={value.dots}
> >
<Staff /> <Staff />
<RestOnStaff rest={value.rest} dots={value.dots} /> <g transform={`translate(${CLEF_GUTTER} 0)`}>
<RestOnStaff rest={value.rest} dots={value.dots} />
</g>
</svg> </svg>
); );
} }
@@ -148,8 +158,8 @@ function Staff({ pitch }: { pitch?: string }) {
{[0, 2, 4, 6, 8].map((index) => ( {[0, 2, 4, 6, 8].map((index) => (
<line <line
key={index} key={index}
x1={1.5} x1={1}
x2={46.5} x2={CHART_VB_W - 1.5}
y1={staffY(index)} y1={staffY(index)}
y2={staffY(index)} y2={staffY(index)}
stroke={INK} stroke={INK}
@@ -161,25 +171,13 @@ function Staff({ pitch }: { pitch?: string }) {
); );
} }
/** Filled treble clef, drawn larger than the staff. The spiral is centered on the G line. */
function TrebleClef() { function TrebleClef() {
const y = staffY(2); const y = staffY(2);
return ( return (
<path <g transform={`translate(0.7 ${y})`} data-clef="treble">
d={`M 7.4 ${y - 10.5} <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" />
C 10.6 ${y - 12.2} 12.4 ${y - 8.2} 11.2 ${y - 5.2} </g>
C 10 ${y - 2.4} 7.2 ${y - 1.6} 6.8 ${y + 1.2}
C 6.2 ${y + 4.6} 8.8 ${y + 6.2} 10.2 ${y + 3.6}
C 11.4 ${y + 1.2} 9.2 ${y - 0.6} 7.6 ${y + 0.8}
C 6.8 ${y + 1.6} 7 ${y + 4.2} 8.4 ${y + 7.4}
C 9.6 ${y + 11.2} 10.2 ${y + 15} 8.6 ${y + 17.6}
C 7 ${y + 20.2} 4.2 ${y + 18.6} 4.8 ${y + 15.4}
C 5.2 ${y + 13.2} 7.6 ${y + 13.6} 8 ${y + 15.8}`}
fill="none"
stroke={INK}
strokeWidth={1.45}
strokeLinecap="round"
strokeLinejoin="round"
/>
); );
} }
+11 -3
View File
@@ -1,7 +1,10 @@
import { quartersMatch } from "@/lib/duration"; import { quartersMatch } from "@/lib/duration";
/** The tube ends just below the thumb hole. The staff is added underneath. */ /** The tube ends just below the thumb hole. The staff is added underneath. */
export const CHART_VB_W = 48; 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; const RECORDER_VB_H = 152;
/** Distance between two staff lines. One diatonic step is half of this. */ /** Distance between two staff lines. One diatonic step is half of this. */
@@ -21,8 +24,13 @@ const BELOW = -LOW_INDEX * STEP + PAD_BOTTOM;
export const BOTTOM_LINE_Y = RECORDER_VB_H + 3 + PAD_TOP + ABOVE; export const BOTTOM_LINE_Y = RECORDER_VB_H + 3 + PAD_TOP + ABOVE;
export const CHART_VB_H = Math.ceil(BOTTOM_LINE_Y + BELOW); export const CHART_VB_H = Math.ceil(BOTTOM_LINE_Y + BELOW);
export function chartPixelHeight(width: number): number { /** Pixel width of a chart once the clef gutter is included. Holes keep their old size. */
return Math.round((width * CHART_VB_H) / CHART_VB_W); 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 { export function staffY(index: number): number {
+3 -2
View File
@@ -1,5 +1,5 @@
import type { LineNote } from "@/lib/line"; import type { LineNote } from "@/lib/line";
import { chartPixelHeight } from "@/lib/notation"; import { chartOuterWidth, chartPixelHeight } from "@/lib/notation";
export const PNG_CHART_WIDTH = 88; export const PNG_CHART_WIDTH = 88;
const GAP = 18; const GAP = 18;
@@ -20,12 +20,13 @@ export type PngItem = {
}; };
export function pngItemFromNote(note: Pick<LineNote, "kind" | "pitch" | "quarters" | "label">, chartWidth = PNG_CHART_WIDTH): PngItem { export function pngItemFromNote(note: Pick<LineNote, "kind" | "pitch" | "quarters" | "label">, chartWidth = PNG_CHART_WIDTH): PngItem {
const drawn = chartOuterWidth(chartWidth);
const glyphHeight = chartPixelHeight(chartWidth); const glyphHeight = chartPixelHeight(chartWidth);
const caption = note.kind === "note" ? note.pitch : note.kind === "rest" ? "Rest" : "Space"; const caption = note.kind === "note" ? note.pitch : note.kind === "rest" ? "Rest" : "Space";
return { return {
kind: note.kind, kind: note.kind,
caption, caption,
glyphWidth: note.kind === "space" ? spacePixelWidth(note.quarters, chartWidth) : chartWidth, glyphWidth: note.kind === "space" ? spacePixelWidth(note.quarters, drawn) : drawn,
glyphHeight, glyphHeight,
}; };
} }