Draw fingering charts foot-up, in grouped rows.
AppImage release / appimage (push) Successful in 1m36s

The foot and the double holes sit at the top of each narrow chart, and the mouthpiece sits at the bottom, matching hand-drawn tabs. Which holes are open or closed is unchanged. A repeat mark is shown only when the score repeats. Version 0.1.2.

Co-authored-by: catpersonisme <catpersonisme@gmail.com>
This commit is contained in:
Cursor Agentandchen committed 2026-10-11 08:30:50 +00:00
1 parent 6da116518f
commit 229a8d4723
9 files changed
+314 -122

No files matched your search

+6 -4
View File
@@ -7,14 +7,14 @@ The usual way to run it is one Linux AppImage. That file contains the window, th
## Run the AppImage ## Run the AppImage
```bash ```bash
chmod +x Recorder-Tab-Converter-0.1.1.AppImage chmod +x Recorder-Tab-Converter-0.1.2.AppImage
./Recorder-Tab-Converter-0.1.1.AppImage ./Recorder-Tab-Converter-0.1.2.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.1.1.AppImage APPIMAGE_EXTRACT_AND_RUN=1 ./Recorder-Tab-Converter-0.1.2.AppImage
``` ```
Open **Twinkle, Twinkle, Little Star** or **Ode to Joy** to see tabs immediately. Or drop a PNG, JPG, or WEBP of a page. The note list under the tab is editable. Fix a bad pitch or duration and the fingerings update. If a photo cannot be read, the app says so and does not invent notes. Open **Twinkle, Twinkle, Little Star** or **Ode to Joy** to see tabs immediately. Or drop a PNG, JPG, or WEBP of a page. The note list under the tab is editable. Fix a bad pitch or duration and the fingerings update. If a photo cannot be read, the app says so and does not invent notes.
@@ -30,7 +30,7 @@ From a checkout, one command:
That installs Node packages, downloads a relocatable Python plus oemer and its ONNX models, builds the app, and writes: That installs Node packages, downloads a relocatable Python plus oemer and its ONNX models, builds the app, and writes:
```text ```text
dist/Recorder-Tab-Converter-0.1.1.AppImage dist/Recorder-Tab-Converter-0.1.2.AppImage
``` ```
The U-Net checkpoint from oemer 0.1.8 has ConvTranspose pads `[0, 0, -1, -1]`. The build rewrites those nodes to pads `[0, 0, 0, 0]` and `output_padding` `[1, 1]` before packaging. That is the same output size, and it is what current ONNX Runtime will load. The rewrite does not change fingerings. The U-Net checkpoint from oemer 0.1.8 has ConvTranspose pads `[0, 0, -1, -1]`. The build rewrites those nodes to pads `[0, 0, 0, 0]` and `output_padding` `[1, 1]` before packaging. That is the same output size, and it is what current ONNX Runtime will load. The rewrite does not change fingerings.
@@ -51,6 +51,8 @@ The dev server listens on [http://127.0.0.1:3847](http://127.0.0.1:3847). Photo
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. Notes outside C5–D7 are flagged instead of guessed. 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. Notes outside C5–D7 are flagged instead of guessed.
Each chart is a narrow recorder 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. The lead is a horizontal row of those charts, grouped by bar. An arrow after a chart is one more beat. A ×2 mark appears only when the music has a repeat.
German mode changes three notes: German mode changes three notes:
- **F** — Dolmetsch’s German fingering, thumb plus holes 1–4 (`0 123 4`). - **F** — Dolmetsch’s German fingering, thumb plus holes 1–4 (`0 123 4`).
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "recorder-tab-converter", "name": "recorder-tab-converter",
"version": "0.1.1", "version": "0.1.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "recorder-tab-converter", "name": "recorder-tab-converter",
"version": "0.1.1", "version": "0.1.2",
"dependencies": { "dependencies": {
"@base-ui/react": "^1.8.0", "@base-ui/react": "^1.8.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "recorder-tab-converter", "name": "recorder-tab-converter",
"version": "0.1.1", "version": "0.1.2",
"private": true, "private": true,
"description": "Turn a sheet-music image into soprano recorder fingering tabs, with rhythm kept.", "description": "Turn a sheet-music image into soprano recorder fingering tabs, with rhythm kept.",
"main": "electron/main.cjs", "main": "electron/main.cjs",
+3 -2
View File
@@ -114,8 +114,9 @@ export function Converter() {
<p className="text-sm tracking-[0.16em] text-[#6a4328] uppercase">Soprano in C · concert pitch</p> <p className="text-sm tracking-[0.16em] text-[#6a4328] uppercase">Soprano in C · concert pitch</p>
<h1 className="font-heading text-4xl leading-tight md:text-5xl">Recorder Tab Converter</h1> <h1 className="font-heading text-4xl leading-tight md:text-5xl">Recorder Tab Converter</h1>
<p className="max-w-2xl text-base leading-7 text-muted-foreground"> <p className="max-w-2xl text-base leading-7 text-muted-foreground">
Turn a lead melody into fingering tabs a soprano player can read: thumb and holes, the note Turn a lead melody into fingering tabs a soprano player can read. Each chart has the foot at
name, and the rhythm, lined up in bars. Chords keep the top note. A second staff is ignored. the top and the mouthpiece at the bottom, with the rhythm lined up in bars. Chords keep the
top note. A second staff is ignored.
</p> </p>
</header> </header>
+68 -53
View File
@@ -6,6 +6,13 @@ import {
type HoleState = Fingering["holes"][number]; type HoleState = Fingering["holes"][number];
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.
*/
function Hole({ function Hole({
cx, cx,
cy, cy,
@@ -19,15 +26,15 @@ function Hole({
}) { }) {
return ( return (
<g> <g>
<circle cx={cx} cy={cy} r={r} fill="#fbf6ec" /> <circle cx={cx} cy={cy} r={r} fill={TUBE} />
{state === "closed" ? <circle cx={cx} cy={cy} r={r - 1.1} fill="#1c3830" /> : null} {state === "closed" ? <circle cx={cx} cy={cy} r={r - 0.55} fill={INK} /> : null}
{state === "half" ? ( {state === "half" ? (
<path <path
d={`M ${cx} ${cy - r + 1} A ${r - 1} ${r - 1} 0 0 0 ${cx} ${cy + r - 1} Z`} d={`M ${cx} ${cy - r + 0.4} A ${r - 0.4} ${r - 0.4} 0 0 0 ${cx} ${cy + r - 0.4} Z`}
fill="#1c3830" fill={INK}
/> />
) : null} ) : null}
<circle cx={cx} cy={cy} r={r} fill="none" stroke="#5c3d28" strokeWidth="1.6" /> <circle cx={cx} cy={cy} r={r} fill="none" stroke={INK} strokeWidth="1.25" />
</g> </g>
); );
} }
@@ -35,69 +42,77 @@ function Hole({
export function FingeringDiagram({ export function FingeringDiagram({
fingering, fingering,
pitch, pitch,
size = "lg", size = "lead",
}: { }: {
fingering: Fingering | null; fingering: Fingering | null;
pitch: string; pitch: string;
size?: "lg" | "sm"; size?: "lead" | "sm";
}) { }) {
const width = size === "lg" ? 112 : 88; const width = size === "lead" ? 44 : 40;
const height = Math.round(width * (188 / 48));
if (!fingering) { if (!fingering) {
return ( return (
<div <div
className="flex items-center justify-center rounded-2xl border border-dashed border-destructive/50 bg-destructive/5 px-2 text-center text-xs leading-snug text-destructive" className="flex items-center justify-center rounded-md border border-dashed border-destructive/50 bg-destructive/5 px-1 text-center text-[10px] leading-tight text-destructive"
style={{ width, height: size === "lg" ? 220 : 172 }} style={{ width, height }}
role="img" role="img"
aria-label={`${pitch} is outside the C5 to D7 chart. No fingering is shown.`} aria-label={`${pitch} is outside the C5 to D7 chart. No fingering is shown.`}
> >
Outside C5–D7. No fingering. No chart
</div> </div>
); );
} }
const [h1, h2, h3, h4, h5, h6a, h6b, h7a, h7b] = fingering.holes; const [h1, h2, h3, h4, h5, h6a, h6b, h7a, h7b] = fingering.holes;
const label = `${pitch}. ${fingeringProse(fingering)}. ${fingeringMarks(fingering).join(", ")}`; const label = `${pitch}. Foot at the top, mouthpiece at the bottom. ${fingeringProse(fingering)}. ${fingeringMarks(fingering).join(", ")}`;
return ( return (
<svg <svg
viewBox="0 0 104 228" viewBox="0 0 48 188"
width={width} width={width}
height={height}
role="img" role="img"
aria-label={label} aria-label={label}
className="h-auto shrink-0" className="shrink-0"
> >
<rect x="34" y="8" width="46" height="208" rx="20" fill="#e4cfae" stroke="#6a4328" strokeWidth="2" /> <path
<path d="M46 28c8-16 22-16 30 0" fill="none" stroke="#6a4328" strokeWidth="2" /> d="M9 3 H33 V148 H44 V184 H9 Z"
<Hole cx={18} cy={40} r={8} state={fingering.thumb} /> fill={TUBE}
<text x={14} y={62} textAnchor="middle" fontSize="8" fill="#6a4328"> stroke={INK}
T strokeWidth="1.4"
</text> strokeLinejoin="miter"
<Hole cx={57} cy={58} r={8} state={h1} /> />
<Hole cx={57} cy={80} r={8} state={h2} /> {fingering.bell ? <rect x="12" y="4" width="18" height="5" rx="1.2" fill={INK} /> : null}
<Hole cx={57} cy={102} r={8} state={h3} /> <Hole cx={15} cy={16} r={3.5} state={h7a} />
<Hole cx={57} cy={132} r={8} state={h4} /> <Hole cx={27} cy={16} r={3.5} state={h7b} />
<Hole cx={57} cy={154} r={8} state={h5} /> <Hole cx={15} cy={30} r={3.5} state={h6a} />
<Hole cx={48} cy={178} r={6.5} state={h6a} /> <Hole cx={27} cy={30} r={3.5} state={h6b} />
<Hole cx={66} cy={178} r={6.5} state={h6b} /> <Hole cx={21} cy={46} r={4.7} state={h5} />
<Hole cx={48} cy={198} r={6.5} state={h7a} /> <Hole cx={21} cy={62} r={4.7} state={h4} />
<Hole cx={66} cy={198} r={6.5} state={h7b} /> <line x1="9" y1="74" x2="33" y2="74" stroke={INK} strokeWidth="1" />
<text x={84} y={61} fontSize="8" fill="#7a5a3c">1</text> <Hole cx={21} cy={86} r={4.7} state={h3} />
<text x={84} y={83} fontSize="8" fill="#7a5a3c">2</text> <Hole cx={21} cy={102} r={4.7} state={h2} />
<text x={84} y={105} fontSize="8" fill="#7a5a3c">3</text> <Hole cx={21} cy={118} r={4.7} state={h1} />
<text x={84} y={135} fontSize="8" fill="#7a5a3c">4</text> <line x1="9" y1="130" x2="33" y2="130" stroke={INK} strokeWidth="1" />
<text x={84} y={157} fontSize="8" fill="#7a5a3c">5</text> <Hole cx={21} cy={140} r={4.5} state={fingering.thumb} />
<text x={84} y={181} fontSize="8" fill="#7a5a3c">6</text> <line x1="9" y1="156" x2="44" y2="156" stroke={INK} strokeWidth="1" />
<text x={84} y={201} fontSize="8" fill="#7a5a3c">7</text> <line x1="9" y1="166" x2="44" y2="166" stroke={INK} strokeWidth="1" />
{fingering.bell ? ( <line x1="9" y1="176" x2="44" y2="176" stroke={INK} strokeWidth="1" />
<text x={57} y={222} textAnchor="middle" fontSize="8" fill="#6a4328"> <ellipse cx="26" cy="166" rx="3.2" ry="4.4" fill={TUBE} stroke={INK} strokeWidth="1.2" transform="rotate(-24 26 166)" />
bell
</text>
) : null}
</svg> </svg>
); );
} }
export function RestMark({ label }: { label: string }) { export function BeatArrow() {
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>
);
}
export function RestMark({ label, width = 44 }: { label: string; width?: number }) {
const height = Math.round(width * (188 / 48));
const kind = label.includes("whole") const kind = label.includes("whole")
? "whole" ? "whole"
: label.includes("half") : label.includes("half")
@@ -108,25 +123,25 @@ export function RestMark({ label }: { label: string }) {
? "eighth" ? "eighth"
: "quarter"; : "quarter";
return ( return (
<svg viewBox="0 0 64 120" width={72} className="h-auto" role="img" aria-label={`${label} rest`}> <svg viewBox="0 0 48 188" width={width} height={height} className="shrink-0" role="img" aria-label={`${label} rest`}>
<line x1="8" y1="60" x2="56" y2="60" stroke="#c4b39a" strokeWidth="2" /> <line x1="8" y1="94" x2="40" y2="94" stroke="#8a8174" strokeWidth="1.5" />
{kind === "whole" ? <rect x="20" y="36" width="24" height="14" rx="1" fill="#2a2118" /> : null} {kind === "whole" ? <rect x="14" y="78" width="20" height="12" rx="1" fill={INK} /> : null}
{kind === "half" ? <rect x="20" y="60" width="24" height="14" rx="1" fill="#2a2118" /> : null} {kind === "half" ? <rect x="14" y="94" width="20" height="12" rx="1" fill={INK} /> : null}
{kind === "quarter" ? ( {kind === "quarter" ? (
<path <path
d="M34 28c8 8 8 14 0 20s-8 12 2 20 10 10 4 18" d="M26 62c7 7 7 12 0 18s-7 10 2 17 8 8 3 15"
fill="none" fill="none"
stroke="#2a2118" stroke={INK}
strokeWidth="4" strokeWidth="3.2"
strokeLinecap="round" strokeLinecap="round"
/> />
) : null} ) : null}
{kind === "eighth" || kind === "sixteenth" ? ( {kind === "eighth" || kind === "sixteenth" ? (
<g fill="#2a2118"> <g fill={INK}>
<circle cx="36" cy="78" r="6" /> <circle cx="27" cy="112" r="5" />
<path d="M36 72c10-14 16-20 8-36" fill="none" stroke="#2a2118" strokeWidth="3" /> <path d="M27 107c8-12 13-16 6-30" fill="none" stroke={INK} strokeWidth="2.6" />
{kind === "sixteenth" ? ( {kind === "sixteenth" ? (
<path d="M32 64c10-10 14-14 6-26" fill="none" stroke="#2a2118" strokeWidth="3" /> <path d="M23 100c8-8 11-12 5-22" fill="none" stroke={INK} strokeWidth="2.6" />
) : null} ) : null}
</g> </g>
) : null} ) : null}
+137 -58
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { FingeringDiagram, RestMark } from "@/components/fingering-diagram"; import { BeatArrow, FingeringDiagram, RestMark } from "@/components/fingering-diagram";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -8,11 +8,20 @@ import {
CHART_NOTES, CHART_NOTES,
fingeringCode, fingeringCode,
fingeringFor, fingeringFor,
fingeringMarks,
germanDiffers, germanDiffers,
type FingeringSystem, type FingeringSystem,
} from "@/lib/fingering"; } from "@/lib/fingering";
import { formatBeat, signatureKey, startBeats, type Score } from "@/lib/score"; import {
extraBeatArrows,
formatBeat,
leadRows,
signatureKey,
startBeats,
type Measure,
type Score,
type ScoreEvent,
type TimeSignature,
} from "@/lib/score";
import { formatTextTab } from "@/lib/text-tab"; import { formatTextTab } from "@/lib/text-tab";
import { Copy } from "lucide-react"; import { Copy } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -36,8 +45,9 @@ export function ScoreViews({ score, system }: { score: Score; system: FingeringS
<TabsContent value="player" className="space-y-4"> <TabsContent value="player" className="space-y-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Each cell is wide in proportion to its length, so a half note takes twice the room of a Each note is a narrow recorder with the foot at the top and the mouthpiece at the bottom.
quarter. Scroll a bar sideways if the fingerings need more room than the screen. Filled holes are closed. Notes sit in a row, grouped by bar. An arrow is one more beat on
the note before it. The duration is also written under the chart.
</p> </p>
{outside > 0 ? ( {outside > 0 ? (
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">
@@ -46,56 +56,21 @@ export function ScoreViews({ score, system }: { score: Score; system: FingeringS
</p> </p>
) : null} ) : null}
<div className="space-y-4"> <div className="space-y-4">
{score.measures.map((measure, index) => { {leadRows(score.measures).map((row, rowIndex) => (
const beats = startBeats(measure.events, score.timeSignature); <div key={row[0]?.id ?? rowIndex} className="overflow-x-auto">
return ( <div className="flex w-max min-w-full items-stretch gap-3 border-2 border-foreground bg-card px-3 py-3">
<section key={measure.id} className="rounded-2xl bg-card p-3 ring-1 ring-foreground/10"> {row.map((measure) => (
<h3 className="mb-2 font-heading text-lg">Bar {index + 1}</h3> <MeasureGroup
{measure.events.length === 0 ? ( key={measure.id}
<p className="px-2 py-8 text-sm text-muted-foreground">Empty bar. Add a note or a rest in the editor.</p> measure={measure}
) : ( system={system}
<div className="overflow-x-auto pb-2"> timeSignature={score.timeSignature}
<div className="flex w-max min-w-full border-x-[3px] border-foreground"> barNumber={score.measures.findIndex((item) => item.id === measure.id) + 1}
{measure.events.map((event, eventIndex) => { />
const fingering = ))}
event.kind === "note" && event.pitch </div>
? fingeringFor(event.pitch, system) </div>
: null; ))}
const marks = fingering ? fingeringMarks(fingering) : [];
return (
<div
key={event.id}
className="flex flex-col items-center gap-1 border-r border-foreground/15 px-2 py-2 last:border-r-0"
style={{ minWidth: Math.max(124, event.quarters * 124) }}
>
<div className="flex min-h-[230px] items-center">
{event.kind === "rest" ? (
<RestMark label={event.label} />
) : (
<FingeringDiagram
fingering={fingering}
pitch={event.pitch ?? "?"}
/>
)}
</div>
<p className="font-heading text-xl leading-none">
{event.kind === "rest" ? "rest" : event.pitch}
</p>
<p className="text-sm text-muted-foreground">{event.label}</p>
{marks.length > 0 ? (
<p className="text-xs tracking-wide text-[#6a4328] uppercase">{marks.join(" · ")}</p>
) : null}
{event.tied ? <p className="text-xs text-muted-foreground">ties forward</p> : null}
<p className="mt-1 text-xs text-muted-foreground">beat {formatBeat(beats[eventIndex] ?? 1)}</p>
</div>
);
})}
</div>
</div>
)}
</section>
);
})}
</div> </div>
<FingeringLegend /> <FingeringLegend />
{score.measures.some((measure) => {score.measures.some((measure) =>
@@ -131,13 +106,13 @@ export function ScoreViews({ score, system }: { score: Score; system: FingeringS
<TabsContent value="chart" className="space-y-4"> <TabsContent value="chart" className="space-y-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Every note from C5 to D7. {system === "baroque" ? "Baroque is showing." : "German changes are badged."} Every note from C5 to D7, foot at the top. {system === "baroque" ? "Baroque is showing." : "German changes are badged."}
</p> </p>
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-4"> <ul className="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8">
{CHART_NOTES.map((note) => { {CHART_NOTES.map((note) => {
const fingering = fingeringFor(note, system); const fingering = fingeringFor(note, system);
return ( return (
<li key={note} className="flex flex-col items-center gap-1 rounded-2xl bg-card p-3 ring-1 ring-foreground/10"> <li key={note} className="flex flex-col items-center gap-1 rounded-2xl bg-card px-2 py-3 ring-1 ring-foreground/10">
<FingeringDiagram fingering={fingering} pitch={note} size="sm" /> <FingeringDiagram fingering={fingering} pitch={note} size="sm" />
<p className="font-heading text-lg">{note}</p> <p className="font-heading text-lg">{note}</p>
<p className="text-center text-xs text-muted-foreground"> <p className="text-center text-xs text-muted-foreground">
@@ -158,13 +133,117 @@ export function ScoreViews({ score, system }: { score: Score; system: FingeringS
); );
} }
function MeasureGroup({
measure,
system,
timeSignature,
barNumber,
}: {
measure: Measure;
system: FingeringSystem;
timeSignature: TimeSignature;
barNumber: number;
}) {
const beats = startBeats(measure.events, timeSignature);
return (
<section
className="flex items-end gap-2 border-l-2 border-foreground pl-3 first:border-l-0 first:pl-0"
aria-label={`Bar ${barNumber}`}
>
<div className="flex items-end gap-2">
{measure.events.length === 0 ? (
<p className="px-2 py-8 text-sm text-muted-foreground">Empty bar</p>
) : (
groupByBeat(measure.events, beats).map((group) => (
<div key={group[0]?.event.id} className="flex items-end gap-1">
{group.map(({ event, beat }) => (
<LeadNote
key={event.id}
event={event}
beat={beat}
system={system}
timeSignature={timeSignature}
/>
))}
</div>
))
)}
</div>
{measure.repeatTimes ? (
<p
className="self-center px-1 font-heading text-4xl leading-none tracking-tight"
aria-label={`Repeat this passage ${measure.repeatTimes} times`}
>
×{measure.repeatTimes}
</p>
) : null}
</section>
);
}
function groupByBeat(events: ScoreEvent[], beats: number[]) {
const groups: { event: ScoreEvent; beat: number }[][] = [];
events.forEach((event, index) => {
const beat = beats[index] ?? 1;
const bucket = Math.floor(beat - 1 + 1e-6);
const current = groups[groups.length - 1];
const currentBucket = current ? Math.floor(current[0].beat - 1 + 1e-6) : -1;
if (!current || currentBucket !== bucket) groups.push([{ event, beat }]);
else current.push({ event, beat });
});
return groups;
}
function LeadNote({
event,
beat,
system,
timeSignature,
}: {
event: ScoreEvent;
beat: number;
system: FingeringSystem;
timeSignature: TimeSignature;
}) {
const fingering = event.kind === "note" && event.pitch ? fingeringFor(event.pitch, system) : null;
const arrows = extraBeatArrows(event.quarters, timeSignature);
const name = event.kind === "rest" ? "rest" : (event.pitch ?? "?");
return (
<div className="flex flex-col items-center">
<div className="flex items-center gap-1">
{event.kind === "rest" ? (
<RestMark label={event.label} />
) : (
<FingeringDiagram fingering={fingering} pitch={event.pitch ?? "?"} />
)}
{arrows > 0 ? (
<span className="flex items-center gap-1" aria-hidden>
{Array.from({ length: arrows }, (_, index) => (
<BeatArrow key={index} />
))}
</span>
) : null}
</div>
<p className="mt-1 w-11 text-center text-[11px] leading-none font-medium">{name}</p>
<p className="mt-0.5 w-11 text-center text-[10px] leading-tight text-muted-foreground">
{event.label}
{event.tied ? " · tie" : ""}
</p>
<p className="w-11 text-center text-[10px] leading-tight text-muted-foreground">beat {formatBeat(beat)}</p>
</div>
);
}
function FingeringLegend() { function FingeringLegend() {
return ( return (
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"> <ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<li>Foot at the top, mouthpiece at the bottom</li>
<li>Filled hole: closed</li> <li>Filled hole: closed</li>
<li>Empty hole: open</li> <li>Empty hole: open</li>
<li>One of a pair filled: half-hole</li> <li>One of a pair filled: half-hole</li>
<li>Thumb half-filled: pinch</li> <li>Thumb half-filled: pinch</li>
<li>Arrow: one more beat</li>
<li>×2 only when the music repeats</li>
</ul> </ul>
); );
} }
+20 -1
View File
@@ -173,10 +173,24 @@ export function parseMusicXml(xml: string): ParseSuccess | ParseFailure {
for (const [measureIndex, measureNode] of children(parts[0]).filter((node) => tagOf(node) === "measure").entries()) { for (const [measureIndex, measureNode] of children(parts[0]).filter((node) => tagOf(node) === "measure").entries()) {
let cursor = 0; let cursor = 0;
let lastDuration = 0; let lastDuration = 0;
let repeatForward = false;
let repeatTimes: number | undefined;
const grouped = new Map<number, Candidate[]>(); const grouped = new Map<number, Candidate[]>();
for (const element of children(measureNode)) { for (const element of children(measureNode)) {
const tag = tagOf(element); const tag = tagOf(element);
if (tag === "barline") {
const repeat = findChild(children(element), "repeat");
if (repeat) {
const repeatAttrs = attrs(repeat);
if (repeatAttrs.direction === "forward") repeatForward = true;
if (repeatAttrs.direction === "backward") {
const times = Number(repeatAttrs.times);
repeatTimes = Number.isFinite(times) && times >= 2 ? times : 2;
}
}
continue;
}
if (tag === "attributes") { if (tag === "attributes") {
const kids = children(element); const kids = children(element);
const nextDivisions = childText(kids, "divisions"); const nextDivisions = childText(kids, "divisions");
@@ -287,7 +301,12 @@ export function parseMusicXml(xml: string): ParseSuccess | ParseFailure {
}); });
} }
measures.push({ id: newId("m"), events }); measures.push({
id: newId("m"),
events,
...(repeatForward ? { repeatForward: true } : {}),
...(repeatTimes ? { repeatTimes } : {}),
});
void measureIndex; void measureIndex;
} }
+31
View File
@@ -17,6 +17,10 @@ export type ScoreEvent = {
export type Measure = { export type Measure = {
id: string; id: string;
events: ScoreEvent[]; events: ScoreEvent[];
/** A forward repeat barline stands at the start of this measure. */
repeatForward?: boolean;
/** A backward repeat ends on this measure. The number is how many times to play the passage. */
repeatTimes?: number;
}; };
export type ScoreOrigin = "sample" | "upload" | "blank"; export type ScoreOrigin = "sample" | "upload" | "blank";
@@ -86,6 +90,33 @@ export function formatBeat(beat: number): string {
return Number.isInteger(rounded) ? String(rounded) : String(rounded); return Number.isInteger(rounded) ? String(rounded) : String(rounded);
} }
/** Extra full beats after the note itself. A half note in 4/4 returns 1. A dotted quarter returns 0. */
export function extraBeatArrows(quarters: number, timeSignature: TimeSignature): number {
const perBeat = 4 / timeSignature.beatType;
if (!(perBeat > 0) || !(quarters > 0)) return 0;
const wholeBeats = Math.floor(quarters / perBeat + 1e-6);
return Math.max(0, wholeBeats - 1);
}
/** Group a lead into horizontal rows. A row holds about `budget` notes, and never splits a bar. */
export function leadRows<T extends { events: { length: number } }>(measures: T[], budget = 14): T[][] {
const rows: T[][] = [];
let current: T[] = [];
let count = 0;
for (const measure of measures) {
const notes = Math.max(1, measure.events.length);
if (current.length > 0 && count + notes > budget) {
rows.push(current);
current = [];
count = 0;
}
current.push(measure);
count += notes;
}
if (current.length > 0) rows.push(current);
return rows;
}
export function blankScore(): Score { export function blankScore(): Score {
return { return {
title: "Untitled line", title: "Untitled line",
+46 -1
View File
@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
import { fingeringCode, fingeringFor, fingeringMarks, CHART_NOTES } from "@/lib/fingering"; import { fingeringCode, fingeringFor, fingeringMarks, CHART_NOTES } from "@/lib/fingering";
import { parseMusicXml } from "@/lib/musicxml"; import { parseMusicXml } from "@/lib/musicxml";
import { parsePitch } from "@/lib/pitch"; import { parsePitch } from "@/lib/pitch";
import { barStatus, expectedQuarters } from "@/lib/score"; import { barStatus, expectedQuarters, extraBeatArrows, leadRows } from "@/lib/score";
import { odeToJoy, twinkle } from "@/lib/samples"; import { odeToJoy, twinkle } from "@/lib/samples";
import { formatTextTab } from "@/lib/text-tab"; import { formatTextTab } from "@/lib/text-tab";
@@ -121,5 +121,50 @@ describe("lead-line MusicXML", () => {
expect(parsed.warnings.join(" ")).toMatch(/lower staves/); expect(parsed.warnings.join(" ")).toMatch(/lower staves/);
expect(parsed.warnings.join(" ")).toMatch(/highest note/); expect(parsed.warnings.join(" ")).toMatch(/highest note/);
expect(parsed.warnings.join(" ")).toMatch(/Grace notes/); expect(parsed.warnings.join(" ")).toMatch(/Grace notes/);
expect(parsed.score.measures.some((measure) => measure.repeatTimes || measure.repeatForward)).toBe(false);
});
it("shows a repeat count only when the barline repeats", () => {
const xml = `<?xml version="1.0"?>
<score-partwise version="4.0">
<part-list><score-part id="P1"><part-name>Lead</part-name></score-part></part-list>
<part id="P1">
<measure number="1">
<barline location="left"><repeat direction="forward"/></barline>
<attributes><divisions>1</divisions><time><beats>4</beats><beat-type>4</beat-type></time></attributes>
<note><pitch><step>C</step><octave>5</octave></pitch><duration>1</duration><type>quarter</type></note>
<note><rest/><duration>3</duration><type>half</type><dot/></note>
</measure>
<measure number="2">
<note><pitch><step>D</step><octave>5</octave></pitch><duration>4</duration><type>whole</type></note>
<barline location="right"><repeat direction="backward" times="2"/></barline>
</measure>
</part>
</score-partwise>`;
const parsed = parseMusicXml(xml);
expect(parsed.ok).toBe(true);
if (!parsed.ok) return;
expect(parsed.score.measures[0].repeatForward).toBe(true);
expect(parsed.score.measures[0].repeatTimes).toBeUndefined();
expect(parsed.score.measures[1].repeatTimes).toBe(2);
expect(parsed.score.measures[1].events.map((event) => event.label)).toEqual(["whole"]);
});
});
describe("lead layout", () => {
it("counts extra beats without adding an arrow for a dotted quarter", () => {
const fourFour = { beats: 4, beatType: 4 };
expect(extraBeatArrows(1, fourFour)).toBe(0);
expect(extraBeatArrows(0.5, fourFour)).toBe(0);
expect(extraBeatArrows(1.5, fourFour)).toBe(0);
expect(extraBeatArrows(2, fourFour)).toBe(1);
expect(extraBeatArrows(3, fourFour)).toBe(2);
expect(extraBeatArrows(4, fourFour)).toBe(3);
});
it("keeps Twinkle in three rows and does not invent a repeat", () => {
expect(leadRows(twinkle.measures)).toHaveLength(3);
expect(twinkle.measures.some((measure) => measure.repeatTimes)).toBe(false);
expect(odeToJoy.measures.some((measure) => measure.repeatTimes)).toBe(false);
}); });
}); });