@real-music-packages/web-core 0.37.0 → 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
 
@@ -159,32 +160,7 @@ interface NotationPlayer {
159
160
  * listeners/state (including the `display:'scroll'` page-scroll listener). */
160
161
  destroy(): void;
161
162
  }
162
- /**
163
- * Pure hit-test: which measure (by its engraved index) contains — or is
164
- * nearest to — point (mx, my) in a given followed layout. Reuses
165
- * `measureColumnsFromLayout` (the SAME per-measure union boxes the playhead
166
- * anchors to) for the boxes; only the index bookkeeping (matching each
167
- * returned box back to its measure index, which `measureColumnsFromLayout`
168
- * intentionally drops — the playhead has no use for it) is new here, and it
169
- * is pure array/index bookkeeping, not geometry math. Exported standalone so
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.
175
- *
176
- * COORDINATE SPACE — read this before calling directly: (mx, my) MUST be in
177
- * the same DEST/DEVICE-PIXEL space `layout.measures[].box` is already mapped
178
- * into by `notationLayout()` (i.e. the player's own `<canvas>` device pixels,
179
- * origin top-left, NOT CSS px) — the same space this module's own
180
- * `onCanvasClick` computes via `(clientX - rect.left) * (canvas.width /
181
- * rect.width)`. It is NOT the raster/src space (`RenderedNotation.canvas`,
182
- * OSMD's own pre-map bitmap px) — passing src-space coordinates here is
183
- * exactly the "classic scale gotcha" (`extractGeometry`'s `canvas.width /
184
- * pageW` vs `/(contentRight+contentLeft)`, see the module doc) this component
185
- * exists to make impossible; don't reintroduce it at the call site.
186
- */
187
- declare function hitTestMeasureAt(layout: NotationLayout, mx: number, my: number): number | null;
163
+
188
164
  /**
189
165
  * Full-score "flow" layout for scroll mode: the ENTIRE engraved raster
190
166
  * (`rn.content`, the same tight ink box `notationLayout` itself already falls
@@ -277,4 +253,4 @@ declare function computeScrollTiles(dispWdev: number, totalHeightDev: number, dp
277
253
  */
278
254
  declare function createNotationPlayer(opts: CreateNotationPlayerOpts): NotationPlayer;
279
255
 
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 };
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 };
@@ -1,14 +1,16 @@
1
+ import {
2
+ getNotationEngraving,
3
+ notationFactory,
4
+ scrollCursorFactory
5
+ } from "./chunk-QGKDKXX2.js";
1
6
  import {
2
7
  audioPlayheadLine,
3
8
  distinctOnsets,
4
- getNotationEngraving,
5
- measureColumnsFromLayout,
9
+ hitTestMeasureAt,
6
10
  measureCount,
7
- notationFactory,
8
11
  notationLayout,
9
- scrollCursorFactory,
10
12
  vstackAudioPlayheadLine
11
- } from "./chunk-4565POLG.js";
13
+ } from "./chunk-BHRDISMU.js";
12
14
  import {
13
15
  safeBox
14
16
  } from "./chunk-HXTRNE74.js";
@@ -44,25 +46,6 @@ function scoreFromOnsets(onsetsMs) {
44
46
  durationMs
45
47
  };
46
48
  }
47
- function hitTestMeasureAt(layout, mx, my) {
48
- if (!layout.measures.length) return null;
49
- const indices = [...new Set(layout.measures.map((m) => m.index))].sort((a, b) => a - b);
50
- const cols = measureColumnsFromLayout(layout.measures);
51
- let nearest = null;
52
- let nearestD = Infinity;
53
- for (let i = 0; i < cols.length; i++) {
54
- const b = cols[i];
55
- if (mx >= b.x && mx <= b.x + b.w && my >= b.y && my <= b.y + b.h) return indices[i];
56
- const cx = b.x + b.w / 2;
57
- const cy = b.y + b.h / 2;
58
- const d = (mx - cx) * (mx - cx) + (my - cy) * (my - cy);
59
- if (d < nearestD) {
60
- nearestD = d;
61
- nearest = indices[i];
62
- }
63
- }
64
- return nearest;
65
- }
66
49
  function resolveDpr() {
67
50
  return typeof window !== "undefined" && window.devicePixelRatio ? window.devicePixelRatio : 1;
68
51
  }