2 Commits
Author SHA1 Message Date
Cursor Agentandchen 229a8d4723 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>
2026-10-11 08:30:50 +00:00
Cursor Agent 6da116518f Rewrite oemer U-Net ConvTranspose pads before packaging.
AppImage release / appimage (push) Successful in 1m42s
ONNX Runtime 1.30+ rejects pads [0, 0, -1, -1] on the unet checkpoint. The build now rewrites those nodes to pads [0, 0, 0, 0] and output_padding [1, 1], which keeps the same output size. Version 0.1.1.
2026-10-11 07:51:24 +00:00
11 changed files with 464 additions and 122 deletions

No files matched your search

+8 -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.0.AppImage
./Recorder-Tab-Converter-0.1.0.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.0.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,9 +30,11 @@ 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.0.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 build needs network access the first time. The AppImage is not committed; keep it under 600MB.
## Develop in a browser
@@ -49,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.0",
"version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "recorder-tab-converter",
"version": "0.1.0",
"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.0",
"version": "0.1.2",
"private": true,
"description": "Turn a sheet-music image into soprano recorder fingering tabs, with rhythm kept.",
"main": "electron/main.cjs",
+36 -3
View File
@@ -7,12 +7,13 @@ ROOT="$(pwd)"
echo "== Install Node packages =="
npm install
PY="$ROOT/build/python/bin/python3"
if ! "$PY" -c "import oemer.ete, onnxruntime" >/dev/null 2>&1; then
echo "== Download a relocatable Python and oemer =="
mkdir -p "$ROOT/build/cache"
TARBALL="$ROOT/build/cache/cpython.tar.gz"
URL="https://github.com/astral-sh/python-build-standalone/releases/download/20261003/cpython-3.12.15%2B20261003-x86_64-unknown-linux-gnu-install_only.tar.gz"
PY="$ROOT/build/python/bin/python3"
if ! "$PY" -c "import oemer.ete, onnxruntime" >/dev/null 2>&1; then
echo "== Download a relocatable Python and oemer =="
if [[ ! -f "$TARBALL" ]]; then
curl -fL --retry 3 -o "$TARBALL" "$URL"
fi
@@ -36,6 +37,38 @@ if [[ ! -f "$SITE/checkpoints/seg_net/model.onnx" ]]; then
curl -fL --retry 3 -o "$SITE/checkpoints/seg_net/model.onnx" \
"https://github.com/BreezeWhite/oemer/releases/download/checkpoints/2nd_model.onnx"
fi
echo "== Rewrite U-Net ConvTranspose pads =="
# oemer 0.1.8's unet has ConvTranspose pads [0, 0, -1, -1]. ONNX Runtime
# 1.30+ rejects those. The rewrite keeps the same output size. onnx itself
# is not packaged.
if ! "$PY" -m pip --version >/dev/null 2>&1; then
if [[ ! -f "$TARBALL" ]]; then
echo "pip was removed and the Python tarball is missing, so the U-Net cannot be rewritten." >&2
exit 1
fi
PIP_RESTORE="$ROOT/build/cache/pip-restore"
rm -rf "$PIP_RESTORE"
mkdir -p "$PIP_RESTORE"
tar -xzf "$TARBALL" -C "$PIP_RESTORE" --wildcards \
'python/lib/python3.12/site-packages/pip' \
'python/lib/python3.12/site-packages/pip-*.dist-info'
rm -rf "$ROOT/build/python/lib/python3.12/site-packages/pip" \
"$ROOT/build/python/lib/python3.12/site-packages/pip-"*.dist-info
cp -a "$PIP_RESTORE/python/lib/python3.12/site-packages/pip" \
"$ROOT/build/python/lib/python3.12/site-packages/"
cp -a "$PIP_RESTORE"/python/lib/python3.12/site-packages/pip-*.dist-info \
"$ROOT/build/python/lib/python3.12/site-packages/"
rm -rf "$PIP_RESTORE"
fi
PATCH_PY="$ROOT/build/cache/onnx-patch"
rm -rf "$PATCH_PY"
"$PY" -m pip install --disable-pip-version-check --target "$PATCH_PY" 'onnx>=1.16,<2'
PYTHONPATH="$PATCH_PY${PYTHONPATH:+:$PYTHONPATH}" \
"$PY" "$ROOT/scripts/patch_oemer_unet.py" \
"$SITE/checkpoints/unet_big/model.onnx"
rm -rf "$PATCH_PY"
MPLBACKEND=Agg "$PY" -c "import oemer.ete, cv2; from cv2.typing import RotatedRect; print('oemer', cv2.__version__)"
echo "== Trim the Python prefix =="
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Rewrite oemer U-Net ConvTranspose pads so ONNX Runtime can load the model.
oemer 0.1.8's 1st_model.onnx ships ConvTranspose nodes with pads
[0, 0, -1, -1]. ONNX Runtime 1.30 and newer reject those during shape
inference. pads [0, 0, 0, 0] with output_padding [1, 1] is the same
output size for these stride-2, kernel-1 nodes. Weights are left as they are.
"""
from __future__ import annotations
import sys
from pathlib import Path
import onnx
from onnx import helper
BAD_PADS = [0, 0, -1, -1]
FIXED_PADS = [0, 0, 0, 0]
FIXED_OUTPUT_PADDING = [1, 1]
CRASH_NODE = "model/conv2d_transpose_1/conv2d_transpose"
def _attr(node: onnx.NodeProto, name: str) -> onnx.AttributeProto | None:
for attr in node.attribute:
if attr.name == name:
return attr
return None
def _ints(node: onnx.NodeProto, name: str) -> list[int] | None:
attr = _attr(node, name)
if attr is None:
return None
return list(attr.ints)
def _set_ints(attr: onnx.AttributeProto, values: list[int]) -> None:
del attr.ints[:]
attr.ints.extend(values)
def rewrite(model_path: Path) -> int:
model = onnx.load(model_path)
changed = 0
for node in model.graph.node:
if node.op_type != "ConvTranspose":
continue
pads = _ints(node, "pads")
if pads is None or pads == FIXED_PADS:
continue
if pads != BAD_PADS:
if any(value < 0 for value in pads):
raise SystemExit(
f"{model_path}: {node.name} has unexpected negative pads {pads}"
)
continue
pads_attr = _attr(node, "pads")
if pads_attr is None:
raise SystemExit(f"{model_path}: {node.name} is missing pads")
_set_ints(pads_attr, FIXED_PADS)
output_padding = _attr(node, "output_padding")
if output_padding is None:
node.attribute.append(
helper.make_attribute("output_padding", FIXED_OUTPUT_PADDING)
)
else:
_set_ints(output_padding, FIXED_OUTPUT_PADDING)
changed += 1
print(
f"rewrote {node.name}: pads {FIXED_PADS} output_padding {FIXED_OUTPUT_PADDING}"
)
if changed:
onnx.save(model, model_path)
verify(model_path)
print(f"rewrote {changed} ConvTranspose node(s) in {model_path}")
return changed
def verify(model_path: Path) -> None:
model = onnx.load(model_path)
found = False
for node in model.graph.node:
if node.op_type != "ConvTranspose":
continue
pads = _ints(node, "pads") or []
if any(value < 0 for value in pads):
raise SystemExit(f"{model_path}: {node.name} still has negative pads {pads}")
if node.name != CRASH_NODE:
continue
found = True
output_padding = _ints(node, "output_padding")
if pads != FIXED_PADS or output_padding != FIXED_OUTPUT_PADDING:
raise SystemExit(
f"{model_path}: {node.name} is {pads} / {output_padding}, "
f"expected {FIXED_PADS} / {FIXED_OUTPUT_PADDING}"
)
if not found:
raise SystemExit(f"{model_path}: missing {CRASH_NODE}")
def main() -> int:
if len(sys.argv) != 2:
print("usage: patch_oemer_unet.py MODEL.onnx", file=sys.stderr)
return 2
path = Path(sys.argv[1])
if not path.is_file():
print(f"missing model: {path}", file=sys.stderr)
return 1
rewrite(path)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+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}
+134 -55
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 ?? "?"}
{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>
<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>
<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);
});
});