@real-music-packages/web-core 0.36.2 → 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/notationPlayer.d.ts +143 -21
- package/dist/notationPlayer.js +252 -18
- package/dist/notationPlayer.js.map +1 -1
- package/package.json +1 -1
package/dist/notationPlayer.d.ts
CHANGED
|
@@ -24,12 +24,33 @@ import { PromoTheme } from './video.js';
|
|
|
24
24
|
* exercised of the two modes for live players (most real players show
|
|
25
25
|
* multi-line music, which needs vstack) — supported and correct today, but
|
|
26
26
|
* treat it as the less battle-tested choice.
|
|
27
|
+
*
|
|
28
|
+
* NOTE for `display: 'scroll'` (0.37.0): scroll mode shows the WHOLE score
|
|
29
|
+
* at once with no camera crop, which is only meaningful for a page-wrapped
|
|
30
|
+
* ('vstack') engraving — 'hstack' engraves the whole piece onto ONE very
|
|
31
|
+
* wide row, so a scroll-mode flow layout for it degenerates to a single
|
|
32
|
+
* short, squeezed tile. Scroll mode does not forbid 'hstack' (the geometry
|
|
33
|
+
* is agnostic), but in practice pick 'vstack' for it, same as window mode.
|
|
27
34
|
*/
|
|
28
35
|
type NotationPlayerMode = 'hstack' | 'vstack';
|
|
36
|
+
/**
|
|
37
|
+
* Which player shell to build. See the module doc's "DISPLAY MODES" section.
|
|
38
|
+
* Default `'window'` — every pre-0.37.0 caller (and promos/whozart, which never
|
|
39
|
+
* set this) keeps the exact camera-band behavior, byte-for-byte.
|
|
40
|
+
*/
|
|
41
|
+
type NotationPlayerDisplay = 'window' | 'scroll';
|
|
29
42
|
interface NotationPlayerTheme extends Partial<PromoTheme> {
|
|
30
43
|
}
|
|
31
44
|
interface CreateNotationPlayerOpts {
|
|
32
|
-
/** Element the player's
|
|
45
|
+
/** Element the player's content is mounted into.
|
|
46
|
+
* - `display:'window'` (default): a single canvas that FILLS the host
|
|
47
|
+
* (`width:100%;height:100%`) — the host's own size is the frame.
|
|
48
|
+
* - `display:'scroll'`: an internal wrapper that fills the host
|
|
49
|
+
* HORIZONTALLY but is left to its NATURAL height (the whole score's
|
|
50
|
+
* height at the host's width) — the host must not force/clip a fixed
|
|
51
|
+
* height (no `overflow:hidden` + fixed height) or native page scroll
|
|
52
|
+
* can't reach the tiles below the fold. This is the one structural
|
|
53
|
+
* assumption scroll mode makes about its host; window mode has none. */
|
|
33
54
|
host: HTMLElement;
|
|
34
55
|
/** MusicXML to engrave. */
|
|
35
56
|
musicXml: string;
|
|
@@ -60,6 +81,14 @@ interface CreateNotationPlayerOpts {
|
|
|
60
81
|
* (noted as a follow-up, not built: nothing in the shared machinery makes a
|
|
61
82
|
* live re-layout trivial). */
|
|
62
83
|
mode?: NotationPlayerMode;
|
|
84
|
+
/**
|
|
85
|
+
* Which player shell to build — `'window'` (default, the original
|
|
86
|
+
* camera-band player) or `'scroll'` (0.37.0, a tiled full-score display the
|
|
87
|
+
* PAGE scrolls natively). See the module doc's "DISPLAY MODES" section and
|
|
88
|
+
* `NotationPlayerDisplay`. Fixed for the life of the instance, same
|
|
89
|
+
* reasoning as `mode`: create a new player if it must change.
|
|
90
|
+
*/
|
|
91
|
+
display?: NotationPlayerDisplay;
|
|
63
92
|
/** Duration of one measure in ms — enables time-accurate cursor/window
|
|
64
93
|
* placement when `noteCols` isn't supplied (see `scroll-cursor`'s
|
|
65
94
|
* `barDurMs`). */
|
|
@@ -68,16 +97,21 @@ interface CreateNotationPlayerOpts {
|
|
|
68
97
|
* bars/drawFrom-drawUpTo) — engrave an excerpt rather than the whole score. */
|
|
69
98
|
bars?: [number, number];
|
|
70
99
|
/** Top of the notation band, screen px. Default 0 (fills the host — this is
|
|
71
|
-
* a UI widget, not a phone-safe video frame).
|
|
100
|
+
* a UI widget, not a phone-safe video frame). `display:'scroll'` IGNORES
|
|
101
|
+
* this — there is no fixed band to inset; the whole score is shown. */
|
|
72
102
|
bandTop?: number;
|
|
73
|
-
/** Height of the notation band, screen px. Default the full frame height.
|
|
103
|
+
/** Height of the notation band, screen px. Default the full frame height.
|
|
104
|
+
* `display:'scroll'` IGNORES this for the same reason as `bandTop`. */
|
|
74
105
|
bandHeight?: number;
|
|
75
106
|
/** Theme tokens (colours/fonts) forwarded to the layers. Any field omitted
|
|
76
107
|
* falls back to a neutral default. */
|
|
77
108
|
theme?: NotationPlayerTheme;
|
|
78
109
|
/** Explicit canvas size in device px. Default: host.clientWidth/clientHeight
|
|
79
110
|
* × devicePixelRatio. Pass this in test/headless environments where the
|
|
80
|
-
* host has no real layout (e.g. jsdom, where clientWidth is always 0).
|
|
111
|
+
* host has no real layout (e.g. jsdom, where clientWidth is always 0).
|
|
112
|
+
* `display:'scroll'` only reads the WIDTH component (`size[0]`) — the
|
|
113
|
+
* display height is derived from the score's own content, so `size[1]` is
|
|
114
|
+
* ignored in that mode. */
|
|
81
115
|
size?: [number, number];
|
|
82
116
|
/**
|
|
83
117
|
* Advanced / test seam: a pre-rasterized engraving, bypassing the browser
|
|
@@ -102,13 +136,18 @@ interface NotationPlayer {
|
|
|
102
136
|
* playback position; everything else (follow camera, onset-locked
|
|
103
137
|
* playhead) is a pure function of `tMs`, exactly as the promo/whozart path
|
|
104
138
|
* drives it.
|
|
139
|
+
*
|
|
140
|
+
* `display:'scroll'`: also feeds the auto-follow discontinuity detector —
|
|
141
|
+
* see `CreateNotationPlayerOpts.display`'s doc for the exact re-arm rule.
|
|
105
142
|
*/
|
|
106
143
|
setTime(tMs: number): void;
|
|
107
|
-
/** Re-measure the host and resize
|
|
144
|
+
/** Re-measure the host and resize/rebuild to match (device-px aware).
|
|
108
145
|
* Cheap after the first draw — reuses the already-rasterized engraving
|
|
109
|
-
* bitmap
|
|
110
|
-
*
|
|
111
|
-
*
|
|
146
|
+
* bitmap unless the host's CSS width crossed the engrave-width breakpoint
|
|
147
|
+
* (see `desiredEngraveWidth`), it does not otherwise re-run OSMD. Call on
|
|
148
|
+
* host resize / orientation change. Fire-and-forget (async internally; the
|
|
149
|
+
* next `setTime` reflects the new size once it lands, typically within a
|
|
150
|
+
* microtask). `display:'scroll'` rebuilds the whole tile stack. */
|
|
112
151
|
resize(): void;
|
|
113
152
|
/** Register a measure-click handler: fires with the clicked measure's
|
|
114
153
|
* engraved index (matching `ScoreNote.measure` numbering) when a click
|
|
@@ -116,7 +155,8 @@ interface NotationPlayer {
|
|
|
116
155
|
* may be registered (they all fire); returns an unsubscribe function for
|
|
117
156
|
* that one handler. `destroy()` also clears every remaining listener. */
|
|
118
157
|
onMeasureClick(cb: (measureIndex: number) => void): () => void;
|
|
119
|
-
/** Tear down: removes the canvas from `host` and drops
|
|
158
|
+
/** Tear down: removes the canvas/tiles from `host` and drops
|
|
159
|
+
* listeners/state (including the `display:'scroll'` page-scroll listener). */
|
|
120
160
|
destroy(): void;
|
|
121
161
|
}
|
|
122
162
|
/**
|
|
@@ -127,7 +167,11 @@ interface NotationPlayer {
|
|
|
127
167
|
* returned box back to its measure index, which `measureColumnsFromLayout`
|
|
128
168
|
* intentionally drops — the playhead has no use for it) is new here, and it
|
|
129
169
|
* is pure array/index bookkeeping, not geometry math. Exported standalone so
|
|
130
|
-
* it is unit-testable without a DOM/canvas.
|
|
170
|
+
* it is unit-testable without a DOM/canvas. Shared by BOTH display modes:
|
|
171
|
+
* `display:'window'` hit-tests against the followed (camera-cropped) layout;
|
|
172
|
+
* `display:'scroll'` hit-tests against the full-score `flowLayout` (see
|
|
173
|
+
* below) with the click's y already translated from tile-local into
|
|
174
|
+
* flow-layout space by the caller.
|
|
131
175
|
*
|
|
132
176
|
* COORDINATE SPACE — read this before calling directly: (mx, my) MUST be in
|
|
133
177
|
* the same DEST/DEVICE-PIXEL space `layout.measures[].box` is already mapped
|
|
@@ -142,17 +186,95 @@ interface NotationPlayer {
|
|
|
142
186
|
*/
|
|
143
187
|
declare function hitTestMeasureAt(layout: NotationLayout, mx: number, my: number): number | null;
|
|
144
188
|
/**
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
189
|
+
* Full-score "flow" layout for scroll mode: the ENTIRE engraved raster
|
|
190
|
+
* (`rn.content`, the same tight ink box `notationLayout` itself already falls
|
|
191
|
+
* back to) scaled to fill `dispWdev` device px of WIDTH, with height
|
|
192
|
+
* following naturally from the content's own aspect ratio — no camera crop,
|
|
193
|
+
* no bounded box (the opposite of the follow-window layout `notationLayout`
|
|
194
|
+
* computes for window mode via `{focusBox}`). Reuses `notationLayout` for ALL
|
|
195
|
+
* the actual scale/map/dx/dy math — no new geometry — via two calls:
|
|
196
|
+
*
|
|
197
|
+
* 1. a PROBE call with an arbitrarily large `boxH` (1e9 — real engraved
|
|
198
|
+
* music's content aspect ratio, height/width, is always many orders of
|
|
199
|
+
* magnitude below that, so this bound is never the true constraint; it
|
|
200
|
+
* exists ONLY so the width-bound branch is guaranteed to be taken,
|
|
201
|
+
* sidestepping a chicken-and-egg height guess) to read back the TRUE
|
|
202
|
+
* fitted height (`rect.dh`) `notationLayout` would compute for this
|
|
203
|
+
* width;
|
|
204
|
+
* 2. an EXACT call with `boxH` set to precisely that height, so the result
|
|
205
|
+
* is TOP-aligned (`rect.dy === 0`) rather than vertically centered
|
|
206
|
+
* inside an oversized probe box. Both calls share the identical `src` /
|
|
207
|
+
* `srcAspect` internally (same `rn`, `focusBox: null`, default
|
|
208
|
+
* `zoom01`), so the two `dh` values are bit-identical and the second
|
|
209
|
+
* call's `dh > boxH` branch is never taken (equal, not greater).
|
|
210
|
+
*
|
|
211
|
+
* Exported so scroll-mode's tile partition (`computeScrollTiles`) and the
|
|
212
|
+
* playhead's y-mapping can be unit-tested against it directly, without a DOM.
|
|
213
|
+
*/
|
|
214
|
+
declare function flowLayout(rn: RenderedNotation, dispWdev: number): NotationLayout;
|
|
215
|
+
/** Target tile height, CSS px — ~2x a typical viewport, so a tile shows
|
|
216
|
+
* roughly "one screenful plus one" of context, and system-boundary carriage
|
|
217
|
+
* returns rarely straddle a tile seam. The ACTUAL tile height is this OR the
|
|
218
|
+
* area-cap-derived height (`SCROLL_TILE_MAX_AREA_PX`), whichever is
|
|
219
|
+
* SMALLER — so a high-dpr device automatically gets shorter (CSS-px) tiles
|
|
220
|
+
* rather than ever exceeding the backing-store area cap; see
|
|
221
|
+
* `computeScrollTiles`. */
|
|
222
|
+
declare const SCROLL_TILE_TARGET_CSS_PX = 1600;
|
|
223
|
+
/** Per-tile canvas backing-store area cap, device px². Comfortably under both
|
|
224
|
+
* iOS Safari's ~16.7M px² (4096×4096) canvas-backing-store limit AND
|
|
225
|
+
* `promo.ts`'s own `MAX_RASTER_AREA_PX` (12M — the cap for the SOURCE raster
|
|
226
|
+
* a tile reads FROM): a tile is a separate, smaller destination canvas than
|
|
227
|
+
* the source raster, so it gets its own, tighter cap; 8M leaves comfortable
|
|
228
|
+
* headroom under both limits at any realistic tile width. */
|
|
229
|
+
declare const SCROLL_TILE_MAX_AREA_PX = 8000000;
|
|
230
|
+
/** One vertical tile of the full-score raster, in the same device-px space
|
|
231
|
+
* `flowLayout` maps into. */
|
|
232
|
+
interface ScrollTileSpec {
|
|
233
|
+
/** Number of vertical tiles covering the full score. Always >= 1. */
|
|
234
|
+
count: number;
|
|
235
|
+
/** Per-tile height, device px. `heights.length === count`,
|
|
236
|
+
* `sum(heights) === totalHeightDev` (within float precision). */
|
|
237
|
+
heights: number[];
|
|
238
|
+
/** Per-tile top y-offset, device px, within the full-score raster
|
|
239
|
+
* (`flowLayout`'s coordinate space). `offsets.length === count`,
|
|
240
|
+
* `offsets[0] === 0`, `offsets[i+1] === offsets[i] + heights[i]`. */
|
|
241
|
+
offsets: number[];
|
|
242
|
+
/** Total display height, device px (== the flow layout's `rect.dh`). */
|
|
243
|
+
totalHeightDev: number;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Pure tile-partition math (unit-tested, no DOM) — same shape/discipline as
|
|
247
|
+
* `clampRasterDpr` in `src/promo.ts`: given the full-score display width and
|
|
248
|
+
* height in device px (`dispWdev`/`totalHeightDev` — `flowLayout`'s own
|
|
249
|
+
* `rect.dw`/`rect.dh`) and the active `dpr`, partition the height into N
|
|
250
|
+
* EQUAL-height tiles such that:
|
|
251
|
+
*
|
|
252
|
+
* - each tile's target height is `SCROLL_TILE_TARGET_CSS_PX * dpr` device
|
|
253
|
+
* px (~2x viewport) UNLESS that would push a tile's own backing-store
|
|
254
|
+
* area (`dispWdev * tileHeightDev`) over `capPx2` — in which case the
|
|
255
|
+
* tile height is derived FROM the area cap instead. This is COMPUTED
|
|
256
|
+
* from `dispWdev`/`dpr` every call, not assumed safe at a fixed CSS
|
|
257
|
+
* height — a very wide host at a high dpr still gets a shorter tile, so
|
|
258
|
+
* the cap holds "at any DPR" as the design requires.
|
|
259
|
+
* - tiles split EVENLY (`totalHeightDev / count`), not
|
|
260
|
+
* max-height-tile-then-a-small-remainder — so there's never an oddly
|
|
261
|
+
* short final tile, and every tile (including the last) is <= the area
|
|
262
|
+
* cap by construction (see the proof in the inline comment below).
|
|
263
|
+
* - a score shorter than one tile's max height gets exactly ONE tile (the
|
|
264
|
+
* degenerate/short-score case) — tiling only exists to keep any single
|
|
265
|
+
* canvas's backing-store area under the cap, which is already true for
|
|
266
|
+
* the whole score at that size, so a single tile is simplest.
|
|
267
|
+
*/
|
|
268
|
+
declare function computeScrollTiles(dispWdev: number, totalHeightDev: number, dpr: number, opts?: {
|
|
269
|
+
targetTileCssPx?: number;
|
|
270
|
+
capPx2?: number;
|
|
271
|
+
}): ScrollTileSpec;
|
|
272
|
+
/**
|
|
273
|
+
* Build a live, interactive notation player. Dispatches on
|
|
274
|
+
* `opts.display` (default `'window'`) — see the module doc's "DISPLAY MODES"
|
|
275
|
+
* section, `NotationPlayerDisplay`, `createWindowPlayer`, and
|
|
276
|
+
* `createScrollPlayer`.
|
|
155
277
|
*/
|
|
156
278
|
declare function createNotationPlayer(opts: CreateNotationPlayerOpts): NotationPlayer;
|
|
157
279
|
|
|
158
|
-
export { type CreateNotationPlayerOpts, type NotationPlayer, type NotationPlayerMode, type NotationPlayerTheme, createNotationPlayer, hitTestMeasureAt };
|
|
280
|
+
export { type CreateNotationPlayerOpts, type NotationPlayer, type NotationPlayerDisplay, type NotationPlayerMode, type NotationPlayerTheme, SCROLL_TILE_MAX_AREA_PX, SCROLL_TILE_TARGET_CSS_PX, type ScrollTileSpec, computeScrollTiles, createNotationPlayer, flowLayout, hitTestMeasureAt };
|
package/dist/notationPlayer.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
|
+
audioPlayheadLine,
|
|
3
|
+
distinctOnsets,
|
|
2
4
|
getNotationEngraving,
|
|
3
5
|
measureColumnsFromLayout,
|
|
6
|
+
measureCount,
|
|
4
7
|
notationFactory,
|
|
5
|
-
|
|
8
|
+
notationLayout,
|
|
9
|
+
scrollCursorFactory,
|
|
10
|
+
vstackAudioPlayheadLine
|
|
6
11
|
} from "./chunk-4565POLG.js";
|
|
7
12
|
import {
|
|
8
13
|
safeBox
|
|
@@ -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 };
|
|
@@ -68,19 +121,8 @@ function createNotationPlayer(opts) {
|
|
|
68
121
|
canvas.style.height = "100%";
|
|
69
122
|
canvas.style.touchAction = "pan-y";
|
|
70
123
|
host.appendChild(canvas);
|
|
71
|
-
const dpr =
|
|
72
|
-
|
|
73
|
-
if (opts.size) return opts.size;
|
|
74
|
-
const w = Math.max(1, Math.round((host.clientWidth || 1) * dpr));
|
|
75
|
-
const h = Math.max(1, Math.round((host.clientHeight || 1) * dpr));
|
|
76
|
-
return [w, h];
|
|
77
|
-
}
|
|
78
|
-
const DEFAULT_ENGRAVE_WIDTH = 560;
|
|
79
|
-
const MAX_ENGRAVE_WIDTH = 1150;
|
|
80
|
-
function desiredEngraveWidth() {
|
|
81
|
-
return (host.clientWidth || 0) > DEFAULT_ENGRAVE_WIDTH ? MAX_ENGRAVE_WIDTH : DEFAULT_ENGRAVE_WIDTH;
|
|
82
|
-
}
|
|
83
|
-
const [initW, initH] = frameSize();
|
|
124
|
+
const dpr = resolveDpr();
|
|
125
|
+
const [initW, initH] = frameSize(host, dpr, opts.size);
|
|
84
126
|
canvas.width = initW;
|
|
85
127
|
canvas.height = initH;
|
|
86
128
|
const ctx2d = canvas.getContext("2d");
|
|
@@ -124,7 +166,7 @@ function createNotationPlayer(opts) {
|
|
|
124
166
|
let destroyed = false;
|
|
125
167
|
let engravedHostWidthPx = 0;
|
|
126
168
|
const ready = (async () => {
|
|
127
|
-
engravedHostWidthPx = desiredEngraveWidth();
|
|
169
|
+
engravedHostWidthPx = desiredEngraveWidth(host);
|
|
128
170
|
await notation.init(rctx, notationProps(opts.rendered, engravedHostWidthPx));
|
|
129
171
|
renderedRn = getNotationEngraving(rctx)?.rendered ?? null;
|
|
130
172
|
await scrollCursor.init(rctx, scrollCursorProps);
|
|
@@ -144,8 +186,8 @@ function createNotationPlayer(opts) {
|
|
|
144
186
|
scrollCursor.draw(rctx, tMs);
|
|
145
187
|
}
|
|
146
188
|
async function performResize() {
|
|
147
|
-
const [w, h] = frameSize();
|
|
148
|
-
const newEngraveWidth = desiredEngraveWidth();
|
|
189
|
+
const [w, h] = frameSize(host, dpr, opts.size);
|
|
190
|
+
const newEngraveWidth = desiredEngraveWidth(host);
|
|
149
191
|
const needsReEngrave = !opts.rendered && renderedRn != null && newEngraveWidth !== engravedHostWidthPx;
|
|
150
192
|
if (w === canvas.width && h === canvas.height && !needsReEngrave) return;
|
|
151
193
|
canvas.width = w;
|
|
@@ -205,8 +247,200 @@ function createNotationPlayer(opts) {
|
|
|
205
247
|
}
|
|
206
248
|
};
|
|
207
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
|
+
}
|
|
208
438
|
export {
|
|
439
|
+
SCROLL_TILE_MAX_AREA_PX,
|
|
440
|
+
SCROLL_TILE_TARGET_CSS_PX,
|
|
441
|
+
computeScrollTiles,
|
|
209
442
|
createNotationPlayer,
|
|
443
|
+
flowLayout,
|
|
210
444
|
hitTestMeasureAt
|
|
211
445
|
};
|
|
212
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 // 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 = 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 // 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 above, which only controls how big the (already-engraved) raster is\n // blitted. Left unset, `renderNotation` defaults to a fixed 560px host,\n // sized for the promo card this draw path was built for; a live player\n // embedded in a wide desktop container inherits that same narrow 560px\n // line-breaking regardless of how big its own host is, so systems come out\n // engraved for a phone-width column and then get letterboxed (aspect-fit,\n // see `notationLayout`) into the middle of the real, wider canvas — narrow\n // 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`), so a host\n // narrower than 1150px never actually shows more than it has room for —\n // only the OFFSCREEN raster is engraved wider than strictly necessary, at\n // the (cheap, one-time) cost of engraving a system with a couple of unused\n // measures's worth of headroom baked in.\n const DEFAULT_ENGRAVE_WIDTH = 560;\n const MAX_ENGRAVE_WIDTH = 1150;\n function desiredEngraveWidth(): number {\n return (host.clientWidth || 0) > DEFAULT_ENGRAVE_WIDTH ? MAX_ENGRAVE_WIDTH : DEFAULT_ENGRAVE_WIDTH;\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 | 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();\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();\n const newEngraveWidth = desiredEngraveWidth();\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"],"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":[]}
|