@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.
- package/README.md +25 -0
- package/dist/audio.d.ts +36 -0
- package/dist/audio.js +55 -0
- package/dist/audio.js.map +1 -1
- package/dist/scene/headless.d.ts +10 -0
- package/dist/scene/headless.js +108 -0
- package/dist/scene/headless.js.map +1 -0
- package/dist/scene/index.d.ts +605 -87
- package/dist/scene/index.js +1625 -296
- package/dist/scene/index.js.map +1 -1
- package/package.json +5 -1
package/dist/scene/index.js
CHANGED
|
@@ -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
|
|
96
|
-
return (wholeNotes) => wholeNotes * BEATS_PER_WHOLE_NOTE *
|
|
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.
|
|
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/
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
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
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
const
|
|
1553
|
-
|
|
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
|
-
|
|
1573
|
-
|
|
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
|
-
|
|
1584
|
-
const {
|
|
1585
|
-
const
|
|
1586
|
-
const
|
|
1587
|
-
|
|
1588
|
-
|
|
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,
|
|
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 =
|
|
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/
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
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
|
-
|
|
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
|
|
1649
|
-
|
|
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
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
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
|
-
|
|
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
|