@real-music-packages/web-core 0.11.0 → 0.12.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,6 +1322,1268 @@ var safeGuidesFactory = {
1427
1322
  }
1428
1323
  };
1429
1324
 
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 };
1336
+ }
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 };
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;
1349
+ return {
1350
+ staff,
1351
+ keyboard: { x: keyCenterX(keyboard, pitchMidi), y: keyboard.top }
1352
+ };
1353
+ }
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
1366
+ };
1367
+ }
1368
+
1369
+ // src/scene/highlight.ts
1370
+ function highlightIntensity(region, tMs) {
1371
+ const fade = region.fadeMs ?? 120;
1372
+ if (tMs <= region.inMs - fade || tMs >= region.outMs + fade) return 0;
1373
+ const rampIn = invLerp(region.inMs - fade, region.inMs, tMs);
1374
+ const rampOut = 1 - invLerp(region.outMs, region.outMs + fade, tMs);
1375
+ return clamp(Math.min(rampIn, rampOut), 0, 1);
1376
+ }
1377
+ function noteSetXRange(onsetsMs, windowMs, timeToX2) {
1378
+ let lo = Infinity;
1379
+ let hi = -Infinity;
1380
+ for (const on of onsetsMs) {
1381
+ if (on < windowMs[0] || on > windowMs[1]) continue;
1382
+ const x = timeToX2(on);
1383
+ if (x < lo) lo = x;
1384
+ if (x > hi) hi = x;
1385
+ }
1386
+ if (lo === Infinity) return null;
1387
+ return { x: lo, w: Math.max(0, hi - lo) };
1388
+ }
1389
+ function drawHighlight(ctx, region, tMs, accent) {
1390
+ const a = highlightIntensity(region, tMs);
1391
+ if (a <= 0) return;
1392
+ ctx.save();
1393
+ ctx.globalAlpha = a * 0.35;
1394
+ ctx.fillStyle = region.color ?? accent;
1395
+ ctx.fillRect(region.x, region.y, region.w, region.h);
1396
+ ctx.restore();
1397
+ }
1398
+
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;
1483
+ }
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]);
1544
+ }
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)];
1550
+ }
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
+
1430
2587
  // src/scene/registry.ts
1431
2588
  var REGISTRY = /* @__PURE__ */ new Map();
1432
2589
  function registerLayer(factory) {
@@ -1451,6 +2608,14 @@ registerLayer(portraitFactory);
1451
2608
  registerLayer(spectrumFactory);
1452
2609
  registerLayer(brandingFactory);
1453
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);
1454
2619
 
1455
2620
  // src/scene/camera.ts
1456
2621
  function cameraTransform(cam, W, H) {
@@ -1506,7 +2671,8 @@ var SCREEN_PINNED_KEYS = /* @__PURE__ */ new Set([
1506
2671
  "hook",
1507
2672
  "reveal",
1508
2673
  "portrait",
1509
- "safe-guides"
2674
+ "safe-guides",
2675
+ "mcq-card"
1510
2676
  ]);
1511
2677
  async function buildScene(opts) {
1512
2678
  const { spec, theme, score } = opts;
@@ -1597,36 +2763,6 @@ async function recordSceneSpec(opts) {
1597
2763
  });
1598
2764
  }
1599
2765
 
1600
- // src/scene/highlight.ts
1601
- function highlightIntensity(region, tMs) {
1602
- const fade = region.fadeMs ?? 120;
1603
- if (tMs <= region.inMs - fade || tMs >= region.outMs + fade) return 0;
1604
- const rampIn = invLerp(region.inMs - fade, region.inMs, tMs);
1605
- const rampOut = 1 - invLerp(region.outMs, region.outMs + fade, tMs);
1606
- return clamp(Math.min(rampIn, rampOut), 0, 1);
1607
- }
1608
- function noteSetXRange(onsetsMs, windowMs, timeToX) {
1609
- let lo = Infinity;
1610
- let hi = -Infinity;
1611
- for (const on of onsetsMs) {
1612
- if (on < windowMs[0] || on > windowMs[1]) continue;
1613
- const x = timeToX(on);
1614
- if (x < lo) lo = x;
1615
- if (x > hi) hi = x;
1616
- }
1617
- if (lo === Infinity) return null;
1618
- return { x: lo, w: Math.max(0, hi - lo) };
1619
- }
1620
- function drawHighlight(ctx, region, tMs, accent) {
1621
- const a = highlightIntensity(region, tMs);
1622
- if (a <= 0) return;
1623
- ctx.save();
1624
- ctx.globalAlpha = a * 0.35;
1625
- ctx.fillStyle = region.color ?? accent;
1626
- ctx.fillRect(region.x, region.y, region.w, region.h);
1627
- ctx.restore();
1628
- }
1629
-
1630
2766
  // src/scene/audioLayers.ts
