@real-music-packages/web-core 0.11.0 → 0.13.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.
@@ -92,8 +92,8 @@ function resolveTempo(sheet, opts) {
92
92
  return { source: "fallback", segments: [{ atMs: 0, bpm: opts.tempoFallback ?? 100 }] };
93
93
  }
94
94
  function makeWholeNoteToMs(bpm) {
95
- const msPerBeat = 6e4 / bpm;
96
- return (wholeNotes) => wholeNotes * BEATS_PER_WHOLE_NOTE * msPerBeat;
95
+ const msPerBeat2 = 6e4 / bpm;
96
+ return (wholeNotes) => wholeNotes * BEATS_PER_WHOLE_NOTE * msPerBeat2;
97
97
  }
98
98
  function inferHand(instrument, staff) {
99
99
  const staves = instrument?.Staves;
@@ -149,7 +149,7 @@ function readTimeSig(sheet) {
149
149
  async function defaultOsmd() {
150
150
  if (typeof document === "undefined") {
151
151
  throw new Error(
152
- "scoreFromMusicXML: no DOM. Call setupHeadlessDom() first (Node), or pass opts.osmdFactory."
152
+ "scoreFromMusicXML: no DOM. In Node, import setupHeadlessDom from '@real-music-packages/web-core/scene/headless' and call it first, or pass opts.osmdFactory."
153
153
  );
154
154
  }
155
155
  const mod = await import("opensheetmusicdisplay");
@@ -160,111 +160,6 @@ async function defaultOsmd() {
160
160
  });
161
161
  }
162
162
 
163
- // src/scene/headless.ts
164
- var installed = false;
165
- async function setupHeadlessDom() {
166
- if (installed) return;
167
- const g = globalThis;
168
- if (typeof g.document !== "undefined" && typeof g.window !== "undefined") {
169
- ensureFakeContext(g.window);
170
- installed = true;
171
- return;
172
- }
173
- const { JSDOM } = await import("jsdom");
174
- const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
175
- pretendToBeVisual: true
176
- });
177
- const { window } = dom;
178
- ensureFakeContext(window);
179
- g.window = window;
180
- g.document = window.document;
181
- try {
182
- g.navigator = window.navigator;
183
- } catch {
184
- }
185
- g.HTMLElement = window.HTMLElement;
186
- g.Node = window.Node;
187
- g.DOMParser = window.DOMParser;
188
- g.XMLSerializer = window.XMLSerializer;
189
- g.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0);
190
- g.cancelAnimationFrame = () => {
191
- };
192
- installed = true;
193
- }
194
- function ensureFakeContext(window) {
195
- const proto = window.HTMLCanvasElement?.prototype;
196
- if (!proto) return;
197
- const fakeCtx = makeFakeContext();
198
- proto.getContext = function() {
199
- return fakeCtx;
200
- };
201
- }
202
- function makeFakeContext() {
203
- return {
204
- font: "10px Arial",
205
- fillStyle: "#000",
206
- strokeStyle: "#000",
207
- lineWidth: 1,
208
- textAlign: "left",
209
- textBaseline: "alphabetic",
210
- globalAlpha: 1,
211
- measureText: (s) => ({
212
- width: (s ? s.length : 0) * 6,
213
- actualBoundingBoxAscent: 8,
214
- actualBoundingBoxDescent: 2
215
- }),
216
- save() {
217
- },
218
- restore() {
219
- },
220
- beginPath() {
221
- },
222
- closePath() {
223
- },
224
- moveTo() {
225
- },
226
- lineTo() {
227
- },
228
- bezierCurveTo() {
229
- },
230
- quadraticCurveTo() {
231
- },
232
- arc() {
233
- },
234
- rect() {
235
- },
236
- fill() {
237
- },
238
- stroke() {
239
- },
240
- fillRect() {
241
- },
242
- clearRect() {
243
- },
244
- fillText() {
245
- },
246
- strokeText() {
247
- },
248
- translate() {
249
- },
250
- rotate() {
251
- },
252
- scale() {
253
- },
254
- setTransform() {
255
- },
256
- transform() {
257
- },
258
- drawImage() {
259
- },
260
- clip() {
261
- },
262
- createLinearGradient: () => ({ addColorStop() {
263
- } }),
264
- getImageData: () => ({ data: new Uint8ClampedArray(4) })
265
- };
266
- }
267
-
268
163
  // src/scene/math.ts
269
164
  var clamp = (x, lo, hi) => x < lo ? lo : x > hi ? hi : x;
270
165
  var lerp = (a, b, t) => a + (b - a) * t;
@@ -1427,174 +1322,48 @@ var safeGuidesFactory = {
1427
1322
  }
1428
1323
  };
1429
1324
 
