@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.
- package/dist/{chunk-4565POLG.js → chunk-BHRDISMU.js} +22 -263
- package/dist/chunk-BHRDISMU.js.map +1 -0
- package/dist/chunk-QGKDKXX2.js +278 -0
- package/dist/chunk-QGKDKXX2.js.map +1 -0
- package/dist/{notationGeometry-CyYXJrUH.d.ts → notationGeometry-54fFq5yU.d.ts} +36 -1
- package/dist/notationPlayer.d.ts +138 -40
- package/dist/notationPlayer.js +253 -36
- package/dist/notationPlayer.js.map +1 -1
- package/dist/notationPlayerSvg.d.ts +140 -0
- package/dist/notationPlayerSvg.js +316 -0
- package/dist/notationPlayerSvg.js.map +1 -0
- package/dist/scene/index.d.ts +2 -2
- package/dist/scene/index.js +8 -6
- package/dist/scene/index.js.map +1 -1
- package/package.json +5 -1
- package/dist/chunk-4565POLG.js.map +0 -1
|
@@ -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,
|
|
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 };
|
package/dist/notationPlayer.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { N as NotationLayout } from './notationGeometry-
|
|
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
|
|
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
|
|
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
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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
|
|
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
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
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
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
* `
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
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
|
|
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
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
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,
|
|
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 };
|