@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.
@@ -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 canvas is mounted into (fills it). */
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 the canvas to match (device-px aware).
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, it does not re-run OSMD. Call on host resize / orientation
110
- * change. Fire-and-forget (async internally; the next `setTime` reflects
111
- * the new size once it lands, typically within a microtask). */
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 listeners/state. */
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
- * Build a live, interactive notation player: a thin packaging of the SAME
146
- * working path whozart's live in-browser promo player uses (see the module
147
- * doc). Renders the engraving ONCE (OSMD raster, via the `notation` Layer),
148
- * then every `setTime(tMs)` call blits the current follow window (via
149
- * `followBoxAt`/`vstackFollowBox` `notationLayout`) and draws the
150
- * onset-locked playhead (`audioPlayheadLine`/`vstackAudioPlayheadLine`)
151
- * exactly the `notation` + `scroll-cursor` Layer pair the promo/video runner
152
- * uses, instantiated directly instead of through the SceneSpec/timeline
153
- * runner (which targets a fixed-duration recorded clip, not a live,
154
- * caller-driven widget).
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 };
@@ -1,8 +1,13 @@
1
1
  import {
2
+ audioPlayheadLine,
3
+ distinctOnsets,
2
4
  getNotationEngraving,
3
5
  measureColumnsFromLayout,
6
+ measureCount,
4
7
  notationFactory,
5
- scrollCursorFactory
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 createNotationPlayer(opts) {
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 = typeof window !== "undefined" && window.devicePixelRatio ? window.devicePixelRatio : 1;
72
- function frameSize() {
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":";;;;;;;;;;;AA2KA,IAAM,gBAA4B;AAAA,EAChC,OAAO;AAAA,EAAW,KAAK;AAAA,EAAW,QAAQ;AAAA,EAAW,OAAO;AAAA,EAAW,MAAM;AAAA,EAC7E,aAAa;AAAA,EAAkB,UAAU;AAAA,EAAkB,OAAO;AACpE;AAOA,SAAS,gBAAgB,UAA2B;AAClD,QAAM,QAAqB,SAAS,IAAI,CAAC,aAAa;AAAA,IACpD,WAAW;AAAA,IAAI,MAAM;AAAA,IAAK,OAAO;AAAA,IAAG,QAAQ;AAAA,IAC5C;AAAA,IAAS,OAAO;AAAA,IAAG,OAAO;AAAA,IAAG,OAAO;AAAA,IAAG,MAAM;AAAA,IAAK,SAAS;AAAA,EAC7D,EAAE;AACF,QAAM,aAAa,SAAS,SAAS,KAAK,IAAI,GAAG,QAAQ,IAAI;AAC7D,SAAO;AAAA,IACL;AAAA,IACA,UAAU,EAAE,QAAQ,YAAY,UAAU,CAAC,EAAE,MAAM,GAAG,KAAK,IAAI,CAAC,EAAE;AAAA,IAClE;AAAA,EACF;AACF;AAuBO,SAAS,iBAAiB,QAAwB,IAAY,IAA2B;AAC9F,MAAI,CAAC,OAAO,SAAS,OAAQ,QAAO;AACpC,QAAM,UAAU,CAAC,GAAG,IAAI,IAAI,OAAO,SAAS,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,GAAG,MAAM,IAAI,CAAC;AACtF,QAAM,OAAO,yBAAyB,OAAO,QAAQ;AACrD,MAAI,UAAyB;AAC7B,MAAI,WAAW;AACf,WAAS,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK;AACpC,UAAM,IAAI,KAAK,CAAC;AAChB,QAAI,MAAM,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,KAAK,MAAM,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,EAAG,QAAO,QAAQ,CAAC;AAClF,UAAM,KAAK,EAAE,IAAI,EAAE,IAAI;AACvB,UAAM,KAAK,EAAE,IAAI,EAAE,IAAI;AACvB,UAAM,KAAK,KAAK,OAAO,KAAK,OAAO,KAAK,OAAO,KAAK;AACpD,QAAI,IAAI,UAAU;AAChB,iBAAW;AACX,gBAAU,QAAQ,CAAC;AAAA,IACrB;AAAA,EACF;AACA,SAAO;AACT;AAcO,SAAS,qBAAqB,MAAgD;AACnF,QAAM,EAAE,MAAM,UAAU,UAAU,UAAU,UAAU,KAAK,IAAI;AAC/D,QAAM,OAA2B,KAAK,QAAQ;AAC9C,QAAM,QAAoB,EAAE,GAAG,eAAe,GAAG,KAAK,MAAM;AAE5D,QAAM,SAAS,SAAS,cAAc,QAAQ;AAC9C,SAAO,MAAM,UAAU;AACvB,SAAO,MAAM,QAAQ;AACrB,SAAO,MAAM,SAAS;AAYtB,SAAO,MAAM,cAAc;AAC3B,OAAK,YAAY,MAAM;AAEvB,QAAM,MAAM,OAAO,WAAW,eAAe,OAAO,mBAAmB,OAAO,mBAAmB;AACjG,WAAS,YAA8B;AACrC,QAAI,KAAK,KAAM,QAAO,KAAK;AAC3B,UAAM,IAAI,KAAK,IAAI,GAAG,KAAK,OAAO,KAAK,eAAe,KAAK,GAAG,CAAC;AAC/D,UAAM,IAAI,KAAK,IAAI,GAAG,KAAK,OAAO,KAAK,gBAAgB,KAAK,GAAG,CAAC;AAChE,WAAO,CAAC,GAAG,CAAC;AAAA,EACd;AAiCA,QAAM,wBAAwB;AAC9B,QAAM,oBAAoB;AAC1B,WAAS,sBAA8B;AACrC,YAAQ,KAAK,eAAe,KAAK,wBAAwB,oBAAoB;AAAA,EAC/E;AAEA,QAAM,CAAC,OAAO,KAAK,IAAI,UAAU;AACjC,SAAO,QAAQ;AACf,SAAO,SAAS;AAChB,QAAM,QAAQ,OAAO,WAAW,IAAI;AACpC,MAAI,CAAC,MAAO,OAAM,IAAI,MAAM,qDAAqD;AAEjF,MAAI,UAAU;AACd,QAAM,aAAyB,EAAE,OAAO,MAAM,QAAQ;AACtD,QAAM,OAAkB;AAAA,IACtB;AAAA,IAAO,GAAG;AAAA,IAAO,GAAG;AAAA,IACpB,OAAO,gBAAgB,QAAQ;AAAA,IAC/B;AAAA,IAAY;AAAA,IAAO,SAAS,QAAQ,OAAO,KAAK;AAAA,IAAG,KAAK;AAAA,EAC1D;AAEA,QAAM,WAAW,gBAAgB,OAAO;AACxC,QAAM,eAAe,oBAAoB,OAAO;AAEhD,WAAS,cAAc,UAAwC,WAAkC;AAS/F,UAAM,UAAU,YAAY;AAC5B,WAAO;AAAA,MACL,GAAI,WAAW,EAAE,SAAS,IAAI,EAAE,KAAK,SAAS;AAAA,MAC9C,YAAY;AAAA,MACZ;AAAA,MACA,SAAS,KAAK,WAAW;AAAA,MACzB,YAAY,KAAK,cAAc,KAAK;AAAA,MACpC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MASA,GAAI,UAAU,EAAE,WAAW,KAAK,IAAI,oBAAoB,KAAK,KAAK,CAAC,EAAE,IAAI,CAAC;AAAA,IAC5E;AAAA,EACF;AACA,QAAM,oBAAuC,EAAE,YAAY,MAAM,UAAU,SAAS;AAEpF,MAAI,aAAsC;AAC1C,MAAI,YAAY;AAOhB,MAAI,sBAAsB;AAE1B,QAAM,SAAS,YAAY;AACzB,0BAAsB,oBAAoB;AAC1C,UAAM,SAAS,KAAK,MAAM,cAAc,KAAK,UAAU,mBAAmB,CAAC;AAC3E,iBAAa,qBAAqB,IAAI,GAAG,YAAY;AACrD,UAAM,aAAa,KAAK,MAAM,iBAAiB;AAAA,EACjD,GAAG;AAEH,WAAS,QAAc;AACrB,UAAO,KAAK;AACZ,UAAO,aAAa,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC;AACpC,UAAO,YAAY,MAAM;AACzB,UAAO,SAAS,GAAG,GAAG,OAAO,OAAO,OAAO,MAAM;AACjD,UAAO,QAAQ;AAAA,EACjB;AAEA,WAAS,QAAQ,KAAmB;AAClC,QAAI,UAAW;AACf,cAAU;AACV,UAAM;AACN,aAAS,KAAK,MAAM,GAAG;AACvB,iBAAa,KAAK,MAAM,GAAG;AAAA,EAC7B;AAEA,iBAAe,gBAA+B;AAC5C,UAAM,CAAC,GAAG,CAAC,IAAI,UAAU;AACzB,UAAM,kBAAkB,oBAAoB;AAQ5C,UAAM,iBAAiB,CAAC,KAAK,YAAY,cAAc,QAAQ,oBAAoB;AACnF,QAAI,MAAM,OAAO,SAAS,MAAM,OAAO,UAAU,CAAC,eAAgB;AAClE,WAAO,QAAQ;AACf,WAAO,SAAS;AAChB,SAAK,IAAI;AACT,SAAK,IAAI;AACT,SAAK,UAAU,QAAQ,GAAG,CAAC;AAC3B,QAAI,gBAAgB;AAIlB,4BAAsB;AACtB,YAAM,SAAS,KAAK,MAAM,cAAc,QAAW,mBAAmB,CAAC;AACvE,mBAAa,qBAAqB,IAAI,GAAG,YAAY;AACrD,YAAM,aAAa,KAAK,MAAM,iBAAiB;AAAA,IACjD,WAAW,YAAY;AAErB,YAAM,SAAS,KAAK,MAAM,cAAc,YAAY,mBAAmB,CAAC;AACxE,YAAM,aAAa,KAAK,MAAM,iBAAiB;AAAA,IACjD;AACA,QAAI,CAAC,UAAW,SAAQ,OAAO;AAAA,EACjC;AAEA,WAAS,gBAAuC;AAC9C,UAAM,MAAM,qBAAqB,IAAI;AACrC,QAAI,CAAC,IAAK,QAAO;AACjB,WAAO,IAAI,iBAAiB,IAAI,eAAe,MAAM,OAAO,IAAI,IAAI;AAAA,EACtE;AAEA,QAAM,iBAAwD,CAAC;AAC/D,WAAS,cAAc,GAAqB;AAC1C,UAAM,OAAO,OAAO,sBAAsB;AAC1C,QAAI,EAAE,KAAK,QAAQ,MAAM,EAAE,KAAK,SAAS,GAAI;AAC7C,UAAM,MAAM,EAAE,UAAU,KAAK,SAAS,OAAO,QAAQ,KAAK;AAC1D,UAAM,MAAM,EAAE,UAAU,KAAK,QAAQ,OAAO,SAAS,KAAK;AAC1D,UAAM,SAAS,cAAc;AAC7B,QAAI,CAAC,OAAQ;AACb,UAAM,MAAM,iBAAiB,QAAQ,IAAI,EAAE;AAC3C,QAAI,OAAO,KAAM,YAAW,MAAM,eAAgB,IAAG,GAAG;AAAA,EAC1D;AACA,SAAO,iBAAiB,SAAS,aAAa;AAE9C,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,SAAe;AACb,WAAK,cAAc;AAAA,IACrB;AAAA,IACA,eAAe,IAAgD;AAC7D,qBAAe,KAAK,EAAE;AACtB,aAAO,MAAM;AACX,cAAM,IAAI,eAAe,QAAQ,EAAE;AACnC,YAAI,KAAK,EAAG,gBAAe,OAAO,GAAG,CAAC;AAAA,MACxC;AAAA,IACF;AAAA,IACA,UAAgB;AACd,UAAI,UAAW;AACf,kBAAY;AACZ,aAAO,oBAAoB,SAAS,aAAa;AACjD,eAAS,UAAU;AACnB,mBAAa,UAAU;AACvB,qBAAe,SAAS;AACxB,UAAI,OAAO,eAAe,KAAM,MAAK,YAAY,MAAM;AAAA,IACzD;AAAA,EACF;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/notationPlayer.ts"],"sourcesContent":["// createNotationPlayer — THE canonical notation-playback component: one\n// scrolling-notation-plus-gliding-playhead implementation, used for BOTH live\n// in-app players (this module's reason to exist) and the promo/video-recording\n// path (src/scene/layers/notation.ts + scrollCursor.ts, driven by the SceneSpec\n// runner for a fixed-duration capture). Both consume the SAME geometry —\n// `notationLayout`, `followBoxAt`/`vstackFollowBox`, `audioPlayheadLine`/\n// `vstackAudioPlayheadLine`, `measureColumnsFromLayout` — which still lives in\n// `src/scene/` (notationGeometry.ts, engravingStore.ts, layers/notation.ts,\n// layers/scrollCursor.ts) for now; this module is their canonical PUBLIC\n// surface for interactive, caller-driven playback. It does not reimplement or\n// fork any of that math — it just instantiates the two `Layer`s directly\n// (bypassing the SceneSpec/timeline runner, which is built for a fixed-length\n// recorded clip, not a live `setTime()`-driven widget) and adds the two things\n// a live player needs that a recorded promo never did: resize, and\n// measure-click hit-testing.\n//\n// WHY THIS EXISTS: a prior in-app player (stave-web-sightread's /bach/play)\n// hand-rolled its own playhead interpolation directly against OSMD's graphical\n// output instead of reusing this path, and the result was a janky cursor.\n// Hand-rolling notation/playhead geometry against OSMD is a KNOWN FAILURE MODE\n// — OSMD's rasterized bitmap does not scale by the naive `canvas.width / pageW`\n// whenever the engraving overflows the nominal page width (which the `hstack`\n// single-staffline layout does routinely), so a from-scratch cursor drifts\n// steadily off the noteheads over a piece. That exact bug was found and fixed\n// ONCE, here, in `extractGeometry` (src/promo.ts — `canvas.width /\n// (contentRight + contentLeft)`, not `/pageW`); every consumer of THIS module\n// inherits the fix for free. Don't re-derive playhead/scroll geometry against\n// OSMD anywhere else — wrap this component instead.\n//\n// Follow-up (noted, not done here): src/scene/layers/notation.ts +\n// scrollCursor.ts could themselves be rebuilt on top of this module instead of\n// duplicating the init/draw wiring; deferred to keep this change additive.\n//\n// DISPLAY MODES (0.37.0) — `display: 'window' | 'scroll'` (see\n// `CreateNotationPlayerOpts.display`):\n// - 'window' (DEFAULT) — the original camera-band player: a fixed-size\n// canvas, a follow camera that crops to ~2 bars/one system at a time, and\n// an onset-locked playhead drawn INTO that canvas. This is the path\n// promos/video recording and every existing caller before 0.37.0 uses; it\n// is completely untouched by this addition (see `createWindowPlayer`) —\n// selecting it (or omitting `display`) is byte-identical to pre-0.37.0\n// behavior.\n// - 'scroll' — a live-reading display: the ENTIRE score is rasterized once\n// (same raster `renderNotation` already produces, unchanged), laid out at\n// full width with NO camera crop (`flowLayout`, below), and sliced into a\n// vertical stack of `<canvas>` tiles so the HOST grows to the score's\n// natural height and the PAGE scrolls it (native wheel/trackpad/swipe) —\n// not a camera pan inside a fixed frame. See `createScrollPlayer`.\n// Promos/whozart never set `display`, so they are unaffected by any of this.\n//\n// IMPORT PATH: this module is SUBPATH-ONLY —\n// `import { createNotationPlayer } from '@real-music-packages/web-core/notationPlayer'`\n// — matching every other non-trivial module in this package (promo.ts,\n// video.ts, scene/, server.ts, streak.ts, playback.ts). It is intentionally\n// NOT re-exported from the root barrel (src/index.ts): the root barrel is\n// theory-only (notes/frequency/scales/intervals/chords/fretboard/shareCard,\n// all zero-dependency) so apps that only want pitch/theory helpers don't\n// statically pull in this module's scene/video runtime deps.\n\nimport { notationFactory, type NotationProps } from './scene/layers/notation';\nimport { scrollCursorFactory, type ScrollCursorProps } from './scene/layers/scrollCursor';\nimport { getNotationEngraving } from './scene/engravingStore';\nimport {\n audioPlayheadLine,\n distinctOnsets,\n measureColumnsFromLayout,\n measureCount,\n notationLayout,\n vstackAudioPlayheadLine,\n type NotationLayout,\n} from './scene/notationGeometry';\nimport type { AudioClock, RenderCtx } from './scene/layer';\nimport type { Score, ScoreNote } from './scene/score';\nimport type { RenderedNotation } from './promo';\nimport { safeBox, type PromoTheme } from './video';\n\n/**\n * Notation scroll/camera mode — forwarded verbatim to the underlying engraving\n * + follow-camera (see `RenderNotationOpts.scrollMode` in src/promo.ts and\n * `notationGeometry.ts`'s hstack/vstack follow functions). These are the ONLY\n * two modes the shared machinery implements; there is no third.\n *\n * 'vstack' (DEFAULT here) — classic stacked systems (page-wrap engraving).\n * The follow camera frames one system at a time and scrolls vertically\n * (with a \"carriage return\" playhead) as the piece crosses system breaks.\n * This is the actively-exercised path (whozart's live player, the RSR/RET/\n * RMT promo videos) and the one the memory note \"use vstack everywhere\"\n * refers to — pick it unless you have a specific reason not to.\n *\n * 'hstack' — single horizontal staffline; the follow window pans purely\n * left→right. The cursor is onset-locked here too (audioPlayheadLine, not\n * a free-running sweep — that linear-sweep footgun was removed from the\n * shared cursor entirely in web-core 0.22.0), and the earlier\n * `canvas.width/pageW` scale bug that made hstack drift is fixed (0.30+\n * `extractGeometry`, see the module doc above). It is still the LESS\n * exercised of the two modes for live players (most real players show\n * multi-line music, which needs vstack) — supported and correct today, but\n * treat it as the less battle-tested choice.\n *\n * NOTE for `display: 'scroll'` (0.37.0): scroll mode shows the WHOLE score\n * at once with no camera crop, which is only meaningful for a page-wrapped\n * ('vstack') engraving — 'hstack' engraves the whole piece onto ONE very\n * wide row, so a scroll-mode flow layout for it degenerates to a single\n * short, squeezed tile. Scroll mode does not forbid 'hstack' (the geometry\n * is agnostic), but in practice pick 'vstack' for it, same as window mode.\n */\nexport type NotationPlayerMode = 'hstack' | 'vstack';\n\n/**\n * Which player shell to build. See the module doc's \"DISPLAY MODES\" section.\n * Default `'window'` — every pre-0.37.0 caller (and promos/whozart, which never\n * set this) keeps the exact camera-band behavior, byte-for-byte.\n */\nexport type NotationPlayerDisplay = 'window' | 'scroll';\n\nexport interface NotationPlayerTheme extends Partial<PromoTheme> {}\n\nexport interface CreateNotationPlayerOpts {\n /** Element the player's content is mounted into.\n * - `display:'window'` (default): a single canvas that FILLS the host\n * (`width:100%;height:100%`) — the host's own size is the frame.\n * - `display:'scroll'`: an internal wrapper that fills the host\n * HORIZONTALLY but is left to its NATURAL height (the whole score's\n * height at the host's width) — the host must not force/clip a fixed\n * height (no `overflow:hidden` + fixed height) or native page scroll\n * can't reach the tiles below the fold. This is the one structural\n * assumption scroll mode makes about its host; window mode has none. */\n host: HTMLElement;\n /** MusicXML to engrave. */\n musicXml: string;\n /** Distinct note onsets (ms, sorted or not — de-duped/sorted internally via\n * the shared score model) the playhead locks to. One per played note (or\n * the caller's own onset schedule) — this IS the audio clock's timing. */\n onsetsMs: number[];\n /**\n * Per-onset engraved column positions (`measureIndex + frac`), 1:1 with\n * `onsetsMs`. Optional — falls back to the shared machinery's own\n * time/ordinal-based placement when omitted (see `scroll-cursor`'s\n * `noteCols`). Strongly recommended for anything beyond a demo: it is what\n * lands the cursor on the actual notehead instead of a time-derived guess.\n *\n * GOTCHA (pre-existing in the wrapped machinery, not introduced here):\n * internally the cursor works off `distinctOnsets(onsetsMs)` (deduped +\n * sorted), so if `onsetsMs` contains duplicate onsets (simultaneous chord\n * notes sharing one onset time — a legitimate input), the deduped length no\n * longer matches `noteCols.length` and the `1:1` alignment silently breaks,\n * degrading to the `barDurMs`/ordinal fallback with no warning. Build\n * `noteCols` against the DEDUPED, sorted onset list, not the raw one.\n */\n noteCols?: number[];\n /** Scroll/camera mode. Default 'vstack'. See `NotationPlayerMode`. Fixed for\n * the life of the instance — switching modes needs a fresh OSMD engrave\n * (different `RenderSingleHorizontalStaffline` layout), so it is NOT a\n * runtime-switchable option; create a new player if the mode must change\n * (noted as a follow-up, not built: nothing in the shared machinery makes a\n * live re-layout trivial). */\n mode?: NotationPlayerMode;\n /**\n * Which player shell to build — `'window'` (default, the original\n * camera-band player) or `'scroll'` (0.37.0, a tiled full-score display the\n * PAGE scrolls natively). See the module doc's \"DISPLAY MODES\" section and\n * `NotationPlayerDisplay`. Fixed for the life of the instance, same\n * reasoning as `mode`: create a new player if it must change.\n */\n display?: NotationPlayerDisplay;\n /** Duration of one measure in ms — enables time-accurate cursor/window\n * placement when `noteCols` isn't supplied (see `scroll-cursor`'s\n * `barDurMs`). */\n barDurMs?: number;\n /** Bar range [from,to] forwarded to the engraver (renderNotation\n * bars/drawFrom-drawUpTo) — engrave an excerpt rather than the whole score. */\n bars?: [number, number];\n /** Top of the notation band, screen px. Default 0 (fills the host — this is\n * a UI widget, not a phone-safe video frame). `display:'scroll'` IGNORES\n * this — there is no fixed band to inset; the whole score is shown. */\n bandTop?: number;\n /** Height of the notation band, screen px. Default the full frame height.\n * `display:'scroll'` IGNORES this for the same reason as `bandTop`. */\n bandHeight?: number;\n /** Theme tokens (colours/fonts) forwarded to the layers. Any field omitted\n * falls back to a neutral default. */\n theme?: NotationPlayerTheme;\n /** Explicit canvas size in device px. Default: host.clientWidth/clientHeight\n * × devicePixelRatio. Pass this in test/headless environments where the\n * host has no real layout (e.g. jsdom, where clientWidth is always 0).\n * `display:'scroll'` only reads the WIDTH component (`size[0]`) — the\n * display height is derived from the score's own content, so `size[1]` is\n * ignored in that mode. */\n size?: [number, number];\n /**\n * Advanced / test seam: a pre-rasterized engraving, bypassing the browser\n * OSMD raster (`musicXml` is still required by the type but is ignored when\n * this is set). Mirrors `NotationProps.rendered` in\n * `src/scene/layers/notation.ts` (\"test/headless injection\") — real OSMD\n * *rendering* (as opposed to parsing) needs a real 2D canvas context (glyph\n * metrics for its line-breaking pass) that headless/jsdom test contexts\n * can't fully provide, so this is how this module (and the layer it wraps)\n * stays unit-testable in Node. Not needed in a real browser host.\n */\n rendered?: RenderedNotation;\n}\n\nexport interface NotationPlayer {\n /** Resolves once the engraving has been rasterized and is ready to draw.\n * `setTime`/click hit-testing are safe to call before this resolves — they\n * simply no-op (draw a blank frame / report no measure) until ready. */\n readonly ready: Promise<void>;\n /**\n * Draw the frame for absolute playback time `tMs`. The caller owns the\n * audio clock + the rAF loop — call this every frame with the current\n * playback position; everything else (follow camera, onset-locked\n * playhead) is a pure function of `tMs`, exactly as the promo/whozart path\n * drives it.\n *\n * `display:'scroll'`: also feeds the auto-follow discontinuity detector —\n * see `CreateNotationPlayerOpts.display`'s doc for the exact re-arm rule.\n */\n setTime(tMs: number): void;\n /** Re-measure the host and resize/rebuild to match (device-px aware).\n * Cheap after the first draw — reuses the already-rasterized engraving\n * bitmap unless the host's CSS width crossed the engrave-width breakpoint\n * (see `desiredEngraveWidth`), it does not otherwise re-run OSMD. Call on\n * host resize / orientation change. Fire-and-forget (async internally; the\n * next `setTime` reflects the new size once it lands, typically within a\n * microtask). `display:'scroll'` rebuilds the whole tile stack. */\n resize(): void;\n /** Register a measure-click handler: fires with the clicked measure's\n * engraved index (matching `ScoreNote.measure` numbering) when a click\n * lands on — or nearest to — a rendered measure column. Multiple handlers\n * may be registered (they all fire); returns an unsubscribe function for\n * that one handler. `destroy()` also clears every remaining listener. */\n onMeasureClick(cb: (measureIndex: number) => void): () => void;\n /** Tear down: removes the canvas/tiles from `host` and drops\n * listeners/state (including the `display:'scroll'` page-scroll listener). */\n destroy(): void;\n}\n\nconst DEFAULT_THEME: PromoTheme = {\n paper: '#faf7f0', ink: '#1a1614', accent: '#7b2436', sepia: '#6d5d4d', gold: '#c8a55b',\n fontDisplay: 'Georgia, serif', fontBody: 'Georgia, serif', brand: '',\n};\n\n/** A minimal but valid `Score` carrying only the onset timings the shared\n * scroll-cursor needs (`distinctOnsets(ctx.score.notes)`). Pitch/hand/measure\n * fields are placeholders — the notation + scroll-cursor layers never read\n * them (they read the RASTERIZED engraving's own geometry for position; the\n * Score here only supplies the audio-onset clock). Used by `display:'window'`\n * only — `display:'scroll'` reads `onsetsMs` directly (no Layer/RenderCtx). */\nfunction scoreFromOnsets(onsetsMs: number[]): Score {\n const notes: ScoreNote[] = onsetsMs.map((onsetMs) => ({\n pitchMidi: 60, step: 'C', alter: 0, octave: 4,\n onsetMs, durMs: 0, staff: 0, voice: 0, hand: 'R', measure: 1,\n }));\n const durationMs = onsetsMs.length ? Math.max(...onsetsMs) : 0;\n return {\n notes,\n tempoMap: { source: 'fallback', segments: [{ atMs: 0, bpm: 120 }] },\n durationMs,\n };\n}\n\n/**\n * Pure hit-test: which measure (by its engraved index) contains — or is\n * nearest to — point (mx, my) in a given followed layout. Reuses\n * `measureColumnsFromLayout` (the SAME per-measure union boxes the playhead\n * anchors to) for the boxes; only the index bookkeeping (matching each\n * returned box back to its measure index, which `measureColumnsFromLayout`\n * intentionally drops — the playhead has no use for it) is new here, and it\n * is pure array/index bookkeeping, not geometry math. Exported standalone so\n * it is unit-testable without a DOM/canvas. Shared by BOTH display modes:\n * `display:'window'` hit-tests against the followed (camera-cropped) layout;\n * `display:'scroll'` hit-tests against the full-score `flowLayout` (see\n * below) with the click's y already translated from tile-local into\n * flow-layout space by the caller.\n *\n * COORDINATE SPACE — read this before calling directly: (mx, my) MUST be in\n * the same DEST/DEVICE-PIXEL space `layout.measures[].box` is already mapped\n * into by `notationLayout()` (i.e. the player's own `<canvas>` device pixels,\n * origin top-left, NOT CSS px) — the same space this module's own\n * `onCanvasClick` computes via `(clientX - rect.left) * (canvas.width /\n * rect.width)`. It is NOT the raster/src space (`RenderedNotation.canvas`,\n * OSMD's own pre-map bitmap px) — passing src-space coordinates here is\n * exactly the \"classic scale gotcha\" (`extractGeometry`'s `canvas.width /\n * pageW` vs `/(contentRight+contentLeft)`, see the module doc) this component\n * exists to make impossible; don't reintroduce it at the call site.\n */\nexport function hitTestMeasureAt(layout: NotationLayout, mx: number, my: number): number | null {\n if (!layout.measures.length) return null;\n const indices = [...new Set(layout.measures.map((m) => m.index))].sort((a, b) => a - b);\n const cols = measureColumnsFromLayout(layout.measures);\n let nearest: number | null = null;\n let nearestD = Infinity;\n for (let i = 0; i < cols.length; i++) {\n const b = cols[i];\n if (mx >= b.x && mx <= b.x + b.w && my >= b.y && my <= b.y + b.h) return indices[i];\n const cx = b.x + b.w / 2;\n const cy = b.y + b.h / 2;\n const d = (mx - cx) * (mx - cx) + (my - cy) * (my - cy);\n if (d < nearestD) {\n nearestD = d;\n nearest = indices[i];\n }\n }\n return nearest;\n}\n\n// ─── Shared sizing helpers (used by both display modes) ───────────────────\n\n/** `window.devicePixelRatio`, falling back to 1 whenever it's absent/0/NaN\n * (headless, or a browser that doesn't expose it). */\nfunction resolveDpr(): number {\n return typeof window !== 'undefined' && window.devicePixelRatio ? window.devicePixelRatio : 1;\n}\n\n/** Canvas backing-store size in device px for a host + optional explicit\n * override (see `CreateNotationPlayerOpts.size`'s doc — `display:'scroll'`\n * only reads index 0). */\nfunction frameSize(host: HTMLElement, dpr: number, size?: [number, number]): [number, number] {\n if (size) return size;\n const w = Math.max(1, Math.round((host.clientWidth || 1) * dpr));\n const h = Math.max(1, Math.round((host.clientHeight || 1) * dpr));\n return [w, h];\n}\n\n// The width OSMD engraves against (CSS px, forwarded as `renderNotation`'s\n// `hostWidth`) — NOT the same thing as `frameSize()`'s canvas device-px\n// size, which only controls how big the (already-engraved) raster is\n// blitted (window mode) or laid out (scroll mode). Left unset, `renderNotation`\n// defaults to a fixed 560px host, sized for the promo card this draw path was\n// built for; a live player embedded in a wide desktop container inherits that\n// same narrow 560px line-breaking regardless of how big its own host is, so\n// systems come out engraved for a phone-width column and then get letterboxed\n// (aspect-fit, see `notationLayout`) into the middle of the real, wider canvas\n// — narrow systems on a lot of unused horizontal whitespace.\n//\n// `DEFAULT_ENGRAVE_WIDTH` is a floor: hosts at or narrower than it (phones)\n// get exactly `renderNotation`'s own 560px default, unchanged — never\n// narrower. Any host WIDER than that always engraves at the single fixed\n// `MAX_ENGRAVE_WIDTH` ceiling, rather than tracking the host's exact CSS\n// width 1:1: the engrave step decides how many measures line-break onto one\n// system (i.e. the system's raw width:height aspect ratio) — the MORE\n// measures per line, the wider that aspect ratio, and a wider aspect ratio\n// is what lets the SEPARATE display-time fit (`notationLayout`'s\n// width-vs-height-bound choice) actually reach a wide `dw` before it runs\n// out of `boxH` — so engraving at less than the max on, say, a\n// 936px-wide-but-only-438px-tall card leaves the aspect ratio too narrow\n// for the fit to ever get wide even though the container has width to\n// spare (measured: srcAspect 1.65 tops out at ~73% of a 934px canvas at\n// boxH=438; engraving at the full 1150px ceiling instead reaches srcAspect\n// ~2.0+, wide enough to fill it). The container's OWN width still governs\n// how far that gets displayed (`bandWidth`, capped at `rctx.W`, or —\n// `display:'scroll'` — `dispWdev`), so a host narrower than 1150px never\n// actually shows more than it has room for — only the OFFSCREEN raster is\n// engraved wider than strictly necessary, at the (cheap, one-time) cost of\n// engraving a system with a couple of unused measures's worth of headroom\n// baked in.\nconst DEFAULT_ENGRAVE_WIDTH = 560;\nconst MAX_ENGRAVE_WIDTH = 1150;\nfunction desiredEngraveWidth(host: HTMLElement): number {\n return (host.clientWidth || 0) > DEFAULT_ENGRAVE_WIDTH ? MAX_ENGRAVE_WIDTH : DEFAULT_ENGRAVE_WIDTH;\n}\n\n// ─── `display:'scroll'` pure geometry (unit-tested, no DOM) ────────────────\n\n/**\n * Full-score \"flow\" layout for scroll mode: the ENTIRE engraved raster\n * (`rn.content`, the same tight ink box `notationLayout` itself already falls\n * back to) scaled to fill `dispWdev` device px of WIDTH, with height\n * following naturally from the content's own aspect ratio — no camera crop,\n * no bounded box (the opposite of the follow-window layout `notationLayout`\n * computes for window mode via `{focusBox}`). Reuses `notationLayout` for ALL\n * the actual scale/map/dx/dy math — no new geometry — via two calls:\n *\n * 1. a PROBE call with an arbitrarily large `boxH` (1e9 — real engraved\n * music's content aspect ratio, height/width, is always many orders of\n * magnitude below that, so this bound is never the true constraint; it\n * exists ONLY so the width-bound branch is guaranteed to be taken,\n * sidestepping a chicken-and-egg height guess) to read back the TRUE\n * fitted height (`rect.dh`) `notationLayout` would compute for this\n * width;\n * 2. an EXACT call with `boxH` set to precisely that height, so the result\n * is TOP-aligned (`rect.dy === 0`) rather than vertically centered\n * inside an oversized probe box. Both calls share the identical `src` /\n * `srcAspect` internally (same `rn`, `focusBox: null`, default\n * `zoom01`), so the two `dh` values are bit-identical and the second\n * call's `dh > boxH` branch is never taken (equal, not greater).\n *\n * Exported so scroll-mode's tile partition (`computeScrollTiles`) and the\n * playhead's y-mapping can be unit-tested against it directly, without a DOM.\n */\nexport function flowLayout(rn: RenderedNotation, dispWdev: number): NotationLayout {\n const w = dispWdev > 0 ? dispWdev : 1;\n const probe = notationLayout(rn, w, 1e9, 0, 1e9, { boxWidth: w });\n const dh = probe.rect.dh;\n return notationLayout(rn, w, dh, 0, dh, { boxWidth: w });\n}\n\n/** Target tile height, CSS px — ~2x a typical viewport, so a tile shows\n * roughly \"one screenful plus one\" of context, and system-boundary carriage\n * returns rarely straddle a tile seam. The ACTUAL tile height is this OR the\n * area-cap-derived height (`SCROLL_TILE_MAX_AREA_PX`), whichever is\n * SMALLER — so a high-dpr device automatically gets shorter (CSS-px) tiles\n * rather than ever exceeding the backing-store area cap; see\n * `computeScrollTiles`. */\nexport const SCROLL_TILE_TARGET_CSS_PX = 1600;\n\n/** Per-tile canvas backing-store area cap, device px². Comfortably under both\n * iOS Safari's ~16.7M px² (4096×4096) canvas-backing-store limit AND\n * `promo.ts`'s own `MAX_RASTER_AREA_PX` (12M — the cap for the SOURCE raster\n * a tile reads FROM): a tile is a separate, smaller destination canvas than\n * the source raster, so it gets its own, tighter cap; 8M leaves comfortable\n * headroom under both limits at any realistic tile width. */\nexport const SCROLL_TILE_MAX_AREA_PX = 8_000_000;\n\n/** One vertical tile of the full-score raster, in the same device-px space\n * `flowLayout` maps into. */\nexport interface ScrollTileSpec {\n /** Number of vertical tiles covering the full score. Always >= 1. */\n count: number;\n /** Per-tile height, device px. `heights.length === count`,\n * `sum(heights) === totalHeightDev` (within float precision). */\n heights: number[];\n /** Per-tile top y-offset, device px, within the full-score raster\n * (`flowLayout`'s coordinate space). `offsets.length === count`,\n * `offsets[0] === 0`, `offsets[i+1] === offsets[i] + heights[i]`. */\n offsets: number[];\n /** Total display height, device px (== the flow layout's `rect.dh`). */\n totalHeightDev: number;\n}\n\n/**\n * Pure tile-partition math (unit-tested, no DOM) — same shape/discipline as\n * `clampRasterDpr` in `src/promo.ts`: given the full-score display width and\n * height in device px (`dispWdev`/`totalHeightDev` — `flowLayout`'s own\n * `rect.dw`/`rect.dh`) and the active `dpr`, partition the height into N\n * EQUAL-height tiles such that:\n *\n * - each tile's target height is `SCROLL_TILE_TARGET_CSS_PX * dpr` device\n * px (~2x viewport) UNLESS that would push a tile's own backing-store\n * area (`dispWdev * tileHeightDev`) over `capPx2` — in which case the\n * tile height is derived FROM the area cap instead. This is COMPUTED\n * from `dispWdev`/`dpr` every call, not assumed safe at a fixed CSS\n * height — a very wide host at a high dpr still gets a shorter tile, so\n * the cap holds \"at any DPR\" as the design requires.\n * - tiles split EVENLY (`totalHeightDev / count`), not\n * max-height-tile-then-a-small-remainder — so there's never an oddly\n * short final tile, and every tile (including the last) is <= the area\n * cap by construction (see the proof in the inline comment below).\n * - a score shorter than one tile's max height gets exactly ONE tile (the\n * degenerate/short-score case) — tiling only exists to keep any single\n * canvas's backing-store area under the cap, which is already true for\n * the whole score at that size, so a single tile is simplest.\n */\nexport function computeScrollTiles(\n dispWdev: number,\n totalHeightDev: number,\n dpr: number,\n opts?: { targetTileCssPx?: number; capPx2?: number },\n): ScrollTileSpec {\n const targetCssPx = opts?.targetTileCssPx ?? SCROLL_TILE_TARGET_CSS_PX;\n const capPx2 = opts?.capPx2 ?? SCROLL_TILE_MAX_AREA_PX;\n const safeDpr = dpr > 0 ? dpr : 1;\n const w = dispWdev > 0 ? dispWdev : 1;\n const total = totalHeightDev > 0 ? totalHeightDev : 0;\n\n const desiredDev = targetCssPx * safeDpr;\n const maxByArea = capPx2 / w;\n // tileMax <= maxByArea = capPx2/w always, so w*tileMax <= capPx2: any tile\n // AT tileMax height already respects the cap. Splitting `total` into\n // `count = ceil(total/tileMax)` EQUAL tiles gives each tile height\n // `total/count <= tileMax` (ceil never under-counts), so every tile\n // (including the last) inherits that same guarantee — no per-tile check\n // needed after the fact.\n const tileMax = Math.max(1, Math.min(desiredDev, maxByArea));\n\n if (total <= tileMax) {\n return { count: 1, heights: [total], offsets: [0], totalHeightDev: total };\n }\n\n const count = Math.max(1, Math.ceil(total / tileMax));\n const even = total / count;\n const heights: number[] = new Array(count).fill(even);\n const offsets: number[] = new Array(count);\n let y = 0;\n for (let i = 0; i < count; i++) {\n offsets[i] = y;\n y += even;\n }\n return { count, heights, offsets, totalHeightDev: total };\n}\n\nfunction nowMs(): number {\n return typeof performance !== 'undefined' ? performance.now() : Date.now();\n}\n\n/**\n * `display:'window'` implementation (unchanged from pre-0.37.0\n * `createNotationPlayer` — see the module doc's \"DISPLAY MODES\" section).\n * Build a live, interactive notation player: a thin packaging of the SAME\n * working path whozart's live in-browser promo player uses (see the module\n * doc). Renders the engraving ONCE (OSMD raster, via the `notation` Layer),\n * then every `setTime(tMs)` call blits the current follow window (via\n * `followBoxAt`/`vstackFollowBox` → `notationLayout`) and draws the\n * onset-locked playhead (`audioPlayheadLine`/`vstackAudioPlayheadLine`) —\n * exactly the `notation` + `scroll-cursor` Layer pair the promo/video runner\n * uses, instantiated directly instead of through the SceneSpec/timeline\n * runner (which targets a fixed-duration recorded clip, not a live,\n * caller-driven widget).\n */\nfunction createWindowPlayer(opts: CreateNotationPlayerOpts): NotationPlayer {\n const { host, musicXml, onsetsMs, noteCols, barDurMs, bars } = opts;\n const mode: NotationPlayerMode = opts.mode ?? 'vstack';\n const theme: PromoTheme = { ...DEFAULT_THEME, ...opts.theme };\n\n const canvas = document.createElement('canvas');\n canvas.style.display = 'block';\n canvas.style.width = '100%';\n canvas.style.height = '100%';\n // Let the browser handle vertical page-scroll gestures that start over the\n // canvas (a live player is normally embedded in a scrolling page, unlike\n // the fixed-frame promo/video path this module shares its draw code with).\n // Without this, WebKit/iOS routinely demotes a swipe that starts on an\n // element carrying a pointer/click listener (see `onCanvasClick` below) to\n // its slow-path gesture disambiguation, which can read as \"the page won't\n // scroll\" even though nothing here calls `preventDefault()`. `pan-y` keeps\n // vertical panning on the browser's fast path while still leaving the\n // synthesized `click` (tap, near-zero movement) to reach `onCanvasClick` —\n // browsers already withhold `click` after a scrolling gesture, so tap vs.\n // swipe still discriminates for free.\n canvas.style.touchAction = 'pan-y';\n host.appendChild(canvas);\n\n const dpr = resolveDpr();\n\n const [initW, initH] = frameSize(host, dpr, opts.size);\n canvas.width = initW;\n canvas.height = initH;\n const ctx2d = canvas.getContext('2d');\n if (!ctx2d) throw new Error('createNotationPlayer: 2D canvas context unavailable');\n\n let lastTMs = 0;\n const audioClock: AudioClock = { nowMs: () => lastTMs };\n const rctx: RenderCtx = {\n ctx2d, W: initW, H: initH,\n score: scoreFromOnsets(onsetsMs),\n audioClock, theme, safeBox: safeBox(initW, initH), fps: 30,\n };\n\n const notation = notationFactory.create();\n const scrollCursor = scrollCursorFactory.create();\n\n function notationProps(rendered: RenderedNotation | undefined, hostWidth: number): NotationProps {\n // Only claim (up to) the full canvas width for the DISPLAY fit when the\n // engrave itself was actually widened past `renderNotation`'s own\n // phone-card default (`hostWidth` here is always `desiredEngraveWidth()`'s\n // output — see its doc comment: a floor, never narrower than\n // DEFAULT_ENGRAVE_WIDTH). On a narrow/mobile host this is false, so\n // `bandWidth` is omitted and `notationLayout` falls back to its original\n // `safeBox(W,H).centeredW` — mobile's display fit stays BYTE-IDENTICAL to\n // pre-fix behavior, not just visually similar.\n const widened = hostWidth > DEFAULT_ENGRAVE_WIDTH;\n return {\n ...(rendered ? { rendered } : { xml: musicXml }),\n scrollMode: mode,\n bars,\n bandTop: opts.bandTop ?? 0,\n bandHeight: opts.bandHeight ?? rctx.H,\n hostWidth,\n // Fit to (up to) the full canvas width instead of `notationLayout`'s\n // default `safeBox(W,H).centeredW` — that default reserves ~24% of\n // width for the PROMO video path's caption/share-button chrome (see\n // `NotationLayoutOpts.boxWidth`'s doc comment), which doesn't apply to\n // this plain, chrome-less player canvas. Capped at the same\n // `MAX_ENGRAVE_WIDTH` (converted CSS px → device px) as the engrave\n // width itself, so a very wide desktop host doesn't stretch systems\n // past a readable size even though the canvas has room for it.\n ...(widened ? { bandWidth: Math.min(MAX_ENGRAVE_WIDTH * dpr, rctx.W) } : {}),\n };\n }\n const scrollCursorProps: ScrollCursorProps = { scrollMode: mode, barDurMs, noteCols };\n\n let renderedRn: RenderedNotation | null = null;\n let destroyed = false;\n // The host width the CURRENT `renderedRn` was actually engraved at — kept\n // in sync with every real (re-)engrave so `performResize` can tell \"host\n // got bigger/smaller, systems should re-flow\" apart from \"canvas device\n // pixels changed but the CSS width driving line-breaking didn't\" (e.g. a\n // pure devicePixelRatio or height-only change), which stays on the cheap\n // reuse-the-bitmap path.\n let engravedHostWidthPx = 0;\n\n const ready = (async () => {\n engravedHostWidthPx = desiredEngraveWidth(host);\n await notation.init(rctx, notationProps(opts.rendered, engravedHostWidthPx));\n renderedRn = getNotationEngraving(rctx)?.rendered ?? null;\n await scrollCursor.init(rctx, scrollCursorProps);\n })();\n\n function clear(): void {\n ctx2d!.save();\n ctx2d!.setTransform(1, 0, 0, 1, 0, 0);\n ctx2d!.fillStyle = theme.paper;\n ctx2d!.fillRect(0, 0, canvas.width, canvas.height);\n ctx2d!.restore();\n }\n\n function setTime(tMs: number): void {\n if (destroyed) return;\n lastTMs = tMs;\n clear();\n notation.draw(rctx, tMs);\n scrollCursor.draw(rctx, tMs);\n }\n\n async function performResize(): Promise<void> {\n const [w, h] = frameSize(host, dpr, opts.size);\n const newEngraveWidth = desiredEngraveWidth(host);\n // Only a REAL OSMD re-engrave (not the cheap bitmap-reuse relayout below)\n // can change how many measures fit per system — see `desiredEngraveWidth`'s\n // doc comment. Gated on `!opts.rendered`: a caller that supplied a\n // pre-rasterized `rendered` (the headless/test seam) never had a real xml\n // engrave to begin with, and `notation.init` can't run OSMD against jsdom\n // — re-deriving `engravedHostWidthPx` for that seam would just make this\n // check misfire on the next resize, so it's skipped entirely.\n const needsReEngrave = !opts.rendered && renderedRn != null && newEngraveWidth !== engravedHostWidthPx;\n if (w === canvas.width && h === canvas.height && !needsReEngrave) return;\n canvas.width = w;\n canvas.height = h;\n rctx.W = w;\n rctx.H = h;\n rctx.safeBox = safeBox(w, h);\n if (needsReEngrave) {\n // Host width materially changed (e.g. mobile→desktop, a fullscreen\n // toggle, or a page-layout reflow) — re-run OSMD so systems re-flow at\n // the new width instead of just rescaling the old, narrower engrave.\n engravedHostWidthPx = newEngraveWidth;\n await notation.init(rctx, notationProps(undefined, engravedHostWidthPx));\n renderedRn = getNotationEngraving(rctx)?.rendered ?? null;\n await scrollCursor.init(rctx, scrollCursorProps);\n } else if (renderedRn) {\n // Cheap re-layout: reuses the already-rasterized bitmap (no OSMD re-run).\n await notation.init(rctx, notationProps(renderedRn, engravedHostWidthPx));\n await scrollCursor.init(rctx, scrollCursorProps);\n }\n if (!destroyed) setTime(lastTMs);\n }\n\n function currentLayout(): NotationLayout | null {\n const eng = getNotationEngraving(rctx);\n if (!eng) return null;\n return eng.followLayoutAt ? eng.followLayoutAt(rctx, lastTMs) : eng.base;\n }\n\n const clickListeners: Array<(measureIndex: number) => void> = [];\n function onCanvasClick(e: MouseEvent): void {\n const rect = canvas.getBoundingClientRect();\n if (!(rect.width > 0) || !(rect.height > 0)) return;\n const mx = (e.clientX - rect.left) * (canvas.width / rect.width);\n const my = (e.clientY - rect.top) * (canvas.height / rect.height);\n const layout = currentLayout();\n if (!layout) return;\n const idx = hitTestMeasureAt(layout, mx, my);\n if (idx != null) for (const cb of clickListeners) cb(idx);\n }\n canvas.addEventListener('click', onCanvasClick);\n\n return {\n ready,\n setTime,\n resize(): void {\n void performResize();\n },\n onMeasureClick(cb: (measureIndex: number) => void): () => void {\n clickListeners.push(cb);\n return () => {\n const i = clickListeners.indexOf(cb);\n if (i >= 0) clickListeners.splice(i, 1);\n };\n },\n destroy(): void {\n if (destroyed) return;\n destroyed = true;\n canvas.removeEventListener('click', onCanvasClick);\n notation.dispose?.();\n scrollCursor.dispose?.();\n clickListeners.length = 0;\n if (canvas.parentNode === host) host.removeChild(canvas);\n },\n };\n}\n\n/**\n * `display:'scroll'` implementation (0.37.0) — the tiled, page-scrolling\n * full-score player. See `CreateNotationPlayerOpts.display`'s doc for the\n * design summary. Structurally independent of `createWindowPlayer`: it does\n * NOT use the `notation`/`scroll-cursor` Layer pair (those are built around\n * ONE bounded canvas + a follow camera — the opposite of \"show the whole\n * score, let the PAGE scroll\"). It reuses the SAME pure geometry\n * (`renderNotation`'s raster, `flowLayout` → `notationLayout`,\n * `audioPlayheadLine`/`vstackAudioPlayheadLine`, `hitTestMeasureAt`) instead\n * of the Layer wrappers around it.\n *\n * AUTO-FOLLOW — exact rule (per the design's requirement to document it):\n * - Suspended the instant the PAGE scrolls for a reason other than this\n * player's own `scrollIntoView` call. Every `scrollIntoView` call opens a\n * short (600ms) \"programmatic scroll\" grace window (`programmaticScrollUntil`);\n * a `window` `scroll` event firing AFTER that window closes is treated as\n * manual and suspends auto-follow.\n * - Re-armed on the next `setTime(tMs)` call that looks like a seek/play\n * discontinuity rather than a normal per-frame tick, defined operationally\n * (a live player has no other signal to distinguish the two) as: `tMs`\n * moving BACKWARD since the previous call, OR the elapsed MUSIC time\n * (`tMs` delta) disagreeing with the elapsed WALL-CLOCK time between the\n * two `setTime` calls by more than `SEEK_DISCONTINUITY_MS` (400ms) — i.e.\n * the caller visibly jumped the playhead instead of ticking it forward\n * one frame at a time. The very first `setTime` call also re-arms (no\n * prior wall-clock sample to compare against).\n * - When armed, a rAF-debounced check (at most one `scrollIntoView` decision\n * per animation frame, regardless of how many `setTime` calls land within\n * it) re-centers the playhead ONLY when it has left a comfortable middle\n * band of the viewport (25%-75% of `window.innerHeight`) — so a playhead\n * already near-centered never triggers a redundant/jittery scroll.\n */\nfunction createScrollPlayer(opts: CreateNotationPlayerOpts): NotationPlayer {\n const { host, musicXml, noteCols, barDurMs, bars } = opts;\n const mode: NotationPlayerMode = opts.mode ?? 'vstack';\n const theme: PromoTheme = { ...DEFAULT_THEME, ...opts.theme };\n const dpr = resolveDpr();\n const onsets = distinctOnsets(opts.onsetsMs.map((onsetMs) => ({ onsetMs })));\n\n const root = document.createElement('div');\n root.style.position = 'relative';\n root.style.width = '100%';\n host.appendChild(root);\n\n const playheadEl = document.createElement('div');\n playheadEl.style.position = 'absolute';\n playheadEl.style.left = '0px';\n playheadEl.style.top = '0px';\n playheadEl.style.width = '2px';\n playheadEl.style.height = '0px';\n playheadEl.style.background = theme.accent;\n playheadEl.style.opacity = '0';\n // Never intercept clicks — the 2px-wide line otherwise shadows a thin\n // vertical strip of every tile underneath it for the entire page height.\n playheadEl.style.pointerEvents = 'none';\n root.appendChild(playheadEl);\n\n let rn: RenderedNotation | null = null;\n let currentFlow: NotationLayout | null = null;\n let engravedHostWidthPx = 0;\n let destroyed = false;\n let lastTMs = 0;\n\n const clickListeners: Array<(measureIndex: number) => void> = [];\n const tileCanvases: HTMLCanvasElement[] = [];\n const tileClickHandlers: Array<{ el: HTMLCanvasElement; fn: (e: MouseEvent) => void }> = [];\n\n async function doEngrave(hostWidthPx: number): Promise<RenderedNotation> {\n if (opts.rendered) return opts.rendered;\n // Literal dynamic import, same reason as notation.ts's own: consumers'\n // bundlers must statically see the specifier, and callers using the\n // `rendered` test seam never need `renderNotation`/OSMD pulled in at all.\n const { renderNotation } = await import('./promo');\n return renderNotation(musicXml, { bars, paper: theme.paper, scrollMode: mode, hostWidth: hostWidthPx });\n }\n\n function clearTiles(): void {\n for (const { el, fn } of tileClickHandlers) el.removeEventListener('click', fn);\n tileClickHandlers.length = 0;\n for (const c of tileCanvases) c.remove();\n tileCanvases.length = 0;\n }\n\n // Per-tile hit-testing mapped through tile offsets to the existing measure\n // hit-test (design point 5): a click's LOCAL (mx, myLocal) inside one tile\n // canvas is translated into the SAME full-score device-px space\n // `currentFlow.measures[].box` lives in by adding that tile's own y-offset\n // (`computeScrollTiles`'s `offsets[i]`, captured per-tile at build time) —\n // then it's the exact same `hitTestMeasureAt` window mode uses.\n function handleTileClick(e: MouseEvent, canvas: HTMLCanvasElement, tileOffsetDev: number): void {\n if (!currentFlow) return;\n const rect = canvas.getBoundingClientRect();\n if (!(rect.width > 0) || !(rect.height > 0)) return;\n const mx = (e.clientX - rect.left) * (canvas.width / rect.width);\n const myLocal = (e.clientY - rect.top) * (canvas.height / rect.height);\n const idx = hitTestMeasureAt(currentFlow, mx, tileOffsetDev + myLocal);\n if (idx != null) for (const cb of clickListeners) cb(idx);\n }\n\n function buildTiles(flow: NotationLayout, dispWdev: number): void {\n clearTiles();\n if (!rn) return;\n const spec = computeScrollTiles(dispWdev, flow.rect.dh, dpr);\n // Device px of SOURCE raster per device px of DEST tile — uniform (no\n // distortion) since `flowLayout` scales width and height by the same\n // factor; derived from the SAME flow layout every tile blits from, not\n // re-computed per tile.\n const scale = flow.src.w > 0 ? flow.rect.dw / flow.src.w : 1;\n for (let i = 0; i < spec.count; i++) {\n const c = document.createElement('canvas');\n c.style.display = 'block';\n c.style.width = '100%';\n c.style.height = `${spec.heights[i] / dpr}px`;\n // Same reasoning as the window-mode canvas: let the page's own vertical\n // swipe/wheel gesture through (this IS the primary scroll mechanism in\n // scroll mode) while still letting a near-zero-movement tap reach the\n // click handler below.\n c.style.touchAction = 'pan-y';\n c.width = Math.max(1, Math.round(dispWdev));\n c.height = Math.max(1, Math.round(spec.heights[i]));\n root.insertBefore(c, playheadEl);\n tileCanvases.push(c);\n const tileOffsetDev = spec.offsets[i];\n const onClick = (e: MouseEvent) => handleTileClick(e, c, tileOffsetDev);\n c.addEventListener('click', onClick);\n tileClickHandlers.push({ el: c, fn: onClick });\n const cctx = c.getContext('2d');\n if (cctx) {\n cctx.fillStyle = theme.paper;\n cctx.fillRect(0, 0, c.width, c.height);\n // This tile's slice of the SOURCE raster, in raster (src) px — the\n // inverse of `flowLayout`'s forward map, sliced to just this tile's\n // y-range. One drawImage per tile, same drawImage SHAPE window mode's\n // `notation` layer uses (src rect → dest rect), just y-sliced.\n const srcY = flow.src.y + tileOffsetDev / scale;\n const srcH = spec.heights[i] / scale;\n cctx.drawImage(rn.canvas, flow.src.x, srcY, flow.src.w, srcH, 0, 0, c.width, c.height);\n }\n }\n }\n\n function relayout(): void {\n if (destroyed || !rn) return;\n const [w] = frameSize(host, dpr, opts.size);\n const flow = flowLayout(rn, w);\n currentFlow = flow;\n buildTiles(flow, w);\n updatePlayhead(lastTMs);\n }\n\n const ready = (async () => {\n engravedHostWidthPx = desiredEngraveWidth(host);\n rn = await doEngrave(engravedHostWidthPx);\n relayout();\n })();\n\n async function performResize(): Promise<void> {\n if (destroyed || !rn) return;\n const newEngraveWidth = desiredEngraveWidth(host);\n // Same re-engrave condition as window mode's `performResize` — see its\n // comment for why `!opts.rendered` gates it (the headless/test seam has\n // no real xml engrave to redo).\n const needsReEngrave = !opts.rendered && newEngraveWidth !== engravedHostWidthPx;\n if (needsReEngrave) {\n engravedHostWidthPx = newEngraveWidth;\n rn = await doEngrave(engravedHostWidthPx);\n }\n // Unlike window mode, ALWAYS relayout+rebuild here (not just on\n // re-engrave): a scroll-mode tile's pixel width/height depends directly\n // on `dispWdev`/`dpr`, which can change (window resize, DPR change via a\n // display swap) without crossing the re-engrave breakpoint.\n relayout();\n }\n\n // ─── Auto-follow — see this function's doc comment for the exact rule ────\n const SEEK_DISCONTINUITY_MS = 400;\n let lastWallMs: number | null = null;\n let lastMusicMs = 0;\n let autoFollowSuspended = false;\n let programmaticScrollUntil = 0;\n let followRafPending = false;\n\n function onWindowScroll(): void {\n if (nowMs() > programmaticScrollUntil) autoFollowSuspended = true;\n }\n const hasWindow = typeof window !== 'undefined' && typeof window.addEventListener === 'function';\n if (hasWindow) window.addEventListener('scroll', onWindowScroll, { passive: true });\n\n function detectDiscontinuity(tMs: number): void {\n const now = nowMs();\n if (lastWallMs != null) {\n const dtMusic = tMs - lastMusicMs;\n const dtWall = now - lastWallMs;\n if (dtMusic < 0 || Math.abs(dtMusic - dtWall) > SEEK_DISCONTINUITY_MS) {\n autoFollowSuspended = false;\n }\n } else {\n autoFollowSuspended = false;\n }\n lastWallMs = now;\n lastMusicMs = tMs;\n }\n\n function maybeAutoFollow(): void {\n if (autoFollowSuspended || followRafPending) return;\n if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') return;\n followRafPending = true;\n window.requestAnimationFrame(() => {\n followRafPending = false;\n if (destroyed || autoFollowSuspended) return;\n if (typeof playheadEl.getBoundingClientRect !== 'function') return;\n const rect = playheadEl.getBoundingClientRect();\n const vh = window.innerHeight;\n if (!(vh > 0)) return;\n if (rect.top >= vh * 0.25 && rect.top <= vh * 0.75) return; // comfortably in view\n if (typeof playheadEl.scrollIntoView === 'function') {\n programmaticScrollUntil = nowMs() + 600;\n playheadEl.scrollIntoView({ block: 'center', behavior: 'smooth' });\n }\n });\n }\n\n function updatePlayhead(tMs: number): void {\n lastTMs = tMs;\n if (!currentFlow || !rn) return;\n // Same dispatch scrollCursor.ts's draw() uses — SAME functions, just fed\n // the full-score `flowLayout` instead of the camera-cropped follow\n // layout, so `y` naturally spans the WHOLE tile stack (carriage returns\n // = y jumps between systems, exactly as window mode's vstack cursor\n // jumps between follow-window rows — no per-tile special-casing).\n const line = mode === 'vstack'\n ? vstackAudioPlayheadLine(currentFlow, onsets, tMs, measureCount(rn), noteCols)\n : audioPlayheadLine(currentFlow, onsets, tMs, barDurMs, noteCols);\n if (!line) {\n playheadEl.style.opacity = '0';\n return;\n }\n playheadEl.style.opacity = String(line.alpha);\n playheadEl.style.left = `${line.x / dpr}px`;\n playheadEl.style.top = `${line.y0 / dpr}px`;\n playheadEl.style.height = `${Math.max(0, line.y1 - line.y0) / dpr}px`;\n maybeAutoFollow();\n }\n\n function setTime(tMs: number): void {\n if (destroyed) return;\n detectDiscontinuity(tMs);\n updatePlayhead(tMs);\n }\n\n return {\n ready,\n setTime,\n resize(): void {\n void performResize();\n },\n onMeasureClick(cb: (measureIndex: number) => void): () => void {\n clickListeners.push(cb);\n return () => {\n const i = clickListeners.indexOf(cb);\n if (i >= 0) clickListeners.splice(i, 1);\n };\n },\n destroy(): void {\n if (destroyed) return;\n destroyed = true;\n if (hasWindow) window.removeEventListener('scroll', onWindowScroll);\n clearTiles();\n clickListeners.length = 0;\n if (root.parentNode === host) host.removeChild(root);\n },\n };\n}\n\n/**\n * Build a live, interactive notation player. Dispatches on\n * `opts.display` (default `'window'`) — see the module doc's \"DISPLAY MODES\"\n * section, `NotationPlayerDisplay`, `createWindowPlayer`, and\n * `createScrollPlayer`.\n */\nexport function createNotationPlayer(opts: CreateNotationPlayerOpts): NotationPlayer {\n return (opts.display ?? 'window') === 'scroll' ? createScrollPlayer(opts) : createWindowPlayer(opts);\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@real-music-packages/web-core",
3
- "version": "0.36.2",
3
+ "version": "0.37.0",
4
4
  "description": "Shared music-theory + audio primitives for the music-suite web apps",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",