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
```bash
chmod +x Recorder-Tab-Converter-0.1.1.AppImage
./Recorder-Tab-Converter-0.1.1.AppImage
chmod +x Recorder-Tab-Converter-0.1.2.AppImage
./Recorder-Tab-Converter-0.1.2.AppImage
```
If the file will not start because FUSE is missing:
```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.
@@ -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:
```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.
@@ -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.
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:
- **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",
"version": "0.1.1",
"version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "recorder-tab-converter",
"version": "0.1.1",
"version": "0.1.2",
"dependencies": {
"@base-ui/react": "^1.8.0",
"class-variance-authority": "^0.7.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "recorder-tab-converter",
"version": "0.1.1",
"version": "0.1.2",
"private": true,
"description": "Turn a sheet-music image into soprano recorder fingering tabs, with rhythm kept.",
"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>
<h1 className="font-heading text-4xl leading-tight md:text-5xl">Recorder Tab Converter</h1>
<p className="max-w-2xl text-base leading-7 text-muted-foreground">
Turn a lead melody into fingering tabs a soprano player can read: thumb and holes, the note
name, and the rhythm, lined up in bars. Chords keep the top note. A second staff is ignored.
Turn a lead melody into fingering tabs a soprano player can read. Each chart has the foot at
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>
</header>
+68 -53
View File
@@ -6,6 +6,13 @@ import {
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({
cx,
cy,
@@ -19,15 +26,15 @@ function Hole({
}) {
return (
<g>
<circle cx={cx} cy={cy} r={r} fill="#fbf6ec" />
{state === "closed" ? <circle cx={cx} cy={cy} r={r - 1.1} fill="#1c3830" /> : null}
<circle cx={cx} cy={cy} r={r} fill={TUBE} />
{state === "closed" ? <circle cx={cx} cy={cy} r={r - 0.55} fill={INK} /> : null}
{state === "half" ? (
<path
d={`M ${cx} ${cy - r + 1} A ${r - 1} ${r - 1} 0 0 0 ${cx} ${cy + r - 1} Z`}
fill="#1c3830"
d={`M ${cx} ${cy - r + 0.4} A ${r - 0.4} ${r - 0.4} 0 0 0 ${cx} ${cy + r - 0.4} Z`}
fill={INK}
/>
) : 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>
);
}
@@ -35,69 +42,77 @@ function Hole({
export function FingeringDiagram({
fingering,
pitch,
size = "lg",
size = "lead",
}: {
fingering: Fingering | null;
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) {
return (
<div
className="flex items-center justify-center rounded-2xl border border-dashed border-destructive/50 bg-destructive/5 px-2 text-center text-xs leading-snug text-destructive"
style={{ width, height: size === "lg" ? 220 : 172 }}
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 }}
role="img"
aria-label={`${pitch} is outside the C5 to D7 chart. No fingering is shown.`}
>
Outside C5–D7. No fingering.
No chart
</div>
);
}
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 (
<svg
viewBox="0 0 104 228"
viewBox="0 0 48 188"
width={width}
height={height}
role="img"
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 d="M46 28c8-16 22-16 30 0" fill="none" stroke="#6a4328" strokeWidth="2" />
<Hole cx={18} cy={40} r={8} state={fingering.thumb} />
<text x={14} y={62} textAnchor="middle" fontSize="8" fill="#6a4328">
T
</text>
<Hole cx={57} cy={58} r={8} state={h1} />
<Hole cx={57} cy={80} r={8} state={h2} />
<Hole cx={57} cy={102} r={8} state={h3} />
<Hole cx={57} cy={132} r={8} state={h4} />
<Hole cx={57} cy={154} r={8} state={h5} />
<Hole cx={48} cy={178} r={6.5} state={h6a} />
<Hole cx={66} cy={178} r={6.5} state={h6b} />
<Hole cx={48} cy={198} r={6.5} state={h7a} />
<Hole cx={66} cy={198} r={6.5} state={h7b} />
<text x={84} y={61} fontSize="8" fill="#7a5a3c">1</text>
<text x={84} y={83} fontSize="8" fill="#7a5a3c">2</text>
<text x={84} y={105} fontSize="8" fill="#7a5a3c">3</text>
<text x={84} y={135} fontSize="8" fill="#7a5a3c">4</text>
<text x={84} y={157} fontSize="8" fill="#7a5a3c">5</text>
<text x={84} y={181} fontSize="8" fill="#7a5a3c">6</text>
<text x={84} y={201} fontSize="8" fill="#7a5a3c">7</text>
{fingering.bell ? (
<text x={57} y={222} textAnchor="middle" fontSize="8" fill="#6a4328">
bell
</text>
) : null}
<path
d="M9 3 H33 V148 H44 V184 H9 Z"
fill={TUBE}
stroke={INK}
strokeWidth="1.4"
strokeLinejoin="miter"
/>
{fingering.bell ? <rect x="12" y="4" width="18" height="5" rx="1.2" fill={INK} /> : null}
<Hole cx={15} cy={16} r={3.5} state={h7a} />
<Hole cx={27} cy={16} r={3.5} state={h7b} />
<Hole cx={15} cy={30} r={3.5} state={h6a} />
<Hole cx={27} cy={30} r={3.5} state={h6b} />
<Hole cx={21} cy={46} r={4.7} state={h5} />
<Hole cx={21} cy={62} r={4.7} state={h4} />
<line x1="9" y1="74" x2="33" y2="74" stroke={INK} strokeWidth="1" />
<Hole cx={21} cy={86} r={4.7} state={h3} />
<Hole cx={21} cy={102} r={4.7} state={h2} />
<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)" />
</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")
? "whole"
: label.includes("half")
@@ -108,25 +123,25 @@ export function RestMark({ label }: { label: string }) {
? "eighth"
: "quarter";
return (
<svg viewBox="0 0 64 120" width={72} className="h-auto" role="img" aria-label={`${label} rest`}>
<line x1="8" y1="60" x2="56" y2="60" stroke="#c4b39a" strokeWidth="2" />
{kind === "whole" ? <rect x="20" y="36" width="24" height="14" rx="1" fill="#2a2118" /> : null}
{kind === "half" ? <rect x="20" y="60" width="24" height="14" rx="1" fill="#2a2118" /> : null}
<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="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"
stroke="#2a2118"
strokeWidth="4"
stroke={INK}
strokeWidth="3.2"
strokeLinecap="round"
/>
) : null}
{kind === "eighth" || kind === "sixteenth" ? (
<g fill="#2a2118">
<circle cx="36" cy="78" r="6" />
<path d="M36 72c10-14 16-20 8-36" fill="none" stroke="#2a2118" strokeWidth="3" />
<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="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}
</g>
) : null}
+137 -58
View File
@@ -1,6 +1,6 @@
"use client";
import { FingeringDiagram, RestMark } from "@/components/fingering-diagram";
import { BeatArrow, FingeringDiagram, RestMark } from "@/components/fingering-diagram";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -8,11 +8,20 @@ import {
CHART_NOTES,
fingeringCode,
fingeringFor,
fingeringMarks,
germanDiffers,
type FingeringSystem,
} 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 { Copy } from "lucide-react";
import { toast } from "sonner";
@@ -36,8 +45,9 @@ export function ScoreViews({ score, system }: { score: Score; system: FingeringS
<TabsContent value="player" className="space-y-4">
<p className="text-sm text-muted-foreground">
Each cell is wide in proportion to its length, so a half note takes twice the room of a
quarter. Scroll a bar sideways if the fingerings need more room than the screen.
Each note is a narrow recorder with the foot at the top and the mouthpiece at the bottom.
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>
{outside > 0 ? (
<p className="text-sm text-destructive">
@@ -46,56 +56,21 @@ export function ScoreViews({ score, system }: { score: Score; system: FingeringS
</p>
) : null}
<div className="space-y-4">
{score.measures.map((measure, index) => {
const beats = startBeats(measure.events, score.timeSignature);
return (
<section key={measure.id} className="rounded-2xl bg-card p-3 ring-1 ring-foreground/10">
<h3 className="mb-2 font-heading text-lg">Bar {index + 1}</h3>
{measure.events.length === 0 ? (
<p className="px-2 py-8 text-sm text-muted-foreground">Empty bar. Add a note or a rest in the editor.</p>
) : (
<div className="overflow-x-auto pb-2">
<div className="flex w-max min-w-full border-x-[3px] border-foreground">
{measure.events.map((event, eventIndex) => {
const fingering =
event.kind === "note" && event.pitch
? fingeringFor(event.pitch, system)
: null;
const marks = fingering ? fingeringMarks(fingering) : [];
return (
<div
key={event.id}
className="flex flex-col items-center gap-1 border-r border-foreground/15 px-2 py-2 last:border-r-0"
style={{ minWidth: Math.max(124, event.quarters * 124) }}
>
<div className="flex min-h-[230px] items-center">
{event.kind === "rest" ? (
<RestMark label={event.label} />
) : (
<FingeringDiagram
fingering={fingering}
pitch={event.pitch ?? "?"}
/>
)}
</div>
<p className="font-heading text-xl leading-none">
{event.kind === "rest" ? "rest" : event.pitch}
</p>
<p className="text-sm text-muted-foreground">{event.label}</p>
{marks.length > 0 ? (
<p className="text-xs tracking-wide text-[#6a4328] uppercase">{marks.join(" · ")}</p>
) : null}
{event.tied ? <p className="text-xs text-muted-foreground">ties forward</p> : null}
<p className="mt-1 text-xs text-muted-foreground">beat {formatBeat(beats[eventIndex] ?? 1)}</p>
</div>
);
})}
</div>
</div>
)}
</section>
);
})}
{leadRows(score.measures).map((row, rowIndex) => (
<div key={row[0]?.id ?? rowIndex} className="overflow-x-auto">
<div className="flex w-max min-w-full items-stretch gap-3 border-2 border-foreground bg-card px-3 py-3">
{row.map((measure) => (
<MeasureGroup
key={measure.id}
measure={measure}
system={system}
timeSignature={score.timeSignature}
barNumber={score.measures.findIndex((item) => item.id === measure.id) + 1}
/>
))}
</div>
</div>
))}
</div>
<FingeringLegend />
{score.measures.some((measure) =>
@@ -131,13 +106,13 @@ export function ScoreViews({ score, system }: { score: Score; system: FingeringS
<TabsContent value="chart" className="space-y-4">
<p className="text-sm text-muted-foreground">
Every note from C5 to D7. {system === "baroque" ? "Baroque is showing." : "German changes are badged."}
Every note from C5 to D7, foot at the top. {system === "baroque" ? "Baroque is showing." : "German changes are badged."}
</p>
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-4">
<ul className="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8">
{CHART_NOTES.map((note) => {
const fingering = fingeringFor(note, system);
return (
<li key={note} className="flex flex-col items-center gap-1 rounded-2xl bg-card p-3 ring-1 ring-foreground/10">
<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" />
<p className="font-heading text-lg">{note}</p>
<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() {
return (
<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>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>×2 only when the music repeats</li>
</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()) {
let cursor = 0;
let lastDuration = 0;
let repeatForward = false;
let repeatTimes: number | undefined;
const grouped = new Map<number, Candidate[]>();
for (const element of children(measureNode)) {
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") {
const kids = children(element);
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;
}
+31
View File
@@ -17,6 +17,10 @@ export type ScoreEvent = {
export type Measure = {
id: string;
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";
@@ -86,6 +90,33 @@ export function formatBeat(beat: number): string {
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 {
return {
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 { parseMusicXml } from "@/lib/musicxml";
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 { 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(/highest note/);
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);
});
});