@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.
- package/README.md +25 -0
- 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 +459 -10
- package/dist/scene/index.js +1446 -140
- 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,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
|
-
|
|
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
|