Spaces:
Runtime error
Runtime error
Upload folder using huggingface_hub
Browse files- Dockerfile +1 -1
- package-lock.json +11 -0
- package.json +4 -1
- scripts/test-abc.ts +172 -0
- scripts/test-score-editor.mjs +116 -0
- src/components/piano-roll.tsx +346 -0
- src/components/score-panel.tsx +120 -29
- src/components/staff-preview.tsx +70 -0
- src/lib/abc/model.ts +75 -0
- src/lib/abc/parse.ts +403 -0
- src/lib/abc/serialize.ts +193 -0
Dockerfile
CHANGED
|
@@ -9,7 +9,7 @@ FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04
|
|
| 9 |
|
| 10 |
ARG COMFY_REF=master
|
| 11 |
# Bumped by hand to make a rebuild obvious in the running container's log.
|
| 12 |
-
ARG BUILD_STAMP=2026-09-18-
|
| 13 |
|
| 14 |
ENV DEBIAN_FRONTEND=noninteractive \
|
| 15 |
PIP_NO_CACHE_DIR=1 \
|
|
|
|
| 9 |
|
| 10 |
ARG COMFY_REF=master
|
| 11 |
# Bumped by hand to make a rebuild obvious in the running container's log.
|
| 12 |
+
ARG BUILD_STAMP=2026-09-18-score-editor
|
| 13 |
|
| 14 |
ENV DEBIAN_FRONTEND=noninteractive \
|
| 15 |
PIP_NO_CACHE_DIR=1 \
|
package-lock.json
CHANGED
|
@@ -9,6 +9,7 @@
|
|
| 9 |
"version": "0.1.0",
|
| 10 |
"dependencies": {
|
| 11 |
"@phosphor-icons/react": "^2.1.7",
|
|
|
|
| 12 |
"geist": "^1.7.2",
|
| 13 |
"motion": "^12.0.0",
|
| 14 |
"next": "^16.3.5",
|
|
@@ -1070,6 +1071,16 @@
|
|
| 1070 |
"@types/node": "*"
|
| 1071 |
}
|
| 1072 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1073 |
"node_modules/baseline-browser-mapping": {
|
| 1074 |
"version": "2.11.24",
|
| 1075 |
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.24.tgz",
|
|
|
|
| 9 |
"version": "0.1.0",
|
| 10 |
"dependencies": {
|
| 11 |
"@phosphor-icons/react": "^2.1.7",
|
| 12 |
+
"abcjs": "^6.7.0",
|
| 13 |
"geist": "^1.7.2",
|
| 14 |
"motion": "^12.0.0",
|
| 15 |
"next": "^16.3.5",
|
|
|
|
| 1071 |
"@types/node": "*"
|
| 1072 |
}
|
| 1073 |
},
|
| 1074 |
+
"node_modules/abcjs": {
|
| 1075 |
+
"version": "6.7.0",
|
| 1076 |
+
"resolved": "https://registry.npmjs.org/abcjs/-/abcjs-6.7.0.tgz",
|
| 1077 |
+
"integrity": "sha512-dSj1Iho8IvGZEbfRTcGS4uGt+DMrrXTqXXpfNXYMuUNfDMRadTqA1DlVvs3u0uUDJIej8UOAeDauEPnZ6RThmg==",
|
| 1078 |
+
"license": "MIT",
|
| 1079 |
+
"funding": {
|
| 1080 |
+
"type": "github",
|
| 1081 |
+
"url": "https://github.com/sponsors/paulrosen"
|
| 1082 |
+
}
|
| 1083 |
+
},
|
| 1084 |
"node_modules/baseline-browser-mapping": {
|
| 1085 |
"version": "2.11.24",
|
| 1086 |
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.24.tgz",
|
package.json
CHANGED
|
@@ -9,10 +9,13 @@
|
|
| 9 |
"lint": "next lint",
|
| 10 |
"typecheck": "tsc --noEmit",
|
| 11 |
"check": "node scripts/check.mjs",
|
| 12 |
-
"verify": "node scripts/verify-build.mjs"
|
|
|
|
|
|
|
| 13 |
},
|
| 14 |
"dependencies": {
|
| 15 |
"@phosphor-icons/react": "^2.1.7",
|
|
|
|
| 16 |
"geist": "^1.7.2",
|
| 17 |
"motion": "^12.0.0",
|
| 18 |
"next": "^16.3.5",
|
|
|
|
| 9 |
"lint": "next lint",
|
| 10 |
"typecheck": "tsc --noEmit",
|
| 11 |
"check": "node scripts/check.mjs",
|
| 12 |
+
"verify": "node scripts/verify-build.mjs",
|
| 13 |
+
"test:abc": "tsx scripts/test-abc.ts",
|
| 14 |
+
"test:editor": "node scripts/test-score-editor.mjs"
|
| 15 |
},
|
| 16 |
"dependencies": {
|
| 17 |
"@phosphor-icons/react": "^2.1.7",
|
| 18 |
+
"abcjs": "^6.7.0",
|
| 19 |
"geist": "^1.7.2",
|
| 20 |
"motion": "^12.0.0",
|
| 21 |
"next": "^16.3.5",
|
scripts/test-abc.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Round-trip tests for the ABC parser and serializer.
|
| 3 |
+
*
|
| 4 |
+
* The grid rewrites the score, so a parse or spelling bug here silently
|
| 5 |
+
* transposes the operator's music. The randomised block at the bottom is the
|
| 6 |
+
* important half: the fixed cases only cover what I thought to write down, and
|
| 7 |
+
* it was the random ones that found the accidental bugs.
|
| 8 |
+
*
|
| 9 |
+
* npm run test:abc
|
| 10 |
+
*/
|
| 11 |
+
import { unitsPerBar } from "@/lib/abc/model";
|
| 12 |
+
import type { AbcNote, AbcScore } from "@/lib/abc/model";
|
| 13 |
+
import { parseAbc } from "@/lib/abc/parse";
|
| 14 |
+
import { serializeAbc } from "@/lib/abc/serialize";
|
| 15 |
+
|
| 16 |
+
let fails = 0;
|
| 17 |
+
const check = (label: string, ok: boolean, extra?: string) => {
|
| 18 |
+
console.log((ok ? " ok " : " FAIL ") + label + (ok || !extra ? "" : "\n " + extra));
|
| 19 |
+
if (!ok) fails++;
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
const SKELETON = `X:1
|
| 23 |
+
T:Your title
|
| 24 |
+
M:4/4
|
| 25 |
+
L:1/8
|
| 26 |
+
K:G
|
| 27 |
+
V:Vocal
|
| 28 |
+
|: G2 A2 B2 d2 | e4 d4 :|
|
| 29 |
+
V:Ins
|
| 30 |
+
|: G,4 B,4 | C4 D4 :|`;
|
| 31 |
+
|
| 32 |
+
const first = parseAbc(SKELETON);
|
| 33 |
+
check("the skeleton parses", first.ok, first.ok ? "" : first.reason);
|
| 34 |
+
if (!first.ok) process.exit(1);
|
| 35 |
+
const s = first.score;
|
| 36 |
+
|
| 37 |
+
check("two voices found", s.voices.length === 2 && s.voices[0].id === "Vocal" && s.voices[1].id === "Ins");
|
| 38 |
+
check("meter and unit read", s.meter[0] === 4 && s.meter[1] === 4 && s.unit[1] === 8);
|
| 39 |
+
check("eight units to a bar", unitsPerBar(s) === 8);
|
| 40 |
+
check("repeats were written out", s.repeatsExpanded);
|
| 41 |
+
|
| 42 |
+
const vocal = s.notes.filter((n) => n.voice === 0).sort((a, b) => a.start - b.start);
|
| 43 |
+
check("repeat doubled the vocal line", vocal.length === 12, `got ${vocal.length}`);
|
| 44 |
+
check("the first pass spans two bars", vocal[5].start + vocal[5].duration === 16);
|
| 45 |
+
check("G in the key of G is G4 = midi 67", vocal[0].midi === 67, `got ${vocal[0].midi}`);
|
| 46 |
+
check("lower case d is an octave up from D", vocal[3].midi === 74, `got ${vocal[3].midi}`);
|
| 47 |
+
check("the repeat starts where the first pass ended", vocal[6].start === 16, `got ${vocal[6].start}`);
|
| 48 |
+
check(
|
| 49 |
+
"the repeat replays the same pitches",
|
| 50 |
+
vocal.slice(0, 6).map((n) => n.midi).join() === vocal.slice(6).map((n) => n.midi).join(),
|
| 51 |
+
);
|
| 52 |
+
const ins = s.notes.filter((n) => n.voice === 1).sort((a, b) => a.start - b.start);
|
| 53 |
+
check("G, is an octave below G", ins[0].midi === 55, `got ${ins[0].midi}`);
|
| 54 |
+
|
| 55 |
+
// --- key signature and accidentals ------------------------------------------
|
| 56 |
+
const inG = parseAbc("X:1\nM:4/4\nL:1/8\nK:G\nV:1\nF4 f4 |");
|
| 57 |
+
check("the key signature sharpens F", inG.ok && inG.score.notes[0].midi === 66);
|
| 58 |
+
const natural = parseAbc("X:1\nM:4/4\nL:1/8\nK:G\nV:1\n=F4 F4 |");
|
| 59 |
+
check(
|
| 60 |
+
"a natural cancels it for the rest of the bar",
|
| 61 |
+
natural.ok && natural.score.notes[0].midi === 65 && natural.score.notes[1].midi === 65,
|
| 62 |
+
);
|
| 63 |
+
const nextBar = parseAbc("X:1\nM:4/4\nL:1/8\nK:G\nV:1\n=F4 z4 | F4 z4 |");
|
| 64 |
+
check("and the signature returns in the next bar", nextBar.ok && nextBar.score.notes[1].midi === 66);
|
| 65 |
+
|
| 66 |
+
// --- lengths, ties, chords, rests -------------------------------------------
|
| 67 |
+
const lengths = parseAbc("X:1\nM:4/4\nL:1/8\nK:C\nV:1\nC C2 C/ C// C3/2 |");
|
| 68 |
+
check(
|
| 69 |
+
"plain, doubled, halved, quartered and dotted lengths",
|
| 70 |
+
lengths.ok && lengths.score.notes.map((n) => n.duration).join() === "1,2,0.5,0.25,1.5",
|
| 71 |
+
);
|
| 72 |
+
const tied = parseAbc("X:1\nM:4/4\nL:1/8\nK:C\nV:1\nC4-C4 |");
|
| 73 |
+
check("a tie merges into one long note", tied.ok && tied.score.notes.length === 1 && tied.score.notes[0].duration === 8);
|
| 74 |
+
const chord = parseAbc("X:1\nM:4/4\nL:1/8\nK:C\nV:1\n[CEG]4 |");
|
| 75 |
+
check(
|
| 76 |
+
"a chord is three notes at one onset",
|
| 77 |
+
chord.ok && chord.score.notes.length === 3 && chord.score.notes.every((n) => n.start === 0 && n.duration === 4),
|
| 78 |
+
);
|
| 79 |
+
const rest = parseAbc("X:1\nM:4/4\nL:1/8\nK:C\nV:1\nz4 C4 |");
|
| 80 |
+
check("a rest advances time without a note", rest.ok && rest.score.notes.length === 1 && rest.score.notes[0].start === 4);
|
| 81 |
+
|
| 82 |
+
// --- refusals ---------------------------------------------------------------
|
| 83 |
+
// Refusing beats dropping: the grid writes the score back out, so anything it
|
| 84 |
+
// cannot draw is something the operator would lose without being told.
|
| 85 |
+
for (const [label, src] of [
|
| 86 |
+
["a tuplet", "X:1\nK:C\nV:1\n(3CDE |"],
|
| 87 |
+
["a grace note", "X:1\nK:C\nV:1\n{g}C4 |"],
|
| 88 |
+
["a chord symbol", 'X:1\nK:C\nV:1\n"Am" C4 |'],
|
| 89 |
+
] as const) {
|
| 90 |
+
const r = parseAbc(src);
|
| 91 |
+
check(`${label} is refused rather than dropped`, !r.ok && Boolean(r.offending));
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// --- round trip -------------------------------------------------------------
|
| 95 |
+
const fingerprint = (score: AbcScore) =>
|
| 96 |
+
score.notes.map((n) => `${n.voice}:${n.start}:${n.duration}:${n.midi}`).sort().join("|");
|
| 97 |
+
|
| 98 |
+
function roundTrip(label: string, src: string) {
|
| 99 |
+
const a = parseAbc(src);
|
| 100 |
+
if (!a.ok) return check(`${label} parses`, false, a.reason);
|
| 101 |
+
const text = serializeAbc(a.score);
|
| 102 |
+
const b = parseAbc(text);
|
| 103 |
+
if (!b.ok) return check(`${label} re-parses`, false, `${b.reason}\n${text}`);
|
| 104 |
+
const same = fingerprint(a.score) === fingerprint(b.score);
|
| 105 |
+
check(`${label} survives a round trip`, same, same ? "" : `\n${text}\nwas ${fingerprint(a.score)}\nnow ${fingerprint(b.score)}`);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
roundTrip("the skeleton", SKELETON);
|
| 109 |
+
roundTrip("chords and rests", "X:1\nM:4/4\nL:1/8\nK:C\nV:1\n[CEG]4 z2 D2 | C8 |");
|
| 110 |
+
roundTrip("accidentals", "X:1\nM:4/4\nL:1/8\nK:C\nV:1\n^C2 _D2 =E2 ^^F2 |");
|
| 111 |
+
roundTrip("a natural after an accidental", "X:1\nM:4/4\nL:1/8\nK:C\nV:1\n^F2 F2 =F2 F2 |");
|
| 112 |
+
roundTrip("naturals under a key signature", "X:1\nM:4/4\nL:1/8\nK:G\nV:1\n=F2 F2 =C2 C2 |");
|
| 113 |
+
roundTrip("wide octaves", "X:1\nM:4/4\nL:1/8\nK:C\nV:1\nC,,2 C,2 C2 c2 | c'2 c''2 z4 |");
|
| 114 |
+
roundTrip("short notes", "X:1\nM:4/4\nL:1/8\nK:C\nV:1\nC/ D/ E// F// G3/2 A/2 z2 |");
|
| 115 |
+
roundTrip("three four time", "X:1\nM:3/4\nL:1/8\nK:D\nV:1\nD2 F2 A2 | d6 |");
|
| 116 |
+
|
| 117 |
+
// --- randomised round trip ---------------------------------------------------
|
| 118 |
+
function mulberry(seed: number) {
|
| 119 |
+
return () => {
|
| 120 |
+
seed |= 0;
|
| 121 |
+
seed = (seed + 0x6d2b79f5) | 0;
|
| 122 |
+
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
| 123 |
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
| 124 |
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
| 125 |
+
};
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
let fuzzFails = 0;
|
| 129 |
+
let firstReport = "";
|
| 130 |
+
for (let trial = 0; trial < 500; trial++) {
|
| 131 |
+
const rand = mulberry(trial * 7919 + 13);
|
| 132 |
+
const lengths = [0.5, 1, 1.5, 2, 3, 4, 6, 8];
|
| 133 |
+
const keys = ["C", "G", "D", "F", "Bb", "Am"];
|
| 134 |
+
const key = keys[Math.floor(rand() * keys.length)];
|
| 135 |
+
const notes: AbcNote[] = [];
|
| 136 |
+
const voiceCount = 1 + Math.floor(rand() * 2);
|
| 137 |
+
let id = 0;
|
| 138 |
+
|
| 139 |
+
for (let v = 0; v < voiceCount; v++) {
|
| 140 |
+
let t = 0;
|
| 141 |
+
const count = 1 + Math.floor(rand() * 10);
|
| 142 |
+
for (let i = 0; i < count; i++) {
|
| 143 |
+
t += Math.floor(rand() * 3) * 0.5;
|
| 144 |
+
const duration = lengths[Math.floor(rand() * lengths.length)];
|
| 145 |
+
notes.push({ id: `f${id++}`, voice: v, start: t, duration, midi: 36 + Math.floor(rand() * 48), tied: false });
|
| 146 |
+
t += duration;
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
const score: AbcScore = {
|
| 151 |
+
header: ["X:1", "M:4/4", "L:1/8", `K:${key}`],
|
| 152 |
+
voices: Array.from({ length: voiceCount }, (_, i) => ({ id: String(i + 1), rest: "" })),
|
| 153 |
+
notes,
|
| 154 |
+
meter: [4, 4],
|
| 155 |
+
unit: [1, 8],
|
| 156 |
+
key,
|
| 157 |
+
repeatsExpanded: false,
|
| 158 |
+
};
|
| 159 |
+
|
| 160 |
+
const text = serializeAbc(score);
|
| 161 |
+
const back = parseAbc(text);
|
| 162 |
+
const want = fingerprint(score);
|
| 163 |
+
const got = back.ok ? fingerprint(back.score) : "PARSE FAILED";
|
| 164 |
+
if (want !== got) {
|
| 165 |
+
if (!fuzzFails) firstReport = `\n first mismatch, trial ${trial}, key ${key}\n${text}\nwant ${want}\ngot ${got}`;
|
| 166 |
+
fuzzFails++;
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
check("500 random scores round trip", fuzzFails === 0, `${fuzzFails} mismatched${firstReport}`);
|
| 170 |
+
|
| 171 |
+
console.log(fails ? `\n${fails} FAILURES` : "\nall assertions passed");
|
| 172 |
+
process.exit(fails ? 1 : 0);
|
scripts/test-score-editor.mjs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { chromium } from 'playwright';
|
| 2 |
+
const b = await chromium.launch({ executablePath: '/opt/pw-browsers/chromium' });
|
| 3 |
+
const page = await b.newPage();
|
| 4 |
+
const fails = [];
|
| 5 |
+
const check = (l, ok, extra) => { console.log((ok?' ok ':' FAIL ')+l+(ok||!extra?'':'\n '+extra)); if(!ok) fails.push(l); };
|
| 6 |
+
|
| 7 |
+
const errors = [];
|
| 8 |
+
page.on('pageerror', (e) => errors.push(String(e)));
|
| 9 |
+
|
| 10 |
+
await page.goto('http://127.0.0.1:7903/', { waitUntil: 'domcontentloaded' });
|
| 11 |
+
await page.waitForSelector('#style-input', { timeout: 15000 });
|
| 12 |
+
await page.getByRole('button', { name: /Write or paste a score|Open the score editor/ }).click();
|
| 13 |
+
await page.waitForTimeout(400);
|
| 14 |
+
|
| 15 |
+
const grid = page.locator('svg[aria-label="Note grid"]');
|
| 16 |
+
check('the grid opens by default', await grid.isVisible());
|
| 17 |
+
|
| 18 |
+
const notes = () => page.locator('rect[data-note]');
|
| 19 |
+
const mine = () => page.locator('rect[data-note][data-voice="0"]');
|
| 20 |
+
const before = await notes().count();
|
| 21 |
+
check('the skeleton renders as notes', before > 0, `count ${before}`);
|
| 22 |
+
|
| 23 |
+
check('notation renders beside it', await page.locator('text=Notation').first().isVisible());
|
| 24 |
+
await page.waitForTimeout(900);
|
| 25 |
+
const engraved = await page.locator('.console-scroll svg').count();
|
| 26 |
+
check('abcjs engraved something', engraved > 0, `${engraved} svg`);
|
| 27 |
+
// The engraved staff was absolutely positioned and lay across the grid,
|
| 28 |
+
// swallowing every click meant for a note. Test a note that is actually
|
| 29 |
+
// inside the scroller's visible box, otherwise this only proves the note was
|
| 30 |
+
// scrolled out of view.
|
| 31 |
+
const topAtNote = await page.evaluate(() => {
|
| 32 |
+
const scroll = document.querySelector('svg[aria-label="Note grid"]')?.parentElement;
|
| 33 |
+
if (!scroll) return 'no scroller';
|
| 34 |
+
const view = scroll.getBoundingClientRect();
|
| 35 |
+
const note = [...document.querySelectorAll('rect[data-note]')].find((r) => {
|
| 36 |
+
const b = r.getBoundingClientRect();
|
| 37 |
+
return b.top >= view.top && b.bottom <= view.bottom && b.left >= view.left && b.right <= view.right;
|
| 38 |
+
});
|
| 39 |
+
if (!note) return 'no note in view';
|
| 40 |
+
const b = note.getBoundingClientRect();
|
| 41 |
+
const el = document.elementFromPoint(b.x + b.width / 2, b.y + b.height / 2);
|
| 42 |
+
return el ? el.tagName + (el.getAttribute('data-note') ? '#note' : '') : 'null';
|
| 43 |
+
});
|
| 44 |
+
check('nothing overlays the notes', topAtNote === 'rect#note', `topmost was ${topAtNote}`);
|
| 45 |
+
|
| 46 |
+
// --- read the ABC back out of the text view --------------------------------
|
| 47 |
+
const abcOf = async () => {
|
| 48 |
+
await page.getByRole('button', { name: 'text', exact: true }).click();
|
| 49 |
+
const v = await page.locator('textarea[aria-label="Score as ABC notation"]').inputValue();
|
| 50 |
+
await page.getByRole('button', { name: 'notes', exact: true }).click();
|
| 51 |
+
await page.waitForTimeout(200);
|
| 52 |
+
return v;
|
| 53 |
+
};
|
| 54 |
+
const original = await abcOf();
|
| 55 |
+
check('text view shows ABC', original.includes('V:Vocal'), original.slice(0, 60));
|
| 56 |
+
|
| 57 |
+
// --- drag a note up --------------------------------------------------------
|
| 58 |
+
const first = mine().first();
|
| 59 |
+
const box = await first.boundingBox();
|
| 60 |
+
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
| 61 |
+
await page.mouse.down();
|
| 62 |
+
await page.mouse.move(box.x + box.width / 2, box.y - 13 * 2, { steps: 8 });
|
| 63 |
+
await page.mouse.up();
|
| 64 |
+
await page.waitForTimeout(250);
|
| 65 |
+
const afterDrag = await abcOf();
|
| 66 |
+
check('dragging up changed the score', afterDrag !== original);
|
| 67 |
+
check('drag did not destroy the voices', afterDrag.includes('V:Vocal') && afterDrag.includes('V:Ins'));
|
| 68 |
+
check('headers survived the rewrite', afterDrag.includes('T:Your title') && afterDrag.includes('K:G'));
|
| 69 |
+
|
| 70 |
+
// --- add a note by clicking empty grid -------------------------------------
|
| 71 |
+
const scroller = page.locator('div.console-scroll').filter({ has: grid });
|
| 72 |
+
const gb = await scroller.boundingBox();
|
| 73 |
+
const countBeforeAdd = await notes().count();
|
| 74 |
+
await page.mouse.click(gb.x + gb.width - 40, gb.y + gb.height - 30);
|
| 75 |
+
await page.waitForTimeout(250);
|
| 76 |
+
check('clicking empty grid adds a note', (await notes().count()) === countBeforeAdd + 1, `${countBeforeAdd} -> ${await notes().count()}`);
|
| 77 |
+
|
| 78 |
+
// --- delete with the keyboard ----------------------------------------------
|
| 79 |
+
const countBeforeDelete = await notes().count();
|
| 80 |
+
await mine().first().focus();
|
| 81 |
+
await page.keyboard.press('Backspace');
|
| 82 |
+
await page.waitForTimeout(250);
|
| 83 |
+
check('Backspace deletes the focused note', (await notes().count()) === countBeforeDelete - 1, `${countBeforeDelete} -> ${await notes().count()}`);
|
| 84 |
+
|
| 85 |
+
// --- arrow keys nudge -------------------------------------------------------
|
| 86 |
+
await mine().first().focus();
|
| 87 |
+
const beforeNudge = await abcOf();
|
| 88 |
+
await mine().first().focus();
|
| 89 |
+
await page.keyboard.press('ArrowUp');
|
| 90 |
+
await page.waitForTimeout(250);
|
| 91 |
+
check('arrow keys move a note without a mouse', (await abcOf()) !== beforeNudge);
|
| 92 |
+
|
| 93 |
+
// --- paste a score and see it render ---------------------------------------
|
| 94 |
+
await page.getByRole('button', { name: 'text', exact: true }).click();
|
| 95 |
+
const pasted = 'X:1\nT:Pasted\nM:3/4\nL:1/8\nK:D\nV:Lead\nD2 F2 A2 | d6 |';
|
| 96 |
+
await page.locator('textarea[aria-label="Score as ABC notation"]').fill(pasted);
|
| 97 |
+
await page.getByRole('button', { name: 'notes', exact: true }).click();
|
| 98 |
+
await page.waitForTimeout(400);
|
| 99 |
+
check('a pasted score renders in the grid', (await notes().count()) === 4, `got ${await notes().count()}`);
|
| 100 |
+
check('its voice name is picked up', await page.getByRole('button', { name: 'Lead' }).isVisible());
|
| 101 |
+
|
| 102 |
+
// --- an unsupported score must refuse, not mangle --------------------------
|
| 103 |
+
await page.getByRole('button', { name: 'text', exact: true }).click();
|
| 104 |
+
await page.locator('textarea[aria-label="Score as ABC notation"]').fill('X:1\nM:4/4\nL:1/8\nK:C\nV:1\n(3CDE G4 |');
|
| 105 |
+
await page.getByRole('button', { name: 'notes', exact: true }).click();
|
| 106 |
+
await page.waitForTimeout(300);
|
| 107 |
+
check('a tuplet is refused with an explanation', await page.getByText(/cannot draw without losing it/).isVisible());
|
| 108 |
+
await page.getByRole('button', { name: 'Edit it as text' }).click();
|
| 109 |
+
await page.waitForTimeout(200);
|
| 110 |
+
check('the refused score is still intact in the text', (await page.locator('textarea[aria-label="Score as ABC notation"]').inputValue()).includes('(3CDE'));
|
| 111 |
+
|
| 112 |
+
check('no page errors throughout', errors.length === 0, errors.join('\n'));
|
| 113 |
+
|
| 114 |
+
await b.close();
|
| 115 |
+
console.log(fails.length ? `\n${fails.length} FAILURES` : '\nall assertions passed');
|
| 116 |
+
process.exit(fails.length ? 1 : 0);
|
src/components/piano-roll.tsx
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
| 4 |
+
|
| 5 |
+
import type { AbcNote, AbcScore } from "@/lib/abc/model";
|
| 6 |
+
import { noteName, unitsPerBar } from "@/lib/abc/model";
|
| 7 |
+
|
| 8 |
+
const ROW = 13;
|
| 9 |
+
const UNIT = 26;
|
| 10 |
+
const GUTTER = 52;
|
| 11 |
+
/** A note narrower than this has no room left for its resize edge. */
|
| 12 |
+
const RESIZE_EDGE = 7;
|
| 13 |
+
|
| 14 |
+
type Drag =
|
| 15 |
+
| { kind: "move"; id: string; grabUnit: number; grabMidi: number; from: AbcNote }
|
| 16 |
+
| { kind: "resize"; id: string; from: AbcNote }
|
| 17 |
+
| null;
|
| 18 |
+
|
| 19 |
+
const BLACK_KEYS = new Set([1, 3, 6, 8, 10]);
|
| 20 |
+
|
| 21 |
+
/**
|
| 22 |
+
* The note grid.
|
| 23 |
+
*
|
| 24 |
+
* Pitch runs up the vertical axis and time along the horizontal, drawn as SVG
|
| 25 |
+
* rather than canvas so every note is a real DOM node: focusable, hit-testable
|
| 26 |
+
* by the browser, and reachable from a test without pixel arithmetic.
|
| 27 |
+
*
|
| 28 |
+
* Every edit goes through onChange with a whole new note list. The score text
|
| 29 |
+
* is regenerated from that, which keeps one source of truth instead of leaving
|
| 30 |
+
* the grid and the text to disagree about what the tune is.
|
| 31 |
+
*/
|
| 32 |
+
export function PianoRoll({
|
| 33 |
+
score,
|
| 34 |
+
selectedVoice,
|
| 35 |
+
onChange,
|
| 36 |
+
onSelectVoice,
|
| 37 |
+
}: {
|
| 38 |
+
score: AbcScore;
|
| 39 |
+
selectedVoice: number;
|
| 40 |
+
onChange: (notes: AbcNote[]) => void;
|
| 41 |
+
onSelectVoice: (voice: number) => void;
|
| 42 |
+
}) {
|
| 43 |
+
const surface = useRef<SVGSVGElement>(null);
|
| 44 |
+
const [drag, setDrag] = useState<Drag>(null);
|
| 45 |
+
const [selected, setSelected] = useState<string | null>(null);
|
| 46 |
+
const dragRef = useRef<Drag>(null);
|
| 47 |
+
dragRef.current = drag;
|
| 48 |
+
|
| 49 |
+
const bar = unitsPerBar(score);
|
| 50 |
+
|
| 51 |
+
const { low, high, span } = useMemo(() => {
|
| 52 |
+
if (!score.notes.length) return { low: 48, high: 84, span: 36 };
|
| 53 |
+
let min = Infinity;
|
| 54 |
+
let max = -Infinity;
|
| 55 |
+
for (const note of score.notes) {
|
| 56 |
+
min = Math.min(min, note.midi);
|
| 57 |
+
max = Math.max(max, note.midi);
|
| 58 |
+
}
|
| 59 |
+
// Breathing room above and below, so a note can always be dragged further.
|
| 60 |
+
const lo = Math.max(0, Math.floor(min) - 4);
|
| 61 |
+
const hi = Math.min(127, Math.ceil(max) + 4);
|
| 62 |
+
return { low: lo, high: hi, span: hi - lo + 1 };
|
| 63 |
+
}, [score.notes]);
|
| 64 |
+
|
| 65 |
+
const totalUnits = useMemo(() => {
|
| 66 |
+
const end = score.notes.reduce((most, note) => Math.max(most, note.start + note.duration), 0);
|
| 67 |
+
// Always leave an empty bar at the end to write into.
|
| 68 |
+
return Math.max(bar * 4, Math.ceil((end + 1) / bar) * bar + bar);
|
| 69 |
+
}, [score.notes, bar]);
|
| 70 |
+
|
| 71 |
+
const width = GUTTER + totalUnits * UNIT;
|
| 72 |
+
const height = span * ROW;
|
| 73 |
+
|
| 74 |
+
const yFor = (midi: number) => (high - midi) * ROW;
|
| 75 |
+
const midiFor = (y: number) => high - Math.floor(y / ROW);
|
| 76 |
+
|
| 77 |
+
// getBoundingClientRect is viewport relative, so the scroll position is
|
| 78 |
+
// already in it. Adding scrollLeft as well counts it twice, and every hit
|
| 79 |
+
// test drifts further off the further the grid is scrolled.
|
| 80 |
+
const pointFrom = useCallback((event: { clientX: number; clientY: number }) => {
|
| 81 |
+
const box = surface.current?.getBoundingClientRect();
|
| 82 |
+
if (!box) return null;
|
| 83 |
+
return {
|
| 84 |
+
unit: (event.clientX - box.left - GUTTER) / UNIT,
|
| 85 |
+
midi: midiFor(event.clientY - box.top),
|
| 86 |
+
};
|
| 87 |
+
}, [high]);
|
| 88 |
+
|
| 89 |
+
// Dragging is tracked on the window, not the note, so the pointer leaving the
|
| 90 |
+
// note (or the grid) mid gesture does not silently abandon the edit.
|
| 91 |
+
useEffect(() => {
|
| 92 |
+
if (!drag) return;
|
| 93 |
+
|
| 94 |
+
const move = (event: PointerEvent) => {
|
| 95 |
+
const current = dragRef.current;
|
| 96 |
+
if (!current) return;
|
| 97 |
+
const at = pointFrom(event);
|
| 98 |
+
if (!at) return;
|
| 99 |
+
|
| 100 |
+
onChange(
|
| 101 |
+
score.notes.map((note) => {
|
| 102 |
+
if (note.id !== current.id) return note;
|
| 103 |
+
if (current.kind === "resize") {
|
| 104 |
+
const want = Math.round(at.unit - note.start);
|
| 105 |
+
return { ...note, duration: Math.max(1, want) };
|
| 106 |
+
}
|
| 107 |
+
const start = Math.max(0, Math.round(current.from.start + (at.unit - current.grabUnit)));
|
| 108 |
+
const midi = Math.min(127, Math.max(0, current.from.midi + (at.midi - current.grabMidi)));
|
| 109 |
+
return { ...note, start, midi };
|
| 110 |
+
}),
|
| 111 |
+
);
|
| 112 |
+
};
|
| 113 |
+
|
| 114 |
+
const end = () => setDrag(null);
|
| 115 |
+
window.addEventListener("pointermove", move);
|
| 116 |
+
window.addEventListener("pointerup", end);
|
| 117 |
+
window.addEventListener("pointercancel", end);
|
| 118 |
+
return () => {
|
| 119 |
+
window.removeEventListener("pointermove", move);
|
| 120 |
+
window.removeEventListener("pointerup", end);
|
| 121 |
+
window.removeEventListener("pointercancel", end);
|
| 122 |
+
};
|
| 123 |
+
}, [drag, onChange, score.notes, pointFrom]);
|
| 124 |
+
|
| 125 |
+
const addAt = (event: React.MouseEvent) => {
|
| 126 |
+
const at = pointFrom(event);
|
| 127 |
+
if (!at) return;
|
| 128 |
+
const start = Math.max(0, Math.floor(at.unit));
|
| 129 |
+
const id = `n${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
| 130 |
+
onChange([...score.notes, { id, voice: selectedVoice, start, duration: 2, midi: at.midi, tied: false }]);
|
| 131 |
+
setSelected(id);
|
| 132 |
+
};
|
| 133 |
+
|
| 134 |
+
const remove = useCallback(
|
| 135 |
+
(id: string) => {
|
| 136 |
+
onChange(score.notes.filter((note) => note.id !== id));
|
| 137 |
+
setSelected((current) => (current === id ? null : current));
|
| 138 |
+
},
|
| 139 |
+
[onChange, score.notes],
|
| 140 |
+
);
|
| 141 |
+
|
| 142 |
+
const nudge = useCallback(
|
| 143 |
+
(id: string, dMidi: number, dStart: number) => {
|
| 144 |
+
onChange(
|
| 145 |
+
score.notes.map((note) =>
|
| 146 |
+
note.id === id
|
| 147 |
+
? {
|
| 148 |
+
...note,
|
| 149 |
+
midi: Math.min(127, Math.max(0, note.midi + dMidi)),
|
| 150 |
+
start: Math.max(0, note.start + dStart),
|
| 151 |
+
}
|
| 152 |
+
: note,
|
| 153 |
+
),
|
| 154 |
+
);
|
| 155 |
+
},
|
| 156 |
+
[onChange, score.notes],
|
| 157 |
+
);
|
| 158 |
+
|
| 159 |
+
const rows = [];
|
| 160 |
+
for (let midi = high; midi >= low; midi--) rows.push(midi);
|
| 161 |
+
|
| 162 |
+
const bars = [];
|
| 163 |
+
for (let unit = 0; unit <= totalUnits; unit += bar) bars.push(unit);
|
| 164 |
+
|
| 165 |
+
return (
|
| 166 |
+
<div className="flex min-h-0 flex-1 flex-col">
|
| 167 |
+
<div className="flex flex-wrap items-center gap-2 border-b border-line px-5 py-2.5">
|
| 168 |
+
<span className="text-[11px] tracking-[0.06em] text-ink-dim uppercase">Voice</span>
|
| 169 |
+
{score.voices.map((voice, index) => (
|
| 170 |
+
<button
|
| 171 |
+
key={voice.id}
|
| 172 |
+
type="button"
|
| 173 |
+
onClick={() => onSelectVoice(index)}
|
| 174 |
+
className={`rounded-console border px-2.5 py-1 text-[12px] transition-colors duration-150 ${
|
| 175 |
+
index === selectedVoice
|
| 176 |
+
? "border-citron bg-citron/15 text-citron"
|
| 177 |
+
: "border-line-strong text-ink-dim hover:border-ink-faint hover:text-ink"
|
| 178 |
+
}`}
|
| 179 |
+
>
|
| 180 |
+
{voice.id}
|
| 181 |
+
</button>
|
| 182 |
+
))}
|
| 183 |
+
<p className="ml-auto text-[11.5px] text-ink-faint">
|
| 184 |
+
Click to add, drag to move, drag the right edge to stretch, Backspace to delete
|
| 185 |
+
</p>
|
| 186 |
+
</div>
|
| 187 |
+
|
| 188 |
+
{/*
|
| 189 |
+
The grid needs real height. Without a floor it collapses to whatever
|
| 190 |
+
the surrounding flex column has left over, which on a short window was
|
| 191 |
+
83px against 390px of notes: the roll scrolled, but most of it could
|
| 192 |
+
not be reached and the notes in it could not be clicked at all.
|
| 193 |
+
*/}
|
| 194 |
+
<div className="console-scroll min-h-[260px] flex-1 overflow-auto bg-void">
|
| 195 |
+
<svg
|
| 196 |
+
ref={surface}
|
| 197 |
+
width={width}
|
| 198 |
+
height={height}
|
| 199 |
+
role="group"
|
| 200 |
+
aria-label="Note grid"
|
| 201 |
+
className="block touch-none select-none"
|
| 202 |
+
onMouseDown={(event) => {
|
| 203 |
+
if (event.target === event.currentTarget || (event.target as Element).getAttribute("data-grid")) {
|
| 204 |
+
addAt(event);
|
| 205 |
+
}
|
| 206 |
+
}}
|
| 207 |
+
>
|
| 208 |
+
{/* Pitch rows. Black keys are shaded so octaves are countable. */}
|
| 209 |
+
{rows.map((midi) => (
|
| 210 |
+
<rect
|
| 211 |
+
key={midi}
|
| 212 |
+
data-grid="row"
|
| 213 |
+
x={GUTTER}
|
| 214 |
+
y={yFor(midi)}
|
| 215 |
+
width={width - GUTTER}
|
| 216 |
+
height={ROW}
|
| 217 |
+
className={BLACK_KEYS.has(((midi % 12) + 12) % 12) ? "fill-white/[0.025]" : "fill-transparent"}
|
| 218 |
+
/>
|
| 219 |
+
))}
|
| 220 |
+
|
| 221 |
+
{/* Bar lines, with a brighter one at each bar and a faint beat grid. */}
|
| 222 |
+
{Array.from({ length: totalUnits + 1 }, (_, unit) => (
|
| 223 |
+
<line
|
| 224 |
+
key={`u${unit}`}
|
| 225 |
+
x1={GUTTER + unit * UNIT}
|
| 226 |
+
x2={GUTTER + unit * UNIT}
|
| 227 |
+
y1={0}
|
| 228 |
+
y2={height}
|
| 229 |
+
className={unit % bar === 0 ? "stroke-white/15" : "stroke-white/[0.05]"}
|
| 230 |
+
strokeWidth={1}
|
| 231 |
+
/>
|
| 232 |
+
))}
|
| 233 |
+
|
| 234 |
+
{bars.slice(0, -1).map((unit, index) => (
|
| 235 |
+
<text
|
| 236 |
+
key={`b${unit}`}
|
| 237 |
+
x={GUTTER + unit * UNIT + 4}
|
| 238 |
+
y={11}
|
| 239 |
+
className="fill-ink-faint font-mono text-[9px]"
|
| 240 |
+
>
|
| 241 |
+
{index + 1}
|
| 242 |
+
</text>
|
| 243 |
+
))}
|
| 244 |
+
|
| 245 |
+
{/* The keyboard gutter, pinned left so pitch is always readable. */}
|
| 246 |
+
<rect x={0} y={0} width={GUTTER} height={height} className="fill-panel" />
|
| 247 |
+
{rows.map((midi) => (
|
| 248 |
+
<g key={`k${midi}`}>
|
| 249 |
+
<rect
|
| 250 |
+
x={0}
|
| 251 |
+
y={yFor(midi)}
|
| 252 |
+
width={GUTTER}
|
| 253 |
+
height={ROW}
|
| 254 |
+
className={BLACK_KEYS.has(((midi % 12) + 12) % 12) ? "fill-white/[0.04]" : "fill-transparent"}
|
| 255 |
+
/>
|
| 256 |
+
{midi % 12 === 0 ? (
|
| 257 |
+
<text x={6} y={yFor(midi) + ROW - 3} className="fill-ink-faint font-mono text-[9px]">
|
| 258 |
+
{noteName(midi)}
|
| 259 |
+
</text>
|
| 260 |
+
) : null}
|
| 261 |
+
</g>
|
| 262 |
+
))}
|
| 263 |
+
<line x1={GUTTER} x2={GUTTER} y1={0} y2={height} className="stroke-line-strong" strokeWidth={1} />
|
| 264 |
+
|
| 265 |
+
{/* Notes. Other voices stay visible but dimmed, so the part being
|
| 266 |
+
edited reads clearly without losing the context around it. */}
|
| 267 |
+
{score.notes.map((note) => {
|
| 268 |
+
const mine = note.voice === selectedVoice;
|
| 269 |
+
const w = Math.max(UNIT * note.duration, 6);
|
| 270 |
+
return (
|
| 271 |
+
<g key={note.id}>
|
| 272 |
+
<rect
|
| 273 |
+
data-note={note.id}
|
| 274 |
+
data-voice={note.voice}
|
| 275 |
+
tabIndex={mine ? 0 : -1}
|
| 276 |
+
role="button"
|
| 277 |
+
aria-label={`${noteName(note.midi)}, bar ${Math.floor(note.start / bar) + 1}`}
|
| 278 |
+
x={GUTTER + note.start * UNIT}
|
| 279 |
+
y={yFor(note.midi) + 1}
|
| 280 |
+
width={w}
|
| 281 |
+
height={ROW - 2}
|
| 282 |
+
rx={2}
|
| 283 |
+
className={
|
| 284 |
+
mine
|
| 285 |
+
? `cursor-grab ${selected === note.id ? "fill-citron" : "fill-citron/70"} hover:fill-citron`
|
| 286 |
+
: "fill-ink-faint/25"
|
| 287 |
+
}
|
| 288 |
+
onMouseDown={(event) => {
|
| 289 |
+
if (!mine) {
|
| 290 |
+
onSelectVoice(note.voice);
|
| 291 |
+
return;
|
| 292 |
+
}
|
| 293 |
+
event.stopPropagation();
|
| 294 |
+
setSelected(note.id);
|
| 295 |
+
const at = pointFrom(event);
|
| 296 |
+
if (!at) return;
|
| 297 |
+
const fromEnd = (note.start + note.duration - at.unit) * UNIT;
|
| 298 |
+
setDrag(
|
| 299 |
+
fromEnd <= RESIZE_EDGE
|
| 300 |
+
? { kind: "resize", id: note.id, from: note }
|
| 301 |
+
: { kind: "move", id: note.id, grabUnit: at.unit, grabMidi: at.midi, from: note },
|
| 302 |
+
);
|
| 303 |
+
}}
|
| 304 |
+
onKeyDown={(event) => {
|
| 305 |
+
if (!mine) return;
|
| 306 |
+
if (event.key === "Backspace" || event.key === "Delete") {
|
| 307 |
+
event.preventDefault();
|
| 308 |
+
remove(note.id);
|
| 309 |
+
} else if (event.key === "ArrowUp") {
|
| 310 |
+
event.preventDefault();
|
| 311 |
+
nudge(note.id, 1, 0);
|
| 312 |
+
} else if (event.key === "ArrowDown") {
|
| 313 |
+
event.preventDefault();
|
| 314 |
+
nudge(note.id, -1, 0);
|
| 315 |
+
} else if (event.key === "ArrowLeft") {
|
| 316 |
+
event.preventDefault();
|
| 317 |
+
nudge(note.id, 0, -1);
|
| 318 |
+
} else if (event.key === "ArrowRight") {
|
| 319 |
+
event.preventDefault();
|
| 320 |
+
nudge(note.id, 0, 1);
|
| 321 |
+
}
|
| 322 |
+
}}
|
| 323 |
+
onFocus={() => setSelected(note.id)}
|
| 324 |
+
/>
|
| 325 |
+
{mine && w > RESIZE_EDGE * 2 ? (
|
| 326 |
+
<rect
|
| 327 |
+
x={GUTTER + (note.start + note.duration) * UNIT - RESIZE_EDGE}
|
| 328 |
+
y={yFor(note.midi) + 1}
|
| 329 |
+
width={RESIZE_EDGE}
|
| 330 |
+
height={ROW - 2}
|
| 331 |
+
className="cursor-ew-resize fill-void/30"
|
| 332 |
+
onMouseDown={(event) => {
|
| 333 |
+
event.stopPropagation();
|
| 334 |
+
setSelected(note.id);
|
| 335 |
+
setDrag({ kind: "resize", id: note.id, from: note });
|
| 336 |
+
}}
|
| 337 |
+
/>
|
| 338 |
+
) : null}
|
| 339 |
+
</g>
|
| 340 |
+
);
|
| 341 |
+
})}
|
| 342 |
+
</svg>
|
| 343 |
+
</div>
|
| 344 |
+
</div>
|
| 345 |
+
);
|
| 346 |
+
}
|
src/components/score-panel.tsx
CHANGED
|
@@ -1,11 +1,16 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import { useEffect, useState } from "react";
|
| 4 |
import { motion } from "motion/react";
|
| 5 |
import { Check, Copy, X } from "@phosphor-icons/react";
|
| 6 |
|
|
|
|
|
|
|
| 7 |
import { SuggestionHint, acceptSuggestion } from "@/components/suggestion";
|
| 8 |
import { Button } from "@/components/ui";
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
/** The shape YuE2 expects: two voices, no chord symbols. */
|
| 11 |
const ABC_SKELETON = `X:1
|
|
@@ -18,6 +23,8 @@ V:Vocal
|
|
| 18 |
V:Ins
|
| 19 |
|: G,4 B,4 | C4 D4 :|`;
|
| 20 |
|
|
|
|
|
|
|
| 21 |
export function ScorePanel({
|
| 22 |
open,
|
| 23 |
score,
|
|
@@ -31,9 +38,23 @@ export function ScorePanel({
|
|
| 31 |
}) {
|
| 32 |
const [draft, setDraft] = useState(score);
|
| 33 |
const [copied, setCopied] = useState(false);
|
|
|
|
|
|
|
| 34 |
|
| 35 |
useEffect(() => setDraft(score), [score]);
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
useEffect(() => {
|
| 38 |
if (!open) return;
|
| 39 |
const onKey = (event: KeyboardEvent) => {
|
|
@@ -43,6 +64,23 @@ export function ScorePanel({
|
|
| 43 |
return () => window.removeEventListener("keydown", onKey);
|
| 44 |
}, [open, onClose]);
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
if (!open) return null;
|
| 47 |
|
| 48 |
const dirty = draft !== score && draft.trim().length > 0;
|
|
@@ -59,44 +97,97 @@ export function ScorePanel({
|
|
| 59 |
initial={{ x: 24, opacity: 0 }}
|
| 60 |
animate={{ x: 0, opacity: 1 }}
|
| 61 |
transition={{ duration: 0.2, ease: "easeOut" }}
|
| 62 |
-
className="relative flex h-full w-full max-w-[
|
| 63 |
>
|
| 64 |
-
<header className="flex items-
|
| 65 |
-
<div>
|
| 66 |
<h2 className="text-[13px] font-medium text-ink">Score</h2>
|
| 67 |
<p className="text-[11.5px] text-ink-faint">
|
| 68 |
{score
|
| 69 |
-
? "
|
| 70 |
-
: "Paste ABC notation
|
| 71 |
</p>
|
| 72 |
</div>
|
| 73 |
-
<
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
</header>
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
<
|
| 97 |
</div>
|
| 98 |
) : null}
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
<footer className="flex items-center justify-between gap-3 border-t border-line px-5 py-3.5">
|
| 101 |
<button
|
| 102 |
type="button"
|
|
@@ -119,7 +210,7 @@ export function ScorePanel({
|
|
| 119 |
onClose();
|
| 120 |
}}
|
| 121 |
>
|
| 122 |
-
Use
|
| 123 |
</Button>
|
| 124 |
</footer>
|
| 125 |
</motion.aside>
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
| 4 |
import { motion } from "motion/react";
|
| 5 |
import { Check, Copy, X } from "@phosphor-icons/react";
|
| 6 |
|
| 7 |
+
import { PianoRoll } from "@/components/piano-roll";
|
| 8 |
+
import { StaffPreview } from "@/components/staff-preview";
|
| 9 |
import { SuggestionHint, acceptSuggestion } from "@/components/suggestion";
|
| 10 |
import { Button } from "@/components/ui";
|
| 11 |
+
import type { AbcNote } from "@/lib/abc/model";
|
| 12 |
+
import { parseAbc } from "@/lib/abc/parse";
|
| 13 |
+
import { serializeAbc } from "@/lib/abc/serialize";
|
| 14 |
|
| 15 |
/** The shape YuE2 expects: two voices, no chord symbols. */
|
| 16 |
const ABC_SKELETON = `X:1
|
|
|
|
| 23 |
V:Ins
|
| 24 |
|: G,4 B,4 | C4 D4 :|`;
|
| 25 |
|
| 26 |
+
type View = "notes" | "text";
|
| 27 |
+
|
| 28 |
export function ScorePanel({
|
| 29 |
open,
|
| 30 |
score,
|
|
|
|
| 38 |
}) {
|
| 39 |
const [draft, setDraft] = useState(score);
|
| 40 |
const [copied, setCopied] = useState(false);
|
| 41 |
+
const [view, setView] = useState<View>("notes");
|
| 42 |
+
const [voice, setVoice] = useState(0);
|
| 43 |
|
| 44 |
useEffect(() => setDraft(score), [score]);
|
| 45 |
|
| 46 |
+
// Opening on an empty score used to show the skeleton in the grid while the
|
| 47 |
+
// text pane stayed blank: two panes disagreeing about what the tune was, and
|
| 48 |
+
// a drag would then rewrite a score that had never been shown as text. The
|
| 49 |
+
// skeleton is seeded instead, so both panes say the same thing from the
|
| 50 |
+
// first frame. The text view still offers it as a Tab suggestion when the
|
| 51 |
+
// operator clears the field themselves.
|
| 52 |
+
useEffect(() => {
|
| 53 |
+
if (open && !score.trim() && !draft.trim()) setDraft(ABC_SKELETON);
|
| 54 |
+
// Seeding depends on the panel opening, not on every keystroke.
|
| 55 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 56 |
+
}, [open, score]);
|
| 57 |
+
|
| 58 |
useEffect(() => {
|
| 59 |
if (!open) return;
|
| 60 |
const onKey = (event: KeyboardEvent) => {
|
|
|
|
| 64 |
return () => window.removeEventListener("keydown", onKey);
|
| 65 |
}, [open, onClose]);
|
| 66 |
|
| 67 |
+
// The text is the source of truth. The grid is a view of it that can write
|
| 68 |
+
// back, so every keystroke in the editor re-reads the model and the two can
|
| 69 |
+
// never drift apart.
|
| 70 |
+
const parsed = useMemo(() => parseAbc(draft.trim() ? draft : ABC_SKELETON), [draft]);
|
| 71 |
+
|
| 72 |
+
const applyNotes = useCallback(
|
| 73 |
+
(notes: AbcNote[]) => {
|
| 74 |
+
if (!parsed.ok) return;
|
| 75 |
+
setDraft(serializeAbc({ ...parsed.score, notes }));
|
| 76 |
+
},
|
| 77 |
+
[parsed],
|
| 78 |
+
);
|
| 79 |
+
|
| 80 |
+
useEffect(() => {
|
| 81 |
+
if (parsed.ok && voice >= parsed.score.voices.length) setVoice(0);
|
| 82 |
+
}, [parsed, voice]);
|
| 83 |
+
|
| 84 |
if (!open) return null;
|
| 85 |
|
| 86 |
const dirty = draft !== score && draft.trim().length > 0;
|
|
|
|
| 97 |
initial={{ x: 24, opacity: 0 }}
|
| 98 |
animate={{ x: 0, opacity: 1 }}
|
| 99 |
transition={{ duration: 0.2, ease: "easeOut" }}
|
| 100 |
+
className="relative flex h-full w-full max-w-[860px] flex-col border-l border-line bg-panel"
|
| 101 |
>
|
| 102 |
+
<header className="flex items-start justify-between gap-4 border-b border-line px-5 py-3.5">
|
| 103 |
+
<div className="min-w-0">
|
| 104 |
<h2 className="text-[13px] font-medium text-ink">Score</h2>
|
| 105 |
<p className="text-[11.5px] text-ink-faint">
|
| 106 |
{score
|
| 107 |
+
? "Drag the notes or edit the text. Either way it conditions the next take."
|
| 108 |
+
: "Paste ABC notation or draw it in, then use it for the next take."}
|
| 109 |
</p>
|
| 110 |
</div>
|
| 111 |
+
<div className="flex shrink-0 items-center gap-2">
|
| 112 |
+
<div className="flex rounded-console border border-line-strong p-0.5">
|
| 113 |
+
{(["notes", "text"] as View[]).map((option) => (
|
| 114 |
+
<button
|
| 115 |
+
key={option}
|
| 116 |
+
type="button"
|
| 117 |
+
onClick={() => setView(option)}
|
| 118 |
+
className={`rounded-[3px] px-2.5 py-1 text-[12px] capitalize transition-colors duration-150 ${
|
| 119 |
+
view === option ? "bg-citron/15 text-citron" : "text-ink-dim hover:text-ink"
|
| 120 |
+
}`}
|
| 121 |
+
>
|
| 122 |
+
{option}
|
| 123 |
+
</button>
|
| 124 |
+
))}
|
| 125 |
+
</div>
|
| 126 |
+
<button
|
| 127 |
+
type="button"
|
| 128 |
+
onClick={onClose}
|
| 129 |
+
className="flex h-8 w-8 items-center justify-center rounded-console text-ink-dim transition-colors hover:bg-raised hover:text-ink"
|
| 130 |
+
aria-label="Close"
|
| 131 |
+
>
|
| 132 |
+
<X size={15} weight="bold" />
|
| 133 |
+
</button>
|
| 134 |
+
</div>
|
| 135 |
</header>
|
| 136 |
|
| 137 |
+
{view === "notes" && !parsed.ok ? (
|
| 138 |
+
<div className="border-b border-alarm/40 bg-alarm/5 px-5 py-3">
|
| 139 |
+
<p className="text-[12.5px] text-ink">{parsed.reason}</p>
|
| 140 |
+
<p className="mt-1 text-[11.5px] leading-snug text-ink-faint">
|
| 141 |
+
{parsed.offending ? `It stopped at ${parsed.offending.trim()}. ` : ""}
|
| 142 |
+
The text editor still has it, untouched. Nothing was dropped.
|
| 143 |
+
</p>
|
| 144 |
+
<button
|
| 145 |
+
type="button"
|
| 146 |
+
onClick={() => setView("text")}
|
| 147 |
+
className="mt-2 text-[12px] text-citron underline-offset-4 hover:underline"
|
| 148 |
+
>
|
| 149 |
+
Edit it as text
|
| 150 |
+
</button>
|
| 151 |
</div>
|
| 152 |
) : null}
|
| 153 |
|
| 154 |
+
{view === "notes" && parsed.ok && parsed.score.repeatsExpanded ? (
|
| 155 |
+
<p className="border-b border-line bg-raised/40 px-5 py-2 text-[11.5px] text-ink-faint">
|
| 156 |
+
Repeat marks were written out so the grid could show every bar. Saving keeps them written out.
|
| 157 |
+
</p>
|
| 158 |
+
) : null}
|
| 159 |
+
|
| 160 |
+
{view === "notes" && parsed.ok ? (
|
| 161 |
+
<PianoRoll
|
| 162 |
+
score={parsed.score}
|
| 163 |
+
selectedVoice={voice}
|
| 164 |
+
onChange={applyNotes}
|
| 165 |
+
onSelectVoice={setVoice}
|
| 166 |
+
/>
|
| 167 |
+
) : (
|
| 168 |
+
<>
|
| 169 |
+
<textarea
|
| 170 |
+
value={draft}
|
| 171 |
+
onChange={(event) => setDraft(event.target.value)}
|
| 172 |
+
onKeyDown={(event) => {
|
| 173 |
+
if (acceptSuggestion(event, draft, ABC_SKELETON)) setDraft(ABC_SKELETON);
|
| 174 |
+
}}
|
| 175 |
+
spellCheck={false}
|
| 176 |
+
placeholder={ABC_SKELETON}
|
| 177 |
+
aria-label="Score as ABC notation"
|
| 178 |
+
aria-describedby={draft ? undefined : "score-suggestion-hint"}
|
| 179 |
+
className="console-scroll flex-1 resize-none bg-void px-5 py-4 font-mono text-[12.5px] leading-[1.7] text-ink placeholder:text-ink-faint/60"
|
| 180 |
+
/>
|
| 181 |
+
{!draft ? (
|
| 182 |
+
<div className="border-t border-line px-5 pt-2">
|
| 183 |
+
<SuggestionHint id="score-suggestion-hint" show />
|
| 184 |
+
</div>
|
| 185 |
+
) : null}
|
| 186 |
+
</>
|
| 187 |
+
)}
|
| 188 |
+
|
| 189 |
+
<StaffPreview abc={draft.trim() || ABC_SKELETON} />
|
| 190 |
+
|
| 191 |
<footer className="flex items-center justify-between gap-3 border-t border-line px-5 py-3.5">
|
| 192 |
<button
|
| 193 |
type="button"
|
|
|
|
| 210 |
onClose();
|
| 211 |
}}
|
| 212 |
>
|
| 213 |
+
Use for the next take
|
| 214 |
</Button>
|
| 215 |
</footer>
|
| 216 |
</motion.aside>
|
src/components/staff-preview.tsx
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useRef, useState } from "react";
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Engraved notation, rendered from the ABC text by abcjs.
|
| 7 |
+
*
|
| 8 |
+
* It renders the *text*, not the note model, which is the point: if the grid
|
| 9 |
+
* ever wrote something the text does not say, the two panes would disagree on
|
| 10 |
+
* screen and the bug would be visible rather than silent.
|
| 11 |
+
*
|
| 12 |
+
* abcjs is loaded on demand. It is the largest dependency here and nothing
|
| 13 |
+
* needs it until the score panel is actually opened.
|
| 14 |
+
*/
|
| 15 |
+
export function StaffPreview({ abc }: { abc: string }) {
|
| 16 |
+
const host = useRef<HTMLDivElement>(null);
|
| 17 |
+
const [error, setError] = useState<string | null>(null);
|
| 18 |
+
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
let cancelled = false;
|
| 21 |
+
|
| 22 |
+
if (!abc.trim()) {
|
| 23 |
+
if (host.current) host.current.innerHTML = "";
|
| 24 |
+
setError(null);
|
| 25 |
+
return;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
import("abcjs")
|
| 29 |
+
.then((abcjs) => {
|
| 30 |
+
if (cancelled || !host.current) return;
|
| 31 |
+
try {
|
| 32 |
+
abcjs.renderAbc(host.current, abc, {
|
| 33 |
+
// Not responsive: "resize". That mode absolutely positions the
|
| 34 |
+
// engraved svg, which then escapes this container and lies on top
|
| 35 |
+
// of the note grid, swallowing every click meant for a note. A
|
| 36 |
+
// fixed staff width keeps the drawing in normal flow.
|
| 37 |
+
staffwidth: 700,
|
| 38 |
+
paddingtop: 8,
|
| 39 |
+
paddingbottom: 8,
|
| 40 |
+
paddingleft: 0,
|
| 41 |
+
paddingright: 0,
|
| 42 |
+
// The engraver draws in currentColor, so the panel's own ink colour
|
| 43 |
+
// carries through instead of abcjs's default black on black.
|
| 44 |
+
foregroundColor: "currentColor",
|
| 45 |
+
});
|
| 46 |
+
setError(null);
|
| 47 |
+
} catch (caught) {
|
| 48 |
+
setError(caught instanceof Error ? caught.message : "This score could not be engraved");
|
| 49 |
+
}
|
| 50 |
+
})
|
| 51 |
+
.catch(() => {
|
| 52 |
+
if (!cancelled) setError("The notation renderer could not be loaded");
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
return () => {
|
| 56 |
+
cancelled = true;
|
| 57 |
+
};
|
| 58 |
+
}, [abc]);
|
| 59 |
+
|
| 60 |
+
return (
|
| 61 |
+
<div className="shrink-0 border-t border-line bg-panel px-5 py-3">
|
| 62 |
+
<p className="mb-2 text-[11px] tracking-[0.06em] text-ink-dim uppercase">Notation</p>
|
| 63 |
+
{error ? (
|
| 64 |
+
<p className="text-[12px] text-alarm">{error}</p>
|
| 65 |
+
) : (
|
| 66 |
+
<div ref={host} className="console-scroll relative max-h-[170px] overflow-auto text-ink" />
|
| 67 |
+
)}
|
| 68 |
+
</div>
|
| 69 |
+
);
|
| 70 |
+
}
|
src/lib/abc/model.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* The note model behind the piano roll.
|
| 3 |
+
*
|
| 4 |
+
* Time is counted in *units*, where one unit is the tune's L: default note
|
| 5 |
+
* length. That is the same currency ABC itself writes lengths in, so a note
|
| 6 |
+
* written `A2` under `L:1/8` is two units long and needs no conversion in
|
| 7 |
+
* either direction. Using seconds or beats here would mean rounding on every
|
| 8 |
+
* round trip, and rounding is how an edited score slowly drifts out of time.
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
export interface AbcNote {
|
| 12 |
+
id: string;
|
| 13 |
+
/** Index into AbcScore.voices. */
|
| 14 |
+
voice: number;
|
| 15 |
+
/** Onset in units from the start of the piece. */
|
| 16 |
+
start: number;
|
| 17 |
+
/** Length in units. Always at least 1. */
|
| 18 |
+
duration: number;
|
| 19 |
+
/** MIDI note number. Rests are held separately, so this is always a pitch. */
|
| 20 |
+
midi: number;
|
| 21 |
+
/** True when this note is tied into the one that follows it. */
|
| 22 |
+
tied: boolean;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export interface AbcVoice {
|
| 26 |
+
/** The V: identifier as written, e.g. "Vocal". */
|
| 27 |
+
id: string;
|
| 28 |
+
/** Anything after the identifier on the V: line, kept so it survives a rewrite. */
|
| 29 |
+
rest: string;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export interface AbcScore {
|
| 33 |
+
/** Header lines above the first voice, verbatim and in order. */
|
| 34 |
+
header: string[];
|
| 35 |
+
voices: AbcVoice[];
|
| 36 |
+
notes: AbcNote[];
|
| 37 |
+
/** Meter numerator and denominator from M:, defaulting to 4/4. */
|
| 38 |
+
meter: [number, number];
|
| 39 |
+
/** Unit note length from L:, defaulting to 1/8. */
|
| 40 |
+
unit: [number, number];
|
| 41 |
+
/** Key signature from K:, used by the grid to shade the scale. */
|
| 42 |
+
key: string;
|
| 43 |
+
/** True when a repeat was written out so the grid could show it. */
|
| 44 |
+
repeatsExpanded: boolean;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/** Why a score could not be opened in the grid. */
|
| 48 |
+
export interface AbcParseFailure {
|
| 49 |
+
ok: false;
|
| 50 |
+
reason: string;
|
| 51 |
+
/** The construct that stopped us, so the message can name it. */
|
| 52 |
+
offending: string | null;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export interface AbcParseSuccess {
|
| 56 |
+
ok: true;
|
| 57 |
+
score: AbcScore;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
export type AbcParseResult = AbcParseSuccess | AbcParseFailure;
|
| 61 |
+
|
| 62 |
+
/** How many units fill one bar, given the meter and unit length. */
|
| 63 |
+
export function unitsPerBar(score: Pick<AbcScore, "meter" | "unit">): number {
|
| 64 |
+
const [mn, md] = score.meter;
|
| 65 |
+
const [un, ud] = score.unit;
|
| 66 |
+
const bars = (mn / md) / (un / ud);
|
| 67 |
+
// A meter that does not divide evenly into the unit length is legal ABC but
|
| 68 |
+
// makes a ragged grid, so it is rounded rather than left fractional.
|
| 69 |
+
return Math.max(1, Math.round(bars));
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
export function noteName(midi: number): string {
|
| 73 |
+
const names = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
| 74 |
+
return `${names[((midi % 12) + 12) % 12]}${Math.floor(midi / 12) - 1}`;
|
| 75 |
+
}
|
src/lib/abc/parse.ts
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { AbcNote, AbcParseResult, AbcScore, AbcVoice } from "@/lib/abc/model";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Reads the ABC dialect YuE2 and SheetSage2 actually emit.
|
| 5 |
+
*
|
| 6 |
+
* This is deliberately not a general ABC parser. It covers notes, rests,
|
| 7 |
+
* chords, ties, accidentals, octaves, bar lines and repeats, and it *refuses*
|
| 8 |
+
* anything else. Refusing matters more than coverage here: the grid writes the
|
| 9 |
+
* score back out, so a construct it silently drops is a construct the operator
|
| 10 |
+
* loses. When this returns a failure the panel keeps the text editor and says
|
| 11 |
+
* which construct stopped it, which is recoverable. Quietly deleting a tuplet
|
| 12 |
+
* is not.
|
| 13 |
+
*/
|
| 14 |
+
|
| 15 |
+
/** Semitone offset of each natural within an octave. */
|
| 16 |
+
const STEP: Record<string, number> = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
|
| 17 |
+
|
| 18 |
+
/** Sharps and flats a key signature applies, by letter. */
|
| 19 |
+
const KEY_SIGNATURES: Record<string, string[]> = {
|
| 20 |
+
C: [], G: ["F"], D: ["F", "C"], A: ["F", "C", "G"], E: ["F", "C", "G", "D"],
|
| 21 |
+
B: ["F", "C", "G", "D", "A"], "F#": ["F", "C", "G", "D", "A", "E"],
|
| 22 |
+
F: ["B"], Bb: ["B", "E"], Eb: ["B", "E", "A"], Ab: ["B", "E", "A", "D"],
|
| 23 |
+
Db: ["B", "E", "A", "D", "G"], Gb: ["B", "E", "A", "D", "G", "C"],
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
const UNSUPPORTED: { pattern: RegExp; name: string }[] = [
|
| 27 |
+
{ pattern: /\(\d/, name: "a tuplet" },
|
| 28 |
+
{ pattern: /\{/, name: "a grace note" },
|
| 29 |
+
{ pattern: /![^!\n]*!/, name: "a decoration" },
|
| 30 |
+
{ pattern: /\+[^+\n]*\+/, name: "a decoration" },
|
| 31 |
+
{ pattern: /"[^"\n]*"/, name: "a chord symbol or annotation" },
|
| 32 |
+
];
|
| 33 |
+
|
| 34 |
+
interface Cursor {
|
| 35 |
+
text: string;
|
| 36 |
+
at: number;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
export function parseAbc(source: string): AbcParseResult {
|
| 40 |
+
const lines = source.replace(/\r\n?/g, "\n").split("\n");
|
| 41 |
+
|
| 42 |
+
const header: string[] = [];
|
| 43 |
+
const voices: AbcVoice[] = [];
|
| 44 |
+
const bodyByVoice = new Map<number, string[]>();
|
| 45 |
+
|
| 46 |
+
let meter: [number, number] = [4, 4];
|
| 47 |
+
let unit: [number, number] | null = null;
|
| 48 |
+
let key = "C";
|
| 49 |
+
let current = -1;
|
| 50 |
+
let sawVoiceLine = false;
|
| 51 |
+
|
| 52 |
+
for (const raw of lines) {
|
| 53 |
+
const line = raw.replace(/%.*$/, "").trimEnd();
|
| 54 |
+
if (!line.trim()) continue;
|
| 55 |
+
|
| 56 |
+
const field = /^([A-Za-z]):\s*(.*)$/.exec(line);
|
| 57 |
+
if (field) {
|
| 58 |
+
const [, tag, value] = field;
|
| 59 |
+
|
| 60 |
+
if (tag === "V") {
|
| 61 |
+
sawVoiceLine = true;
|
| 62 |
+
const [id, ...rest] = value.trim().split(/\s+/);
|
| 63 |
+
const existing = voices.findIndex((v) => v.id === id);
|
| 64 |
+
if (existing >= 0) {
|
| 65 |
+
current = existing;
|
| 66 |
+
} else {
|
| 67 |
+
voices.push({ id: id || `V${voices.length + 1}`, rest: rest.join(" ") });
|
| 68 |
+
current = voices.length - 1;
|
| 69 |
+
bodyByVoice.set(current, []);
|
| 70 |
+
}
|
| 71 |
+
continue;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
if (tag === "M") {
|
| 75 |
+
const m = /^(\d+)\s*\/\s*(\d+)$/.exec(value.trim());
|
| 76 |
+
if (m) meter = [Number(m[1]), Number(m[2])];
|
| 77 |
+
else if (value.trim() === "C") meter = [4, 4];
|
| 78 |
+
else if (value.trim() === "C|") meter = [2, 2];
|
| 79 |
+
}
|
| 80 |
+
if (tag === "L") {
|
| 81 |
+
const m = /^(\d+)\s*\/\s*(\d+)$/.exec(value.trim());
|
| 82 |
+
if (m) unit = [Number(m[1]), Number(m[2])];
|
| 83 |
+
}
|
| 84 |
+
if (tag === "K") key = value.trim() || "C";
|
| 85 |
+
|
| 86 |
+
// Header fields stay verbatim so a rewrite cannot reword the tune.
|
| 87 |
+
if (!sawVoiceLine) header.push(line);
|
| 88 |
+
continue;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
if (current < 0) {
|
| 92 |
+
// Music before any V: line is a single implicit voice.
|
| 93 |
+
voices.push({ id: "1", rest: "" });
|
| 94 |
+
current = 0;
|
| 95 |
+
bodyByVoice.set(0, []);
|
| 96 |
+
}
|
| 97 |
+
bodyByVoice.get(current)!.push(line);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
if (!voices.length) {
|
| 101 |
+
return { ok: false, reason: "There is no music in this score yet.", offending: null };
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
// L: is optional in ABC. Its default depends on the meter: short meters get
|
| 105 |
+
// a sixteenth, everything else an eighth.
|
| 106 |
+
if (!unit) unit = meter[0] / meter[1] < 0.75 ? [1, 16] : [1, 8];
|
| 107 |
+
|
| 108 |
+
const notes: AbcNote[] = [];
|
| 109 |
+
let repeatsExpanded = false;
|
| 110 |
+
let seq = 0;
|
| 111 |
+
|
| 112 |
+
for (const [voiceIndex, body] of bodyByVoice) {
|
| 113 |
+
const text = body.join(" ");
|
| 114 |
+
|
| 115 |
+
for (const { pattern, name } of UNSUPPORTED) {
|
| 116 |
+
const hit = pattern.exec(text);
|
| 117 |
+
if (hit) {
|
| 118 |
+
return {
|
| 119 |
+
ok: false,
|
| 120 |
+
reason: `This score contains ${name}, which the grid cannot draw without losing it.`,
|
| 121 |
+
offending: hit[0],
|
| 122 |
+
};
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
const read = readVoice(text, key);
|
| 127 |
+
if (!read.ok) return read;
|
| 128 |
+
if (read.repeated) repeatsExpanded = true;
|
| 129 |
+
|
| 130 |
+
for (const event of read.events) {
|
| 131 |
+
notes.push({
|
| 132 |
+
id: `n${seq++}`,
|
| 133 |
+
voice: voiceIndex,
|
| 134 |
+
start: event.start,
|
| 135 |
+
duration: event.duration,
|
| 136 |
+
midi: event.midi,
|
| 137 |
+
tied: event.tied,
|
| 138 |
+
});
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
notes.sort((a, b) => a.start - b.start || a.midi - b.midi);
|
| 143 |
+
|
| 144 |
+
const score: AbcScore = { header, voices, notes, meter, unit, key, repeatsExpanded };
|
| 145 |
+
return { ok: true, score };
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
interface Event {
|
| 149 |
+
start: number;
|
| 150 |
+
duration: number;
|
| 151 |
+
midi: number;
|
| 152 |
+
tied: boolean;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
interface VoiceOk {
|
| 156 |
+
ok: true;
|
| 157 |
+
events: Event[];
|
| 158 |
+
repeated: boolean;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
function readVoice(text: string, key: string): VoiceOk | Extract<AbcParseResult, { ok: false }> {
|
| 162 |
+
const cursor: Cursor = { text, at: 0 };
|
| 163 |
+
const events: Event[] = [];
|
| 164 |
+
|
| 165 |
+
// A repeated section has to be written out, because the grid shows time as
|
| 166 |
+
// one straight line and cannot draw "go back and play that again".
|
| 167 |
+
let sectionStart = 0;
|
| 168 |
+
let sectionEventStart = 0;
|
| 169 |
+
let repeated = false;
|
| 170 |
+
|
| 171 |
+
let time = 0;
|
| 172 |
+
// Accidentals last to the end of the bar in ABC, exactly as in notation.
|
| 173 |
+
let barAccidentals: Record<string, number> = {};
|
| 174 |
+
const signature = accidentalsFor(key);
|
| 175 |
+
|
| 176 |
+
const pushTiedOrNew = (event: Event) => {
|
| 177 |
+
const previous = events[events.length - 1];
|
| 178 |
+
if (previous && previous.tied && previous.midi === event.midi) {
|
| 179 |
+
previous.duration += event.duration;
|
| 180 |
+
previous.tied = event.tied;
|
| 181 |
+
return;
|
| 182 |
+
}
|
| 183 |
+
events.push(event);
|
| 184 |
+
};
|
| 185 |
+
|
| 186 |
+
while (cursor.at < cursor.text.length) {
|
| 187 |
+
const ch = cursor.text[cursor.at];
|
| 188 |
+
|
| 189 |
+
if (ch === " " || ch === "\t") {
|
| 190 |
+
cursor.at++;
|
| 191 |
+
continue;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
// Bar lines, in the order that matters: two-character forms first.
|
| 195 |
+
const two = cursor.text.slice(cursor.at, cursor.at + 2);
|
| 196 |
+
if (two === ":|" || two === "::") {
|
| 197 |
+
const from = events.length;
|
| 198 |
+
// Replay everything since the section opened.
|
| 199 |
+
const span = time - sectionStart;
|
| 200 |
+
for (let i = sectionEventStart; i < from; i++) {
|
| 201 |
+
const source = events[i];
|
| 202 |
+
events.push({ ...source, start: source.start + span });
|
| 203 |
+
}
|
| 204 |
+
if (from > sectionEventStart) repeated = true;
|
| 205 |
+
time += span;
|
| 206 |
+
sectionStart = time;
|
| 207 |
+
sectionEventStart = events.length;
|
| 208 |
+
cursor.at += 2;
|
| 209 |
+
barAccidentals = {};
|
| 210 |
+
continue;
|
| 211 |
+
}
|
| 212 |
+
if (two === "|:" || two === "||" || two === "|]" || two === "[|") {
|
| 213 |
+
sectionStart = time;
|
| 214 |
+
sectionEventStart = events.length;
|
| 215 |
+
cursor.at += 2;
|
| 216 |
+
barAccidentals = {};
|
| 217 |
+
continue;
|
| 218 |
+
}
|
| 219 |
+
if (ch === "|") {
|
| 220 |
+
cursor.at++;
|
| 221 |
+
barAccidentals = {};
|
| 222 |
+
continue;
|
| 223 |
+
}
|
| 224 |
+
if (ch === ":") {
|
| 225 |
+
cursor.at++;
|
| 226 |
+
continue;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
// Slur parentheses carry no duration. Tuplets were rejected earlier, so a
|
| 230 |
+
// bare "(" here is safe to skip.
|
| 231 |
+
if (ch === "(" || ch === ")") {
|
| 232 |
+
cursor.at++;
|
| 233 |
+
continue;
|
| 234 |
+
}
|
| 235 |
+
if (ch === "-") {
|
| 236 |
+
const previous = events[events.length - 1];
|
| 237 |
+
if (previous) previous.tied = true;
|
| 238 |
+
cursor.at++;
|
| 239 |
+
continue;
|
| 240 |
+
}
|
| 241 |
+
if (ch === "$" || ch === "\\") {
|
| 242 |
+
cursor.at++;
|
| 243 |
+
continue;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
// A chord: every note inside shares one onset and one length.
|
| 247 |
+
if (ch === "[") {
|
| 248 |
+
const close = cursor.text.indexOf("]", cursor.at);
|
| 249 |
+
if (close < 0) {
|
| 250 |
+
return { ok: false, reason: "A chord bracket is never closed.", offending: "[" };
|
| 251 |
+
}
|
| 252 |
+
const inner = cursor.text.slice(cursor.at + 1, close);
|
| 253 |
+
cursor.at = close + 1;
|
| 254 |
+
const trailing = readLength(cursor);
|
| 255 |
+
|
| 256 |
+
const chordCursor: Cursor = { text: inner, at: 0 };
|
| 257 |
+
let chordLength = 1;
|
| 258 |
+
const pitches: number[] = [];
|
| 259 |
+
while (chordCursor.at < inner.length) {
|
| 260 |
+
if (/\s/.test(inner[chordCursor.at])) {
|
| 261 |
+
chordCursor.at++;
|
| 262 |
+
continue;
|
| 263 |
+
}
|
| 264 |
+
const note = readNote(chordCursor, barAccidentals, signature);
|
| 265 |
+
if (!note) {
|
| 266 |
+
return { ok: false, reason: "A chord contains something that is not a note.", offending: inner };
|
| 267 |
+
}
|
| 268 |
+
if (note.midi !== null) pitches.push(note.midi);
|
| 269 |
+
chordLength = note.length;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
const duration = chordLength * trailing;
|
| 273 |
+
for (const midi of pitches) {
|
| 274 |
+
pushTiedOrNew({ start: time, duration, midi, tied: false });
|
| 275 |
+
}
|
| 276 |
+
time += duration;
|
| 277 |
+
continue;
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
const note = readNote(cursor, barAccidentals, signature);
|
| 281 |
+
if (note) {
|
| 282 |
+
if (note.midi === null) {
|
| 283 |
+
time += note.length; // a rest
|
| 284 |
+
} else {
|
| 285 |
+
pushTiedOrNew({ start: time, duration: note.length, midi: note.midi, tied: false });
|
| 286 |
+
time += note.length;
|
| 287 |
+
}
|
| 288 |
+
continue;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
return {
|
| 292 |
+
ok: false,
|
| 293 |
+
reason: "This score has notation the grid does not understand.",
|
| 294 |
+
offending: cursor.text.slice(cursor.at, cursor.at + 8).trim() || "?",
|
| 295 |
+
};
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
return { ok: true, events, repeated };
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
interface ReadNote {
|
| 302 |
+
midi: number | null;
|
| 303 |
+
length: number;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
function readNote(
|
| 307 |
+
cursor: Cursor,
|
| 308 |
+
barAccidentals: Record<string, number>,
|
| 309 |
+
signature: Record<string, number>,
|
| 310 |
+
): ReadNote | null {
|
| 311 |
+
const start = cursor.at;
|
| 312 |
+
let accidental: number | null = null;
|
| 313 |
+
|
| 314 |
+
while (cursor.at < cursor.text.length) {
|
| 315 |
+
const ch = cursor.text[cursor.at];
|
| 316 |
+
if (ch === "^") {
|
| 317 |
+
accidental = (accidental ?? 0) + 1;
|
| 318 |
+
cursor.at++;
|
| 319 |
+
} else if (ch === "_") {
|
| 320 |
+
accidental = (accidental ?? 0) - 1;
|
| 321 |
+
cursor.at++;
|
| 322 |
+
} else if (ch === "=") {
|
| 323 |
+
accidental = 0;
|
| 324 |
+
cursor.at++;
|
| 325 |
+
} else {
|
| 326 |
+
break;
|
| 327 |
+
}
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
const letter = cursor.text[cursor.at];
|
| 331 |
+
if (!letter || !/[A-Ga-gzZxX]/.test(letter)) {
|
| 332 |
+
cursor.at = start;
|
| 333 |
+
return null;
|
| 334 |
+
}
|
| 335 |
+
cursor.at++;
|
| 336 |
+
|
| 337 |
+
if (/[zZxX]/.test(letter)) {
|
| 338 |
+
return { midi: null, length: readLength(cursor) };
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
// Lower case is the octave above upper case, then , and ' shift further.
|
| 342 |
+
const upper = letter.toUpperCase();
|
| 343 |
+
let octave = letter === upper ? 4 : 5;
|
| 344 |
+
while (cursor.at < cursor.text.length) {
|
| 345 |
+
const ch = cursor.text[cursor.at];
|
| 346 |
+
if (ch === ",") {
|
| 347 |
+
octave--;
|
| 348 |
+
cursor.at++;
|
| 349 |
+
} else if (ch === "'") {
|
| 350 |
+
octave++;
|
| 351 |
+
cursor.at++;
|
| 352 |
+
} else {
|
| 353 |
+
break;
|
| 354 |
+
}
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
const slot = `${upper}${octave}`;
|
| 358 |
+
if (accidental !== null) barAccidentals[slot] = accidental;
|
| 359 |
+
const applied = slot in barAccidentals ? barAccidentals[slot] : (signature[upper] ?? 0);
|
| 360 |
+
|
| 361 |
+
const midi = (octave + 1) * 12 + STEP[upper] + applied;
|
| 362 |
+
return { midi, length: readLength(cursor) };
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
/** Reads the multiplier after a note: `2`, `/2`, `/`, `3/2`, or nothing. */
|
| 366 |
+
function readLength(cursor: Cursor): number {
|
| 367 |
+
let numerator = "";
|
| 368 |
+
while (/\d/.test(cursor.text[cursor.at] ?? "")) numerator += cursor.text[cursor.at++];
|
| 369 |
+
|
| 370 |
+
let denominator = "";
|
| 371 |
+
let slashes = 0;
|
| 372 |
+
while (cursor.text[cursor.at] === "/") {
|
| 373 |
+
slashes++;
|
| 374 |
+
cursor.at++;
|
| 375 |
+
}
|
| 376 |
+
if (slashes) {
|
| 377 |
+
while (/\d/.test(cursor.text[cursor.at] ?? "")) denominator += cursor.text[cursor.at++];
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
const top = numerator ? Number(numerator) : 1;
|
| 381 |
+
// `/` alone halves, `//` quarters, and so on.
|
| 382 |
+
const bottom = denominator ? Number(denominator) : slashes ? 2 ** slashes : 1;
|
| 383 |
+
const value = top / bottom;
|
| 384 |
+
return Number.isFinite(value) && value > 0 ? value : 1;
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
export function accidentalsFor(key: string): Record<string, number> {
|
| 388 |
+
const match = /^([A-G][#b]?)/.exec(key.trim());
|
| 389 |
+
const root = match ? match[1] : "C";
|
| 390 |
+
const minor = /m(?!aj)/i.test(key) && !/major/i.test(key);
|
| 391 |
+
// A minor key carries the signature of its relative major.
|
| 392 |
+
const relative = (minor ? RELATIVE_MAJOR[root] : undefined) ?? root;
|
| 393 |
+
const letters = KEY_SIGNATURES[relative] ?? [];
|
| 394 |
+
const sharp = !relative.includes("b") && relative !== "F";
|
| 395 |
+
const out: Record<string, number> = {};
|
| 396 |
+
for (const letter of letters) out[letter] = sharp ? 1 : -1;
|
| 397 |
+
return out;
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
/** A minor key carries the signature of its relative major. */
|
| 401 |
+
const RELATIVE_MAJOR: Record<string, string> = {
|
| 402 |
+
A: "C", E: "G", B: "D", "F#": "A", "C#": "E", D: "F", G: "Bb", C: "Eb", F: "Ab", Bb: "Db",
|
| 403 |
+
};
|
src/lib/abc/serialize.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { AbcNote, AbcScore } from "@/lib/abc/model";
|
| 2 |
+
import { unitsPerBar } from "@/lib/abc/model";
|
| 3 |
+
import { accidentalsFor } from "@/lib/abc/parse";
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Writes the note model back out as ABC.
|
| 7 |
+
*
|
| 8 |
+
* Headers come through verbatim, so a rewrite can never reword the tune's
|
| 9 |
+
* title, meter or key. Only the music is regenerated, and only from what the
|
| 10 |
+
* grid actually holds.
|
| 11 |
+
*
|
| 12 |
+
* Simultaneous notes in one voice are emitted as chords, rests fill the gaps,
|
| 13 |
+
* and a note crossing a bar line is tied across it rather than being truncated
|
| 14 |
+
* or allowed to run past the barline, either of which would change the music.
|
| 15 |
+
*/
|
| 16 |
+
export function serializeAbc(score: AbcScore): string {
|
| 17 |
+
const bar = unitsPerBar(score);
|
| 18 |
+
const lines = [...score.header];
|
| 19 |
+
|
| 20 |
+
for (let voice = 0; voice < score.voices.length; voice++) {
|
| 21 |
+
const meta = score.voices[voice];
|
| 22 |
+
lines.push(`V:${meta.id}${meta.rest ? ` ${meta.rest}` : ""}`);
|
| 23 |
+
|
| 24 |
+
const notes = score.notes
|
| 25 |
+
.filter((note) => note.voice === voice)
|
| 26 |
+
.sort((a, b) => a.start - b.start || a.midi - b.midi);
|
| 27 |
+
|
| 28 |
+
lines.push(...voiceLines(notes, bar, score.key));
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
return lines.join("\n");
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function voiceLines(notes: AbcNote[], bar: number, key: string): string[] {
|
| 35 |
+
if (!notes.length) return ["z8 |"];
|
| 36 |
+
|
| 37 |
+
// Accidentals carry to the end of the bar, so writing one changes how every
|
| 38 |
+
// later note on that pitch reads. The serializer has to track exactly what
|
| 39 |
+
// the parser will track, or a natural written after a sharp comes back as a
|
| 40 |
+
// sharp. The fuzz test found this; the fixed cases never did, because they
|
| 41 |
+
// happened not to put a natural after an accidental in one bar.
|
| 42 |
+
const signature = accidentalsFor(key);
|
| 43 |
+
let barAccidentals: Record<string, number> = {};
|
| 44 |
+
|
| 45 |
+
// Group by onset so a chord is written once rather than as stacked notes.
|
| 46 |
+
const byStart = new Map<number, AbcNote[]>();
|
| 47 |
+
for (const note of notes) {
|
| 48 |
+
const list = byStart.get(note.start);
|
| 49 |
+
if (list) list.push(note);
|
| 50 |
+
else byStart.set(note.start, [note]);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const onsets = [...byStart.keys()].sort((a, b) => a - b);
|
| 54 |
+
const end = Math.max(...notes.map((note) => note.start + note.duration));
|
| 55 |
+
const totalBars = Math.max(1, Math.ceil(end / bar));
|
| 56 |
+
|
| 57 |
+
const out: string[] = [];
|
| 58 |
+
let tokens: string[] = [];
|
| 59 |
+
let cursor = 0;
|
| 60 |
+
let index = 0;
|
| 61 |
+
|
| 62 |
+
for (let barIndex = 0; barIndex < totalBars; barIndex++) {
|
| 63 |
+
const barEnd = (barIndex + 1) * bar;
|
| 64 |
+
const parts: string[] = [];
|
| 65 |
+
barAccidentals = {};
|
| 66 |
+
|
| 67 |
+
while (index < onsets.length && onsets[index] < barEnd) {
|
| 68 |
+
const start = onsets[index];
|
| 69 |
+
|
| 70 |
+
if (start > cursor) {
|
| 71 |
+
parts.push(`z${lengthToken(start - cursor)}`);
|
| 72 |
+
cursor = start;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
const group = byStart.get(start)!;
|
| 76 |
+
// A chord's length is its shortest member, so nothing in it overruns.
|
| 77 |
+
const want = Math.min(...group.map((note) => note.duration));
|
| 78 |
+
// Split at the bar line and tie, so the written bar still adds up.
|
| 79 |
+
const fits = Math.min(want, barEnd - start);
|
| 80 |
+
|
| 81 |
+
parts.push(chordToken(group, fits, key, barAccidentals, signature) + (fits < want ? "-" : ""));
|
| 82 |
+
cursor = start + fits;
|
| 83 |
+
|
| 84 |
+
if (fits < want) {
|
| 85 |
+
// The remainder becomes a fresh onset in the next bar.
|
| 86 |
+
const remainder = group.map((note) => ({ ...note, start: cursor, duration: want - fits }));
|
| 87 |
+
const existing = byStart.get(cursor);
|
| 88 |
+
if (existing) existing.push(...remainder);
|
| 89 |
+
else {
|
| 90 |
+
byStart.set(cursor, remainder);
|
| 91 |
+
onsets.splice(index + 1, 0, cursor);
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
index++;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
if (cursor < barEnd && barIndex < totalBars) {
|
| 98 |
+
const rest = barEnd - cursor;
|
| 99 |
+
// Trailing silence past the last note is not worth writing.
|
| 100 |
+
if (index < onsets.length || barIndex < totalBars - 1) {
|
| 101 |
+
parts.push(`z${lengthToken(rest)}`);
|
| 102 |
+
cursor = barEnd;
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
if (parts.length) tokens.push(parts.join(" "), "|");
|
| 107 |
+
|
| 108 |
+
// Four bars to a line keeps the text readable next to the grid.
|
| 109 |
+
if ((barIndex + 1) % 4 === 0 && tokens.length) {
|
| 110 |
+
out.push(tokens.join(" "));
|
| 111 |
+
tokens = [];
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
if (tokens.length) out.push(tokens.join(" "));
|
| 116 |
+
return out.length ? out : ["z8 |"];
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
function chordToken(
|
| 120 |
+
group: AbcNote[],
|
| 121 |
+
duration: number,
|
| 122 |
+
key: string,
|
| 123 |
+
barAccidentals: Record<string, number>,
|
| 124 |
+
signature: Record<string, number>,
|
| 125 |
+
): string {
|
| 126 |
+
const spell = (note: AbcNote) => pitchToken(note.midi, key, barAccidentals, signature);
|
| 127 |
+
if (group.length === 1) return spell(group[0]) + lengthToken(duration);
|
| 128 |
+
return `[${group.map(spell).join("")}]${lengthToken(duration)}`;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
const SHARP_SPELLING = ["C", "^C", "D", "^D", "E", "F", "^F", "G", "^G", "A", "^A", "B"];
|
| 132 |
+
const FLAT_SPELLING = ["C", "_D", "D", "_E", "E", "F", "_G", "G", "_A", "A", "_B", "B"];
|
| 133 |
+
|
| 134 |
+
/**
|
| 135 |
+
* Spells a MIDI note, writing an accidental whenever one is needed to be read
|
| 136 |
+
* back as this exact pitch.
|
| 137 |
+
*
|
| 138 |
+
* "Needed" covers three cases, and missing any of them silently transposes a
|
| 139 |
+
* note on the next parse: the pitch is a black key; the key signature already
|
| 140 |
+
* alters this letter and we want the natural; or an accidental earlier in this
|
| 141 |
+
* bar altered it and we want something else now. The last two both require an
|
| 142 |
+
* explicit natural sign, which is easy to forget because the note itself has
|
| 143 |
+
* no accidental in it.
|
| 144 |
+
*/
|
| 145 |
+
function pitchToken(
|
| 146 |
+
midi: number,
|
| 147 |
+
key: string,
|
| 148 |
+
barAccidentals: Record<string, number>,
|
| 149 |
+
signature: Record<string, number>,
|
| 150 |
+
): string {
|
| 151 |
+
const flats = /b/.test(key) || /^(F|Dm|Gm|Cm|Fm|Bbm|Ebm)\b/.test(key.trim());
|
| 152 |
+
const spelling = flats ? FLAT_SPELLING : SHARP_SPELLING;
|
| 153 |
+
const step = spelling[((midi % 12) + 12) % 12];
|
| 154 |
+
const letter = step.replace(/[\^_]/, "");
|
| 155 |
+
const wanted = step.startsWith("^") ? 1 : step.startsWith("_") ? -1 : 0;
|
| 156 |
+
const octave = Math.floor(midi / 12) - 1;
|
| 157 |
+
const slot = `${letter}${octave}`;
|
| 158 |
+
|
| 159 |
+
const inForce = slot in barAccidentals ? barAccidentals[slot] : (signature[letter] ?? 0);
|
| 160 |
+
|
| 161 |
+
let mark = "";
|
| 162 |
+
if (wanted !== inForce) {
|
| 163 |
+
mark = wanted === 1 ? "^" : wanted === -1 ? "_" : "=";
|
| 164 |
+
barAccidentals[slot] = wanted;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
// Lower case is the octave above upper case.
|
| 168 |
+
const body = octave >= 5 ? letter.toLowerCase() + "'".repeat(octave - 5) : letter + ",".repeat(Math.max(0, 4 - octave));
|
| 169 |
+
return mark + body;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
/** Writes a length in units the way ABC spells it. */
|
| 173 |
+
function lengthToken(units: number): string {
|
| 174 |
+
if (Math.abs(units - 1) < 1e-9) return "";
|
| 175 |
+
if (units >= 1 && Math.abs(units - Math.round(units)) < 1e-9) return String(Math.round(units));
|
| 176 |
+
|
| 177 |
+
// Halves and quarters have their own shorthand; anything else is a fraction.
|
| 178 |
+
const rounded = Math.round(units * 16) / 16;
|
| 179 |
+
if (Math.abs(rounded - 0.5) < 1e-9) return "/";
|
| 180 |
+
if (Math.abs(rounded - 0.25) < 1e-9) return "//";
|
| 181 |
+
|
| 182 |
+
for (let denominator = 2; denominator <= 16; denominator++) {
|
| 183 |
+
const numerator = rounded * denominator;
|
| 184 |
+
if (Math.abs(numerator - Math.round(numerator)) < 1e-9) {
|
| 185 |
+
const top = Math.round(numerator);
|
| 186 |
+
return top === 1 ? `/${denominator}` : `${top}/${denominator}`;
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
return String(Math.max(1, Math.round(units)));
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
/** Exposed for the round-trip tests only. */
|
| 193 |
+
export const __testables = { lengthToken, pitchToken };
|