@real-music-packages/web-core 0.36.2 → 0.38.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.
@@ -0,0 +1,278 @@
1
+ import {
2
+ audioPlayheadLine,
3
+ distinctOnsets,
4
+ followBoxAt,
5
+ followWindowStart,
6
+ measureCount,
7
+ notationLayout,
8
+ vstackAudioPlayheadLine,
9
+ vstackFollowBox
10
+ } from "./chunk-BHRDISMU.js";
11
+ import {
12
+ safeBox
13
+ } from "./chunk-HXTRNE74.js";
14
+
15
+ // src/scene/engravingStore.ts
16
+ var STORE = /* @__PURE__ */ new WeakMap();
17
+ function keyFor(ctx) {
18
+ return ctx.audioClock;
19
+ }
20
+ function setNotationEngraving(ctx, eng) {
21
+ STORE.set(keyFor(ctx), eng);
22
+ }
23
+ function getNotationEngraving(ctx) {
24
+ return STORE.get(keyFor(ctx));
25
+ }
26
+ function setFollowLayoutProvider(ctx, fn) {
27
+ const e = STORE.get(keyFor(ctx));
28
+ if (e) e.followLayoutAt = fn;
29
+ }
30
+
31
+ // src/scene/layers/notation.ts
32
+ function notationLayer() {
33
+ let rn = null;
34
+ let base = null;
35
+ let propScale = 1;
36
+ let propBandTop;
37
+ let propBandHeight;
38
+ let propBandWidth;
39
+ function bandTop(_ctx, sb) {
40
+ return propBandTop ?? sb.top;
41
+ }
42
+ function bandHeight(ctx, sb) {
43
+ const top = bandTop(ctx, sb);
44
+ return (propBandHeight ?? sb.bottom - top) * propScale;
45
+ }
46
+ return {
47
+ key: "notation",
48
+ async init(ctx, props) {
49
+ propScale = props.scale ?? 1;
50
+ propBandTop = props.bandTop;
51
+ propBandHeight = props.bandHeight;
52
+ propBandWidth = props.bandWidth;
53
+ if (props.rendered) {
54
+ rn = props.rendered;
55
+ } else if (props.xml) {
56
+ const { renderNotation } = await import("./promo.js");
57
+ rn = await renderNotation(props.xml, {
58
+ bars: props.bars,
59
+ paper: ctx.theme.paper,
60
+ scrollMode: props.scrollMode ?? "hstack",
61
+ hostWidth: props.hostWidth
62
+ });
63
+ } else {
64
+ throw new Error("notation layer: provide `rendered` or `xml`");
65
+ }
66
+ const sb = safeBox(ctx.W, ctx.H);
67
+ const top = bandTop(ctx, sb);
68
+ const height = bandHeight(ctx, sb);
69
+ base = notationLayout(rn, ctx.W, ctx.H, top, height, { boxWidth: propBandWidth });
70
+ setNotationEngraving(ctx, { rendered: rn, base, bandTop: top, bandHeight: height, bandWidth: propBandWidth });
71
+ },
72
+ draw(ctx, tMs) {
73
+ if (!rn || !base) return;
74
+ const eng = getNotationEngraving(ctx);
75
+ const l = eng?.followLayoutAt ? eng.followLayoutAt(ctx, tMs) : base;
76
+ const c = ctx.ctx2d;
77
+ c.drawImage(
78
+ rn.canvas,
79
+ l.src.x,
80
+ l.src.y,
81
+ l.src.w,
82
+ l.src.h,
83
+ l.rect.dx,
84
+ l.rect.dy,
85
+ l.rect.dw,
86
+ l.rect.dh
87
+ );
88
+ },
89
+ dispose() {
90
+ rn = null;
91
+ base = null;
92
+ }
93
+ };
94
+ }
95
+ var notationFactory = {
96
+ key: "notation",
97
+ create: notationLayer,
98
+ validateProps(props) {
99
+ const errs = [];
100
+ if (props == null || typeof props !== "object") return ["notation: props must be an object"];
101
+ const p = props;
102
+ if (p.system != null && p.system !== "grand" && p.system !== "single")
103
+ errs.push('notation.system must be "grand" | "single"');
104
+ if (p.scrollMode != null && p.scrollMode !== "hstack" && p.scrollMode !== "vstack")
105
+ errs.push('notation.scrollMode must be "hstack" | "vstack"');
106
+ if (p.scale != null && (typeof p.scale !== "number" || p.scale <= 0))
107
+ errs.push("notation.scale must be a positive number");
108
+ if (p.rendered == null && typeof p.xml !== "string")
109
+ errs.push("notation: provide `rendered` (RenderedNotation) or `xml` (string)");
110
+ if (p.bars != null && (!Array.isArray(p.bars) || p.bars.length !== 2))
111
+ errs.push("notation.bars must be [from,to]");
112
+ if (p.hostWidth != null && (typeof p.hostWidth !== "number" || p.hostWidth <= 0))
113
+ errs.push("notation.hostWidth must be a positive number");
114
+ if (p.bandWidth != null && (typeof p.bandWidth !== "number" || p.bandWidth <= 0))
115
+ errs.push("notation.bandWidth must be a positive number");
116
+ return errs;
117
+ }
118
+ };
119
+
120
+ // src/scene/layers/scrollCursor.ts
121
+ function followLayoutFor(ctx, progress01, scrollMode) {
122
+ const eng = getNotationEngraving(ctx);
123
+ if (!eng) return null;
124
+ const nBars = measureCount(eng.rendered);
125
+ if (nBars <= 0) return eng.base;
126
+ const focusBox = scrollMode === "vstack" ? vstackFollowBox(eng.rendered, progress01) : followBoxAt(eng.rendered, followWindowStart(eng.rendered, progress01));
127
+ return notationLayout(eng.rendered, ctx.W, ctx.H, eng.bandTop, eng.bandHeight, { focusBox, boxWidth: eng.bandWidth });
128
+ }
129
+ function scrollCursorLayer() {
130
+ let scrollMode = "hstack";
131
+ let openingZoomMs = 900;
132
+ let color;
133
+ let barDurMs;
134
+ let propNoteCols;
135
+ function onsetsFor(ctx) {
136
+ const notes = ctx.score?.notes;
137
+ return notes && notes.length ? distinctOnsets(notes) : [];
138
+ }
139
+ function notesProgress(onsetsMs, tMs, totalMeasures, noteCols) {
140
+ if (tMs < openingZoomMs) return 0;
141
+ const n = onsetsMs.length;
142
+ if (n <= 1) return 0;
143
+ const first = onsetsMs[0];
144
+ const last = onsetsMs[n - 1];
145
+ if (tMs <= first) return 0;
146
+ if (tMs >= last || last <= first) return 1;
147
+ let lo = 0, hi = n - 1;
148
+ while (lo < hi) {
149
+ const mid = lo + hi + 1 >> 1;
150
+ if (onsetsMs[mid] <= tMs) lo = mid;
151
+ else hi = mid - 1;
152
+ }
153
+ const segFrac = (tMs - onsetsMs[lo]) / (onsetsMs[lo + 1] - onsetsMs[lo]);
154
+ if (noteCols && noteCols.length === n && totalMeasures > 0) {
155
+ const pos = noteCols[lo] + (noteCols[lo + 1] - noteCols[lo]) * segFrac;
156
+ return Math.min(1, Math.max(0, pos / totalMeasures));
157
+ }
158
+ if (barDurMs && barDurMs > 0 && totalMeasures > 0) {
159
+ const posLo = (onsetsMs[lo] - first) / barDurMs;
160
+ const posHi = (onsetsMs[lo + 1] - first) / barDurMs;
161
+ const pos = posLo + (posHi - posLo) * segFrac;
162
+ return Math.min(1, Math.max(0, pos / totalMeasures));
163
+ }
164
+ return (lo + segFrac) / (n - 1);
165
+ }
166
+ function noteColsFor(ctx) {
167
+ const eng = getNotationEngraving(ctx)?.rendered?.noteCols;
168
+ const onsets = onsetsFor(ctx);
169
+ const n = onsets.length;
170
+ if (!eng || !eng.length) return propNoteCols;
171
+ if (eng.length === n) return eng;
172
+ if (!propNoteCols || propNoteCols.length !== n) return eng;
173
+ const measureOf = (c) => Math.floor(c + 1e-6);
174
+ const engByMeasure = /* @__PURE__ */ new Map();
175
+ for (const e of eng) {
176
+ const m = measureOf(e);
177
+ const a = engByMeasure.get(m);
178
+ if (a) a.push(e);
179
+ else engByMeasure.set(m, [e]);
180
+ }
181
+ for (const a of engByMeasure.values()) a.sort((x, y) => x - y);
182
+ const idxByMeasure = /* @__PURE__ */ new Map();
183
+ propNoteCols.forEach((c, i) => {
184
+ const m = measureOf(c);
185
+ const a = idxByMeasure.get(m);
186
+ if (a) a.push(i);
187
+ else idxByMeasure.set(m, [i]);
188
+ });
189
+ const out = propNoteCols.slice();
190
+ for (const [m, idxs] of idxByMeasure) {
191
+ const e = engByMeasure.get(m);
192
+ if (!e || !e.length) continue;
193
+ const K = idxs.length;
194
+ idxs.forEach((origIdx, i) => {
195
+ const j = K > 1 ? Math.round(i * (e.length - 1) / (K - 1)) : 0;
196
+ out[origIdx] = e[Math.min(j, e.length - 1)];
197
+ });
198
+ }
199
+ return out;
200
+ }
201
+ function followProgress(ctx, tMs) {
202
+ const eng = getNotationEngraving(ctx);
203
+ const total = eng ? measureCount(eng.rendered) : 0;
204
+ return notesProgress(onsetsFor(ctx), tMs, total, noteColsFor(ctx));
205
+ }
206
+ function layoutAt(ctx, tMs) {
207
+ const eng = getNotationEngraving(ctx);
208
+ return followLayoutFor(ctx, followProgress(ctx, tMs), scrollMode) ?? eng.base;
209
+ }
210
+ return {
211
+ key: "scroll-cursor",
212
+ init(ctx, props) {
213
+ scrollMode = props.scrollMode ?? "hstack";
214
+ openingZoomMs = props.openingZoomMs ?? 900;
215
+ color = props.color;
216
+ barDurMs = props.barDurMs;
217
+ propNoteCols = Array.isArray(props.noteCols) ? props.noteCols : void 0;
218
+ setFollowLayoutProvider(ctx, layoutAt);
219
+ },
220
+ draw(ctx, tMs) {
221
+ const eng = getNotationEngraving(ctx);
222
+ if (!eng) return;
223
+ const layout = layoutAt(ctx, tMs);
224
+ const onsets = onsetsFor(ctx);
225
+ let line;
226
+ if (scrollMode === "vstack") {
227
+ line = vstackAudioPlayheadLine(layout, onsets, tMs, measureCount(eng.rendered), noteColsFor(ctx));
228
+ } else {
229
+ line = audioPlayheadLine(layout, onsets, tMs, barDurMs, noteColsFor(ctx));
230
+ }
231
+ if (!line) return;
232
+ const c = ctx.ctx2d;
233
+ c.save();
234
+ c.strokeStyle = color ?? ctx.theme.accent;
235
+ c.globalAlpha = line.alpha;
236
+ c.lineWidth = 4;
237
+ c.beginPath();
238
+ c.moveTo(line.x, line.y0);
239
+ c.lineTo(line.x, line.y1);
240
+ c.stroke();
241
+ c.restore();
242
+ }
243
+ };
244
+ }
245
+ var scrollCursorFactory = {
246
+ key: "scroll-cursor",
247
+ create: scrollCursorLayer,
248
+ validateProps(props) {
249
+ const errs = [];
250
+ if (props == null || typeof props !== "object") return ["scroll-cursor: props must be an object"];
251
+ const p = props;
252
+ if (p.mode != null && p.mode !== "audio" && p.mode !== "linear")
253
+ errs.push('scroll-cursor.mode must be "audio" | "linear"');
254
+ if (p.scrollMode != null && p.scrollMode !== "hstack" && p.scrollMode !== "vstack")
255
+ errs.push('scroll-cursor.scrollMode must be "hstack" | "vstack"');
256
+ if (p.musicMs != null && (typeof p.musicMs !== "number" || !(p.musicMs > 0)))
257
+ errs.push("scroll-cursor.musicMs must be a positive number");
258
+ if (p.followBars != null && (typeof p.followBars !== "number" || p.followBars < 1))
259
+ errs.push("scroll-cursor.followBars must be a number >= 1");
260
+ if (p.openingZoomMs != null && (typeof p.openingZoomMs !== "number" || p.openingZoomMs < 0))
261
+ errs.push("scroll-cursor.openingZoomMs must be a number >= 0");
262
+ if (p.color != null && typeof p.color !== "string") errs.push("scroll-cursor.color must be a string");
263
+ if (p.barDurMs != null && (typeof p.barDurMs !== "number" || !(p.barDurMs > 0)))
264
+ errs.push("scroll-cursor.barDurMs must be a positive number");
265
+ if (p.noteCols != null && (!Array.isArray(p.noteCols) || p.noteCols.some((c) => typeof c !== "number")))
266
+ errs.push("scroll-cursor.noteCols must be a number[]");
267
+ return errs;
268
+ }
269
+ };
270
+
271
+ export {
272
+ setNotationEngraving,
273
+ getNotationEngraving,
274
+ setFollowLayoutProvider,
275
+ notationFactory,
276
+ scrollCursorFactory
277
+ };
278
+ //# sourceMappingURL=chunk-QGKDKXX2.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/scene/engravingStore.ts","../src/scene/layers/notation.ts","../src/scene/layers/scrollCursor.ts"],"sourcesContent":["// Shared engraving handoff (S2) — lets the `notation` + `scroll-cursor` layers\n// cooperate without re-laying-out: notation publishes its rasterized engraving;\n// scroll-cursor publishes the per-frame follow LAYOUT (the src crop + dest rect),\n// which notation then blits. They agree by construction (one geometry source).\n//\n// Why a side store rather than Score.engraving: the spec's Score.engraving holds\n// browser-only canvas types, but Score is parsed headless in Node and is the\n// audio source for whozart (no notation). Keeping the engraving off Score keeps\n// score.ts DOM-free. We key the store by the runner's shared `audioClock` object\n// (one per render) via a WeakMap — per-render, GC-friendly, no globals.\n\nimport type { RenderCtx } from './layer';\nimport type { RenderedNotation } from '../promo';\nimport type { NotationLayout } from './notationGeometry';\n\nexport interface NotationEngraving {\n rendered: RenderedNotation;\n /** The base world layout (full content fitted to the band; zoom01=1). */\n base: NotationLayout;\n /** Notation band rect (screen px) the follow window fits into, per frame. */\n bandTop: number;\n bandHeight: number;\n /** Explicit horizontal fit width (screen px), forwarded to `notationLayout`'s\n * `boxWidth` — undefined keeps the default `safeBox(W,H).centeredW` promo\n * framing (see `notationGeometry.ts`'s `NotationLayoutOpts.boxWidth` doc).\n * Published so `scroll-cursor`'s per-frame follow layout uses the SAME\n * width `notation`'s base layout was computed with — they must agree, or\n * the visible framing jumps between the base frame and the first followed\n * frame. */\n bandWidth?: number;\n /**\n * A pure follow-layout provider, published by scroll-cursor at init. notation\n * calls it each frame to blit the SAME followed-bars window the cursor sweeps —\n * so the two agree by construction AND z-order is correct (notation drawn first,\n * cursor line on top), regardless of which layer the runner draws first.\n * Absent when no scroll-cursor is in the scene (notation-only) -> base layout.\n */\n followLayoutAt?: (ctx: RenderCtx, tMs: number) => NotationLayout;\n}\n\nconst STORE = new WeakMap<object, NotationEngraving>();\n\nfunction keyFor(ctx: RenderCtx): object {\n return ctx.audioClock;\n}\n\nexport function setNotationEngraving(ctx: RenderCtx, eng: NotationEngraving): void {\n STORE.set(keyFor(ctx), eng);\n}\n\nexport function getNotationEngraving(ctx: RenderCtx): NotationEngraving | undefined {\n return STORE.get(keyFor(ctx));\n}\n\n/** scroll-cursor publishes its follow-layout provider; notation reads it. */\nexport function setFollowLayoutProvider(\n ctx: RenderCtx,\n fn: (ctx: RenderCtx, tMs: number) => NotationLayout,\n): void {\n const e = STORE.get(keyFor(ctx));\n if (e) e.followLayoutAt = fn;\n}\n","// `notation` layer (S2) — extracted FAITHFULLY from RSR's render code\n// (stave-web-sightread/src/routes/promo/+page.svelte drawNotation +\n// $lib/promo/notation.ts renderNotation).\n//\n// Responsibilities:\n// - init(): rasterize the engraving ONCE via web-core's renderNotation (OSMD).\n// The expensive OSMD relayout happens here, never per-frame (spec: \"render\n// once, rasterize, pan/scroll the bitmap\").\n// - lay out the bitmap into the base world rect once (RSR's drawNotation with\n// zoom01=1, focusBox=null) — the full content fitted to the notation band.\n// - draw(): blit the bitmap using the PER-FRAME follow layout published by the\n// scroll-cursor layer (RSR re-crops the src window each frame to keep the\n// followed 2 bars filling the band). When no scroll-cursor is present it falls\n// back to the base full-excerpt layout. This is RSR's drawNotation exactly,\n// with only the single drawImage living here.\n//\n// The engraving + base layout are published via the engraving store (set in init)\n// so the scroll-cursor layer reads the SAME geometry without re-laying-out. Both\n// layers therefore agree by construction (one geometry source).\n\nimport type { Layer, LayerFactory, RenderCtx } from '../layer';\nimport type { RenderedNotation } from '../../promo';\nimport { safeBox } from '../../video';\nimport { notationLayout, type NotationLayout } from '../notationGeometry';\nimport { setNotationEngraving, getNotationEngraving } from '../engravingStore';\n\nexport interface NotationProps {\n /** Engraving system: \"grand\" (two staves) or \"single\". Informational for v1 —\n * the layout is driven by the rasterized bitmap's geometry either way. */\n system?: 'grand' | 'single';\n /** Extra scale applied to the band height the notation fits into. Default 1. */\n scale?: number;\n /**\n * A pre-rendered notation (test/headless injection). When omitted, init()\n * calls renderNotation(xml). One of `rendered` or `xml` is required.\n */\n rendered?: RenderedNotation;\n /** MusicXML to engrave (browser path). Ignored when `rendered` is given. */\n xml?: string;\n /** Bar range [from,to] forwarded to renderNotation (RSR drawFrom/drawUpTo). */\n bars?: [number, number];\n /**\n * Engraving scroll mode (forwarded to renderNotation when engraving from\n * `xml`). 'hstack' (default) = single horizontal staffline; 'vstack' = stacked\n * systems. MUST match the scroll-cursor layer's `scrollMode`. Ignored when a\n * pre-rendered `rendered` is supplied (engraving is already laid out).\n */\n scrollMode?: 'hstack' | 'vstack';\n /** Top y of the notation band (screen px). Default safeBox.top. */\n bandTop?: number;\n /** Height of the notation band (screen px). Default safeBox.bottom - bandTop. */\n bandHeight?: number;\n /** Offscreen OSMD host div width in CSS px, forwarded verbatim to\n * `renderNotation`'s `hostWidth` (default 560 there when omitted). This is\n * the width OSMD line-breaks against — a live player wanting engraved\n * systems to use its actual container width (rather than always the\n * 560px default meant for a fixed-size promo card) sets this to its own\n * measured host size. Ignored when `rendered` is supplied (no engrave\n * happens). */\n hostWidth?: number;\n /** Explicit horizontal fit width (screen px), forwarded to `notationLayout`'s\n * `boxWidth` (see its doc comment) — bypasses `safeBox`'s promo-video\n * caption/share-button margins (~24% of width reserved) so the engraving\n * can use the FULL band width instead. Default: unset (existing\n * `safeBox(W,H).centeredW` promo framing, unchanged for every caller that\n * doesn't pass this). */\n bandWidth?: number;\n}\n\nfunction notationLayer(): Layer<NotationProps> {\n let rn: RenderedNotation | null = null;\n let base: NotationLayout | null = null;\n\n let propScale = 1;\n let propBandTop: number | undefined;\n let propBandHeight: number | undefined;\n let propBandWidth: number | undefined;\n\n function bandTop(_ctx: RenderCtx, sb: ReturnType<typeof safeBox>): number {\n return propBandTop ?? sb.top;\n }\n function bandHeight(ctx: RenderCtx, sb: ReturnType<typeof safeBox>): number {\n const top = bandTop(ctx, sb);\n return (propBandHeight ?? sb.bottom - top) * propScale;\n }\n\n return {\n key: 'notation',\n async init(ctx, props) {\n propScale = props.scale ?? 1;\n propBandTop = props.bandTop;\n propBandHeight = props.bandHeight;\n propBandWidth = props.bandWidth;\n\n if (props.rendered) {\n rn = props.rendered;\n } else if (props.xml) {\n // Browser path: rasterize the engraving once (OSMD). Literal import is\n // inside renderNotation so bundlers resolve it.\n const { renderNotation } = await import('../../promo');\n rn = await renderNotation(props.xml, {\n bars: props.bars,\n paper: ctx.theme.paper,\n scrollMode: props.scrollMode ?? 'hstack',\n hostWidth: props.hostWidth,\n });\n } else {\n throw new Error('notation layer: provide `rendered` or `xml`');\n }\n\n // Publish the engraving + base layout + band so the scroll-cursor layer\n // reads the SAME geometry (no second relayout). ctx2d may be null in init.\n const sb = safeBox(ctx.W, ctx.H);\n const top = bandTop(ctx, sb);\n const height = bandHeight(ctx, sb);\n base = notationLayout(rn, ctx.W, ctx.H, top, height, { boxWidth: propBandWidth });\n setNotationEngraving(ctx, { rendered: rn, base, bandTop: top, bandHeight: height, bandWidth: propBandWidth });\n },\n\n draw(ctx, tMs) {\n if (!rn || !base) return;\n // Blit the scroll-cursor's follow window when present (a scroll-cursor layer\n // published a pure follow-layout provider at init); else the base full-\n // excerpt layout (notation-only scene). One drawImage — the sole canvas op of\n // RSR's drawNotation. Calling the provider (rather than reading a value the\n // cursor's draw set) makes blit z-order independent of layer draw order.\n const eng = getNotationEngraving(ctx);\n const l = eng?.followLayoutAt ? eng.followLayoutAt(ctx, tMs) : base;\n const c = ctx.ctx2d;\n c.drawImage(\n rn.canvas,\n l.src.x, l.src.y, l.src.w, l.src.h,\n l.rect.dx, l.rect.dy, l.rect.dw, l.rect.dh,\n );\n },\n\n dispose() {\n rn = null;\n base = null;\n },\n };\n}\n\nexport const notationFactory: LayerFactory<NotationProps> = {\n key: 'notation',\n create: notationLayer,\n validateProps(props) {\n const errs: string[] = [];\n if (props == null || typeof props !== 'object') return ['notation: props must be an object'];\n const p = props as Record<string, unknown>;\n if (p.system != null && p.system !== 'grand' && p.system !== 'single')\n errs.push('notation.system must be \"grand\" | \"single\"');\n if (p.scrollMode != null && p.scrollMode !== 'hstack' && p.scrollMode !== 'vstack')\n errs.push('notation.scrollMode must be \"hstack\" | \"vstack\"');\n if (p.scale != null && (typeof p.scale !== 'number' || p.scale <= 0))\n errs.push('notation.scale must be a positive number');\n if (p.rendered == null && typeof p.xml !== 'string')\n errs.push('notation: provide `rendered` (RenderedNotation) or `xml` (string)');\n if (p.bars != null && (!Array.isArray(p.bars) || p.bars.length !== 2))\n errs.push('notation.bars must be [from,to]');\n if (p.hostWidth != null && (typeof p.hostWidth !== 'number' || p.hostWidth <= 0))\n errs.push('notation.hostWidth must be a positive number');\n if (p.bandWidth != null && (typeof p.bandWidth !== 'number' || p.bandWidth <= 0))\n errs.push('notation.bandWidth must be a positive number');\n return errs;\n },\n};\n","// `scroll-cursor` layer (S2) — the scrolling playhead + 2-bar follow window.\n//\n// AUDIO-ONSET LOCKED, ALWAYS (web-core 0.22.0). The cursor is positioned by the\n// audio clock NOTE-BY-NOTE — never by a free-running linear interpolation. Each\n// played note is anchored to a geometry X (from the followed layout's mapped\n// measure columns); the cursor is the time-lerp of the two notes bracketing the\n// current audio time `t` by their `onsetMs`. It therefore lands on each note as\n// it sounds and only ever traverses the notes that actually play — it CANNOT race\n// the whole score or drift, no matter what `musicMs` / score length / segment\n// duration it is fed. This is the ONLY positioning path: feed the cursor the\n// played notes via `ctx.score` and it is onset-synced by construction.\n//\n// NO LINEAR SWEEP. The legacy \"camProgress 0..1 across musicMs\" measure-linear\n// sweep (the whozart/RSR desync footgun — a plausible-but-wrong interpolation\n// that drifts off the real onsets) has been REMOVED as a positioning path. When\n// there is NO `ctx.score` (no onsets to lock to) the cursor does NOT fake a\n// sweep: it HOLDS at the start (follow window held at bar 0, cursor on the first\n// note's anchor) so a mis-wired scene shows a stationary playhead — an obvious\n// \"no score fed\" signal — instead of a smooth-but-lying scroll. The `mode` prop\n// is retained only for back-compat; both values now resolve to the onset path\n// (it is a no-op, NOT a switch back to the linear sweep).\n//\n// Per frame it:\n// 1. derives the follow progress 0..1 from the cursor's onset clock\n// (notesProgress over the score's distinct onsets; held at 0 with no score),\n// 2. computes the follow-window start + follow box, then the follow LAYOUT\n// (notationLayout with that focusBox) — RSR's drawNotation geometry,\n// 3. publishes that layout so the notation layer blits the followed bars,\n// 4. draws the onset-anchored playhead line (audioPlayheadLine /\n// vstackAudioPlayheadLine).\n//\n// Riding the camera primitive: the follow window is a world-space rect; the same\n// scroll/zoom RSR achieves by re-cropping `src` is expressible as a camera pose\n// (frameRect over the follow rect). `cameraForFollow()` (in ../notationCamera)\n// produces that pose, and scene-camera-equivalence.test.ts proves it reproduces\n// RSR's crop.\n\nimport type { Layer, LayerFactory, RenderCtx } from '../layer';\nimport {\n audioPlayheadLine,\n distinctOnsets,\n followBoxAt,\n followWindowStart,\n measureCount,\n notationLayout,\n vstackAudioPlayheadLine,\n vstackFollowBox,\n type NotationLayout,\n} from '../notationGeometry';\nimport { getNotationEngraving, setFollowLayoutProvider } from '../engravingStore';\n\nexport interface ScrollCursorProps {\n /**\n * DEPRECATED / back-compat only. The cursor is ALWAYS audio-onset locked now —\n * driven note-by-note off the audio clock against each note's `onsetMs` (see\n * `ctx.score`). Both `'audio'` and `'linear'` resolve to that single onset path;\n * `'linear'` no longer re-enables the legacy measure-linear sweep (removed —\n * that was the drift footgun). Prefer leaving this unset.\n */\n mode?: 'audio' | 'linear';\n /**\n * Notation scroll mode — MUST match the engraving's `scrollMode`.\n * 'hstack' (default) — single horizontal staffline: the follow window is a\n * FOLLOW_BARS-wide horizontal slice that pans left→right (followBoxAt /\n * followWindowStart). No vertical movement.\n * 'vstack' — stacked systems: the follow camera frames the ACTIVE system at a\n * fixed band position and scrolls VERTICALLY to the next system as the\n * playhead crosses systems (vstackFollowBox). The playhead pans L→R within\n * the framed system; the only horizontal reset is per-system, never a\n * vertical leap over staff lines.\n */\n scrollMode?: 'hstack' | 'vstack';\n /** Bars visible in the follow window. Default 2 (RSR FOLLOW_BARS). Informational\n * for v1 — the geometry uses the module constant unless overridden here. */\n followBars?: number;\n /** Opening-zoom duration in ms before the music/cursor start (RSR INTRO_MS=900).\n * During this lead-in the follow window is held at the start. Default 900. */\n openingZoomMs?: number;\n /** DEPRECATED. Total music length in ms (RSR musicMs). No longer used to\n * position the cursor (the onset clock drives all pacing); accepted for\n * back-compat and ignored. */\n musicMs?: number;\n /** Cursor stroke colour. Defaults to theme.accent. */\n color?: string;\n /**\n * Duration of ONE measure in ms. When provided, the cursor + follow window are\n * positioned by onset TIME (column position = (onset-first)/barDurMs), so the\n * playhead lands on each note's real rhythmic X. Without it, positioning falls\n * back to the legacy ORDINAL spread (note index / count), which drifts off the\n * noteheads on any non-uniform rhythm. Assumes a constant meter over the\n * excerpt (true for the curated short excerpts).\n */\n barDurMs?: number;\n /**\n * Per-distinct-onset cursor column positions (`ordinal + frac`), 1:1 with the\n * score's sorted distinct onsets. When provided this OVERRIDES the engraving's\n * own `noteCols` — the app computes it from the score's measure timings so it\n * pairs exactly with the onsets even on polyphonic grand-staff music (engraved\n * pixel columns can't: their count diverges from the onset count). `ordinal` is\n * the 0-based measure column within the excerpt; `frac` the onset's time fraction\n * through that measure. Falls through to engraving noteCols → barDurMs when unset\n * or mismatched in length.\n */\n noteCols?: number[];\n}\n\n/** Compute the follow layout (src crop + mapped boxes) for an audio progress.\n * hstack pans a FOLLOW_BARS horizontal slice; vstack frames the active system\n * and scrolls vertically between systems. */\nfunction followLayoutFor(\n ctx: RenderCtx,\n progress01: number,\n scrollMode: 'hstack' | 'vstack',\n): NotationLayout | null {\n const eng = getNotationEngraving(ctx);\n if (!eng) return null;\n const nBars = measureCount(eng.rendered);\n if (nBars <= 0) return eng.base;\n const focusBox =\n scrollMode === 'vstack'\n ? vstackFollowBox(eng.rendered, progress01)\n : followBoxAt(eng.rendered, followWindowStart(eng.rendered, progress01));\n return notationLayout(eng.rendered, ctx.W, ctx.H, eng.bandTop, eng.bandHeight, { focusBox, boxWidth: eng.bandWidth });\n}\n\nfunction scrollCursorLayer(): Layer<ScrollCursorProps> {\n let scrollMode: 'hstack' | 'vstack' = 'hstack';\n let openingZoomMs = 900;\n let color: string | undefined;\n let barDurMs: number | undefined;\n let propNoteCols: number[] | undefined;\n\n /** Distinct note onsets for the score (sorted). [] when no score/notes. */\n function onsetsFor(ctx: RenderCtx): number[] {\n const notes = ctx.score?.notes;\n return notes && notes.length ? distinctOnsets(notes) : [];\n }\n\n /**\n * Follow progress 0..1 = the cursor's fractional position across the distinct\n * onsets at time `tMs`. The follow window therefore tracks the SAME note clock\n * the cursor does, so window + cursor are locked by construction (no musicMs to\n * drift against). Held at 0 during the opening-zoom lead-in. With no score /\n * onsets this returns 0 — the window HOLDS at the start (no fake linear sweep).\n */\n function notesProgress(onsetsMs: number[], tMs: number, totalMeasures: number, noteCols?: number[]): number {\n if (tMs < openingZoomMs) return 0;\n const n = onsetsMs.length;\n if (n <= 1) return 0;\n const first = onsetsMs[0];\n const last = onsetsMs[n - 1];\n if (tMs <= first) return 0;\n if (tMs >= last || last <= first) return 1;\n let lo = 0, hi = n - 1;\n while (lo < hi) {\n const mid = (lo + hi + 1) >> 1;\n if (onsetsMs[mid] <= tMs) lo = mid; else hi = mid - 1;\n }\n const segFrac = (tMs - onsetsMs[lo]) / (onsetsMs[lo + 1] - onsetsMs[lo]);\n // Window progress tracks the SAME positions the cursor uses, so the followed\n // bars stay under the playhead: engraved noteCols (best) → time-based\n // (onset/barDurMs) → legacy ordinal. All normalised by total measures.\n if (noteCols && noteCols.length === n && totalMeasures > 0) {\n const pos = noteCols[lo] + (noteCols[lo + 1] - noteCols[lo]) * segFrac;\n return Math.min(1, Math.max(0, pos / totalMeasures));\n }\n if (barDurMs && barDurMs > 0 && totalMeasures > 0) {\n const posLo = (onsetsMs[lo] - first) / barDurMs;\n const posHi = (onsetsMs[lo + 1] - first) / barDurMs;\n const pos = posLo + (posHi - posLo) * segFrac;\n return Math.min(1, Math.max(0, pos / totalMeasures));\n }\n return (lo + segFrac) / (n - 1);\n }\n\n /**\n * Per-onset cursor columns — the cursor MUST land on the real engraved notehead\n * of each onset (not a time-derived guess, which drifts ~½ bar ahead on dense\n * bars because OSMD spaces noteheads non-linearly).\n *\n * `propNoteCols` (app timing) gives the reliable per-onset structure: one entry\n * per onset, `ordinal + time-frac`, so its integer part is the correct measure.\n * `eng` (engraving) gives the real notehead X columns but its count can differ\n * from the onsets (ties/grace notes engrave extra columns). We therefore remap\n * each onset to a REAL engraved column, per measure, in time order:\n * - exact 1:1 overall → use the engraved columns directly;\n * - else, within each measure, map this measure's K onsets onto its E engraved\n * columns proportionally in order (k-th onset → k-th notehead), so every\n * onset still sits on an actual notehead even when E≠K.\n * Falls back to pure timing only when there is no engraving at all.\n */\n function noteColsFor(ctx: RenderCtx): number[] | undefined {\n const eng = getNotationEngraving(ctx)?.rendered?.noteCols;\n const onsets = onsetsFor(ctx);\n const n = onsets.length;\n if (!eng || !eng.length) return propNoteCols;\n if (eng.length === n) return eng; // clean 1:1 — real notehead X per onset\n if (!propNoteCols || propNoteCols.length !== n) return eng;\n\n const measureOf = (c: number) => Math.floor(c + 1e-6);\n const engByMeasure = new Map<number, number[]>();\n for (const e of eng) {\n const m = measureOf(e);\n const a = engByMeasure.get(m);\n if (a) a.push(e); else engByMeasure.set(m, [e]);\n }\n for (const a of engByMeasure.values()) a.sort((x, y) => x - y);\n\n const idxByMeasure = new Map<number, number[]>();\n propNoteCols.forEach((c, i) => {\n const m = measureOf(c);\n const a = idxByMeasure.get(m);\n if (a) a.push(i); else idxByMeasure.set(m, [i]);\n });\n\n const out = propNoteCols.slice();\n for (const [m, idxs] of idxByMeasure) {\n const e = engByMeasure.get(m);\n if (!e || !e.length) continue; // no engraving for this bar → keep timing\n const K = idxs.length;\n idxs.forEach((origIdx, i) => {\n const j = K > 1 ? Math.round((i * (e.length - 1)) / (K - 1)) : 0;\n out[origIdx] = e[Math.min(j, e.length - 1)];\n });\n }\n return out;\n }\n\n /** Follow progress 0..1 — ALWAYS the onset clock (no linear fallback). Held at\n * 0 (window at the start) when there is no score to lock to. */\n function followProgress(ctx: RenderCtx, tMs: number): number {\n const eng = getNotationEngraving(ctx);\n const total = eng ? measureCount(eng.rendered) : 0;\n return notesProgress(onsetsFor(ctx), tMs, total, noteColsFor(ctx));\n }\n\n /** The follow layout for a frame time — the provider notation calls so it\n * blits the SAME window the cursor sweeps. Pure fn of (ctx, tMs). */\n function layoutAt(ctx: RenderCtx, tMs: number): NotationLayout {\n const eng = getNotationEngraving(ctx);\n return followLayoutFor(ctx, followProgress(ctx, tMs), scrollMode) ?? eng!.base;\n }\n\n return {\n key: 'scroll-cursor',\n init(ctx, props) {\n scrollMode = props.scrollMode ?? 'hstack';\n openingZoomMs = props.openingZoomMs ?? 900;\n color = props.color;\n barDurMs = props.barDurMs;\n propNoteCols = Array.isArray(props.noteCols) ? props.noteCols : undefined;\n // `mode` / `musicMs` are accepted for back-compat and intentionally ignored:\n // the cursor is always onset-locked now (no linear sweep). See the header.\n // Publish the provider so notation blits the followed bars (works whichever\n // layer the runner draws first; the cursor LINE below is the only thing that\n // must come after notation, which it does when notation precedes us).\n setFollowLayoutProvider(ctx, layoutAt);\n },\n draw(ctx, tMs) {\n const eng = getNotationEngraving(ctx);\n if (!eng) return; // notation layer absent — nothing to follow\n const layout = layoutAt(ctx, tMs);\n\n // Playhead: ALWAYS onset-anchored off the audio clock (foolproof — cursor +\n // scroll share the onset clock, no full-width sweep). With NO score, the\n // onset list is empty and audioPlayheadLine returns null → the cursor is\n // hidden (held window, no fake sweep) instead of lying.\n const onsets = onsetsFor(ctx);\n let line: ReturnType<typeof audioPlayheadLine>;\n if (scrollMode === 'vstack') {\n // vstack: phase-locked to the follow camera so the cursor rides between\n // systems WITH the vertical scroll instead of leaping across staff lines.\n line = vstackAudioPlayheadLine(layout, onsets, tMs, measureCount(eng.rendered), noteColsFor(ctx));\n } else {\n line = audioPlayheadLine(layout, onsets, tMs, barDurMs, noteColsFor(ctx));\n }\n if (!line) return;\n const c = ctx.ctx2d;\n c.save();\n c.strokeStyle = color ?? ctx.theme.accent;\n c.globalAlpha = line.alpha;\n c.lineWidth = 4;\n c.beginPath();\n c.moveTo(line.x, line.y0);\n c.lineTo(line.x, line.y1);\n c.stroke();\n c.restore();\n },\n };\n}\n\nexport const scrollCursorFactory: LayerFactory<ScrollCursorProps> = {\n key: 'scroll-cursor',\n create: scrollCursorLayer,\n validateProps(props) {\n const errs: string[] = [];\n if (props == null || typeof props !== 'object') return ['scroll-cursor: props must be an object'];\n const p = props as Record<string, unknown>;\n if (p.mode != null && p.mode !== 'audio' && p.mode !== 'linear')\n errs.push('scroll-cursor.mode must be \"audio\" | \"linear\"');\n if (p.scrollMode != null && p.scrollMode !== 'hstack' && p.scrollMode !== 'vstack')\n errs.push('scroll-cursor.scrollMode must be \"hstack\" | \"vstack\"');\n // `mode` + `musicMs` are deprecated (the cursor is always onset-locked); they\n // are accepted for back-compat and ignored, so neither is ever required.\n if (p.musicMs != null && (typeof p.musicMs !== 'number' || !(p.musicMs > 0)))\n errs.push('scroll-cursor.musicMs must be a positive number');\n if (p.followBars != null && (typeof p.followBars !== 'number' || p.followBars < 1))\n errs.push('scroll-cursor.followBars must be a number >= 1');\n if (p.openingZoomMs != null && (typeof p.openingZoomMs !== 'number' || p.openingZoomMs < 0))\n errs.push('scroll-cursor.openingZoomMs must be a number >= 0');\n if (p.color != null && typeof p.color !== 'string') errs.push('scroll-cursor.color must be a string');\n if (p.barDurMs != null && (typeof p.barDurMs !== 'number' || !(p.barDurMs > 0)))\n errs.push('scroll-cursor.barDurMs must be a positive number');\n if (p.noteCols != null && (!Array.isArray(p.noteCols) || (p.noteCols as unknown[]).some((c) => typeof c !== 'number')))\n errs.push('scroll-cursor.noteCols must be a number[]');\n return errs;\n },\n};\n"],"mappings":"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","names":[]}
@@ -114,6 +114,41 @@ declare function notationLayout(rn: RenderedNotation, W: number, H: number, boxT
114
114
  /** Per-measure grand-staff column boxes (both staves unioned) from a frame's
115
115
  * mapped measures, in render order. (RSR measureColumns) */
116
116
  declare function measureColumnsFromLayout(measures: StaffMeasureBox[]): MeasureColumnBox[];
117
+ /**
118
+ * Pure hit-test: which measure (by its engraved index) contains — or is
119
+ * nearest to — point (mx, my) in a given followed layout. Reuses
120
+ * `measureColumnsFromLayout` (the SAME per-measure union boxes the playhead
121
+ * anchors to) for the boxes; only the index bookkeeping (matching each
122
+ * returned box back to its measure index, which `measureColumnsFromLayout`
123
+ * intentionally drops — the playhead has no use for it) is new here, and it
124
+ * is pure array/index bookkeeping, not geometry math. Exported standalone so
125
+ * it is unit-testable without a DOM/canvas.
126
+ *
127
+ * Lives here (not in notationPlayer.ts, its original home through web-core
128
+ * 0.38.0) so BOTH the canvas player (notationPlayer.ts, which re-exports this
129
+ * for backward compat — its public API is unchanged) and the SVG player
130
+ * (notationPlayerSvg.ts) can import it without either one's bundle pulling in
131
+ * the other's implementation. It has no canvas/raster-specific logic — it
132
+ * only ever reads `NotationLayout`, the same backend-agnostic shape both
133
+ * players produce — so this is its natural, shared home alongside
134
+ * `measureColumnsFromLayout`/`vstackAudioPlayheadLine`, not a canvas-only
135
+ * concept that happens to be reusable.
136
+ *
137
+ * COORDINATE SPACE — read this before calling directly: (mx, my) MUST be in
138
+ * the same space `layout.measures[].box` is already mapped into by whichever
139
+ * layout builder produced it — the canvas player's `notationLayout()` maps
140
+ * into DEST/DEVICE-PIXEL space (i.e. the player's own `<canvas>` device
141
+ * pixels, origin top-left, NOT CSS px — the same space its own
142
+ * `onCanvasClick` computes via `(clientX - rect.left) * (canvas.width /
143
+ * rect.width)`); the SVG player's `svgNotationLayout()` maps into real CSS px
144
+ * (there is no separate raster/device-px space for an SVG backend). It is NOT
145
+ * the canvas path's raster/src space (`RenderedNotation.canvas`, OSMD's own
146
+ * pre-map bitmap px) — passing src-space coordinates here is exactly the
147
+ * "classic scale gotcha" (`extractGeometry`'s `canvas.width / pageW` vs
148
+ * `/(contentRight+contentLeft)`, see promo.ts's module doc) this component
149
+ * exists to make impossible; don't reintroduce it at the call site.
150
+ */
151
+ declare function hitTestMeasureAt(layout: NotationLayout, mx: number, my: number): number | null;
117
152
  /** The playhead line + alpha for progress `t01`. null when fully faded. (RSR
118
153
  * drawPlayhead, with the actual stroke factored out into the layer.) */
119
154
  interface PlayheadLine {
@@ -143,4 +178,4 @@ declare function distinctOnsets(notes: {
143
178
  declare function audioPlayheadLine(layout: NotationLayout, onsetsMs: number[], tMs: number, barDurMs?: number, noteCols?: number[]): PlayheadLine | null;
144
179
  declare function vstackAudioPlayheadLine(layout: NotationLayout, onsetsMs: number[], tMs: number, nBars: number, noteCols?: number[]): PlayheadLine | null;
145
180
 
146
- export { FOLLOW_BARS as F, type NotationLayout as N, type PlayheadLine as P, FOLLOW_PAD as a, type NotationLayoutOpts as b, type NotationRect as c, audioPlayheadLine as d, cropAroundBox as e, cubicEaseInOut as f, distinctMeasureIndices as g, distinctOnsets as h, firstMeasureBox as i, followBoxAt as j, followWindowStart as k, lerpBox as l, measureColumnsFromLayout as m, measureCount as n, measureSpanBox as o, measureSystemMap as p, notationLayout as q, playheadLine as r, systemBox as s, vstackFollowBox as t, vstackAudioPlayheadLine as v };
181
+ export { FOLLOW_BARS as F, type NotationLayout as N, type PlayheadLine as P, FOLLOW_PAD as a, type NotationLayoutOpts as b, type NotationRect as c, audioPlayheadLine as d, cropAroundBox as e, cubicEaseInOut as f, distinctMeasureIndices as g, distinctOnsets as h, firstMeasureBox as i, followBoxAt as j, followWindowStart as k, hitTestMeasureAt as l, lerpBox as m, measureColumnsFromLayout as n, measureCount as o, measureSpanBox as p, measureSystemMap as q, notationLayout as r, playheadLine as s, systemBox as t, vstackFollowBox as u, vstackAudioPlayheadLine as v };
@@ -1,4 +1,5 @@
1
- import { N as NotationLayout } from './notationGeometry-CyYXJrUH.js';
1
+ import { N as NotationLayout } from './notationGeometry-54fFq5yU.js';
2
+ export { l as hitTestMeasureAt } from './notationGeometry-54fFq5yU.js';
2
3
  import { RenderedNotation } from './promo.js';
3
4
  import { PromoTheme } from './video.js';
4
5
 
@@ -24,12 +25,33 @@ import { PromoTheme } from './video.js';
24
25
  * exercised of the two modes for live players (most real players show
25
26
  * multi-line music, which needs vstack) — supported and correct today, but
26
27
  * treat it as the less battle-tested choice.
28
+ *
29
+ * NOTE for `display: 'scroll'` (0.37.0): scroll mode shows the WHOLE score
30
+ * at once with no camera crop, which is only meaningful for a page-wrapped
31
+ * ('vstack') engraving — 'hstack' engraves the whole piece onto ONE very
32
+ * wide row, so a scroll-mode flow layout for it degenerates to a single
33
+ * short, squeezed tile. Scroll mode does not forbid 'hstack' (the geometry
34
+ * is agnostic), but in practice pick 'vstack' for it, same as window mode.
27
35
  */
28
36
  type NotationPlayerMode = 'hstack' | 'vstack';
37
+ /**
38
+ * Which player shell to build. See the module doc's "DISPLAY MODES" section.
39
+ * Default `'window'` — every pre-0.37.0 caller (and promos/whozart, which never
40
+ * set this) keeps the exact camera-band behavior, byte-for-byte.
41
+ */
42
+ type NotationPlayerDisplay = 'window' | 'scroll';
29
43
  interface NotationPlayerTheme extends Partial<PromoTheme> {
30
44
  }
31
45
  interface CreateNotationPlayerOpts {
32
- /** Element the player's canvas is mounted into (fills it). */
46
+ /** Element the player's content is mounted into.
47
+ * - `display:'window'` (default): a single canvas that FILLS the host
48
+ * (`width:100%;height:100%`) — the host's own size is the frame.
49
+ * - `display:'scroll'`: an internal wrapper that fills the host
50
+ * HORIZONTALLY but is left to its NATURAL height (the whole score's
51
+ * height at the host's width) — the host must not force/clip a fixed
52
+ * height (no `overflow:hidden` + fixed height) or native page scroll
53
+ * can't reach the tiles below the fold. This is the one structural
54
+ * assumption scroll mode makes about its host; window mode has none. */
33
55
  host: HTMLElement;
34
56
  /** MusicXML to engrave. */
35
57
  musicXml: string;
@@ -60,6 +82,14 @@ interface CreateNotationPlayerOpts {
60
82
  * (noted as a follow-up, not built: nothing in the shared machinery makes a
61
83
  * live re-layout trivial). */
62
84
  mode?: NotationPlayerMode;
85
+ /**
86
+ * Which player shell to build — `'window'` (default, the original
87
+ * camera-band player) or `'scroll'` (0.37.0, a tiled full-score display the
88
+ * PAGE scrolls natively). See the module doc's "DISPLAY MODES" section and
89
+ * `NotationPlayerDisplay`. Fixed for the life of the instance, same
90
+ * reasoning as `mode`: create a new player if it must change.
91
+ */
92
+ display?: NotationPlayerDisplay;
63
93
  /** Duration of one measure in ms — enables time-accurate cursor/window
64
94
  * placement when `noteCols` isn't supplied (see `scroll-cursor`'s
65
95
  * `barDurMs`). */
@@ -68,16 +98,21 @@ interface CreateNotationPlayerOpts {
68
98
  * bars/drawFrom-drawUpTo) — engrave an excerpt rather than the whole score. */
69
99
  bars?: [number, number];
70
100
  /** Top of the notation band, screen px. Default 0 (fills the host — this is
71
- * a UI widget, not a phone-safe video frame). */
101
+ * a UI widget, not a phone-safe video frame). `display:'scroll'` IGNORES
102
+ * this — there is no fixed band to inset; the whole score is shown. */
72
103
  bandTop?: number;
73
- /** Height of the notation band, screen px. Default the full frame height. */
104
+ /** Height of the notation band, screen px. Default the full frame height.
105
+ * `display:'scroll'` IGNORES this for the same reason as `bandTop`. */
74
106
  bandHeight?: number;
75
107
  /** Theme tokens (colours/fonts) forwarded to the layers. Any field omitted
76
108
  * falls back to a neutral default. */
77
109
  theme?: NotationPlayerTheme;
78
110
  /** Explicit canvas size in device px. Default: host.clientWidth/clientHeight
79
111
  * × devicePixelRatio. Pass this in test/headless environments where the
80
- * host has no real layout (e.g. jsdom, where clientWidth is always 0). */
112
+ * host has no real layout (e.g. jsdom, where clientWidth is always 0).
113
+ * `display:'scroll'` only reads the WIDTH component (`size[0]`) — the
114
+ * display height is derived from the score's own content, so `size[1]` is
115
+ * ignored in that mode. */
81
116
  size?: [number, number];
82
117
  /**
83
118
  * Advanced / test seam: a pre-rasterized engraving, bypassing the browser
@@ -102,13 +137,18 @@ interface NotationPlayer {
102
137
  * playback position; everything else (follow camera, onset-locked
103
138
  * playhead) is a pure function of `tMs`, exactly as the promo/whozart path
104
139
  * drives it.
140
+ *
141
+ * `display:'scroll'`: also feeds the auto-follow discontinuity detector —
142
+ * see `CreateNotationPlayerOpts.display`'s doc for the exact re-arm rule.
105
143
  */
106
144
  setTime(tMs: number): void;
107
- /** Re-measure the host and resize the canvas to match (device-px aware).
145
+ /** Re-measure the host and resize/rebuild to match (device-px aware).
108
146
  * 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). */
147
+ * bitmap unless the host's CSS width crossed the engrave-width breakpoint
148
+ * (see `desiredEngraveWidth`), it does not otherwise re-run OSMD. Call on
149
+ * host resize / orientation change. Fire-and-forget (async internally; the
150
+ * next `setTime` reflects the new size once it lands, typically within a
151
+ * microtask). `display:'scroll'` rebuilds the whole tile stack. */
112
152
  resize(): void;
113
153
  /** Register a measure-click handler: fires with the clicked measure's
114
154
  * engraved index (matching `ScoreNote.measure` numbering) when a click
@@ -116,43 +156,101 @@ interface NotationPlayer {
116
156
  * may be registered (they all fire); returns an unsubscribe function for
117
157
  * that one handler. `destroy()` also clears every remaining listener. */
118
158
  onMeasureClick(cb: (measureIndex: number) => void): () => void;
119
- /** Tear down: removes the canvas from `host` and drops listeners/state. */
159
+ /** Tear down: removes the canvas/tiles from `host` and drops
160
+ * listeners/state (including the `display:'scroll'` page-scroll listener). */
120
161
  destroy(): void;
121
162
  }
163
+
164
+ /**
165
+ * Full-score "flow" layout for scroll mode: the ENTIRE engraved raster
166
+ * (`rn.content`, the same tight ink box `notationLayout` itself already falls
167
+ * back to) scaled to fill `dispWdev` device px of WIDTH, with height
168
+ * following naturally from the content's own aspect ratio — no camera crop,
169
+ * no bounded box (the opposite of the follow-window layout `notationLayout`
170
+ * computes for window mode via `{focusBox}`). Reuses `notationLayout` for ALL
171
+ * the actual scale/map/dx/dy math — no new geometry — via two calls:
172
+ *
173
+ * 1. a PROBE call with an arbitrarily large `boxH` (1e9 — real engraved
174
+ * music's content aspect ratio, height/width, is always many orders of
175
+ * magnitude below that, so this bound is never the true constraint; it
176
+ * exists ONLY so the width-bound branch is guaranteed to be taken,
177
+ * sidestepping a chicken-and-egg height guess) to read back the TRUE
178
+ * fitted height (`rect.dh`) `notationLayout` would compute for this
179
+ * width;
180
+ * 2. an EXACT call with `boxH` set to precisely that height, so the result
181
+ * is TOP-aligned (`rect.dy === 0`) rather than vertically centered
182
+ * inside an oversized probe box. Both calls share the identical `src` /
183
+ * `srcAspect` internally (same `rn`, `focusBox: null`, default
184
+ * `zoom01`), so the two `dh` values are bit-identical and the second
185
+ * call's `dh > boxH` branch is never taken (equal, not greater).
186
+ *
187
+ * Exported so scroll-mode's tile partition (`computeScrollTiles`) and the
188
+ * playhead's y-mapping can be unit-tested against it directly, without a DOM.
189
+ */
190
+ declare function flowLayout(rn: RenderedNotation, dispWdev: number): NotationLayout;
191
+ /** Target tile height, CSS px — ~2x a typical viewport, so a tile shows
192
+ * roughly "one screenful plus one" of context, and system-boundary carriage
193
+ * returns rarely straddle a tile seam. The ACTUAL tile height is this OR the
194
+ * area-cap-derived height (`SCROLL_TILE_MAX_AREA_PX`), whichever is
195
+ * SMALLER — so a high-dpr device automatically gets shorter (CSS-px) tiles
196
+ * rather than ever exceeding the backing-store area cap; see
197
+ * `computeScrollTiles`. */
198
+ declare const SCROLL_TILE_TARGET_CSS_PX = 1600;
199
+ /** Per-tile canvas backing-store area cap, device px². Comfortably under both
200
+ * iOS Safari's ~16.7M px² (4096×4096) canvas-backing-store limit AND
201
+ * `promo.ts`'s own `MAX_RASTER_AREA_PX` (12M — the cap for the SOURCE raster
202
+ * a tile reads FROM): a tile is a separate, smaller destination canvas than
203
+ * the source raster, so it gets its own, tighter cap; 8M leaves comfortable
204
+ * headroom under both limits at any realistic tile width. */
205
+ declare const SCROLL_TILE_MAX_AREA_PX = 8000000;
206
+ /** One vertical tile of the full-score raster, in the same device-px space
207
+ * `flowLayout` maps into. */
208
+ interface ScrollTileSpec {
209
+ /** Number of vertical tiles covering the full score. Always >= 1. */
210
+ count: number;
211
+ /** Per-tile height, device px. `heights.length === count`,
212
+ * `sum(heights) === totalHeightDev` (within float precision). */
213
+ heights: number[];
214
+ /** Per-tile top y-offset, device px, within the full-score raster
215
+ * (`flowLayout`'s coordinate space). `offsets.length === count`,
216
+ * `offsets[0] === 0`, `offsets[i+1] === offsets[i] + heights[i]`. */
217
+ offsets: number[];
218
+ /** Total display height, device px (== the flow layout's `rect.dh`). */
219
+ totalHeightDev: number;
220
+ }
122
221
  /**
123
- * Pure hit-test: which measure (by its engraved index) contains or is
124
- * nearest to point (mx, my) in a given followed layout. Reuses
125
- * `measureColumnsFromLayout` (the SAME per-measure union boxes the playhead
126
- * anchors to) for the boxes; only the index bookkeeping (matching each
127
- * returned box back to its measure index, which `measureColumnsFromLayout`
128
- * intentionally drops — the playhead has no use for it) is new here, and it
129
- * is pure array/index bookkeeping, not geometry math. Exported standalone so
130
- * it is unit-testable without a DOM/canvas.
222
+ * Pure tile-partition math (unit-tested, no DOM) — same shape/discipline as
223
+ * `clampRasterDpr` in `src/promo.ts`: given the full-score display width and
224
+ * height in device px (`dispWdev`/`totalHeightDev` `flowLayout`'s own
225
+ * `rect.dw`/`rect.dh`) and the active `dpr`, partition the height into N
226
+ * EQUAL-height tiles such that:
131
227
  *
132
- * COORDINATE SPACE read this before calling directly: (mx, my) MUST be in
133
- * the same DEST/DEVICE-PIXEL space `layout.measures[].box` is already mapped
134
- * into by `notationLayout()` (i.e. the player's own `<canvas>` device pixels,
135
- * origin top-left, NOT CSS px) the same space this module's own
136
- * `onCanvasClick` computes via `(clientX - rect.left) * (canvas.width /
137
- * rect.width)`. It is NOT the raster/src space (`RenderedNotation.canvas`,
138
- * OSMD's own pre-map bitmap px) passing src-space coordinates here is
139
- * exactly the "classic scale gotcha" (`extractGeometry`'s `canvas.width /
140
- * pageW` vs `/(contentRight+contentLeft)`, see the module doc) this component
141
- * exists to make impossible; don't reintroduce it at the call site.
228
+ * - each tile's target height is `SCROLL_TILE_TARGET_CSS_PX * dpr` device
229
+ * px (~2x viewport) UNLESS that would push a tile's own backing-store
230
+ * area (`dispWdev * tileHeightDev`) over `capPx2` in which case the
231
+ * tile height is derived FROM the area cap instead. This is COMPUTED
232
+ * from `dispWdev`/`dpr` every call, not assumed safe at a fixed CSS
233
+ * height a very wide host at a high dpr still gets a shorter tile, so
234
+ * the cap holds "at any DPR" as the design requires.
235
+ * - tiles split EVENLY (`totalHeightDev / count`), not
236
+ * max-height-tile-then-a-small-remainder so there's never an oddly
237
+ * short final tile, and every tile (including the last) is <= the area
238
+ * cap by construction (see the proof in the inline comment below).
239
+ * - a score shorter than one tile's max height gets exactly ONE tile (the
240
+ * degenerate/short-score case) — tiling only exists to keep any single
241
+ * canvas's backing-store area under the cap, which is already true for
242
+ * the whole score at that size, so a single tile is simplest.
142
243
  */
143
- declare function hitTestMeasureAt(layout: NotationLayout, mx: number, my: number): number | null;
244
+ declare function computeScrollTiles(dispWdev: number, totalHeightDev: number, dpr: number, opts?: {
245
+ targetTileCssPx?: number;
246
+ capPx2?: number;
247
+ }): ScrollTileSpec;
144
248
  /**
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).
249
+ * Build a live, interactive notation player. Dispatches on
250
+ * `opts.display` (default `'window'`) see the module doc's "DISPLAY MODES"
251
+ * section, `NotationPlayerDisplay`, `createWindowPlayer`, and
252
+ * `createScrollPlayer`.
155
253
  */
156
254
  declare function createNotationPlayer(opts: CreateNotationPlayerOpts): NotationPlayer;
157
255
 
158
- export { type CreateNotationPlayerOpts, type NotationPlayer, type NotationPlayerMode, type NotationPlayerTheme, createNotationPlayer, hitTestMeasureAt };
256
+ 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 };