1430
- // src/scene/registry.ts
1431
- var REGISTRY = /* @__PURE__ */ new Map();
1432
- function registerLayer(factory) {
1433
- REGISTRY.set(factory.key, factory);
1434
- }
1435
- function getLayerFactory(key) {
1436
- return REGISTRY.get(key);
1437
- }
1438
- function registeredKeys() {
1439
- return [...REGISTRY.keys()];
1440
- }
1441
- registerLayer(backgroundFactory);
1442
- registerLayer(captionFactory);
1443
- registerLayer(notationFactory);
1444
- registerLayer(scrollCursorFactory);
1445
- registerLayer(keyboardFactory);
1446
- registerLayer(fallingNotesFactory);
1447
- registerLayer(hookFactory);
1448
- registerLayer(revealFactory);
1449
- registerLayer(ctaFactory);
1450
- registerLayer(portraitFactory);
1451
- registerLayer(spectrumFactory);
1452
- registerLayer(brandingFactory);
1453
- registerLayer(safeGuidesFactory);
1454
-
1455
- // src/scene/camera.ts
1456
- function cameraTransform(cam, W, H) {
1457
- const s = cam.zoom;
1458
- return [s, 0, 0, s, W / 2 - cam.cx * s, H / 2 - cam.cy * s];
1459
- }
1460
- function worldToViewport(cam, W, H, x, y) {
1461
- const [a, , , d, e, f] = cameraTransform(cam, W, H);
1462
- return { x: a * x + e, y: d * y + f };
1463
- }
1464
- function frameRect(rect, W, H, pad = 0) {
1465
- const padded = 1 + Math.max(0, pad) * 2;
1466
- const zoom = Math.min(W / (rect.w * padded), H / (rect.h * padded));
1467
- return { cx: rect.x + rect.w / 2, cy: rect.y + rect.h / 2, zoom };
1468
- }
1469
- function lerpCamera(a, b, t01, ease = easeInOut) {
1470
- const k = ease(clamp(t01, 0, 1));
1471
- return { cx: lerp(a.cx, b.cx, k), cy: lerp(a.cy, b.cy, k), zoom: lerp(a.zoom, b.zoom, k) };
1472
- }
1473
- function kenBurns(from, to, at01) {
1474
- return lerpCamera(from, to, at01, easeInOut);
1475
- }
1476
- function applyToContext(ctx, cam, W, H) {
1477
- const m = cameraTransform(cam, W, H);
1478
- ctx.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]);
1479
- }
1480
- function identityCamera(W, H) {
1481
- return { cx: W / 2, cy: H / 2, zoom: 1 };
1482
- }
1483
-
1484
- // src/scene/runner.ts
1485
- function resolveAnchor(anchor, totalSec) {
1486
- if (typeof anchor === "number") return anchor;
1487
- if (anchor === "end") return totalSec;
1488
- const m = /^end-(\d+(?:\.\d+)?)$/.exec(anchor);
1489
- if (m) return totalSec - parseFloat(m[1]);
1490
- throw new Error(`resolveAnchor: bad anchor "${anchor}"`);
1491
- }
1492
- function resolveTimeline(spec, totalSec) {
1493
- return spec.timeline.map((seg) => {
1494
- const startSec = resolveAnchor(seg.at[0], totalSec);
1495
- const endSec = resolveAnchor(seg.at[1], totalSec);
1496
- return { startMs: startSec * 1e3, endMs: endSec * 1e3, layers: seg.layers };
1497
- });
1498
- }
1499
- function visualTimelineMs(resolved) {
1500
- return resolved.reduce((mx, s) => Math.max(mx, s.endMs), 0);
1501
- }
1502
- var SCREEN_PINNED_KEYS = /* @__PURE__ */ new Set([
1503
- "caption",
1504
- "branding",
1505
- "cta",
1506
- "hook",
1507
- "reveal",
1508
- "portrait",
1509
- "safe-guides"
1510
- ]);
1511
- async function buildScene(opts) {
1512
- const { spec, theme, score } = opts;
1513
- const [W, H] = spec.size;
1514
- const fps = spec.fps ?? 30;
1515
- const resolved = resolveTimeline(spec, opts.totalSec);
1516
- const safe = safeBox(W, H);
1517
- const camera = opts.camera ?? (() => identityCamera(W, H));
1518
- const clock = { nowMs: () => 0 };
1519
- const baseCtx = {
1520
- W,
1521
- H,
1522
- score,
1523
- audioClock: clock,
1524
- theme,
1525
- safeBox: safe,
1526
- fps
1527
- };
1528
- const bound = [];
1529
- for (const seg of resolved) {
1530
- for (const sl of seg.layers) {
1531
- const factory = getLayerFactory(sl.k);
1532
- if (!factory) {
1533
- throw new Error(`buildScene: unknown layer "${sl.k}" (registered: ${registeredKeys().join(", ")})`);
1534
- }
1535
- const errs = factory.validateProps(sl.p ?? {});
1536
- if (errs.length) {
1537
- throw new Error(`buildScene: invalid props for "${sl.k}": ${errs.join("; ")}`);
1538
- }
1539
- const layer = factory.create();
1540
- await layer.init({ ...baseCtx, ctx2d: null }, sl.p ?? {});
1541
- bound.push({
1542
- layer,
1543
- startMs: seg.startMs,
1544
- endMs: seg.endMs,
1545
- screenPinned: SCREEN_PINNED_KEYS.has(sl.k)
1546
- });
1547
- }
1325
+ // src/scene/staffKeyboardRay.ts
1326
+ function staffAnchor(layout, t01) {
1327
+ const tt = Math.max(0, Math.min(1, t01));
1328
+ const cols = measureColumnsFromLayout(layout.measures);
1329
+ if (cols.length) {
1330
+ const pos = tt * cols.length;
1331
+ const i = Math.min(cols.length - 1, Math.floor(pos));
1332
+ const m = cols[i];
1333
+ const startX = Math.min(m.noteStartX, m.x + m.w);
1334
+ const x = startX + (pos - i) * (m.x + m.w - startX);
1335
+ return { x, y: m.y + m.h / 2 };
1548
1336
  }
1549
- const durationMs = visualTimelineMs(resolved);
1550
- function renderFrame(ctx2d, tMs) {
1551
- clock.nowMs = () => tMs;
1552
- const ctx = { ...baseCtx, ctx2d };
1553
- const cam = camera(tMs);
1554
- ctx2d.save();
1555
- applyToContext(ctx2d, cam, W, H);
1556
- for (const b of bound) {
1557
- if (b.screenPinned) continue;
1558
- if (tMs < b.startMs || tMs >= b.endMs) continue;
1559
- b.layer.draw(ctx, tMs);
1560
- }
1561
- ctx2d.restore();
1562
- ctx2d.save();
1563
- ctx2d.setTransform(1, 0, 0, 1, 0, 0);
1564
- for (const b of bound) {
1565
- if (!b.screenPinned) continue;
1566
- if (tMs < b.startMs || tMs >= b.endMs) continue;
1567
- b.layer.draw(ctx, tMs);
1568
- }
1569
- ctx2d.restore();
1337
+ if (layout.systems.length) {
1338
+ const pos = tt * layout.systems.length;
1339
+ const row = Math.min(layout.systems.length - 1, Math.floor(pos));
1340
+ const s = layout.systems[row];
1341
+ return { x: s.x + (pos - row) * s.w, y: s.y + s.h / 2 };
1570
1342
  }
1343
+ const r = layout.rect;
1344
+ return { x: r.dx + tt * r.dw, y: r.dy + r.dh / 2 };
1345
+ }
1346
+ function rayEndpoints(notation, keyboard, pitchMidi, t01) {
1347
+ const staff = staffAnchor(notation, t01);
1348
+ if (!staff) return null;
1571
1349
  return {
1572
- W,
1573
- H,
1574
- fps,
1575
- durationMs,
1576
- resolved,
1577
- renderFrame,
1578
- dispose() {
1579
- for (const b of bound) b.layer.dispose?.();
1580
- }
1350
+ staff,
1351
+ keyboard: { x: keyCenterX(keyboard, pitchMidi), y: keyboard.top }
1581
1352
  };
1582
1353
  }
1583
- async function recordSceneSpec(opts) {
1584
- const { built } = opts;
1585
- const record = opts.record ?? recordScenes;
1586
- const composite = {
1587
- durationMs: built.durationMs,
1588
- draw: (ctx, t01) => built.renderFrame(ctx, t01 * built.durationMs)
1354
+ function rayPointAt(ends, u, bulge = 0.18) {
1355
+ const { staff: a, keyboard: b } = ends;
1356
+ const mx = (a.x + b.x) / 2;
1357
+ const my = (a.y + b.y) / 2;
1358
+ const span = Math.abs(b.y - a.y);
1359
+ const cx = mx + bulge * span;
1360
+ const cy = my;
1361
+ const uu = u < 0 ? 0 : u > 1 ? 1 : u;
1362
+ const inv = 1 - uu;
1363
+ return {
1364
+ x: inv * inv * a.x + 2 * inv * uu * cx + uu * uu * b.x,
1365
+ y: inv * inv * a.y + 2 * inv * uu * cy + uu * uu * b.y
1589
1366
  };
1590
- return record([composite], {
1591
- audioStream: opts.audioStream,
1592
- width: built.W,
1593
- height: built.H,
1594
- fps: built.fps,
1595
- background: opts.background,
1596
- onProgress: opts.onProgress
1597
- });
1598
1367
  }
1599
1368
 
1600
1369
  // src/scene/highlight.ts
@@ -1605,12 +1374,12 @@ function highlightIntensity(region, tMs) {
1605
1374
  const rampOut = 1 - invLerp(region.outMs, region.outMs + fade, tMs);
1606
1375
  return clamp(Math.min(rampIn, rampOut), 0, 1);
1607
1376
  }
1608
- function noteSetXRange(onsetsMs, windowMs, timeToX) {
1377
+ function noteSetXRange(onsetsMs, windowMs, timeToX2) {
1609
1378
  let lo = Infinity;
1610
1379
  let hi = -Infinity;
1611
1380
  for (const on of onsetsMs) {
1612
1381
  if (on < windowMs[0] || on > windowMs[1]) continue;
1613
- const x = timeToX(on);
1382
+ const x = timeToX2(on);
1614
1383
  if (x < lo) lo = x;
1615
1384
  if (x > hi) hi = x;
1616
1385
  }
@@ -1627,32 +1396,1253 @@ function drawHighlight(ctx, region, tMs, accent) {
1627
1396
  ctx.restore();
1628
1397
  }
1629
1398
 
1630
- // src/scene/audioLayers.ts
1631
- function countInSchedule(opts) {
1632
- const beats = opts.beats ?? 4;
1633
- const accent = opts.accentDownbeat ?? true;
1634
- const beatSec = 60 / opts.bpm;
1635
- const events = [];
1636
- for (let i = 0; i < beats; i++) {
1637
- const isDownbeat = accent && i === 0;
1638
- events.push({
1639
- atSec: i * beatSec,
1640
- note: isDownbeat ? "C6" : "C5",
1641
- durSec: 0.05,
1642
- gain: isDownbeat ? 1 : 0.7,
1643
- kind: "count"
1644
- });
1399
+ // src/scene/layers/staffKeyboardRay.ts
1400
+ var DEFAULT_FADE = 180;
1401
+ var DEFAULT_WIDTH = 4;
1402
+ var DEFAULT_SEGMENTS = 24;
1403
+ function staffKeyboardRayLayer() {
1404
+ let fadeMs = DEFAULT_FADE;
1405
+ let width = DEFAULT_WIDTH;
1406
+ let color;
1407
+ let segments = DEFAULT_SEGMENTS;
1408
+ return {
1409
+ key: "staff-keyboard-ray",
1410
+ init(_ctx, props) {
1411
+ fadeMs = props.fadeMs ?? DEFAULT_FADE;
1412
+ width = props.width ?? DEFAULT_WIDTH;
1413
+ color = props.color;
1414
+ segments = props.segments ?? DEFAULT_SEGMENTS;
1415
+ },
1416
+ draw(ctx, tMs) {
1417
+ const eng = getNotationEngraving(ctx);
1418
+ const kbd = getKeyboardLayout(ctx);
1419
+ if (!eng || !kbd) return;
1420
+ const layout = eng.followLayoutAt ? eng.followLayoutAt(ctx, tMs) : eng.base;
1421
+ const dur = ctx.score?.durationMs ?? 0;
1422
+ const t01 = dur > 0 ? tMs / dur : 0;
1423
+ const c = ctx.ctx2d;
1424
+ const accent = color ?? ctx.theme.accent;
1425
+ for (const n of ctx.score?.notes ?? []) {
1426
+ if (!(tMs >= n.onsetMs && tMs < n.onsetMs + n.durMs)) continue;
1427
+ if (!inRange(kbd, n.pitchMidi)) continue;
1428
+ const ends = rayEndpoints(layout, kbd, n.pitchMidi, t01);
1429
+ if (!ends) continue;
1430
+ if (!Number.isFinite(ends.staff.x) || !Number.isFinite(ends.keyboard.x)) continue;
1431
+ const alpha = highlightIntensity(
1432
+ { x: 0, y: 0, w: 0, h: 0, inMs: n.onsetMs, outMs: n.onsetMs + n.durMs, fadeMs },
1433
+ tMs
1434
+ );
1435
+ if (alpha <= 0) continue;
1436
+ const grow = Math.min(1, fadeMs > 0 ? (tMs - n.onsetMs) / fadeMs : 1);
1437
+ c.save();
1438
+ c.globalAlpha = alpha;
1439
+ c.strokeStyle = accent;
1440
+ c.lineWidth = width;
1441
+ c.beginPath();
1442
+ const steps = Math.max(2, segments);
1443
+ for (let i = 0; i <= steps; i++) {
1444
+ const u = i / steps * Math.max(0, grow);
1445
+ const p = rayPointAt(ends, u);
1446
+ if (i === 0) c.moveTo(p.x, p.y);
1447
+ else c.lineTo(p.x, p.y);
1448
+ }
1449
+ c.stroke();
1450
+ const head = rayPointAt(ends, Math.max(0, grow));
1451
+ c.fillStyle = accent;
1452
+ c.beginPath();
1453
+ c.arc(ends.staff.x, ends.staff.y, width * 1.1, 0, Math.PI * 2);
1454
+ c.fill();
1455
+ c.beginPath();
1456
+ c.arc(head.x, head.y, width * 1.1, 0, Math.PI * 2);
1457
+ c.fill();
1458
+ c.restore();
1459
+ }
1460
+ }
1461
+ };
1462
+ }
1463
+ var staffKeyboardRayFactory = {
1464
+ key: "staff-keyboard-ray",
1465
+ create: staffKeyboardRayLayer,
1466
+ validateProps(props) {
1467
+ const errs = [];
1468
+ if (props == null || typeof props !== "object") return ["staff-keyboard-ray: props must be an object"];
1469
+ const p = props;
1470
+ if (p.notationKey != null && typeof p.notationKey !== "string")
1471
+ errs.push("staff-keyboard-ray.notationKey must be a string");
1472
+ if (p.keyboardKey != null && typeof p.keyboardKey !== "string")
1473
+ errs.push("staff-keyboard-ray.keyboardKey must be a string");
1474
+ if (p.fadeMs != null && (typeof p.fadeMs !== "number" || p.fadeMs < 0))
1475
+ errs.push("staff-keyboard-ray.fadeMs must be a non-negative number");
1476
+ if (p.width != null && (typeof p.width !== "number" || p.width <= 0))
1477
+ errs.push("staff-keyboard-ray.width must be a positive number");
1478
+ if (p.color != null && typeof p.color !== "string")
1479
+ errs.push("staff-keyboard-ray.color must be a string");
1480
+ if (p.segments != null && (typeof p.segments !== "number" || p.segments < 2))
1481
+ errs.push("staff-keyboard-ray.segments must be a number \u2265 2");
1482
+ return errs;
1645
1483
  }
1646
- return events;
1484
+ };
1485
+
1486
+ // src/scene/countingGrid.ts
1487
+ var SUB4 = ["", "e", "&", "a"];
1488
+ function msPerBeat(bpm, beatUnit) {
1489
+ return 6e4 / bpm * (4 / beatUnit);
1490
+ }
1491
+ function beatGrid(opts) {
1492
+ const beatsPerBar = opts.beatsPerBar ?? 4;
1493
+ const beatUnit = opts.beatUnit ?? 4;
1494
+ const subdiv = opts.subdiv ?? 1;
1495
+ const start = opts.startMs ?? 0;
1496
+ const mpb = msPerBeat(opts.bpm, beatUnit);
1497
+ const subMs = mpb / subdiv;
1498
+ const ticks = [];
1499
+ let beatIndex = 0;
1500
+ for (let t = start; t <= start + opts.durationMs + 1e-6; t += mpb) {
1501
+ const beatInBar = beatIndex % beatsPerBar + 1;
1502
+ for (let s = 0; s < subdiv; s++) {
1503
+ const tMs = t + s * subMs;
1504
+ ticks.push({
1505
+ tMs,
1506
+ index: beatIndex,
1507
+ beatInBar,
1508
+ sub: s,
1509
+ syllable: s === 0 ? String(beatInBar) : SUB4[s * (4 / subdiv) | 0] || "\xB7",
1510
+ downbeat: beatInBar === 1 && s === 0
1511
+ });
1512
+ }
1513
+ beatIndex++;
1514
+ }
1515
+ return ticks;
1516
+ }
1517
+ function bpmOf(tempoMap, fallback = 100) {
1518
+ return tempoMap?.segments?.[0]?.bpm ?? fallback;
1519
+ }
1520
+ function beatPhase(bpm, beatUnit, tMs, startMs = 0) {
1521
+ const mpb = msPerBeat(bpm, beatUnit);
1522
+ const rel = tMs - startMs;
1523
+ if (rel < 0) return 0;
1524
+ const p = rel % mpb / mpb;
1525
+ return p < 0 ? p + 1 : p;
1526
+ }
1527
+ function ballArc(phase01) {
1528
+ const p = phase01 < 0 ? 0 : phase01 > 1 ? 1 : phase01;
1529
+ return 4 * p * (1 - p);
1530
+ }
1531
+ function ballX(ticks, tMs, xOf) {
1532
+ if (!ticks.length) return 0;
1533
+ const beats = ticks.filter((t) => t.sub === 0);
1534
+ if (tMs <= beats[0].tMs) return xOf(beats[0]);
1535
+ for (let i = 0; i < beats.length - 1; i++) {
1536
+ const a = beats[i];
1537
+ const b = beats[i + 1];
1538
+ if (tMs >= a.tMs && tMs < b.tMs) {
1539
+ const f = (tMs - a.tMs) / (b.tMs - a.tMs);
1540
+ return xOf(a) + (xOf(b) - xOf(a)) * f;
1541
+ }
1542
+ }
1543
+ return xOf(beats[beats.length - 1]);
1647
1544
  }
1648
- function countInLeadSec(opts) {
1649
- return (opts.beats ?? 4) * (60 / opts.bpm);
1545
+ function parseTimeSig(ts) {
1546
+ if (!ts) return [4, 4];
1547
+ const m = /^(\d+)\s*\/\s*(\d+)$/.exec(ts.trim());
1548
+ if (!m) return [4, 4];
1549
+ return [parseInt(m[1], 10), parseInt(m[2], 10)];
1650
1550
  }
1651
- function clickTrackSchedule(opts) {
1652
- const beatSec = 60 / opts.bpm;
1653
- const bpb = opts.beatsPerBar ?? 4;
1654
- const start = opts.startSec ?? 0;
1655
- const events = [];
1551
+
1552
+ // src/scene/layers/countingTrack.ts
1553
+ var DEFAULT_BOUNCE = 110;
1554
+ var DEFAULT_SIZE = 34;
1555
+ var DEFAULT_BALL = 22;
1556
+ function countingTrackLayer() {
1557
+ let subdiv = 1;
1558
+ let bpm = 100;
1559
+ let beatUnit = 4;
1560
+ let beatsPerBar = 4;
1561
+ let trackYProp;
1562
+ let bounce = DEFAULT_BOUNCE;
1563
+ let size = DEFAULT_SIZE;
1564
+ let ballRadius = DEFAULT_BALL;
1565
+ let startMs = 0;
1566
+ let legend = [];
1567
+ let left = 0;
1568
+ let width = 0;
1569
+ function xOfTick(t) {
1570
+ const idx = (t.beatInBar - 1) * subdiv + t.sub;
1571
+ const slots = beatsPerBar * subdiv;
1572
+ return left + (idx + 0.5) / slots * width;
1573
+ }
1574
+ return {
1575
+ key: "counting-track",
1576
+ init(ctx, props) {
1577
+ subdiv = props.subdiv ?? 1;
1578
+ const [num, den] = parseTimeSig(props.timeSig ?? ctx.score?.timeSig);
1579
+ beatsPerBar = num;
1580
+ beatUnit = den;
1581
+ bpm = props.bpm ?? bpmOf(ctx.score?.tempoMap);
1582
+ trackYProp = props.trackY;
1583
+ bounce = props.bounce ?? DEFAULT_BOUNCE;
1584
+ size = props.size ?? DEFAULT_SIZE;
1585
+ ballRadius = props.ballRadius ?? DEFAULT_BALL;
1586
+ startMs = props.startMs ?? 0;
1587
+ const sb = ctx.safeBox;
1588
+ left = sb.left;
1589
+ width = sb.w;
1590
+ legend = [];
1591
+ for (let b = 0; b < beatsPerBar; b++) {
1592
+ for (let s = 0; s < subdiv; s++) {
1593
+ legend.push({
1594
+ tMs: 0,
1595
+ index: b,
1596
+ beatInBar: b + 1,
1597
+ sub: s,
1598
+ syllable: s === 0 ? String(b + 1) : ["", "e", "&", "a"][s * (4 / subdiv) | 0] || "\xB7",
1599
+ downbeat: b === 0 && s === 0
1600
+ });
1601
+ }
1602
+ }
1603
+ },
1604
+ draw(ctx, tMs) {
1605
+ const c = ctx.ctx2d;
1606
+ const sb = ctx.safeBox;
1607
+ const trackY = trackYProp ?? sb.bottom - 120;
1608
+ c.save();
1609
+ c.strokeStyle = ctx.theme.sepia;
1610
+ c.lineWidth = 2;
1611
+ c.globalAlpha = 0.5;
1612
+ c.beginPath();
1613
+ c.moveTo(left, trackY);
1614
+ c.lineTo(left + width, trackY);
1615
+ c.stroke();
1616
+ c.globalAlpha = 1;
1617
+ const phase = beatPhase(bpm, beatUnit, tMs, startMs);
1618
+ const mpb = 6e4 / bpm * (4 / beatUnit);
1619
+ const rel = Math.max(0, tMs - startMs);
1620
+ const beatIdx = Math.floor(rel / mpb);
1621
+ const activeBeatInBar = beatIdx % beatsPerBar;
1622
+ const activeSub = Math.floor(phase * subdiv) % subdiv;
1623
+ const activeSlot = activeBeatInBar * subdiv + activeSub;
1624
+ c.textAlign = "center";
1625
+ c.textBaseline = "middle";
1626
+ for (const t of legend) {
1627
+ const x = xOfTick(t);
1628
+ const slot = (t.beatInBar - 1) * subdiv + t.sub;
1629
+ const active = slot === activeSlot;
1630
+ c.font = `${t.sub === 0 ? "700" : "400"} ${t.sub === 0 ? size : size * 0.8}px ${ctx.theme.fontBody}`;
1631
+ c.fillStyle = active ? ctx.theme.accent : ctx.theme.ink;
1632
+ c.globalAlpha = active ? 1 : t.sub === 0 ? 0.85 : 0.55;
1633
+ c.fillText(t.syllable, x, trackY + size * 0.95);
1634
+ c.globalAlpha = active ? 0.9 : 0.4;
1635
+ c.fillRect(x - 1, trackY - (t.sub === 0 ? 10 : 6), 2, t.sub === 0 ? 10 : 6);
1636
+ }
1637
+ c.globalAlpha = 1;
1638
+ const beatPosInBar = beatIdx % beatsPerBar + phase;
1639
+ const ballSlot = beatPosInBar * subdiv;
1640
+ const slots = beatsPerBar * subdiv;
1641
+ const bx = left + Math.min(slots, ballSlot + 0.5) / slots * width;
1642
+ const arc = ballArc(phase);
1643
+ const by = trackY - ballRadius - arc * bounce;
1644
+ c.fillStyle = ctx.theme.accent;
1645
+ c.beginPath();
1646
+ c.arc(bx, by, ballRadius, 0, Math.PI * 2);
1647
+ c.fill();
1648
+ c.globalAlpha = 0.2 * (1 - arc);
1649
+ c.fillStyle = ctx.theme.ink;
1650
+ c.beginPath();
1651
+ c.arc(bx, trackY, ballRadius * 0.8, 0, Math.PI * 2);
1652
+ c.fill();
1653
+ c.restore();
1654
+ }
1655
+ };
1656
+ }
1657
+ var countingTrackFactory = {
1658
+ key: "counting-track",
1659
+ create: countingTrackLayer,
1660
+ validateProps(props) {
1661
+ const errs = [];
1662
+ if (props == null || typeof props !== "object") return ["counting-track: props must be an object"];
1663
+ const p = props;
1664
+ if (p.subdiv != null && p.subdiv !== 1 && p.subdiv !== 2 && p.subdiv !== 4)
1665
+ errs.push("counting-track.subdiv must be 1 | 2 | 4");
1666
+ if (p.timeSig != null && (typeof p.timeSig !== "string" || !/^\d+\s*\/\s*\d+$/.test(p.timeSig)))
1667
+ errs.push('counting-track.timeSig must be "n/d"');
1668
+ if (p.bpm != null && (typeof p.bpm !== "number" || p.bpm <= 0))
1669
+ errs.push("counting-track.bpm must be a positive number");
1670
+ if (p.trackY != null && typeof p.trackY !== "number")
1671
+ errs.push("counting-track.trackY must be a number");
1672
+ if (p.bounce != null && (typeof p.bounce !== "number" || p.bounce < 0))
1673
+ errs.push("counting-track.bounce must be a non-negative number");
1674
+ if (p.size != null && (typeof p.size !== "number" || p.size <= 0))
1675
+ errs.push("counting-track.size must be a positive number");
1676
+ if (p.ballRadius != null && (typeof p.ballRadius !== "number" || p.ballRadius <= 0))
1677
+ errs.push("counting-track.ballRadius must be a positive number");
1678
+ if (p.startMs != null && typeof p.startMs !== "number")
1679
+ errs.push("counting-track.startMs must be a number");
1680
+ return errs;
1681
+ }
1682
+ };
1683
+
1684
+ // src/scene/degreeLabels.ts
1685
+ var LETTERS = ["C", "D", "E", "F", "G", "A", "B"];
1686
+ var LETTER_PC = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
1687
+ var MAJOR_OFFSETS = [0, 2, 4, 5, 7, 9, 11];
1688
+ var DIATONIC_SOLFEGE = ["do", "re", "mi", "fa", "sol", "la", "ti"];
1689
+ var RAISED_SOLFEGE = { 1: "di", 2: "ri", 4: "fi", 5: "si", 6: "li" };
1690
+ var LOWERED_SOLFEGE = { 2: "ra", 3: "me", 5: "se", 6: "le", 7: "te" };
1691
+ function parseKey(key) {
1692
+ if (!key) return { tonicLetter: "C", tonicPc: 0, minor: false };
1693
+ const m = /^([A-Ga-g])([#b]*)\s*(major|minor|maj|min|m)?/.exec(key.trim());
1694
+ if (!m) return { tonicLetter: "C", tonicPc: 0, minor: false };
1695
+ const letter = m[1].toUpperCase();
1696
+ let pc = LETTER_PC[letter] ?? 0;
1697
+ for (const ch of m[2]) pc += ch === "#" ? 1 : -1;
1698
+ const modeTok = (m[3] ?? "").toLowerCase();
1699
+ const minor = modeTok === "minor" || modeTok === "min" || modeTok === "m";
1700
+ return { tonicLetter: letter, tonicPc: (pc % 12 + 12) % 12, minor };
1701
+ }
1702
+ function degreeLabel(step, alter, pitchMidi, key, mode) {
1703
+ const { tonicLetter } = parseKey(key);
1704
+ const noteLetterIdx = LETTERS.indexOf(step.toUpperCase());
1705
+ const tonicLetterIdx = LETTERS.indexOf(tonicLetter);
1706
+ const degIdx = noteLetterIdx < 0 || tonicLetterIdx < 0 ? 0 : ((noteLetterIdx - tonicLetterIdx) % 7 + 7) % 7;
1707
+ const degree = degIdx + 1;
1708
+ const { tonicPc } = parseKey(key);
1709
+ const diatonicPc = (tonicPc + MAJOR_OFFSETS[degIdx]) % 12;
1710
+ const letterPc = LETTER_PC[step.toUpperCase()];
1711
+ const actualPc = letterPc != null ? ((letterPc + alter) % 12 + 12) % 12 : (pitchMidi % 12 + 12) % 12;
1712
+ let chrom = actualPc - diatonicPc;
1713
+ if (chrom > 6) chrom -= 12;
1714
+ if (chrom < -6) chrom += 12;
1715
+ const text = mode === "solfege" ? solfegeText(degree, chrom) : degreeText(degree, chrom);
1716
+ return { degree, alter: chrom, text };
1717
+ }
1718
+ function degreeText(degree, chrom) {
1719
+ const acc = chrom > 0 ? "\u266F".repeat(chrom) : chrom < 0 ? "\u266D".repeat(-chrom) : "";
1720
+ return `${acc}${degree}`;
1721
+ }
1722
+ function solfegeText(degree, chrom) {
1723
+ if (chrom === 0) return DIATONIC_SOLFEGE[degree - 1] ?? "?";
1724
+ if (chrom > 0) return RAISED_SOLFEGE[degree] ?? `${DIATONIC_SOLFEGE[degree - 1]}\u266F`;
1725
+ return LOWERED_SOLFEGE[degree] ?? `${DIATONIC_SOLFEGE[degree - 1]}\u266D`;
1726
+ }
1727
+
1728
+ // src/scene/layers/degreeLabels.ts
1729
+ var DEFAULT_SIZE2 = 30;
1730
+ function degreeLabelsLayer() {
1731
+ let mode = "degree";
1732
+ let keyOverride;
1733
+ let size = DEFAULT_SIZE2;
1734
+ let fadeMs = 120;
1735
+ let color;
1736
+ return {
1737
+ key: "degree-labels",
1738
+ init(_ctx, props) {
1739
+ mode = props.mode ?? "degree";
1740
+ keyOverride = props.key;
1741
+ size = props.size ?? DEFAULT_SIZE2;
1742
+ fadeMs = props.fadeMs ?? 120;
1743
+ color = props.color;
1744
+ },
1745
+ draw(ctx, tMs) {
1746
+ const layout = getKeyboardLayout(ctx);
1747
+ if (!layout) return;
1748
+ const c = ctx.ctx2d;
1749
+ const key = keyOverride ?? ctx.score?.key;
1750
+ const ink = color ?? ctx.theme.ink;
1751
+ for (const n of ctx.score?.notes ?? []) {
1752
+ if (!inRange(layout, n.pitchMidi)) continue;
1753
+ const a = highlightIntensity(
1754
+ { x: 0, y: 0, w: 0, h: 0, inMs: n.onsetMs, outMs: n.onsetMs + n.durMs, fadeMs },
1755
+ tMs
1756
+ );
1757
+ if (a <= 0) continue;
1758
+ const label = degreeLabel(n.step, n.alter, n.pitchMidi, key, mode);
1759
+ const cx = keyCenterX(layout, n.pitchMidi);
1760
+ const cy = layout.top - (isBlackKey(n.pitchMidi) ? size * 1.6 : size * 0.6);
1761
+ c.save();
1762
+ c.globalAlpha = a;
1763
+ c.textAlign = "center";
1764
+ c.textBaseline = "middle";
1765
+ c.font = `700 ${size}px ${ctx.theme.fontBody}`;
1766
+ const w = c.measureText(label.text).width;
1767
+ const padX = size * 0.35;
1768
+ const padY = size * 0.22;
1769
+ c.fillStyle = ctx.theme.paper;
1770
+ c.globalAlpha = a * 0.92;
1771
+ roundRect(c, cx - w / 2 - padX, cy - size / 2 - padY, w + 2 * padX, size + 2 * padY, size * 0.3);
1772
+ c.fill();
1773
+ c.globalAlpha = a;
1774
+ c.fillStyle = ink;
1775
+ c.fillText(label.text, cx, cy);
1776
+ c.restore();
1777
+ }
1778
+ }
1779
+ };
1780
+ }
1781
+ function roundRect(c, x, y, w, h, r) {
1782
+ if (typeof c.roundRect === "function") {
1783
+ c.beginPath();
1784
+ c.roundRect(x, y, w, h, r);
1785
+ return;
1786
+ }
1787
+ c.beginPath();
1788
+ c.moveTo(x + r, y);
1789
+ c.lineTo(x + w - r, y);
1790
+ c.lineTo(x + w, y + h);
1791
+ c.lineTo(x, y + h);
1792
+ c.closePath();
1793
+ }
1794
+ var degreeLabelsFactory = {
1795
+ key: "degree-labels",
1796
+ create: degreeLabelsLayer,
1797
+ validateProps(props) {
1798
+ const errs = [];
1799
+ if (props == null || typeof props !== "object") return ["degree-labels: props must be an object"];
1800
+ const p = props;
1801
+ if (p.mode != null && p.mode !== "degree" && p.mode !== "solfege")
1802
+ errs.push('degree-labels.mode must be "degree" | "solfege"');
1803
+ if (p.key != null && typeof p.key !== "string")
1804
+ errs.push("degree-labels.key must be a string");
1805
+ if (p.size != null && (typeof p.size !== "number" || p.size <= 0))
1806
+ errs.push("degree-labels.size must be a positive number");
1807
+ if (p.fadeMs != null && (typeof p.fadeMs !== "number" || p.fadeMs < 0))
1808
+ errs.push("degree-labels.fadeMs must be a non-negative number");
1809
+ if (p.color != null && typeof p.color !== "string")
1810
+ errs.push("degree-labels.color must be a string");
1811
+ return errs;
1812
+ }
1813
+ };
1814
+
1815
+ // src/scene/harmonyTrack.ts
1816
+ var DEFAULT_FUNCTION_COLORS = {
1817
+ T: "#3a7d44",
1818
+ // grounded green
1819
+ S: "#3a6ea5",
1820
+ // calm blue
1821
+ D: "#c2502f",
1822
+ // tense orange-red
1823
+ other: "#7a7a7a"
1824
+ };
1825
+ function activeChord(track, tMs) {
1826
+ for (const s of track) {
1827
+ if (tMs >= s.startMs && tMs < s.endMs) return s;
1828
+ }
1829
+ return null;
1830
+ }
1831
+ function functionColor(fn, colors) {
1832
+ return colors[fn] ?? colors.other;
1833
+ }
1834
+ function validateChordTrack(track) {
1835
+ if (!Array.isArray(track)) return ["chordTrack must be an array of chord spans"];
1836
+ const errs = [];
1837
+ track.forEach((raw, i) => {
1838
+ const s = raw;
1839
+ if (typeof s?.startMs !== "number" || typeof s?.endMs !== "number")
1840
+ errs.push(`chordTrack[${i}] needs numeric startMs/endMs`);
1841
+ else if (s.endMs <= s.startMs) errs.push(`chordTrack[${i}].endMs must be > startMs`);
1842
+ if (s?.fn !== "T" && s?.fn !== "S" && s?.fn !== "D" && s?.fn !== "other")
1843
+ errs.push(`chordTrack[${i}].fn must be "T"|"S"|"D"|"other"`);
1844
+ if (s?.label != null && typeof s.label !== "string")
1845
+ errs.push(`chordTrack[${i}].label must be a string`);
1846
+ });
1847
+ return errs;
1848
+ }
1849
+
1850
+ // src/scene/layers/functionalHarmony.ts
1851
+ var DEFAULT_BAND_H = 64;
1852
+ var DEFAULT_FADE2 = 200;
1853
+ function functionalHarmonyLayer() {
1854
+ let track = [];
1855
+ let modes = ["band"];
1856
+ let colors = DEFAULT_FUNCTION_COLORS;
1857
+ let bandHeight = DEFAULT_BAND_H;
1858
+ let bandTopProp;
1859
+ let fadeMs = DEFAULT_FADE2;
1860
+ let washAlpha = 0.12;
1861
+ return {
1862
+ key: "functional-harmony",
1863
+ init(_ctx, props) {
1864
+ track = props.chordTrack ?? [];
1865
+ modes = props.modes ?? ["band"];
1866
+ colors = props.colors ?? DEFAULT_FUNCTION_COLORS;
1867
+ bandHeight = props.bandHeight ?? DEFAULT_BAND_H;
1868
+ bandTopProp = props.bandTop;
1869
+ fadeMs = props.fadeMs ?? DEFAULT_FADE2;
1870
+ washAlpha = props.washAlpha ?? 0.12;
1871
+ },
1872
+ draw(ctx, tMs) {
1873
+ const span = activeChord(track, tMs);
1874
+ if (!span) return;
1875
+ const c = ctx.ctx2d;
1876
+ const sb = ctx.safeBox;
1877
+ const col = functionColor(span.fn, colors);
1878
+ const a = highlightIntensity(
1879
+ { x: 0, y: 0, w: 0, h: 0, inMs: span.startMs, outMs: span.endMs, fadeMs },
1880
+ tMs
1881
+ );
1882
+ if (a <= 0) return;
1883
+ if (modes.includes("wash")) {
1884
+ c.save();
1885
+ c.globalAlpha = a * washAlpha;
1886
+ c.fillStyle = col;
1887
+ c.fillRect(0, 0, ctx.W, ctx.H);
1888
+ c.restore();
1889
+ }
1890
+ if (modes.includes("keys")) {
1891
+ const layout = getKeyboardLayout(ctx);
1892
+ if (layout) {
1893
+ c.save();
1894
+ for (const n of ctx.score?.notes ?? []) {
1895
+ if (!(tMs >= n.onsetMs && tMs < n.onsetMs + n.durMs)) continue;
1896
+ if (!inRange(layout, n.pitchMidi)) continue;
1897
+ const r = keyRect(layout, n.pitchMidi);
1898
+ c.globalAlpha = a * 0.8;
1899
+ c.fillStyle = col;
1900
+ c.fillRect(r.x, r.y, r.w, r.h);
1901
+ }
1902
+ c.restore();
1903
+ }
1904
+ }
1905
+ if (modes.includes("band")) {
1906
+ const top = bandTopProp ?? sb.top;
1907
+ c.save();
1908
+ c.globalAlpha = a * 0.85;
1909
+ c.fillStyle = col;
1910
+ c.fillRect(sb.left, top, sb.w, bandHeight);
1911
+ if (span.label) {
1912
+ c.globalAlpha = a;
1913
+ c.fillStyle = "#ffffff";
1914
+ c.textAlign = "center";
1915
+ c.textBaseline = "middle";
1916
+ c.font = `700 ${Math.round(bandHeight * 0.5)}px ${ctx.theme.fontDisplay}`;
1917
+ c.fillText(span.label, sb.left + sb.w / 2, top + bandHeight / 2);
1918
+ }
1919
+ c.restore();
1920
+ }
1921
+ }
1922
+ };
1923
+ }
1924
+ var functionalHarmonyFactory = {
1925
+ key: "functional-harmony",
1926
+ create: functionalHarmonyLayer,
1927
+ validateProps(props) {
1928
+ const errs = [];
1929
+ if (props == null || typeof props !== "object") return ["functional-harmony: props must be an object"];
1930
+ const p = props;
1931
+ errs.push(...validateChordTrack(p.chordTrack).map((e) => `functional-harmony.${e}`));
1932
+ if (p.modes != null) {
1933
+ if (!Array.isArray(p.modes)) errs.push("functional-harmony.modes must be an array");
1934
+ else for (const m of p.modes)
1935
+ if (m !== "band" && m !== "keys" && m !== "wash")
1936
+ errs.push('functional-harmony.modes items must be "band"|"keys"|"wash"');
1937
+ }
1938
+ if (p.bandHeight != null && (typeof p.bandHeight !== "number" || p.bandHeight <= 0))
1939
+ errs.push("functional-harmony.bandHeight must be a positive number");
1940
+ if (p.bandTop != null && typeof p.bandTop !== "number")
1941
+ errs.push("functional-harmony.bandTop must be a number");
1942
+ if (p.fadeMs != null && (typeof p.fadeMs !== "number" || p.fadeMs < 0))
1943
+ errs.push("functional-harmony.fadeMs must be a non-negative number");
1944
+ if (p.washAlpha != null && (typeof p.washAlpha !== "number" || p.washAlpha < 0 || p.washAlpha > 1))
1945
+ errs.push("functional-harmony.washAlpha must be in [0,1]");
1946
+ return errs;
1947
+ }
1948
+ };
1949
+
1950
+ // src/scene/quizCard.ts
1951
+ function quizPhase(quiz, tMs) {
1952
+ const ask = quiz.askMs ?? 0;
1953
+ if (tMs < ask) return "before";
1954
+ if (tMs < quiz.revealMs) return "question";
1955
+ if (tMs < quiz.endMs) return "reveal";
1956
+ return "after";
1957
+ }
1958
+ function countdownRemaining(quiz, tMs) {
1959
+ const ask = quiz.askMs ?? 0;
1960
+ const span = quiz.revealMs - ask;
1961
+ if (span <= 0) return 0;
1962
+ const elapsed = (tMs - ask) / span;
1963
+ return elapsed <= 0 ? 1 : elapsed >= 1 ? 0 : 1 - elapsed;
1964
+ }
1965
+ function countdownSeconds(quiz, tMs) {
1966
+ const ask = quiz.askMs ?? 0;
1967
+ const remMs = Math.max(0, quiz.revealMs - Math.max(ask, tMs));
1968
+ return Math.ceil(remMs / 1e3);
1969
+ }
1970
+ function revealProgress(quiz, tMs, windowMs = 350) {
1971
+ if (tMs < quiz.revealMs) return 0;
1972
+ if (windowMs <= 0) return 1;
1973
+ const p = (tMs - quiz.revealMs) / windowMs;
1974
+ return p >= 1 ? 1 : p;
1975
+ }
1976
+ function validateQuiz(quiz) {
1977
+ if (quiz == null || typeof quiz !== "object") return ["quiz must be an object"];
1978
+ const q = quiz;
1979
+ const errs = [];
1980
+ if (typeof q.question !== "string" || q.question.length === 0)
1981
+ errs.push("quiz.question must be a non-empty string");
1982
+ if (!Array.isArray(q.options) || q.options.length < 2 || q.options.length > 4)
1983
+ errs.push("quiz.options must be an array of 2\u20134 options");
1984
+ else {
1985
+ q.options.forEach((o, i) => {
1986
+ const opt = o;
1987
+ if (opt == null || typeof opt.text !== "string" || opt.text.length === 0)
1988
+ errs.push(`quiz.options[${i}].text must be a non-empty string`);
1989
+ });
1990
+ }
1991
+ const nOpts = Array.isArray(q.options) ? q.options.length : 0;
1992
+ if (typeof q.correctIndex !== "number" || !Number.isInteger(q.correctIndex) || q.correctIndex < 0 || nOpts > 0 && q.correctIndex >= nOpts)
1993
+ errs.push("quiz.correctIndex must be an integer index into options");
1994
+ if (typeof q.revealMs !== "number") errs.push("quiz.revealMs must be a number (ms)");
1995
+ if (typeof q.endMs !== "number") errs.push("quiz.endMs must be a number (ms)");
1996
+ if (typeof q.revealMs === "number" && typeof q.endMs === "number" && q.endMs <= q.revealMs)
1997
+ errs.push("quiz.endMs must be > revealMs");
1998
+ if (q.askMs != null && typeof q.askMs !== "number") errs.push("quiz.askMs must be a number (ms)");
1999
+ if (typeof q.askMs === "number" && typeof q.revealMs === "number" && q.revealMs <= q.askMs)
2000
+ errs.push("quiz.revealMs must be > askMs");
2001
+ if (q.poll != null) {
2002
+ if (!Array.isArray(q.poll) || nOpts > 0 && q.poll.length !== nOpts)
2003
+ errs.push("quiz.poll, if given, must be an array matching options length");
2004
+ else if (!q.poll.every((p) => typeof p === "number" && p >= 0 && p <= 100))
2005
+ errs.push("quiz.poll entries must be numbers in [0,100]");
2006
+ }
2007
+ return errs;
2008
+ }
2009
+
2010
+ // src/scene/layers/mcqCard.ts
2011
+ var CARD_RADIUS = 28;
2012
+ function roundRect2(c, x, y, w, h, r) {
2013
+ const rr = Math.min(r, w / 2, h / 2);
2014
+ const anyC = c;
2015
+ if (typeof anyC.roundRect === "function") {
2016
+ c.beginPath();
2017
+ anyC.roundRect(x, y, w, h, rr);
2018
+ return;
2019
+ }
2020
+ c.beginPath();
2021
+ c.moveTo(x + rr, y);
2022
+ c.lineTo(x + w - rr, y);
2023
+ c.lineTo(x + w, y + rr);
2024
+ c.lineTo(x + w, y + h - rr);
2025
+ c.lineTo(x + w - rr, y + h);
2026
+ c.lineTo(x + rr, y + h);
2027
+ c.lineTo(x, y + h - rr);
2028
+ c.lineTo(x, y + rr);
2029
+ c.closePath();
2030
+ }
2031
+ function mcqCardLayer() {
2032
+ let quiz = { question: "", options: [], correctIndex: 0, revealMs: 0, endMs: 0 };
2033
+ let correctColor;
2034
+ let showPoll = true;
2035
+ return {
2036
+ key: "mcq-card",
2037
+ init(_ctx, props) {
2038
+ quiz = props.quiz;
2039
+ correctColor = props.correctColor;
2040
+ showPoll = props.showPoll ?? true;
2041
+ },
2042
+ draw(ctx, tMs) {
2043
+ const phase = quizPhase(quiz, tMs);
2044
+ if (phase === "before" || phase === "after") return;
2045
+ const c = ctx.ctx2d;
2046
+ const sb = ctx.safeBox;
2047
+ const accent = correctColor ?? ctx.theme.accent;
2048
+ const revealed = phase === "reveal";
2049
+ const revP = easeOut(revealProgress(quiz, tMs));
2050
+ const cardW = sb.w;
2051
+ const n = quiz.options.length;
2052
+ const optH = Math.min(120, sb.h * 0.5 / Math.max(1, n));
2053
+ const gap = optH * 0.22;
2054
+ const headH = optH * 1.3;
2055
+ const cardH = headH + n * (optH + gap);
2056
+ const cardX = sb.left;
2057
+ const cardY = sb.top + (sb.h - cardH) / 2;
2058
+ c.save();
2059
+ c.fillStyle = ctx.theme.ink;
2060
+ c.textAlign = "center";
2061
+ c.textBaseline = "middle";
2062
+ c.font = `700 ${Math.round(headH * 0.42)}px ${ctx.theme.fontDisplay}`;
2063
+ c.fillText(quiz.question, cardX + cardW / 2, cardY + headH * 0.45);
2064
+ if (!revealed) {
2065
+ const rem = countdownRemaining(quiz, tMs);
2066
+ const secs = countdownSeconds(quiz, tMs);
2067
+ const cr = headH * 0.34;
2068
+ const ccx = cardX + cardW - cr - 8;
2069
+ const ccy = cardY + headH * 0.45;
2070
+ c.lineWidth = Math.max(3, cr * 0.16);
2071
+ c.strokeStyle = ctx.theme.sepia;
2072
+ c.globalAlpha = 0.3;
2073
+ c.beginPath();
2074
+ c.arc(ccx, ccy, cr, 0, Math.PI * 2);
2075
+ c.stroke();
2076
+ c.globalAlpha = 1;
2077
+ c.strokeStyle = accent;
2078
+ c.beginPath();
2079
+ c.arc(ccx, ccy, cr, -Math.PI / 2, -Math.PI / 2 + rem * Math.PI * 2);
2080
+ c.stroke();
2081
+ c.fillStyle = ctx.theme.ink;
2082
+ c.font = `700 ${Math.round(cr * 0.9)}px ${ctx.theme.fontBody}`;
2083
+ c.fillText(String(secs), ccx, ccy);
2084
+ }
2085
+ let oy = cardY + headH;
2086
+ quiz.options.forEach((opt, i) => {
2087
+ const isCorrect = i === quiz.correctIndex;
2088
+ const x = cardX;
2089
+ const y = oy;
2090
+ let bg = ctx.theme.paper;
2091
+ let alpha = 1;
2092
+ if (revealed) {
2093
+ if (isCorrect) bg = accent;
2094
+ else alpha = 1 - 0.55 * revP;
2095
+ }
2096
+ c.globalAlpha = alpha;
2097
+ c.fillStyle = bg;
2098
+ roundRect2(c, x, y, cardW, optH, CARD_RADIUS);
2099
+ c.fill();
2100
+ c.globalAlpha = alpha;
2101
+ c.lineWidth = isCorrect && revealed ? 4 : 2;
2102
+ c.strokeStyle = isCorrect && revealed ? accent : ctx.theme.sepia;
2103
+ roundRect2(c, x, y, cardW, optH, CARD_RADIUS);
2104
+ c.stroke();
2105
+ if (showPoll && quiz.poll && quiz.poll[i] != null) {
2106
+ const pct = quiz.poll[i] / 100;
2107
+ c.globalAlpha = alpha * 0.25;
2108
+ c.fillStyle = isCorrect ? accent : ctx.theme.sepia;
2109
+ roundRect2(c, x, y, cardW * pct * revP, optH, CARD_RADIUS);
2110
+ c.fill();
2111
+ }
2112
+ c.globalAlpha = alpha;
2113
+ const label = String.fromCharCode(65 + i);
2114
+ c.fillStyle = isCorrect && revealed ? ctx.theme.paper : ctx.theme.ink;
2115
+ c.textAlign = "left";
2116
+ c.font = `700 ${Math.round(optH * 0.34)}px ${ctx.theme.fontBody}`;
2117
+ c.fillText(`${label}.`, x + optH * 0.4, y + optH / 2);
2118
+ c.fillText(opt.text, x + optH * 1.2, y + optH / 2);
2119
+ if (showPoll && quiz.poll && quiz.poll[i] != null && revealed) {
2120
+ c.textAlign = "right";
2121
+ c.font = `600 ${Math.round(optH * 0.3)}px ${ctx.theme.fontBody}`;
2122
+ c.fillText(`${Math.round(quiz.poll[i])}%`, x + cardW - optH * 0.4, y + optH / 2);
2123
+ }
2124
+ oy += optH + gap;
2125
+ });
2126
+ c.restore();
2127
+ }
2128
+ };
2129
+ }
2130
+ var mcqCardFactory = {
2131
+ key: "mcq-card",
2132
+ create: mcqCardLayer,
2133
+ validateProps(props) {
2134
+ if (props == null || typeof props !== "object") return ["mcq-card: props must be an object"];
2135
+ const p = props;
2136
+ const errs = [];
2137
+ errs.push(...validateQuiz(p.quiz).map((e) => `mcq-card.${e}`));
2138
+ if (p.correctColor != null && typeof p.correctColor !== "string")
2139
+ errs.push("mcq-card.correctColor must be a string");
2140
+ if (p.showPoll != null && typeof p.showPoll !== "boolean")
2141
+ errs.push("mcq-card.showPoll must be a boolean");
2142
+ return errs;
2143
+ }
2144
+ };
2145
+
2146
+ // src/scene/circleOfFifths.ts
2147
+ var FIFTHS_MAJOR = ["C", "G", "D", "A", "E", "B", "F\u266F", "D\u266D", "A\u266D", "E\u266D", "B\u266D", "F"];
2148
+ var FIFTHS_MINOR = ["a", "e", "b", "f\u266F", "c\u266F", "g\u266F", "d\u266F", "b\u266D", "f", "c", "g", "d"];
2149
+ function slotPc(i) {
2150
+ return 7 * i % 12;
2151
+ }
2152
+ function pcToSlot(pc) {
2153
+ const p = (pc % 12 + 12) % 12;
2154
+ for (let i = 0; i < 12; i++) if (slotPc(i) === p) return i;
2155
+ return 0;
2156
+ }
2157
+ function keySlot(key) {
2158
+ return pcToSlot(parseKey(key).tonicPc);
2159
+ }
2160
+ function slotAngle(i) {
2161
+ return i / 12 * Math.PI * 2;
2162
+ }
2163
+ function slotPoint(cx, cy, r, i) {
2164
+ const a = slotAngle(i) - Math.PI / 2;
2165
+ return { x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) };
2166
+ }
2167
+ function animatedSlot(fromSlot, toSlot, t01) {
2168
+ let delta = toSlot - fromSlot;
2169
+ if (delta > 6) delta -= 12;
2170
+ if (delta < -6) delta += 12;
2171
+ const t = t01 < 0 ? 0 : t01 > 1 ? 1 : t01;
2172
+ const s = fromSlot + delta * t;
2173
+ return (s % 12 + 12) % 12;
2174
+ }
2175
+ function fracSlotPoint(cx, cy, r, frac) {
2176
+ const a = frac / 12 * Math.PI * 2 - Math.PI / 2;
2177
+ return { x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) };
2178
+ }
2179
+
2180
+ // src/scene/layers/circleOfFifths.ts
2181
+ function circleOfFifthsLayer() {
2182
+ let keyOverride;
2183
+ let toKey;
2184
+ let fromMs = 0;
2185
+ let toMs = 0;
2186
+ let centerProp;
2187
+ let radiusProp;
2188
+ let showMinor = true;
2189
+ let color;
2190
+ return {
2191
+ key: "circle-of-fifths",
2192
+ init(_ctx, props) {
2193
+ keyOverride = props.key;
2194
+ toKey = props.toKey;
2195
+ fromMs = props.fromMs ?? 0;
2196
+ toMs = props.toMs ?? 0;
2197
+ centerProp = props.center;
2198
+ radiusProp = props.radius;
2199
+ showMinor = props.showMinor ?? true;
2200
+ color = props.color;
2201
+ },
2202
+ draw(ctx, tMs) {
2203
+ const c = ctx.ctx2d;
2204
+ const sb = ctx.safeBox;
2205
+ const cx = centerProp?.[0] ?? sb.left + sb.w / 2;
2206
+ const cy = centerProp?.[1] ?? sb.top + sb.h / 2;
2207
+ const R = radiusProp ?? Math.min(sb.w, sb.h) * 0.32;
2208
+ const accent = color ?? ctx.theme.accent;
2209
+ const rMajor = R;
2210
+ const rMinor = R * 0.66;
2211
+ const fromSlot = keySlot(keyOverride ?? ctx.score?.key);
2212
+ let hiSlot = fromSlot;
2213
+ if (toKey) {
2214
+ const t01 = easeInOut(invLerp(fromMs, toMs, tMs));
2215
+ hiSlot = animatedSlot(fromSlot, keySlot(toKey), t01);
2216
+ }
2217
+ c.save();
2218
+ c.lineWidth = 2;
2219
+ c.strokeStyle = ctx.theme.sepia;
2220
+ c.globalAlpha = 0.5;
2221
+ c.beginPath();
2222
+ c.arc(cx, cy, rMajor + R * 0.16, 0, Math.PI * 2);
2223
+ c.stroke();
2224
+ if (showMinor) {
2225
+ c.beginPath();
2226
+ c.arc(cx, cy, rMinor - R * 0.16, 0, Math.PI * 2);
2227
+ c.stroke();
2228
+ }
2229
+ c.globalAlpha = 1;
2230
+ const hp = fracSlotPoint(cx, cy, rMajor, hiSlot);
2231
+ c.fillStyle = accent;
2232
+ c.globalAlpha = 0.9;
2233
+ c.beginPath();
2234
+ c.arc(hp.x, hp.y, R * 0.2, 0, Math.PI * 2);
2235
+ c.fill();
2236
+ c.globalAlpha = 1;
2237
+ c.textAlign = "center";
2238
+ c.textBaseline = "middle";
2239
+ for (let i = 0; i < 12; i++) {
2240
+ const isHi = Math.round(hiSlot) % 12 === i;
2241
+ const pm = slotPoint(cx, cy, rMajor, i);
2242
+ c.fillStyle = isHi ? ctx.theme.paper : ctx.theme.ink;
2243
+ c.font = `700 ${Math.round(R * 0.16)}px ${ctx.theme.fontBody}`;
2244
+ c.fillText(FIFTHS_MAJOR[i], pm.x, pm.y);
2245
+ if (showMinor) {
2246
+ const pn = slotPoint(cx, cy, rMinor, i);
2247
+ c.fillStyle = ctx.theme.sepia;
2248
+ c.globalAlpha = isHi ? 1 : 0.8;
2249
+ c.font = `400 ${Math.round(R * 0.12)}px ${ctx.theme.fontBody}`;
2250
+ c.fillText(FIFTHS_MINOR[i], pn.x, pn.y);
2251
+ c.globalAlpha = 1;
2252
+ }
2253
+ }
2254
+ c.restore();
2255
+ }
2256
+ };
2257
+ }
2258
+ var circleOfFifthsFactory = {
2259
+ key: "circle-of-fifths",
2260
+ create: circleOfFifthsLayer,
2261
+ validateProps(props) {
2262
+ if (props == null || typeof props !== "object") return ["circle-of-fifths: props must be an object"];
2263
+ const p = props;
2264
+ const errs = [];
2265
+ if (p.key != null && typeof p.key !== "string") errs.push("circle-of-fifths.key must be a string");
2266
+ if (p.toKey != null) {
2267
+ if (typeof p.toKey !== "string") errs.push("circle-of-fifths.toKey must be a string");
2268
+ if (typeof p.fromMs !== "number" || typeof p.toMs !== "number")
2269
+ errs.push("circle-of-fifths.toKey requires numeric fromMs and toMs");
2270
+ else if (p.toMs <= p.fromMs) errs.push("circle-of-fifths.toMs must be > fromMs");
2271
+ }
2272
+ if (p.center != null && (!Array.isArray(p.center) || p.center.length !== 2 || !p.center.every((n) => typeof n === "number")))
2273
+ errs.push("circle-of-fifths.center must be [x,y]");
2274
+ if (p.radius != null && (typeof p.radius !== "number" || p.radius <= 0))
2275
+ errs.push("circle-of-fifths.radius must be a positive number");
2276
+ if (p.showMinor != null && typeof p.showMinor !== "boolean")
2277
+ errs.push("circle-of-fifths.showMinor must be a boolean");
2278
+ if (p.color != null && typeof p.color !== "string")
2279
+ errs.push("circle-of-fifths.color must be a string");
2280
+ return errs;
2281
+ }
2282
+ };
2283
+
2284
+ // src/scene/pitchContour.ts
2285
+ function contourPoints(notes) {
2286
+ const byOnset = /* @__PURE__ */ new Map();
2287
+ for (const n of notes) {
2288
+ const cur = byOnset.get(n.onsetMs);
2289
+ if (cur == null || n.pitchMidi > cur) byOnset.set(n.onsetMs, n.pitchMidi);
2290
+ }
2291
+ return [...byOnset.entries()].map(([tMs, pitchMidi]) => ({ tMs, pitchMidi })).sort((a, b) => a.tMs - b.tMs);
2292
+ }
2293
+ function pitchRange(points) {
2294
+ if (!points.length) return { min: 60, max: 72 };
2295
+ let min = Infinity;
2296
+ let max = -Infinity;
2297
+ for (const p of points) {
2298
+ if (p.pitchMidi < min) min = p.pitchMidi;
2299
+ if (p.pitchMidi > max) max = p.pitchMidi;
2300
+ }
2301
+ if (max - min < 2) {
2302
+ min -= 1;
2303
+ max += 1;
2304
+ }
2305
+ return { min: min - 1, max: max + 1 };
2306
+ }
2307
+ function projectPoint(plot, tMs, pitchMidi) {
2308
+ const tx = plot.durationMs > 0 ? clamp(tMs / plot.durationMs, 0, 1) : 0;
2309
+ const py = plot.maxPitch > plot.minPitch ? clamp((pitchMidi - plot.minPitch) / (plot.maxPitch - plot.minPitch), 0, 1) : 0.5;
2310
+ return {
2311
+ x: plot.left + tx * (plot.right - plot.left),
2312
+ y: plot.bottom - py * (plot.bottom - plot.top)
2313
+ };
2314
+ }
2315
+ function contourPolyline(points, plot) {
2316
+ return points.map((p) => projectPoint(plot, p.tMs, p.pitchMidi));
2317
+ }
2318
+ function pitchAt(points, tMs) {
2319
+ if (!points.length || tMs < points[0].tMs) return null;
2320
+ let cur = points[0].pitchMidi;
2321
+ for (const p of points) {
2322
+ if (p.tMs <= tMs) cur = p.pitchMidi;
2323
+ else break;
2324
+ }
2325
+ return cur;
2326
+ }
2327
+ function dotAt(points, plot, tMs) {
2328
+ const pitch = pitchAt(points, tMs);
2329
+ if (pitch == null) return null;
2330
+ return projectPoint(plot, tMs, pitch);
2331
+ }
2332
+
2333
+ // src/scene/layers/pitchContour.ts
2334
+ function pitchContourLayer() {
2335
+ let topProp;
2336
+ let heightProp;
2337
+ let width = 5;
2338
+ let color;
2339
+ let dot = true;
2340
+ let dotRadius = 14;
2341
+ let points = [];
2342
+ return {
2343
+ key: "pitch-contour",
2344
+ init(ctx, props) {
2345
+ topProp = props.top;
2346
+ heightProp = props.height;
2347
+ width = props.width ?? 5;
2348
+ color = props.color;
2349
+ dot = props.dot ?? true;
2350
+ dotRadius = props.dotRadius ?? 14;
2351
+ points = contourPoints(ctx.score?.notes ?? []);
2352
+ },
2353
+ draw(ctx, tMs) {
2354
+ if (points.length < 2) return;
2355
+ const c = ctx.ctx2d;
2356
+ const sb = ctx.safeBox;
2357
+ const accent = color ?? ctx.theme.accent;
2358
+ const top = topProp ?? sb.top + sb.h * 0.15;
2359
+ const height = heightProp ?? sb.h * 0.35;
2360
+ const range = pitchRange(points);
2361
+ const plot = {
2362
+ left: sb.left,
2363
+ right: sb.right,
2364
+ top,
2365
+ bottom: top + height,
2366
+ minPitch: range.min,
2367
+ maxPitch: range.max,
2368
+ durationMs: ctx.score?.durationMs ?? points[points.length - 1].tMs
2369
+ };
2370
+ const poly = contourPolyline(points, plot);
2371
+ const playX = projectPoint(plot, tMs, range.min).x;
2372
+ c.save();
2373
+ c.lineJoin = "round";
2374
+ c.lineCap = "round";
2375
+ c.globalAlpha = 0.22;
2376
+ c.strokeStyle = ctx.theme.sepia;
2377
+ c.lineWidth = width;
2378
+ c.beginPath();
2379
+ poly.forEach((p, i) => i === 0 ? c.moveTo(p.x, p.y) : c.lineTo(p.x, p.y));
2380
+ c.stroke();
2381
+ c.globalAlpha = 1;
2382
+ c.strokeStyle = accent;
2383
+ c.lineWidth = width;
2384
+ c.beginPath();
2385
+ let started = false;
2386
+ for (let i = 0; i < poly.length; i++) {
2387
+ const p = poly[i];
2388
+ if (p.x <= playX) {
2389
+ if (!started) {
2390
+ c.moveTo(p.x, p.y);
2391
+ started = true;
2392
+ } else c.lineTo(p.x, p.y);
2393
+ } else {
2394
+ if (i > 0) {
2395
+ const a = poly[i - 1];
2396
+ const f = (playX - a.x) / (p.x - a.x || 1);
2397
+ const y = a.y + (p.y - a.y) * f;
2398
+ if (!started) {
2399
+ c.moveTo(a.x, a.y);
2400
+ started = true;
2401
+ }
2402
+ c.lineTo(playX, y);
2403
+ }
2404
+ break;
2405
+ }
2406
+ }
2407
+ if (started) c.stroke();
2408
+ if (dot) {
2409
+ const d = dotAt(points, plot, tMs);
2410
+ if (d && Number.isFinite(d.x) && Number.isFinite(d.y)) {
2411
+ c.globalAlpha = 1;
2412
+ c.fillStyle = accent;
2413
+ c.beginPath();
2414
+ c.arc(d.x, d.y, dotRadius, 0, Math.PI * 2);
2415
+ c.fill();
2416
+ c.globalAlpha = 0.3;
2417
+ c.beginPath();
2418
+ c.arc(d.x, d.y, dotRadius * 1.7, 0, Math.PI * 2);
2419
+ c.fill();
2420
+ }
2421
+ }
2422
+ c.restore();
2423
+ }
2424
+ };
2425
+ }
2426
+ var pitchContourFactory = {
2427
+ key: "pitch-contour",
2428
+ create: pitchContourLayer,
2429
+ validateProps(props) {
2430
+ if (props == null || typeof props !== "object") return ["pitch-contour: props must be an object"];
2431
+ const p = props;
2432
+ const errs = [];
2433
+ if (p.top != null && typeof p.top !== "number") errs.push("pitch-contour.top must be a number");
2434
+ if (p.height != null && (typeof p.height !== "number" || p.height <= 0))
2435
+ errs.push("pitch-contour.height must be a positive number");
2436
+ if (p.width != null && (typeof p.width !== "number" || p.width <= 0))
2437
+ errs.push("pitch-contour.width must be a positive number");
2438
+ if (p.color != null && typeof p.color !== "string") errs.push("pitch-contour.color must be a string");
2439
+ if (p.dot != null && typeof p.dot !== "boolean") errs.push("pitch-contour.dot must be a boolean");
2440
+ if (p.dotRadius != null && (typeof p.dotRadius !== "number" || p.dotRadius <= 0))
2441
+ errs.push("pitch-contour.dotRadius must be a positive number");
2442
+ return errs;
2443
+ }
2444
+ };
2445
+
2446
+ // src/scene/sectionMinimap.ts
2447
+ function progress01(durationMs, tMs) {
2448
+ return durationMs > 0 ? clamp(tMs / durationMs, 0, 1) : 0;
2449
+ }
2450
+ function timeToX(left, width, durationMs, tMs) {
2451
+ return left + progress01(durationMs, tMs) * width;
2452
+ }
2453
+ function activeSection(sections, tMs) {
2454
+ for (const s of sections) if (tMs >= s.startMs && tMs < s.endMs) return s;
2455
+ return null;
2456
+ }
2457
+ function measureSpans(measureCount2, durationMs) {
2458
+ if (measureCount2 <= 0 || durationMs <= 0) return [];
2459
+ const w = durationMs / measureCount2;
2460
+ const out = [];
2461
+ for (let i = 0; i < measureCount2; i++) {
2462
+ out.push({ startMs: i * w, endMs: (i + 1) * w, label: String(i + 1) });
2463
+ }
2464
+ return out;
2465
+ }
2466
+ function validateSections(sections) {
2467
+ if (!Array.isArray(sections)) return ["sections must be an array"];
2468
+ const errs = [];
2469
+ sections.forEach((raw, i) => {
2470
+ const s = raw;
2471
+ if (typeof s?.startMs !== "number" || typeof s?.endMs !== "number")
2472
+ errs.push(`sections[${i}] needs numeric startMs/endMs`);
2473
+ else if (s.endMs <= s.startMs) errs.push(`sections[${i}].endMs must be > startMs`);
2474
+ if (s?.label != null && typeof s.label !== "string")
2475
+ errs.push(`sections[${i}].label must be a string`);
2476
+ });
2477
+ return errs;
2478
+ }
2479
+
2480
+ // src/scene/layers/sectionMinimap.ts
2481
+ function sectionMinimapLayer() {
2482
+ let sectionsProp;
2483
+ let measureCount2 = 0;
2484
+ let barYProp;
2485
+ let barHeight = 10;
2486
+ let showLabel = true;
2487
+ let color;
2488
+ let sections = [];
2489
+ return {
2490
+ key: "section-minimap",
2491
+ init(ctx, props) {
2492
+ sectionsProp = props.sections;
2493
+ measureCount2 = props.measureCount ?? 0;
2494
+ barYProp = props.barY;
2495
+ barHeight = props.barHeight ?? 10;
2496
+ showLabel = props.showLabel ?? true;
2497
+ color = props.color;
2498
+ const dur = ctx.score?.durationMs ?? 0;
2499
+ sections = sectionsProp ?? (measureCount2 > 0 ? measureSpans(measureCount2, dur) : []);
2500
+ },
2501
+ draw(ctx, tMs) {
2502
+ const c = ctx.ctx2d;
2503
+ const sb = ctx.safeBox;
2504
+ const accent = color ?? ctx.theme.accent;
2505
+ const dur = ctx.score?.durationMs ?? 0;
2506
+ if (dur <= 0) return;
2507
+ const left = sb.left;
2508
+ const width = sb.w;
2509
+ const barY = barYProp ?? sb.bottom - 40;
2510
+ c.save();
2511
+ const round = barHeight / 2;
2512
+ c.fillStyle = ctx.theme.sepia;
2513
+ c.globalAlpha = 0.3;
2514
+ drawBar(c, left, barY - round, width, barHeight, round);
2515
+ if (sections.length) {
2516
+ c.globalAlpha = 0.5;
2517
+ sections.forEach((s, i) => {
2518
+ const x0 = timeToX(left, width, dur, s.startMs);
2519
+ const x1 = timeToX(left, width, dur, s.endMs);
2520
+ c.fillStyle = i % 2 === 0 ? ctx.theme.sepia : ctx.theme.ink;
2521
+ c.globalAlpha = i % 2 === 0 ? 0.18 : 0.1;
2522
+ c.fillRect(x0, barY - round, Math.max(0, x1 - x0), barHeight);
2523
+ c.globalAlpha = 0.4;
2524
+ c.fillStyle = ctx.theme.ink;
2525
+ c.fillRect(x0 - 1, barY - round - 3, 2, barHeight + 6);
2526
+ });
2527
+ }
2528
+ const px = timeToX(left, width, dur, tMs);
2529
+ c.globalAlpha = 0.85;
2530
+ c.fillStyle = accent;
2531
+ drawBar(c, left, barY - round, Math.max(0, px - left), barHeight, round);
2532
+ c.globalAlpha = 1;
2533
+ c.fillStyle = accent;
2534
+ c.beginPath();
2535
+ c.arc(px, barY, barHeight * 1.6, 0, Math.PI * 2);
2536
+ c.fill();
2537
+ c.fillStyle = ctx.theme.paper;
2538
+ c.beginPath();
2539
+ c.arc(px, barY, barHeight * 0.7, 0, Math.PI * 2);
2540
+ c.fill();
2541
+ if (showLabel) {
2542
+ const act = activeSection(sections, tMs);
2543
+ if (act?.label) {
2544
+ c.globalAlpha = 1;
2545
+ c.fillStyle = ctx.theme.ink;
2546
+ c.textAlign = "center";
2547
+ c.textBaseline = "bottom";
2548
+ c.font = `700 ${Math.round(barHeight * 2.4)}px ${ctx.theme.fontBody}`;
2549
+ c.fillText(act.label, px, barY - barHeight * 2.2);
2550
+ }
2551
+ }
2552
+ c.restore();
2553
+ }
2554
+ };
2555
+ }
2556
+ function drawBar(c, x, y, w, h, r) {
2557
+ const rr = Math.min(r, w / 2, h / 2);
2558
+ const anyC = c;
2559
+ if (typeof anyC.roundRect === "function") {
2560
+ c.beginPath();
2561
+ anyC.roundRect(x, y, w, h, rr);
2562
+ c.fill();
2563
+ return;
2564
+ }
2565
+ c.fillRect(x, y, w, h);
2566
+ }
2567
+ var sectionMinimapFactory = {
2568
+ key: "section-minimap",
2569
+ create: sectionMinimapLayer,
2570
+ validateProps(props) {
2571
+ if (props == null || typeof props !== "object") return ["section-minimap: props must be an object"];
2572
+ const p = props;
2573
+ const errs = [];
2574
+ if (p.sections != null) errs.push(...validateSections(p.sections).map((e) => `section-minimap.${e}`));
2575
+ if (p.measureCount != null && (typeof p.measureCount !== "number" || p.measureCount < 0 || !Number.isInteger(p.measureCount)))
2576
+ errs.push("section-minimap.measureCount must be a non-negative integer");
2577
+ if (p.barY != null && typeof p.barY !== "number") errs.push("section-minimap.barY must be a number");
2578
+ if (p.barHeight != null && (typeof p.barHeight !== "number" || p.barHeight <= 0))
2579
+ errs.push("section-minimap.barHeight must be a positive number");
2580
+ if (p.showLabel != null && typeof p.showLabel !== "boolean")
2581
+ errs.push("section-minimap.showLabel must be a boolean");
2582
+ if (p.color != null && typeof p.color !== "string") errs.push("section-minimap.color must be a string");
2583
+ return errs;
2584
+ }
2585
+ };
2586
+
2587
+ // src/scene/registry.ts
2588
+ var REGISTRY = /* @__PURE__ */ new Map();
2589
+ function registerLayer(factory) {
2590
+ REGISTRY.set(factory.key, factory);
2591
+ }
2592
+ function getLayerFactory(key) {
2593
+ return REGISTRY.get(key);
2594
+ }
2595
+ function registeredKeys() {
2596
+ return [...REGISTRY.keys()];
2597
+ }
2598
+ registerLayer(backgroundFactory);
2599
+ registerLayer(captionFactory);
2600
+ registerLayer(notationFactory);
2601
+ registerLayer(scrollCursorFactory);
2602
+ registerLayer(keyboardFactory);
2603
+ registerLayer(fallingNotesFactory);
2604
+ registerLayer(hookFactory);
2605
+ registerLayer(revealFactory);
2606
+ registerLayer(ctaFactory);
2607
+ registerLayer(portraitFactory);
2608
+ registerLayer(spectrumFactory);
2609
+ registerLayer(brandingFactory);
2610
+ registerLayer(safeGuidesFactory);
2611
+ registerLayer(staffKeyboardRayFactory);
2612
+ registerLayer(countingTrackFactory);
2613
+ registerLayer(degreeLabelsFactory);
2614
+ registerLayer(functionalHarmonyFactory);
2615
+ registerLayer(mcqCardFactory);
2616
+ registerLayer(circleOfFifthsFactory);
2617
+ registerLayer(pitchContourFactory);
2618
+ registerLayer(sectionMinimapFactory);
2619
+
2620
+ // src/scene/audioLayers.ts
2621
+ function countInSchedule(opts) {
2622
+ const beats = opts.beats ?? 4;
2623
+ const accent = opts.accentDownbeat ?? true;
2624
+ const beatSec = 60 / opts.bpm;
2625
+ const events = [];
2626
+ for (let i = 0; i < beats; i++) {
2627
+ const isDownbeat = accent && i === 0;
2628
+ events.push({
2629
+ atSec: i * beatSec,
2630
+ note: isDownbeat ? "C6" : "C5",
2631
+ durSec: 0.05,
2632
+ gain: isDownbeat ? 1 : 0.7,
2633
+ kind: "count"
2634
+ });
2635
+ }
2636
+ return events;
2637
+ }
2638
+ function countInLeadSec(opts) {
2639
+ return (opts.beats ?? 4) * (60 / opts.bpm);
2640
+ }
2641
+ function clickTrackSchedule(opts) {
2642
+ const beatSec = 60 / opts.bpm;
2643
+ const bpb = opts.beatsPerBar ?? 4;
2644
+ const start = opts.startSec ?? 0;
2645
+ const events = [];
1656
2646
  const n = Math.floor(opts.durationSec / beatSec);
1657
2647
  for (let i = 0; i < n; i++) {
1658
2648
  const isDownbeat = i % bpb === 0;
@@ -1717,6 +2707,175 @@ function applySchedule(instrument, schedule, startTime) {
1717
2707
  return schedule.length;
1718
2708
  }
1719
2709
 
2710
+ // src/scene/camera.ts
2711
+ function cameraTransform(cam, W, H) {
2712
+ const s = cam.zoom;
2713
+ return [s, 0, 0, s, W / 2 - cam.cx * s, H / 2 - cam.cy * s];
2714
+ }
2715
+ function worldToViewport(cam, W, H, x, y) {
2716
+ const [a, , , d, e, f] = cameraTransform(cam, W, H);
2717
+ return { x: a * x + e, y: d * y + f };
2718
+ }
2719
+ function frameRect(rect, W, H, pad = 0) {
2720
+ const padded = 1 + Math.max(0, pad) * 2;
2721
+ const zoom = Math.min(W / (rect.w * padded), H / (rect.h * padded));
2722
+ return { cx: rect.x + rect.w / 2, cy: rect.y + rect.h / 2, zoom };
2723
+ }
2724
+ function lerpCamera(a, b, t01, ease = easeInOut) {
2725
+ const k = ease(clamp(t01, 0, 1));
2726
+ return { cx: lerp(a.cx, b.cx, k), cy: lerp(a.cy, b.cy, k), zoom: lerp(a.zoom, b.zoom, k) };
2727
+ }
2728
+ function kenBurns(from, to, at01) {
2729
+ return lerpCamera(from, to, at01, easeInOut);
2730
+ }
2731
+ function applyToContext(ctx, cam, W, H) {
2732
+ const m = cameraTransform(cam, W, H);
2733
+ ctx.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]);
2734
+ }
2735
+ function identityCamera(W, H) {
2736
+ return { cx: W / 2, cy: H / 2, zoom: 1 };
2737
+ }
2738
+
2739
+ // src/scene/runner.ts
2740
+ function resolveAnchor(anchor, totalSec) {
2741
+ if (typeof anchor === "number") return anchor;
2742
+ if (anchor === "end") return totalSec;
2743
+ const m = /^end-(\d+(?:\.\d+)?)$/.exec(anchor);
2744
+ if (m) return totalSec - parseFloat(m[1]);
2745
+ throw new Error(`resolveAnchor: bad anchor "${anchor}"`);
2746
+ }
2747
+ function resolveTimeline(spec, totalSec) {
2748
+ return spec.timeline.map((seg) => {
2749
+ const startSec = resolveAnchor(seg.at[0], totalSec);
2750
+ const endSec = resolveAnchor(seg.at[1], totalSec);
2751
+ return { startMs: startSec * 1e3, endMs: endSec * 1e3, layers: seg.layers, audio: seg.audio };
2752
+ });
2753
+ }
2754
+ function visualTimelineMs(resolved) {
2755
+ return resolved.reduce((mx, s) => Math.max(mx, s.endMs), 0);
2756
+ }
2757
+ var SCREEN_PINNED_KEYS = /* @__PURE__ */ new Set([
2758
+ "caption",
2759
+ "branding",
2760
+ "cta",
2761
+ "hook",
2762
+ "reveal",
2763
+ "portrait",
2764
+ "safe-guides",
2765
+ "mcq-card"
2766
+ ]);
2767
+ async function buildScene(opts) {
2768
+ const { spec, theme, score } = opts;
2769
+ const [W, H] = spec.size;
2770
+ const fps = spec.fps ?? 30;
2771
+ const resolved = resolveTimeline(spec, opts.totalSec);
2772
+ const safe = safeBox(W, H);
2773
+ const camera = opts.camera ?? (() => identityCamera(W, H));
2774
+ const clock = { nowMs: () => 0 };
2775
+ const baseCtx = {
2776
+ W,
2777
+ H,
2778
+ score,
2779
+ audioClock: clock,
2780
+ theme,
2781
+ safeBox: safe,
2782
+ fps
2783
+ };
2784
+ const bound = [];
2785
+ for (const seg of resolved) {
2786
+ for (const sl of seg.layers) {
2787
+ const factory = getLayerFactory(sl.k);
2788
+ if (!factory) {
2789
+ throw new Error(`buildScene: unknown layer "${sl.k}" (registered: ${registeredKeys().join(", ")})`);
2790
+ }
2791
+ const errs = factory.validateProps(sl.p ?? {});
2792
+ if (errs.length) {
2793
+ throw new Error(`buildScene: invalid props for "${sl.k}": ${errs.join("; ")}`);
2794
+ }
2795
+ const layer = factory.create();
2796
+ await layer.init({ ...baseCtx, ctx2d: null }, sl.p ?? {});
2797
+ bound.push({
2798
+ layer,
2799
+ startMs: seg.startMs,
2800
+ endMs: seg.endMs,
2801
+ screenPinned: SCREEN_PINNED_KEYS.has(sl.k)
2802
+ });
2803
+ }
2804
+ }
2805
+ const durationMs = visualTimelineMs(resolved);
2806
+ function renderFrame(ctx2d, tMs) {
2807
+ clock.nowMs = () => tMs;
2808
+ const ctx = { ...baseCtx, ctx2d };
2809
+ const cam = camera(tMs);
2810
+ ctx2d.save();
2811
+ applyToContext(ctx2d, cam, W, H);
2812
+ for (const b of bound) {
2813
+ if (b.screenPinned) continue;
2814
+ if (tMs < b.startMs || tMs >= b.endMs) continue;
2815
+ b.layer.draw(ctx, tMs);
2816
+ }
2817
+ ctx2d.restore();
2818
+ ctx2d.save();
2819
+ ctx2d.setTransform(1, 0, 0, 1, 0, 0);
2820
+ for (const b of bound) {
2821
+ if (!b.screenPinned) continue;
2822
+ if (tMs < b.startMs || tMs >= b.endMs) continue;
2823
+ b.layer.draw(ctx, tMs);
2824
+ }
2825
+ ctx2d.restore();
2826
+ }
2827
+ function scheduleAudio(instrument, baseSec) {
2828
+ let fired = 0;
2829
+ for (const seg of resolved) {
2830
+ if (!seg.audio) continue;
2831
+ const segStartSec = baseSec + seg.startMs / 1e3;
2832
+ if (seg.audio.schedule?.length) {
2833
+ fired += applySchedule(instrument, seg.audio.schedule, segStartSec);
2834
+ }
2835
+ if (seg.audio.onEnter) {
2836
+ const segClock = { nowMs: () => seg.startMs };
2837
+ seg.audio.onEnter(segClock, { instrument, startSec: segStartSec, score });
2838
+ }
2839
+ }
2840
+ return fired;
2841
+ }
2842
+ return {
2843
+ W,
2844
+ H,
2845
+ fps,
2846
+ durationMs,
2847
+ resolved,
2848
+ renderFrame,
2849
+ scheduleAudio,
2850
+ dispose() {
2851
+ for (const b of bound) b.layer.dispose?.();
2852
+ }
2853
+ };
2854
+ }
2855
+ async function recordSceneSpec(opts) {
2856
+ const { built } = opts;
2857
+ const record = opts.record ?? recordScenes;
2858
+ const composite = {
2859
+ durationMs: built.durationMs,
2860
+ // Schedule any per-segment audio against the capture clock exactly when the
2861
+ // first frame is entered, so it's anchored to the same start the recorder
2862
+ // uses. onEnter fires once per Scene (see recordScenes), so this runs once.
2863
+ onEnter: opts.segmentAudio ? () => {
2864
+ const sa = opts.segmentAudio;
2865
+ built.scheduleAudio(sa.instrument, sa.audioNow() + (sa.lookaheadSec ?? 0.1));
2866
+ } : void 0,
2867
+ draw: (ctx, t01) => built.renderFrame(ctx, t01 * built.durationMs)
2868
+ };
2869
+ return record([composite], {
2870
+ audioStream: opts.audioStream,
2871
+ width: built.W,
2872
+ height: built.H,
2873
+ fps: built.fps,
2874
+ background: opts.background,
2875
+ onProgress: opts.onProgress
2876
+ });
2877
+ }
2878
+
1720
2879
  // src/scene/gate.ts
1721
2880
  var AV_TOLERANCE_MS = 60;
1722
2881
  async function runGate(input) {
@@ -1917,28 +3076,167 @@ function promoCardsDemoSpec(opts = {}) {
1917
3076
  audio: { voicing: "reading" }
1918
3077
  };
1919
3078
  }
3079
+
3080
+ // src/scene/demos/extendedDemo.ts
3081
+ function countingDegreeDemoSpec(opts = {}) {
3082
+ return {
3083
+ size: opts.size ?? [1080, 1920],
3084
+ theme: opts.theme ?? "rsr",
3085
+ durationMode: "audio",
3086
+ timeline: [
3087
+ {
3088
+ at: [0, "end"],
3089
+ layers: [
3090
+ { k: "background", p: { style: "paper" } },
3091
+ { k: "keyboard", p: { range: opts.range ?? "auto" } },
3092
+ { k: "degree-labels", p: { mode: opts.labelMode ?? "degree" } },
3093
+ { k: "counting-track", p: { subdiv: opts.subdiv ?? 2 } }
3094
+ ]
3095
+ }
3096
+ ]
3097
+ };
3098
+ }
3099
+ function staffRayDemoSpec(rendered, opts = {}) {
3100
+ return {
3101
+ size: opts.size ?? [1080, 1920],
3102
+ theme: opts.theme ?? "rsr",
3103
+ durationMode: "audio",
3104
+ timeline: [
3105
+ {
3106
+ at: [0, "end"],
3107
+ layers: [
3108
+ { k: "background", p: { style: "paper" } },
3109
+ // notation in a top band; keyboard a short strip at the very bottom, so
3110
+ // the rays span a clear vertical gap between staff and keys.
3111
+ { k: "notation", p: { rendered, bandHeight: (opts.size?.[1] ?? 1920) * 0.28 } },
3112
+ { k: "keyboard", p: { range: opts.range ?? "88", height: (opts.size?.[1] ?? 1920) * 0.16 } },
3113
+ { k: "staff-keyboard-ray", p: {} }
3114
+ ]
3115
+ }
3116
+ ]
3117
+ };
3118
+ }
3119
+ function harmonyDemoSpec(chordTrack, opts = {}) {
3120
+ return {
3121
+ size: opts.size ?? [1080, 1920],
3122
+ theme: opts.theme ?? "rsr",
3123
+ durationMode: "audio",
3124
+ timeline: [
3125
+ {
3126
+ at: [0, "end"],
3127
+ layers: [
3128
+ { k: "background", p: { style: "ink" } },
3129
+ { k: "keyboard", p: { range: opts.range ?? "auto" } },
3130
+ { k: "functional-harmony", p: { chordTrack, modes: ["band", "keys"] } }
3131
+ ]
3132
+ }
3133
+ ]
3134
+ };
3135
+ }
3136
+ function mcqDemoSpec(quiz, opts = {}) {
3137
+ return {
3138
+ size: opts.size ?? [1080, 1920],
3139
+ theme: opts.theme ?? "rsr",
3140
+ durationMode: "fixed",
3141
+ durationSec: quiz.endMs / 1e3,
3142
+ timeline: [
3143
+ {
3144
+ at: [0, "end"],
3145
+ layers: [
3146
+ { k: "background", p: { style: "paper" } },
3147
+ { k: "mcq-card", p: { quiz } }
3148
+ ]
3149
+ }
3150
+ ]
3151
+ };
3152
+ }
3153
+ function circleOfFifthsDemoSpec(opts = {}) {
3154
+ const p = {};
3155
+ if (opts.toKey) {
3156
+ p.toKey = opts.toKey;
3157
+ p.fromMs = opts.fromMs ?? 0;
3158
+ p.toMs = opts.toMs ?? 2e3;
3159
+ }
3160
+ return {
3161
+ size: opts.size ?? [1080, 1920],
3162
+ theme: opts.theme ?? "rsr",
3163
+ durationMode: "audio",
3164
+ timeline: [
3165
+ {
3166
+ at: [0, "end"],
3167
+ layers: [
3168
+ { k: "background", p: { style: "paper" } },
3169
+ { k: "circle-of-fifths", p }
3170
+ ]
3171
+ }
3172
+ ]
3173
+ };
3174
+ }
3175
+ function contourMinimapDemoSpec(opts = {}) {
3176
+ const mini = {};
3177
+ if (opts.sections) mini.sections = opts.sections;
3178
+ else if (opts.measureCount) mini.measureCount = opts.measureCount;
3179
+ return {
3180
+ size: opts.size ?? [1080, 1920],
3181
+ theme: opts.theme ?? "rsr",
3182
+ durationMode: "audio",
3183
+ timeline: [
3184
+ {
3185
+ at: [0, "end"],
3186
+ layers: [
3187
+ { k: "background", p: { style: "paper" } },
3188
+ { k: "pitch-contour", p: {} },
3189
+ { k: "section-minimap", p: mini }
3190
+ ]
3191
+ }
3192
+ ]
3193
+ };
3194
+ }
1920
3195
  export {
3196
+ DEFAULT_FUNCTION_COLORS,
3197
+ FIFTHS_MAJOR,
3198
+ FIFTHS_MINOR,
1921
3199
  FOLLOW_BARS,
1922
3200
  FOLLOW_PAD,
1923
3201
  PIANO_HIGH,
1924
3202
  PIANO_LOW,
3203
+ activeChord,
1925
3204
  activeCue,
3205
+ activeSection,
3206
+ animatedSlot,
1926
3207
  applySchedule,
1927
3208
  applyToContext,
1928
3209
  assertGate,
3210
+ ballArc,
3211
+ ballX,
3212
+ beatGrid,
3213
+ beatPhase,
1929
3214
  blackKeys,
3215
+ bpmOf,
1930
3216
  brandingFactory,
1931
3217
  buildScene,
1932
3218
  cameraForFollow,
1933
3219
  cameraTransform,
3220
+ circleOfFifthsDemoSpec,
3221
+ circleOfFifthsFactory,
1934
3222
  clamp,
1935
3223
  clickTrackSchedule,
3224
+ contourMinimapDemoSpec,
3225
+ contourPoints,
3226
+ contourPolyline,
1936
3227
  countInLeadSec,
1937
3228
  countInSchedule,
3229
+ countdownRemaining,
3230
+ countdownSeconds,
3231
+ countingDegreeDemoSpec,
3232
+ countingTrackFactory,
1938
3233
  cropAroundBox,
1939
3234
  ctaFactory,
1940
3235
  cubicEaseInOut,
1941
3236
  cueOpacity,
3237
+ degreeLabel,
3238
+ degreeLabelsFactory,
3239
+ dotAt,
1942
3240
  drawCaption,
1943
3241
  drawHighlight,
1944
3242
  droneSchedule,
@@ -1953,10 +3251,14 @@ export {
1953
3251
  followBoxAt,
1954
3252
  followSrcBox,
1955
3253
  followWindowStart,
3254
+ fracSlotPoint,
1956
3255
  frameRect,
3256
+ functionColor,
3257
+ functionalHarmonyFactory,
1957
3258
  getKeyboardLayout,
1958
3259
  getLayerFactory,
1959
3260
  getNotationEngraving,
3261
+ harmonyDemoSpec,
1960
3262
  highlightIntensity,
1961
3263
  hookFactory,
1962
3264
  identityCamera,
@@ -1967,6 +3269,7 @@ export {
1967
3269
  keyCenterX,
1968
3270
  keyColumnWidth,
1969
3271
  keyRect,
3272
+ keySlot,
1970
3273
  keyboardFactory,
1971
3274
  keyboardLayout,
1972
3275
  lerp,
@@ -1974,16 +3277,32 @@ export {
1974
3277
  lerpCamera,
1975
3278
  linear,
1976
3279
  mapBoxThroughLayout,
3280
+ mcqCardFactory,
3281
+ mcqDemoSpec,
1977
3282
  measureColumnsFromLayout,
1978
3283
  measureCount,
1979
3284
  measureSpanBox,
3285
+ measureSpans,
3286
+ timeToX as minimapTimeToX,
3287
+ msPerBeat,
1980
3288
  notationFactory,
1981
3289
  notationLayout,
1982
3290
  noteColor,
1983
3291
  noteSetXRange,
3292
+ parseKey,
3293
+ parseTimeSig,
3294
+ pcToSlot,
3295
+ pitchAt,
3296
+ pitchContourFactory,
3297
+ pitchRange,
1984
3298
  playheadLine,
1985
3299
  portraitFactory,
3300
+ progress01,
3301
+ projectPoint,
1986
3302
  promoCardsDemoSpec,
3303
+ quizPhase,
3304
+ rayEndpoints,
3305
+ rayPointAt,
1987
3306
  recordSceneSpec,
1988
3307
  registerLayer,
1989
3308
  registeredKeys,
@@ -1991,16 +3310,26 @@ export {
1991
3310
  resolveKeyboardLayout,
1992
3311
  resolveTimeline,
1993
3312
  revealFactory,
3313
+ revealProgress,
1994
3314
  runGate,
1995
3315
  safeGuidesFactory,
1996
3316
  scoreFromMusicXML,
1997
3317
  scorePitchSpan,
1998
3318
  scrollCursorFactory,
3319
+ sectionMinimapFactory,
1999
3320
  setFollowLayoutProvider,
2000
3321
  setKeyboardLayout,
2001
3322
  setNotationEngraving,
2002
- setupHeadlessDom,
3323
+ slotAngle,
3324
+ slotPc,
3325
+ slotPoint,
2003
3326
  spectrumFactory,
3327
+ staffAnchor,
3328
+ staffKeyboardRayFactory,
3329
+ staffRayDemoSpec,
3330
+ validateChordTrack,
3331
+ validateQuiz,
3332
+ validateSections,
2004
3333
  visualTimelineMs,
2005
3334
  whiteKeys,
2006
3335
  worldToViewport