@real-music-packages/web-core 0.38.0 → 0.39.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,109 @@
1
+ import {
2
+ hitTestMeasureAt,
3
+ measureColumnsFromLayout
4
+ } from "./chunk-BHRDISMU.js";
5
+
6
+ // src/notationCommon.ts
7
+ function computeReflowScrollDelta(oldLayout, newLayout, anchorX, anchorY) {
8
+ const idx = hitTestMeasureAt(oldLayout, anchorX, anchorY);
9
+ if (idx == null) return 0;
10
+ const oldIndices = [...new Set(oldLayout.measures.map((m) => m.index))].sort((a, b) => a - b);
11
+ const newIndices = [...new Set(newLayout.measures.map((m) => m.index))].sort((a, b) => a - b);
12
+ const oi = oldIndices.indexOf(idx);
13
+ const ni = newIndices.indexOf(idx);
14
+ if (oi < 0 || ni < 0) return 0;
15
+ const oldCols = measureColumnsFromLayout(oldLayout.measures);
16
+ const newCols = measureColumnsFromLayout(newLayout.measures);
17
+ const oldBox = oldCols[oi];
18
+ const newBox = newCols[ni];
19
+ if (!oldBox || !newBox) return 0;
20
+ const fracY = oldBox.h > 0 ? (anchorY - oldBox.y) / oldBox.h : 0;
21
+ const newAnchorY = newBox.y + fracY * newBox.h;
22
+ return newAnchorY - anchorY;
23
+ }
24
+ var PROGRAMMATIC_SCROLL_EPSILON_PX = 2;
25
+ function isWithinProgrammaticScroll(y, start, target, epsilon = PROGRAMMATIC_SCROLL_EPSILON_PX) {
26
+ const lo = Math.min(start, target) - epsilon;
27
+ const hi = Math.max(start, target) + epsilon;
28
+ return y >= lo && y <= hi;
29
+ }
30
+ function hasReachedProgrammaticTarget(y, target, epsilon = PROGRAMMATIC_SCROLL_EPSILON_PX) {
31
+ return Math.abs(y - target) <= epsilon;
32
+ }
33
+ var SEEK_DISCONTINUITY_MS = 400;
34
+ function nowMs() {
35
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
36
+ }
37
+ function createFollowController() {
38
+ let autoFollowSuspended = false;
39
+ let programmaticStart = null;
40
+ let programmaticTarget = null;
41
+ let followRafPending = false;
42
+ let lastWallMs = null;
43
+ let lastMusicMs = 0;
44
+ let destroyed = false;
45
+ function onWindowScroll() {
46
+ if (programmaticTarget != null && programmaticStart != null) {
47
+ const y = window.scrollY;
48
+ if (isWithinProgrammaticScroll(y, programmaticStart, programmaticTarget)) {
49
+ if (hasReachedProgrammaticTarget(y, programmaticTarget)) {
50
+ programmaticStart = null;
51
+ programmaticTarget = null;
52
+ }
53
+ return;
54
+ }
55
+ }
56
+ autoFollowSuspended = true;
57
+ programmaticStart = null;
58
+ programmaticTarget = null;
59
+ }
60
+ const hasWindow = typeof window !== "undefined" && typeof window.addEventListener === "function";
61
+ if (hasWindow) window.addEventListener("scroll", onWindowScroll, { passive: true });
62
+ return {
63
+ onSetTime(tMs) {
64
+ const now = nowMs();
65
+ if (lastWallMs != null) {
66
+ const dtMusic = tMs - lastMusicMs;
67
+ const dtWall = now - lastWallMs;
68
+ if (dtMusic < 0 || Math.abs(dtMusic - dtWall) > SEEK_DISCONTINUITY_MS) autoFollowSuspended = false;
69
+ } else {
70
+ autoFollowSuspended = false;
71
+ }
72
+ lastWallMs = now;
73
+ lastMusicMs = tMs;
74
+ },
75
+ follow(getPlayheadRect) {
76
+ if (destroyed || autoFollowSuspended || followRafPending) return;
77
+ if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") return;
78
+ followRafPending = true;
79
+ window.requestAnimationFrame(() => {
80
+ followRafPending = false;
81
+ if (destroyed || autoFollowSuspended) return;
82
+ const rect = getPlayheadRect();
83
+ if (!rect) return;
84
+ const vh = window.innerHeight;
85
+ if (!(vh > 0)) return;
86
+ if (rect.top >= vh * 0.25 && rect.top <= vh * 0.75) return;
87
+ const start = window.scrollY;
88
+ const target = Math.max(0, start + rect.top - vh / 2);
89
+ programmaticStart = start;
90
+ programmaticTarget = target;
91
+ window.scrollTo({ top: target, left: window.scrollX, behavior: "smooth" });
92
+ });
93
+ },
94
+ destroy() {
95
+ if (destroyed) return;
96
+ destroyed = true;
97
+ if (hasWindow) window.removeEventListener("scroll", onWindowScroll);
98
+ }
99
+ };
100
+ }
101
+
102
+ export {
103
+ computeReflowScrollDelta,
104
+ PROGRAMMATIC_SCROLL_EPSILON_PX,
105
+ isWithinProgrammaticScroll,
106
+ hasReachedProgrammaticTarget,
107
+ createFollowController
108
+ };
109
+ //# sourceMappingURL=chunk-HZFDLJLA.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/notationCommon.ts"],"sourcesContent":["// Shared internal logic for the SVG-family notation players\n// (notationPlayerSvg.ts + notationPlayerVerovio.ts, 0.39.0). EXTRACTED\n// verbatim (behavior-preserving refactor, not a rewrite) out of\n// notationPlayerSvg.ts, which was the sole owner of this logic through\n// 0.38.0 — see docs/superpowers/specs/2026-08-11-verovio-player-design.md\n// (web-core) §2: \"Shared logic (follow discriminator, scroll preservation)\n// is EXTRACTED from notationPlayerSvg.ts into a common internal module both\n// import — no copy-paste drift between the two SVG-family players.\"\n//\n// notationPlayerSvg.ts re-imports (and re-exports, for backward-compat\n// import paths) the pure pieces below; ITS OWN TEST SUITE IS THE PARITY\n// GATE for this extraction — those tests were not touched, so any behavior\n// drift introduced here would fail them.\n//\n// NOT exported from the root barrel or any package.json `exports` subpath —\n// this is an internal implementation detail of the two SVG-family players,\n// not public API in its own right.\n//\n// Two tiers:\n// - PURE math: computeReflowScrollDelta (zoom/resize position\n// preservation) + the auto-follow discriminator predicates\n// (isWithinProgrammaticScroll / hasReachedProgrammaticTarget). Moved\n// with zero logic changes from notationPlayerSvg.ts.\n// - A small STATEFUL controller (createFollowController) wrapping the\n// imperative wiring (window scroll listener, rAF-scheduled\n// scroll-into-view, seek-discontinuity re-arm) that both players\n// previously duplicated as private closures. Same control flow, same\n// window/rAF calls, just packaged so it isn't re-typed per player.\n\nimport { hitTestMeasureAt, measureColumnsFromLayout, type NotationLayout } from './scene/notationGeometry';\n\n// ─── Zoom/resize position preservation (pure, unit-tested) ────────────────\n\n/**\n * Pure reflow position-preservation math: given the OLD layout (before a\n * `setZoom`/`resize` re-engrave), the NEW layout (after it), and an anchor\n * point `(anchorX, anchorY)` in the OLD layout's coordinate space (typically\n * \"whatever content sat at the vertical center of the viewport\" — see the\n * call site), return the vertical delta (new px minus old px) to add to the\n * window's scroll position so that SAME content stays in the SAME on-screen\n * position across the reflow.\n *\n * Deliberately NOT playhead interpolation: it's a one-shot \"where did this\n * content move to\" lookup built from two existing, reused primitives —\n * `hitTestMeasureAt` (which measure box contains/is nearest the anchor) and\n * `measureColumnsFromLayout` (that same measure's box in each layout) — plus\n * a linear fraction-within-the-box preservation so an anchor near the bottom\n * of a tall measure box stays near the bottom after reflow, not snapped to\n * the box's top edge. Returns 0 when the anchor measure can't be resolved in\n * either layout (e.g. an empty layout) — a safe no-op scroll.\n *\n * Works identically for either player's `NotationLayout` — it only reads the\n * backend-agnostic shape (measure index/box/noteStartX), never anything\n * OSMD- or Verovio-specific.\n */\nexport function computeReflowScrollDelta(\n oldLayout: NotationLayout,\n newLayout: NotationLayout,\n anchorX: number,\n anchorY: number,\n): number {\n const idx = hitTestMeasureAt(oldLayout, anchorX, anchorY);\n if (idx == null) return 0;\n\n const oldIndices = [...new Set(oldLayout.measures.map((m) => m.index))].sort((a, b) => a - b);\n const newIndices = [...new Set(newLayout.measures.map((m) => m.index))].sort((a, b) => a - b);\n const oi = oldIndices.indexOf(idx);\n const ni = newIndices.indexOf(idx);\n if (oi < 0 || ni < 0) return 0;\n\n const oldCols = measureColumnsFromLayout(oldLayout.measures);\n const newCols = measureColumnsFromLayout(newLayout.measures);\n const oldBox = oldCols[oi];\n const newBox = newCols[ni];\n if (!oldBox || !newBox) return 0;\n\n const fracY = oldBox.h > 0 ? (anchorY - oldBox.y) / oldBox.h : 0;\n const newAnchorY = newBox.y + fracY * newBox.h;\n return newAnchorY - anchorY;\n}\n\n// ─── Auto-follow discriminator (design doc §4, moved verbatim) ────────────\n//\n// The canvas scroll-mode player (notationPlayer.ts's `createScrollPlayer`)\n// discriminates \"our own programmatic scroll\" from \"a manual user scroll\" via\n// a fixed 600ms grace window after every `scrollIntoView` call. That has a\n// real failure mode (flagged in the canvas module's own review): a\n// longer-than-600ms smooth-scroll animation's LATER frames land after the\n// window closes and get misread as manual, suspending auto-follow mid-\n// animation; conversely a genuinely fast manual scroll DURING the window can\n// slip through unsuspended.\n//\n// Fix: compare against the EXACT target position this component itself\n// requested, not elapsed time. We control the scroll call ourselves\n// (`window.scrollTo({top, behavior:'smooth'})`, not `element.scrollIntoView`,\n// specifically so we know the target precisely) — every intermediate frame of\n// OUR OWN smooth-scroll animation reports a `window.scrollY` somewhere on the\n// straight line between where we started and where we're headed; it can never\n// overshoot or reverse past that. A scroll event reporting a position OUTSIDE\n// that span can therefore only be a manual scroll, and is classified as such\n// immediately, independent of animation duration.\n\n/** Tolerance, px, for \"close enough\" to count as the same position (float/\n * sub-pixel scroll settling noise). */\nexport const PROGRAMMATIC_SCROLL_EPSILON_PX = 2;\n\n/**\n * True while `y` lies within `[min(start,target), max(start,target)]`\n * (± `epsilon`) — i.e. anywhere along the straight-line path of a\n * `window.scrollTo({top: target, behavior: 'smooth'})` call issued from\n * `start`. A scroll event reporting a position outside this span cannot be a\n * frame of that animation.\n */\nexport function isWithinProgrammaticScroll(\n y: number,\n start: number,\n target: number,\n epsilon: number = PROGRAMMATIC_SCROLL_EPSILON_PX,\n): boolean {\n const lo = Math.min(start, target) - epsilon;\n const hi = Math.max(start, target) + epsilon;\n return y >= lo && y <= hi;\n}\n\n/** True once `y` has settled at (within `epsilon` of) the programmatic\n * scroll's target — the animation is complete, so tracking can be cleared. */\nexport function hasReachedProgrammaticTarget(\n y: number,\n target: number,\n epsilon: number = PROGRAMMATIC_SCROLL_EPSILON_PX,\n): boolean {\n return Math.abs(y - target) <= epsilon;\n}\n\n// ─── Seek-discontinuity re-arm (control-flow bookkeeping, not geometry) ───\n\n/** Same re-arm rule as the canvas scroll player's discontinuity detector —\n * control-flow bookkeeping, copied verbatim: if the audio clock jumps by\n * more than this relative to wall-clock elapsed time, treat it as a seek\n * (not natural playback drift) and un-suspend auto-follow. */\nexport const SEEK_DISCONTINUITY_MS = 400;\n\nfunction nowMs(): number {\n return typeof performance !== 'undefined' ? performance.now() : Date.now();\n}\n\n// ─── Stateful follow controller ────────────────────────────────────────────\n\nexport interface FollowController {\n /** Feed the current audio-clock time; detects seek/tempo discontinuities\n * (the clock jumping further than wall-clock time elapsed) and re-arms\n * auto-follow when one is seen, exactly as a manual seek should. Call\n * from every `setTime`. */\n onSetTime(tMs: number): void;\n /** Call after the playhead DOM has been positioned for this frame.\n * Schedules (at most once per animation frame) a check of whether the\n * playhead needs to be scrolled back into view; if so, issues ONE\n * `window.scrollTo({..., behavior:'smooth'})` call and records its exact\n * target so the resulting scroll events are recognized as our own (via\n * `isWithinProgrammaticScroll`) rather than a manual scroll. No-ops when\n * the playhead is already comfortably on screen, when auto-follow is\n * currently suspended (a manual scroll was detected), or outside a\n * browser (`window`/rAF unavailable). */\n follow(getPlayheadRect: () => DOMRect | null): void;\n /** Remove the window scroll listener. Idempotent. */\n destroy(): void;\n}\n\n/** Build a fresh auto-follow controller. Wires a `window` scroll listener\n * immediately (no-ops outside a browser/jsdom-with-window environment). */\nexport function createFollowController(): FollowController {\n let autoFollowSuspended = false;\n let programmaticStart: number | null = null;\n let programmaticTarget: number | null = null;\n let followRafPending = false;\n let lastWallMs: number | null = null;\n let lastMusicMs = 0;\n let destroyed = false;\n\n function onWindowScroll(): void {\n if (programmaticTarget != null && programmaticStart != null) {\n const y = window.scrollY;\n if (isWithinProgrammaticScroll(y, programmaticStart, programmaticTarget)) {\n if (hasReachedProgrammaticTarget(y, programmaticTarget)) {\n programmaticStart = null;\n programmaticTarget = null;\n }\n return; // still (or just finished) our own scroll — don't suspend\n }\n }\n autoFollowSuspended = true;\n programmaticStart = null;\n programmaticTarget = null;\n }\n\n const hasWindow = typeof window !== 'undefined' && typeof window.addEventListener === 'function';\n if (hasWindow) window.addEventListener('scroll', onWindowScroll, { passive: true });\n\n return {\n onSetTime(tMs: number): void {\n const now = nowMs();\n if (lastWallMs != null) {\n const dtMusic = tMs - lastMusicMs;\n const dtWall = now - lastWallMs;\n if (dtMusic < 0 || Math.abs(dtMusic - dtWall) > SEEK_DISCONTINUITY_MS) autoFollowSuspended = false;\n } else {\n autoFollowSuspended = false;\n }\n lastWallMs = now;\n lastMusicMs = tMs;\n },\n follow(getPlayheadRect: () => DOMRect | null): void {\n if (destroyed || autoFollowSuspended || followRafPending) return;\n if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') return;\n followRafPending = true;\n window.requestAnimationFrame(() => {\n followRafPending = false;\n if (destroyed || autoFollowSuspended) return;\n const rect = getPlayheadRect();\n if (!rect) return;\n const vh = window.innerHeight;\n if (!(vh > 0)) return;\n if (rect.top >= vh * 0.25 && rect.top <= vh * 0.75) return; // comfortably in view\n const start = window.scrollY;\n const target = Math.max(0, start + rect.top - vh / 2);\n programmaticStart = start;\n programmaticTarget = target;\n window.scrollTo({ top: target, left: window.scrollX, behavior: 'smooth' });\n });\n },\n destroy(): void {\n if (destroyed) return;\n destroyed = true;\n if (hasWindow) window.removeEventListener('scroll', onWindowScroll);\n },\n };\n}\n"],"mappings":"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","names":[]}
@@ -1,6 +1,44 @@
1
1
  import { N as NotationLayout } from './notationGeometry-54fFq5yU.js';
2
2
  import './promo.js';
3
3
 
