From 229a8d4723c94e71184f1519185d6e288bc770eb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 11 Oct 2026 08:30:50 +0000 Subject: [PATCH] Draw fingering charts foot-up, in grouped rows. 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 --- README.md | 10 +- package-lock.json | 4 +- package.json | 2 +- src/components/converter.tsx | 5 +- src/components/fingering-diagram.tsx | 121 +++++++++-------- src/components/tab-score.tsx | 195 +++++++++++++++++++-------- src/lib/musicxml.ts | 21 ++- src/lib/score.ts | 31 +++++ tests/converter.test.ts | 47 ++++++- 9 files changed, 314 insertions(+), 122 deletions(-) diff --git a/README.md b/README.md index 9e98649..6719290 100644 --- a/README.md +++ b/README.md @@ -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`). diff --git a/package-lock.json b/package-lock.json index c50ce3d..df76635 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index d939f4b..d1c7c34 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/converter.tsx b/src/components/converter.tsx index de30498..3c9022f 100644 --- a/src/components/converter.tsx +++ b/src/components/converter.tsx @@ -114,8 +114,9 @@ export function Converter() {

Soprano in C · concert pitch

Recorder Tab Converter

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

diff --git a/src/components/fingering-diagram.tsx b/src/components/fingering-diagram.tsx index f65dca6..62509e7 100644 --- a/src/components/fingering-diagram.tsx +++ b/src/components/fingering-diagram.tsx @@ -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 ( - - {state === "closed" ? : null} + + {state === "closed" ? : null} {state === "half" ? ( ) : null} - + ); } @@ -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 (
- Outside C5–D7. No fingering. + No chart
); } 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 ( - - - - - T - - - - - - - - - - - 1 - 2 - 3 - 4 - 5 - 6 - 7 - {fingering.bell ? ( - - bell - - ) : null} + + {fingering.bell ? : null} + + + + + + + + + + + + + + + + ); } -export function RestMark({ label }: { label: string }) { +export function BeatArrow() { + return ( + + + + ); +} + +export function RestMark({ label, width = 44 }: { label: string; width?: number }) { + const height = Math.round(width * (188 / 48)); const kind = label.includes("whole") ? "whole" : label.includes("half") @@ -108,25 +123,25 @@ export function RestMark({ label }: { label: string }) { ? "eighth" : "quarter"; return ( - - - {kind === "whole" ? : null} - {kind === "half" ? : null} + + + {kind === "whole" ? : null} + {kind === "half" ? : null} {kind === "quarter" ? ( ) : null} {kind === "eighth" || kind === "sixteenth" ? ( - - - + + + {kind === "sixteenth" ? ( - + ) : null} ) : null} diff --git a/src/components/tab-score.tsx b/src/components/tab-score.tsx index 9e2107c..2503153 100644 --- a/src/components/tab-score.tsx +++ b/src/components/tab-score.tsx @@ -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

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

{outside > 0 ? (

@@ -46,56 +56,21 @@ export function ScoreViews({ score, system }: { score: Score; system: FingeringS

) : null}
- {score.measures.map((measure, index) => { - const beats = startBeats(measure.events, score.timeSignature); - return ( -
-

Bar {index + 1}

- {measure.events.length === 0 ? ( -

Empty bar. Add a note or a rest in the editor.

- ) : ( -
-
- {measure.events.map((event, eventIndex) => { - const fingering = - event.kind === "note" && event.pitch - ? fingeringFor(event.pitch, system) - : null; - const marks = fingering ? fingeringMarks(fingering) : []; - return ( -
-
- {event.kind === "rest" ? ( - - ) : ( - - )} -
-

- {event.kind === "rest" ? "rest" : event.pitch} -

-

{event.label}

- {marks.length > 0 ? ( -

{marks.join(" · ")}

- ) : null} - {event.tied ?

ties forward

: null} -

beat {formatBeat(beats[eventIndex] ?? 1)}

-
- ); - })} -
-
- )} -
- ); - })} + {leadRows(score.measures).map((row, rowIndex) => ( +
+
+ {row.map((measure) => ( + item.id === measure.id) + 1} + /> + ))} +
+
+ ))}
{score.measures.some((measure) => @@ -131,13 +106,13 @@ export function ScoreViews({ score, system }: { score: Score; system: FingeringS

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

-
    +
      {CHART_NOTES.map((note) => { const fingering = fingeringFor(note, system); return ( -
    • +
    • {note}

      @@ -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 ( +

      +
      + {measure.events.length === 0 ? ( +

      Empty bar

      + ) : ( + groupByBeat(measure.events, beats).map((group) => ( +
      + {group.map(({ event, beat }) => ( + + ))} +
      + )) + )} +
      + {measure.repeatTimes ? ( +

      + ×{measure.repeatTimes} +

      + ) : null} +
      + ); +} + +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 ( +
      +
      + {event.kind === "rest" ? ( + + ) : ( + + )} + {arrows > 0 ? ( + + {Array.from({ length: arrows }, (_, index) => ( + + ))} + + ) : null} +
      +

      {name}

      +

      + {event.label} + {event.tied ? " · tie" : ""} +

      +

      beat {formatBeat(beat)}

      +
      + ); +} + function FingeringLegend() { return (
        +
      • Foot at the top, mouthpiece at the bottom
      • Filled hole: closed
      • Empty hole: open
      • One of a pair filled: half-hole
      • Thumb half-filled: pinch
      • +
      • Arrow: one more beat
      • +
      • ×2 only when the music repeats
      ); } diff --git a/src/lib/musicxml.ts b/src/lib/musicxml.ts index 8d7d710..986ae15 100644 --- a/src/lib/musicxml.ts +++ b/src/lib/musicxml.ts @@ -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(); 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; } diff --git a/src/lib/score.ts b/src/lib/score.ts index fd153b3..d4a868d 100644 --- a/src/lib/score.ts +++ b/src/lib/score.ts @@ -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(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", diff --git a/tests/converter.test.ts b/tests/converter.test.ts index 7952f9f..84b98df 100644 --- a/tests/converter.test.ts +++ b/tests/converter.test.ts @@ -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 = ` + + Lead + + + + 1 + C51quarter + 3half + + + D54whole + + + + `; + 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); }); });