RalphThings commited on
Commit
5c0b162
·
verified ·
1 Parent(s): 10a3361

Upload folder using huggingface_hub

Browse files
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-lyric-structure
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-[560px] flex-col border-l border-line bg-panel"
63
  >
64
- <header className="flex items-center justify-between border-b border-line px-5 py-3.5">
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
- ? "ABC notation. Edit it and render again to keep the take but change the tune."
70
- : "Paste ABC notation, or write your own. Whatever is here conditions the next render."}
71
  </p>
72
  </div>
73
- <button
74
- type="button"
75
- onClick={onClose}
76
- className="flex h-8 w-8 items-center justify-center rounded-console text-ink-dim transition-colors hover:bg-raised hover:text-ink"
77
- aria-label="Close"
78
- >
79
- <X size={15} weight="bold" />
80
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  </header>
82
 
83
- <textarea
84
- value={draft}
85
- onChange={(event) => setDraft(event.target.value)}
86
- onKeyDown={(event) => {
87
- if (acceptSuggestion(event, draft, ABC_SKELETON)) setDraft(ABC_SKELETON);
88
- }}
89
- spellCheck={false}
90
- placeholder={ABC_SKELETON}
91
- aria-describedby={draft ? undefined : "score-suggestion-hint"}
92
- 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"
93
- />
94
- {!draft ? (
95
- <div className="border-t border-line px-5 pt-2">
96
- <SuggestionHint id="score-suggestion-hint" show />
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 this score
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 };