4
+ /**
5
+ * Pure reflow position-preservation math: given the OLD layout (before a
6
+ * `setZoom`/`resize` re-engrave), the NEW layout (after it), and an anchor
7
+ * point `(anchorX, anchorY)` in the OLD layout's coordinate space (typically
8
+ * "whatever content sat at the vertical center of the viewport" — see the
9
+ * call site), return the vertical delta (new px minus old px) to add to the
10
+ * window's scroll position so that SAME content stays in the SAME on-screen
11
+ * position across the reflow.
12
+ *
13
+ * Deliberately NOT playhead interpolation: it's a one-shot "where did this
14
+ * content move to" lookup built from two existing, reused primitives —
15
+ * `hitTestMeasureAt` (which measure box contains/is nearest the anchor) and
16
+ * `measureColumnsFromLayout` (that same measure's box in each layout) — plus
17
+ * a linear fraction-within-the-box preservation so an anchor near the bottom
18
+ * of a tall measure box stays near the bottom after reflow, not snapped to
19
+ * the box's top edge. Returns 0 when the anchor measure can't be resolved in
20
+ * either layout (e.g. an empty layout) — a safe no-op scroll.
21
+ *
22
+ * Works identically for either player's `NotationLayout` — it only reads the
23
+ * backend-agnostic shape (measure index/box/noteStartX), never anything
24
+ * OSMD- or Verovio-specific.
25
+ */
26
+ declare function computeReflowScrollDelta(oldLayout: NotationLayout, newLayout: NotationLayout, anchorX: number, anchorY: number): number;
27
+ /** Tolerance, px, for "close enough" to count as the same position (float/
28
+ * sub-pixel scroll settling noise). */
29
+ declare const PROGRAMMATIC_SCROLL_EPSILON_PX = 2;
30
+ /**
31
+ * True while `y` lies within `[min(start,target), max(start,target)]`
32
+ * (± `epsilon`) — i.e. anywhere along the straight-line path of a
33
+ * `window.scrollTo({top: target, behavior: 'smooth'})` call issued from
34
+ * `start`. A scroll event reporting a position outside this span cannot be a
35
+ * frame of that animation.
36
+ */
37
+ declare function isWithinProgrammaticScroll(y: number, start: number, target: number, epsilon?: number): boolean;
38
+ /** True once `y` has settled at (within `epsilon` of) the programmatic
39
+ * scroll's target — the animation is complete, so tracking can be cleared. */
40
+ declare function hasReachedProgrammaticTarget(y: number, target: number, epsilon?: number): boolean;
41
+
4
42
  interface CreateSvgNotationPlayerOpts {
5
43
  /** Element the player's content is mounted into. Takes NATURAL content
6
44
  * height (the whole score, page-flow layout) — the host must not clip a
@@ -93,39 +131,6 @@ interface SvgNotationLayoutOpts {
93
131
  * `rect` for that consumer.
94
132
  */
95
133
  declare function svgNotationLayout(osmd: any, opts: SvgNotationLayoutOpts): NotationLayout;
96
- /**
97
- * Pure reflow position-preservation math: given the OLD layout (before a
98
- * `setZoom`/`resize` re-engrave), the NEW layout (after it), and an anchor
99
- * point `(anchorX, anchorY)` in the OLD layout's coordinate space (typically
100
- * "whatever content sat at the vertical center of the viewport" — see the
101
- * call site), return the vertical delta (new px minus old px) to add to the
102
- * window's scroll position so that SAME content stays in the SAME on-screen
103
- * position across the reflow.
104
- *
105
- * Deliberately NOT playhead interpolation: it's a one-shot "where did this
106
- * content move to" lookup built from two existing, reused primitives —
107
- * `hitTestMeasureAt` (which measure box contains/is nearest the anchor) and
108
- * `measureColumnsFromLayout` (that same measure's box in each layout) — plus
109
- * a linear fraction-within-the-box preservation so an anchor near the bottom
110
- * of a tall measure box stays near the bottom after reflow, not snapped to
111
- * the box's top edge. Returns 0 when the anchor measure can't be resolved in
112
- * either layout (e.g. an empty layout) — a safe no-op scroll.
113
- */
114
- declare function computeReflowScrollDelta(oldLayout: NotationLayout, newLayout: NotationLayout, anchorX: number, anchorY: number): number;
115
- /** Tolerance, px, for "close enough" to count as the same position (float/
116
- * sub-pixel scroll settling noise). */
117
- declare const PROGRAMMATIC_SCROLL_EPSILON_PX = 2;
118
- /**
119
- * True while `y` lies within `[min(start,target), max(start,target)]`
120
- * (± `epsilon`) — i.e. anywhere along the straight-line path of a
121
- * `window.scrollTo({top: target, behavior:'smooth'})` call issued from
122
- * `start`. A scroll event reporting a position outside this span cannot be a
123
- * frame of that animation.
124
- */
125
- declare function isWithinProgrammaticScroll(y: number, start: number, target: number, epsilon?: number): boolean;
126
- /** True once `y` has settled at (within `epsilon` of) the programmatic
127
- * scroll's target — the animation is complete, so tracking can be cleared. */
128
- declare function hasReachedProgrammaticTarget(y: number, target: number, epsilon?: number): boolean;
129
134
  /** Engrave-width ceiling, CSS px (design doc §"Render": "cap ~1200px, the
130
135
  * 0.36.2 rule" — the same reasoning as notationPlayer.ts's
131
136
  * `MAX_ENGRAVE_WIDTH`, restated here rather than imported since the two
@@ -1,7 +1,13 @@
1
+ import {
2
+ PROGRAMMATIC_SCROLL_EPSILON_PX,
3
+ computeReflowScrollDelta,
4
+ createFollowController,
5
+ hasReachedProgrammaticTarget,
6
+ isWithinProgrammaticScroll
7
+ } from "./chunk-HZFDLJLA.js";
1
8
  import {
2
9
  distinctOnsets,
3
10
  hitTestMeasureAt,
4
- measureColumnsFromLayout,
5
11
  vstackAudioPlayheadLine
6
12
  } from "./chunk-BHRDISMU.js";
7
13
  import "./chunk-HXTRNE74.js";
@@ -54,39 +60,9 @@ function svgNotationLayout(osmd, opts) {
54
60
  return EMPTY_LAYOUT;
55
61
  }
56
62
  }
57
- function computeReflowScrollDelta(oldLayout, newLayout, anchorX, anchorY) {
58
- const idx = hitTestMeasureAt(oldLayout, anchorX, anchorY);
59
- if (idx == null) return 0;
60
- const oldIndices = [...new Set(oldLayout.measures.map((m) => m.index))].sort((a, b) => a - b);
61
- const newIndices = [...new Set(newLayout.measures.map((m) => m.index))].sort((a, b) => a - b);
62
- const oi = oldIndices.indexOf(idx);
63
- const ni = newIndices.indexOf(idx);
64
- if (oi < 0 || ni < 0) return 0;
65
- const oldCols = measureColumnsFromLayout(oldLayout.measures);
66
- const newCols = measureColumnsFromLayout(newLayout.measures);
67
- const oldBox = oldCols[oi];
68
- const newBox = newCols[ni];
69
- if (!oldBox || !newBox) return 0;
70
- const fracY = oldBox.h > 0 ? (anchorY - oldBox.y) / oldBox.h : 0;
71
- const newAnchorY = newBox.y + fracY * newBox.h;
72
- return newAnchorY - anchorY;
73
- }
74
- var PROGRAMMATIC_SCROLL_EPSILON_PX = 2;
75
- function isWithinProgrammaticScroll(y, start, target, epsilon = PROGRAMMATIC_SCROLL_EPSILON_PX) {
76
- const lo = Math.min(start, target) - epsilon;
77
- const hi = Math.max(start, target) + epsilon;
78
- return y >= lo && y <= hi;
79
- }
80
- function hasReachedProgrammaticTarget(y, target, epsilon = PROGRAMMATIC_SCROLL_EPSILON_PX) {
81
- return Math.abs(y - target) <= epsilon;
82
- }
83
63
  var DEFAULT_PLAYHEAD_COLOR = "#2f6f4f";
84
64
  var MAX_ENGRAVE_WIDTH_SVG = 1200;
85
65
  var MIN_ENGRAVE_WIDTH_SVG = 280;
86
- var SEEK_DISCONTINUITY_MS = 400;
87
- function nowMs() {
88
- return typeof performance !== "undefined" ? performance.now() : Date.now();
89
- }
90
66
  function createSvgNotationPlayer(opts) {
91
67
  const { host, musicXml, onsetsMs, noteCols } = opts;
92
68
  const playheadColor = opts.playheadColor ?? DEFAULT_PLAYHEAD_COLOR;
@@ -133,65 +109,14 @@ function createSvgNotationPlayer(opts) {
133
109
  playheadEl.style.left = `${line.x}px`;
134
110
  playheadEl.style.top = `${line.y0}px`;
135
111
  playheadEl.style.height = `${Math.max(0, line.y1 - line.y0)}px`;
136
- maybeAutoFollow();
137
- }
138
- let autoFollowSuspended = false;
139
- let programmaticStart = null;
140
- let programmaticTarget = null;
141
- let followRafPending = false;
142
- let lastWallMs = null;
143
- let lastMusicMs = 0;
144
- function onWindowScroll() {
145
- if (programmaticTarget != null && programmaticStart != null) {
146
- const y = window.scrollY;
147
- if (isWithinProgrammaticScroll(y, programmaticStart, programmaticTarget)) {
148
- if (hasReachedProgrammaticTarget(y, programmaticTarget)) {
149
- programmaticStart = null;
150
- programmaticTarget = null;
151
- }
152
- return;
153
- }
154
- }
155
- autoFollowSuspended = true;
156
- programmaticStart = null;
157
- programmaticTarget = null;
158
- }
159
- const hasWindow = typeof window !== "undefined" && typeof window.addEventListener === "function";
160
- if (hasWindow) window.addEventListener("scroll", onWindowScroll, { passive: true });
161
- function detectDiscontinuity(tMs) {
162
- const now = nowMs();
163
- if (lastWallMs != null) {
164
- const dtMusic = tMs - lastMusicMs;
165
- const dtWall = now - lastWallMs;
166
- if (dtMusic < 0 || Math.abs(dtMusic - dtWall) > SEEK_DISCONTINUITY_MS) autoFollowSuspended = false;
167
- } else {
168
- autoFollowSuspended = false;
169
- }
170
- lastWallMs = now;
171
- lastMusicMs = tMs;
172
- }
173
- function maybeAutoFollow() {
174
- if (autoFollowSuspended || followRafPending) return;
175
- if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") return;
176
- followRafPending = true;
177
- window.requestAnimationFrame(() => {
178
- followRafPending = false;
179
- if (destroyed || autoFollowSuspended) return;
180
- if (typeof playheadEl.getBoundingClientRect !== "function") return;
181
- const rect = playheadEl.getBoundingClientRect();
182
- const vh = window.innerHeight;
183
- if (!(vh > 0)) return;
184
- if (rect.top >= vh * 0.25 && rect.top <= vh * 0.75) return;
185
- const start = window.scrollY;
186
- const target = Math.max(0, start + rect.top - vh / 2);
187
- programmaticStart = start;
188
- programmaticTarget = target;
189
- window.scrollTo({ top: target, left: window.scrollX, behavior: "smooth" });
190
- });
112
+ follow.follow(
113
+ () => typeof playheadEl.getBoundingClientRect === "function" ? playheadEl.getBoundingClientRect() : null
114
+ );
191
115
  }
116
+ const follow = createFollowController();
192
117
  function setTime(tMs) {
193
118
  if (destroyed) return;
194
- detectDiscontinuity(tMs);
119
+ follow.onSetTime(tMs);
195
120
  renderPlayhead(tMs);
196
121
  }
197
122
  async function initialEngrave() {
@@ -292,7 +217,7 @@ function createSvgNotationPlayer(opts) {
292
217
  destroyed = true;
293
218
  rebuildToken++;
294
219
  root.removeEventListener("click", onRootClick);
295
- if (hasWindow) window.removeEventListener("scroll", onWindowScroll);
220
+ follow.destroy();
296
221
  clickListeners.length = 0;
297
222
  try {
298
223
  osmd?.clear?.();
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/notationPlayerSvg.ts"],"sourcesContent":["// createSvgNotationPlayer — the SVG (vector) sibling of `createNotationPlayer`\n// (src/notationPlayer.ts). Same job (a live, caller-driven notation +\n// gliding-playhead widget), different medium: OSMD's native SVG backend\n// instead of a rasterized canvas. See\n// docs — stave-web-sightread's\n// docs/superpowers/specs/2026-08-11-svg-notation-player-design.md — for the\n// full design rationale (why: vectors don't blur on pinch/browser zoom, no\n// canvas-area cap, no raster tiling needed for full-score scroll).\n//\n// THE CANVAS MODULE IS NOT MODIFIED OR IMPORTED FOR ITS OSMD/RASTER PATH —\n// this is a parallel component. What IS reused, verbatim, no new math:\n// - `vstackAudioPlayheadLine` (scene/notationGeometry.ts) — the exact same\n// onset-anchored, carriage-return playhead interpolation the canvas path\n// uses. It operates purely on a `NotationLayout` (measure column boxes +\n// a vertical clamp band) — it does not care whether those boxes came from\n// a canvas raster or live SVG DOM geometry, so THIS module's job is only\n// to produce a `NotationLayout` in SVG/CSS px space (see\n// `svgNotationLayout` below) and everything downstream is identical.\n// - `hitTestMeasureAt` (scene/notationGeometry.ts, moved there in 0.38.0\n// specifically so this module never has a build-time edge into\n// notationPlayer.ts's canvas/raster implementation) — the exact same pure\n// point-in-measure-box hit-test, reused for click-to-seek.\n// - `distinctOnsets`, `measureColumnsFromLayout` — small pure helpers,\n// reused as-is.\n//\n// WHAT'S NEW (not a re-derivation of any of the above):\n// - `svgNotationLayout` — an SVG-backend geometry extractor, the SVG\n// counterpart to promo.ts's canvas-only `extractGeometry`. See its own\n// doc comment for the unit-conversion derivation.\n// - `computeReflowScrollDelta` — zoom/resize position-preservation math\n// (not playhead interpolation; a one-shot \"where did this same content\n// move to\" lookup using the existing hit-test + column helpers).\n// - `isWithinProgrammaticScroll` / `hasReachedProgrammaticTarget` — the\n// improved auto-follow discriminator (design doc §4): unlike the canvas\n// scroll-mode player's fixed 600ms \"programmatic scroll\" grace window,\n// this compares the ACTUAL scroll position against the EXACT target this\n// component itself requested, so classification is correct regardless of\n// how long a smooth-scroll animation takes (no grace-window race).\n//\n// IMPORT PATH: subpath-only — `@real-music-packages/web-core/notationPlayerSvg`\n// — not re-exported from the root barrel (same reasoning as notationPlayer.ts:\n// the root barrel is theory-only/zero-dependency).\n\nimport {\n vstackAudioPlayheadLine,\n measureColumnsFromLayout,\n distinctOnsets,\n hitTestMeasureAt,\n type NotationLayout,\n} from './scene/notationGeometry';\nimport type { Box, StaffMeasureBox } from './promo';\n\nexport interface CreateSvgNotationPlayerOpts {\n /** Element the player's content is mounted into. Takes NATURAL content\n * height (the whole score, page-flow layout) — the host must not clip a\n * fixed height; the PAGE scrolls the score, there is no internal camera. */\n host: HTMLElement;\n /** MusicXML to engrave. Ignored when `rendered` (the test seam) is set. */\n musicXml: string;\n /** Distinct note onsets (ms) the playhead locks to — same contract as\n * `CreateNotationPlayerOpts.onsetsMs` in notationPlayer.ts. */\n onsetsMs: number[];\n /** Per-onset engraved column positions, 1:1 with the DEDUPED/sorted\n * `onsetsMs` — same semantics as the canvas player's `noteCols`. */\n noteCols?: number[];\n /** Playhead line color. Default `'#2f6f4f'`. */\n playheadColor?: string;\n /** Initial OSMD zoom (a pure post-layout visual scale — see\n * `svgNotationLayout`'s doc). Default 1 (OSMD's own default — the\n * engraving fits the host's own width at normal note size). */\n zoom?: number;\n /**\n * Advanced / test seam: a pre-built `NotationLayout`, bypassing the real\n * OSMD SVG engrave (`musicXml` is still required by the type but is\n * ignored when this is set). Mirrors `CreateNotationPlayerOpts.rendered` in\n * notationPlayer.ts for the identical reason: real OSMD *rendering* needs\n * actual browser canvas glyph metrics (for its line-breaking pass) even\n * when the SVG backend is selected — headless/jsdom can't fully provide\n * that — so this is how this module stays unit-testable in Node. Not\n * needed in a real browser host. When set, `setZoom`/`resize` update the\n * tracked zoom/width but perform no real re-engrave (there is nothing to\n * re-engrave).\n */\n rendered?: NotationLayout;\n}\n\nexport interface SvgNotationPlayer {\n /** Resolves once the engraving is in the DOM and ready to draw. `setTime`/\n * click hit-testing are safe to call before this resolves (no-op until\n * ready, same contract as the canvas player). */\n readonly ready: Promise<void>;\n /** Drive the playhead for absolute playback time `tMs`. Caller owns the\n * audio clock + rAF loop. */\n setTime(tMs: number): void;\n /** Re-engrave at a new OSMD zoom (systems reflow). The scroll position is\n * restored afterward so the content that was centered in the viewport\n * before the reflow is still centered after it. */\n setZoom(z: number): Promise<void>;\n /** Re-measure the host and reflow to match its current width, with the\n * same scroll-position preservation as `setZoom`. Call on host resize /\n * orientation change. */\n resize(): Promise<void>;\n /** Register a measure-click handler (measure index, matching\n * `ScoreNote.measure`/the engraved index). Returns an unsubscribe fn. */\n onMeasureClick(cb: (measureIndex: number) => void): () => void;\n /** Tear down: removes the mounted DOM (engraving + playhead overlay) from\n * `host`, and drops every listener this instance added (click, window\n * scroll) and pending async work (a token guard drops any in-flight\n * reflow's effects). Idempotent. */\n destroy(): void;\n}\n\n// ─── svgNotationLayout — the SVG-backend geometry extractor ───────────────\n//\n// UNIT CONVERSION — derived, not guessed (verified against the vendored\n// opensheetmusicdisplay + vexflow source, not just its .d.ts comments, which\n// are stale here — see below):\n//\n// 1. `GraphicalMusicSheet` (`osmd.GraphicSheet`) positions\n// (`PositionAndShape.AbsolutePosition`/`.Size`) are in backend-agnostic\n// \"OSMD units\" — the SAME object model promo.ts's canvas-only\n// `extractGeometry` reads (`osmd.GraphicSheet.MusicPages[0].MusicSystems`,\n// `.MeasureList`). Whichever backend (canvas/svg) was requested, this\n// layer is identical.\n// 2. OSMD's Vexflow draw layer (`VexFlowMusicSheetDrawer`) converts an OSMD\n// unit value to a \"raw\" Vexflow/SVG px value via an EXPORTED constant,\n// `unitInPixels` (currently 10 — NOT `EngravingRules.unit`, which is a\n// different, unrelated field that is NOT the px-per-unit factor despite\n// what its .d.ts comment implies; confirmed by reading the actual\n// compiled source, not trusting the stale doc comment). This raw value\n// is written directly into each SVG element's own coordinate attributes\n// — it does NOT yet include `zoom`.\n// 3. `osmd.Zoom` (current zoom) is applied SEPARATELY, at the SVG-backend\n// level, via a `viewBox` trick (`vexflow/src/svgcontext.js`'s\n// `scale(x,y)`): the `<svg>` element's `width`/`height` ATTRIBUTES are\n// set to the FINAL (zoomed) CSS px size, while its `viewBox` spans\n// `width/zoom .. height/zoom` — i.e. the RAW (unzoomed, step-2) content\n// coordinates. The browser therefore maps that raw coordinate space onto\n// the final CSS px box by exactly `zoom`.\n// Net result: `cssPx = unitValue * unitInPixels * zoom`. Both factors are\n// read from the live library/instance (never a hardcoded literal `10` or an\n// assumed zoom) — see `SvgNotationLayoutOpts.unitInPixels`'s doc — so a\n// future OSMD version changing either is picked up automatically, which is\n// exactly the \"10 units/staff-space assumptions have version drift\" risk\n// the design doc calls out.\n//\n// This mirrors, in spirit, promo.ts's canvas `extractGeometry` self-\n// calibration (`canvas.width / (contentRight+contentLeft)`, chosen there\n// specifically because a naive formula broke on overflowed layouts) — the SVG\n// backend doesn't have that raster-overflow failure mode (there is no\n// backing-store to overflow; the viewBox IS the content, always), so the\n// derived formula above is exact, not an approximation.\n\nexport interface SvgNotationLayoutOpts {\n /** CSS px per OSMD unit at zoom 1 — OSMD's own exported `unitInPixels`\n * constant (see the derivation above). REQUIRED, no default: the point is\n * to FORCE the real call site to source this from the live\n * `opensheetmusicdisplay` import\n * (`const { unitInPixels } = await import('opensheetmusicdisplay')`)\n * rather than this module assuming a value that could drift across OSMD\n * versions. Tests pin it explicitly. */\n unitInPixels: number;\n}\n\nconst EMPTY_LAYOUT: NotationLayout = {\n src: { x: 0, y: 0, w: 0, h: 0 },\n rect: { dx: 0, dy: 0, dw: 0, dh: 0 },\n systems: [],\n measures: [],\n};\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n/**\n * Pure SVG-backend geometry extractor — builds the SAME `NotationLayout`\n * shape the canvas path's `notationLayout()` does (measure column boxes +\n * system rows + a `rect` vertical band), but read directly from OSMD's\n * `GraphicalMusicSheet` in SVG/CSS px space (see the unit-conversion doc\n * above) instead of a rasterized bitmap. Rebuild on every render (zoom /\n * resize / new score) — cheap, pure array/object mapping, no DOM reads.\n *\n * `osmd` is duck-typed `any` — same contract as promo.ts's\n * `extractGeometry(osmd: any, ...)` — so tests can pass a plain fixture\n * object shaped like the minimal slice of a real `OpenSheetMusicDisplay`\n * instance this function reads (`{ Zoom, GraphicSheet: { MusicPages,\n * MeasureList } }`) without needing a real browser OSMD render (see\n * `CreateSvgNotationPlayerOpts.rendered`'s doc for why headless can't do a\n * real one).\n *\n * `rect` spans the FULL engraved page (top-aligned, `dy = 0`) — there is no\n * follow-camera crop in this component (the whole score is always in the\n * DOM; the PAGE scrolls it) — matching the canvas scroll-mode's `flowLayout`\n * in spirit. `vstackAudioPlayheadLine` only reads `rect.dy`/`rect.dh` to\n * clamp the playhead into the drawn band, so this is a correct, minimal\n * `rect` for that consumer.\n */\nexport function svgNotationLayout(osmd: any, opts: SvgNotationLayoutOpts): NotationLayout {\n try {\n const zoom =\n typeof osmd?.Zoom === 'number' && osmd.Zoom > 0\n ? osmd.Zoom\n : typeof osmd?.zoom === 'number' && osmd.zoom > 0\n ? osmd.zoom\n : 1;\n const f = opts.unitInPixels * zoom;\n\n const graphic: any = osmd?.GraphicSheet;\n const page: any = graphic?.MusicPages?.[0];\n const pageSize = page?.PositionAndShape?.Size;\n const musicSystems: any[] = page?.MusicSystems ?? [];\n if (!(pageSize?.width > 0) || !(pageSize?.height > 0) || !musicSystems.length) return EMPTY_LAYOUT;\n\n const toBox = (pas: any): Box | null => {\n const p = pas?.AbsolutePosition;\n const sz = pas?.Size;\n if (!p || !sz) return null;\n return { x: p.x * f, y: p.y * f, w: sz.width * f, h: sz.height * f };\n };\n\n const systems: Box[] = musicSystems\n .map((s) => toBox(s?.PositionAndShape))\n .filter((b): b is Box => !!b && b.w > 1 && b.h > 1)\n .sort((a, b) => a.y - b.y);\n if (!systems.length) return EMPTY_LAYOUT;\n\n const measureList: any[][] = graphic?.MeasureList ?? [];\n const measures: StaffMeasureBox[] = [];\n measureList.forEach((staves, index) => {\n (staves ?? []).forEach((m: any, staff: number) => {\n const box = toBox(m?.PositionAndShape);\n if (box && box.w > 1 && box.h > 1) {\n const seX = (m?.staffEntries ?? [])[0]?.PositionAndShape?.AbsolutePosition?.x;\n const noteStartX = typeof seX === 'number' ? seX * f : box.x;\n measures.push({ index, staff, box, noteStartX });\n }\n });\n });\n\n const dw = pageSize.width * f;\n const dh = pageSize.height * f;\n return {\n src: { x: 0, y: 0, w: dw, h: dh },\n rect: { dx: 0, dy: 0, dw, dh },\n systems,\n measures,\n };\n } catch {\n return EMPTY_LAYOUT;\n }\n}\n/* eslint-enable @typescript-eslint/no-explicit-any */\n\n// ─── Zoom/resize position preservation (pure, unit-tested) ────────────────\n\n/**\n * Pure reflow position-preservation math: given the OLD layout (before a\n * `setZoom`/`resize` re-engrave), the NEW layout (after it), and an anchor\n * point `(anchorX, anchorY)` in the OLD layout's coordinate space (typically\n * \"whatever content sat at the vertical center of the viewport\" — see the\n * call site), return the vertical delta (new px minus old px) to add to the\n * window's scroll position so that SAME content stays in the SAME on-screen\n * position across the reflow.\n *\n * Deliberately NOT playhead interpolation: it's a one-shot \"where did this\n * content move to\" lookup built from two existing, reused primitives —\n * `hitTestMeasureAt` (which measure box contains/is nearest the anchor) and\n * `measureColumnsFromLayout` (that same measure's box in each layout) — plus\n * a linear fraction-within-the-box preservation so an anchor near the bottom\n * of a tall measure box stays near the bottom after reflow, not snapped to\n * the box's top edge. Returns 0 when the anchor measure can't be resolved in\n * either layout (e.g. an empty layout) — a safe no-op scroll.\n */\nexport function computeReflowScrollDelta(\n oldLayout: NotationLayout,\n newLayout: NotationLayout,\n anchorX: number,\n anchorY: number,\n): number {\n const idx = hitTestMeasureAt(oldLayout, anchorX, anchorY);\n if (idx == null) return 0;\n\n const oldIndices = [...new Set(oldLayout.measures.map((m) => m.index))].sort((a, b) => a - b);\n const newIndices = [...new Set(newLayout.measures.map((m) => m.index))].sort((a, b) => a - b);\n const oi = oldIndices.indexOf(idx);\n const ni = newIndices.indexOf(idx);\n if (oi < 0 || ni < 0) return 0;\n\n const oldCols = measureColumnsFromLayout(oldLayout.measures);\n const newCols = measureColumnsFromLayout(newLayout.measures);\n const oldBox = oldCols[oi];\n const newBox = newCols[ni];\n if (!oldBox || !newBox) return 0;\n\n const fracY = oldBox.h > 0 ? (anchorY - oldBox.y) / oldBox.h : 0;\n const newAnchorY = newBox.y + fracY * newBox.h;\n return newAnchorY - anchorY;\n}\n\n// ─── Auto-follow discriminator (design doc §4) ─────────────────────────────\n//\n// The canvas scroll-mode player (notationPlayer.ts's `createScrollPlayer`)\n// discriminates \"our own programmatic scroll\" from \"a manual user scroll\" via\n// a fixed 600ms grace window after every `scrollIntoView` call. That has a\n// real failure mode (flagged in the canvas module's own review): a\n// longer-than-600ms smooth-scroll animation's LATER frames land after the\n// window closes and get misread as manual, suspending auto-follow mid-\n// animation; conversely a genuinely fast manual scroll DURING the window can\n// slip through unsuspended.\n//\n// Fix: compare against the EXACT target position this component itself\n// requested, not elapsed time. We control the scroll call ourselves\n// (`window.scrollTo({top, behavior:'smooth'})`, not `element.scrollIntoView`,\n// specifically so we know the target precisely) — every intermediate frame of\n// OUR OWN smooth-scroll animation reports a `window.scrollY` somewhere on the\n// straight line between where we started and where we're headed; it can never\n// overshoot or reverse past that. A scroll event reporting a position OUTSIDE\n// that span can therefore only be a manual scroll, and is classified as such\n// immediately, independent of animation duration.\n\n/** Tolerance, px, for \"close enough\" to count as the same position (float/\n * sub-pixel scroll settling noise). */\nexport const PROGRAMMATIC_SCROLL_EPSILON_PX = 2;\n\n/**\n * True while `y` lies within `[min(start,target), max(start,target)]`\n * (± `epsilon`) — i.e. anywhere along the straight-line path of a\n * `window.scrollTo({top: target, behavior:'smooth'})` call issued from\n * `start`. A scroll event reporting a position outside this span cannot be a\n * frame of that animation.\n */\nexport function isWithinProgrammaticScroll(\n y: number,\n start: number,\n target: number,\n epsilon: number = PROGRAMMATIC_SCROLL_EPSILON_PX,\n): boolean {\n const lo = Math.min(start, target) - epsilon;\n const hi = Math.max(start, target) + epsilon;\n return y >= lo && y <= hi;\n}\n\n/** True once `y` has settled at (within `epsilon` of) the programmatic\n * scroll's target — the animation is complete, so tracking can be cleared. */\nexport function hasReachedProgrammaticTarget(\n y: number,\n target: number,\n epsilon: number = PROGRAMMATIC_SCROLL_EPSILON_PX,\n): boolean {\n return Math.abs(y - target) <= epsilon;\n}\n\n// ─── createSvgNotationPlayer ────────────────────────────────────────────────\n\nconst DEFAULT_PLAYHEAD_COLOR = '#2f6f4f';\n/** Engrave-width ceiling, CSS px (design doc §\"Render\": \"cap ~1200px, the\n * 0.36.2 rule\" — the same reasoning as notationPlayer.ts's\n * `MAX_ENGRAVE_WIDTH`, restated here rather than imported since the two\n * players' constants are independently tunable, and this one is spec'd to a\n * slightly different value). */\nexport const MAX_ENGRAVE_WIDTH_SVG = 1200;\n/** Floor so a not-yet-laid-out / zero-width host never engraves at 0px. */\nconst MIN_ENGRAVE_WIDTH_SVG = 280;\n/** Same re-arm rule as the canvas scroll player's discontinuity detector —\n * control-flow bookkeeping (not playhead/geometry math), copied verbatim. */\nconst SEEK_DISCONTINUITY_MS = 400;\n\nfunction nowMs(): number {\n return typeof performance !== 'undefined' ? performance.now() : Date.now();\n}\n\n/** Build a live, interactive SVG (vector) notation player. See the module doc\n * + `docs/superpowers/specs/2026-08-11-svg-notation-player-design.md` (in\n * stave-web-sightread) for the full design. */\nexport function createSvgNotationPlayer(opts: CreateSvgNotationPlayerOpts): SvgNotationPlayer {\n const { host, musicXml, onsetsMs, noteCols } = opts;\n const playheadColor = opts.playheadColor ?? DEFAULT_PLAYHEAD_COLOR;\n const onsets = distinctOnsets(onsetsMs.map((onsetMs) => ({ onsetMs })));\n\n const root = document.createElement('div');\n root.style.position = 'relative';\n root.style.width = '100%';\n // Let the browser's native pinch-zoom AND vertical page-scroll gestures\n // through — the \"optical zoom is free\" half of the design (§5): vectors\n // stay crisp at any pinch/browser-zoom level, and this component adds\n // nothing to make that work beyond not blocking the gesture.\n root.style.touchAction = 'pan-y pinch-zoom';\n host.appendChild(root);\n\n const svgHost = document.createElement('div');\n root.appendChild(svgHost);\n\n const playheadEl = document.createElement('div');\n playheadEl.style.position = 'absolute';\n playheadEl.style.left = '0px';\n playheadEl.style.top = '0px';\n playheadEl.style.width = '2px';\n playheadEl.style.height = '0px';\n playheadEl.style.background = playheadColor;\n playheadEl.style.opacity = '0';\n playheadEl.style.pointerEvents = 'none';\n root.appendChild(playheadEl);\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let osmd: any = null;\n let currentLayout: NotationLayout | null = null;\n let currentZoom = opts.zoom ?? 1;\n let unitInPixelsConst = 10; // overwritten by the real import before first use (rendered-seam path never reads it)\n let lastEngravedWidthPx = 0;\n let destroyed = false;\n let lastTMs = 0;\n // Stale-table guard (design doc §\"Known risks\" — \"Overlay drift on\n // reflow\"): each async re-engrave captures its own token; if a NEWER\n // reflow starts before an older one's `await` resolves, the older one's\n // completion is a no-op instead of clobbering the newer geometry.\n let rebuildToken = 0;\n\n function desiredEngraveWidthPx(): number {\n const w = host.clientWidth || 0;\n return Math.max(MIN_ENGRAVE_WIDTH_SVG, Math.min(w || MAX_ENGRAVE_WIDTH_SVG, MAX_ENGRAVE_WIDTH_SVG));\n }\n\n // ─── Playhead ──────────────────────────────────────────────────────────\n\n function renderPlayhead(tMs: number): void {\n lastTMs = tMs;\n if (!currentLayout) return;\n const nBars = currentLayout.measures.length\n ? Math.max(...currentLayout.measures.map((m) => m.index)) + 1\n : 0;\n const line = vstackAudioPlayheadLine(currentLayout, onsets, tMs, nBars, noteCols);\n if (!line) {\n playheadEl.style.opacity = '0';\n return;\n }\n playheadEl.style.opacity = String(line.alpha);\n playheadEl.style.left = `${line.x}px`;\n playheadEl.style.top = `${line.y0}px`;\n playheadEl.style.height = `${Math.max(0, line.y1 - line.y0)}px`;\n maybeAutoFollow();\n }\n\n // ─── Auto-follow (target-position discriminator — see the module doc) ────\n\n let autoFollowSuspended = false;\n let programmaticStart: number | null = null;\n let programmaticTarget: number | null = null;\n let followRafPending = false;\n let lastWallMs: number | null = null;\n let lastMusicMs = 0;\n\n function onWindowScroll(): void {\n if (programmaticTarget != null && programmaticStart != null) {\n const y = window.scrollY;\n if (isWithinProgrammaticScroll(y, programmaticStart, programmaticTarget)) {\n if (hasReachedProgrammaticTarget(y, programmaticTarget)) {\n programmaticStart = null;\n programmaticTarget = null;\n }\n return; // still (or just finished) our own scroll — don't suspend\n }\n }\n autoFollowSuspended = true;\n programmaticStart = null;\n programmaticTarget = null;\n }\n const hasWindow = typeof window !== 'undefined' && typeof window.addEventListener === 'function';\n if (hasWindow) window.addEventListener('scroll', onWindowScroll, { passive: true });\n\n function detectDiscontinuity(tMs: number): void {\n const now = nowMs();\n if (lastWallMs != null) {\n const dtMusic = tMs - lastMusicMs;\n const dtWall = now - lastWallMs;\n if (dtMusic < 0 || Math.abs(dtMusic - dtWall) > SEEK_DISCONTINUITY_MS) autoFollowSuspended = false;\n } else {\n autoFollowSuspended = false;\n }\n lastWallMs = now;\n lastMusicMs = tMs;\n }\n\n function maybeAutoFollow(): void {\n if (autoFollowSuspended || followRafPending) return;\n if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') return;\n followRafPending = true;\n window.requestAnimationFrame(() => {\n followRafPending = false;\n if (destroyed || autoFollowSuspended) return;\n if (typeof playheadEl.getBoundingClientRect !== 'function') return;\n const rect = playheadEl.getBoundingClientRect();\n const vh = window.innerHeight;\n if (!(vh > 0)) return;\n if (rect.top >= vh * 0.25 && rect.top <= vh * 0.75) return; // comfortably in view\n const start = window.scrollY;\n const target = Math.max(0, start + rect.top - vh / 2);\n programmaticStart = start;\n programmaticTarget = target;\n window.scrollTo({ top: target, left: window.scrollX, behavior: 'smooth' });\n });\n }\n\n // ─── setTime ───────────────────────────────────────────────────────────\n\n function setTime(tMs: number): void {\n if (destroyed) return;\n detectDiscontinuity(tMs);\n renderPlayhead(tMs);\n }\n\n // ─── Engrave / reflow ──────────────────────────────────────────────────\n\n async function initialEngrave(): Promise<void> {\n if (opts.rendered) {\n currentLayout = opts.rendered;\n lastEngravedWidthPx = desiredEngraveWidthPx();\n renderPlayhead(lastTMs);\n return;\n }\n // Literal dynamic import — consumers' bundlers must statically see the\n // specifier (same reasoning as promo.ts/notationPlayer.ts); a caller\n // that only ever uses the `rendered` test seam never pulls OSMD in.\n const { OpenSheetMusicDisplay, unitInPixels } = await import('opensheetmusicdisplay');\n unitInPixelsConst = unitInPixels;\n if (destroyed) return;\n\n const widthPx = desiredEngraveWidthPx();\n svgHost.style.width = `${widthPx}px`;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const inst: any = new OpenSheetMusicDisplay(svgHost, {\n backend: 'svg',\n autoResize: false,\n drawTitle: false,\n drawSubtitle: false,\n drawComposer: false,\n drawLyricist: false,\n drawPartNames: false,\n });\n await inst.load(musicXml);\n if (destroyed) return;\n inst.Zoom = currentZoom;\n inst.render();\n if (destroyed) return;\n\n osmd = inst;\n lastEngravedWidthPx = widthPx;\n currentLayout = svgNotationLayout(inst, { unitInPixels: unitInPixelsConst });\n renderPlayhead(lastTMs);\n }\n\n const ready = initialEngrave();\n\n /** Re-engrave at `newZoom` and the host's CURRENT width, preserving the\n * scroll position of whatever content is centered in the viewport right\n * now. Shared by both `setZoom` and `resize` (resize just passes the\n * unchanged `currentZoom`). No-op when neither the width nor the zoom\n * actually changed, or when using the `rendered` test seam (nothing to\n * re-engrave). */\n async function reflow(newZoom: number): Promise<void> {\n if (destroyed) return;\n const widthPx = desiredEngraveWidthPx();\n if (widthPx === lastEngravedWidthPx && newZoom === currentZoom) return;\n if (opts.rendered || !osmd) {\n currentZoom = newZoom;\n return;\n }\n\n const myToken = ++rebuildToken;\n const oldLayout = currentLayout;\n const hasWin = typeof window !== 'undefined';\n let anchorY: number | null = null;\n const anchorX = widthPx / 2;\n if (hasWin && typeof root.getBoundingClientRect === 'function') {\n const r = root.getBoundingClientRect();\n anchorY = window.innerHeight / 2 - r.top;\n }\n\n svgHost.style.width = `${widthPx}px`;\n osmd.Zoom = newZoom;\n // Re-run layout (not just a redraw): a width change must re-flow which\n // measures land on which system, and — since we can't be certain a pure\n // zoom change never affects line-breaking on every OSMD version — this is\n // called unconditionally rather than gated to the width-only case.\n osmd.updateGraphic();\n osmd.render();\n if (destroyed || myToken !== rebuildToken) return;\n\n lastEngravedWidthPx = widthPx;\n currentZoom = newZoom;\n currentLayout = svgNotationLayout(osmd, { unitInPixels: unitInPixelsConst });\n\n if (oldLayout && anchorY != null && hasWin) {\n const delta = computeReflowScrollDelta(oldLayout, currentLayout, anchorX, anchorY);\n if (Number.isFinite(delta) && Math.abs(delta) > 0.5) {\n window.scrollTo({ top: Math.max(0, window.scrollY + delta), left: window.scrollX, behavior: 'auto' });\n }\n }\n if (!destroyed) renderPlayhead(lastTMs);\n }\n\n // ─── Click-to-seek ─────────────────────────────────────────────────────\n\n const clickListeners: Array<(measureIndex: number) => void> = [];\n function onRootClick(e: MouseEvent): void {\n if (!currentLayout) return;\n const rect = root.getBoundingClientRect();\n const mx = e.clientX - rect.left;\n const my = e.clientY - rect.top;\n const idx = hitTestMeasureAt(currentLayout, mx, my);\n if (idx != null) for (const cb of clickListeners) cb(idx);\n }\n root.addEventListener('click', onRootClick);\n\n return {\n ready,\n setTime,\n async setZoom(z: number): Promise<void> {\n await ready;\n await reflow(z);\n },\n async resize(): Promise<void> {\n await ready;\n await reflow(currentZoom);\n },\n onMeasureClick(cb: (measureIndex: number) => void): () => void {\n clickListeners.push(cb);\n return () => {\n const i = clickListeners.indexOf(cb);\n if (i >= 0) clickListeners.splice(i, 1);\n };\n },\n destroy(): void {\n if (destroyed) return;\n destroyed = true;\n rebuildToken++;\n root.removeEventListener('click', onRootClick);\n if (hasWindow) window.removeEventListener('scroll', onWindowScroll);\n clickListeners.length = 0;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n try { (osmd as any)?.clear?.(); } catch { /* best-effort */ }\n osmd = null;\n currentLayout = null;\n if (root.parentNode === host) host.removeChild(root);\n },\n };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/notationPlayerSvg.ts"],"sourcesContent":["// createSvgNotationPlayer — the SVG (vector) sibling of `createNotationPlayer`\n// (src/notationPlayer.ts). Same job (a live, caller-driven notation +\n// gliding-playhead widget), different medium: OSMD's native SVG backend\n// instead of a rasterized canvas. See\n// docs — stave-web-sightread's\n// docs/superpowers/specs/2026-08-11-svg-notation-player-design.md — for the\n// full design rationale (why: vectors don't blur on pinch/browser zoom, no\n// canvas-area cap, no raster tiling needed for full-score scroll).\n//\n// THE CANVAS MODULE IS NOT MODIFIED OR IMPORTED FOR ITS OSMD/RASTER PATH —\n// this is a parallel component. What IS reused, verbatim, no new math:\n// - `vstackAudioPlayheadLine` (scene/notationGeometry.ts) — the exact same\n// onset-anchored, carriage-return playhead interpolation the canvas path\n// uses. It operates purely on a `NotationLayout` (measure column boxes +\n// a vertical clamp band) — it does not care whether those boxes came from\n// a canvas raster or live SVG DOM geometry, so THIS module's job is only\n// to produce a `NotationLayout` in SVG/CSS px space (see\n// `svgNotationLayout` below) and everything downstream is identical.\n// - `hitTestMeasureAt` (scene/notationGeometry.ts, moved there in 0.38.0\n// specifically so this module never has a build-time edge into\n// notationPlayer.ts's canvas/raster implementation) — the exact same pure\n// point-in-measure-box hit-test, reused for click-to-seek.\n// - `distinctOnsets`, `measureColumnsFromLayout` — small pure helpers,\n// reused as-is.\n//\n// WHAT'S NEW (not a re-derivation of any of the above):\n// - `svgNotationLayout` — an SVG-backend geometry extractor, the SVG\n// counterpart to promo.ts's canvas-only `extractGeometry`. See its own\n// doc comment for the unit-conversion derivation.\n// - `computeReflowScrollDelta` — zoom/resize position-preservation math\n// (not playhead interpolation; a one-shot \"where did this same content\n// move to\" lookup using the existing hit-test + column helpers).\n// - `isWithinProgrammaticScroll` / `hasReachedProgrammaticTarget` — the\n// improved auto-follow discriminator (design doc §4): unlike the canvas\n// scroll-mode player's fixed 600ms \"programmatic scroll\" grace window,\n// this compares the ACTUAL scroll position against the EXACT target this\n// component itself requested, so classification is correct regardless of\n// how long a smooth-scroll animation takes (no grace-window race).\n//\n// IMPORT PATH: subpath-only — `@real-music-packages/web-core/notationPlayerSvg`\n// — not re-exported from the root barrel (same reasoning as notationPlayer.ts:\n// the root barrel is theory-only/zero-dependency).\n\nimport {\n vstackAudioPlayheadLine,\n distinctOnsets,\n hitTestMeasureAt,\n type NotationLayout,\n} from './scene/notationGeometry';\nimport type { Box, StaffMeasureBox } from './promo';\nimport {\n computeReflowScrollDelta,\n createFollowController,\n isWithinProgrammaticScroll,\n hasReachedProgrammaticTarget,\n PROGRAMMATIC_SCROLL_EPSILON_PX,\n} from './notationCommon';\n\n// Re-exported for backward compatibility — this module's own tests (and any\n// external importer) still pull these from `notationPlayerSvg`; the\n// implementations now live in `notationCommon.ts` (shared with\n// notationPlayerVerovio.ts, 0.39.0). See that module's doc for why.\nexport { computeReflowScrollDelta, isWithinProgrammaticScroll, hasReachedProgrammaticTarget, PROGRAMMATIC_SCROLL_EPSILON_PX };\n\nexport interface CreateSvgNotationPlayerOpts {\n /** Element the player's content is mounted into. Takes NATURAL content\n * height (the whole score, page-flow layout) — the host must not clip a\n * fixed height; the PAGE scrolls the score, there is no internal camera. */\n host: HTMLElement;\n /** MusicXML to engrave. Ignored when `rendered` (the test seam) is set. */\n musicXml: string;\n /** Distinct note onsets (ms) the playhead locks to — same contract as\n * `CreateNotationPlayerOpts.onsetsMs` in notationPlayer.ts. */\n onsetsMs: number[];\n /** Per-onset engraved column positions, 1:1 with the DEDUPED/sorted\n * `onsetsMs` — same semantics as the canvas player's `noteCols`. */\n noteCols?: number[];\n /** Playhead line color. Default `'#2f6f4f'`. */\n playheadColor?: string;\n /** Initial OSMD zoom (a pure post-layout visual scale — see\n * `svgNotationLayout`'s doc). Default 1 (OSMD's own default — the\n * engraving fits the host's own width at normal note size). */\n zoom?: number;\n /**\n * Advanced / test seam: a pre-built `NotationLayout`, bypassing the real\n * OSMD SVG engrave (`musicXml` is still required by the type but is\n * ignored when this is set). Mirrors `CreateNotationPlayerOpts.rendered` in\n * notationPlayer.ts for the identical reason: real OSMD *rendering* needs\n * actual browser canvas glyph metrics (for its line-breaking pass) even\n * when the SVG backend is selected — headless/jsdom can't fully provide\n * that — so this is how this module stays unit-testable in Node. Not\n * needed in a real browser host. When set, `setZoom`/`resize` update the\n * tracked zoom/width but perform no real re-engrave (there is nothing to\n * re-engrave).\n */\n rendered?: NotationLayout;\n}\n\nexport interface SvgNotationPlayer {\n /** Resolves once the engraving is in the DOM and ready to draw. `setTime`/\n * click hit-testing are safe to call before this resolves (no-op until\n * ready, same contract as the canvas player). */\n readonly ready: Promise<void>;\n /** Drive the playhead for absolute playback time `tMs`. Caller owns the\n * audio clock + rAF loop. */\n setTime(tMs: number): void;\n /** Re-engrave at a new OSMD zoom (systems reflow). The scroll position is\n * restored afterward so the content that was centered in the viewport\n * before the reflow is still centered after it. */\n setZoom(z: number): Promise<void>;\n /** Re-measure the host and reflow to match its current width, with the\n * same scroll-position preservation as `setZoom`. Call on host resize /\n * orientation change. */\n resize(): Promise<void>;\n /** Register a measure-click handler (measure index, matching\n * `ScoreNote.measure`/the engraved index). Returns an unsubscribe fn. */\n onMeasureClick(cb: (measureIndex: number) => void): () => void;\n /** Tear down: removes the mounted DOM (engraving + playhead overlay) from\n * `host`, and drops every listener this instance added (click, window\n * scroll) and pending async work (a token guard drops any in-flight\n * reflow's effects). Idempotent. */\n destroy(): void;\n}\n\n// ─── svgNotationLayout — the SVG-backend geometry extractor ───────────────\n//\n// UNIT CONVERSION — derived, not guessed (verified against the vendored\n// opensheetmusicdisplay + vexflow source, not just its .d.ts comments, which\n// are stale here — see below):\n//\n// 1. `GraphicalMusicSheet` (`osmd.GraphicSheet`) positions\n// (`PositionAndShape.AbsolutePosition`/`.Size`) are in backend-agnostic\n// \"OSMD units\" — the SAME object model promo.ts's canvas-only\n// `extractGeometry` reads (`osmd.GraphicSheet.MusicPages[0].MusicSystems`,\n// `.MeasureList`). Whichever backend (canvas/svg) was requested, this\n// layer is identical.\n// 2. OSMD's Vexflow draw layer (`VexFlowMusicSheetDrawer`) converts an OSMD\n// unit value to a \"raw\" Vexflow/SVG px value via an EXPORTED constant,\n// `unitInPixels` (currently 10 — NOT `EngravingRules.unit`, which is a\n// different, unrelated field that is NOT the px-per-unit factor despite\n// what its .d.ts comment implies; confirmed by reading the actual\n// compiled source, not trusting the stale doc comment). This raw value\n// is written directly into each SVG element's own coordinate attributes\n// — it does NOT yet include `zoom`.\n// 3. `osmd.Zoom` (current zoom) is applied SEPARATELY, at the SVG-backend\n// level, via a `viewBox` trick (`vexflow/src/svgcontext.js`'s\n// `scale(x,y)`): the `<svg>` element's `width`/`height` ATTRIBUTES are\n// set to the FINAL (zoomed) CSS px size, while its `viewBox` spans\n// `width/zoom .. height/zoom` — i.e. the RAW (unzoomed, step-2) content\n// coordinates. The browser therefore maps that raw coordinate space onto\n// the final CSS px box by exactly `zoom`.\n// Net result: `cssPx = unitValue * unitInPixels * zoom`. Both factors are\n// read from the live library/instance (never a hardcoded literal `10` or an\n// assumed zoom) — see `SvgNotationLayoutOpts.unitInPixels`'s doc — so a\n// future OSMD version changing either is picked up automatically, which is\n// exactly the \"10 units/staff-space assumptions have version drift\" risk\n// the design doc calls out.\n//\n// This mirrors, in spirit, promo.ts's canvas `extractGeometry` self-\n// calibration (`canvas.width / (contentRight+contentLeft)`, chosen there\n// specifically because a naive formula broke on overflowed layouts) — the SVG\n// backend doesn't have that raster-overflow failure mode (there is no\n// backing-store to overflow; the viewBox IS the content, always), so the\n// derived formula above is exact, not an approximation.\n\nexport interface SvgNotationLayoutOpts {\n /** CSS px per OSMD unit at zoom 1 — OSMD's own exported `unitInPixels`\n * constant (see the derivation above). REQUIRED, no default: the point is\n * to FORCE the real call site to source this from the live\n * `opensheetmusicdisplay` import\n * (`const { unitInPixels } = await import('opensheetmusicdisplay')`)\n * rather than this module assuming a value that could drift across OSMD\n * versions. Tests pin it explicitly. */\n unitInPixels: number;\n}\n\nconst EMPTY_LAYOUT: NotationLayout = {\n src: { x: 0, y: 0, w: 0, h: 0 },\n rect: { dx: 0, dy: 0, dw: 0, dh: 0 },\n systems: [],\n measures: [],\n};\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n/**\n * Pure SVG-backend geometry extractor — builds the SAME `NotationLayout`\n * shape the canvas path's `notationLayout()` does (measure column boxes +\n * system rows + a `rect` vertical band), but read directly from OSMD's\n * `GraphicalMusicSheet` in SVG/CSS px space (see the unit-conversion doc\n * above) instead of a rasterized bitmap. Rebuild on every render (zoom /\n * resize / new score) — cheap, pure array/object mapping, no DOM reads.\n *\n * `osmd` is duck-typed `any` — same contract as promo.ts's\n * `extractGeometry(osmd: any, ...)` — so tests can pass a plain fixture\n * object shaped like the minimal slice of a real `OpenSheetMusicDisplay`\n * instance this function reads (`{ Zoom, GraphicSheet: { MusicPages,\n * MeasureList } }`) without needing a real browser OSMD render (see\n * `CreateSvgNotationPlayerOpts.rendered`'s doc for why headless can't do a\n * real one).\n *\n * `rect` spans the FULL engraved page (top-aligned, `dy = 0`) — there is no\n * follow-camera crop in this component (the whole score is always in the\n * DOM; the PAGE scrolls it) — matching the canvas scroll-mode's `flowLayout`\n * in spirit. `vstackAudioPlayheadLine` only reads `rect.dy`/`rect.dh` to\n * clamp the playhead into the drawn band, so this is a correct, minimal\n * `rect` for that consumer.\n */\nexport function svgNotationLayout(osmd: any, opts: SvgNotationLayoutOpts): NotationLayout {\n try {\n const zoom =\n typeof osmd?.Zoom === 'number' && osmd.Zoom > 0\n ? osmd.Zoom\n : typeof osmd?.zoom === 'number' && osmd.zoom > 0\n ? osmd.zoom\n : 1;\n const f = opts.unitInPixels * zoom;\n\n const graphic: any = osmd?.GraphicSheet;\n const page: any = graphic?.MusicPages?.[0];\n const pageSize = page?.PositionAndShape?.Size;\n const musicSystems: any[] = page?.MusicSystems ?? [];\n if (!(pageSize?.width > 0) || !(pageSize?.height > 0) || !musicSystems.length) return EMPTY_LAYOUT;\n\n const toBox = (pas: any): Box | null => {\n const p = pas?.AbsolutePosition;\n const sz = pas?.Size;\n if (!p || !sz) return null;\n return { x: p.x * f, y: p.y * f, w: sz.width * f, h: sz.height * f };\n };\n\n const systems: Box[] = musicSystems\n .map((s) => toBox(s?.PositionAndShape))\n .filter((b): b is Box => !!b && b.w > 1 && b.h > 1)\n .sort((a, b) => a.y - b.y);\n if (!systems.length) return EMPTY_LAYOUT;\n\n const measureList: any[][] = graphic?.MeasureList ?? [];\n const measures: StaffMeasureBox[] = [];\n measureList.forEach((staves, index) => {\n (staves ?? []).forEach((m: any, staff: number) => {\n const box = toBox(m?.PositionAndShape);\n if (box && box.w > 1 && box.h > 1) {\n const seX = (m?.staffEntries ?? [])[0]?.PositionAndShape?.AbsolutePosition?.x;\n const noteStartX = typeof seX === 'number' ? seX * f : box.x;\n measures.push({ index, staff, box, noteStartX });\n }\n });\n });\n\n const dw = pageSize.width * f;\n const dh = pageSize.height * f;\n return {\n src: { x: 0, y: 0, w: dw, h: dh },\n rect: { dx: 0, dy: 0, dw, dh },\n systems,\n measures,\n };\n } catch {\n return EMPTY_LAYOUT;\n }\n}\n/* eslint-enable @typescript-eslint/no-explicit-any */\n\n// ─── createSvgNotationPlayer ────────────────────────────────────────────────\n//\n// Zoom/resize position preservation (`computeReflowScrollDelta`) and the\n// auto-follow discriminator (`isWithinProgrammaticScroll` /\n// `hasReachedProgrammaticTarget` + the stateful `createFollowController`)\n// moved to `./notationCommon` (0.39.0) — shared with notationPlayerVerovio.ts.\n// Re-exported above for backward compatibility.\n\nconst DEFAULT_PLAYHEAD_COLOR = '#2f6f4f';\n/** Engrave-width ceiling, CSS px (design doc §\"Render\": \"cap ~1200px, the\n * 0.36.2 rule\" — the same reasoning as notationPlayer.ts's\n * `MAX_ENGRAVE_WIDTH`, restated here rather than imported since the two\n * players' constants are independently tunable, and this one is spec'd to a\n * slightly different value). */\nexport const MAX_ENGRAVE_WIDTH_SVG = 1200;\n/** Floor so a not-yet-laid-out / zero-width host never engraves at 0px. */\nconst MIN_ENGRAVE_WIDTH_SVG = 280;\n\n/** Build a live, interactive SVG (vector) notation player. See the module doc\n * + `docs/superpowers/specs/2026-08-11-svg-notation-player-design.md` (in\n * stave-web-sightread) for the full design. */\nexport function createSvgNotationPlayer(opts: CreateSvgNotationPlayerOpts): SvgNotationPlayer {\n const { host, musicXml, onsetsMs, noteCols } = opts;\n const playheadColor = opts.playheadColor ?? DEFAULT_PLAYHEAD_COLOR;\n const onsets = distinctOnsets(onsetsMs.map((onsetMs) => ({ onsetMs })));\n\n const root = document.createElement('div');\n root.style.position = 'relative';\n root.style.width = '100%';\n // Let the browser's native pinch-zoom AND vertical page-scroll gestures\n // through — the \"optical zoom is free\" half of the design (§5): vectors\n // stay crisp at any pinch/browser-zoom level, and this component adds\n // nothing to make that work beyond not blocking the gesture.\n root.style.touchAction = 'pan-y pinch-zoom';\n host.appendChild(root);\n\n const svgHost = document.createElement('div');\n root.appendChild(svgHost);\n\n const playheadEl = document.createElement('div');\n playheadEl.style.position = 'absolute';\n playheadEl.style.left = '0px';\n playheadEl.style.top = '0px';\n playheadEl.style.width = '2px';\n playheadEl.style.height = '0px';\n playheadEl.style.background = playheadColor;\n playheadEl.style.opacity = '0';\n playheadEl.style.pointerEvents = 'none';\n root.appendChild(playheadEl);\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let osmd: any = null;\n let currentLayout: NotationLayout | null = null;\n let currentZoom = opts.zoom ?? 1;\n let unitInPixelsConst = 10; // overwritten by the real import before first use (rendered-seam path never reads it)\n let lastEngravedWidthPx = 0;\n let destroyed = false;\n let lastTMs = 0;\n // Stale-table guard (design doc §\"Known risks\" — \"Overlay drift on\n // reflow\"): each async re-engrave captures its own token; if a NEWER\n // reflow starts before an older one's `await` resolves, the older one's\n // completion is a no-op instead of clobbering the newer geometry.\n let rebuildToken = 0;\n\n function desiredEngraveWidthPx(): number {\n const w = host.clientWidth || 0;\n return Math.max(MIN_ENGRAVE_WIDTH_SVG, Math.min(w || MAX_ENGRAVE_WIDTH_SVG, MAX_ENGRAVE_WIDTH_SVG));\n }\n\n // ─── Playhead ──────────────────────────────────────────────────────────\n\n function renderPlayhead(tMs: number): void {\n lastTMs = tMs;\n if (!currentLayout) return;\n const nBars = currentLayout.measures.length\n ? Math.max(...currentLayout.measures.map((m) => m.index)) + 1\n : 0;\n const line = vstackAudioPlayheadLine(currentLayout, onsets, tMs, nBars, noteCols);\n if (!line) {\n playheadEl.style.opacity = '0';\n return;\n }\n playheadEl.style.opacity = String(line.alpha);\n playheadEl.style.left = `${line.x}px`;\n playheadEl.style.top = `${line.y0}px`;\n playheadEl.style.height = `${Math.max(0, line.y1 - line.y0)}px`;\n follow.follow(() =>\n typeof playheadEl.getBoundingClientRect === 'function' ? playheadEl.getBoundingClientRect() : null,\n );\n }\n\n // ─── Auto-follow (target-position discriminator — see notationCommon.ts) ─\n\n const follow = createFollowController();\n\n // ─── setTime ───────────────────────────────────────────────────────────\n\n function setTime(tMs: number): void {\n if (destroyed) return;\n follow.onSetTime(tMs);\n renderPlayhead(tMs);\n }\n\n // ─── Engrave / reflow ──────────────────────────────────────────────────\n\n async function initialEngrave(): Promise<void> {\n if (opts.rendered) {\n currentLayout = opts.rendered;\n lastEngravedWidthPx = desiredEngraveWidthPx();\n renderPlayhead(lastTMs);\n return;\n }\n // Literal dynamic import — consumers' bundlers must statically see the\n // specifier (same reasoning as promo.ts/notationPlayer.ts); a caller\n // that only ever uses the `rendered` test seam never pulls OSMD in.\n const { OpenSheetMusicDisplay, unitInPixels } = await import('opensheetmusicdisplay');\n unitInPixelsConst = unitInPixels;\n if (destroyed) return;\n\n const widthPx = desiredEngraveWidthPx();\n svgHost.style.width = `${widthPx}px`;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const inst: any = new OpenSheetMusicDisplay(svgHost, {\n backend: 'svg',\n autoResize: false,\n drawTitle: false,\n drawSubtitle: false,\n drawComposer: false,\n drawLyricist: false,\n drawPartNames: false,\n });\n await inst.load(musicXml);\n if (destroyed) return;\n inst.Zoom = currentZoom;\n inst.render();\n if (destroyed) return;\n\n osmd = inst;\n lastEngravedWidthPx = widthPx;\n currentLayout = svgNotationLayout(inst, { unitInPixels: unitInPixelsConst });\n renderPlayhead(lastTMs);\n }\n\n const ready = initialEngrave();\n\n /** Re-engrave at `newZoom` and the host's CURRENT width, preserving the\n * scroll position of whatever content is centered in the viewport right\n * now. Shared by both `setZoom` and `resize` (resize just passes the\n * unchanged `currentZoom`). No-op when neither the width nor the zoom\n * actually changed, or when using the `rendered` test seam (nothing to\n * re-engrave). */\n async function reflow(newZoom: number): Promise<void> {\n if (destroyed) return;\n const widthPx = desiredEngraveWidthPx();\n if (widthPx === lastEngravedWidthPx && newZoom === currentZoom) return;\n if (opts.rendered || !osmd) {\n currentZoom = newZoom;\n return;\n }\n\n const myToken = ++rebuildToken;\n const oldLayout = currentLayout;\n const hasWin = typeof window !== 'undefined';\n let anchorY: number | null = null;\n const anchorX = widthPx / 2;\n if (hasWin && typeof root.getBoundingClientRect === 'function') {\n const r = root.getBoundingClientRect();\n anchorY = window.innerHeight / 2 - r.top;\n }\n\n svgHost.style.width = `${widthPx}px`;\n osmd.Zoom = newZoom;\n // Re-run layout (not just a redraw): a width change must re-flow which\n // measures land on which system, and — since we can't be certain a pure\n // zoom change never affects line-breaking on every OSMD version — this is\n // called unconditionally rather than gated to the width-only case.\n osmd.updateGraphic();\n osmd.render();\n if (destroyed || myToken !== rebuildToken) return;\n\n lastEngravedWidthPx = widthPx;\n currentZoom = newZoom;\n currentLayout = svgNotationLayout(osmd, { unitInPixels: unitInPixelsConst });\n\n if (oldLayout && anchorY != null && hasWin) {\n const delta = computeReflowScrollDelta(oldLayout, currentLayout, anchorX, anchorY);\n if (Number.isFinite(delta) && Math.abs(delta) > 0.5) {\n window.scrollTo({ top: Math.max(0, window.scrollY + delta), left: window.scrollX, behavior: 'auto' });\n }\n }\n if (!destroyed) renderPlayhead(lastTMs);\n }\n\n // ─── Click-to-seek ─────────────────────────────────────────────────────\n\n const clickListeners: Array<(measureIndex: number) => void> = [];\n function onRootClick(e: MouseEvent): void {\n if (!currentLayout) return;\n const rect = root.getBoundingClientRect();\n const mx = e.clientX - rect.left;\n const my = e.clientY - rect.top;\n const idx = hitTestMeasureAt(currentLayout, mx, my);\n if (idx != null) for (const cb of clickListeners) cb(idx);\n }\n root.addEventListener('click', onRootClick);\n\n return {\n ready,\n setTime,\n async setZoom(z: number): Promise<void> {\n await ready;\n await reflow(z);\n },\n async resize(): Promise<void> {\n await ready;\n await reflow(currentZoom);\n },\n onMeasureClick(cb: (measureIndex: number) => void): () => void {\n clickListeners.push(cb);\n return () => {\n const i = clickListeners.indexOf(cb);\n if (i >= 0) clickListeners.splice(i, 1);\n };\n },\n destroy(): void {\n if (destroyed) return;\n destroyed = true;\n rebuildToken++;\n root.removeEventListener('click', onRootClick);\n follow.destroy();\n clickListeners.length = 0;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n try { (osmd as any)?.clear?.(); } catch { /* best-effort */ }\n osmd = null;\n currentLayout = null;\n if (root.parentNode === host) host.removeChild(root);\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;AAgLA,IAAM,eAA+B;AAAA,EACnC,KAAK,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,EAAE;AAAA,EAC9B,MAAM,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,EAAE;AAAA,EACnC,SAAS,CAAC;AAAA,EACV,UAAU,CAAC;AACb;AA0BO,SAAS,kBAAkB,MAAW,MAA6C;AACxF,MAAI;AACF,UAAM,OACJ,OAAO,MAAM,SAAS,YAAY,KAAK,OAAO,IAC1C,KAAK,OACL,OAAO,MAAM,SAAS,YAAY,KAAK,OAAO,IAC5C,KAAK,OACL;AACR,UAAM,IAAI,KAAK,eAAe;AAE9B,UAAM,UAAe,MAAM;AAC3B,UAAM,OAAY,SAAS,aAAa,CAAC;AACzC,UAAM,WAAW,MAAM,kBAAkB;AACzC,UAAM,eAAsB,MAAM,gBAAgB,CAAC;AACnD,QAAI,EAAE,UAAU,QAAQ,MAAM,EAAE,UAAU,SAAS,MAAM,CAAC,aAAa,OAAQ,QAAO;AAEtF,UAAM,QAAQ,CAAC,QAAyB;AACtC,YAAM,IAAI,KAAK;AACf,YAAM,KAAK,KAAK;AAChB,UAAI,CAAC,KAAK,CAAC,GAAI,QAAO;AACtB,aAAO,EAAE,GAAG,EAAE,IAAI,GAAG,GAAG,EAAE,IAAI,GAAG,GAAG,GAAG,QAAQ,GAAG,GAAG,GAAG,SAAS,EAAE;AAAA,IACrE;AAEA,UAAM,UAAiB,aACpB,IAAI,CAAC,MAAM,MAAM,GAAG,gBAAgB,CAAC,EACrC,OAAO,CAAC,MAAgB,CAAC,CAAC,KAAK,EAAE,IAAI,KAAK,EAAE,IAAI,CAAC,EACjD,KAAK,CAAC,GAAG,MAAM,EAAE,IAAI,EAAE,CAAC;AAC3B,QAAI,CAAC,QAAQ,OAAQ,QAAO;AAE5B,UAAM,cAAuB,SAAS,eAAe,CAAC;AACtD,UAAM,WAA8B,CAAC;AACrC,gBAAY,QAAQ,CAAC,QAAQ,UAAU;AACrC,OAAC,UAAU,CAAC,GAAG,QAAQ,CAAC,GAAQ,UAAkB;AAChD,cAAM,MAAM,MAAM,GAAG,gBAAgB;AACrC,YAAI,OAAO,IAAI,IAAI,KAAK,IAAI,IAAI,GAAG;AACjC,gBAAM,OAAO,GAAG,gBAAgB,CAAC,GAAG,CAAC,GAAG,kBAAkB,kBAAkB;AAC5E,gBAAM,aAAa,OAAO,QAAQ,WAAW,MAAM,IAAI,IAAI;AAC3D,mBAAS,KAAK,EAAE,OAAO,OAAO,KAAK,WAAW,CAAC;AAAA,QACjD;AAAA,MACF,CAAC;AAAA,IACH,CAAC;AAED,UAAM,KAAK,SAAS,QAAQ;AAC5B,UAAM,KAAK,SAAS,SAAS;AAC7B,WAAO;AAAA,MACL,KAAK,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,IAAI,GAAG,GAAG;AAAA,MAChC,MAAM,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG;AAAA,MAC7B;AAAA,MACA;AAAA,IACF;AAAA,EACF,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAWA,IAAM,yBAAyB;AAMxB,IAAM,wBAAwB;AAErC,IAAM,wBAAwB;AAKvB,SAAS,wBAAwB,MAAsD;AAC5F,QAAM,EAAE,MAAM,UAAU,UAAU,SAAS,IAAI;AAC/C,QAAM,gBAAgB,KAAK,iBAAiB;AAC5C,QAAM,SAAS,eAAe,SAAS,IAAI,CAAC,aAAa,EAAE,QAAQ,EAAE,CAAC;AAEtE,QAAM,OAAO,SAAS,cAAc,KAAK;AACzC,OAAK,MAAM,WAAW;AACtB,OAAK,MAAM,QAAQ;AAKnB,OAAK,MAAM,cAAc;AACzB,OAAK,YAAY,IAAI;AAErB,QAAM,UAAU,SAAS,cAAc,KAAK;AAC5C,OAAK,YAAY,OAAO;AAExB,QAAM,aAAa,SAAS,cAAc,KAAK;AAC/C,aAAW,MAAM,WAAW;AAC5B,aAAW,MAAM,OAAO;AACxB,aAAW,MAAM,MAAM;AACvB,aAAW,MAAM,QAAQ;AACzB,aAAW,MAAM,SAAS;AAC1B,aAAW,MAAM,aAAa;AAC9B,aAAW,MAAM,UAAU;AAC3B,aAAW,MAAM,gBAAgB;AACjC,OAAK,YAAY,UAAU;AAG3B,MAAI,OAAY;AAChB,MAAI,gBAAuC;AAC3C,MAAI,cAAc,KAAK,QAAQ;AAC/B,MAAI,oBAAoB;AACxB,MAAI,sBAAsB;AAC1B,MAAI,YAAY;AAChB,MAAI,UAAU;AAKd,MAAI,eAAe;AAEnB,WAAS,wBAAgC;AACvC,UAAM,IAAI,KAAK,eAAe;AAC9B,WAAO,KAAK,IAAI,uBAAuB,KAAK,IAAI,KAAK,uBAAuB,qBAAqB,CAAC;AAAA,EACpG;AAIA,WAAS,eAAe,KAAmB;AACzC,cAAU;AACV,QAAI,CAAC,cAAe;AACpB,UAAM,QAAQ,cAAc,SAAS,SACjC,KAAK,IAAI,GAAG,cAAc,SAAS,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,IAAI,IAC1D;AACJ,UAAM,OAAO,wBAAwB,eAAe,QAAQ,KAAK,OAAO,QAAQ;AAChF,QAAI,CAAC,MAAM;AACT,iBAAW,MAAM,UAAU;AAC3B;AAAA,IACF;AACA,eAAW,MAAM,UAAU,OAAO,KAAK,KAAK;AAC5C,eAAW,MAAM,OAAO,GAAG,KAAK,CAAC;AACjC,eAAW,MAAM,MAAM,GAAG,KAAK,EAAE;AACjC,eAAW,MAAM,SAAS,GAAG,KAAK,IAAI,GAAG,KAAK,KAAK,KAAK,EAAE,CAAC;AAC3D,WAAO;AAAA,MAAO,MACZ,OAAO,WAAW,0BAA0B,aAAa,WAAW,sBAAsB,IAAI;AAAA,IAChG;AAAA,EACF;AAIA,QAAM,SAAS,uBAAuB;AAItC,WAAS,QAAQ,KAAmB;AAClC,QAAI,UAAW;AACf,WAAO,UAAU,GAAG;AACpB,mBAAe,GAAG;AAAA,EACpB;AAIA,iBAAe,iBAAgC;AAC7C,QAAI,KAAK,UAAU;AACjB,sBAAgB,KAAK;AACrB,4BAAsB,sBAAsB;AAC5C,qBAAe,OAAO;AACtB;AAAA,IACF;AAIA,UAAM,EAAE,uBAAuB,aAAa,IAAI,MAAM,OAAO,uBAAuB;AACpF,wBAAoB;AACpB,QAAI,UAAW;AAEf,UAAM,UAAU,sBAAsB;AACtC,YAAQ,MAAM,QAAQ,GAAG,OAAO;AAEhC,UAAM,OAAY,IAAI,sBAAsB,SAAS;AAAA,MACnD,SAAS;AAAA,MACT,YAAY;AAAA,MACZ,WAAW;AAAA,MACX,cAAc;AAAA,MACd,cAAc;AAAA,MACd,cAAc;AAAA,MACd,eAAe;AAAA,IACjB,CAAC;AACD,UAAM,KAAK,KAAK,QAAQ;AACxB,QAAI,UAAW;AACf,SAAK,OAAO;AACZ,SAAK,OAAO;AACZ,QAAI,UAAW;AAEf,WAAO;AACP,0BAAsB;AACtB,oBAAgB,kBAAkB,MAAM,EAAE,cAAc,kBAAkB,CAAC;AAC3E,mBAAe,OAAO;AAAA,EACxB;AAEA,QAAM,QAAQ,eAAe;AAQ7B,iBAAe,OAAO,SAAgC;AACpD,QAAI,UAAW;AACf,UAAM,UAAU,sBAAsB;AACtC,QAAI,YAAY,uBAAuB,YAAY,YAAa;AAChE,QAAI,KAAK,YAAY,CAAC,MAAM;AAC1B,oBAAc;AACd;AAAA,IACF;AAEA,UAAM,UAAU,EAAE;AAClB,UAAM,YAAY;AAClB,UAAM,SAAS,OAAO,WAAW;AACjC,QAAI,UAAyB;AAC7B,UAAM,UAAU,UAAU;AAC1B,QAAI,UAAU,OAAO,KAAK,0BAA0B,YAAY;AAC9D,YAAM,IAAI,KAAK,sBAAsB;AACrC,gBAAU,OAAO,cAAc,IAAI,EAAE;AAAA,IACvC;AAEA,YAAQ,MAAM,QAAQ,GAAG,OAAO;AAChC,SAAK,OAAO;AAKZ,SAAK,cAAc;AACnB,SAAK,OAAO;AACZ,QAAI,aAAa,YAAY,aAAc;AAE3C,0BAAsB;AACtB,kBAAc;AACd,oBAAgB,kBAAkB,MAAM,EAAE,cAAc,kBAAkB,CAAC;AAE3E,QAAI,aAAa,WAAW,QAAQ,QAAQ;AAC1C,YAAM,QAAQ,yBAAyB,WAAW,eAAe,SAAS,OAAO;AACjF,UAAI,OAAO,SAAS,KAAK,KAAK,KAAK,IAAI,KAAK,IAAI,KAAK;AACnD,eAAO,SAAS,EAAE,KAAK,KAAK,IAAI,GAAG,OAAO,UAAU,KAAK,GAAG,MAAM,OAAO,SAAS,UAAU,OAAO,CAAC;AAAA,MACtG;AAAA,IACF;AACA,QAAI,CAAC,UAAW,gBAAe,OAAO;AAAA,EACxC;AAIA,QAAM,iBAAwD,CAAC;AAC/D,WAAS,YAAY,GAAqB;AACxC,QAAI,CAAC,cAAe;AACpB,UAAM,OAAO,KAAK,sBAAsB;AACxC,UAAM,KAAK,EAAE,UAAU,KAAK;AAC5B,UAAM,KAAK,EAAE,UAAU,KAAK;AAC5B,UAAM,MAAM,iBAAiB,eAAe,IAAI,EAAE;AAClD,QAAI,OAAO,KAAM,YAAW,MAAM,eAAgB,IAAG,GAAG;AAAA,EAC1D;AACA,OAAK,iBAAiB,SAAS,WAAW;AAE1C,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,MAAM,QAAQ,GAA0B;AACtC,YAAM;AACN,YAAM,OAAO,CAAC;AAAA,IAChB;AAAA,IACA,MAAM,SAAwB;AAC5B,YAAM;AACN,YAAM,OAAO,WAAW;AAAA,IAC1B;AAAA,IACA,eAAe,IAAgD;AAC7D,qBAAe,KAAK,EAAE;AACtB,aAAO,MAAM;AACX,cAAM,IAAI,eAAe,QAAQ,EAAE;AACnC,YAAI,KAAK,EAAG,gBAAe,OAAO,GAAG,CAAC;AAAA,MACxC;AAAA,IACF;AAAA,IACA,UAAgB;AACd,UAAI,UAAW;AACf,kBAAY;AACZ;AACA,WAAK,oBAAoB,SAAS,WAAW;AAC7C,aAAO,QAAQ;AACf,qBAAe,SAAS;AAExB,UAAI;AAAE,QAAC,MAAc,QAAQ;AAAA,MAAG,QAAQ;AAAA,MAAoB;AAC5D,aAAO;AACP,sBAAgB;AAChB,UAAI,KAAK,eAAe,KAAM,MAAK,YAAY,IAAI;AAAA,IACrD;AAAA,EACF;AACF;","names":[]}
@@ -0,0 +1,163 @@
1
+ import { N as NotationLayout } from './notationGeometry-54fFq5yU.js';
2
+ import './promo.js';
3
+
4
+ /** One distinct note-onset instant: the audio-clock time it sounds at, and
5
+ * the stamped `xml:id`s (Task 1, stave repo) of every note that sounds at
6
+ * that instant (>1 for a chord). Multiple entries sharing the same `tMs`
7
+ * are merged (their `noteIds` unioned) — the caller does not need to
8
+ * pre-group chords into one entry. */
9
+ interface VerovioOnset {
10
+ tMs: number;
11
+ noteIds: string[];
12
+ }
13
+ interface CreateVerovioNotationPlayerOpts {
14
+ /** Element the player's content is mounted into. Takes NATURAL content
15
+ * height (the whole score, page-flow layout, ALL Verovio pages stacked)
16
+ * — the host must not clip a fixed height; the PAGE scrolls the score. */
17
+ host: HTMLElement;
18
+ /** Display MusicXML to engrave (Task 1's transform-pipeline output — ids
19
+ * already stamped). Ignored when `rendered` (the test seam) is set. */
20
+ musicXml: string;
21
+ /** Note onsets the playhead locks to, WITH the stamped ids of the notes
22
+ * sounding at each onset — see `VerovioOnset`. Distinct/sorted
23
+ * automatically (duplicates by `tMs` are merged, not required to be
24
+ * pre-sorted). */
25
+ onsets: VerovioOnset[];
26
+ /** Playhead line color. Default `'#2f6f4f'`. */
27
+ playheadColor?: string;
28
+ /** Initial semantic zoom — see `verovioZoomOptions`'s doc for the mapping.
29
+ * Default 1 (a normal readable size that fits the host's width). */
30
+ zoom?: number;
31
+ /**
32
+ * Advanced / test seam: a pre-built `NotationLayout`, bypassing the real
33
+ * Verovio engrave (`musicXml` is still required by the type but is
34
+ * ignored when this is set). Mirrors `CreateSvgNotationPlayerOpts.rendered`
35
+ * in notationPlayerSvg.ts for the identical reason: real Verovio rendering
36
+ * needs a real SVG DOM (`getBBox`/`getBoundingClientRect`) that jsdom can't
37
+ * provide — see notationPlayerSvg.ts's module doc for why headless can't do
38
+ * a real one. When set, `noteIds`-based note lookups have no live DOM to
39
+ * resolve against, so the playhead falls back to `vstackAudioPlayheadLine`'s
40
+ * own ordinal spread (same graceful-degradation path as "no `noteCols`
41
+ * supplied" on the SVG player) — fine for a lifecycle test, not for
42
+ * notehead-accurate positioning. `setZoom`/`resize` update the tracked zoom
43
+ * /width but perform no real re-engrave (there is nothing to re-engrave).
44
+ */
45
+ rendered?: NotationLayout;
46
+ }
47
+ interface VerovioNotationPlayer {
48
+ /** Resolves once the engraving is in the DOM and ready to draw. `setTime`/
49
+ * click hit-testing are safe to call before this resolves (no-op until
50
+ * ready, same contract as the SVG player). */
51
+ readonly ready: Promise<void>;
52
+ /** Drive the playhead for absolute playback time `tMs`. Caller owns the
53
+ * audio clock + rAF loop. */
54
+ setTime(tMs: number): void;
55
+ /** Re-engrave at a new semantic zoom (systems reflow — see
56
+ * `verovioZoomOptions`). The scroll position is restored afterward so the
57
+ * content that was centered in the viewport before the reflow is still
58
+ * centered after it. */
59
+ setZoom(z: number): Promise<void>;
60
+ /** Re-measure the host and reflow to match its current width, with the
61
+ * same scroll-position preservation as `setZoom`. Call on host resize /
62
+ * orientation change. */
63
+ resize(): Promise<void>;
64
+ /** Register a measure-click handler (measure index, matching the DOM-order
65
+ * index `verovioNotationLayout` assigns). Returns an unsubscribe fn. */
66
+ onMeasureClick(cb: (measureIndex: number) => void): () => void;
67
+ /** Tear down: removes the mounted DOM (engraving + playhead overlay) from
68
+ * `host`, and drops every listener this instance added (click, window
69
+ * scroll) and pending async work (a token guard drops any in-flight
70
+ * reflow's effects). Idempotent. Does NOT destroy the shared module-level
71
+ * Verovio toolkit instance (see the module doc's "SHARED TOOLKIT
72
+ * INSTANCE" note) — it is reused by the next player, if any. */
73
+ destroy(): void;
74
+ }
75
+ /**
76
+ * Pure(ish) — DOM-in, `NotationLayout`-out — Verovio-backend geometry
77
+ * extractor. `root` is the container holding every rendered `.vrv-page`
78
+ * wrapper `<div>` (this player's `svgHost`; a test fixture must reproduce
79
+ * that same wrapper structure — see the extractor tests). Builds the SAME
80
+ * `NotationLayout` shape `svgNotationLayout` (notationPlayerSvg.ts) does,
81
+ * from Verovio's rendered SVG DOM instead of OSMD's object model — see the
82
+ * module doc for the full derivation (measure/staff/system/note lookup via
83
+ * Verovio's own stable SVG classes, unit conversion via the live
84
+ * width/viewBox on each page). Never throws; returns `EMPTY_LAYOUT` on any
85
+ * missing/malformed structure.
86
+ */
87
+ declare function verovioNotationLayout(root: Element): NotationLayout;
88
+ /**
89
+ * Resolve each DISTINCT onset (see `distinctOnsets`) to a `noteCols` entry —
90
+ * the SAME "real engraved column" format `vstackAudioPlayheadLine` already
91
+ * accepts from the SVG/canvas players (`measureIndex + fractionWithinMeasure`
92
+ * — see that function's doc for how it's consumed). For each onset, every
93
+ * stamped `noteId` sounding at that instant (a chord may have several) is
94
+ * looked up by id in the live DOM (`document.getElementById` — Task 1 stamps
95
+ * ids that Verovio preserves verbatim as SVG element ids), mapped to its
96
+ * measure via `collectMeasureElements`'s canonical indexing (so it lines up
97
+ * EXACTLY with `verovioNotationLayout`'s own `index` numbering), and its
98
+ * fractional x-position within that measure's column computed with the exact
99
+ * same formula `vstackAudioPlayheadLine`'s own `colX` uses internally (not
100
+ * re-derived independently — this is the note's ANCHOR position, not new
101
+ * interpolation math). A chord's several ids are averaged. Any onset with NO
102
+ * resolvable id (missing from the DOM, e.g. a `rendered`-seam layout with no
103
+ * live SVG) falls back to the ordinal spread `vstackAudioPlayheadLine` itself
104
+ * uses when `noteCols` is entirely absent — so one bad id degrades ONLY that
105
+ * onset, not the whole piece. Returns `undefined` (not a partially-bad array)
106
+ * only when there is no usable layout/DOM at all to resolve against.
107
+ */
108
+ declare function verovioOnsetColumns(root: Element, layout: NotationLayout, onsets: VerovioOnset[]): number[] | undefined;
109
+ /** Verovio glyph-size percent at semantic zoom 1 (matches the migration
110
+ * spike's own default — a normal, readable size at typical host widths). */
111
+ declare const VEROVIO_BASE_SCALE = 40;
112
+ /** Clamp band for the derived `scale`, so an extreme `zoom` never collapses
113
+ * glyphs to unreadable or blows them up past sane bounds. */
114
+ declare const VEROVIO_MIN_SCALE = 20;
115
+ declare const VEROVIO_MAX_SCALE = 120;
116
+ interface VerovioRenderOptions {
117
+ scale: number;
118
+ pageWidth: number;
119
+ }
120
+ /**
121
+ * Semantic zoom → Verovio options. The migration spike's `zoom-test*.mjs`
122
+ * proved `pageWidth` (Verovio's line-breaking width, in ITS OWN units)
123
+ * drives measures-per-system while `scale` (glyph size) alone does NOT
124
+ * (avgMeasuresPerSystem was IDENTICAL — 3.61 — across scale 40/80/150 at a
125
+ * fixed pageWidth 1600; it moved 2.24→3.61→5.91 as pageWidth alone rose
126
+ * 1000→1600→2400 at a fixed scale). Verovio's own rendered SVG width in CSS
127
+ * px is EXACTLY `pageWidth * scale / 100` (confirmed empirically against
128
+ * real 6.2.0 renders) — an identity, not an approximation.
129
+ *
130
+ * This function picks `scale` proportional to `zoom` (bigger zoom ⇒ bigger
131
+ * glyphs) and then SOLVES that identity for the `pageWidth` that makes the
132
+ * rendered width land EXACTLY on `hostWidthPx` regardless of zoom
133
+ * (`pageWidth = hostWidthPx * 100 / scale`). Composing it this way — instead
134
+ * of tuning `scale` and `pageWidth` independently — makes BOTH halves of the
135
+ * spec's semantic fall out of the ONE formula: as `zoom` rises, `scale`
136
+ * rises (glyphs bigger) AND the required `pageWidth` (in Verovio units)
137
+ * SHRINKS proportionally (since it's inversely proportional to `scale` at a
138
+ * fixed target width) — and per the spike's own finding, a smaller
139
+ * `pageWidth` fits FEWER measures per system. So "bigger zoom ⇒ fewer
140
+ * measures/system, glyphs larger, width still fits host" (design doc §2) is
141
+ * a direct consequence of this one identity, pinned by
142
+ * `tests/notationPlayerVerovio.test.ts`'s real-Verovio monotonicity test.
143
+ *
144
+ * No floor on `pageWidth` beyond what the identity itself produces: `scale`
145
+ * is already clamped to `[VEROVIO_MIN_SCALE, VEROVIO_MAX_SCALE]` (both > 0)
146
+ * and `hostWidthPx` is floored to a sane fallback when invalid, so
147
+ * `pageWidth = w * 100 / scale` is ALWAYS finite and positive — an
148
+ * additional floor would only ever fire by breaking the width-fits-host
149
+ * identity (clamping the OUTPUT width away from the host's actual width),
150
+ * which is worse than a small `pageWidth`.
151
+ */
152
+ declare function verovioZoomOptions(hostWidthPx: number, zoom: number): VerovioRenderOptions;
153
+ /** Engrave-width ceiling, CSS px — same policy as notationPlayerSvg.ts's
154
+ * `MAX_ENGRAVE_WIDTH_SVG` (design doc §"Render": "cap ~1200px"), restated
155
+ * independently since the two players' constants are independently
156
+ * tunable. */
157
+ declare const MAX_ENGRAVE_WIDTH_VRV = 1200;
158
+ /** Build a live, interactive Verovio (vector) notation player. See the
159
+ * module doc + `docs/superpowers/specs/2026-08-11-verovio-player-design.md`
160
+ * (in stave-web-sightread) §2 for the full design. */
161
+ declare function createVerovioNotationPlayer(opts: CreateVerovioNotationPlayerOpts): VerovioNotationPlayer;
162
+
163
+ export { type CreateVerovioNotationPlayerOpts, MAX_ENGRAVE_WIDTH_VRV, VEROVIO_BASE_SCALE, VEROVIO_MAX_SCALE, VEROVIO_MIN_SCALE, type VerovioNotationPlayer, type VerovioOnset, type VerovioRenderOptions, createVerovioNotationPlayer, verovioNotationLayout, verovioOnsetColumns, verovioZoomOptions };
@@ -0,0 +1,367 @@
1
+ import {
2
+ computeReflowScrollDelta,
3
+ createFollowController
4
+ } from "./chunk-HZFDLJLA.js";
5
+ import {
6
+ distinctOnsets,
7
+ hitTestMeasureAt,
8
+ measureColumnsFromLayout,
9
+ vstackAudioPlayheadLine
10
+ } from "./chunk-BHRDISMU.js";
11
+ import "./chunk-HXTRNE74.js";
12
+
13
+ // src/notationPlayerVerovio.ts
14
+ var EMPTY_LAYOUT = {
15
+ src: { x: 0, y: 0, w: 0, h: 0 },
16
+ rect: { dx: 0, dy: 0, dw: 0, dh: 0 },
17
+ systems: [],
18
+ measures: []
19
+ };
20
+ function safeRect(el) {
21
+ return typeof el.getBoundingClientRect === "function" ? el.getBoundingClientRect() : null;
22
+ }
23
+ function readViewBox(svgEl) {
24
+ const baseVal = svgEl.viewBox && svgEl.viewBox.baseVal;
25
+ if (baseVal && baseVal.width > 0) return { w: baseVal.width, h: baseVal.height };
26
+ const attr = svgEl.getAttribute("viewBox");
27
+ if (!attr) return null;
28
+ const parts = attr.trim().split(/[\s,]+/).map(Number);
29
+ if (parts.length !== 4 || !(parts[2] > 0)) return null;
30
+ return { w: parts[2], h: parts[3] };
31
+ }
32
+ function pageGeometry(root, pageEl) {
33
+ const outerSvg = pageEl.querySelector("svg");
34
+ if (!outerSvg) return null;
35
+ const innerSvg = outerSvg.querySelector("svg[viewBox]") ?? outerSvg;
36
+ const vb = readViewBox(innerSvg);
37
+ if (!vb) return null;
38
+ const outerRect = safeRect(outerSvg);
39
+ const rootRect = safeRect(root);
40
+ const pageRect = safeRect(pageEl);
41
+ if (!outerRect || !rootRect || !pageRect) return null;
42
+ if (!(outerRect.width > 0)) return null;
43
+ const scale = outerRect.width / vb.w;
44
+ if (!(scale > 0) || !Number.isFinite(scale)) return null;
45
+ return { scale, offsetX: pageRect.left - rootRect.left, offsetY: pageRect.top - rootRect.top };
46
+ }
47
+ function boxFromElement(el, geom) {
48
+ const ge = el;
49
+ if (typeof ge.getBBox !== "function") return null;
50
+ let bbox;
51
+ try {
52
+ bbox = ge.getBBox();
53
+ } catch {
54
+ return null;
55
+ }
56
+ if (!bbox || !(bbox.width > 0) || !(bbox.height > 0)) return null;
57
+ return {
58
+ x: geom.offsetX + bbox.x * geom.scale,
59
+ y: geom.offsetY + bbox.y * geom.scale,
60
+ w: bbox.width * geom.scale,
61
+ h: bbox.height * geom.scale
62
+ };
63
+ }
64
+ function computePageGeometries(root) {
65
+ const map = /* @__PURE__ */ new Map();
66
+ for (const pageEl of Array.from(root.querySelectorAll(".vrv-page"))) {
67
+ const geom = pageGeometry(root, pageEl);
68
+ if (geom) map.set(pageEl, geom);
69
+ }
70
+ return map;
71
+ }
72
+ function collectMeasureElements(pageGeoms) {
73
+ const out = [];
74
+ for (const [pageEl, geom] of pageGeoms) {
75
+ for (const measureEl of Array.from(pageEl.querySelectorAll("g.measure"))) {
76
+ if (measureEl.querySelector("g.staff")) out.push({ el: measureEl, geom });
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+ function verovioNotationLayout(root) {
82
+ try {
83
+ const pageGeoms = computePageGeometries(root);
84
+ if (!pageGeoms.size) return EMPTY_LAYOUT;
85
+ const measureEntries = collectMeasureElements(pageGeoms);
86
+ if (!measureEntries.length) return EMPTY_LAYOUT;
87
+ const measures = [];
88
+ measureEntries.forEach(({ el: measureEl, geom }, index) => {
89
+ const staffEls = Array.from(measureEl.querySelectorAll("g.staff"));
90
+ const staffBoxes = staffEls.map((el) => ({ el, box: boxFromElement(el, geom) })).filter((s) => !!s.box).sort((a, b) => a.box.y - b.box.y);
91
+ staffBoxes.forEach(({ el: staffEl, box }, staff) => {
92
+ const noteEls = Array.from(measureEl.querySelectorAll("g.note")).filter(
93
+ (n) => n.closest("g.staff") === staffEl
94
+ );
95
+ const noteXs = noteEls.map((n) => boxFromElement(n, geom)?.x).filter((x) => x != null);
96
+ const noteStartX = noteXs.length ? Math.min(...noteXs) : box.x;
97
+ measures.push({ index, staff, box, noteStartX });
98
+ });
99
+ });
100
+ if (!measures.length) return EMPTY_LAYOUT;
101
+ const systems = [];
102
+ for (const [pageEl, geom] of pageGeoms) {
103
+ for (const sysEl of Array.from(pageEl.querySelectorAll("g.system"))) {
104
+ const box = boxFromElement(sysEl, geom);
105
+ if (box) systems.push(box);
106
+ }
107
+ }
108
+ const rootRect = safeRect(root);
109
+ const fallbackW = Math.max(0, ...measures.map((m) => m.box.x + m.box.w));
110
+ const fallbackH = Math.max(0, ...measures.map((m) => m.box.y + m.box.h));
111
+ const dw = rootRect && rootRect.width > 0 ? rootRect.width : fallbackW;
112
+ const dh = rootRect && rootRect.height > 0 ? rootRect.height : fallbackH;
113
+ return { src: { x: 0, y: 0, w: dw, h: dh }, rect: { dx: 0, dy: 0, dw, dh }, systems, measures };
114
+ } catch {
115
+ return EMPTY_LAYOUT;
116
+ }
117
+ }
118
+ function verovioOnsetColumns(root, layout, onsets) {
119
+ try {
120
+ const distinctMs = distinctOnsets(onsets.map((o) => ({ onsetMs: o.tMs })));
121
+ if (!distinctMs.length) return void 0;
122
+ const cols = measureColumnsFromLayout(layout.measures);
123
+ if (!cols.length) return void 0;
124
+ const pageGeoms = computePageGeometries(root);
125
+ const measureEntries = collectMeasureElements(pageGeoms);
126
+ if (!measureEntries.length) return void 0;
127
+ const measureIndexOf = /* @__PURE__ */ new Map();
128
+ measureEntries.forEach((m, i) => measureIndexOf.set(m.el, i));
129
+ const idsByOnset = /* @__PURE__ */ new Map();
130
+ for (const o of onsets) {
131
+ const arr = idsByOnset.get(o.tMs) ?? [];
132
+ for (const id of o.noteIds) if (!arr.includes(id)) arr.push(id);
133
+ idsByOnset.set(o.tMs, arr);
134
+ }
135
+ const doc = root.ownerDocument;
136
+ return distinctMs.map((tMs, k) => {
137
+ const ids = idsByOnset.get(tMs) ?? [];
138
+ const positions = [];
139
+ for (const id of ids) {
140
+ const noteEl = doc ? doc.getElementById(id) : null;
141
+ const measureEl = noteEl ? noteEl.closest("g.measure") : null;
142
+ const index = measureEl ? measureIndexOf.get(measureEl) : void 0;
143
+ if (index == null) continue;
144
+ const m = cols[index];
145
+ const geom = measureEntries[index]?.geom;
146
+ const box = noteEl && geom ? boxFromElement(noteEl, geom) : null;
147
+ if (!box || !m) continue;
148
+ const sx = Math.min(m.noteStartX, m.x + m.w);
149
+ const denom = m.x + m.w - sx;
150
+ const frac = denom > 0 ? Math.min(1, Math.max(0, (box.x - sx) / denom)) : 0;
151
+ positions.push(index + frac);
152
+ }
153
+ if (positions.length) return positions.reduce((a, b) => a + b, 0) / positions.length;
154
+ return distinctMs.length === 1 ? 0 : k / (distinctMs.length - 1) * cols.length;
155
+ });
156
+ } catch {
157
+ return void 0;
158
+ }
159
+ }
160
+ var VEROVIO_BASE_SCALE = 40;
161
+ var VEROVIO_MIN_SCALE = 20;
162
+ var VEROVIO_MAX_SCALE = 120;
163
+ function verovioZoomOptions(hostWidthPx, zoom) {
164
+ const z = Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
165
+ const scale = Math.max(VEROVIO_MIN_SCALE, Math.min(VEROVIO_MAX_SCALE, VEROVIO_BASE_SCALE * z));
166
+ const w = Number.isFinite(hostWidthPx) && hostWidthPx > 0 ? hostWidthPx : MAX_ENGRAVE_WIDTH_VRV;
167
+ const pageWidth = w * 100 / scale;
168
+ return { scale, pageWidth };
169
+ }
170
+ var toolkitPromise = null;
171
+ function getVerovioToolkit() {
172
+ if (!toolkitPromise) {
173
+ toolkitPromise = (async () => {
174
+ const [{ default: createVerovioModule }, { VerovioToolkit }] = await Promise.all([
175
+ import("verovio/wasm"),
176
+ import("verovio/esm")
177
+ ]);
178
+ const VerovioModule = await createVerovioModule();
179
+ return new VerovioToolkit(VerovioModule);
180
+ })();
181
+ }
182
+ return toolkitPromise;
183
+ }
184
+ var DEFAULT_PLAYHEAD_COLOR = "#2f6f4f";
185
+ var MAX_ENGRAVE_WIDTH_VRV = 1200;
186
+ var MIN_ENGRAVE_WIDTH_VRV = 280;
187
+ function createVerovioNotationPlayer(opts) {
188
+ const { host, musicXml, onsets: rawOnsets } = opts;
189
+ const playheadColor = opts.playheadColor ?? DEFAULT_PLAYHEAD_COLOR;
190
+ const onsets = distinctOnsets(rawOnsets.map((o) => ({ onsetMs: o.tMs })));
191
+ const root = document.createElement("div");
192
+ root.style.position = "relative";
193
+ root.style.width = "100%";
194
+ root.style.touchAction = "pan-y pinch-zoom";
195
+ host.appendChild(root);
196
+ const svgHost = document.createElement("div");
197
+ root.appendChild(svgHost);
198
+ const playheadEl = document.createElement("div");
199
+ playheadEl.style.position = "absolute";
200
+ playheadEl.style.left = "0px";
201
+ playheadEl.style.top = "0px";
202
+ playheadEl.style.width = "2px";
203
+ playheadEl.style.height = "0px";
204
+ playheadEl.style.background = playheadColor;
205
+ playheadEl.style.opacity = "0";
206
+ playheadEl.style.pointerEvents = "none";
207
+ root.appendChild(playheadEl);
208
+ let currentLayout = null;
209
+ let currentNoteCols;
210
+ let currentZoom = opts.zoom ?? 1;
211
+ let lastEngravedWidthPx = 0;
212
+ let loaded = false;
213
+ let destroyed = false;
214
+ let lastTMs = 0;
215
+ let rebuildToken = 0;
216
+ function desiredEngraveWidthPx() {
217
+ const w = host.clientWidth || 0;
218
+ return Math.max(MIN_ENGRAVE_WIDTH_VRV, Math.min(w || MAX_ENGRAVE_WIDTH_VRV, MAX_ENGRAVE_WIDTH_VRV));
219
+ }
220
+ const follow = createFollowController();
221
+ function renderPlayhead(tMs) {
222
+ lastTMs = tMs;
223
+ if (!currentLayout) return;
224
+ const nBars = currentLayout.measures.length ? Math.max(...currentLayout.measures.map((m) => m.index)) + 1 : 0;
225
+ const line = vstackAudioPlayheadLine(currentLayout, onsets, tMs, nBars, currentNoteCols);
226
+ if (!line) {
227
+ playheadEl.style.opacity = "0";
228
+ return;
229
+ }
230
+ playheadEl.style.opacity = String(line.alpha);
231
+ playheadEl.style.left = `${line.x}px`;
232
+ playheadEl.style.top = `${line.y0}px`;
233
+ playheadEl.style.height = `${Math.max(0, line.y1 - line.y0)}px`;
234
+ follow.follow(
235
+ () => typeof playheadEl.getBoundingClientRect === "function" ? playheadEl.getBoundingClientRect() : null
236
+ );
237
+ }
238
+ function setTime(tMs) {
239
+ if (destroyed) return;
240
+ follow.onSetTime(tMs);
241
+ renderPlayhead(tMs);
242
+ }
243
+ function rebuildLayoutFromDom() {
244
+ currentLayout = verovioNotationLayout(svgHost);
245
+ currentNoteCols = verovioOnsetColumns(svgHost, currentLayout, rawOnsets);
246
+ }
247
+ function renderAllPages(toolkit) {
248
+ svgHost.replaceChildren();
249
+ const pageCount = Math.max(0, toolkit.getPageCount());
250
+ for (let p = 1; p <= pageCount; p++) {
251
+ const pageDiv = document.createElement("div");
252
+ pageDiv.className = "vrv-page";
253
+ pageDiv.innerHTML = toolkit.renderToSVG(p);
254
+ svgHost.appendChild(pageDiv);
255
+ }
256
+ }
257
+ async function initialEngrave() {
258
+ if (opts.rendered) {
259
+ currentLayout = opts.rendered;
260
+ lastEngravedWidthPx = desiredEngraveWidthPx();
261
+ renderPlayhead(lastTMs);
262
+ return;
263
+ }
264
+ const toolkit = await getVerovioToolkit();
265
+ if (destroyed) return;
266
+ const widthPx = desiredEngraveWidthPx();
267
+ const { scale, pageWidth } = verovioZoomOptions(widthPx, currentZoom);
268
+ toolkit.setOptions({ scale, pageWidth, breaks: "auto", adjustPageHeight: true });
269
+ loaded = !!toolkit.loadData(musicXml);
270
+ if (destroyed) return;
271
+ lastEngravedWidthPx = widthPx;
272
+ if (loaded) renderAllPages(toolkit);
273
+ rebuildLayoutFromDom();
274
+ renderPlayhead(lastTMs);
275
+ }
276
+ const ready = initialEngrave();
277
+ async function reflow(newZoom) {
278
+ if (destroyed) return;
279
+ const widthPx = desiredEngraveWidthPx();
280
+ if (widthPx === lastEngravedWidthPx && newZoom === currentZoom) return;
281
+ if (opts.rendered || !loaded) {
282
+ currentZoom = newZoom;
283
+ return;
284
+ }
285
+ const toolkit = await getVerovioToolkit();
286
+ if (destroyed) return;
287
+ const myToken = ++rebuildToken;
288
+ const oldLayout = currentLayout;
289
+ const hasWin = typeof window !== "undefined";
290
+ let anchorY = null;
291
+ const anchorX = widthPx / 2;
292
+ if (hasWin && typeof root.getBoundingClientRect === "function") {
293
+ const r = root.getBoundingClientRect();
294
+ anchorY = window.innerHeight / 2 - r.top;
295
+ }
296
+ const { scale, pageWidth } = verovioZoomOptions(widthPx, newZoom);
297
+ toolkit.setOptions({ scale, pageWidth, breaks: "auto", adjustPageHeight: true });
298
+ const ok = toolkit.loadData(musicXml);
299
+ if (destroyed || myToken !== rebuildToken) return;
300
+ if (!ok) {
301
+ loaded = false;
302
+ return;
303
+ }
304
+ lastEngravedWidthPx = widthPx;
305
+ currentZoom = newZoom;
306
+ renderAllPages(toolkit);
307
+ rebuildLayoutFromDom();
308
+ if (oldLayout && currentLayout && anchorY != null && hasWin) {
309
+ const delta = computeReflowScrollDelta(oldLayout, currentLayout, anchorX, anchorY);
310
+ if (Number.isFinite(delta) && Math.abs(delta) > 0.5) {
311
+ window.scrollTo({ top: Math.max(0, window.scrollY + delta), left: window.scrollX, behavior: "auto" });
312
+ }
313
+ }
314
+ if (!destroyed) renderPlayhead(lastTMs);
315
+ }
316
+ const clickListeners = [];
317
+ function onRootClick(e) {
318
+ if (!currentLayout) return;
319
+ const rect = root.getBoundingClientRect();
320
+ const mx = e.clientX - rect.left;
321
+ const my = e.clientY - rect.top;
322
+ const idx = hitTestMeasureAt(currentLayout, mx, my);
323
+ if (idx != null) for (const cb of clickListeners) cb(idx);
324
+ }
325
+ root.addEventListener("click", onRootClick);
326
+ return {
327
+ ready,
328
+ setTime,
329
+ async setZoom(z) {
330
+ await ready;
331
+ await reflow(z);
332
+ },
333
+ async resize() {
334
+ await ready;
335
+ await reflow(currentZoom);
336
+ },
337
+ onMeasureClick(cb) {
338
+ clickListeners.push(cb);
339
+ return () => {
340
+ const i = clickListeners.indexOf(cb);
341
+ if (i >= 0) clickListeners.splice(i, 1);
342
+ };
343
+ },
344
+ destroy() {
345
+ if (destroyed) return;
346
+ destroyed = true;
347
+ rebuildToken++;
348
+ root.removeEventListener("click", onRootClick);
349
+ follow.destroy();
350
+ clickListeners.length = 0;
351
+ currentLayout = null;
352
+ currentNoteCols = void 0;
353
+ if (root.parentNode === host) host.removeChild(root);
354
+ }
355
+ };
356
+ }
357
+ export {
358
+ MAX_ENGRAVE_WIDTH_VRV,
359
+ VEROVIO_BASE_SCALE,
360
+ VEROVIO_MAX_SCALE,
361
+ VEROVIO_MIN_SCALE,
362
+ createVerovioNotationPlayer,
363
+ verovioNotationLayout,
364
+ verovioOnsetColumns,
365
+ verovioZoomOptions
366
+ };
367
+ //# sourceMappingURL=notationPlayerVerovio.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/notationPlayerVerovio.ts"],"sourcesContent":["// createVerovioNotationPlayer — the Verovio (vector) sibling of\n// createSvgNotationPlayer (notationPlayerSvg.ts). Same job (a live,\n// caller-driven notation + gliding-playhead widget) and the same swap-friendly\n// API shape, different rendering engine: Verovio's own MusicXML→SVG engraver\n// instead of OSMD. See\n// docs/superpowers/specs/2026-08-11-verovio-player-design.md (stave-web-sightread)\n// §2 for the full design rationale.\n//\n// THE BINDING REFRAME (design doc, top): \"Timing is ours; Verovio renders.\"\n// This module NEVER calls `renderToTimemap` or `getElementsAtTime` — Verovio's\n// timemap is proven wrong on tuplets (the root-cause finding that motivated\n// this whole migration). All timing (`onsets`) is supplied by the caller,\n// derived from `parseReduction`'s spans/offsets; this module's only job is\n// SVG + id→geometry, exactly like notationPlayerSvg.ts's job is OSMD SVG +\n// id→geometry. (Grep gate — see the build report.)\n//\n// REUSED, VERBATIM, NO NEW MATH (same hard rule as notationPlayerSvg.ts):\n// - `vstackAudioPlayheadLine` (scene/notationGeometry.ts) — the exact same\n// onset-anchored, carriage-return playhead interpolation both SVG-family\n// players use. It only ever reads a `NotationLayout`; it does not care\n// that THIS module's layout came from Verovio's rendered SVG DOM instead\n// of OSMD's `GraphicalMusicSheet` object model.\n// - `hitTestMeasureAt`, `measureColumnsFromLayout`, `distinctOnsets`\n// (scene/notationGeometry.ts) — reused as-is, identical to\n// notationPlayerSvg.ts's usage.\n// - `computeReflowScrollDelta` + the auto-follow discriminator\n// (`createFollowController`) — EXTRACTED (0.39.0) out of\n// notationPlayerSvg.ts into `./notationCommon`, so both SVG-family\n// players share one implementation. See that module's doc.\n//\n// WHAT'S NEW (not a re-derivation of any of the above):\n// - `verovioNotationLayout` — a Verovio-backend geometry extractor, this\n// module's counterpart to notationPlayerSvg.ts's `svgNotationLayout`.\n// Verovio exposes NO object model to JS (unlike OSMD's `GraphicSheet`) —\n// only rendered SVG + MEI/timemap (timemap being off-limits per the\n// binding reframe above) — so this reads the ACTUAL rendered SVG DOM:\n// `<g class=\"measure\">` / `<g class=\"staff\">` / `<g class=\"system\">` /\n// `<g class=\"note\">` are Verovio's own stable, documented SVG output\n// classes (confirmed against a real 6.2.0 render — see the migration\n// spike's `id-test*.mjs`). Measure `index` is assigned by DOCUMENT ORDER\n// (musical order) across however many pages were rendered — no reliance\n// on Verovio's own (internal, non-deterministic) generated ids.\n// - Stamped-id note lookups (`verovioOnsetColumns`): Task 1 (stave repo)\n// stamps a deterministic `xml:id` per note before handing MusicXML to\n// Verovio; Verovio PRESERVES caller-supplied ids as the rendered SVG\n// element's own `id` attribute (confirmed: `<note id=\"n-0-0-0\">` in the\n// source round-trips to `<g id=\"n-0-0-0\" class=\"note\">` in the output —\n// an EXACT lookup, no heuristics, unlike the ordinal-spread fallback\n// `vstackAudioPlayheadLine` uses when no `noteCols` are supplied at all).\n// - `verovioZoomOptions` — the semantic zoom→Verovio-options mapping. The\n// migration spike's `zoom-test*.mjs` proved `pageWidth` (Verovio's\n// line-breaking width, in ITS OWN units) drives measures-per-system\n// while `scale` (glyph size) alone does NOT (avgMeasuresPerSystem stayed\n// 3.61 across scale 40/80/150 at a fixed pageWidth; it moved from 2.24 to\n// 3.61 to 5.91 as pageWidth alone rose 1000→1600→2400). Verovio's\n// rendered SVG width in CSS px is EXACTLY `pageWidth * scale / 100`\n// (confirmed empirically) — so solving that identity for `pageWidth`\n// given a TARGET output width (the host's width, held fixed across zoom\n// levels) and a zoom-driven `scale` makes both halves of the semantic\n// (\"bigger zoom ⇒ bigger glyphs AND fewer measures/system, width still\n// fits host\") fall out of ONE formula, not two independently-tuned ones.\n// - The unit-conversion for any element's `getBBox()` (Verovio's rendered\n// SVG user-unit space) → CSS px: each rendered page is\n// `<svg width=\"Wpx\" height=\"Hpx\">` (no viewBox) wrapping a nested\n// `<svg class=\"definition-scale\" viewBox=\"0 0 VBW VBH\">` (Verovio's own\n// structure) — so `cssPx = userUnit * (W / VBW)`, the SAME \"read the\n// scale factor from the live render, never hardcode it\" principle\n// `svgNotationLayout`'s `unitInPixels` derivation uses, just sourced from\n// the DOM (Verovio exposes no JS-side unit constant) instead of an\n// imported library constant.\n//\n// PAGES: \"all rendered, stacked in flow\" (design doc §2) — every Verovio\n// page (`getPageCount()`) is rendered to its own `<svg>` and appended, in\n// order, inside its own `.vrv-page` wrapper `<div>`, inside `svgHost`. Normal\n// block layout stacks them vertically; `verovioNotationLayout` reads each\n// page's OWN offset (`getBoundingClientRect()` relative to the shared root)\n// so geometry from every page lands in ONE continuous coordinate space, the\n// same space the playhead overlay is positioned in. No \"current page\" /\n// pagination concept anywhere in this module — the whole score is always in\n// the DOM; the PAGE scrolls it (identical framing to notationPlayerSvg.ts).\n//\n// SHARED TOOLKIT INSTANCE: Verovio's own package doc: \"only one instance can\n// be created for now\" (`VerovioToolkit.instances`, a static array the C++\n// bridge expects to hold at most one live toolkit). This module therefore\n// keeps ONE module-level toolkit init promise for the whole session — every\n// player created on the page shares it. This is NOT a \"one live player at a\n// time\" assumption — the real consumer (PlayerPage) keeps a harmony player\n// AND a written player alive SIMULTANEOUSLY (lazy-built, destroyed only on\n// piece switch), so two players' `loadData` calls genuinely interleave over\n// the toolkit's lifetime. Safety comes from RECLAIM: every toolkit-consuming\n// path (initial engrave; `reflow`, shared by `setZoom`/`resize`) re-parses\n// ITS OWN `musicXml` via `loadData` synchronously, immediately before\n// rendering — never assumes the toolkit still holds what it loaded last\n// time. Because the reclaim call and the render calls that follow it have no\n// `await` between them, JS's single-threaded run-to-completion semantics\n// guarantee no other player's reflow can interleave mid-sequence — see\n// `reflow`'s own comment + `tests/notationPlayerVerovio.test.ts`'s\n// two-player interleaved-reflow test (the regression this guards against).\n//\n// IMPORT PATH: subpath-only —\n// `@real-music-packages/web-core/notationPlayerVerovio` — not re-exported\n// from the root barrel (same reasoning as notationPlayer.ts/\n// notationPlayerSvg.ts: the root barrel is theory-only/zero-dependency).\n// `verovio` itself is a dynamic `import('verovio/wasm')` /\n// `import('verovio/esm')` INSIDE this module only, marked `external` in\n// tsup.config.ts, so the ~2.3MB gzip WASM only loads on pages that actually\n// construct a player — see the build report's dist-grep evidence.\n\nimport {\n vstackAudioPlayheadLine,\n distinctOnsets,\n hitTestMeasureAt,\n measureColumnsFromLayout,\n type NotationLayout,\n} from './scene/notationGeometry';\nimport type { Box, StaffMeasureBox } from './promo';\nimport { computeReflowScrollDelta, createFollowController } from './notationCommon';\n\n// ─── Public types ───────────────────────────────────────────────────────────\n\n/** One distinct note-onset instant: the audio-clock time it sounds at, and\n * the stamped `xml:id`s (Task 1, stave repo) of every note that sounds at\n * that instant (>1 for a chord). Multiple entries sharing the same `tMs`\n * are merged (their `noteIds` unioned) — the caller does not need to\n * pre-group chords into one entry. */\nexport interface VerovioOnset {\n tMs: number;\n noteIds: string[];\n}\n\nexport interface CreateVerovioNotationPlayerOpts {\n /** Element the player's content is mounted into. Takes NATURAL content\n * height (the whole score, page-flow layout, ALL Verovio pages stacked)\n * — the host must not clip a fixed height; the PAGE scrolls the score. */\n host: HTMLElement;\n /** Display MusicXML to engrave (Task 1's transform-pipeline output — ids\n * already stamped). Ignored when `rendered` (the test seam) is set. */\n musicXml: string;\n /** Note onsets the playhead locks to, WITH the stamped ids of the notes\n * sounding at each onset — see `VerovioOnset`. Distinct/sorted\n * automatically (duplicates by `tMs` are merged, not required to be\n * pre-sorted). */\n onsets: VerovioOnset[];\n /** Playhead line color. Default `'#2f6f4f'`. */\n playheadColor?: string;\n /** Initial semantic zoom — see `verovioZoomOptions`'s doc for the mapping.\n * Default 1 (a normal readable size that fits the host's width). */\n zoom?: number;\n /**\n * Advanced / test seam: a pre-built `NotationLayout`, bypassing the real\n * Verovio engrave (`musicXml` is still required by the type but is\n * ignored when this is set). Mirrors `CreateSvgNotationPlayerOpts.rendered`\n * in notationPlayerSvg.ts for the identical reason: real Verovio rendering\n * needs a real SVG DOM (`getBBox`/`getBoundingClientRect`) that jsdom can't\n * provide — see notationPlayerSvg.ts's module doc for why headless can't do\n * a real one. When set, `noteIds`-based note lookups have no live DOM to\n * resolve against, so the playhead falls back to `vstackAudioPlayheadLine`'s\n * own ordinal spread (same graceful-degradation path as \"no `noteCols`\n * supplied\" on the SVG player) — fine for a lifecycle test, not for\n * notehead-accurate positioning. `setZoom`/`resize` update the tracked zoom\n * /width but perform no real re-engrave (there is nothing to re-engrave).\n */\n rendered?: NotationLayout;\n}\n\nexport interface VerovioNotationPlayer {\n /** Resolves once the engraving is in the DOM and ready to draw. `setTime`/\n * click hit-testing are safe to call before this resolves (no-op until\n * ready, same contract as the SVG player). */\n readonly ready: Promise<void>;\n /** Drive the playhead for absolute playback time `tMs`. Caller owns the\n * audio clock + rAF loop. */\n setTime(tMs: number): void;\n /** Re-engrave at a new semantic zoom (systems reflow — see\n * `verovioZoomOptions`). The scroll position is restored afterward so the\n * content that was centered in the viewport before the reflow is still\n * centered after it. */\n setZoom(z: number): Promise<void>;\n /** Re-measure the host and reflow to match its current width, with the\n * same scroll-position preservation as `setZoom`. Call on host resize /\n * orientation change. */\n resize(): Promise<void>;\n /** Register a measure-click handler (measure index, matching the DOM-order\n * index `verovioNotationLayout` assigns). Returns an unsubscribe fn. */\n onMeasureClick(cb: (measureIndex: number) => void): () => void;\n /** Tear down: removes the mounted DOM (engraving + playhead overlay) from\n * `host`, and drops every listener this instance added (click, window\n * scroll) and pending async work (a token guard drops any in-flight\n * reflow's effects). Idempotent. Does NOT destroy the shared module-level\n * Verovio toolkit instance (see the module doc's \"SHARED TOOLKIT\n * INSTANCE\" note) — it is reused by the next player, if any. */\n destroy(): void;\n}\n\n// ─── Verovio-backend geometry extraction (DOM-based) ───────────────────────\n\nconst EMPTY_LAYOUT: NotationLayout = {\n src: { x: 0, y: 0, w: 0, h: 0 },\n rect: { dx: 0, dy: 0, dw: 0, dh: 0 },\n systems: [],\n measures: [],\n};\n\n/** Per-page unit-conversion + placement: `cssPx = userUnit * scale`, plus\n * this page's own `(offsetX, offsetY)` within the shared root's coordinate\n * space (see the module doc's \"unit-conversion\" + \"PAGES\" sections). */\ninterface PageGeom {\n scale: number;\n offsetX: number;\n offsetY: number;\n}\n\nfunction safeRect(el: Element): DOMRect | null {\n return typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect() : null;\n}\n\nfunction readViewBox(svgEl: SVGSVGElement): { w: number; h: number } | null {\n const baseVal = svgEl.viewBox && svgEl.viewBox.baseVal;\n if (baseVal && baseVal.width > 0) return { w: baseVal.width, h: baseVal.height };\n const attr = svgEl.getAttribute('viewBox');\n if (!attr) return null;\n const parts = attr.trim().split(/[\\s,]+/).map(Number);\n if (parts.length !== 4 || !(parts[2] > 0)) return null;\n return { w: parts[2], h: parts[3] };\n}\n\n/** One page's unit-conversion factor + placement offset, derived ENTIRELY\n * from the live DOM (Verovio's rendered SVG is self-describing — outer\n * `<svg width>` + inner `<svg viewBox>` — no external constant needed; see\n * the module doc). Returns null (never throws) if the page's markup is\n * missing the expected structure. */\nfunction pageGeometry(root: Element, pageEl: Element): PageGeom | null {\n const outerSvg = pageEl.querySelector('svg');\n if (!outerSvg) return null;\n const innerSvg = (outerSvg.querySelector('svg[viewBox]') ?? outerSvg) as unknown as SVGSVGElement;\n const vb = readViewBox(innerSvg);\n if (!vb) return null;\n const outerRect = safeRect(outerSvg);\n const rootRect = safeRect(root);\n const pageRect = safeRect(pageEl);\n if (!outerRect || !rootRect || !pageRect) return null;\n if (!(outerRect.width > 0)) return null;\n const scale = outerRect.width / vb.w;\n if (!(scale > 0) || !Number.isFinite(scale)) return null;\n return { scale, offsetX: pageRect.left - rootRect.left, offsetY: pageRect.top - rootRect.top };\n}\n\n/** `el.getBBox()` (Verovio's rendered user-unit space) converted to this\n * page's CSS px space (`scale` + `offsetX`/`offsetY`). Null for anything\n * that isn't a real `SVGGraphicsElement` or has a degenerate (zero-area)\n * box — same defensive style as `svgNotationLayout`'s box filtering. */\nfunction boxFromElement(el: Element, geom: PageGeom): Box | null {\n const ge = el as unknown as SVGGraphicsElement;\n if (typeof ge.getBBox !== 'function') return null;\n let bbox: DOMRect;\n try {\n bbox = ge.getBBox();\n } catch {\n return null;\n }\n if (!bbox || !(bbox.width > 0) || !(bbox.height > 0)) return null;\n return {\n x: geom.offsetX + bbox.x * geom.scale,\n y: geom.offsetY + bbox.y * geom.scale,\n w: bbox.width * geom.scale,\n h: bbox.height * geom.scale,\n };\n}\n\nfunction computePageGeometries(root: Element): Map<Element, PageGeom> {\n const map = new Map<Element, PageGeom>();\n for (const pageEl of Array.from(root.querySelectorAll('.vrv-page'))) {\n const geom = pageGeometry(root, pageEl);\n if (geom) map.set(pageEl, geom);\n }\n return map;\n}\n\n/**\n * Every `<g class=\"measure\">` across all rendered pages that has at least\n * one `<g class=\"staff\">` child, in DOCUMENT ORDER (= musical order, since\n * pages are stacked in `.vrv-page` DOM order and Verovio renders each page's\n * measures left-to-right/top-to-bottom). This exact list's POSITION is the\n * single source of truth for the `index` every measure/note lookup in this\n * module uses (`verovioNotationLayout` AND `verovioOnsetColumns` both call\n * this, so they can never drift out of sync with each other — no separate\n * re-derivation of \"which position is this measure\" anywhere else).\n */\nfunction collectMeasureElements(pageGeoms: Map<Element, PageGeom>): { el: Element; geom: PageGeom }[] {\n const out: { el: Element; geom: PageGeom }[] = [];\n for (const [pageEl, geom] of pageGeoms) {\n for (const measureEl of Array.from(pageEl.querySelectorAll('g.measure'))) {\n if (measureEl.querySelector('g.staff')) out.push({ el: measureEl, geom });\n }\n }\n return out;\n}\n\n/**\n * Pure(ish) — DOM-in, `NotationLayout`-out — Verovio-backend geometry\n * extractor. `root` is the container holding every rendered `.vrv-page`\n * wrapper `<div>` (this player's `svgHost`; a test fixture must reproduce\n * that same wrapper structure — see the extractor tests). Builds the SAME\n * `NotationLayout` shape `svgNotationLayout` (notationPlayerSvg.ts) does,\n * from Verovio's rendered SVG DOM instead of OSMD's object model — see the\n * module doc for the full derivation (measure/staff/system/note lookup via\n * Verovio's own stable SVG classes, unit conversion via the live\n * width/viewBox on each page). Never throws; returns `EMPTY_LAYOUT` on any\n * missing/malformed structure.\n */\nexport function verovioNotationLayout(root: Element): NotationLayout {\n try {\n const pageGeoms = computePageGeometries(root);\n if (!pageGeoms.size) return EMPTY_LAYOUT;\n const measureEntries = collectMeasureElements(pageGeoms);\n if (!measureEntries.length) return EMPTY_LAYOUT;\n\n const measures: StaffMeasureBox[] = [];\n measureEntries.forEach(({ el: measureEl, geom }, index) => {\n const staffEls = Array.from(measureEl.querySelectorAll('g.staff'));\n const staffBoxes = staffEls\n .map((el) => ({ el, box: boxFromElement(el, geom) }))\n .filter((s): s is { el: Element; box: Box } => !!s.box)\n .sort((a, b) => a.box.y - b.box.y);\n staffBoxes.forEach(({ el: staffEl, box }, staff) => {\n const noteEls = Array.from(measureEl.querySelectorAll('g.note')).filter(\n (n) => n.closest('g.staff') === staffEl,\n );\n const noteXs = noteEls.map((n) => boxFromElement(n, geom)?.x).filter((x): x is number => x != null);\n const noteStartX = noteXs.length ? Math.min(...noteXs) : box.x;\n measures.push({ index, staff, box, noteStartX });\n });\n });\n if (!measures.length) return EMPTY_LAYOUT;\n\n const systems: Box[] = [];\n for (const [pageEl, geom] of pageGeoms) {\n for (const sysEl of Array.from(pageEl.querySelectorAll('g.system'))) {\n const box = boxFromElement(sysEl, geom);\n if (box) systems.push(box);\n }\n }\n\n const rootRect = safeRect(root);\n const fallbackW = Math.max(0, ...measures.map((m) => m.box.x + m.box.w));\n const fallbackH = Math.max(0, ...measures.map((m) => m.box.y + m.box.h));\n const dw = rootRect && rootRect.width > 0 ? rootRect.width : fallbackW;\n const dh = rootRect && rootRect.height > 0 ? rootRect.height : fallbackH;\n\n return { src: { x: 0, y: 0, w: dw, h: dh }, rect: { dx: 0, dy: 0, dw, dh }, systems, measures };\n } catch {\n return EMPTY_LAYOUT;\n }\n}\n\n/**\n * Resolve each DISTINCT onset (see `distinctOnsets`) to a `noteCols` entry —\n * the SAME \"real engraved column\" format `vstackAudioPlayheadLine` already\n * accepts from the SVG/canvas players (`measureIndex + fractionWithinMeasure`\n * — see that function's doc for how it's consumed). For each onset, every\n * stamped `noteId` sounding at that instant (a chord may have several) is\n * looked up by id in the live DOM (`document.getElementById` — Task 1 stamps\n * ids that Verovio preserves verbatim as SVG element ids), mapped to its\n * measure via `collectMeasureElements`'s canonical indexing (so it lines up\n * EXACTLY with `verovioNotationLayout`'s own `index` numbering), and its\n * fractional x-position within that measure's column computed with the exact\n * same formula `vstackAudioPlayheadLine`'s own `colX` uses internally (not\n * re-derived independently — this is the note's ANCHOR position, not new\n * interpolation math). A chord's several ids are averaged. Any onset with NO\n * resolvable id (missing from the DOM, e.g. a `rendered`-seam layout with no\n * live SVG) falls back to the ordinal spread `vstackAudioPlayheadLine` itself\n * uses when `noteCols` is entirely absent — so one bad id degrades ONLY that\n * onset, not the whole piece. Returns `undefined` (not a partially-bad array)\n * only when there is no usable layout/DOM at all to resolve against.\n */\nexport function verovioOnsetColumns(\n root: Element,\n layout: NotationLayout,\n onsets: VerovioOnset[],\n): number[] | undefined {\n try {\n const distinctMs = distinctOnsets(onsets.map((o) => ({ onsetMs: o.tMs })));\n if (!distinctMs.length) return undefined;\n const cols = measureColumnsFromLayout(layout.measures);\n if (!cols.length) return undefined;\n\n const pageGeoms = computePageGeometries(root);\n const measureEntries = collectMeasureElements(pageGeoms);\n if (!measureEntries.length) return undefined;\n const measureIndexOf = new Map<Element, number>();\n measureEntries.forEach((m, i) => measureIndexOf.set(m.el, i));\n\n const idsByOnset = new Map<number, string[]>();\n for (const o of onsets) {\n const arr = idsByOnset.get(o.tMs) ?? [];\n for (const id of o.noteIds) if (!arr.includes(id)) arr.push(id);\n idsByOnset.set(o.tMs, arr);\n }\n\n const doc = root.ownerDocument;\n\n return distinctMs.map((tMs, k) => {\n const ids = idsByOnset.get(tMs) ?? [];\n const positions: number[] = [];\n for (const id of ids) {\n const noteEl = doc ? doc.getElementById(id) : null;\n const measureEl = noteEl ? noteEl.closest('g.measure') : null;\n const index = measureEl ? measureIndexOf.get(measureEl) : undefined;\n if (index == null) continue;\n const m = cols[index];\n const geom = measureEntries[index]?.geom;\n const box = noteEl && geom ? boxFromElement(noteEl, geom) : null;\n if (!box || !m) continue;\n const sx = Math.min(m.noteStartX, m.x + m.w);\n const denom = m.x + m.w - sx;\n const frac = denom > 0 ? Math.min(1, Math.max(0, (box.x - sx) / denom)) : 0;\n positions.push(index + frac);\n }\n if (positions.length) return positions.reduce((a, b) => a + b, 0) / positions.length;\n // Same ordinal-spread fallback vstackAudioPlayheadLine uses internally\n // when no noteCols are supplied at all — degrades this ONE onset only.\n return distinctMs.length === 1 ? 0 : (k / (distinctMs.length - 1)) * cols.length;\n });\n } catch {\n return undefined;\n }\n}\n\n// ─── Semantic zoom mapping ──────────────────────────────────────────────────\n\n/** Verovio glyph-size percent at semantic zoom 1 (matches the migration\n * spike's own default — a normal, readable size at typical host widths). */\nexport const VEROVIO_BASE_SCALE = 40;\n/** Clamp band for the derived `scale`, so an extreme `zoom` never collapses\n * glyphs to unreadable or blows them up past sane bounds. */\nexport const VEROVIO_MIN_SCALE = 20;\nexport const VEROVIO_MAX_SCALE = 120;\n\nexport interface VerovioRenderOptions {\n scale: number;\n pageWidth: number;\n}\n\n/**\n * Semantic zoom → Verovio options. The migration spike's `zoom-test*.mjs`\n * proved `pageWidth` (Verovio's line-breaking width, in ITS OWN units)\n * drives measures-per-system while `scale` (glyph size) alone does NOT\n * (avgMeasuresPerSystem was IDENTICAL — 3.61 — across scale 40/80/150 at a\n * fixed pageWidth 1600; it moved 2.24→3.61→5.91 as pageWidth alone rose\n * 1000→1600→2400 at a fixed scale). Verovio's own rendered SVG width in CSS\n * px is EXACTLY `pageWidth * scale / 100` (confirmed empirically against\n * real 6.2.0 renders) — an identity, not an approximation.\n *\n * This function picks `scale` proportional to `zoom` (bigger zoom ⇒ bigger\n * glyphs) and then SOLVES that identity for the `pageWidth` that makes the\n * rendered width land EXACTLY on `hostWidthPx` regardless of zoom\n * (`pageWidth = hostWidthPx * 100 / scale`). Composing it this way — instead\n * of tuning `scale` and `pageWidth` independently — makes BOTH halves of the\n * spec's semantic fall out of the ONE formula: as `zoom` rises, `scale`\n * rises (glyphs bigger) AND the required `pageWidth` (in Verovio units)\n * SHRINKS proportionally (since it's inversely proportional to `scale` at a\n * fixed target width) — and per the spike's own finding, a smaller\n * `pageWidth` fits FEWER measures per system. So \"bigger zoom ⇒ fewer\n * measures/system, glyphs larger, width still fits host\" (design doc §2) is\n * a direct consequence of this one identity, pinned by\n * `tests/notationPlayerVerovio.test.ts`'s real-Verovio monotonicity test.\n *\n * No floor on `pageWidth` beyond what the identity itself produces: `scale`\n * is already clamped to `[VEROVIO_MIN_SCALE, VEROVIO_MAX_SCALE]` (both > 0)\n * and `hostWidthPx` is floored to a sane fallback when invalid, so\n * `pageWidth = w * 100 / scale` is ALWAYS finite and positive — an\n * additional floor would only ever fire by breaking the width-fits-host\n * identity (clamping the OUTPUT width away from the host's actual width),\n * which is worse than a small `pageWidth`.\n */\nexport function verovioZoomOptions(hostWidthPx: number, zoom: number): VerovioRenderOptions {\n const z = Number.isFinite(zoom) && zoom > 0 ? zoom : 1;\n const scale = Math.max(VEROVIO_MIN_SCALE, Math.min(VEROVIO_MAX_SCALE, VEROVIO_BASE_SCALE * z));\n const w = Number.isFinite(hostWidthPx) && hostWidthPx > 0 ? hostWidthPx : MAX_ENGRAVE_WIDTH_VRV;\n const pageWidth = (w * 100) / scale;\n return { scale, pageWidth };\n}\n\n// ─── Shared module-level toolkit (see the module doc's \"SHARED TOOLKIT\n// INSTANCE\" section) ─────────────────────────────────────────────────\n\n/** The subset of `VerovioToolkit`'s instance API this module calls — see\n * `src/types/verovio.d.ts` for the ambient module declaration backing the\n * dynamic imports below (the `verovio` npm package ships no types of its\n * own). Deliberately duck-typed/local rather than importing the real class\n * type statically, so nothing about this module's own type-checking\n * depends on a STATIC import of `verovio/esm` (only the dynamic one inside\n * `getVerovioToolkit` touches the package at all, which is what tsup's\n * `external` + the build-audit grep gate verify). */\ninterface VerovioToolkitInstance {\n loadData(data: string): boolean;\n getPageCount(): number;\n renderToSVG(page: number): string;\n setOptions(options: Record<string, unknown>): void;\n redoLayout(options?: Record<string, unknown>): void;\n}\n\nlet toolkitPromise: Promise<VerovioToolkitInstance> | null = null;\n\nfunction getVerovioToolkit(): Promise<VerovioToolkitInstance> {\n if (!toolkitPromise) {\n toolkitPromise = (async () => {\n // Literal dynamic imports — consumers' bundlers must statically see\n // these specifiers to code-split Verovio out of every OTHER dist\n // entry (tsup marks `verovio` external — see tsup.config.ts + the\n // build report's dist-grep evidence). A caller that only ever uses\n // the `rendered` test seam never pulls Verovio in at all.\n const [{ default: createVerovioModule }, { VerovioToolkit }] = await Promise.all([\n import('verovio/wasm'),\n import('verovio/esm'),\n ]);\n const VerovioModule = await createVerovioModule();\n return new VerovioToolkit(VerovioModule) as unknown as VerovioToolkitInstance;\n })();\n }\n return toolkitPromise;\n}\n\n// ─── createVerovioNotationPlayer ────────────────────────────────────────────\n\nconst DEFAULT_PLAYHEAD_COLOR = '#2f6f4f';\n/** Engrave-width ceiling, CSS px — same policy as notationPlayerSvg.ts's\n * `MAX_ENGRAVE_WIDTH_SVG` (design doc §\"Render\": \"cap ~1200px\"), restated\n * independently since the two players' constants are independently\n * tunable. */\nexport const MAX_ENGRAVE_WIDTH_VRV = 1200;\n/** Floor so a not-yet-laid-out / zero-width host never engraves at 0px. */\nconst MIN_ENGRAVE_WIDTH_VRV = 280;\n\n/** Build a live, interactive Verovio (vector) notation player. See the\n * module doc + `docs/superpowers/specs/2026-08-11-verovio-player-design.md`\n * (in stave-web-sightread) §2 for the full design. */\nexport function createVerovioNotationPlayer(opts: CreateVerovioNotationPlayerOpts): VerovioNotationPlayer {\n const { host, musicXml, onsets: rawOnsets } = opts;\n const playheadColor = opts.playheadColor ?? DEFAULT_PLAYHEAD_COLOR;\n const onsets = distinctOnsets(rawOnsets.map((o) => ({ onsetMs: o.tMs })));\n\n const root = document.createElement('div');\n root.style.position = 'relative';\n root.style.width = '100%';\n // Same \"optical zoom is free\" reasoning as notationPlayerSvg.ts — let\n // native pinch-zoom + vertical page-scroll gestures through unimpeded.\n root.style.touchAction = 'pan-y pinch-zoom';\n host.appendChild(root);\n\n const svgHost = document.createElement('div');\n root.appendChild(svgHost);\n\n const playheadEl = document.createElement('div');\n playheadEl.style.position = 'absolute';\n playheadEl.style.left = '0px';\n playheadEl.style.top = '0px';\n playheadEl.style.width = '2px';\n playheadEl.style.height = '0px';\n playheadEl.style.background = playheadColor;\n playheadEl.style.opacity = '0';\n playheadEl.style.pointerEvents = 'none';\n root.appendChild(playheadEl);\n\n let currentLayout: NotationLayout | null = null;\n let currentNoteCols: number[] | undefined;\n let currentZoom = opts.zoom ?? 1;\n let lastEngravedWidthPx = 0;\n let loaded = false; // toolkit.loadData succeeded (rendered-seam path never sets this)\n let destroyed = false;\n let lastTMs = 0;\n // Stale-reflow guard — same \"each async re-engrave captures its own\n // token\" rule as notationPlayerSvg.ts's `rebuildToken`.\n let rebuildToken = 0;\n\n function desiredEngraveWidthPx(): number {\n const w = host.clientWidth || 0;\n return Math.max(MIN_ENGRAVE_WIDTH_VRV, Math.min(w || MAX_ENGRAVE_WIDTH_VRV, MAX_ENGRAVE_WIDTH_VRV));\n }\n\n // ─── Playhead + auto-follow (createFollowController — notationCommon.ts) ─\n\n const follow = createFollowController();\n\n function renderPlayhead(tMs: number): void {\n lastTMs = tMs;\n if (!currentLayout) return;\n const nBars = currentLayout.measures.length\n ? Math.max(...currentLayout.measures.map((m) => m.index)) + 1\n : 0;\n const line = vstackAudioPlayheadLine(currentLayout, onsets, tMs, nBars, currentNoteCols);\n if (!line) {\n playheadEl.style.opacity = '0';\n return;\n }\n playheadEl.style.opacity = String(line.alpha);\n playheadEl.style.left = `${line.x}px`;\n playheadEl.style.top = `${line.y0}px`;\n playheadEl.style.height = `${Math.max(0, line.y1 - line.y0)}px`;\n follow.follow(() =>\n typeof playheadEl.getBoundingClientRect === 'function' ? playheadEl.getBoundingClientRect() : null,\n );\n }\n\n function setTime(tMs: number): void {\n if (destroyed) return;\n follow.onSetTime(tMs);\n renderPlayhead(tMs);\n }\n\n // ─── Engrave / reflow ──────────────────────────────────────────────────\n\n function rebuildLayoutFromDom(): void {\n currentLayout = verovioNotationLayout(svgHost);\n currentNoteCols = verovioOnsetColumns(svgHost, currentLayout, rawOnsets);\n }\n\n function renderAllPages(toolkit: VerovioToolkitInstance): void {\n svgHost.replaceChildren();\n const pageCount = Math.max(0, toolkit.getPageCount());\n for (let p = 1; p <= pageCount; p++) {\n const pageDiv = document.createElement('div');\n pageDiv.className = 'vrv-page';\n pageDiv.innerHTML = toolkit.renderToSVG(p);\n svgHost.appendChild(pageDiv);\n }\n }\n\n async function initialEngrave(): Promise<void> {\n if (opts.rendered) {\n currentLayout = opts.rendered;\n lastEngravedWidthPx = desiredEngraveWidthPx();\n renderPlayhead(lastTMs);\n return;\n }\n\n const toolkit = await getVerovioToolkit();\n if (destroyed) return;\n\n const widthPx = desiredEngraveWidthPx();\n const { scale, pageWidth } = verovioZoomOptions(widthPx, currentZoom);\n toolkit.setOptions({ scale, pageWidth, breaks: 'auto', adjustPageHeight: true });\n loaded = !!toolkit.loadData(musicXml);\n if (destroyed) return;\n\n lastEngravedWidthPx = widthPx;\n if (loaded) renderAllPages(toolkit);\n rebuildLayoutFromDom();\n renderPlayhead(lastTMs);\n }\n\n const ready = initialEngrave();\n\n /** Re-engrave at `newZoom` and the host's CURRENT width, preserving the\n * scroll position of whatever content is centered in the viewport right\n * now. Shared by both `setZoom` and `resize` — same shape as\n * notationPlayerSvg.ts's `reflow`. No-op when neither the width nor the\n * zoom actually changed, or when using the `rendered` test seam / the\n * initial load never succeeded (nothing to re-engrave). */\n async function reflow(newZoom: number): Promise<void> {\n if (destroyed) return;\n const widthPx = desiredEngraveWidthPx();\n if (widthPx === lastEngravedWidthPx && newZoom === currentZoom) return;\n if (opts.rendered || !loaded) {\n currentZoom = newZoom;\n return;\n }\n\n const toolkit = await getVerovioToolkit();\n if (destroyed) return;\n\n const myToken = ++rebuildToken;\n const oldLayout = currentLayout;\n const hasWin = typeof window !== 'undefined';\n let anchorY: number | null = null;\n const anchorX = widthPx / 2;\n if (hasWin && typeof root.getBoundingClientRect === 'function') {\n const r = root.getBoundingClientRect();\n anchorY = window.innerHeight / 2 - r.top;\n }\n\n const { scale, pageWidth } = verovioZoomOptions(widthPx, newZoom);\n toolkit.setOptions({ scale, pageWidth, breaks: 'auto', adjustPageHeight: true });\n // RECLAIM (see the module doc's \"SHARED TOOLKIT INSTANCE\" note): the\n // module-level toolkit is shared across every live player on the page —\n // and the real consumer (PlayerPage) keeps a harmony player AND a\n // written player alive SIMULTANEOUSLY (lazy-built, destroyed only on\n // piece switch), so `loadData` calls from the two players genuinely\n // interleave (e.g. harmony active -> resize while written hidden -> back\n // to written -> writtenPlayer.setZoom()). A plain `redoLayout()` here\n // would silently re-lay-out and render WHATEVER document the toolkit\n // currently holds — which may belong to the OTHER player if it rendered\n // more recently. So every reflow re-parses THIS player's own `musicXml`\n // synchronously, immediately before rendering (loadData does a full\n // parse + layout with the just-set options, making a separate\n // `redoLayout()` call redundant). Because this call and `renderAllPages`\n // below are both synchronous with NO `await` between them, and the only\n // preceding `await` (`getVerovioToolkit()`) already resolved, JS's\n // single-threaded run-to-completion semantics guarantee no other\n // player's reflow/initialEngrave can interleave between the reclaim and\n // the render — the shared singleton is therefore safe under alternating\n // use. See `tests/notationPlayerVerovio.test.ts`'s two-player\n // interleaved-reflow test for the regression this guards against.\n const ok = toolkit.loadData(musicXml);\n if (destroyed || myToken !== rebuildToken) return;\n if (!ok) {\n loaded = false;\n return;\n }\n\n lastEngravedWidthPx = widthPx;\n currentZoom = newZoom;\n renderAllPages(toolkit);\n rebuildLayoutFromDom();\n\n if (oldLayout && currentLayout && anchorY != null && hasWin) {\n const delta = computeReflowScrollDelta(oldLayout, currentLayout, anchorX, anchorY);\n if (Number.isFinite(delta) && Math.abs(delta) > 0.5) {\n window.scrollTo({ top: Math.max(0, window.scrollY + delta), left: window.scrollX, behavior: 'auto' });\n }\n }\n if (!destroyed) renderPlayhead(lastTMs);\n }\n\n // ─── Click-to-seek ─────────────────────────────────────────────────────\n\n const clickListeners: Array<(measureIndex: number) => void> = [];\n function onRootClick(e: MouseEvent): void {\n if (!currentLayout) return;\n const rect = root.getBoundingClientRect();\n const mx = e.clientX - rect.left;\n const my = e.clientY - rect.top;\n const idx = hitTestMeasureAt(currentLayout, mx, my);\n if (idx != null) for (const cb of clickListeners) cb(idx);\n }\n root.addEventListener('click', onRootClick);\n\n return {\n ready,\n setTime,\n async setZoom(z: number): Promise<void> {\n await ready;\n await reflow(z);\n },\n async resize(): Promise<void> {\n await ready;\n await reflow(currentZoom);\n },\n onMeasureClick(cb: (measureIndex: number) => void): () => void {\n clickListeners.push(cb);\n return () => {\n const i = clickListeners.indexOf(cb);\n if (i >= 0) clickListeners.splice(i, 1);\n };\n },\n destroy(): void {\n if (destroyed) return;\n destroyed = true;\n rebuildToken++;\n root.removeEventListener('click', onRootClick);\n follow.destroy();\n clickListeners.length = 0;\n currentLayout = null;\n currentNoteCols = undefined;\n if (root.parentNode === host) host.removeChild(root);\n },\n };\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@real-music-packages/web-core",
3
- "version": "0.38.0",
3
+ "version": "0.39.0",
4
4
  "description": "Shared music-theory + audio primitives for the music-suite web apps",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -43,6 +43,10 @@
43
43
  "types": "./dist/notationPlayerSvg.d.ts",
44
44
  "import": "./dist/notationPlayerSvg.js"
45
45
  },
46
+ "./notationPlayerVerovio": {
47
+ "types": "./dist/notationPlayerVerovio.d.ts",
48
+ "import": "./dist/notationPlayerVerovio.js"
49
+ },
46
50
  "./scene": {
47
51
  "types": "./dist/scene/index.d.ts",
48
52
  "import": "./dist/scene/index.js"
@@ -72,7 +76,8 @@
72
76
  },
73
77
  "peerDependencies": {
74
78
  "opensheetmusicdisplay": ">=1.8",
75
- "tone": ">=14"
79
+ "tone": ">=14",
80
+ "verovio": ">=6 <7"
76
81
  },
77
82
  "peerDependenciesMeta": {
78
83
  "tone": {
@@ -80,6 +85,9 @@
80
85
  },
81
86
  "opensheetmusicdisplay": {
82
87
  "optional": true
88
+ },
89
+ "verovio": {
90
+ "optional": true
83
91
  }
84
92
  },
85
93
  "devDependencies": {
@@ -92,6 +100,7 @@
92
100
  "tone": "^15.1.22",
93
101
  "tsup": "^8.3.0",
94
102
  "typescript": "^5.6.0",
103
+ "verovio": "^6.2.0",
95
104
  "vitest": "^4.1.7"
96
105
  }
97
106
  }