Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
229a8d4723 |
No files matched your search
@@ -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`).
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user