@real-music-packages/web-core 0.36.1 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-JVSXE4X3.js → chunk-4565POLG.js} +13 -6
- package/dist/chunk-4565POLG.js.map +1 -0
- package/dist/{notationGeometry-CZJ0U6PQ.d.ts → notationGeometry-CyYXJrUH.d.ts} +11 -0
- package/dist/notationPlayer.d.ts +144 -22
- package/dist/notationPlayer.js +278 -18
- package/dist/notationPlayer.js.map +1 -1
- package/dist/scene/index.d.ts +25 -2
- package/dist/scene/index.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-JVSXE4X3.js.map +0 -1
package/dist/notationPlayer.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
|
+
audioPlayheadLine,
|
|
3
|
+
distinctOnsets,
|
|
2
4
|
getNotationEngraving,
|
|
3
5
|
measureColumnsFromLayout,
|
|
6
|
+
measureCount,
|
|
4
7
|
notationFactory,
|
|
5
|
-
|
|
6
|
-
|
|
8
|
+
notationLayout,
|
|
9
|
+
scrollCursorFactory,
|
|
10
|
+
vstackAudioPlayheadLine
|
|
11
|
+
} from "./chunk-4565POLG.js";
|
|
7
12
|
import {
|
|
8
13
|
safeBox
|
|
9
14
|
} from "./chunk-HXTRNE74.js";
|
|
@@ -58,7 +63,55 @@ function hitTestMeasureAt(layout, mx, my) {
|
|
|
58
63
|
}
|
|
59
64
|
return nearest;
|
|
60
65
|
}
|
|
61
|
-
function
|
|
66
|
+
function resolveDpr() {
|
|
67
|
+
return typeof window !== "undefined" && window.devicePixelRatio ? window.devicePixelRatio : 1;
|
|
68
|
+
}
|
|
69
|
+
function frameSize(host, dpr, size) {
|
|
70
|
+
if (size) return size;
|
|
71
|
+
const w = Math.max(1, Math.round((host.clientWidth || 1) * dpr));
|
|
72
|
+
const h = Math.max(1, Math.round((host.clientHeight || 1) * dpr));
|
|
73
|
+
return [w, h];
|
|
74
|
+
}
|
|
75
|
+
var DEFAULT_ENGRAVE_WIDTH = 560;
|
|
76
|
+
var MAX_ENGRAVE_WIDTH = 1150;
|
|
77
|
+
function desiredEngraveWidth(host) {
|
|
78
|
+
return (host.clientWidth || 0) > DEFAULT_ENGRAVE_WIDTH ? MAX_ENGRAVE_WIDTH : DEFAULT_ENGRAVE_WIDTH;
|
|
79
|
+
}
|
|
80
|
+
function flowLayout(rn, dispWdev) {
|
|
81
|
+
const w = dispWdev > 0 ? dispWdev : 1;
|
|
82
|
+
const probe = notationLayout(rn, w, 1e9, 0, 1e9, { boxWidth: w });
|
|
83
|
+
const dh = probe.rect.dh;
|
|
84
|
+
return notationLayout(rn, w, dh, 0, dh, { boxWidth: w });
|
|
85
|
+
}
|
|
86
|
+
var SCROLL_TILE_TARGET_CSS_PX = 1600;
|
|
87
|
+
var SCROLL_TILE_MAX_AREA_PX = 8e6;
|
|
88
|
+
function computeScrollTiles(dispWdev, totalHeightDev, dpr, opts) {
|
|
89
|
+
const targetCssPx = opts?.targetTileCssPx ?? SCROLL_TILE_TARGET_CSS_PX;
|
|
90
|
+
const capPx2 = opts?.capPx2 ?? SCROLL_TILE_MAX_AREA_PX;
|
|
91
|
+
const safeDpr = dpr > 0 ? dpr : 1;
|
|
92
|
+
const w = dispWdev > 0 ? dispWdev : 1;
|
|
93
|
+
const total = totalHeightDev > 0 ? totalHeightDev : 0;
|
|
94
|
+
const desiredDev = targetCssPx * safeDpr;
|
|
95
|
+
const maxByArea = capPx2 / w;
|
|
96
|
+
const tileMax = Math.max(1, Math.min(desiredDev, maxByArea));
|
|
97
|
+
if (total <= tileMax) {
|
|
98
|
+
return { count: 1, heights: [total], offsets: [0], totalHeightDev: total };
|
|
99
|
+
}
|
|
100
|
+
const count = Math.max(1, Math.ceil(total / tileMax));
|
|
101
|
+
const even = total / count;
|
|
102
|
+
const heights = new Array(count).fill(even);
|
|
103
|
+
const offsets = new Array(count);
|
|
104
|
+
let y = 0;
|
|
105
|
+
for (let i = 0; i < count; i++) {
|
|
106
|
+
offsets[i] = y;
|
|
107
|
+
y += even;
|
|
108
|
+
}
|
|
109
|
+
return { count, heights, offsets, totalHeightDev: total };
|
|
110
|
+
}
|
|
111
|
+
function nowMs() {
|
|
112
|
+
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
113
|
+
}
|
|
114
|
+
function createWindowPlayer(opts) {
|
|
62
115
|
const { host, musicXml, onsetsMs, noteCols, barDurMs, bars } = opts;
|
|
63
116
|
const mode = opts.mode ?? "vstack";
|
|
64
117
|
const theme = { ...DEFAULT_THEME, ...opts.theme };
|
|
@@ -66,15 +119,10 @@ function createNotationPlayer(opts) {
|
|
|
66
119
|
canvas.style.display = "block";
|
|
67
120
|
canvas.style.width = "100%";
|
|
68
121
|
canvas.style.height = "100%";
|
|
122
|
+
canvas.style.touchAction = "pan-y";
|
|
69
123
|
host.appendChild(canvas);
|
|
70
|
-
const dpr =
|
|
71
|
-
|
|
72
|
-
if (opts.size) return opts.size;
|
|
73
|
-
const w = Math.max(1, Math.round((host.clientWidth || 1) * dpr));
|
|
74
|
-
const h = Math.max(1, Math.round((host.clientHeight || 1) * dpr));
|
|
75
|
-
return [w, h];
|
|
76
|
-
}
|
|
77
|
-
const [initW, initH] = frameSize();
|
|
124
|
+
const dpr = resolveDpr();
|
|
125
|
+
const [initW, initH] = frameSize(host, dpr, opts.size);
|
|
78
126
|
canvas.width = initW;
|
|
79
127
|
canvas.height = initH;
|
|
80
128
|
const ctx2d = canvas.getContext("2d");
|
|
@@ -93,20 +141,33 @@ function createNotationPlayer(opts) {
|
|
|
93
141
|
};
|
|
94
142
|
const notation = notationFactory.create();
|
|
95
143
|
const scrollCursor = scrollCursorFactory.create();
|
|
96
|
-
function notationProps(rendered) {
|
|
144
|
+
function notationProps(rendered, hostWidth) {
|
|
145
|
+
const widened = hostWidth > DEFAULT_ENGRAVE_WIDTH;
|
|
97
146
|
return {
|
|
98
147
|
...rendered ? { rendered } : { xml: musicXml },
|
|
99
148
|
scrollMode: mode,
|
|
100
149
|
bars,
|
|
101
150
|
bandTop: opts.bandTop ?? 0,
|
|
102
|
-
bandHeight: opts.bandHeight ?? rctx.H
|
|
151
|
+
bandHeight: opts.bandHeight ?? rctx.H,
|
|
152
|
+
hostWidth,
|
|
153
|
+
// Fit to (up to) the full canvas width instead of `notationLayout`'s
|
|
154
|
+
// default `safeBox(W,H).centeredW` — that default reserves ~24% of
|
|
155
|
+
// width for the PROMO video path's caption/share-button chrome (see
|
|
156
|
+
// `NotationLayoutOpts.boxWidth`'s doc comment), which doesn't apply to
|
|
157
|
+
// this plain, chrome-less player canvas. Capped at the same
|
|
158
|
+
// `MAX_ENGRAVE_WIDTH` (converted CSS px → device px) as the engrave
|
|
159
|
+
// width itself, so a very wide desktop host doesn't stretch systems
|
|
160
|
+
// past a readable size even though the canvas has room for it.
|
|
161
|
+
...widened ? { bandWidth: Math.min(MAX_ENGRAVE_WIDTH * dpr, rctx.W) } : {}
|
|
103
162
|
};
|
|
104
163
|
}
|
|
105
164
|
const scrollCursorProps = { scrollMode: mode, barDurMs, noteCols };
|
|
106
165
|
let renderedRn = null;
|
|
107
166
|
let destroyed = false;
|
|
167
|
+
let engravedHostWidthPx = 0;
|
|
108
168
|
const ready = (async () => {
|
|
109
|
-
|
|
169
|
+
engravedHostWidthPx = desiredEngraveWidth(host);
|
|
170
|
+
await notation.init(rctx, notationProps(opts.rendered, engravedHostWidthPx));
|
|
110
171
|
renderedRn = getNotationEngraving(rctx)?.rendered ?? null;
|
|
111
172
|
await scrollCursor.init(rctx, scrollCursorProps);
|
|
112
173
|
})();
|
|
@@ -125,15 +186,22 @@ function createNotationPlayer(opts) {
|
|
|
125
186
|
scrollCursor.draw(rctx, tMs);
|
|
126
187
|
}
|
|
127
188
|
async function performResize() {
|
|
128
|
-
const [w, h] = frameSize();
|
|
129
|
-
|
|
189
|
+
const [w, h] = frameSize(host, dpr, opts.size);
|
|
190
|
+
const newEngraveWidth = desiredEngraveWidth(host);
|
|
191
|
+
const needsReEngrave = !opts.rendered && renderedRn != null && newEngraveWidth !== engravedHostWidthPx;
|
|
192
|
+
if (w === canvas.width && h === canvas.height && !needsReEngrave) return;
|
|
130
193
|
canvas.width = w;
|
|
131
194
|
canvas.height = h;
|
|
132
195
|
rctx.W = w;
|
|
133
196
|
rctx.H = h;
|
|
134
197
|
rctx.safeBox = safeBox(w, h);
|
|
135
|
-
if (
|
|
136
|
-
|
|
198
|
+
if (needsReEngrave) {
|
|
199
|
+
engravedHostWidthPx = newEngraveWidth;
|
|
200
|
+
await notation.init(rctx, notationProps(void 0, engravedHostWidthPx));
|
|
201
|
+
renderedRn = getNotationEngraving(rctx)?.rendered ?? null;
|
|
202
|
+
await scrollCursor.init(rctx, scrollCursorProps);
|
|
203
|
+
} else if (renderedRn) {
|
|
204
|
+
await notation.init(rctx, notationProps(renderedRn, engravedHostWidthPx));
|
|
137
205
|
await scrollCursor.init(rctx, scrollCursorProps);
|
|
138
206
|
}
|
|
139
207
|
if (!destroyed) setTime(lastTMs);
|
|
@@ -179,8 +247,200 @@ function createNotationPlayer(opts) {
|
|
|
179
247
|
}
|
|
180
248
|
};
|
|
181
249
|
}
|
|
250
|
+
function createScrollPlayer(opts) {
|
|
251
|
+
const { host, musicXml, noteCols, barDurMs, bars } = opts;
|
|
252
|
+
const mode = opts.mode ?? "vstack";
|
|
253
|
+
const theme = { ...DEFAULT_THEME, ...opts.theme };
|
|
254
|
+
const dpr = resolveDpr();
|
|
255
|
+
const onsets = distinctOnsets(opts.onsetsMs.map((onsetMs) => ({ onsetMs })));
|
|
256
|
+
const root = document.createElement("div");
|
|
257
|
+
root.style.position = "relative";
|
|
258
|
+
root.style.width = "100%";
|
|
259
|
+
host.appendChild(root);
|
|
260
|
+
const playheadEl = document.createElement("div");
|
|
261
|
+
playheadEl.style.position = "absolute";
|
|
262
|
+
playheadEl.style.left = "0px";
|
|
263
|
+
playheadEl.style.top = "0px";
|
|
264
|
+
playheadEl.style.width = "2px";
|
|
265
|
+
playheadEl.style.height = "0px";
|
|
266
|
+
playheadEl.style.background = theme.accent;
|
|
267
|
+
playheadEl.style.opacity = "0";
|
|
268
|
+
playheadEl.style.pointerEvents = "none";
|
|
269
|
+
root.appendChild(playheadEl);
|
|
270
|
+
let rn = null;
|
|
271
|
+
let currentFlow = null;
|
|
272
|
+
let engravedHostWidthPx = 0;
|
|
273
|
+
let destroyed = false;
|
|
274
|
+
let lastTMs = 0;
|
|
275
|
+
const clickListeners = [];
|
|
276
|
+
const tileCanvases = [];
|
|
277
|
+
const tileClickHandlers = [];
|
|
278
|
+
async function doEngrave(hostWidthPx) {
|
|
279
|
+
if (opts.rendered) return opts.rendered;
|
|
280
|
+
const { renderNotation } = await import("./promo.js");
|
|
281
|
+
return renderNotation(musicXml, { bars, paper: theme.paper, scrollMode: mode, hostWidth: hostWidthPx });
|
|
282
|
+
}
|
|
283
|
+
function clearTiles() {
|
|
284
|
+
for (const { el, fn } of tileClickHandlers) el.removeEventListener("click", fn);
|
|
285
|
+
tileClickHandlers.length = 0;
|
|
286
|
+
for (const c of tileCanvases) c.remove();
|
|
287
|
+
tileCanvases.length = 0;
|
|
288
|
+
}
|
|
289
|
+
function handleTileClick(e, canvas, tileOffsetDev) {
|
|
290
|
+
if (!currentFlow) return;
|
|
291
|
+
const rect = canvas.getBoundingClientRect();
|
|
292
|
+
if (!(rect.width > 0) || !(rect.height > 0)) return;
|
|
293
|
+
const mx = (e.clientX - rect.left) * (canvas.width / rect.width);
|
|
294
|
+
const myLocal = (e.clientY - rect.top) * (canvas.height / rect.height);
|
|
295
|
+
const idx = hitTestMeasureAt(currentFlow, mx, tileOffsetDev + myLocal);
|
|
296
|
+
if (idx != null) for (const cb of clickListeners) cb(idx);
|
|
297
|
+
}
|
|
298
|
+
function buildTiles(flow, dispWdev) {
|
|
299
|
+
clearTiles();
|
|
300
|
+
if (!rn) return;
|
|
301
|
+
const spec = computeScrollTiles(dispWdev, flow.rect.dh, dpr);
|
|
302
|
+
const scale = flow.src.w > 0 ? flow.rect.dw / flow.src.w : 1;
|
|
303
|
+
for (let i = 0; i < spec.count; i++) {
|
|
304
|
+
const c = document.createElement("canvas");
|
|
305
|
+
c.style.display = "block";
|
|
306
|
+
c.style.width = "100%";
|
|
307
|
+
c.style.height = `${spec.heights[i] / dpr}px`;
|
|
308
|
+
c.style.touchAction = "pan-y";
|
|
309
|
+
c.width = Math.max(1, Math.round(dispWdev));
|
|
310
|
+
c.height = Math.max(1, Math.round(spec.heights[i]));
|
|
311
|
+
root.insertBefore(c, playheadEl);
|
|
312
|
+
tileCanvases.push(c);
|
|
313
|
+
const tileOffsetDev = spec.offsets[i];
|
|
314
|
+
const onClick = (e) => handleTileClick(e, c, tileOffsetDev);
|
|
315
|
+
c.addEventListener("click", onClick);
|
|
316
|
+
tileClickHandlers.push({ el: c, fn: onClick });
|
|
317
|
+
const cctx = c.getContext("2d");
|
|
318
|
+
if (cctx) {
|
|
319
|
+
cctx.fillStyle = theme.paper;
|
|
320
|
+
cctx.fillRect(0, 0, c.width, c.height);
|
|
321
|
+
const srcY = flow.src.y + tileOffsetDev / scale;
|
|
322
|
+
const srcH = spec.heights[i] / scale;
|
|
323
|
+
cctx.drawImage(rn.canvas, flow.src.x, srcY, flow.src.w, srcH, 0, 0, c.width, c.height);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function relayout() {
|
|
328
|
+
if (destroyed || !rn) return;
|
|
329
|
+
const [w] = frameSize(host, dpr, opts.size);
|
|
330
|
+
const flow = flowLayout(rn, w);
|
|
331
|
+
currentFlow = flow;
|
|
332
|
+
buildTiles(flow, w);
|
|
333
|
+
updatePlayhead(lastTMs);
|
|
334
|
+
}
|
|
335
|
+
const ready = (async () => {
|
|
336
|
+
engravedHostWidthPx = desiredEngraveWidth(host);
|
|
337
|
+
rn = await doEngrave(engravedHostWidthPx);
|
|
338
|
+
relayout();
|
|
339
|
+
})();
|
|
340
|
+
async function performResize() {
|
|
341
|
+
if (destroyed || !rn) return;
|
|
342
|
+
const newEngraveWidth = desiredEngraveWidth(host);
|
|
343
|
+
const needsReEngrave = !opts.rendered && newEngraveWidth !== engravedHostWidthPx;
|
|
344
|
+
if (needsReEngrave) {
|
|
345
|
+
engravedHostWidthPx = newEngraveWidth;
|
|
346
|
+
rn = await doEngrave(engravedHostWidthPx);
|
|
347
|
+
}
|
|
348
|
+
relayout();
|
|
349
|
+
}
|
|
350
|
+
const SEEK_DISCONTINUITY_MS = 400;
|
|
351
|
+
let lastWallMs = null;
|
|
352
|
+
let lastMusicMs = 0;
|
|
353
|
+
let autoFollowSuspended = false;
|
|
354
|
+
let programmaticScrollUntil = 0;
|
|
355
|
+
let followRafPending = false;
|
|
356
|
+
function onWindowScroll() {
|
|
357
|
+
if (nowMs() > programmaticScrollUntil) autoFollowSuspended = true;
|
|
358
|
+
}
|
|
359
|
+
const hasWindow = typeof window !== "undefined" && typeof window.addEventListener === "function";
|
|
360
|
+
if (hasWindow) window.addEventListener("scroll", onWindowScroll, { passive: true });
|
|
361
|
+
function detectDiscontinuity(tMs) {
|
|
362
|
+
const now = nowMs();
|
|
363
|
+
if (lastWallMs != null) {
|
|
364
|
+
const dtMusic = tMs - lastMusicMs;
|
|
365
|
+
const dtWall = now - lastWallMs;
|
|
366
|
+
if (dtMusic < 0 || Math.abs(dtMusic - dtWall) > SEEK_DISCONTINUITY_MS) {
|
|
367
|
+
autoFollowSuspended = false;
|
|
368
|
+
}
|
|
369
|
+
} else {
|
|
370
|
+
autoFollowSuspended = false;
|
|
371
|
+
}
|
|
372
|
+
lastWallMs = now;
|
|
373
|
+
lastMusicMs = tMs;
|
|
374
|
+
}
|
|
375
|
+
function maybeAutoFollow() {
|
|
376
|
+
if (autoFollowSuspended || followRafPending) return;
|
|
377
|
+
if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") return;
|
|
378
|
+
followRafPending = true;
|
|
379
|
+
window.requestAnimationFrame(() => {
|
|
380
|
+
followRafPending = false;
|
|
381
|
+
if (destroyed || autoFollowSuspended) return;
|
|
382
|
+
if (typeof playheadEl.getBoundingClientRect !== "function") return;
|
|
383
|
+
const rect = playheadEl.getBoundingClientRect();
|
|
384
|
+
const vh = window.innerHeight;
|
|
385
|
+
if (!(vh > 0)) return;
|
|
386
|
+
if (rect.top >= vh * 0.25 && rect.top <= vh * 0.75) return;
|
|
387
|
+
if (typeof playheadEl.scrollIntoView === "function") {
|
|
388
|
+
programmaticScrollUntil = nowMs() + 600;
|
|
389
|
+
playheadEl.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
function updatePlayhead(tMs) {
|
|
394
|
+
lastTMs = tMs;
|
|
395
|
+
if (!currentFlow || !rn) return;
|
|
396
|
+
const line = mode === "vstack" ? vstackAudioPlayheadLine(currentFlow, onsets, tMs, measureCount(rn), noteCols) : audioPlayheadLine(currentFlow, onsets, tMs, barDurMs, noteCols);
|
|
397
|
+
if (!line) {
|
|
398
|
+
playheadEl.style.opacity = "0";
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
playheadEl.style.opacity = String(line.alpha);
|
|
402
|
+
playheadEl.style.left = `${line.x / dpr}px`;
|
|
403
|
+
playheadEl.style.top = `${line.y0 / dpr}px`;
|
|
404
|
+
playheadEl.style.height = `${Math.max(0, line.y1 - line.y0) / dpr}px`;
|
|
405
|
+
maybeAutoFollow();
|
|
406
|
+
}
|
|
407
|
+
function setTime(tMs) {
|
|
408
|
+
if (destroyed) return;
|
|
409
|
+
detectDiscontinuity(tMs);
|
|
410
|
+
updatePlayhead(tMs);
|
|
411
|
+
}
|
|
412
|
+
return {
|
|
413
|
+
ready,
|
|
414
|
+
setTime,
|
|
415
|
+
resize() {
|
|
416
|
+
void performResize();
|
|
417
|
+
},
|
|
418
|
+
onMeasureClick(cb) {
|
|
419
|
+
clickListeners.push(cb);
|
|
420
|
+
return () => {
|
|
421
|
+
const i = clickListeners.indexOf(cb);
|
|
422
|
+
if (i >= 0) clickListeners.splice(i, 1);
|
|
423
|
+
};
|
|
424
|
+
},
|
|
425
|
+
destroy() {
|
|
426
|
+
if (destroyed) return;
|
|
427
|
+
destroyed = true;
|
|
428
|
+
if (hasWindow) window.removeEventListener("scroll", onWindowScroll);
|
|
429
|
+
clearTiles();
|
|
430
|
+
clickListeners.length = 0;
|
|
431
|
+
if (root.parentNode === host) host.removeChild(root);
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
function createNotationPlayer(opts) {
|
|
436
|
+
return (opts.display ?? "window") === "scroll" ? createScrollPlayer(opts) : createWindowPlayer(opts);
|
|
437
|
+
}
|
|
182
438
|
export {
|
|
439
|
+
SCROLL_TILE_MAX_AREA_PX,
|
|
440
|
+
SCROLL_TILE_TARGET_CSS_PX,
|
|
441
|
+
computeScrollTiles,
|
|
183
442
|
createNotationPlayer,
|
|
443
|
+
flowLayout,
|
|
184
444
|
hitTestMeasureAt
|
|
185
445
|
};
|
|
186
446
|
//# sourceMappingURL=notationPlayer.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/notationPlayer.ts"],"sourcesContent":["// createNotationPlayer — THE canonical notation-playback component: one\n// scrolling-notation-plus-gliding-playhead implementation, used for BOTH live\n// in-app players (this module's reason to exist) and the promo/video-recording\n// path (src/scene/layers/notation.ts + scrollCursor.ts, driven by the SceneSpec\n// runner for a fixed-duration capture). Both consume the SAME geometry —\n// `notationLayout`, `followBoxAt`/`vstackFollowBox`, `audioPlayheadLine`/\n// `vstackAudioPlayheadLine`, `measureColumnsFromLayout` — which still lives in\n// `src/scene/` (notationGeometry.ts, engravingStore.ts, layers/notation.ts,\n// layers/scrollCursor.ts) for now; this module is their canonical PUBLIC\n// surface for interactive, caller-driven playback. It does not reimplement or\n// fork any of that math — it just instantiates the two `Layer`s directly\n// (bypassing the SceneSpec/timeline runner, which is built for a fixed-length\n// recorded clip, not a live `setTime()`-driven widget) and adds the two things\n// a live player needs that a recorded promo never did: resize, and\n// measure-click hit-testing.\n//\n// WHY THIS EXISTS: a prior in-app player (stave-web-sightread's /bach/play)\n// hand-rolled its own playhead interpolation directly against OSMD's graphical\n// output instead of reusing this path, and the result was a janky cursor.\n// Hand-rolling notation/playhead geometry against OSMD is a KNOWN FAILURE MODE\n// — OSMD's rasterized bitmap does not scale by the naive `canvas.width / pageW`\n// whenever the engraving overflows the nominal page width (which the `hstack`\n// single-staffline layout does routinely), so a from-scratch cursor drifts\n// steadily off the noteheads over a piece. That exact bug was found and fixed\n// ONCE, here, in `extractGeometry` (src/promo.ts — `canvas.width /\n// (contentRight + contentLeft)`, not `/pageW`); every consumer of THIS module\n// inherits the fix for free. Don't re-derive playhead/scroll geometry against\n// OSMD anywhere else — wrap this component instead.\n//\n// Follow-up (noted, not done here): src/scene/layers/notation.ts +\n// scrollCursor.ts could themselves be rebuilt on top of this module instead of\n// duplicating the init/draw wiring; deferred to keep this change additive.\n//\n// IMPORT PATH: this module is SUBPATH-ONLY —\n// `import { createNotationPlayer } from '@real-music-packages/web-core/notationPlayer'`\n// — matching every other non-trivial module in this package (promo.ts,\n// video.ts, scene/, server.ts, streak.ts, playback.ts). It is intentionally\n// NOT re-exported from the root barrel (src/index.ts): the root barrel is\n// theory-only (notes/frequency/scales/intervals/chords/fretboard/shareCard,\n// all zero-dependency) so apps that only want pitch/theory helpers don't\n// statically pull in this module's scene/video runtime deps.\n\nimport { notationFactory, type NotationProps } from './scene/layers/notation';\nimport { scrollCursorFactory, type ScrollCursorProps } from './scene/layers/scrollCursor';\nimport { getNotationEngraving } from './scene/engravingStore';\nimport { measureColumnsFromLayout, type NotationLayout } from './scene/notationGeometry';\nimport type { AudioClock, RenderCtx } from './scene/layer';\nimport type { Score, ScoreNote } from './scene/score';\nimport type { RenderedNotation } from './promo';\nimport { safeBox, type PromoTheme } from './video';\n\n/**\n * Notation scroll/camera mode — forwarded verbatim to the underlying engraving\n * + follow-camera (see `RenderNotationOpts.scrollMode` in src/promo.ts and\n * `notationGeometry.ts`'s hstack/vstack follow functions). These are the ONLY\n * two modes the shared machinery implements; there is no third.\n *\n * 'vstack' (DEFAULT here) — classic stacked systems (page-wrap engraving).\n * The follow camera frames one system at a time and scrolls vertically\n * (with a \"carriage return\" playhead) as the piece crosses system breaks.\n * This is the actively-exercised path (whozart's live player, the RSR/RET/\n * RMT promo videos) and the one the memory note \"use vstack everywhere\"\n * refers to — pick it unless you have a specific reason not to.\n *\n * 'hstack' — single horizontal staffline; the follow window pans purely\n * left→right. The cursor is onset-locked here too (audioPlayheadLine, not\n * a free-running sweep — that linear-sweep footgun was removed from the\n * shared cursor entirely in web-core 0.22.0), and the earlier\n * `canvas.width/pageW` scale bug that made hstack drift is fixed (0.30+\n * `extractGeometry`, see the module doc above). It is still the LESS\n * exercised of the two modes for live players (most real players show\n * multi-line music, which needs vstack) — supported and correct today, but\n * treat it as the less battle-tested choice.\n */\nexport type NotationPlayerMode = 'hstack' | 'vstack';\n\nexport interface NotationPlayerTheme extends Partial<PromoTheme> {}\n\nexport interface CreateNotationPlayerOpts {\n /** Element the player's canvas is mounted into (fills it). */\n host: HTMLElement;\n /** MusicXML to engrave. */\n musicXml: string;\n /** Distinct note onsets (ms, sorted or not — de-duped/sorted internally via\n * the shared score model) the playhead locks to. One per played note (or\n * the caller's own onset schedule) — this IS the audio clock's timing. */\n onsetsMs: number[];\n /**\n * Per-onset engraved column positions (`measureIndex + frac`), 1:1 with\n * `onsetsMs`. Optional — falls back to the shared machinery's own\n * time/ordinal-based placement when omitted (see `scroll-cursor`'s\n * `noteCols`). Strongly recommended for anything beyond a demo: it is what\n * lands the cursor on the actual notehead instead of a time-derived guess.\n *\n * GOTCHA (pre-existing in the wrapped machinery, not introduced here):\n * internally the cursor works off `distinctOnsets(onsetsMs)` (deduped +\n * sorted), so if `onsetsMs` contains duplicate onsets (simultaneous chord\n * notes sharing one onset time — a legitimate input), the deduped length no\n * longer matches `noteCols.length` and the `1:1` alignment silently breaks,\n * degrading to the `barDurMs`/ordinal fallback with no warning. Build\n * `noteCols` against the DEDUPED, sorted onset list, not the raw one.\n */\n noteCols?: number[];\n /** Scroll/camera mode. Default 'vstack'. See `NotationPlayerMode`. Fixed for\n * the life of the instance — switching modes needs a fresh OSMD engrave\n * (different `RenderSingleHorizontalStaffline` layout), so it is NOT a\n * runtime-switchable option; create a new player if the mode must change\n * (noted as a follow-up, not built: nothing in the shared machinery makes a\n * live re-layout trivial). */\n mode?: NotationPlayerMode;\n /** Duration of one measure in ms — enables time-accurate cursor/window\n * placement when `noteCols` isn't supplied (see `scroll-cursor`'s\n * `barDurMs`). */\n barDurMs?: number;\n /** Bar range [from,to] forwarded to the engraver (renderNotation\n * bars/drawFrom-drawUpTo) — engrave an excerpt rather than the whole score. */\n bars?: [number, number];\n /** Top of the notation band, screen px. Default 0 (fills the host — this is\n * a UI widget, not a phone-safe video frame). */\n bandTop?: number;\n /** Height of the notation band, screen px. Default the full frame height. */\n bandHeight?: number;\n /** Theme tokens (colours/fonts) forwarded to the layers. Any field omitted\n * falls back to a neutral default. */\n theme?: NotationPlayerTheme;\n /** Explicit canvas size in device px. Default: host.clientWidth/clientHeight\n * × devicePixelRatio. Pass this in test/headless environments where the\n * host has no real layout (e.g. jsdom, where clientWidth is always 0). */\n size?: [number, number];\n /**\n * Advanced / test seam: a pre-rasterized engraving, bypassing the browser\n * OSMD raster (`musicXml` is still required by the type but is ignored when\n * this is set). Mirrors `NotationProps.rendered` in\n * `src/scene/layers/notation.ts` (\"test/headless injection\") — real OSMD\n * *rendering* (as opposed to parsing) needs a real 2D canvas context (glyph\n * metrics for its line-breaking pass) that headless/jsdom test contexts\n * can't fully provide, so this is how this module (and the layer it wraps)\n * stays unit-testable in Node. Not needed in a real browser host.\n */\n rendered?: RenderedNotation;\n}\n\nexport interface NotationPlayer {\n /** Resolves once the engraving has been rasterized and is ready to draw.\n * `setTime`/click hit-testing are safe to call before this resolves — they\n * simply no-op (draw a blank frame / report no measure) until ready. */\n readonly ready: Promise<void>;\n /**\n * Draw the frame for absolute playback time `tMs`. The caller owns the\n * audio clock + the rAF loop — call this every frame with the current\n * playback position; everything else (follow camera, onset-locked\n * playhead) is a pure function of `tMs`, exactly as the promo/whozart path\n * drives it.\n */\n setTime(tMs: number): void;\n /** Re-measure the host and resize the canvas to match (device-px aware).\n * Cheap after the first draw — reuses the already-rasterized engraving\n * bitmap, it does not re-run OSMD. Call on host resize / orientation\n * change. Fire-and-forget (async internally; the next `setTime` reflects\n * the new size once it lands, typically within a microtask). */\n resize(): void;\n /** Register a measure-click handler: fires with the clicked measure's\n * engraved index (matching `ScoreNote.measure` numbering) when a click\n * lands on — or nearest to — a rendered measure column. Multiple handlers\n * may be registered (they all fire); returns an unsubscribe function for\n * that one handler. `destroy()` also clears every remaining listener. */\n onMeasureClick(cb: (measureIndex: number) => void): () => void;\n /** Tear down: removes the canvas from `host` and drops listeners/state. */\n destroy(): void;\n}\n\nconst DEFAULT_THEME: PromoTheme = {\n paper: '#faf7f0', ink: '#1a1614', accent: '#7b2436', sepia: '#6d5d4d', gold: '#c8a55b',\n fontDisplay: 'Georgia, serif', fontBody: 'Georgia, serif', brand: '',\n};\n\n/** A minimal but valid `Score` carrying only the onset timings the shared\n * scroll-cursor needs (`distinctOnsets(ctx.score.notes)`). Pitch/hand/measure\n * fields are placeholders — the notation + scroll-cursor layers never read\n * them (they read the RASTERIZED engraving's own geometry for position; the\n * Score here only supplies the audio-onset clock). */\nfunction scoreFromOnsets(onsetsMs: number[]): Score {\n const notes: ScoreNote[] = onsetsMs.map((onsetMs) => ({\n pitchMidi: 60, step: 'C', alter: 0, octave: 4,\n onsetMs, durMs: 0, staff: 0, voice: 0, hand: 'R', measure: 1,\n }));\n const durationMs = onsetsMs.length ? Math.max(...onsetsMs) : 0;\n return {\n notes,\n tempoMap: { source: 'fallback', segments: [{ atMs: 0, bpm: 120 }] },\n durationMs,\n };\n}\n\n/**\n * Pure hit-test: which measure (by its engraved index) contains — or is\n * nearest to — point (mx, my) in a given followed layout. Reuses\n * `measureColumnsFromLayout` (the SAME per-measure union boxes the playhead\n * anchors to) for the boxes; only the index bookkeeping (matching each\n * returned box back to its measure index, which `measureColumnsFromLayout`\n * intentionally drops — the playhead has no use for it) is new here, and it\n * is pure array/index bookkeeping, not geometry math. Exported standalone so\n * it is unit-testable without a DOM/canvas.\n *\n * COORDINATE SPACE — read this before calling directly: (mx, my) MUST be in\n * the same DEST/DEVICE-PIXEL space `layout.measures[].box` is already mapped\n * into by `notationLayout()` (i.e. the player's own `<canvas>` device pixels,\n * origin top-left, NOT CSS px) — the same space this module's own\n * `onCanvasClick` computes via `(clientX - rect.left) * (canvas.width /\n * rect.width)`. It is NOT the raster/src space (`RenderedNotation.canvas`,\n * OSMD's own pre-map bitmap px) — passing src-space coordinates here is\n * exactly the \"classic scale gotcha\" (`extractGeometry`'s `canvas.width /\n * pageW` vs `/(contentRight+contentLeft)`, see the module doc) this component\n * exists to make impossible; don't reintroduce it at the call site.\n */\nexport function hitTestMeasureAt(layout: NotationLayout, mx: number, my: number): number | null {\n if (!layout.measures.length) return null;\n const indices = [...new Set(layout.measures.map((m) => m.index))].sort((a, b) => a - b);\n const cols = measureColumnsFromLayout(layout.measures);\n let nearest: number | null = null;\n let nearestD = Infinity;\n for (let i = 0; i < cols.length; i++) {\n const b = cols[i];\n if (mx >= b.x && mx <= b.x + b.w && my >= b.y && my <= b.y + b.h) return indices[i];\n const cx = b.x + b.w / 2;\n const cy = b.y + b.h / 2;\n const d = (mx - cx) * (mx - cx) + (my - cy) * (my - cy);\n if (d < nearestD) {\n nearestD = d;\n nearest = indices[i];\n }\n }\n return nearest;\n}\n\n/**\n * Build a live, interactive notation player: a thin packaging of the SAME\n * working path whozart's live in-browser promo player uses (see the module\n * doc). Renders the engraving ONCE (OSMD raster, via the `notation` Layer),\n * then every `setTime(tMs)` call blits the current follow window (via\n * `followBoxAt`/`vstackFollowBox` → `notationLayout`) and draws the\n * onset-locked playhead (`audioPlayheadLine`/`vstackAudioPlayheadLine`) —\n * exactly the `notation` + `scroll-cursor` Layer pair the promo/video runner\n * uses, instantiated directly instead of through the SceneSpec/timeline\n * runner (which targets a fixed-duration recorded clip, not a live,\n * caller-driven widget).\n */\nexport function createNotationPlayer(opts: CreateNotationPlayerOpts): NotationPlayer {\n const { host, musicXml, onsetsMs, noteCols, barDurMs, bars } = opts;\n const mode: NotationPlayerMode = opts.mode ?? 'vstack';\n const theme: PromoTheme = { ...DEFAULT_THEME, ...opts.theme };\n\n const canvas = document.createElement('canvas');\n canvas.style.display = 'block';\n canvas.style.width = '100%';\n canvas.style.height = '100%';\n host.appendChild(canvas);\n\n const dpr = typeof window !== 'undefined' && window.devicePixelRatio ? window.devicePixelRatio : 1;\n function frameSize(): [number, number] {\n if (opts.size) return opts.size;\n const w = Math.max(1, Math.round((host.clientWidth || 1) * dpr));\n const h = Math.max(1, Math.round((host.clientHeight || 1) * dpr));\n return [w, h];\n }\n\n const [initW, initH] = frameSize();\n canvas.width = initW;\n canvas.height = initH;\n const ctx2d = canvas.getContext('2d');\n if (!ctx2d) throw new Error('createNotationPlayer: 2D canvas context unavailable');\n\n let lastTMs = 0;\n const audioClock: AudioClock = { nowMs: () => lastTMs };\n const rctx: RenderCtx = {\n ctx2d, W: initW, H: initH,\n score: scoreFromOnsets(onsetsMs),\n audioClock, theme, safeBox: safeBox(initW, initH), fps: 30,\n };\n\n const notation = notationFactory.create();\n const scrollCursor = scrollCursorFactory.create();\n\n function notationProps(rendered?: RenderedNotation): NotationProps {\n return {\n ...(rendered ? { rendered } : { xml: musicXml }),\n scrollMode: mode,\n bars,\n bandTop: opts.bandTop ?? 0,\n bandHeight: opts.bandHeight ?? rctx.H,\n };\n }\n const scrollCursorProps: ScrollCursorProps = { scrollMode: mode, barDurMs, noteCols };\n\n let renderedRn: RenderedNotation | null = null;\n let destroyed = false;\n\n const ready = (async () => {\n await notation.init(rctx, notationProps(opts.rendered));\n renderedRn = getNotationEngraving(rctx)?.rendered ?? null;\n await scrollCursor.init(rctx, scrollCursorProps);\n })();\n\n function clear(): void {\n ctx2d!.save();\n ctx2d!.setTransform(1, 0, 0, 1, 0, 0);\n ctx2d!.fillStyle = theme.paper;\n ctx2d!.fillRect(0, 0, canvas.width, canvas.height);\n ctx2d!.restore();\n }\n\n function setTime(tMs: number): void {\n if (destroyed) return;\n lastTMs = tMs;\n clear();\n notation.draw(rctx, tMs);\n scrollCursor.draw(rctx, tMs);\n }\n\n async function performResize(): Promise<void> {\n const [w, h] = frameSize();\n if (w === canvas.width && h === canvas.height) return;\n canvas.width = w;\n canvas.height = h;\n rctx.W = w;\n rctx.H = h;\n rctx.safeBox = safeBox(w, h);\n if (renderedRn) {\n // Cheap re-layout: reuses the already-rasterized bitmap (no OSMD re-run).\n await notation.init(rctx, notationProps(renderedRn));\n await scrollCursor.init(rctx, scrollCursorProps);\n }\n if (!destroyed) setTime(lastTMs);\n }\n\n function currentLayout(): NotationLayout | null {\n const eng = getNotationEngraving(rctx);\n if (!eng) return null;\n return eng.followLayoutAt ? eng.followLayoutAt(rctx, lastTMs) : eng.base;\n }\n\n const clickListeners: Array<(measureIndex: number) => void> = [];\n function onCanvasClick(e: MouseEvent): void {\n const rect = canvas.getBoundingClientRect();\n if (!(rect.width > 0) || !(rect.height > 0)) return;\n const mx = (e.clientX - rect.left) * (canvas.width / rect.width);\n const my = (e.clientY - rect.top) * (canvas.height / rect.height);\n const layout = currentLayout();\n if (!layout) return;\n const idx = hitTestMeasureAt(layout, mx, my);\n if (idx != null) for (const cb of clickListeners) cb(idx);\n }\n canvas.addEventListener('click', onCanvasClick);\n\n return {\n ready,\n setTime,\n resize(): void {\n void performResize();\n },\n onMeasureClick(cb: (measureIndex: number) => void): () => void {\n clickListeners.push(cb);\n return () => {\n const i = clickListeners.indexOf(cb);\n if (i >= 0) clickListeners.splice(i, 1);\n };\n },\n destroy(): void {\n if (destroyed) return;\n destroyed = true;\n canvas.removeEventListener('click', onCanvasClick);\n notation.dispose?.();\n scrollCursor.dispose?.();\n clickListeners.length = 0;\n if (canvas.parentNode === host) host.removeChild(canvas);\n },\n };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/notationPlayer.ts"],"sourcesContent":["// createNotationPlayer — THE canonical notation-playback component: one\n// scrolling-notation-plus-gliding-playhead implementation, used for BOTH live\n// in-app players (this module's reason to exist) and the promo/video-recording\n// path (src/scene/layers/notation.ts + scrollCursor.ts, driven by the SceneSpec\n// runner for a fixed-duration capture). Both consume the SAME geometry —\n// `notationLayout`, `followBoxAt`/`vstackFollowBox`, `audioPlayheadLine`/\n// `vstackAudioPlayheadLine`, `measureColumnsFromLayout` — which still lives in\n// `src/scene/` (notationGeometry.ts, engravingStore.ts, layers/notation.ts,\n// layers/scrollCursor.ts) for now; this module is their canonical PUBLIC\n// surface for interactive, caller-driven playback. It does not reimplement or\n// fork any of that math — it just instantiates the two `Layer`s directly\n// (bypassing the SceneSpec/timeline runner, which is built for a fixed-length\n// recorded clip, not a live `setTime()`-driven widget) and adds the two things\n// a live player needs that a recorded promo never did: resize, and\n// measure-click hit-testing.\n//\n// WHY THIS EXISTS: a prior in-app player (stave-web-sightread's /bach/play)\n// hand-rolled its own playhead interpolation directly against OSMD's graphical\n// output instead of reusing this path, and the result was a janky cursor.\n// Hand-rolling notation/playhead geometry against OSMD is a KNOWN FAILURE MODE\n// — OSMD's rasterized bitmap does not scale by the naive `canvas.width / pageW`\n// whenever the engraving overflows the nominal page width (which the `hstack`\n// single-staffline layout does routinely), so a from-scratch cursor drifts\n// steadily off the noteheads over a piece. That exact bug was found and fixed\n// ONCE, here, in `extractGeometry` (src/promo.ts — `canvas.width /\n// (contentRight + contentLeft)`, not `/pageW`); every consumer of THIS module\n// inherits the fix for free. Don't re-derive playhead/scroll geometry against\n// OSMD anywhere else — wrap this component instead.\n//\n// Follow-up (noted, not done here): src/scene/layers/notation.ts +\n// scrollCursor.ts could themselves be rebuilt on top of this module instead of\n// duplicating the init/draw wiring; deferred to keep this change additive.\n//\n// DISPLAY MODES (0.37.0) — `display: 'window' | 'scroll'` (see\n// `CreateNotationPlayerOpts.display`):\n// - 'window' (DEFAULT) — the original camera-band player: a fixed-size\n// canvas, a follow camera that crops to ~2 bars/one system at a time, and\n// an onset-locked playhead drawn INTO that canvas. This is the path\n// promos/video recording and every existing caller before 0.37.0 uses; it\n// is completely untouched by this addition (see `createWindowPlayer`) —\n// selecting it (or omitting `display`) is byte-identical to pre-0.37.0\n// behavior.\n// - 'scroll' — a live-reading display: the ENTIRE score is rasterized once\n// (same raster `renderNotation` already produces, unchanged), laid out at\n// full width with NO camera crop (`flowLayout`, below), and sliced into a\n// vertical stack of `<canvas>` tiles so the HOST grows to the score's\n// natural height and the PAGE scrolls it (native wheel/trackpad/swipe) —\n// not a camera pan inside a fixed frame. See `createScrollPlayer`.\n// Promos/whozart never set `display`, so they are unaffected by any of this.\n//\n// IMPORT PATH: this module is SUBPATH-ONLY —\n// `import { createNotationPlayer } from '@real-music-packages/web-core/notationPlayer'`\n// — matching every other non-trivial module in this package (promo.ts,\n// video.ts, scene/, server.ts, streak.ts, playback.ts). It is intentionally\n// NOT re-exported from the root barrel (src/index.ts): the root barrel is\n// theory-only (notes/frequency/scales/intervals/chords/fretboard/shareCard,\n// all zero-dependency) so apps that only want pitch/theory helpers don't\n// statically pull in this module's scene/video runtime deps.\n\nimport { notationFactory, type NotationProps } from './scene/layers/notation';\nimport { scrollCursorFactory, type ScrollCursorProps } from './scene/layers/scrollCursor';\nimport { getNotationEngraving } from './scene/engravingStore';\nimport {\n audioPlayheadLine,\n distinctOnsets,\n measureColumnsFromLayout,\n measureCount,\n notationLayout,\n vstackAudioPlayheadLine,\n type NotationLayout,\n} from './scene/notationGeometry';\nimport type { AudioClock, RenderCtx } from './scene/layer';\nimport type { Score, ScoreNote } from './scene/score';\nimport type { RenderedNotation } from './promo';\nimport { safeBox, type PromoTheme } from './video';\n\n/**\n * Notation scroll/camera mode — forwarded verbatim to the underlying engraving\n * + follow-camera (see `RenderNotationOpts.scrollMode` in src/promo.ts and\n * `notationGeometry.ts`'s hstack/vstack follow functions). These are the ONLY\n * two modes the shared machinery implements; there is no third.\n *\n * 'vstack' (DEFAULT here) — classic stacked systems (page-wrap engraving).\n * The follow camera frames one system at a time and scrolls vertically\n * (with a \"carriage return\" playhead) as the piece crosses system breaks.\n * This is the actively-exercised path (whozart's live player, the RSR/RET/\n * RMT promo videos) and the one the memory note \"use vstack everywhere\"\n * refers to — pick it unless you have a specific reason not to.\n *\n * 'hstack' — single horizontal staffline; the follow window pans purely\n * left→right. The cursor is onset-locked here too (audioPlayheadLine, not\n * a free-running sweep — that linear-sweep footgun was removed from the\n * shared cursor entirely in web-core 0.22.0), and the earlier\n * `canvas.width/pageW` scale bug that made hstack drift is fixed (0.30+\n * `extractGeometry`, see the module doc above). It is still the LESS\n * exercised of the two modes for live players (most real players show\n * multi-line music, which needs vstack) — supported and correct today, but\n * treat it as the less battle-tested choice.\n *\n * NOTE for `display: 'scroll'` (0.37.0): scroll mode shows the WHOLE score\n * at once with no camera crop, which is only meaningful for a page-wrapped\n * ('vstack') engraving — 'hstack' engraves the whole piece onto ONE very\n * wide row, so a scroll-mode flow layout for it degenerates to a single\n * short, squeezed tile. Scroll mode does not forbid 'hstack' (the geometry\n * is agnostic), but in practice pick 'vstack' for it, same as window mode.\n */\nexport type NotationPlayerMode = 'hstack' | 'vstack';\n\n/**\n * Which player shell to build. See the module doc's \"DISPLAY MODES\" section.\n * Default `'window'` — every pre-0.37.0 caller (and promos/whozart, which never\n * set this) keeps the exact camera-band behavior, byte-for-byte.\n */\nexport type NotationPlayerDisplay = 'window' | 'scroll';\n\nexport interface NotationPlayerTheme extends Partial<PromoTheme> {}\n\nexport interface CreateNotationPlayerOpts {\n /** Element the player's content is mounted into.\n * - `display:'window'` (default): a single canvas that FILLS the host\n * (`width:100%;height:100%`) — the host's own size is the frame.\n * - `display:'scroll'`: an internal wrapper that fills the host\n * HORIZONTALLY but is left to its NATURAL height (the whole score's\n * height at the host's width) — the host must not force/clip a fixed\n * height (no `overflow:hidden` + fixed height) or native page scroll\n * can't reach the tiles below the fold. This is the one structural\n * assumption scroll mode makes about its host; window mode has none. */\n host: HTMLElement;\n /** MusicXML to engrave. */\n musicXml: string;\n /** Distinct note onsets (ms, sorted or not — de-duped/sorted internally via\n * the shared score model) the playhead locks to. One per played note (or\n * the caller's own onset schedule) — this IS the audio clock's timing. */\n onsetsMs: number[];\n /**\n * Per-onset engraved column positions (`measureIndex + frac`), 1:1 with\n * `onsetsMs`. Optional — falls back to the shared machinery's own\n * time/ordinal-based placement when omitted (see `scroll-cursor`'s\n * `noteCols`). Strongly recommended for anything beyond a demo: it is what\n * lands the cursor on the actual notehead instead of a time-derived guess.\n *\n * GOTCHA (pre-existing in the wrapped machinery, not introduced here):\n * internally the cursor works off `distinctOnsets(onsetsMs)` (deduped +\n * sorted), so if `onsetsMs` contains duplicate onsets (simultaneous chord\n * notes sharing one onset time — a legitimate input), the deduped length no\n * longer matches `noteCols.length` and the `1:1` alignment silently breaks,\n * degrading to the `barDurMs`/ordinal fallback with no warning. Build\n * `noteCols` against the DEDUPED, sorted onset list, not the raw one.\n */\n noteCols?: number[];\n /** Scroll/camera mode. Default 'vstack'. See `NotationPlayerMode`. Fixed for\n * the life of the instance — switching modes needs a fresh OSMD engrave\n * (different `RenderSingleHorizontalStaffline` layout), so it is NOT a\n * runtime-switchable option; create a new player if the mode must change\n * (noted as a follow-up, not built: nothing in the shared machinery makes a\n * live re-layout trivial). */\n mode?: NotationPlayerMode;\n /**\n * Which player shell to build — `'window'` (default, the original\n * camera-band player) or `'scroll'` (0.37.0, a tiled full-score display the\n * PAGE scrolls natively). See the module doc's \"DISPLAY MODES\" section and\n * `NotationPlayerDisplay`. Fixed for the life of the instance, same\n * reasoning as `mode`: create a new player if it must change.\n */\n display?: NotationPlayerDisplay;\n /** Duration of one measure in ms — enables time-accurate cursor/window\n * placement when `noteCols` isn't supplied (see `scroll-cursor`'s\n * `barDurMs`). */\n barDurMs?: number;\n /** Bar range [from,to] forwarded to the engraver (renderNotation\n * bars/drawFrom-drawUpTo) — engrave an excerpt rather than the whole score. */\n bars?: [number, number];\n /** Top of the notation band, screen px. Default 0 (fills the host — this is\n * a UI widget, not a phone-safe video frame). `display:'scroll'` IGNORES\n * this — there is no fixed band to inset; the whole score is shown. */\n bandTop?: number;\n /** Height of the notation band, screen px. Default the full frame height.\n * `display:'scroll'` IGNORES this for the same reason as `bandTop`. */\n bandHeight?: number;\n /** Theme tokens (colours/fonts) forwarded to the layers. Any field omitted\n * falls back to a neutral default. */\n theme?: NotationPlayerTheme;\n /** Explicit canvas size in device px. Default: host.clientWidth/clientHeight\n * × devicePixelRatio. Pass this in test/headless environments where the\n * host has no real layout (e.g. jsdom, where clientWidth is always 0).\n * `display:'scroll'` only reads the WIDTH component (`size[0]`) — the\n * display height is derived from the score's own content, so `size[1]` is\n * ignored in that mode. */\n size?: [number, number];\n /**\n * Advanced / test seam: a pre-rasterized engraving, bypassing the browser\n * OSMD raster (`musicXml` is still required by the type but is ignored when\n * this is set). Mirrors `NotationProps.rendered` in\n * `src/scene/layers/notation.ts` (\"test/headless injection\") — real OSMD\n * *rendering* (as opposed to parsing) needs a real 2D canvas context (glyph\n * metrics for its line-breaking pass) that headless/jsdom test contexts\n * can't fully provide, so this is how this module (and the layer it wraps)\n * stays unit-testable in Node. Not needed in a real browser host.\n */\n rendered?: RenderedNotation;\n}\n\nexport interface NotationPlayer {\n /** Resolves once the engraving has been rasterized and is ready to draw.\n * `setTime`/click hit-testing are safe to call before this resolves — they\n * simply no-op (draw a blank frame / report no measure) until ready. */\n readonly ready: Promise<void>;\n /**\n * Draw the frame for absolute playback time `tMs`. The caller owns the\n * audio clock + the rAF loop — call this every frame with the current\n * playback position; everything else (follow camera, onset-locked\n * playhead) is a pure function of `tMs`, exactly as the promo/whozart path\n * drives it.\n *\n * `display:'scroll'`: also feeds the auto-follow discontinuity detector —\n * see `CreateNotationPlayerOpts.display`'s doc for the exact re-arm rule.\n */\n setTime(tMs: number): void;\n /** Re-measure the host and resize/rebuild to match (device-px aware).\n * Cheap after the first draw — reuses the already-rasterized engraving\n * bitmap unless the host's CSS width crossed the engrave-width breakpoint\n * (see `desiredEngraveWidth`), it does not otherwise re-run OSMD. Call on\n * host resize / orientation change. Fire-and-forget (async internally; the\n * next `setTime` reflects the new size once it lands, typically within a\n * microtask). `display:'scroll'` rebuilds the whole tile stack. */\n resize(): void;\n /** Register a measure-click handler: fires with the clicked measure's\n * engraved index (matching `ScoreNote.measure` numbering) when a click\n * lands on — or nearest to — a rendered measure column. Multiple handlers\n * may be registered (they all fire); returns an unsubscribe function for\n * that one handler. `destroy()` also clears every remaining listener. */\n onMeasureClick(cb: (measureIndex: number) => void): () => void;\n /** Tear down: removes the canvas/tiles from `host` and drops\n * listeners/state (including the `display:'scroll'` page-scroll listener). */\n destroy(): void;\n}\n\nconst DEFAULT_THEME: PromoTheme = {\n paper: '#faf7f0', ink: '#1a1614', accent: '#7b2436', sepia: '#6d5d4d', gold: '#c8a55b',\n fontDisplay: 'Georgia, serif', fontBody: 'Georgia, serif', brand: '',\n};\n\n/** A minimal but valid `Score` carrying only the onset timings the shared\n * scroll-cursor needs (`distinctOnsets(ctx.score.notes)`). Pitch/hand/measure\n * fields are placeholders — the notation + scroll-cursor layers never read\n * them (they read the RASTERIZED engraving's own geometry for position; the\n * Score here only supplies the audio-onset clock). Used by `display:'window'`\n * only — `display:'scroll'` reads `onsetsMs` directly (no Layer/RenderCtx). */\nfunction scoreFromOnsets(onsetsMs: number[]): Score {\n const notes: ScoreNote[] = onsetsMs.map((onsetMs) => ({\n pitchMidi: 60, step: 'C', alter: 0, octave: 4,\n onsetMs, durMs: 0, staff: 0, voice: 0, hand: 'R', measure: 1,\n }));\n const durationMs = onsetsMs.length ? Math.max(...onsetsMs) : 0;\n return {\n notes,\n tempoMap: { source: 'fallback', segments: [{ atMs: 0, bpm: 120 }] },\n durationMs,\n };\n}\n\n/**\n * Pure hit-test: which measure (by its engraved index) contains — or is\n * nearest to — point (mx, my) in a given followed layout. Reuses\n * `measureColumnsFromLayout` (the SAME per-measure union boxes the playhead\n * anchors to) for the boxes; only the index bookkeeping (matching each\n * returned box back to its measure index, which `measureColumnsFromLayout`\n * intentionally drops — the playhead has no use for it) is new here, and it\n * is pure array/index bookkeeping, not geometry math. Exported standalone so\n * it is unit-testable without a DOM/canvas. Shared by BOTH display modes:\n * `display:'window'` hit-tests against the followed (camera-cropped) layout;\n * `display:'scroll'` hit-tests against the full-score `flowLayout` (see\n * below) with the click's y already translated from tile-local into\n * flow-layout space by the caller.\n *\n * COORDINATE SPACE — read this before calling directly: (mx, my) MUST be in\n * the same DEST/DEVICE-PIXEL space `layout.measures[].box` is already mapped\n * into by `notationLayout()` (i.e. the player's own `<canvas>` device pixels,\n * origin top-left, NOT CSS px) — the same space this module's own\n * `onCanvasClick` computes via `(clientX - rect.left) * (canvas.width /\n * rect.width)`. It is NOT the raster/src space (`RenderedNotation.canvas`,\n * OSMD's own pre-map bitmap px) — passing src-space coordinates here is\n * exactly the \"classic scale gotcha\" (`extractGeometry`'s `canvas.width /\n * pageW` vs `/(contentRight+contentLeft)`, see the module doc) this component\n * exists to make impossible; don't reintroduce it at the call site.\n */\nexport function hitTestMeasureAt(layout: NotationLayout, mx: number, my: number): number | null {\n if (!layout.measures.length) return null;\n const indices = [...new Set(layout.measures.map((m) => m.index))].sort((a, b) => a - b);\n const cols = measureColumnsFromLayout(layout.measures);\n let nearest: number | null = null;\n let nearestD = Infinity;\n for (let i = 0; i < cols.length; i++) {\n const b = cols[i];\n if (mx >= b.x && mx <= b.x + b.w && my >= b.y && my <= b.y + b.h) return indices[i];\n const cx = b.x + b.w / 2;\n const cy = b.y + b.h / 2;\n const d = (mx - cx) * (mx - cx) + (my - cy) * (my - cy);\n if (d < nearestD) {\n nearestD = d;\n nearest = indices[i];\n }\n }\n return nearest;\n}\n\n// ─── Shared sizing helpers (used by both display modes) ───────────────────\n\n/** `window.devicePixelRatio`, falling back to 1 whenever it's absent/0/NaN\n * (headless, or a browser that doesn't expose it). */\nfunction resolveDpr(): number {\n return typeof window !== 'undefined' && window.devicePixelRatio ? window.devicePixelRatio : 1;\n}\n\n/** Canvas backing-store size in device px for a host + optional explicit\n * override (see `CreateNotationPlayerOpts.size`'s doc — `display:'scroll'`\n * only reads index 0). */\nfunction frameSize(host: HTMLElement, dpr: number, size?: [number, number]): [number, number] {\n if (size) return size;\n const w = Math.max(1, Math.round((host.clientWidth || 1) * dpr));\n const h = Math.max(1, Math.round((host.clientHeight || 1) * dpr));\n return [w, h];\n}\n\n// The width OSMD engraves against (CSS px, forwarded as `renderNotation`'s\n// `hostWidth`) — NOT the same thing as `frameSize()`'s canvas device-px\n// size, which only controls how big the (already-engraved) raster is\n// blitted (window mode) or laid out (scroll mode). Left unset, `renderNotation`\n// defaults to a fixed 560px host, sized for the promo card this draw path was\n// built for; a live player embedded in a wide desktop container inherits that\n// same narrow 560px line-breaking regardless of how big its own host is, so\n// systems come out engraved for a phone-width column and then get letterboxed\n// (aspect-fit, see `notationLayout`) into the middle of the real, wider canvas\n// — narrow systems on a lot of unused horizontal whitespace.\n//\n// `DEFAULT_ENGRAVE_WIDTH` is a floor: hosts at or narrower than it (phones)\n// get exactly `renderNotation`'s own 560px default, unchanged — never\n// narrower. Any host WIDER than that always engraves at the single fixed\n// `MAX_ENGRAVE_WIDTH` ceiling, rather than tracking the host's exact CSS\n// width 1:1: the engrave step decides how many measures line-break onto one\n// system (i.e. the system's raw width:height aspect ratio) — the MORE\n// measures per line, the wider that aspect ratio, and a wider aspect ratio\n// is what lets the SEPARATE display-time fit (`notationLayout`'s\n// width-vs-height-bound choice) actually reach a wide `dw` before it runs\n// out of `boxH` — so engraving at less than the max on, say, a\n// 936px-wide-but-only-438px-tall card leaves the aspect ratio too narrow\n// for the fit to ever get wide even though the container has width to\n// spare (measured: srcAspect 1.65 tops out at ~73% of a 934px canvas at\n// boxH=438; engraving at the full 1150px ceiling instead reaches srcAspect\n// ~2.0+, wide enough to fill it). The container's OWN width still governs\n// how far that gets displayed (`bandWidth`, capped at `rctx.W`, or —\n// `display:'scroll'` — `dispWdev`), so a host narrower than 1150px never\n// actually shows more than it has room for — only the OFFSCREEN raster is\n// engraved wider than strictly necessary, at the (cheap, one-time) cost of\n// engraving a system with a couple of unused measures's worth of headroom\n// baked in.\nconst DEFAULT_ENGRAVE_WIDTH = 560;\nconst MAX_ENGRAVE_WIDTH = 1150;\nfunction desiredEngraveWidth(host: HTMLElement): number {\n return (host.clientWidth || 0) > DEFAULT_ENGRAVE_WIDTH ? MAX_ENGRAVE_WIDTH : DEFAULT_ENGRAVE_WIDTH;\n}\n\n// ─── `display:'scroll'` pure geometry (unit-tested, no DOM) ────────────────\n\n/**\n * Full-score \"flow\" layout for scroll mode: the ENTIRE engraved raster\n * (`rn.content`, the same tight ink box `notationLayout` itself already falls\n * back to) scaled to fill `dispWdev` device px of WIDTH, with height\n * following naturally from the content's own aspect ratio — no camera crop,\n * no bounded box (the opposite of the follow-window layout `notationLayout`\n * computes for window mode via `{focusBox}`). Reuses `notationLayout` for ALL\n * the actual scale/map/dx/dy math — no new geometry — via two calls:\n *\n * 1. a PROBE call with an arbitrarily large `boxH` (1e9 — real engraved\n * music's content aspect ratio, height/width, is always many orders of\n * magnitude below that, so this bound is never the true constraint; it\n * exists ONLY so the width-bound branch is guaranteed to be taken,\n * sidestepping a chicken-and-egg height guess) to read back the TRUE\n * fitted height (`rect.dh`) `notationLayout` would compute for this\n * width;\n * 2. an EXACT call with `boxH` set to precisely that height, so the result\n * is TOP-aligned (`rect.dy === 0`) rather than vertically centered\n * inside an oversized probe box. Both calls share the identical `src` /\n * `srcAspect` internally (same `rn`, `focusBox: null`, default\n * `zoom01`), so the two `dh` values are bit-identical and the second\n * call's `dh > boxH` branch is never taken (equal, not greater).\n *\n * Exported so scroll-mode's tile partition (`computeScrollTiles`) and the\n * playhead's y-mapping can be unit-tested against it directly, without a DOM.\n */\nexport function flowLayout(rn: RenderedNotation, dispWdev: number): NotationLayout {\n const w = dispWdev > 0 ? dispWdev : 1;\n const probe = notationLayout(rn, w, 1e9, 0, 1e9, { boxWidth: w });\n const dh = probe.rect.dh;\n return notationLayout(rn, w, dh, 0, dh, { boxWidth: w });\n}\n\n/** Target tile height, CSS px — ~2x a typical viewport, so a tile shows\n * roughly \"one screenful plus one\" of context, and system-boundary carriage\n * returns rarely straddle a tile seam. The ACTUAL tile height is this OR the\n * area-cap-derived height (`SCROLL_TILE_MAX_AREA_PX`), whichever is\n * SMALLER — so a high-dpr device automatically gets shorter (CSS-px) tiles\n * rather than ever exceeding the backing-store area cap; see\n * `computeScrollTiles`. */\nexport const SCROLL_TILE_TARGET_CSS_PX = 1600;\n\n/** Per-tile canvas backing-store area cap, device px². Comfortably under both\n * iOS Safari's ~16.7M px² (4096×4096) canvas-backing-store limit AND\n * `promo.ts`'s own `MAX_RASTER_AREA_PX` (12M — the cap for the SOURCE raster\n * a tile reads FROM): a tile is a separate, smaller destination canvas than\n * the source raster, so it gets its own, tighter cap; 8M leaves comfortable\n * headroom under both limits at any realistic tile width. */\nexport const SCROLL_TILE_MAX_AREA_PX = 8_000_000;\n\n/** One vertical tile of the full-score raster, in the same device-px space\n * `flowLayout` maps into. */\nexport interface ScrollTileSpec {\n /** Number of vertical tiles covering the full score. Always >= 1. */\n count: number;\n /** Per-tile height, device px. `heights.length === count`,\n * `sum(heights) === totalHeightDev` (within float precision). */\n heights: number[];\n /** Per-tile top y-offset, device px, within the full-score raster\n * (`flowLayout`'s coordinate space). `offsets.length === count`,\n * `offsets[0] === 0`, `offsets[i+1] === offsets[i] + heights[i]`. */\n offsets: number[];\n /** Total display height, device px (== the flow layout's `rect.dh`). */\n totalHeightDev: number;\n}\n\n/**\n * Pure tile-partition math (unit-tested, no DOM) — same shape/discipline as\n * `clampRasterDpr` in `src/promo.ts`: given the full-score display width and\n * height in device px (`dispWdev`/`totalHeightDev` — `flowLayout`'s own\n * `rect.dw`/`rect.dh`) and the active `dpr`, partition the height into N\n * EQUAL-height tiles such that:\n *\n * - each tile's target height is `SCROLL_TILE_TARGET_CSS_PX * dpr` device\n * px (~2x viewport) UNLESS that would push a tile's own backing-store\n * area (`dispWdev * tileHeightDev`) over `capPx2` — in which case the\n * tile height is derived FROM the area cap instead. This is COMPUTED\n * from `dispWdev`/`dpr` every call, not assumed safe at a fixed CSS\n * height — a very wide host at a high dpr still gets a shorter tile, so\n * the cap holds \"at any DPR\" as the design requires.\n * - tiles split EVENLY (`totalHeightDev / count`), not\n * max-height-tile-then-a-small-remainder — so there's never an oddly\n * short final tile, and every tile (including the last) is <= the area\n * cap by construction (see the proof in the inline comment below).\n * - a score shorter than one tile's max height gets exactly ONE tile (the\n * degenerate/short-score case) — tiling only exists to keep any single\n * canvas's backing-store area under the cap, which is already true for\n * the whole score at that size, so a single tile is simplest.\n */\nexport function computeScrollTiles(\n dispWdev: number,\n totalHeightDev: number,\n dpr: number,\n opts?: { targetTileCssPx?: number; capPx2?: number },\n): ScrollTileSpec {\n const targetCssPx = opts?.targetTileCssPx ?? SCROLL_TILE_TARGET_CSS_PX;\n const capPx2 = opts?.capPx2 ?? SCROLL_TILE_MAX_AREA_PX;\n const safeDpr = dpr > 0 ? dpr : 1;\n const w = dispWdev > 0 ? dispWdev : 1;\n const total = totalHeightDev > 0 ? totalHeightDev : 0;\n\n const desiredDev = targetCssPx * safeDpr;\n const maxByArea = capPx2 / w;\n // tileMax <= maxByArea = capPx2/w always, so w*tileMax <= capPx2: any tile\n // AT tileMax height already respects the cap. Splitting `total` into\n // `count = ceil(total/tileMax)` EQUAL tiles gives each tile height\n // `total/count <= tileMax` (ceil never under-counts), so every tile\n // (including the last) inherits that same guarantee — no per-tile check\n // needed after the fact.\n const tileMax = Math.max(1, Math.min(desiredDev, maxByArea));\n\n if (total <= tileMax) {\n return { count: 1, heights: [total], offsets: [0], totalHeightDev: total };\n }\n\n const count = Math.max(1, Math.ceil(total / tileMax));\n const even = total / count;\n const heights: number[] = new Array(count).fill(even);\n const offsets: number[] = new Array(count);\n let y = 0;\n for (let i = 0; i < count; i++) {\n offsets[i] = y;\n y += even;\n }\n return { count, heights, offsets, totalHeightDev: total };\n}\n\nfunction nowMs(): number {\n return typeof performance !== 'undefined' ? performance.now() : Date.now();\n}\n\n/**\n * `display:'window'` implementation (unchanged from pre-0.37.0\n * `createNotationPlayer` — see the module doc's \"DISPLAY MODES\" section).\n * Build a live, interactive notation player: a thin packaging of the SAME\n * working path whozart's live in-browser promo player uses (see the module\n * doc). Renders the engraving ONCE (OSMD raster, via the `notation` Layer),\n * then every `setTime(tMs)` call blits the current follow window (via\n * `followBoxAt`/`vstackFollowBox` → `notationLayout`) and draws the\n * onset-locked playhead (`audioPlayheadLine`/`vstackAudioPlayheadLine`) —\n * exactly the `notation` + `scroll-cursor` Layer pair the promo/video runner\n * uses, instantiated directly instead of through the SceneSpec/timeline\n * runner (which targets a fixed-duration recorded clip, not a live,\n * caller-driven widget).\n */\nfunction createWindowPlayer(opts: CreateNotationPlayerOpts): NotationPlayer {\n const { host, musicXml, onsetsMs, noteCols, barDurMs, bars } = opts;\n const mode: NotationPlayerMode = opts.mode ?? 'vstack';\n const theme: PromoTheme = { ...DEFAULT_THEME, ...opts.theme };\n\n const canvas = document.createElement('canvas');\n canvas.style.display = 'block';\n canvas.style.width = '100%';\n canvas.style.height = '100%';\n // Let the browser handle vertical page-scroll gestures that start over the\n // canvas (a live player is normally embedded in a scrolling page, unlike\n // the fixed-frame promo/video path this module shares its draw code with).\n // Without this, WebKit/iOS routinely demotes a swipe that starts on an\n // element carrying a pointer/click listener (see `onCanvasClick` below) to\n // its slow-path gesture disambiguation, which can read as \"the page won't\n // scroll\" even though nothing here calls `preventDefault()`. `pan-y` keeps\n // vertical panning on the browser's fast path while still leaving the\n // synthesized `click` (tap, near-zero movement) to reach `onCanvasClick` —\n // browsers already withhold `click` after a scrolling gesture, so tap vs.\n // swipe still discriminates for free.\n canvas.style.touchAction = 'pan-y';\n host.appendChild(canvas);\n\n const dpr = resolveDpr();\n\n const [initW, initH] = frameSize(host, dpr, opts.size);\n canvas.width = initW;\n canvas.height = initH;\n const ctx2d = canvas.getContext('2d');\n if (!ctx2d) throw new Error('createNotationPlayer: 2D canvas context unavailable');\n\n let lastTMs = 0;\n const audioClock: AudioClock = { nowMs: () => lastTMs };\n const rctx: RenderCtx = {\n ctx2d, W: initW, H: initH,\n score: scoreFromOnsets(onsetsMs),\n audioClock, theme, safeBox: safeBox(initW, initH), fps: 30,\n };\n\n const notation = notationFactory.create();\n const scrollCursor = scrollCursorFactory.create();\n\n function notationProps(rendered: RenderedNotation | undefined, hostWidth: number): NotationProps {\n // Only claim (up to) the full canvas width for the DISPLAY fit when the\n // engrave itself was actually widened past `renderNotation`'s own\n // phone-card default (`hostWidth` here is always `desiredEngraveWidth()`'s\n // output — see its doc comment: a floor, never narrower than\n // DEFAULT_ENGRAVE_WIDTH). On a narrow/mobile host this is false, so\n // `bandWidth` is omitted and `notationLayout` falls back to its original\n // `safeBox(W,H).centeredW` — mobile's display fit stays BYTE-IDENTICAL to\n // pre-fix behavior, not just visually similar.\n const widened = hostWidth > DEFAULT_ENGRAVE_WIDTH;\n return {\n ...(rendered ? { rendered } : { xml: musicXml }),\n scrollMode: mode,\n bars,\n bandTop: opts.bandTop ?? 0,\n bandHeight: opts.bandHeight ?? rctx.H,\n hostWidth,\n // Fit to (up to) the full canvas width instead of `notationLayout`'s\n // default `safeBox(W,H).centeredW` — that default reserves ~24% of\n // width for the PROMO video path's caption/share-button chrome (see\n // `NotationLayoutOpts.boxWidth`'s doc comment), which doesn't apply to\n // this plain, chrome-less player canvas. Capped at the same\n // `MAX_ENGRAVE_WIDTH` (converted CSS px → device px) as the engrave\n // width itself, so a very wide desktop host doesn't stretch systems\n // past a readable size even though the canvas has room for it.\n ...(widened ? { bandWidth: Math.min(MAX_ENGRAVE_WIDTH * dpr, rctx.W) } : {}),\n };\n }\n const scrollCursorProps: ScrollCursorProps = { scrollMode: mode, barDurMs, noteCols };\n\n let renderedRn: RenderedNotation | null = null;\n let destroyed = false;\n // The host width the CURRENT `renderedRn` was actually engraved at — kept\n // in sync with every real (re-)engrave so `performResize` can tell \"host\n // got bigger/smaller, systems should re-flow\" apart from \"canvas device\n // pixels changed but the CSS width driving line-breaking didn't\" (e.g. a\n // pure devicePixelRatio or height-only change), which stays on the cheap\n // reuse-the-bitmap path.\n let engravedHostWidthPx = 0;\n\n const ready = (async () => {\n engravedHostWidthPx = desiredEngraveWidth(host);\n await notation.init(rctx, notationProps(opts.rendered, engravedHostWidthPx));\n renderedRn = getNotationEngraving(rctx)?.rendered ?? null;\n await scrollCursor.init(rctx, scrollCursorProps);\n })();\n\n function clear(): void {\n ctx2d!.save();\n ctx2d!.setTransform(1, 0, 0, 1, 0, 0);\n ctx2d!.fillStyle = theme.paper;\n ctx2d!.fillRect(0, 0, canvas.width, canvas.height);\n ctx2d!.restore();\n }\n\n function setTime(tMs: number): void {\n if (destroyed) return;\n lastTMs = tMs;\n clear();\n notation.draw(rctx, tMs);\n scrollCursor.draw(rctx, tMs);\n }\n\n async function performResize(): Promise<void> {\n const [w, h] = frameSize(host, dpr, opts.size);\n const newEngraveWidth = desiredEngraveWidth(host);\n // Only a REAL OSMD re-engrave (not the cheap bitmap-reuse relayout below)\n // can change how many measures fit per system — see `desiredEngraveWidth`'s\n // doc comment. Gated on `!opts.rendered`: a caller that supplied a\n // pre-rasterized `rendered` (the headless/test seam) never had a real xml\n // engrave to begin with, and `notation.init` can't run OSMD against jsdom\n // — re-deriving `engravedHostWidthPx` for that seam would just make this\n // check misfire on the next resize, so it's skipped entirely.\n const needsReEngrave = !opts.rendered && renderedRn != null && newEngraveWidth !== engravedHostWidthPx;\n if (w === canvas.width && h === canvas.height && !needsReEngrave) return;\n canvas.width = w;\n canvas.height = h;\n rctx.W = w;\n rctx.H = h;\n rctx.safeBox = safeBox(w, h);\n if (needsReEngrave) {\n // Host width materially changed (e.g. mobile→desktop, a fullscreen\n // toggle, or a page-layout reflow) — re-run OSMD so systems re-flow at\n // the new width instead of just rescaling the old, narrower engrave.\n engravedHostWidthPx = newEngraveWidth;\n await notation.init(rctx, notationProps(undefined, engravedHostWidthPx));\n renderedRn = getNotationEngraving(rctx)?.rendered ?? null;\n await scrollCursor.init(rctx, scrollCursorProps);\n } else if (renderedRn) {\n // Cheap re-layout: reuses the already-rasterized bitmap (no OSMD re-run).\n await notation.init(rctx, notationProps(renderedRn, engravedHostWidthPx));\n await scrollCursor.init(rctx, scrollCursorProps);\n }\n if (!destroyed) setTime(lastTMs);\n }\n\n function currentLayout(): NotationLayout | null {\n const eng = getNotationEngraving(rctx);\n if (!eng) return null;\n return eng.followLayoutAt ? eng.followLayoutAt(rctx, lastTMs) : eng.base;\n }\n\n const clickListeners: Array<(measureIndex: number) => void> = [];\n function onCanvasClick(e: MouseEvent): void {\n const rect = canvas.getBoundingClientRect();\n if (!(rect.width > 0) || !(rect.height > 0)) return;\n const mx = (e.clientX - rect.left) * (canvas.width / rect.width);\n const my = (e.clientY - rect.top) * (canvas.height / rect.height);\n const layout = currentLayout();\n if (!layout) return;\n const idx = hitTestMeasureAt(layout, mx, my);\n if (idx != null) for (const cb of clickListeners) cb(idx);\n }\n canvas.addEventListener('click', onCanvasClick);\n\n return {\n ready,\n setTime,\n resize(): void {\n void performResize();\n },\n onMeasureClick(cb: (measureIndex: number) => void): () => void {\n clickListeners.push(cb);\n return () => {\n const i = clickListeners.indexOf(cb);\n if (i >= 0) clickListeners.splice(i, 1);\n };\n },\n destroy(): void {\n if (destroyed) return;\n destroyed = true;\n canvas.removeEventListener('click', onCanvasClick);\n notation.dispose?.();\n scrollCursor.dispose?.();\n clickListeners.length = 0;\n if (canvas.parentNode === host) host.removeChild(canvas);\n },\n };\n}\n\n/**\n * `display:'scroll'` implementation (0.37.0) — the tiled, page-scrolling\n * full-score player. See `CreateNotationPlayerOpts.display`'s doc for the\n * design summary. Structurally independent of `createWindowPlayer`: it does\n * NOT use the `notation`/`scroll-cursor` Layer pair (those are built around\n * ONE bounded canvas + a follow camera — the opposite of \"show the whole\n * score, let the PAGE scroll\"). It reuses the SAME pure geometry\n * (`renderNotation`'s raster, `flowLayout` → `notationLayout`,\n * `audioPlayheadLine`/`vstackAudioPlayheadLine`, `hitTestMeasureAt`) instead\n * of the Layer wrappers around it.\n *\n * AUTO-FOLLOW — exact rule (per the design's requirement to document it):\n * - Suspended the instant the PAGE scrolls for a reason other than this\n * player's own `scrollIntoView` call. Every `scrollIntoView` call opens a\n * short (600ms) \"programmatic scroll\" grace window (`programmaticScrollUntil`);\n * a `window` `scroll` event firing AFTER that window closes is treated as\n * manual and suspends auto-follow.\n * - Re-armed on the next `setTime(tMs)` call that looks like a seek/play\n * discontinuity rather than a normal per-frame tick, defined operationally\n * (a live player has no other signal to distinguish the two) as: `tMs`\n * moving BACKWARD since the previous call, OR the elapsed MUSIC time\n * (`tMs` delta) disagreeing with the elapsed WALL-CLOCK time between the\n * two `setTime` calls by more than `SEEK_DISCONTINUITY_MS` (400ms) — i.e.\n * the caller visibly jumped the playhead instead of ticking it forward\n * one frame at a time. The very first `setTime` call also re-arms (no\n * prior wall-clock sample to compare against).\n * - When armed, a rAF-debounced check (at most one `scrollIntoView` decision\n * per animation frame, regardless of how many `setTime` calls land within\n * it) re-centers the playhead ONLY when it has left a comfortable middle\n * band of the viewport (25%-75% of `window.innerHeight`) — so a playhead\n * already near-centered never triggers a redundant/jittery scroll.\n */\nfunction createScrollPlayer(opts: CreateNotationPlayerOpts): NotationPlayer {\n const { host, musicXml, noteCols, barDurMs, bars } = opts;\n const mode: NotationPlayerMode = opts.mode ?? 'vstack';\n const theme: PromoTheme = { ...DEFAULT_THEME, ...opts.theme };\n const dpr = resolveDpr();\n const onsets = distinctOnsets(opts.onsetsMs.map((onsetMs) => ({ onsetMs })));\n\n const root = document.createElement('div');\n root.style.position = 'relative';\n root.style.width = '100%';\n host.appendChild(root);\n\n const playheadEl = document.createElement('div');\n playheadEl.style.position = 'absolute';\n playheadEl.style.left = '0px';\n playheadEl.style.top = '0px';\n playheadEl.style.width = '2px';\n playheadEl.style.height = '0px';\n playheadEl.style.background = theme.accent;\n playheadEl.style.opacity = '0';\n // Never intercept clicks — the 2px-wide line otherwise shadows a thin\n // vertical strip of every tile underneath it for the entire page height.\n playheadEl.style.pointerEvents = 'none';\n root.appendChild(playheadEl);\n\n let rn: RenderedNotation | null = null;\n let currentFlow: NotationLayout | null = null;\n let engravedHostWidthPx = 0;\n let destroyed = false;\n let lastTMs = 0;\n\n const clickListeners: Array<(measureIndex: number) => void> = [];\n const tileCanvases: HTMLCanvasElement[] = [];\n const tileClickHandlers: Array<{ el: HTMLCanvasElement; fn: (e: MouseEvent) => void }> = [];\n\n async function doEngrave(hostWidthPx: number): Promise<RenderedNotation> {\n if (opts.rendered) return opts.rendered;\n // Literal dynamic import, same reason as notation.ts's own: consumers'\n // bundlers must statically see the specifier, and callers using the\n // `rendered` test seam never need `renderNotation`/OSMD pulled in at all.\n const { renderNotation } = await import('./promo');\n return renderNotation(musicXml, { bars, paper: theme.paper, scrollMode: mode, hostWidth: hostWidthPx });\n }\n\n function clearTiles(): void {\n for (const { el, fn } of tileClickHandlers) el.removeEventListener('click', fn);\n tileClickHandlers.length = 0;\n for (const c of tileCanvases) c.remove();\n tileCanvases.length = 0;\n }\n\n // Per-tile hit-testing mapped through tile offsets to the existing measure\n // hit-test (design point 5): a click's LOCAL (mx, myLocal) inside one tile\n // canvas is translated into the SAME full-score device-px space\n // `currentFlow.measures[].box` lives in by adding that tile's own y-offset\n // (`computeScrollTiles`'s `offsets[i]`, captured per-tile at build time) —\n // then it's the exact same `hitTestMeasureAt` window mode uses.\n function handleTileClick(e: MouseEvent, canvas: HTMLCanvasElement, tileOffsetDev: number): void {\n if (!currentFlow) return;\n const rect = canvas.getBoundingClientRect();\n if (!(rect.width > 0) || !(rect.height > 0)) return;\n const mx = (e.clientX - rect.left) * (canvas.width / rect.width);\n const myLocal = (e.clientY - rect.top) * (canvas.height / rect.height);\n const idx = hitTestMeasureAt(currentFlow, mx, tileOffsetDev + myLocal);\n if (idx != null) for (const cb of clickListeners) cb(idx);\n }\n\n function buildTiles(flow: NotationLayout, dispWdev: number): void {\n clearTiles();\n if (!rn) return;\n const spec = computeScrollTiles(dispWdev, flow.rect.dh, dpr);\n // Device px of SOURCE raster per device px of DEST tile — uniform (no\n // distortion) since `flowLayout` scales width and height by the same\n // factor; derived from the SAME flow layout every tile blits from, not\n // re-computed per tile.\n const scale = flow.src.w > 0 ? flow.rect.dw / flow.src.w : 1;\n for (let i = 0; i < spec.count; i++) {\n const c = document.createElement('canvas');\n c.style.display = 'block';\n c.style.width = '100%';\n c.style.height = `${spec.heights[i] / dpr}px`;\n // Same reasoning as the window-mode canvas: let the page's own vertical\n // swipe/wheel gesture through (this IS the primary scroll mechanism in\n // scroll mode) while still letting a near-zero-movement tap reach the\n // click handler below.\n c.style.touchAction = 'pan-y';\n c.width = Math.max(1, Math.round(dispWdev));\n c.height = Math.max(1, Math.round(spec.heights[i]));\n root.insertBefore(c, playheadEl);\n tileCanvases.push(c);\n const tileOffsetDev = spec.offsets[i];\n const onClick = (e: MouseEvent) => handleTileClick(e, c, tileOffsetDev);\n c.addEventListener('click', onClick);\n tileClickHandlers.push({ el: c, fn: onClick });\n const cctx = c.getContext('2d');\n if (cctx) {\n cctx.fillStyle = theme.paper;\n cctx.fillRect(0, 0, c.width, c.height);\n // This tile's slice of the SOURCE raster, in raster (src) px — the\n // inverse of `flowLayout`'s forward map, sliced to just this tile's\n // y-range. One drawImage per tile, same drawImage SHAPE window mode's\n // `notation` layer uses (src rect → dest rect), just y-sliced.\n const srcY = flow.src.y + tileOffsetDev / scale;\n const srcH = spec.heights[i] / scale;\n cctx.drawImage(rn.canvas, flow.src.x, srcY, flow.src.w, srcH, 0, 0, c.width, c.height);\n }\n }\n }\n\n function relayout(): void {\n if (destroyed || !rn) return;\n const [w] = frameSize(host, dpr, opts.size);\n const flow = flowLayout(rn, w);\n currentFlow = flow;\n buildTiles(flow, w);\n updatePlayhead(lastTMs);\n }\n\n const ready = (async () => {\n engravedHostWidthPx = desiredEngraveWidth(host);\n rn = await doEngrave(engravedHostWidthPx);\n relayout();\n })();\n\n async function performResize(): Promise<void> {\n if (destroyed || !rn) return;\n const newEngraveWidth = desiredEngraveWidth(host);\n // Same re-engrave condition as window mode's `performResize` — see its\n // comment for why `!opts.rendered` gates it (the headless/test seam has\n // no real xml engrave to redo).\n const needsReEngrave = !opts.rendered && newEngraveWidth !== engravedHostWidthPx;\n if (needsReEngrave) {\n engravedHostWidthPx = newEngraveWidth;\n rn = await doEngrave(engravedHostWidthPx);\n }\n // Unlike window mode, ALWAYS relayout+rebuild here (not just on\n // re-engrave): a scroll-mode tile's pixel width/height depends directly\n // on `dispWdev`/`dpr`, which can change (window resize, DPR change via a\n // display swap) without crossing the re-engrave breakpoint.\n relayout();\n }\n\n // ─── Auto-follow — see this function's doc comment for the exact rule ────\n const SEEK_DISCONTINUITY_MS = 400;\n let lastWallMs: number | null = null;\n let lastMusicMs = 0;\n let autoFollowSuspended = false;\n let programmaticScrollUntil = 0;\n let followRafPending = false;\n\n function onWindowScroll(): void {\n if (nowMs() > programmaticScrollUntil) autoFollowSuspended = true;\n }\n const hasWindow = typeof window !== 'undefined' && typeof window.addEventListener === 'function';\n if (hasWindow) window.addEventListener('scroll', onWindowScroll, { passive: true });\n\n function detectDiscontinuity(tMs: number): void {\n const now = nowMs();\n if (lastWallMs != null) {\n const dtMusic = tMs - lastMusicMs;\n const dtWall = now - lastWallMs;\n if (dtMusic < 0 || Math.abs(dtMusic - dtWall) > SEEK_DISCONTINUITY_MS) {\n autoFollowSuspended = false;\n }\n } else {\n autoFollowSuspended = false;\n }\n lastWallMs = now;\n lastMusicMs = tMs;\n }\n\n function maybeAutoFollow(): void {\n if (autoFollowSuspended || followRafPending) return;\n if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') return;\n followRafPending = true;\n window.requestAnimationFrame(() => {\n followRafPending = false;\n if (destroyed || autoFollowSuspended) return;\n if (typeof playheadEl.getBoundingClientRect !== 'function') return;\n const rect = playheadEl.getBoundingClientRect();\n const vh = window.innerHeight;\n if (!(vh > 0)) return;\n if (rect.top >= vh * 0.25 && rect.top <= vh * 0.75) return; // comfortably in view\n if (typeof playheadEl.scrollIntoView === 'function') {\n programmaticScrollUntil = nowMs() + 600;\n playheadEl.scrollIntoView({ block: 'center', behavior: 'smooth' });\n }\n });\n }\n\n function updatePlayhead(tMs: number): void {\n lastTMs = tMs;\n if (!currentFlow || !rn) return;\n // Same dispatch scrollCursor.ts's draw() uses — SAME functions, just fed\n // the full-score `flowLayout` instead of the camera-cropped follow\n // layout, so `y` naturally spans the WHOLE tile stack (carriage returns\n // = y jumps between systems, exactly as window mode's vstack cursor\n // jumps between follow-window rows — no per-tile special-casing).\n const line = mode === 'vstack'\n ? vstackAudioPlayheadLine(currentFlow, onsets, tMs, measureCount(rn), noteCols)\n : audioPlayheadLine(currentFlow, onsets, tMs, barDurMs, noteCols);\n if (!line) {\n playheadEl.style.opacity = '0';\n return;\n }\n playheadEl.style.opacity = String(line.alpha);\n playheadEl.style.left = `${line.x / dpr}px`;\n playheadEl.style.top = `${line.y0 / dpr}px`;\n playheadEl.style.height = `${Math.max(0, line.y1 - line.y0) / dpr}px`;\n maybeAutoFollow();\n }\n\n function setTime(tMs: number): void {\n if (destroyed) return;\n detectDiscontinuity(tMs);\n updatePlayhead(tMs);\n }\n\n return {\n ready,\n setTime,\n resize(): void {\n void performResize();\n },\n onMeasureClick(cb: (measureIndex: number) => void): () => void {\n clickListeners.push(cb);\n return () => {\n const i = clickListeners.indexOf(cb);\n if (i >= 0) clickListeners.splice(i, 1);\n };\n },\n destroy(): void {\n if (destroyed) return;\n destroyed = true;\n if (hasWindow) window.removeEventListener('scroll', onWindowScroll);\n clearTiles();\n clickListeners.length = 0;\n if (root.parentNode === host) host.removeChild(root);\n },\n };\n}\n\n/**\n * Build a live, interactive notation player. Dispatches on\n * `opts.display` (default `'window'`) — see the module doc's \"DISPLAY MODES\"\n * section, `NotationPlayerDisplay`, `createWindowPlayer`, and\n * `createScrollPlayer`.\n */\nexport function createNotationPlayer(opts: CreateNotationPlayerOpts): NotationPlayer {\n return (opts.display ?? 'window') === 'scroll' ? createScrollPlayer(opts) : createWindowPlayer(opts);\n}\n"],"mappings":"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","names":[]}
|
package/dist/scene/index.d.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { A as AudioClock, S as Score, a as LayerFactory, R as RenderCtx, b as Sc
|
|
|
2
2
|
export { L as Layer, d as SpectrumInput, e as SpectrumProps, W as WaveformInput, f as WaveformProps, s as scoreFromMusicXML, g as spectrumFactory, w as waveformFactory } from '../waveform-DdSMAbYQ.js';
|
|
3
3
|
import { PromoTheme, RecordOpts, Scene, SafeBox } from '../video.js';
|
|
4
4
|
import { RenderedNotation, Box } from '../promo.js';
|
|
5
|
-
import { N as NotationLayout } from '../notationGeometry-
|
|
6
|
-
export { F as FOLLOW_BARS, a as FOLLOW_PAD, b as NotationLayoutOpts, c as NotationRect, P as PlayheadLine, d as audioPlayheadLine, e as cropAroundBox, f as cubicEaseInOut, g as distinctMeasureIndices, h as distinctOnsets, i as firstMeasureBox, j as followBoxAt, k as followWindowStart, l as lerpBox, m as measureColumnsFromLayout, n as measureCount, o as measureSpanBox, p as measureSystemMap, q as notationLayout, r as playheadLine, s as systemBox, v as vstackAudioPlayheadLine, t as vstackFollowBox } from '../notationGeometry-
|
|
5
|
+
import { N as NotationLayout } from '../notationGeometry-CyYXJrUH.js';
|
|
6
|
+
export { F as FOLLOW_BARS, a as FOLLOW_PAD, b as NotationLayoutOpts, c as NotationRect, P as PlayheadLine, d as audioPlayheadLine, e as cropAroundBox, f as cubicEaseInOut, g as distinctMeasureIndices, h as distinctOnsets, i as firstMeasureBox, j as followBoxAt, k as followWindowStart, l as lerpBox, m as measureColumnsFromLayout, n as measureCount, o as measureSpanBox, p as measureSystemMap, q as notationLayout, r as playheadLine, s as systemBox, v as vstackAudioPlayheadLine, t as vstackFollowBox } from '../notationGeometry-CyYXJrUH.js';
|
|
7
7
|
|
|
8
8
|
/** One scheduled audio event, in seconds RELATIVE to the schedule start. */
|
|
9
9
|
interface AudioEvent {
|
|
@@ -538,6 +538,21 @@ interface NotationProps {
|
|
|
538
538
|
bandTop?: number;
|
|
539
539
|
/** Height of the notation band (screen px). Default safeBox.bottom - bandTop. */
|
|
540
540
|
bandHeight?: number;
|
|
541
|
+
/** Offscreen OSMD host div width in CSS px, forwarded verbatim to
|
|
542
|
+
* `renderNotation`'s `hostWidth` (default 560 there when omitted). This is
|
|
543
|
+
* the width OSMD line-breaks against — a live player wanting engraved
|
|
544
|
+
* systems to use its actual container width (rather than always the
|
|
545
|
+
* 560px default meant for a fixed-size promo card) sets this to its own
|
|
546
|
+
* measured host size. Ignored when `rendered` is supplied (no engrave
|
|
547
|
+
* happens). */
|
|
548
|
+
hostWidth?: number;
|
|
549
|
+
/** Explicit horizontal fit width (screen px), forwarded to `notationLayout`'s
|
|
550
|
+
* `boxWidth` (see its doc comment) — bypasses `safeBox`'s promo-video
|
|
551
|
+
* caption/share-button margins (~24% of width reserved) so the engraving
|
|
552
|
+
* can use the FULL band width instead. Default: unset (existing
|
|
553
|
+
* `safeBox(W,H).centeredW` promo framing, unchanged for every caller that
|
|
554
|
+
* doesn't pass this). */
|
|
555
|
+
bandWidth?: number;
|
|
541
556
|
}
|
|
542
557
|
declare const notationFactory: LayerFactory<NotationProps>;
|
|
543
558
|
|
|
@@ -621,6 +636,14 @@ interface NotationEngraving {
|
|
|
621
636
|
/** Notation band rect (screen px) the follow window fits into, per frame. */
|
|
622
637
|
bandTop: number;
|
|
623
638
|
bandHeight: number;
|
|
639
|
+
/** Explicit horizontal fit width (screen px), forwarded to `notationLayout`'s
|
|
640
|
+
* `boxWidth` — undefined keeps the default `safeBox(W,H).centeredW` promo
|
|
641
|
+
* framing (see `notationGeometry.ts`'s `NotationLayoutOpts.boxWidth` doc).
|
|
642
|
+
* Published so `scroll-cursor`'s per-frame follow layout uses the SAME
|
|
643
|
+
* width `notation`'s base layout was computed with — they must agree, or
|
|
644
|
+
* the visible framing jumps between the base frame and the first followed
|
|
645
|
+
* frame. */
|
|
646
|
+
bandWidth?: number;
|
|
624
647
|
/**
|
|
625
648
|
* A pure follow-layout provider, published by scroll-cursor at init. notation
|
|
626
649
|
* calls it each frame to blit the SAME followed-bars window the cursor sweeps —
|
package/dist/scene/index.js
CHANGED