1631
2767
  function countInSchedule(opts) {
1632
2768
  const beats = opts.beats ?? 4;
@@ -1917,28 +3053,167 @@ function promoCardsDemoSpec(opts = {}) {
1917
3053
  audio: { voicing: "reading" }
1918
3054
  };
1919
3055
  }
3056
+
3057
+ // src/scene/demos/extendedDemo.ts
3058
+ function countingDegreeDemoSpec(opts = {}) {
3059
+ return {
3060
+ size: opts.size ?? [1080, 1920],
3061
+ theme: opts.theme ?? "rsr",
3062
+ durationMode: "audio",
3063
+ timeline: [
3064
+ {
3065
+ at: [0, "end"],
3066
+ layers: [
3067
+ { k: "background", p: { style: "paper" } },
3068
+ { k: "keyboard", p: { range: opts.range ?? "auto" } },
3069
+ { k: "degree-labels", p: { mode: opts.labelMode ?? "degree" } },
3070
+ { k: "counting-track", p: { subdiv: opts.subdiv ?? 2 } }
3071
+ ]
3072
+ }
3073
+ ]
3074
+ };
3075
+ }
3076
+ function staffRayDemoSpec(rendered, opts = {}) {
3077
+ return {
3078
+ size: opts.size ?? [1080, 1920],
3079
+ theme: opts.theme ?? "rsr",
3080
+ durationMode: "audio",
3081
+ timeline: [
3082
+ {
3083
+ at: [0, "end"],
3084
+ layers: [
3085
+ { k: "background", p: { style: "paper" } },
3086
+ // notation in a top band; keyboard a short strip at the very bottom, so
3087
+ // the rays span a clear vertical gap between staff and keys.
3088
+ { k: "notation", p: { rendered, bandHeight: (opts.size?.[1] ?? 1920) * 0.28 } },
3089
+ { k: "keyboard", p: { range: opts.range ?? "88", height: (opts.size?.[1] ?? 1920) * 0.16 } },
3090
+ { k: "staff-keyboard-ray", p: {} }
3091
+ ]
3092
+ }
3093
+ ]
3094
+ };
3095
+ }
3096
+ function harmonyDemoSpec(chordTrack, opts = {}) {
3097
+ return {
3098
+ size: opts.size ?? [1080, 1920],
3099
+ theme: opts.theme ?? "rsr",
3100
+ durationMode: "audio",
3101
+ timeline: [
3102
+ {
3103
+ at: [0, "end"],
3104
+ layers: [
3105
+ { k: "background", p: { style: "ink" } },
3106
+ { k: "keyboard", p: { range: opts.range ?? "auto" } },
3107
+ { k: "functional-harmony", p: { chordTrack, modes: ["band", "keys"] } }
3108
+ ]
3109
+ }
3110
+ ]
3111
+ };
3112
+ }
3113
+ function mcqDemoSpec(quiz, opts = {}) {
3114
+ return {
3115
+ size: opts.size ?? [1080, 1920],
3116
+ theme: opts.theme ?? "rsr",
3117
+ durationMode: "fixed",
3118
+ durationSec: quiz.endMs / 1e3,
3119
+ timeline: [
3120
+ {
3121
+ at: [0, "end"],
3122
+ layers: [
3123
+ { k: "background", p: { style: "paper" } },
3124
+ { k: "mcq-card", p: { quiz } }
3125
+ ]
3126
+ }
3127
+ ]
3128
+ };
3129
+ }
3130
+ function circleOfFifthsDemoSpec(opts = {}) {
3131
+ const p = {};
3132
+ if (opts.toKey) {
3133
+ p.toKey = opts.toKey;
3134
+ p.fromMs = opts.fromMs ?? 0;
3135
+ p.toMs = opts.toMs ?? 2e3;
3136
+ }
3137
+ return {
3138
+ size: opts.size ?? [1080, 1920],
3139
+ theme: opts.theme ?? "rsr",
3140
+ durationMode: "audio",
3141
+ timeline: [
3142
+ {
3143
+ at: [0, "end"],
3144
+ layers: [
3145
+ { k: "background", p: { style: "paper" } },
3146
+ { k: "circle-of-fifths", p }
3147
+ ]
3148
+ }
3149
+ ]
3150
+ };
3151
+ }
3152
+ function contourMinimapDemoSpec(opts = {}) {
3153
+ const mini = {};
3154
+ if (opts.sections) mini.sections = opts.sections;
3155
+ else if (opts.measureCount) mini.measureCount = opts.measureCount;
3156
+ return {
3157
+ size: opts.size ?? [1080, 1920],
3158
+ theme: opts.theme ?? "rsr",
3159
+ durationMode: "audio",
3160
+ timeline: [
3161
+ {
3162
+ at: [0, "end"],
3163
+ layers: [
3164
+ { k: "background", p: { style: "paper" } },
3165
+ { k: "pitch-contour", p: {} },
3166
+ { k: "section-minimap", p: mini }
3167
+ ]
3168
+ }
3169
+ ]
3170
+ };
3171
+ }
1920
3172
  export {
3173
+ DEFAULT_FUNCTION_COLORS,
3174
+ FIFTHS_MAJOR,
3175
+ FIFTHS_MINOR,
1921
3176
  FOLLOW_BARS,
1922
3177
  FOLLOW_PAD,
1923
3178
  PIANO_HIGH,
1924
3179
  PIANO_LOW,
3180
+ activeChord,
1925
3181
  activeCue,
3182
+ activeSection,
3183
+ animatedSlot,
1926
3184
  applySchedule,
1927
3185
  applyToContext,
1928
3186
  assertGate,
3187
+ ballArc,
3188
+ ballX,
3189
+ beatGrid,
3190
+ beatPhase,
1929
3191
  blackKeys,
3192
+ bpmOf,
1930
3193
  brandingFactory,
1931
3194
  buildScene,
1932
3195
  cameraForFollow,
1933
3196
  cameraTransform,
3197
+ circleOfFifthsDemoSpec,
3198
+ circleOfFifthsFactory,
1934
3199
  clamp,
1935
3200
  clickTrackSchedule,
3201
+ contourMinimapDemoSpec,
3202
+ contourPoints,
3203
+ contourPolyline,
1936
3204
  countInLeadSec,
1937
3205
  countInSchedule,
3206
+ countdownRemaining,
3207
+ countdownSeconds,
3208
+ countingDegreeDemoSpec,
3209
+ countingTrackFactory,
1938
3210
  cropAroundBox,
1939
3211
  ctaFactory,
1940
3212
  cubicEaseInOut,
1941
3213
  cueOpacity,
3214
+ degreeLabel,
3215
+ degreeLabelsFactory,
3216
+ dotAt,
1942
3217
  drawCaption,
1943
3218
  drawHighlight,
1944
3219
  droneSchedule,
@@ -1953,10 +3228,14 @@ export {
1953
3228
  followBoxAt,
1954
3229
  followSrcBox,
1955
3230
  followWindowStart,
3231
+ fracSlotPoint,
1956
3232
  frameRect,
3233
+ functionColor,
3234
+ functionalHarmonyFactory,
1957
3235
  getKeyboardLayout,
1958
3236
  getLayerFactory,
1959
3237
  getNotationEngraving,
3238
+ harmonyDemoSpec,
1960
3239
  highlightIntensity,
1961
3240
  hookFactory,
1962
3241
  identityCamera,
@@ -1967,6 +3246,7 @@ export {
1967
3246
  keyCenterX,
1968
3247
  keyColumnWidth,
1969
3248
  keyRect,
3249
+ keySlot,
1970
3250
  keyboardFactory,
1971
3251
  keyboardLayout,
1972
3252
  lerp,
@@ -1974,16 +3254,32 @@ export {
1974
3254
  lerpCamera,
1975
3255
  linear,
1976
3256
  mapBoxThroughLayout,
3257
+ mcqCardFactory,
3258
+ mcqDemoSpec,
1977
3259
  measureColumnsFromLayout,
1978
3260
  measureCount,
1979
3261
  measureSpanBox,
3262
+ measureSpans,
3263
+ timeToX as minimapTimeToX,
3264
+ msPerBeat,
1980
3265
  notationFactory,
1981
3266
  notationLayout,
1982
3267
  noteColor,
1983
3268
  noteSetXRange,
3269
+ parseKey,
3270
+ parseTimeSig,
3271
+ pcToSlot,
3272
+ pitchAt,
3273
+ pitchContourFactory,
3274
+ pitchRange,
1984
3275
  playheadLine,
1985
3276
  portraitFactory,
3277
+ progress01,
3278
+ projectPoint,
1986
3279
  promoCardsDemoSpec,
3280
+ quizPhase,
3281
+ rayEndpoints,
3282
+ rayPointAt,
1987
3283
  recordSceneSpec,
1988
3284
  registerLayer,
1989
3285
  registeredKeys,
@@ -1991,16 +3287,26 @@ export {
1991
3287
  resolveKeyboardLayout,
1992
3288
  resolveTimeline,
1993
3289
  revealFactory,
3290
+ revealProgress,
1994
3291
  runGate,
1995
3292
  safeGuidesFactory,
1996
3293
  scoreFromMusicXML,
1997
3294
  scorePitchSpan,
1998
3295
  scrollCursorFactory,
3296
+ sectionMinimapFactory,
1999
3297
  setFollowLayoutProvider,
2000
3298
  setKeyboardLayout,
2001
3299
  setNotationEngraving,
2002
- setupHeadlessDom,
3300
+ slotAngle,
3301
+ slotPc,
3302
+ slotPoint,
2003
3303
  spectrumFactory,
3304
+ staffAnchor,
3305
+ staffKeyboardRayFactory,
3306
+ staffRayDemoSpec,
3307
+ validateChordTrack,
3308
+ validateQuiz,
3309
+ validateSections,
2004
3310
  visualTimelineMs,
2005
3311
  whiteKeys,
2006
3312
  worldToViewport