@real-music-packages/web-core 0.10.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/chunk-HXTRNE74.js +325 -0
- package/dist/chunk-HXTRNE74.js.map +1 -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 +1399 -0
- package/dist/scene/index.js +3314 -0
- package/dist/scene/index.js.map +1 -0
- package/dist/video.js +16 -307
- package/dist/video.js.map +1 -1
- package/package.json +14 -1
|
@@ -0,0 +1,3314 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ctaScene,
|
|
3
|
+
drawSafeGuides,
|
|
4
|
+
hookScene,
|
|
5
|
+
loadPortrait,
|
|
6
|
+
recordScenes,
|
|
7
|
+
revealScene,
|
|
8
|
+
safeBox
|
|
9
|
+
} from "../chunk-HXTRNE74.js";
|
|
10
|
+
|
|
11
|
+
// src/scene/score.ts
|
|
12
|
+
var BEATS_PER_WHOLE_NOTE = 4;
|
|
13
|
+
var MAX_ITERATOR_STEPS = 2e5;
|
|
14
|
+
async function scoreFromMusicXML(xml, opts = {}) {
|
|
15
|
+
const osmd = opts.osmdFactory ? opts.osmdFactory() : await defaultOsmd();
|
|
16
|
+
let loadError = null;
|
|
17
|
+
try {
|
|
18
|
+
await osmd.load(xml);
|
|
19
|
+
} catch (e) {
|
|
20
|
+
loadError = e;
|
|
21
|
+
}
|
|
22
|
+
const sheet = osmd.sheet;
|
|
23
|
+
if (!sheet || !sheet.SourceMeasures?.length) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
"scoreFromMusicXML: OSMD produced no source model" + (loadError ? `: ${loadError.message}` : "")
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
const tempoMap = resolveTempo(sheet, opts);
|
|
29
|
+
const wholeNoteToMs = makeWholeNoteToMs(tempoMap.segments[0].bpm);
|
|
30
|
+
const it = sheet.MusicPartManager.getIterator();
|
|
31
|
+
const notes = [];
|
|
32
|
+
let steps = 0;
|
|
33
|
+
while (!it.EndReached && steps++ < MAX_ITERATOR_STEPS) {
|
|
34
|
+
const enrolled = it.CurrentEnrolledTimestamp?.RealValue ?? 0;
|
|
35
|
+
const onsetMs = wholeNoteToMs(enrolled);
|
|
36
|
+
const voiceEntries = it.CurrentAudibleVoiceEntries?.() ?? [];
|
|
37
|
+
for (const ve of voiceEntries) {
|
|
38
|
+
const voiceId = ve.ParentVoice?.VoiceId ?? 1;
|
|
39
|
+
const sse = ve.ParentSourceStaffEntry;
|
|
40
|
+
const staffIdx = sse?.ParentStaff?.idInMusicSheet ?? 0;
|
|
41
|
+
const instrument = sse?.ParentStaff?.ParentInstrument;
|
|
42
|
+
const hand = inferHand(instrument, sse?.ParentStaff);
|
|
43
|
+
for (const n of ve.Notes ?? []) {
|
|
44
|
+
if (n.isRestFlag || n.IsRest) continue;
|
|
45
|
+
const pitch = n.Pitch;
|
|
46
|
+
if (!pitch) continue;
|
|
47
|
+
const tie = n.NoteTie;
|
|
48
|
+
if (tie && tie.StartNote && tie.StartNote !== n) continue;
|
|
49
|
+
const wholeNoteLen = tie && tie.StartNote === n && tie.Duration ? tie.Duration.RealValue : n.Length?.RealValue ?? 0;
|
|
50
|
+
const halfTone = pitch.getHalfTone?.();
|
|
51
|
+
const pitchMidi = halfTone == null ? NaN : halfTone + 12;
|
|
52
|
+
const note = {
|
|
53
|
+
pitchMidi,
|
|
54
|
+
step: fundamentalToStep(pitch.FundamentalNote),
|
|
55
|
+
alter: pitch.AccidentalHalfTones ?? 0,
|
|
56
|
+
octave: octaveFromMidi(pitchMidi),
|
|
57
|
+
onsetMs: Math.round(onsetMs),
|
|
58
|
+
durMs: Math.round(wholeNoteToMs(wholeNoteLen)),
|
|
59
|
+
staff: staffIdx,
|
|
60
|
+
voice: voiceId,
|
|
61
|
+
hand
|
|
62
|
+
};
|
|
63
|
+
const lyric = extractLyric(ve);
|
|
64
|
+
if (lyric != null) note.lyric = lyric;
|
|
65
|
+
const fingering = extractFingering(n);
|
|
66
|
+
if (fingering != null) note.fingering = fingering;
|
|
67
|
+
notes.push(note);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
it.moveToNext();
|
|
71
|
+
}
|
|
72
|
+
notes.sort((a, b) => a.onsetMs - b.onsetMs || a.pitchMidi - b.pitchMidi);
|
|
73
|
+
const durationMs = notes.reduce((mx, n) => Math.max(mx, n.onsetMs + n.durMs), 0);
|
|
74
|
+
return {
|
|
75
|
+
notes,
|
|
76
|
+
tempoMap,
|
|
77
|
+
durationMs,
|
|
78
|
+
key: readKey(sheet),
|
|
79
|
+
timeSig: readTimeSig(sheet),
|
|
80
|
+
title: sheet.TitleString || void 0,
|
|
81
|
+
composer: sheet.ComposerString || void 0
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function resolveTempo(sheet, opts) {
|
|
85
|
+
if (opts.tempoOverride && opts.tempoOverride > 0) {
|
|
86
|
+
return { source: "override", segments: [{ atMs: 0, bpm: opts.tempoOverride }] };
|
|
87
|
+
}
|
|
88
|
+
const xmlTempo = sheet.DefaultStartTempoInBpm;
|
|
89
|
+
if (xmlTempo && xmlTempo > 0) {
|
|
90
|
+
return { source: "xml", segments: [{ atMs: 0, bpm: xmlTempo }] };
|
|
91
|
+
}
|
|
92
|
+
return { source: "fallback", segments: [{ atMs: 0, bpm: opts.tempoFallback ?? 100 }] };
|
|
93
|
+
}
|
|
94
|
+
function makeWholeNoteToMs(bpm) {
|
|
95
|
+
const msPerBeat2 = 6e4 / bpm;
|
|
96
|
+
return (wholeNotes) => wholeNotes * BEATS_PER_WHOLE_NOTE * msPerBeat2;
|
|
97
|
+
}
|
|
98
|
+
function inferHand(instrument, staff) {
|
|
99
|
+
const staves = instrument?.Staves;
|
|
100
|
+
if (Array.isArray(staves) && staves.length >= 2 && staff) {
|
|
101
|
+
const local = staves.indexOf(staff);
|
|
102
|
+
if (local >= 0) return local === 0 ? "R" : "L";
|
|
103
|
+
}
|
|
104
|
+
return "R";
|
|
105
|
+
}
|
|
106
|
+
function fundamentalToStep(f) {
|
|
107
|
+
return { 0: "C", 2: "D", 4: "E", 5: "F", 7: "G", 9: "A", 11: "B" }[f] ?? "?";
|
|
108
|
+
}
|
|
109
|
+
function octaveFromMidi(midi) {
|
|
110
|
+
if (!Number.isFinite(midi)) return 0;
|
|
111
|
+
return Math.floor(midi / 12) - 1;
|
|
112
|
+
}
|
|
113
|
+
function extractLyric(ve) {
|
|
114
|
+
const dict = ve.LyricsEntries;
|
|
115
|
+
if (!dict || !dict.size) return void 0;
|
|
116
|
+
const first = [...dict.values()][0];
|
|
117
|
+
const text = first?.Text?.text ?? first?.Text;
|
|
118
|
+
return typeof text === "string" && text.length ? text : void 0;
|
|
119
|
+
}
|
|
120
|
+
function extractFingering(note) {
|
|
121
|
+
const raw = note?.Fingering?.value ?? note?.Fingering?.Value ?? note?.Fingering;
|
|
122
|
+
const n = typeof raw === "string" ? parseInt(raw, 10) : typeof raw === "number" ? raw : NaN;
|
|
123
|
+
return Number.isFinite(n) ? n : void 0;
|
|
124
|
+
}
|
|
125
|
+
var MAJOR_KEYS = ["Cb", "Gb", "Db", "Ab", "Eb", "Bb", "F", "C", "G", "D", "A", "E", "B", "F#", "C#"];
|
|
126
|
+
var MINOR_KEYS = ["Ab", "Eb", "Bb", "F", "C", "G", "D", "A", "E", "B", "F#", "C#", "G#", "D#", "A#"];
|
|
127
|
+
function readKey(sheet) {
|
|
128
|
+
for (const entry of sheet?.SourceMeasures?.[0]?.FirstInstructionsStaffEntries ?? []) {
|
|
129
|
+
for (const ins of entry?.Instructions ?? []) {
|
|
130
|
+
if (typeof ins?.keyType === "number") {
|
|
131
|
+
const fifths = ins.keyType;
|
|
132
|
+
const idx = fifths + 7;
|
|
133
|
+
if (idx < 0 || idx > 14) return void 0;
|
|
134
|
+
const isMinor = ins.mode === 2;
|
|
135
|
+
const name = (isMinor ? MINOR_KEYS : MAJOR_KEYS)[idx];
|
|
136
|
+
return name ? `${name} ${isMinor ? "minor" : "major"}` : void 0;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return void 0;
|
|
141
|
+
}
|
|
142
|
+
function readTimeSig(sheet) {
|
|
143
|
+
const ts = sheet?.SourceMeasures?.[0]?.ActiveTimeSignature;
|
|
144
|
+
if (ts && Number.isFinite(ts.Numerator) && Number.isFinite(ts.Denominator)) {
|
|
145
|
+
return `${ts.Numerator}/${ts.Denominator}`;
|
|
146
|
+
}
|
|
147
|
+
return void 0;
|
|
148
|
+
}
|
|
149
|
+
async function defaultOsmd() {
|
|
150
|
+
if (typeof document === "undefined") {
|
|
151
|
+
throw new Error(
|
|
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
|
+
);
|
|
154
|
+
}
|
|
155
|
+
const mod = await import("opensheetmusicdisplay");
|
|
156
|
+
const OpenSheetMusicDisplay = mod.OpenSheetMusicDisplay ?? mod.default?.OpenSheetMusicDisplay;
|
|
157
|
+
return new OpenSheetMusicDisplay(document.createElement("div"), {
|
|
158
|
+
backend: "svg",
|
|
159
|
+
autoResize: false
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// src/scene/math.ts
|
|
164
|
+
var clamp = (x, lo, hi) => x < lo ? lo : x > hi ? hi : x;
|
|
165
|
+
var lerp = (a, b, t) => a + (b - a) * t;
|
|
166
|
+
var invLerp = (a, b, x) => a === b ? 0 : clamp((x - a) / (b - a), 0, 1);
|
|
167
|
+
var linear = (t) => t;
|
|
168
|
+
var easeInOut = (t) => {
|
|
169
|
+
const c = clamp(t, 0, 1);
|
|
170
|
+
return c * c * (3 - 2 * c);
|
|
171
|
+
};
|
|
172
|
+
var easeIn = (t) => {
|
|
173
|
+
const c = clamp(t, 0, 1);
|
|
174
|
+
return c * c;
|
|
175
|
+
};
|
|
176
|
+
var easeOut = (t) => {
|
|
177
|
+
const c = clamp(t, 0, 1);
|
|
178
|
+
return 1 - (1 - c) * (1 - c);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// src/scene/caption.ts
|
|
182
|
+
function activeCue(script, tMs) {
|
|
183
|
+
let found = null;
|
|
184
|
+
for (const cue of script) {
|
|
185
|
+
if (tMs >= cue.inMs && tMs < cue.outMs) found = cue;
|
|
186
|
+
}
|
|
187
|
+
return found;
|
|
188
|
+
}
|
|
189
|
+
function cueOpacity(cue, tMs, fadeMs = 150) {
|
|
190
|
+
if (tMs < cue.inMs || tMs >= cue.outMs) return 0;
|
|
191
|
+
const inA = invLerp(cue.inMs, cue.inMs + fadeMs, tMs);
|
|
192
|
+
const outA = 1 - invLerp(cue.outMs - fadeMs, cue.outMs, tMs);
|
|
193
|
+
return clamp(Math.min(inA, outA), 0, 1);
|
|
194
|
+
}
|
|
195
|
+
function wrap(ctx, text, maxW, maxLines) {
|
|
196
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
197
|
+
const lines = [];
|
|
198
|
+
let cur = "";
|
|
199
|
+
for (const w of words) {
|
|
200
|
+
const next = cur ? `${cur} ${w}` : w;
|
|
201
|
+
if (ctx.measureText(next).width > maxW && cur) {
|
|
202
|
+
lines.push(cur);
|
|
203
|
+
cur = w;
|
|
204
|
+
if (lines.length === maxLines) break;
|
|
205
|
+
} else {
|
|
206
|
+
cur = next;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (cur && lines.length < maxLines) lines.push(cur);
|
|
210
|
+
return lines;
|
|
211
|
+
}
|
|
212
|
+
function drawCaption(ctx, script, tMs, safe, theme, style = {}) {
|
|
213
|
+
const cue = activeCue(script, tMs);
|
|
214
|
+
if (!cue) return;
|
|
215
|
+
const alpha = cueOpacity(cue, tMs);
|
|
216
|
+
if (alpha <= 0) return;
|
|
217
|
+
const size = style.size ?? 44;
|
|
218
|
+
const yFrac = style.yFrac ?? 0.92;
|
|
219
|
+
const baseY = safe.top + safe.h * yFrac;
|
|
220
|
+
ctx.save();
|
|
221
|
+
ctx.globalAlpha = alpha;
|
|
222
|
+
ctx.textAlign = "center";
|
|
223
|
+
ctx.textBaseline = "middle";
|
|
224
|
+
ctx.font = `bold ${size}px ${theme.fontBody}`;
|
|
225
|
+
const lines = wrap(ctx, cue.text, safe.w * 0.92, 3);
|
|
226
|
+
const lineH = size * 1.2;
|
|
227
|
+
const blockH = lines.length * lineH;
|
|
228
|
+
const top = baseY - blockH;
|
|
229
|
+
if (style.pill !== false) {
|
|
230
|
+
let maxW = 0;
|
|
231
|
+
for (const ln of lines) maxW = Math.max(maxW, ctx.measureText(ln).width);
|
|
232
|
+
const padX = size * 0.5;
|
|
233
|
+
const padY = size * 0.3;
|
|
234
|
+
ctx.fillStyle = "rgba(0,0,0,0.55)";
|
|
235
|
+
const pillW = Math.min(safe.w, maxW + padX * 2);
|
|
236
|
+
ctx.fillRect(safe.cx - pillW / 2, top - padY, pillW, blockH + padY * 2);
|
|
237
|
+
}
|
|
238
|
+
ctx.fillStyle = "#ffffff";
|
|
239
|
+
lines.forEach((ln, i) => {
|
|
240
|
+
ctx.fillText(ln, safe.cx, top + lineH * (i + 0.5));
|
|
241
|
+
});
|
|
242
|
+
ctx.restore();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// src/scene/layers/demo.ts
|
|
246
|
+
function backgroundLayer() {
|
|
247
|
+
let fill = "#000000";
|
|
248
|
+
return {
|
|
249
|
+
key: "background",
|
|
250
|
+
init(ctx, props) {
|
|
251
|
+
const s = props.style ?? "paper";
|
|
252
|
+
fill = s === "paper" ? ctx.theme.paper : s === "ink" ? ctx.theme.ink : s;
|
|
253
|
+
},
|
|
254
|
+
draw(ctx) {
|
|
255
|
+
const c = ctx.ctx2d;
|
|
256
|
+
c.save();
|
|
257
|
+
c.fillStyle = fill;
|
|
258
|
+
c.fillRect(0, 0, ctx.W, ctx.H);
|
|
259
|
+
c.restore();
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
var backgroundFactory = {
|
|
264
|
+
key: "background",
|
|
265
|
+
create: backgroundLayer,
|
|
266
|
+
validateProps(props) {
|
|
267
|
+
const errs = [];
|
|
268
|
+
if (props == null || typeof props !== "object") return ["background: props must be an object"];
|
|
269
|
+
const p = props;
|
|
270
|
+
if (p.style != null && typeof p.style !== "string") errs.push("background.style must be a string");
|
|
271
|
+
return errs;
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
function captionLayer() {
|
|
275
|
+
let script = [];
|
|
276
|
+
let size;
|
|
277
|
+
let yFrac;
|
|
278
|
+
return {
|
|
279
|
+
key: "caption",
|
|
280
|
+
init(_ctx, props) {
|
|
281
|
+
script = props.script ?? [];
|
|
282
|
+
size = props.size;
|
|
283
|
+
yFrac = props.yFrac;
|
|
284
|
+
},
|
|
285
|
+
draw(ctx, tMs) {
|
|
286
|
+
drawCaption(ctx.ctx2d, script, tMs, ctx.safeBox, ctx.theme, { size, yFrac });
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
var captionFactory = {
|
|
291
|
+
key: "caption",
|
|
292
|
+
create: captionLayer,
|
|
293
|
+
validateProps(props) {
|
|
294
|
+
const errs = [];
|
|
295
|
+
if (props == null || typeof props !== "object") return ["caption: props must be an object"];
|
|
296
|
+
const p = props;
|
|
297
|
+
if (!Array.isArray(p.script)) {
|
|
298
|
+
errs.push("caption.script must be an array of cues");
|
|
299
|
+
} else {
|
|
300
|
+
p.script.forEach((cue, i) => {
|
|
301
|
+
const c = cue;
|
|
302
|
+
if (typeof c?.text !== "string") errs.push(`caption.script[${i}].text must be a string`);
|
|
303
|
+
if (typeof c?.inMs !== "number" || typeof c?.outMs !== "number")
|
|
304
|
+
errs.push(`caption.script[${i}] needs numeric inMs/outMs`);
|
|
305
|
+
else if (c.outMs <= c.inMs) errs.push(`caption.script[${i}] outMs must be > inMs`);
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
if (p.size != null && typeof p.size !== "number") errs.push("caption.size must be a number");
|
|
309
|
+
if (p.yFrac != null && typeof p.yFrac !== "number") errs.push("caption.yFrac must be a number");
|
|
310
|
+
return errs;
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// src/scene/notationGeometry.ts
|
|
315
|
+
var FOLLOW_BARS = 2;
|
|
316
|
+
var FOLLOW_PAD = 1.06;
|
|
317
|
+
function cubicEaseInOut(t) {
|
|
318
|
+
if (t < 0.5) return 4 * t * t * t;
|
|
319
|
+
const f = 2 * t - 2;
|
|
320
|
+
return 0.5 * f * f * f + 1;
|
|
321
|
+
}
|
|
322
|
+
function lerpBox(a, b, e) {
|
|
323
|
+
return {
|
|
324
|
+
x: a.x + (b.x - a.x) * e,
|
|
325
|
+
y: a.y + (b.y - a.y) * e,
|
|
326
|
+
w: a.w + (b.w - a.w) * e,
|
|
327
|
+
h: a.h + (b.h - a.h) * e
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
function cropAroundBox(box, aspect, pad, cw, ch) {
|
|
331
|
+
const bw = box.w * pad;
|
|
332
|
+
const bh = box.h * pad;
|
|
333
|
+
let w = Math.max(bw, bh * aspect);
|
|
334
|
+
let h = w / aspect;
|
|
335
|
+
w = Math.min(w, cw);
|
|
336
|
+
h = Math.min(h, ch);
|
|
337
|
+
const ccx = box.x + box.w / 2;
|
|
338
|
+
const ccy = box.y + box.h / 2;
|
|
339
|
+
let x = ccx - w / 2;
|
|
340
|
+
let y = ccy - h / 2;
|
|
341
|
+
x = Math.max(0, Math.min(cw - w, x));
|
|
342
|
+
y = Math.max(0, Math.min(ch - h, y));
|
|
343
|
+
return { x, y, w, h };
|
|
344
|
+
}
|
|
345
|
+
function measureSpanBox(rn, lo, hi) {
|
|
346
|
+
const ms = (rn.measures ?? []).filter((m) => m.index >= lo && m.index < hi).map((m) => m.box);
|
|
347
|
+
if (!ms.length) return null;
|
|
348
|
+
const x0 = Math.min(...ms.map((b) => b.x));
|
|
349
|
+
const y0 = Math.min(...ms.map((b) => b.y));
|
|
350
|
+
const x1 = Math.max(...ms.map((b) => b.x + b.w));
|
|
351
|
+
const y1 = Math.max(...ms.map((b) => b.y + b.h));
|
|
352
|
+
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
|
|
353
|
+
}
|
|
354
|
+
function firstMeasureBox(rn) {
|
|
355
|
+
const first = (rn.measures ?? []).filter((m) => m.index === 0).map((m) => m.box);
|
|
356
|
+
if (!first.length) return rn.systems?.[0] ?? null;
|
|
357
|
+
const x0 = Math.min(...first.map((b) => b.x));
|
|
358
|
+
const y0 = Math.min(...first.map((b) => b.y));
|
|
359
|
+
const x1 = Math.max(...first.map((b) => b.x + b.w));
|
|
360
|
+
const y1 = Math.max(...first.map((b) => b.y + b.h));
|
|
361
|
+
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
|
|
362
|
+
}
|
|
363
|
+
function measureCount(rn) {
|
|
364
|
+
const idx = (rn.measures ?? []).map((m) => m.index);
|
|
365
|
+
return idx.length ? Math.max(...idx) + 1 : 0;
|
|
366
|
+
}
|
|
367
|
+
function followBoxAt(rn, posMeasures) {
|
|
368
|
+
const cur = Math.floor(posMeasures);
|
|
369
|
+
const frac = posMeasures - cur;
|
|
370
|
+
const a = measureSpanBox(rn, cur, cur + FOLLOW_BARS);
|
|
371
|
+
const b = measureSpanBox(rn, cur + 1, cur + 1 + FOLLOW_BARS) ?? a;
|
|
372
|
+
if (!a) return b;
|
|
373
|
+
if (!b) return a;
|
|
374
|
+
return lerpBox(a, b, frac);
|
|
375
|
+
}
|
|
376
|
+
function followWindowStart(nBars, camProgress01) {
|
|
377
|
+
const posBars = camProgress01 * nBars;
|
|
378
|
+
const curBar = Math.floor(posBars);
|
|
379
|
+
const frac = posBars - curBar;
|
|
380
|
+
const scrollFrac = cubicEaseInOut(Math.min(1, Math.max(0, (frac - 0.66) / 0.34)));
|
|
381
|
+
const maxStart = Math.max(0, nBars - FOLLOW_BARS);
|
|
382
|
+
return Math.max(0, Math.min(maxStart, curBar + scrollFrac));
|
|
383
|
+
}
|
|
384
|
+
function notationLayout(rn, W, H, boxTop, boxH, opts = {}) {
|
|
385
|
+
const { zoom01 = 1, focusBox = null } = opts;
|
|
386
|
+
const sb = safeBox(W, H);
|
|
387
|
+
const maxW = sb.centeredW;
|
|
388
|
+
const c = rn.content && rn.content.w > 0 && rn.content.h > 0 ? rn.content : { x: 0, y: 0, w: rn.canvas.width || 1400, h: rn.canvas.height || 300 };
|
|
389
|
+
let src;
|
|
390
|
+
if (focusBox) {
|
|
391
|
+
const px = focusBox.w * (FOLLOW_PAD - 1) / 2;
|
|
392
|
+
const py = focusBox.h * (FOLLOW_PAD - 1) / 2;
|
|
393
|
+
src = {
|
|
394
|
+
x: Math.max(0, focusBox.x - px),
|
|
395
|
+
y: Math.max(0, focusBox.y - py),
|
|
396
|
+
w: focusBox.w + 2 * px,
|
|
397
|
+
h: focusBox.h + 2 * py
|
|
398
|
+
};
|
|
399
|
+
src.w = Math.min(src.w, rn.canvas.width - src.x);
|
|
400
|
+
src.h = Math.min(src.h, rn.canvas.height - src.y);
|
|
401
|
+
} else {
|
|
402
|
+
src = c;
|
|
403
|
+
const focal = firstMeasureBox(rn);
|
|
404
|
+
if (zoom01 < 1 && focal) {
|
|
405
|
+
const start = cropAroundBox(focal, c.w / c.h, 1.25, rn.canvas.width, rn.canvas.height);
|
|
406
|
+
src = lerpBox(start, c, cubicEaseInOut(zoom01));
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const srcAspect = src.w / src.h;
|
|
410
|
+
let dw = maxW;
|
|
411
|
+
let dh = dw / srcAspect;
|
|
412
|
+
if (dh > boxH) {
|
|
413
|
+
dh = boxH;
|
|
414
|
+
dw = dh * srcAspect;
|
|
415
|
+
}
|
|
416
|
+
const dx = (W - dw) / 2;
|
|
417
|
+
const dy = boxTop + (boxH - dh) / 2;
|
|
418
|
+
const fx = dw / src.w;
|
|
419
|
+
const fy = dh / src.h;
|
|
420
|
+
const map = (b) => ({
|
|
421
|
+
x: dx + (b.x - src.x) * fx,
|
|
422
|
+
y: dy + (b.y - src.y) * fy,
|
|
423
|
+
w: b.w * fx,
|
|
424
|
+
h: b.h * fy
|
|
425
|
+
});
|
|
426
|
+
const systems = (rn.systems ?? []).map(map);
|
|
427
|
+
const measures = (rn.measures ?? []).map((m) => ({
|
|
428
|
+
...m,
|
|
429
|
+
box: map(m.box),
|
|
430
|
+
noteStartX: dx + (m.noteStartX - src.x) * fx
|
|
431
|
+
}));
|
|
432
|
+
return { src, rect: { dx, dy, dw, dh }, systems, measures };
|
|
433
|
+
}
|
|
434
|
+
function measureColumnsFromLayout(measures) {
|
|
435
|
+
const byIndex = /* @__PURE__ */ new Map();
|
|
436
|
+
for (const m of measures) {
|
|
437
|
+
const cur = byIndex.get(m.index);
|
|
438
|
+
if (!cur) {
|
|
439
|
+
byIndex.set(m.index, { ...m.box, noteStartX: m.noteStartX });
|
|
440
|
+
} else {
|
|
441
|
+
const x0 = Math.min(cur.x, m.box.x);
|
|
442
|
+
const y0 = Math.min(cur.y, m.box.y);
|
|
443
|
+
const x1 = Math.max(cur.x + cur.w, m.box.x + m.box.w);
|
|
444
|
+
const y1 = Math.max(cur.y + cur.h, m.box.y + m.box.h);
|
|
445
|
+
byIndex.set(m.index, {
|
|
446
|
+
x: x0,
|
|
447
|
+
y: y0,
|
|
448
|
+
w: x1 - x0,
|
|
449
|
+
h: y1 - y0,
|
|
450
|
+
noteStartX: Math.min(cur.noteStartX, m.noteStartX)
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return [...byIndex.keys()].sort((a, b) => a - b).map((k) => byIndex.get(k));
|
|
455
|
+
}
|
|
456
|
+
function playheadLine(layout, t01) {
|
|
457
|
+
const tt = Math.max(0, Math.min(1, t01));
|
|
458
|
+
let alpha = 0.85;
|
|
459
|
+
if (tt < 0.03) alpha *= tt / 0.03;
|
|
460
|
+
if (tt > 0.94) alpha *= Math.max(0, (1 - tt) / 0.06);
|
|
461
|
+
if (alpha <= 0.02) return null;
|
|
462
|
+
const padV = 10;
|
|
463
|
+
let x, y0, y1;
|
|
464
|
+
const cols = measureColumnsFromLayout(layout.measures);
|
|
465
|
+
if (cols.length) {
|
|
466
|
+
const pos = tt * cols.length;
|
|
467
|
+
const i = Math.min(cols.length - 1, Math.floor(pos));
|
|
468
|
+
const m = cols[i];
|
|
469
|
+
const startX = Math.min(m.noteStartX, m.x + m.w);
|
|
470
|
+
x = startX + (pos - i) * (m.x + m.w - startX);
|
|
471
|
+
y0 = m.y - padV;
|
|
472
|
+
y1 = m.y + m.h + padV;
|
|
473
|
+
} else if (layout.systems.length) {
|
|
474
|
+
const pos = tt * layout.systems.length;
|
|
475
|
+
const row = Math.min(layout.systems.length - 1, Math.floor(pos));
|
|
476
|
+
const s = layout.systems[row];
|
|
477
|
+
x = s.x + (pos - row) * s.w;
|
|
478
|
+
y0 = s.y - padV;
|
|
479
|
+
y1 = s.y + s.h + padV;
|
|
480
|
+
} else {
|
|
481
|
+
const r = layout.rect;
|
|
482
|
+
x = r.dx + tt * r.dw;
|
|
483
|
+
y0 = r.dy - padV;
|
|
484
|
+
y1 = r.dy + r.dh + padV;
|
|
485
|
+
}
|
|
486
|
+
return { x, y0, y1, alpha };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// src/scene/engravingStore.ts
|
|
490
|
+
var STORE = /* @__PURE__ */ new WeakMap();
|
|
491
|
+
function keyFor(ctx) {
|
|
492
|
+
return ctx.audioClock;
|
|
493
|
+
}
|
|
494
|
+
function setNotationEngraving(ctx, eng) {
|
|
495
|
+
STORE.set(keyFor(ctx), eng);
|
|
496
|
+
}
|
|
497
|
+
function getNotationEngraving(ctx) {
|
|
498
|
+
return STORE.get(keyFor(ctx));
|
|
499
|
+
}
|
|
500
|
+
function setFollowLayoutProvider(ctx, fn) {
|
|
501
|
+
const e = STORE.get(keyFor(ctx));
|
|
502
|
+
if (e) e.followLayoutAt = fn;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// src/scene/layers/notation.ts
|
|
506
|
+
function notationLayer() {
|
|
507
|
+
let rn = null;
|
|
508
|
+
let base = null;
|
|
509
|
+
let propScale = 1;
|
|
510
|
+
let propBandTop;
|
|
511
|
+
let propBandHeight;
|
|
512
|
+
function bandTop(_ctx, sb) {
|
|
513
|
+
return propBandTop ?? sb.top;
|
|
514
|
+
}
|
|
515
|
+
function bandHeight(ctx, sb) {
|
|
516
|
+
const top = bandTop(ctx, sb);
|
|
517
|
+
return (propBandHeight ?? sb.bottom - top) * propScale;
|
|
518
|
+
}
|
|
519
|
+
return {
|
|
520
|
+
key: "notation",
|
|
521
|
+
async init(ctx, props) {
|
|
522
|
+
propScale = props.scale ?? 1;
|
|
523
|
+
propBandTop = props.bandTop;
|
|
524
|
+
propBandHeight = props.bandHeight;
|
|
525
|
+
if (props.rendered) {
|
|
526
|
+
rn = props.rendered;
|
|
527
|
+
} else if (props.xml) {
|
|
528
|
+
const { renderNotation } = await import("../promo.js");
|
|
529
|
+
rn = await renderNotation(props.xml, { bars: props.bars, paper: ctx.theme.paper });
|
|
530
|
+
} else {
|
|
531
|
+
throw new Error("notation layer: provide `rendered` or `xml`");
|
|
532
|
+
}
|
|
533
|
+
const sb = safeBox(ctx.W, ctx.H);
|
|
534
|
+
const top = bandTop(ctx, sb);
|
|
535
|
+
const height = bandHeight(ctx, sb);
|
|
536
|
+
base = notationLayout(rn, ctx.W, ctx.H, top, height, {});
|
|
537
|
+
setNotationEngraving(ctx, { rendered: rn, base, bandTop: top, bandHeight: height });
|
|
538
|
+
},
|
|
539
|
+
draw(ctx, tMs) {
|
|
540
|
+
if (!rn || !base) return;
|
|
541
|
+
const eng = getNotationEngraving(ctx);
|
|
542
|
+
const l = eng?.followLayoutAt ? eng.followLayoutAt(ctx, tMs) : base;
|
|
543
|
+
const c = ctx.ctx2d;
|
|
544
|
+
c.drawImage(
|
|
545
|
+
rn.canvas,
|
|
546
|
+
l.src.x,
|
|
547
|
+
l.src.y,
|
|
548
|
+
l.src.w,
|
|
549
|
+
l.src.h,
|
|
550
|
+
l.rect.dx,
|
|
551
|
+
l.rect.dy,
|
|
552
|
+
l.rect.dw,
|
|
553
|
+
l.rect.dh
|
|
554
|
+
);
|
|
555
|
+
},
|
|
556
|
+
dispose() {
|
|
557
|
+
rn = null;
|
|
558
|
+
base = null;
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
var notationFactory = {
|
|
563
|
+
key: "notation",
|
|
564
|
+
create: notationLayer,
|
|
565
|
+
validateProps(props) {
|
|
566
|
+
const errs = [];
|
|
567
|
+
if (props == null || typeof props !== "object") return ["notation: props must be an object"];
|
|
568
|
+
const p = props;
|
|
569
|
+
if (p.system != null && p.system !== "grand" && p.system !== "single")
|
|
570
|
+
errs.push('notation.system must be "grand" | "single"');
|
|
571
|
+
if (p.scale != null && (typeof p.scale !== "number" || p.scale <= 0))
|
|
572
|
+
errs.push("notation.scale must be a positive number");
|
|
573
|
+
if (p.rendered == null && typeof p.xml !== "string")
|
|
574
|
+
errs.push("notation: provide `rendered` (RenderedNotation) or `xml` (string)");
|
|
575
|
+
if (p.bars != null && (!Array.isArray(p.bars) || p.bars.length !== 2))
|
|
576
|
+
errs.push("notation.bars must be [from,to]");
|
|
577
|
+
return errs;
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
// src/scene/layers/scrollCursor.ts
|
|
582
|
+
function followLayoutFor(ctx, camProgress01) {
|
|
583
|
+
const eng = getNotationEngraving(ctx);
|
|
584
|
+
if (!eng) return null;
|
|
585
|
+
const nBars = measureCount(eng.rendered);
|
|
586
|
+
if (nBars <= 0) return eng.base;
|
|
587
|
+
const windowStart = followWindowStart(nBars, camProgress01);
|
|
588
|
+
const focusBox = followBoxAt(eng.rendered, windowStart);
|
|
589
|
+
return notationLayout(eng.rendered, ctx.W, ctx.H, eng.bandTop, eng.bandHeight, { focusBox });
|
|
590
|
+
}
|
|
591
|
+
function scrollCursorLayer() {
|
|
592
|
+
let musicMs = 0;
|
|
593
|
+
let openingZoomMs = 900;
|
|
594
|
+
let color;
|
|
595
|
+
function camProgress(tMs) {
|
|
596
|
+
const phMs = tMs - openingZoomMs;
|
|
597
|
+
if (phMs < 0 || musicMs <= 0) return 0;
|
|
598
|
+
return Math.min(1, phMs / musicMs);
|
|
599
|
+
}
|
|
600
|
+
function layoutAt(ctx, tMs) {
|
|
601
|
+
const eng = getNotationEngraving(ctx);
|
|
602
|
+
return followLayoutFor(ctx, camProgress(tMs)) ?? eng.base;
|
|
603
|
+
}
|
|
604
|
+
return {
|
|
605
|
+
key: "scroll-cursor",
|
|
606
|
+
init(ctx, props) {
|
|
607
|
+
musicMs = props.musicMs;
|
|
608
|
+
openingZoomMs = props.openingZoomMs ?? 900;
|
|
609
|
+
color = props.color;
|
|
610
|
+
setFollowLayoutProvider(ctx, layoutAt);
|
|
611
|
+
},
|
|
612
|
+
draw(ctx, tMs) {
|
|
613
|
+
const eng = getNotationEngraving(ctx);
|
|
614
|
+
if (!eng) return;
|
|
615
|
+
const p = camProgress(tMs);
|
|
616
|
+
const layout = layoutAt(ctx, tMs);
|
|
617
|
+
const line = playheadLine(layout, p);
|
|
618
|
+
if (!line) return;
|
|
619
|
+
const c = ctx.ctx2d;
|
|
620
|
+
c.save();
|
|
621
|
+
c.strokeStyle = color ?? ctx.theme.accent;
|
|
622
|
+
c.globalAlpha = line.alpha;
|
|
623
|
+
c.lineWidth = 4;
|
|
624
|
+
c.beginPath();
|
|
625
|
+
c.moveTo(line.x, line.y0);
|
|
626
|
+
c.lineTo(line.x, line.y1);
|
|
627
|
+
c.stroke();
|
|
628
|
+
c.restore();
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
var scrollCursorFactory = {
|
|
633
|
+
key: "scroll-cursor",
|
|
634
|
+
create: scrollCursorLayer,
|
|
635
|
+
validateProps(props) {
|
|
636
|
+
const errs = [];
|
|
637
|
+
if (props == null || typeof props !== "object") return ["scroll-cursor: props must be an object"];
|
|
638
|
+
const p = props;
|
|
639
|
+
if (typeof p.musicMs !== "number" || !(p.musicMs > 0))
|
|
640
|
+
errs.push("scroll-cursor.musicMs must be a positive number (total music length ms)");
|
|
641
|
+
if (p.followBars != null && (typeof p.followBars !== "number" || p.followBars < 1))
|
|
642
|
+
errs.push("scroll-cursor.followBars must be a number >= 1");
|
|
643
|
+
if (p.openingZoomMs != null && (typeof p.openingZoomMs !== "number" || p.openingZoomMs < 0))
|
|
644
|
+
errs.push("scroll-cursor.openingZoomMs must be a number >= 0");
|
|
645
|
+
if (p.color != null && typeof p.color !== "string") errs.push("scroll-cursor.color must be a string");
|
|
646
|
+
return errs;
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
// src/scene/keyboardGeometry.ts
|
|
651
|
+
var BLACK_PC = /* @__PURE__ */ new Set([1, 3, 6, 8, 10]);
|
|
652
|
+
function whiteIndexAtOrBelow(midi) {
|
|
653
|
+
const oct = Math.floor(midi / 12);
|
|
654
|
+
const pc = midi - oct * 12;
|
|
655
|
+
const WHITES_BELOW = [0, 1, 1, 2, 2, 3, 4, 4, 5, 5, 6, 6];
|
|
656
|
+
return oct * 7 + WHITES_BELOW[pc];
|
|
657
|
+
}
|
|
658
|
+
function isBlackKey(midi) {
|
|
659
|
+
return BLACK_PC.has((midi % 12 + 12) % 12);
|
|
660
|
+
}
|
|
661
|
+
var PIANO_LOW = 21;
|
|
662
|
+
var PIANO_HIGH = 108;
|
|
663
|
+
function snapDownToWhite(midi) {
|
|
664
|
+
let m = midi;
|
|
665
|
+
while (isBlackKey(m)) m--;
|
|
666
|
+
return m;
|
|
667
|
+
}
|
|
668
|
+
function snapUpToWhite(midi) {
|
|
669
|
+
let m = midi;
|
|
670
|
+
while (isBlackKey(m)) m++;
|
|
671
|
+
return m;
|
|
672
|
+
}
|
|
673
|
+
function keyboardLayout(opts) {
|
|
674
|
+
let low;
|
|
675
|
+
let high;
|
|
676
|
+
if (opts.range === "auto" && opts.span) {
|
|
677
|
+
low = snapDownToWhite(opts.span[0]);
|
|
678
|
+
high = snapUpToWhite(opts.span[1]);
|
|
679
|
+
low = snapDownToWhite(low - 1);
|
|
680
|
+
high = snapUpToWhite(high + 1);
|
|
681
|
+
} else {
|
|
682
|
+
low = PIANO_LOW;
|
|
683
|
+
high = PIANO_HIGH;
|
|
684
|
+
}
|
|
685
|
+
const firstWhiteIndex = whiteIndexAtOrBelow(low);
|
|
686
|
+
const lastWhiteIndex = whiteIndexAtOrBelow(high);
|
|
687
|
+
const whiteCount = lastWhiteIndex - firstWhiteIndex + 1;
|
|
688
|
+
const whiteW = opts.w / whiteCount;
|
|
689
|
+
return {
|
|
690
|
+
lowMidi: low,
|
|
691
|
+
highMidi: high,
|
|
692
|
+
x: opts.x,
|
|
693
|
+
w: opts.w,
|
|
694
|
+
top: opts.top,
|
|
695
|
+
height: opts.height,
|
|
696
|
+
whiteW,
|
|
697
|
+
firstWhiteIndex,
|
|
698
|
+
whiteCount
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
function scorePitchSpan(midis) {
|
|
702
|
+
if (!midis.length) return [PIANO_LOW, PIANO_HIGH];
|
|
703
|
+
return [Math.min(...midis), Math.max(...midis)];
|
|
704
|
+
}
|
|
705
|
+
function resolveKeyboardLayout(args) {
|
|
706
|
+
return keyboardLayout({
|
|
707
|
+
range: args.range,
|
|
708
|
+
span: args.range === "auto" ? scorePitchSpan(args.pitchMidis) : void 0,
|
|
709
|
+
x: args.left,
|
|
710
|
+
w: args.width,
|
|
711
|
+
top: args.bottomY - args.height,
|
|
712
|
+
height: args.height
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
function keyCenterX(layout, midi) {
|
|
716
|
+
const wIdx = whiteIndexAtOrBelow(midi) - layout.firstWhiteIndex;
|
|
717
|
+
const whiteCenter = layout.x + (wIdx + 0.5) * layout.whiteW;
|
|
718
|
+
if (!isBlackKey(midi)) return whiteCenter;
|
|
719
|
+
return whiteCenter + layout.whiteW / 2;
|
|
720
|
+
}
|
|
721
|
+
function keyColumnWidth(layout, midi) {
|
|
722
|
+
return isBlackKey(midi) ? layout.whiteW * 0.6 : layout.whiteW * 0.9;
|
|
723
|
+
}
|
|
724
|
+
function keyRect(layout, midi) {
|
|
725
|
+
const cx = keyCenterX(layout, midi);
|
|
726
|
+
if (isBlackKey(midi)) {
|
|
727
|
+
const w2 = layout.whiteW * 0.6;
|
|
728
|
+
return { x: cx - w2 / 2, y: layout.top, w: w2, h: layout.height * 0.62, black: true };
|
|
729
|
+
}
|
|
730
|
+
const w = layout.whiteW;
|
|
731
|
+
return { x: cx - w / 2, y: layout.top, w, h: layout.height, black: false };
|
|
732
|
+
}
|
|
733
|
+
function inRange(layout, midi) {
|
|
734
|
+
return midi >= layout.lowMidi && midi <= layout.highMidi;
|
|
735
|
+
}
|
|
736
|
+
function whiteKeys(layout) {
|
|
737
|
+
const out = [];
|
|
738
|
+
for (let m = layout.lowMidi; m <= layout.highMidi; m++) if (!isBlackKey(m)) out.push(m);
|
|
739
|
+
return out;
|
|
740
|
+
}
|
|
741
|
+
function blackKeys(layout) {
|
|
742
|
+
const out = [];
|
|
743
|
+
for (let m = layout.lowMidi; m <= layout.highMidi; m++) if (isBlackKey(m)) out.push(m);
|
|
744
|
+
return out;
|
|
745
|
+
}
|
|
746
|
+
var PC_COLORS = [
|
|
747
|
+
"#e64545",
|
|
748
|
+
"#e6803a",
|
|
749
|
+
"#e6c23a",
|
|
750
|
+
"#9bcf3a",
|
|
751
|
+
"#3acf6e",
|
|
752
|
+
"#3acfb0",
|
|
753
|
+
"#3aa6e6",
|
|
754
|
+
"#3a5fe6",
|
|
755
|
+
"#7a3ae6",
|
|
756
|
+
"#b03ae6",
|
|
757
|
+
"#e63ab0",
|
|
758
|
+
"#e63a6e"
|
|
759
|
+
];
|
|
760
|
+
function noteColor(midi, hand, colorBy, hands) {
|
|
761
|
+
if (colorBy === "pitch-class") return PC_COLORS[(midi % 12 + 12) % 12];
|
|
762
|
+
return hand === "L" ? hands.L : hands.R;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// src/scene/keyboardStore.ts
|
|
766
|
+
var STORE2 = /* @__PURE__ */ new WeakMap();
|
|
767
|
+
function keyFor2(ctx) {
|
|
768
|
+
return ctx.audioClock;
|
|
769
|
+
}
|
|
770
|
+
function setKeyboardLayout(ctx, layout) {
|
|
771
|
+
STORE2.set(keyFor2(ctx), layout);
|
|
772
|
+
}
|
|
773
|
+
function getKeyboardLayout(ctx) {
|
|
774
|
+
return STORE2.get(keyFor2(ctx));
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// src/scene/layers/keyboard.ts
|
|
778
|
+
function keyboardLayer() {
|
|
779
|
+
let layout = null;
|
|
780
|
+
let colorBy = "hand";
|
|
781
|
+
let hands = { R: "#7b2436", L: "#c8a55b" };
|
|
782
|
+
return {
|
|
783
|
+
key: "keyboard",
|
|
784
|
+
init(ctx, props) {
|
|
785
|
+
colorBy = props.colorBy ?? "hand";
|
|
786
|
+
hands = props.handColors ?? { R: ctx.theme.accent, L: ctx.theme.gold };
|
|
787
|
+
const sb = ctx.safeBox;
|
|
788
|
+
const height = props.height ?? 220;
|
|
789
|
+
const bottomY = props.bottomY ?? sb.bottom;
|
|
790
|
+
const midis = (ctx.score?.notes ?? []).map((n) => n.pitchMidi);
|
|
791
|
+
layout = resolveKeyboardLayout({
|
|
792
|
+
range: props.range ?? "auto",
|
|
793
|
+
pitchMidis: midis,
|
|
794
|
+
left: sb.left,
|
|
795
|
+
width: sb.w,
|
|
796
|
+
bottomY,
|
|
797
|
+
height
|
|
798
|
+
});
|
|
799
|
+
setKeyboardLayout(ctx, layout);
|
|
800
|
+
},
|
|
801
|
+
draw(ctx, tMs) {
|
|
802
|
+
if (!layout) return;
|
|
803
|
+
const c = ctx.ctx2d;
|
|
804
|
+
const L = layout;
|
|
805
|
+
const lit = /* @__PURE__ */ new Map();
|
|
806
|
+
for (const n of ctx.score?.notes ?? []) {
|
|
807
|
+
if (tMs >= n.onsetMs && tMs < n.onsetMs + n.durMs) lit.set(n.pitchMidi, n.hand);
|
|
808
|
+
}
|
|
809
|
+
c.save();
|
|
810
|
+
for (const m of whiteKeys(L)) {
|
|
811
|
+
const r = keyRect(L, m);
|
|
812
|
+
const onHand = lit.get(m);
|
|
813
|
+
c.fillStyle = onHand ? noteColor(m, onHand, colorBy, hands) : "#fbfbfb";
|
|
814
|
+
c.fillRect(r.x, r.y, r.w, r.h);
|
|
815
|
+
c.strokeStyle = "#b8b0a4";
|
|
816
|
+
c.lineWidth = 1;
|
|
817
|
+
c.strokeRect(r.x, r.y, r.w, r.h);
|
|
818
|
+
}
|
|
819
|
+
for (const m of blackKeys(L)) {
|
|
820
|
+
const r = keyRect(L, m);
|
|
821
|
+
const onHand = lit.get(m);
|
|
822
|
+
c.fillStyle = onHand ? noteColor(m, onHand, colorBy, hands) : "#1a1614";
|
|
823
|
+
c.fillRect(r.x, r.y, r.w, r.h);
|
|
824
|
+
}
|
|
825
|
+
c.restore();
|
|
826
|
+
},
|
|
827
|
+
dispose() {
|
|
828
|
+
layout = null;
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
var keyboardFactory = {
|
|
833
|
+
key: "keyboard",
|
|
834
|
+
create: keyboardLayer,
|
|
835
|
+
validateProps(props) {
|
|
836
|
+
const errs = [];
|
|
837
|
+
if (props == null || typeof props !== "object") return ["keyboard: props must be an object"];
|
|
838
|
+
const p = props;
|
|
839
|
+
if (p.range != null && p.range !== "88" && p.range !== "auto")
|
|
840
|
+
errs.push('keyboard.range must be "88" | "auto"');
|
|
841
|
+
if (p.colorBy != null && p.colorBy !== "hand" && p.colorBy !== "pitch-class")
|
|
842
|
+
errs.push('keyboard.colorBy must be "hand" | "pitch-class"');
|
|
843
|
+
if (p.height != null && (typeof p.height !== "number" || p.height <= 0))
|
|
844
|
+
errs.push("keyboard.height must be a positive number");
|
|
845
|
+
if (p.bottomY != null && typeof p.bottomY !== "number")
|
|
846
|
+
errs.push("keyboard.bottomY must be a number");
|
|
847
|
+
return errs;
|
|
848
|
+
}
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
// src/scene/layers/fallingNotes.ts
|
|
852
|
+
var DEFAULT_LEAD_MS = 2e3;
|
|
853
|
+
function fallingNotesLayer() {
|
|
854
|
+
let paired = true;
|
|
855
|
+
let colorBy = "hand";
|
|
856
|
+
let hands = { R: "#7b2436", L: "#c8a55b" };
|
|
857
|
+
let hitGlow = true;
|
|
858
|
+
let ownLayout = null;
|
|
859
|
+
let topY = 0;
|
|
860
|
+
let leadMsProp;
|
|
861
|
+
let speedProp;
|
|
862
|
+
function layoutFor(ctx) {
|
|
863
|
+
if (paired) return getKeyboardLayout(ctx) ?? ownLayout;
|
|
864
|
+
return ownLayout;
|
|
865
|
+
}
|
|
866
|
+
function hitLineY(layout) {
|
|
867
|
+
return layout.top;
|
|
868
|
+
}
|
|
869
|
+
function pxPerMs(fallHeight) {
|
|
870
|
+
if (leadMsProp != null) return fallHeight / leadMsProp;
|
|
871
|
+
if (speedProp != null) return speedProp / 1e3;
|
|
872
|
+
return fallHeight / DEFAULT_LEAD_MS;
|
|
873
|
+
}
|
|
874
|
+
return {
|
|
875
|
+
key: "falling-notes",
|
|
876
|
+
init(ctx, props) {
|
|
877
|
+
paired = props.keyboard ?? true;
|
|
878
|
+
colorBy = props.colorBy ?? "hand";
|
|
879
|
+
hands = props.handColors ?? { R: ctx.theme.accent, L: ctx.theme.gold };
|
|
880
|
+
hitGlow = props.hitGlow ?? true;
|
|
881
|
+
leadMsProp = props.leadMs;
|
|
882
|
+
speedProp = props.speed;
|
|
883
|
+
const sb = ctx.safeBox;
|
|
884
|
+
topY = props.topY ?? sb.top;
|
|
885
|
+
if (!paired) {
|
|
886
|
+
const hitLine = props.hitLineY ?? sb.bottom - 220;
|
|
887
|
+
const midis = (ctx.score?.notes ?? []).map((n) => n.pitchMidi);
|
|
888
|
+
ownLayout = resolveKeyboardLayout({
|
|
889
|
+
range: props.range ?? "auto",
|
|
890
|
+
pitchMidis: midis,
|
|
891
|
+
left: sb.left,
|
|
892
|
+
width: sb.w,
|
|
893
|
+
// top of the keyboard == hit-line; height below it is irrelevant here.
|
|
894
|
+
bottomY: hitLine + 1,
|
|
895
|
+
height: 1
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
},
|
|
899
|
+
draw(ctx, tMs) {
|
|
900
|
+
const layout = layoutFor(ctx);
|
|
901
|
+
if (!layout) return;
|
|
902
|
+
const c = ctx.ctx2d;
|
|
903
|
+
const hit = hitLineY(layout);
|
|
904
|
+
const fallH = Math.max(1, hit - topY);
|
|
905
|
+
const v = pxPerMs(fallH);
|
|
906
|
+
for (const n of ctx.score?.notes ?? []) {
|
|
907
|
+
if (!inRange(layout, n.pitchMidi)) continue;
|
|
908
|
+
const bottomY = hit - (n.onsetMs - tMs) * v;
|
|
909
|
+
const lenPx = Math.max(2, n.durMs * v);
|
|
910
|
+
const topEdge = bottomY - lenPx;
|
|
911
|
+
if (bottomY < topY) continue;
|
|
912
|
+
if (topEdge > hit) continue;
|
|
913
|
+
const cx = keyCenterX(layout, n.pitchMidi);
|
|
914
|
+
const w = keyColumnWidth(layout, n.pitchMidi);
|
|
915
|
+
const drawTop = Math.max(topY, topEdge);
|
|
916
|
+
const drawBottom = Math.min(hit, bottomY);
|
|
917
|
+
const fill = noteColor(n.pitchMidi, n.hand, colorBy, hands);
|
|
918
|
+
c.save();
|
|
919
|
+
c.fillStyle = fill;
|
|
920
|
+
c.globalAlpha = 0.92;
|
|
921
|
+
c.fillRect(cx - w / 2, drawTop, w, Math.max(1, drawBottom - drawTop));
|
|
922
|
+
c.restore();
|
|
923
|
+
}
|
|
924
|
+
if (hitGlow) {
|
|
925
|
+
for (const n of ctx.score?.notes ?? []) {
|
|
926
|
+
if (!(tMs >= n.onsetMs && tMs < n.onsetMs + n.durMs)) continue;
|
|
927
|
+
if (!inRange(layout, n.pitchMidi)) continue;
|
|
928
|
+
const cx = keyCenterX(layout, n.pitchMidi);
|
|
929
|
+
const w = keyColumnWidth(layout, n.pitchMidi);
|
|
930
|
+
c.save();
|
|
931
|
+
c.globalAlpha = 0.5;
|
|
932
|
+
c.fillStyle = noteColor(n.pitchMidi, n.hand, colorBy, hands);
|
|
933
|
+
c.fillRect(cx - w / 2, hit - 8, w, 8);
|
|
934
|
+
c.restore();
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
},
|
|
938
|
+
dispose() {
|
|
939
|
+
ownLayout = null;
|
|
940
|
+
}
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
var fallingNotesFactory = {
|
|
944
|
+
key: "falling-notes",
|
|
945
|
+
create: fallingNotesLayer,
|
|
946
|
+
validateProps(props) {
|
|
947
|
+
const errs = [];
|
|
948
|
+
if (props == null || typeof props !== "object") return ["falling-notes: props must be an object"];
|
|
949
|
+
const p = props;
|
|
950
|
+
if (p.keyboard != null && typeof p.keyboard !== "boolean")
|
|
951
|
+
errs.push("falling-notes.keyboard must be a boolean");
|
|
952
|
+
if (p.colorBy != null && p.colorBy !== "hand" && p.colorBy !== "pitch-class")
|
|
953
|
+
errs.push('falling-notes.colorBy must be "hand" | "pitch-class"');
|
|
954
|
+
if (p.leadMs != null && (typeof p.leadMs !== "number" || p.leadMs <= 0))
|
|
955
|
+
errs.push("falling-notes.leadMs must be a positive number");
|
|
956
|
+
if (p.speed != null && (typeof p.speed !== "number" || p.speed <= 0))
|
|
957
|
+
errs.push("falling-notes.speed must be a positive number");
|
|
958
|
+
if (p.hitGlow != null && typeof p.hitGlow !== "boolean")
|
|
959
|
+
errs.push("falling-notes.hitGlow must be a boolean");
|
|
960
|
+
if (p.range != null && p.range !== "88" && p.range !== "auto")
|
|
961
|
+
errs.push('falling-notes.range must be "88" | "auto"');
|
|
962
|
+
return errs;
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
// src/scene/layers/promoCards.ts
|
|
967
|
+
function localT01(tMs, startMs, durationMs) {
|
|
968
|
+
if (durationMs <= 0) return 0;
|
|
969
|
+
const t = (tMs - startMs) / durationMs;
|
|
970
|
+
return t < 0 ? 0 : t > 1 ? 1 : t;
|
|
971
|
+
}
|
|
972
|
+
function validateWindow(p, key) {
|
|
973
|
+
const errs = [];
|
|
974
|
+
if (p.startMs != null && (typeof p.startMs !== "number" || p.startMs < 0))
|
|
975
|
+
errs.push(`${key}.startMs must be a number >= 0`);
|
|
976
|
+
if (p.durationMs != null && (typeof p.durationMs !== "number" || !(p.durationMs > 0)))
|
|
977
|
+
errs.push(`${key}.durationMs must be a positive number`);
|
|
978
|
+
return errs;
|
|
979
|
+
}
|
|
980
|
+
function hookLayer() {
|
|
981
|
+
let scene = null;
|
|
982
|
+
let startMs = 0;
|
|
983
|
+
let durationMs = 0;
|
|
984
|
+
return {
|
|
985
|
+
key: "hook",
|
|
986
|
+
init(ctx, props) {
|
|
987
|
+
const opts = { lines: props.lines, brand: props.brand };
|
|
988
|
+
scene = hookScene(ctx.theme, opts);
|
|
989
|
+
startMs = props.startMs ?? 0;
|
|
990
|
+
durationMs = props.durationMs ?? scene.durationMs;
|
|
991
|
+
},
|
|
992
|
+
draw(ctx, tMs) {
|
|
993
|
+
if (!scene) return;
|
|
994
|
+
scene.draw(ctx.ctx2d, localT01(tMs, startMs, durationMs));
|
|
995
|
+
}
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
var hookFactory = {
|
|
999
|
+
key: "hook",
|
|
1000
|
+
create: hookLayer,
|
|
1001
|
+
validateProps(props) {
|
|
1002
|
+
if (props == null || typeof props !== "object") return ["hook: props must be an object"];
|
|
1003
|
+
const p = props;
|
|
1004
|
+
const errs = [];
|
|
1005
|
+
if (!Array.isArray(p.lines) || !p.lines.every((l) => typeof l === "string"))
|
|
1006
|
+
errs.push("hook.lines must be an array of strings");
|
|
1007
|
+
if (p.brand != null && typeof p.brand !== "string") errs.push("hook.brand must be a string");
|
|
1008
|
+
return [...errs, ...validateWindow(p, "hook")];
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
function revealLayer() {
|
|
1012
|
+
let scene = null;
|
|
1013
|
+
let startMs = 0;
|
|
1014
|
+
let durationMs = 0;
|
|
1015
|
+
return {
|
|
1016
|
+
key: "reveal",
|
|
1017
|
+
init(ctx, props) {
|
|
1018
|
+
const opts = {
|
|
1019
|
+
title: props.title,
|
|
1020
|
+
subtitle: props.subtitle,
|
|
1021
|
+
initials: props.initials,
|
|
1022
|
+
funFact: props.funFact,
|
|
1023
|
+
portrait: null
|
|
1024
|
+
// the `portrait` layer carries the medallion image; reveal
|
|
1025
|
+
// here is the initials-fallback look. See portrait.ts.
|
|
1026
|
+
};
|
|
1027
|
+
scene = revealScene(ctx.theme, opts);
|
|
1028
|
+
startMs = props.startMs ?? 0;
|
|
1029
|
+
durationMs = props.durationMs ?? scene.durationMs;
|
|
1030
|
+
},
|
|
1031
|
+
draw(ctx, tMs) {
|
|
1032
|
+
if (!scene) return;
|
|
1033
|
+
scene.draw(ctx.ctx2d, localT01(tMs, startMs, durationMs));
|
|
1034
|
+
}
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
var revealFactory = {
|
|
1038
|
+
key: "reveal",
|
|
1039
|
+
create: revealLayer,
|
|
1040
|
+
validateProps(props) {
|
|
1041
|
+
if (props == null || typeof props !== "object") return ["reveal: props must be an object"];
|
|
1042
|
+
const p = props;
|
|
1043
|
+
const errs = [];
|
|
1044
|
+
if (typeof p.title !== "string") errs.push("reveal.title must be a string");
|
|
1045
|
+
if (typeof p.subtitle !== "string") errs.push("reveal.subtitle must be a string");
|
|
1046
|
+
if (p.initials != null && typeof p.initials !== "string") errs.push("reveal.initials must be a string");
|
|
1047
|
+
if (p.funFact != null && typeof p.funFact !== "string") errs.push("reveal.funFact must be a string");
|
|
1048
|
+
return [...errs, ...validateWindow(p, "reveal")];
|
|
1049
|
+
}
|
|
1050
|
+
};
|
|
1051
|
+
function ctaLayer() {
|
|
1052
|
+
let scene = null;
|
|
1053
|
+
let startMs = 0;
|
|
1054
|
+
let durationMs = 0;
|
|
1055
|
+
return {
|
|
1056
|
+
key: "cta",
|
|
1057
|
+
init(ctx, props) {
|
|
1058
|
+
const opts = { lines: props.lines };
|
|
1059
|
+
scene = ctaScene(ctx.theme, opts);
|
|
1060
|
+
startMs = props.startMs ?? 0;
|
|
1061
|
+
durationMs = props.durationMs ?? scene.durationMs;
|
|
1062
|
+
},
|
|
1063
|
+
draw(ctx, tMs) {
|
|
1064
|
+
if (!scene) return;
|
|
1065
|
+
scene.draw(ctx.ctx2d, localT01(tMs, startMs, durationMs));
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
var ctaFactory = {
|
|
1070
|
+
key: "cta",
|
|
1071
|
+
create: ctaLayer,
|
|
1072
|
+
validateProps(props) {
|
|
1073
|
+
if (props == null || typeof props !== "object") return ["cta: props must be an object"];
|
|
1074
|
+
const p = props;
|
|
1075
|
+
const errs = [];
|
|
1076
|
+
if (!Array.isArray(p.lines) || !p.lines.every((l) => typeof l === "string"))
|
|
1077
|
+
errs.push("cta.lines must be an array of strings");
|
|
1078
|
+
return [...errs, ...validateWindow(p, "cta")];
|
|
1079
|
+
}
|
|
1080
|
+
};
|
|
1081
|
+
function portraitLayer() {
|
|
1082
|
+
let scene = null;
|
|
1083
|
+
let startMs = 0;
|
|
1084
|
+
let durationMs = 0;
|
|
1085
|
+
return {
|
|
1086
|
+
key: "portrait",
|
|
1087
|
+
async init(ctx, props) {
|
|
1088
|
+
const portrait = await loadPortrait(props.url ?? null);
|
|
1089
|
+
const opts = {
|
|
1090
|
+
title: props.title,
|
|
1091
|
+
subtitle: props.subtitle,
|
|
1092
|
+
initials: props.initials,
|
|
1093
|
+
funFact: props.funFact,
|
|
1094
|
+
portrait
|
|
1095
|
+
};
|
|
1096
|
+
scene = revealScene(ctx.theme, opts);
|
|
1097
|
+
startMs = props.startMs ?? 0;
|
|
1098
|
+
durationMs = props.durationMs ?? scene.durationMs;
|
|
1099
|
+
},
|
|
1100
|
+
draw(ctx, tMs) {
|
|
1101
|
+
if (!scene) return;
|
|
1102
|
+
scene.draw(ctx.ctx2d, localT01(tMs, startMs, durationMs));
|
|
1103
|
+
}
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
var portraitFactory = {
|
|
1107
|
+
key: "portrait",
|
|
1108
|
+
create: portraitLayer,
|
|
1109
|
+
validateProps(props) {
|
|
1110
|
+
if (props == null || typeof props !== "object") return ["portrait: props must be an object"];
|
|
1111
|
+
const p = props;
|
|
1112
|
+
const errs = [];
|
|
1113
|
+
if (typeof p.title !== "string") errs.push("portrait.title must be a string");
|
|
1114
|
+
if (typeof p.subtitle !== "string") errs.push("portrait.subtitle must be a string");
|
|
1115
|
+
if (p.url != null && typeof p.url !== "string") errs.push("portrait.url must be a string or null");
|
|
1116
|
+
if (p.initials != null && typeof p.initials !== "string") errs.push("portrait.initials must be a string");
|
|
1117
|
+
if (p.funFact != null && typeof p.funFact !== "string") errs.push("portrait.funFact must be a string");
|
|
1118
|
+
return [...errs, ...validateWindow(p, "portrait")];
|
|
1119
|
+
}
|
|
1120
|
+
};
|
|
1121
|
+
|
|
1122
|
+
// src/scene/layers/spectrum.ts
|
|
1123
|
+
var SPEC_BARS = 44;
|
|
1124
|
+
function lerpHex(a, b, f) {
|
|
1125
|
+
const pa = parseInt(a.slice(1), 16);
|
|
1126
|
+
const pb = parseInt(b.slice(1), 16);
|
|
1127
|
+
const r = Math.round((pa >> 16 & 255) + ((pb >> 16 & 255) - (pa >> 16 & 255)) * f);
|
|
1128
|
+
const g = Math.round((pa >> 8 & 255) + ((pb >> 8 & 255) - (pa >> 8 & 255)) * f);
|
|
1129
|
+
const bl = Math.round((pa & 255) + ((pb & 255) - (pa & 255)) * f);
|
|
1130
|
+
return `rgb(${r},${g},${bl})`;
|
|
1131
|
+
}
|
|
1132
|
+
function syntheticMagnitude(tSec, b, n) {
|
|
1133
|
+
const phase = tSec * 5 + b * 0.45;
|
|
1134
|
+
let m = 0.22 + 0.16 * Math.sin(phase) + 0.1 * Math.sin(phase * 1.7 + 1.2);
|
|
1135
|
+
m *= 0.5 + 0.5 * Math.sin(b / (n - 1) * Math.PI);
|
|
1136
|
+
return m;
|
|
1137
|
+
}
|
|
1138
|
+
function binByteFreq(freq, n) {
|
|
1139
|
+
let peak = 0;
|
|
1140
|
+
for (let i = 0; i < freq.length; i++) if (freq[i] > peak) peak = freq[i];
|
|
1141
|
+
if (peak <= 4) return null;
|
|
1142
|
+
const lo = 2, hi = 440;
|
|
1143
|
+
const out = [];
|
|
1144
|
+
for (let b = 0; b < n; b++) {
|
|
1145
|
+
const f0 = lo * Math.pow(hi / lo, b / n);
|
|
1146
|
+
const f1 = lo * Math.pow(hi / lo, (b + 1) / n);
|
|
1147
|
+
const i0 = Math.floor(f0);
|
|
1148
|
+
const i1 = Math.max(i0 + 1, Math.floor(f1));
|
|
1149
|
+
let sum = 0, c = 0;
|
|
1150
|
+
for (let i = i0; i < i1 && i < freq.length; i++) {
|
|
1151
|
+
sum += freq[i];
|
|
1152
|
+
c++;
|
|
1153
|
+
}
|
|
1154
|
+
let m = c ? sum / c / 255 : 0;
|
|
1155
|
+
m = Math.pow(m, 0.78);
|
|
1156
|
+
out.push(m);
|
|
1157
|
+
}
|
|
1158
|
+
return out;
|
|
1159
|
+
}
|
|
1160
|
+
function spectrumLayer() {
|
|
1161
|
+
let n = SPEC_BARS;
|
|
1162
|
+
let centerFrac = 0.46;
|
|
1163
|
+
let maxHeightFrac = 0.135;
|
|
1164
|
+
let colorLow;
|
|
1165
|
+
let colorHigh;
|
|
1166
|
+
let levelsFn;
|
|
1167
|
+
function magnitudesAt(ctx, tMs) {
|
|
1168
|
+
const fromProp = levelsFn?.(tMs, n);
|
|
1169
|
+
const src = fromProp ?? resolveFromCtx(ctx, tMs);
|
|
1170
|
+
if (src && src.length) {
|
|
1171
|
+
const out = new Array(n);
|
|
1172
|
+
for (let b = 0; b < n; b++) out[b] = clamp01(src[Math.min(src.length - 1, b)] ?? 0);
|
|
1173
|
+
return out;
|
|
1174
|
+
}
|
|
1175
|
+
const tSec = tMs / 1e3;
|
|
1176
|
+
return Array.from({ length: n }, (_, b) => clamp01(syntheticMagnitude(tSec, b, n)));
|
|
1177
|
+
}
|
|
1178
|
+
function resolveFromCtx(ctx, tMs) {
|
|
1179
|
+
const sp = ctx.spectrum;
|
|
1180
|
+
if (!sp) return null;
|
|
1181
|
+
const lv = sp.levels?.(tMs, n);
|
|
1182
|
+
if (lv && lv.length) return Array.from(lv);
|
|
1183
|
+
const bf = sp.byteFreq?.(tMs);
|
|
1184
|
+
if (bf && bf.length) return binByteFreq(bf, n);
|
|
1185
|
+
return null;
|
|
1186
|
+
}
|
|
1187
|
+
return {
|
|
1188
|
+
key: "spectrum",
|
|
1189
|
+
init(_ctx, props) {
|
|
1190
|
+
n = props.bars ?? SPEC_BARS;
|
|
1191
|
+
centerFrac = props.centerFrac ?? 0.46;
|
|
1192
|
+
maxHeightFrac = props.maxHeightFrac ?? 0.135;
|
|
1193
|
+
colorLow = props.colorLow;
|
|
1194
|
+
colorHigh = props.colorHigh;
|
|
1195
|
+
levelsFn = props.levelsFn;
|
|
1196
|
+
},
|
|
1197
|
+
draw(ctx, tMs) {
|
|
1198
|
+
const c = ctx.ctx2d;
|
|
1199
|
+
const W = ctx.W, H = ctx.H;
|
|
1200
|
+
const sb = ctx.safeBox;
|
|
1201
|
+
const cy = H * centerFrac;
|
|
1202
|
+
const left = Math.max(W * 0.1, sb.left);
|
|
1203
|
+
const right = sb.right;
|
|
1204
|
+
const span = right - left;
|
|
1205
|
+
const slot = span / n;
|
|
1206
|
+
const gap = slot * 0.34;
|
|
1207
|
+
const barW = slot - gap;
|
|
1208
|
+
const maxH = H * maxHeightFrac;
|
|
1209
|
+
const lo = colorLow ?? ctx.theme.accent;
|
|
1210
|
+
const hi = colorHigh ?? ctx.theme.gold;
|
|
1211
|
+
c.save();
|
|
1212
|
+
c.strokeStyle = lerpHex(ctx.theme.paper, ctx.theme.sepia, 0.28);
|
|
1213
|
+
c.lineWidth = 2;
|
|
1214
|
+
c.beginPath();
|
|
1215
|
+
c.moveTo(left, cy);
|
|
1216
|
+
c.lineTo(right, cy);
|
|
1217
|
+
c.stroke();
|
|
1218
|
+
const mags = magnitudesAt(ctx, tMs);
|
|
1219
|
+
const useRound = typeof c.roundRect === "function";
|
|
1220
|
+
for (let b = 0; b < n; b++) {
|
|
1221
|
+
const m = mags[b];
|
|
1222
|
+
const half = Math.max(barW * 0.5, m * maxH);
|
|
1223
|
+
const x0 = left + b * slot + gap / 2;
|
|
1224
|
+
c.fillStyle = lerpHex(lo, hi, m);
|
|
1225
|
+
if (useRound) {
|
|
1226
|
+
const r = Math.min(barW / 2, half);
|
|
1227
|
+
c.beginPath();
|
|
1228
|
+
c.roundRect(x0, cy - half, barW, half * 2, r);
|
|
1229
|
+
c.fill();
|
|
1230
|
+
} else {
|
|
1231
|
+
c.fillRect(x0, cy - half, barW, half * 2);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
c.restore();
|
|
1235
|
+
}
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
function clamp01(x) {
|
|
1239
|
+
return x < 0 ? 0 : x > 1 ? 1 : x;
|
|
1240
|
+
}
|
|
1241
|
+
var spectrumFactory = {
|
|
1242
|
+
key: "spectrum",
|
|
1243
|
+
create: spectrumLayer,
|
|
1244
|
+
validateProps(props) {
|
|
1245
|
+
if (props == null || typeof props !== "object") return ["spectrum: props must be an object"];
|
|
1246
|
+
const p = props;
|
|
1247
|
+
const errs = [];
|
|
1248
|
+
if (p.bars != null && (typeof p.bars !== "number" || p.bars < 2)) errs.push("spectrum.bars must be a number >= 2");
|
|
1249
|
+
if (p.centerFrac != null && (typeof p.centerFrac !== "number" || p.centerFrac < 0 || p.centerFrac > 1))
|
|
1250
|
+
errs.push("spectrum.centerFrac must be in [0,1]");
|
|
1251
|
+
if (p.maxHeightFrac != null && (typeof p.maxHeightFrac !== "number" || p.maxHeightFrac <= 0))
|
|
1252
|
+
errs.push("spectrum.maxHeightFrac must be a positive number");
|
|
1253
|
+
if (p.colorLow != null && typeof p.colorLow !== "string") errs.push("spectrum.colorLow must be a string");
|
|
1254
|
+
if (p.colorHigh != null && typeof p.colorHigh !== "string") errs.push("spectrum.colorHigh must be a string");
|
|
1255
|
+
if (p.levelsFn != null && typeof p.levelsFn !== "function") errs.push("spectrum.levelsFn must be a function");
|
|
1256
|
+
return errs;
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
|
|
1260
|
+
// src/scene/layers/branding.ts
|
|
1261
|
+
function brandingLayer() {
|
|
1262
|
+
let logo;
|
|
1263
|
+
let safezone = false;
|
|
1264
|
+
let yFrac = 1;
|
|
1265
|
+
let size = 34;
|
|
1266
|
+
let color;
|
|
1267
|
+
return {
|
|
1268
|
+
key: "branding",
|
|
1269
|
+
init(_ctx, props) {
|
|
1270
|
+
logo = props.logo;
|
|
1271
|
+
safezone = props.safezone ?? false;
|
|
1272
|
+
yFrac = props.yFrac ?? 1;
|
|
1273
|
+
size = props.size ?? 34;
|
|
1274
|
+
color = props.color;
|
|
1275
|
+
},
|
|
1276
|
+
draw(ctx) {
|
|
1277
|
+
const c = ctx.ctx2d;
|
|
1278
|
+
const sb = ctx.safeBox;
|
|
1279
|
+
const text = logo ?? ctx.theme.brand;
|
|
1280
|
+
c.save();
|
|
1281
|
+
c.textAlign = "center";
|
|
1282
|
+
c.font = `italic ${size}px ${ctx.theme.fontBody}`;
|
|
1283
|
+
c.fillStyle = color ?? ctx.theme.sepia;
|
|
1284
|
+
const y = sb.top + sb.h * yFrac - (yFrac >= 1 ? size * 0.3 : 0);
|
|
1285
|
+
c.fillText(text, sb.cx, y);
|
|
1286
|
+
c.restore();
|
|
1287
|
+
if (safezone) drawSafeGuides(c);
|
|
1288
|
+
}
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
var brandingFactory = {
|
|
1292
|
+
key: "branding",
|
|
1293
|
+
create: brandingLayer,
|
|
1294
|
+
validateProps(props) {
|
|
1295
|
+
if (props == null || typeof props !== "object") return ["branding: props must be an object"];
|
|
1296
|
+
const p = props;
|
|
1297
|
+
const errs = [];
|
|
1298
|
+
if (p.logo != null && typeof p.logo !== "string") errs.push("branding.logo must be a string");
|
|
1299
|
+
if (p.safezone != null && typeof p.safezone !== "boolean") errs.push("branding.safezone must be a boolean");
|
|
1300
|
+
if (p.yFrac != null && typeof p.yFrac !== "number") errs.push("branding.yFrac must be a number");
|
|
1301
|
+
if (p.size != null && (typeof p.size !== "number" || p.size <= 0)) errs.push("branding.size must be a positive number");
|
|
1302
|
+
if (p.color != null && typeof p.color !== "string") errs.push("branding.color must be a string");
|
|
1303
|
+
return errs;
|
|
1304
|
+
}
|
|
1305
|
+
};
|
|
1306
|
+
function safeGuidesLayer() {
|
|
1307
|
+
return {
|
|
1308
|
+
key: "safe-guides",
|
|
1309
|
+
init() {
|
|
1310
|
+
},
|
|
1311
|
+
draw(ctx) {
|
|
1312
|
+
drawSafeGuides(ctx.ctx2d);
|
|
1313
|
+
}
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
var safeGuidesFactory = {
|
|
1317
|
+
key: "safe-guides",
|
|
1318
|
+
create: safeGuidesLayer,
|
|
1319
|
+
validateProps(props) {
|
|
1320
|
+
if (props != null && typeof props !== "object") return ["safe-guides: props must be an object"];
|
|
1321
|
+
return [];
|
|
1322
|
+
}
|
|
1323
|
+
};
|
|
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
|
+
|
|
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/camera.ts
|
|
2621
|
+
function cameraTransform(cam, W, H) {
|
|
2622
|
+
const s = cam.zoom;
|
|
2623
|
+
return [s, 0, 0, s, W / 2 - cam.cx * s, H / 2 - cam.cy * s];
|
|
2624
|
+
}
|
|
2625
|
+
function worldToViewport(cam, W, H, x, y) {
|
|
2626
|
+
const [a, , , d, e, f] = cameraTransform(cam, W, H);
|
|
2627
|
+
return { x: a * x + e, y: d * y + f };
|
|
2628
|
+
}
|
|
2629
|
+
function frameRect(rect, W, H, pad = 0) {
|
|
2630
|
+
const padded = 1 + Math.max(0, pad) * 2;
|
|
2631
|
+
const zoom = Math.min(W / (rect.w * padded), H / (rect.h * padded));
|
|
2632
|
+
return { cx: rect.x + rect.w / 2, cy: rect.y + rect.h / 2, zoom };
|
|
2633
|
+
}
|
|
2634
|
+
function lerpCamera(a, b, t01, ease = easeInOut) {
|
|
2635
|
+
const k = ease(clamp(t01, 0, 1));
|
|
2636
|
+
return { cx: lerp(a.cx, b.cx, k), cy: lerp(a.cy, b.cy, k), zoom: lerp(a.zoom, b.zoom, k) };
|
|
2637
|
+
}
|
|
2638
|
+
function kenBurns(from, to, at01) {
|
|
2639
|
+
return lerpCamera(from, to, at01, easeInOut);
|
|
2640
|
+
}
|
|
2641
|
+
function applyToContext(ctx, cam, W, H) {
|
|
2642
|
+
const m = cameraTransform(cam, W, H);
|
|
2643
|
+
ctx.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]);
|
|
2644
|
+
}
|
|
2645
|
+
function identityCamera(W, H) {
|
|
2646
|
+
return { cx: W / 2, cy: H / 2, zoom: 1 };
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
// src/scene/runner.ts
|
|
2650
|
+
function resolveAnchor(anchor, totalSec) {
|
|
2651
|
+
if (typeof anchor === "number") return anchor;
|
|
2652
|
+
if (anchor === "end") return totalSec;
|
|
2653
|
+
const m = /^end-(\d+(?:\.\d+)?)$/.exec(anchor);
|
|
2654
|
+
if (m) return totalSec - parseFloat(m[1]);
|
|
2655
|
+
throw new Error(`resolveAnchor: bad anchor "${anchor}"`);
|
|
2656
|
+
}
|
|
2657
|
+
function resolveTimeline(spec, totalSec) {
|
|
2658
|
+
return spec.timeline.map((seg) => {
|
|
2659
|
+
const startSec = resolveAnchor(seg.at[0], totalSec);
|
|
2660
|
+
const endSec = resolveAnchor(seg.at[1], totalSec);
|
|
2661
|
+
return { startMs: startSec * 1e3, endMs: endSec * 1e3, layers: seg.layers };
|
|
2662
|
+
});
|
|
2663
|
+
}
|
|
2664
|
+
function visualTimelineMs(resolved) {
|
|
2665
|
+
return resolved.reduce((mx, s) => Math.max(mx, s.endMs), 0);
|
|
2666
|
+
}
|
|
2667
|
+
var SCREEN_PINNED_KEYS = /* @__PURE__ */ new Set([
|
|
2668
|
+
"caption",
|
|
2669
|
+
"branding",
|
|
2670
|
+
"cta",
|
|
2671
|
+
"hook",
|
|
2672
|
+
"reveal",
|
|
2673
|
+
"portrait",
|
|
2674
|
+
"safe-guides",
|
|
2675
|
+
"mcq-card"
|
|
2676
|
+
]);
|
|
2677
|
+
async function buildScene(opts) {
|
|
2678
|
+
const { spec, theme, score } = opts;
|
|
2679
|
+
const [W, H] = spec.size;
|
|
2680
|
+
const fps = spec.fps ?? 30;
|
|
2681
|
+
const resolved = resolveTimeline(spec, opts.totalSec);
|
|
2682
|
+
const safe = safeBox(W, H);
|
|
2683
|
+
const camera = opts.camera ?? (() => identityCamera(W, H));
|
|
2684
|
+
const clock = { nowMs: () => 0 };
|
|
2685
|
+
const baseCtx = {
|
|
2686
|
+
W,
|
|
2687
|
+
H,
|
|
2688
|
+
score,
|
|
2689
|
+
audioClock: clock,
|
|
2690
|
+
theme,
|
|
2691
|
+
safeBox: safe,
|
|
2692
|
+
fps
|
|
2693
|
+
};
|
|
2694
|
+
const bound = [];
|
|
2695
|
+
for (const seg of resolved) {
|
|
2696
|
+
for (const sl of seg.layers) {
|
|
2697
|
+
const factory = getLayerFactory(sl.k);
|
|
2698
|
+
if (!factory) {
|
|
2699
|
+
throw new Error(`buildScene: unknown layer "${sl.k}" (registered: ${registeredKeys().join(", ")})`);
|
|
2700
|
+
}
|
|
2701
|
+
const errs = factory.validateProps(sl.p ?? {});
|
|
2702
|
+
if (errs.length) {
|
|
2703
|
+
throw new Error(`buildScene: invalid props for "${sl.k}": ${errs.join("; ")}`);
|
|
2704
|
+
}
|
|
2705
|
+
const layer = factory.create();
|
|
2706
|
+
await layer.init({ ...baseCtx, ctx2d: null }, sl.p ?? {});
|
|
2707
|
+
bound.push({
|
|
2708
|
+
layer,
|
|
2709
|
+
startMs: seg.startMs,
|
|
2710
|
+
endMs: seg.endMs,
|
|
2711
|
+
screenPinned: SCREEN_PINNED_KEYS.has(sl.k)
|
|
2712
|
+
});
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
const durationMs = visualTimelineMs(resolved);
|
|
2716
|
+
function renderFrame(ctx2d, tMs) {
|
|
2717
|
+
clock.nowMs = () => tMs;
|
|
2718
|
+
const ctx = { ...baseCtx, ctx2d };
|
|
2719
|
+
const cam = camera(tMs);
|
|
2720
|
+
ctx2d.save();
|
|
2721
|
+
applyToContext(ctx2d, cam, W, H);
|
|
2722
|
+
for (const b of bound) {
|
|
2723
|
+
if (b.screenPinned) continue;
|
|
2724
|
+
if (tMs < b.startMs || tMs >= b.endMs) continue;
|
|
2725
|
+
b.layer.draw(ctx, tMs);
|
|
2726
|
+
}
|
|
2727
|
+
ctx2d.restore();
|
|
2728
|
+
ctx2d.save();
|
|
2729
|
+
ctx2d.setTransform(1, 0, 0, 1, 0, 0);
|
|
2730
|
+
for (const b of bound) {
|
|
2731
|
+
if (!b.screenPinned) continue;
|
|
2732
|
+
if (tMs < b.startMs || tMs >= b.endMs) continue;
|
|
2733
|
+
b.layer.draw(ctx, tMs);
|
|
2734
|
+
}
|
|
2735
|
+
ctx2d.restore();
|
|
2736
|
+
}
|
|
2737
|
+
return {
|
|
2738
|
+
W,
|
|
2739
|
+
H,
|
|
2740
|
+
fps,
|
|
2741
|
+
durationMs,
|
|
2742
|
+
resolved,
|
|
2743
|
+
renderFrame,
|
|
2744
|
+
dispose() {
|
|
2745
|
+
for (const b of bound) b.layer.dispose?.();
|
|
2746
|
+
}
|
|
2747
|
+
};
|
|
2748
|
+
}
|
|
2749
|
+
async function recordSceneSpec(opts) {
|
|
2750
|
+
const { built } = opts;
|
|
2751
|
+
const record = opts.record ?? recordScenes;
|
|
2752
|
+
const composite = {
|
|
2753
|
+
durationMs: built.durationMs,
|
|
2754
|
+
draw: (ctx, t01) => built.renderFrame(ctx, t01 * built.durationMs)
|
|
2755
|
+
};
|
|
2756
|
+
return record([composite], {
|
|
2757
|
+
audioStream: opts.audioStream,
|
|
2758
|
+
width: built.W,
|
|
2759
|
+
height: built.H,
|
|
2760
|
+
fps: built.fps,
|
|
2761
|
+
background: opts.background,
|
|
2762
|
+
onProgress: opts.onProgress
|
|
2763
|
+
});
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
// src/scene/audioLayers.ts
|
|
2767
|
+
function countInSchedule(opts) {
|
|
2768
|
+
const beats = opts.beats ?? 4;
|
|
2769
|
+
const accent = opts.accentDownbeat ?? true;
|
|
2770
|
+
const beatSec = 60 / opts.bpm;
|
|
2771
|
+
const events = [];
|
|
2772
|
+
for (let i = 0; i < beats; i++) {
|
|
2773
|
+
const isDownbeat = accent && i === 0;
|
|
2774
|
+
events.push({
|
|
2775
|
+
atSec: i * beatSec,
|
|
2776
|
+
note: isDownbeat ? "C6" : "C5",
|
|
2777
|
+
durSec: 0.05,
|
|
2778
|
+
gain: isDownbeat ? 1 : 0.7,
|
|
2779
|
+
kind: "count"
|
|
2780
|
+
});
|
|
2781
|
+
}
|
|
2782
|
+
return events;
|
|
2783
|
+
}
|
|
2784
|
+
function countInLeadSec(opts) {
|
|
2785
|
+
return (opts.beats ?? 4) * (60 / opts.bpm);
|
|
2786
|
+
}
|
|
2787
|
+
function clickTrackSchedule(opts) {
|
|
2788
|
+
const beatSec = 60 / opts.bpm;
|
|
2789
|
+
const bpb = opts.beatsPerBar ?? 4;
|
|
2790
|
+
const start = opts.startSec ?? 0;
|
|
2791
|
+
const events = [];
|
|
2792
|
+
const n = Math.floor(opts.durationSec / beatSec);
|
|
2793
|
+
for (let i = 0; i < n; i++) {
|
|
2794
|
+
const isDownbeat = i % bpb === 0;
|
|
2795
|
+
events.push({
|
|
2796
|
+
atSec: start + i * beatSec,
|
|
2797
|
+
note: isDownbeat ? "C6" : "C5",
|
|
2798
|
+
durSec: 0.03,
|
|
2799
|
+
gain: isDownbeat ? 0.6 : 0.4,
|
|
2800
|
+
kind: "click"
|
|
2801
|
+
});
|
|
2802
|
+
}
|
|
2803
|
+
return events;
|
|
2804
|
+
}
|
|
2805
|
+
function droneSchedule(opts) {
|
|
2806
|
+
const gain = opts.gain ?? 0.15;
|
|
2807
|
+
const events = [
|
|
2808
|
+
{ atSec: 0, note: opts.root, durSec: opts.durationSec, gain, kind: "drone" }
|
|
2809
|
+
];
|
|
2810
|
+
if (opts.fifth ?? true) {
|
|
2811
|
+
events.push({ atSec: 0, note: transposeFifth(opts.root), durSec: opts.durationSec, gain, kind: "drone" });
|
|
2812
|
+
}
|
|
2813
|
+
return events;
|
|
2814
|
+
}
|
|
2815
|
+
function transposeFifth(note) {
|
|
2816
|
+
const m = /^([A-G])(#|b)?(\d)$/.exec(note);
|
|
2817
|
+
if (!m) return note;
|
|
2818
|
+
const order = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
|
2819
|
+
const pcMap = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
|
|
2820
|
+
let pc = pcMap[m[1]] + (m[2] === "#" ? 1 : m[2] === "b" ? -1 : 0);
|
|
2821
|
+
let oct = parseInt(m[3], 10);
|
|
2822
|
+
pc += 7;
|
|
2823
|
+
if (pc >= 12) {
|
|
2824
|
+
pc -= 12;
|
|
2825
|
+
oct += 1;
|
|
2826
|
+
}
|
|
2827
|
+
return `${order[pc]}${oct}`;
|
|
2828
|
+
}
|
|
2829
|
+
function duckGainAt(windows, tSec, floor = 0.25, rampSec = 0.2) {
|
|
2830
|
+
for (const w of windows) {
|
|
2831
|
+
if (tSec >= w.startSec - rampSec && tSec <= w.endSec + rampSec) {
|
|
2832
|
+
if (tSec < w.startSec) return lerpGain(1, floor, (tSec - (w.startSec - rampSec)) / rampSec);
|
|
2833
|
+
if (tSec > w.endSec) return lerpGain(floor, 1, (tSec - w.endSec) / rampSec);
|
|
2834
|
+
return floor;
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
return 1;
|
|
2838
|
+
}
|
|
2839
|
+
function lerpGain(a, b, t) {
|
|
2840
|
+
const k = t < 0 ? 0 : t > 1 ? 1 : t;
|
|
2841
|
+
return a + (b - a) * k;
|
|
2842
|
+
}
|
|
2843
|
+
function applySchedule(instrument, schedule, startTime) {
|
|
2844
|
+
for (const ev of schedule) {
|
|
2845
|
+
if (ev.note == null) continue;
|
|
2846
|
+
instrument.triggerAttackRelease(
|
|
2847
|
+
ev.note,
|
|
2848
|
+
ev.durSec ?? 0.05,
|
|
2849
|
+
startTime + ev.atSec,
|
|
2850
|
+
ev.gain ?? 1
|
|
2851
|
+
);
|
|
2852
|
+
}
|
|
2853
|
+
return schedule.length;
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
// src/scene/gate.ts
|
|
2857
|
+
var AV_TOLERANCE_MS = 60;
|
|
2858
|
+
async function runGate(input) {
|
|
2859
|
+
const errors = [];
|
|
2860
|
+
let resolved = [];
|
|
2861
|
+
for (const seg of input.spec.timeline) {
|
|
2862
|
+
for (const sl of seg.layers) {
|
|
2863
|
+
const factory = getLayerFactory(sl.k);
|
|
2864
|
+
if (!factory) {
|
|
2865
|
+
errors.push({ check: "spec", message: `unknown layer "${sl.k}"` });
|
|
2866
|
+
continue;
|
|
2867
|
+
}
|
|
2868
|
+
const errs = factory.validateProps(sl.p ?? {});
|
|
2869
|
+
for (const e of errs) errors.push({ check: "spec", message: e });
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
try {
|
|
2873
|
+
resolved = resolveTimeline(input.spec, input.totalSec);
|
|
2874
|
+
} catch (e) {
|
|
2875
|
+
errors.push({ check: "spec", message: `timeline: ${e.message}` });
|
|
2876
|
+
}
|
|
2877
|
+
if (resolved.length) {
|
|
2878
|
+
const vis = visualTimelineMs(resolved);
|
|
2879
|
+
if (!Number.isFinite(vis) || !Number.isFinite(input.audioMs)) {
|
|
2880
|
+
errors.push({ check: "av-duration", message: "non-finite visual/audio duration" });
|
|
2881
|
+
} else if (Math.abs(vis - input.audioMs) > AV_TOLERANCE_MS) {
|
|
2882
|
+
errors.push({
|
|
2883
|
+
check: "av-duration",
|
|
2884
|
+
message: `|visual ${Math.round(vis)}ms \u2212 audio ${Math.round(input.audioMs)}ms| = ${Math.round(
|
|
2885
|
+
Math.abs(vis - input.audioMs)
|
|
2886
|
+
)}ms > ${AV_TOLERANCE_MS}ms`
|
|
2887
|
+
});
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
for (const p of input.placements ?? []) {
|
|
2891
|
+
if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) {
|
|
2892
|
+
errors.push({ check: "placement", message: `${p.label}: NaN/\u221E position` });
|
|
2893
|
+
continue;
|
|
2894
|
+
}
|
|
2895
|
+
if (p.x < 0 || p.x > input.W || p.y < 0 || p.y > input.H) {
|
|
2896
|
+
errors.push({ check: "placement", message: `${p.label}: (${p.x},${p.y}) outside ${input.W}\xD7${input.H}` });
|
|
2897
|
+
}
|
|
2898
|
+
if (input.safezone && p.mustBeSafe) {
|
|
2899
|
+
const b = input.safeBox;
|
|
2900
|
+
if (p.x < b.left || p.x > b.right || p.y < b.top || p.y > b.bottom) {
|
|
2901
|
+
errors.push({ check: "placement", message: `${p.label}: outside safe box` });
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
const ready = input.fontsReady ? await input.fontsReady() : true;
|
|
2906
|
+
if (!ready) errors.push({ check: "fonts", message: "fonts not loaded before first frame" });
|
|
2907
|
+
if (input.output) {
|
|
2908
|
+
const expectedFrames = Math.floor((input.fps ?? 30) * (input.audioMs / 1e3) * 0.5);
|
|
2909
|
+
if (input.output.frames < expectedFrames) {
|
|
2910
|
+
errors.push({
|
|
2911
|
+
check: "output",
|
|
2912
|
+
message: `only ${input.output.frames} frames (expected \u2265 ${expectedFrames})`
|
|
2913
|
+
});
|
|
2914
|
+
}
|
|
2915
|
+
if (input.output.audioTracks < 1) {
|
|
2916
|
+
errors.push({ check: "output", message: "no audio track in output" });
|
|
2917
|
+
}
|
|
2918
|
+
if (input.output.durationMs != null && Math.abs(input.output.durationMs - input.audioMs) > AV_TOLERANCE_MS) {
|
|
2919
|
+
errors.push({
|
|
2920
|
+
check: "output",
|
|
2921
|
+
message: `output duration ${Math.round(input.output.durationMs)}ms vs audio ${Math.round(
|
|
2922
|
+
input.audioMs
|
|
2923
|
+
)}ms exceeds ${AV_TOLERANCE_MS}ms`
|
|
2924
|
+
});
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
return errors;
|
|
2928
|
+
}
|
|
2929
|
+
async function assertGate(input) {
|
|
2930
|
+
const errors = await runGate(input);
|
|
2931
|
+
if (errors.length) {
|
|
2932
|
+
throw new Error(
|
|
2933
|
+
`pre-render gate failed (${errors.length}):
|
|
2934
|
+
` + errors.map((e) => ` [${e.check}] ${e.message}`).join("\n")
|
|
2935
|
+
);
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
// src/scene/notationCamera.ts
|
|
2940
|
+
function mapBoxThroughLayout(base, b) {
|
|
2941
|
+
const fx = base.rect.dw / base.src.w;
|
|
2942
|
+
const fy = base.rect.dh / base.src.h;
|
|
2943
|
+
return {
|
|
2944
|
+
x: base.rect.dx + (b.x - base.src.x) * fx,
|
|
2945
|
+
y: base.rect.dy + (b.y - base.src.y) * fy,
|
|
2946
|
+
w: b.w * fx,
|
|
2947
|
+
h: b.h * fy
|
|
2948
|
+
};
|
|
2949
|
+
}
|
|
2950
|
+
function followSrcBox(rn, focusBoxCanvas) {
|
|
2951
|
+
const px = focusBoxCanvas.w * (FOLLOW_PAD - 1) / 2;
|
|
2952
|
+
const py = focusBoxCanvas.h * (FOLLOW_PAD - 1) / 2;
|
|
2953
|
+
const src = {
|
|
2954
|
+
x: Math.max(0, focusBoxCanvas.x - px),
|
|
2955
|
+
y: Math.max(0, focusBoxCanvas.y - py),
|
|
2956
|
+
w: focusBoxCanvas.w + 2 * px,
|
|
2957
|
+
h: focusBoxCanvas.h + 2 * py
|
|
2958
|
+
};
|
|
2959
|
+
src.w = Math.min(src.w, rn.canvas.width - src.x);
|
|
2960
|
+
src.h = Math.min(src.h, rn.canvas.height - src.y);
|
|
2961
|
+
return src;
|
|
2962
|
+
}
|
|
2963
|
+
function cameraForFollow(rn, base, focusBoxCanvas, viewW, viewH) {
|
|
2964
|
+
const src = followSrcBox(rn, focusBoxCanvas);
|
|
2965
|
+
const world = mapBoxThroughLayout(base, src);
|
|
2966
|
+
return frameRect(world, viewW, viewH, 0);
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
// src/scene/demos/fallingKeyboardDemo.ts
|
|
2970
|
+
function fallingKeyboardDemoSpec(opts = {}) {
|
|
2971
|
+
const size = opts.size ?? [1080, 1920];
|
|
2972
|
+
const leadMs = opts.leadMs ?? 2200;
|
|
2973
|
+
const colorBy = opts.colorBy ?? "hand";
|
|
2974
|
+
const range = opts.range ?? "auto";
|
|
2975
|
+
return {
|
|
2976
|
+
size,
|
|
2977
|
+
theme: opts.theme ?? "rsr",
|
|
2978
|
+
durationMode: "audio",
|
|
2979
|
+
timeline: [
|
|
2980
|
+
{
|
|
2981
|
+
at: [0, "end"],
|
|
2982
|
+
layers: [
|
|
2983
|
+
{ k: "background", p: { style: "ink" } },
|
|
2984
|
+
// keyboard first so it publishes its layout before falling-notes inits;
|
|
2985
|
+
// draw order: keyboard bed under the falling blocks would hide them, so
|
|
2986
|
+
// we draw falling-notes ON TOP of the keyboard bed (falling listed last).
|
|
2987
|
+
{ k: "keyboard", p: { range, colorBy } },
|
|
2988
|
+
{ k: "falling-notes", p: { keyboard: true, colorBy, leadMs } }
|
|
2989
|
+
]
|
|
2990
|
+
}
|
|
2991
|
+
]
|
|
2992
|
+
};
|
|
2993
|
+
}
|
|
2994
|
+
async function fallingKeyboardDemoScore(xml, opts = {}) {
|
|
2995
|
+
return scoreFromMusicXML(xml, opts.scoreOpts);
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
// src/scene/demos/promoCardsDemo.ts
|
|
2999
|
+
function promoCardsDemoSpec(opts = {}) {
|
|
3000
|
+
const size = opts.size ?? [1080, 1920];
|
|
3001
|
+
const totalSec = opts.totalSec ?? 12;
|
|
3002
|
+
const hookSec = opts.hookSec ?? 2.2;
|
|
3003
|
+
const revealSec = opts.revealSec ?? 4;
|
|
3004
|
+
const ctaSec = opts.ctaSec ?? 3;
|
|
3005
|
+
const hookLines = opts.hookLines ?? ["Can you", "name this?"];
|
|
3006
|
+
const title = opts.title ?? "Claude Debussy";
|
|
3007
|
+
const subtitle = opts.subtitle ?? "1862\u20131918";
|
|
3008
|
+
const ctaLines = opts.ctaLines ?? ["Train your ear", "realeartrainer.com"];
|
|
3009
|
+
const brand = opts.brand;
|
|
3010
|
+
const ms = (s) => Math.round(s * 1e3);
|
|
3011
|
+
const hookStart = 0;
|
|
3012
|
+
const revealStart = hookSec;
|
|
3013
|
+
const ctaStart = totalSec - ctaSec;
|
|
3014
|
+
return {
|
|
3015
|
+
size,
|
|
3016
|
+
theme: opts.theme ?? "rsr",
|
|
3017
|
+
durationMode: "fixed",
|
|
3018
|
+
durationSec: totalSec,
|
|
3019
|
+
timeline: [
|
|
3020
|
+
// background spans the whole clip.
|
|
3021
|
+
{ at: [0, "end"], layers: [{ k: "background", p: { style: "paper" } }] },
|
|
3022
|
+
// hook card.
|
|
3023
|
+
{
|
|
3024
|
+
at: [hookStart, hookSec],
|
|
3025
|
+
layers: [{ k: "hook", p: { lines: hookLines, brand, startMs: ms(hookStart), durationMs: ms(hookSec) } }]
|
|
3026
|
+
},
|
|
3027
|
+
// reveal phase: audio-reactive spectrum + the portrait medallion.
|
|
3028
|
+
{
|
|
3029
|
+
at: [revealStart, revealStart + revealSec],
|
|
3030
|
+
layers: [
|
|
3031
|
+
{ k: "spectrum", p: {} },
|
|
3032
|
+
{
|
|
3033
|
+
k: "portrait",
|
|
3034
|
+
p: {
|
|
3035
|
+
title,
|
|
3036
|
+
subtitle,
|
|
3037
|
+
url: opts.portraitUrl ?? null,
|
|
3038
|
+
funFact: opts.funFact,
|
|
3039
|
+
startMs: ms(revealStart),
|
|
3040
|
+
durationMs: ms(revealSec)
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
]
|
|
3044
|
+
},
|
|
3045
|
+
// end-card CTA.
|
|
3046
|
+
{
|
|
3047
|
+
at: [ctaStart, "end"],
|
|
3048
|
+
layers: [{ k: "cta", p: { lines: ctaLines, startMs: ms(ctaStart), durationMs: ms(ctaSec) } }]
|
|
3049
|
+
},
|
|
3050
|
+
// persistent branding across the whole clip.
|
|
3051
|
+
{ at: [0, "end"], layers: [{ k: "branding", p: { logo: brand } }] }
|
|
3052
|
+
],
|
|
3053
|
+
audio: { voicing: "reading" }
|
|
3054
|
+
};
|
|
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
|
+
}
|
|
3172
|
+
export {
|
|
3173
|
+
DEFAULT_FUNCTION_COLORS,
|
|
3174
|
+
FIFTHS_MAJOR,
|
|
3175
|
+
FIFTHS_MINOR,
|
|
3176
|
+
FOLLOW_BARS,
|
|
3177
|
+
FOLLOW_PAD,
|
|
3178
|
+
PIANO_HIGH,
|
|
3179
|
+
PIANO_LOW,
|
|
3180
|
+
activeChord,
|
|
3181
|
+
activeCue,
|
|
3182
|
+
activeSection,
|
|
3183
|
+
animatedSlot,
|
|
3184
|
+
applySchedule,
|
|
3185
|
+
applyToContext,
|
|
3186
|
+
assertGate,
|
|
3187
|
+
ballArc,
|
|
3188
|
+
ballX,
|
|
3189
|
+
beatGrid,
|
|
3190
|
+
beatPhase,
|
|
3191
|
+
blackKeys,
|
|
3192
|
+
bpmOf,
|
|
3193
|
+
brandingFactory,
|
|
3194
|
+
buildScene,
|
|
3195
|
+
cameraForFollow,
|
|
3196
|
+
cameraTransform,
|
|
3197
|
+
circleOfFifthsDemoSpec,
|
|
3198
|
+
circleOfFifthsFactory,
|
|
3199
|
+
clamp,
|
|
3200
|
+
clickTrackSchedule,
|
|
3201
|
+
contourMinimapDemoSpec,
|
|
3202
|
+
contourPoints,
|
|
3203
|
+
contourPolyline,
|
|
3204
|
+
countInLeadSec,
|
|
3205
|
+
countInSchedule,
|
|
3206
|
+
countdownRemaining,
|
|
3207
|
+
countdownSeconds,
|
|
3208
|
+
countingDegreeDemoSpec,
|
|
3209
|
+
countingTrackFactory,
|
|
3210
|
+
cropAroundBox,
|
|
3211
|
+
ctaFactory,
|
|
3212
|
+
cubicEaseInOut,
|
|
3213
|
+
cueOpacity,
|
|
3214
|
+
degreeLabel,
|
|
3215
|
+
degreeLabelsFactory,
|
|
3216
|
+
dotAt,
|
|
3217
|
+
drawCaption,
|
|
3218
|
+
drawHighlight,
|
|
3219
|
+
droneSchedule,
|
|
3220
|
+
duckGainAt,
|
|
3221
|
+
easeIn,
|
|
3222
|
+
easeInOut,
|
|
3223
|
+
easeOut,
|
|
3224
|
+
fallingKeyboardDemoScore,
|
|
3225
|
+
fallingKeyboardDemoSpec,
|
|
3226
|
+
fallingNotesFactory,
|
|
3227
|
+
firstMeasureBox,
|
|
3228
|
+
followBoxAt,
|
|
3229
|
+
followSrcBox,
|
|
3230
|
+
followWindowStart,
|
|
3231
|
+
fracSlotPoint,
|
|
3232
|
+
frameRect,
|
|
3233
|
+
functionColor,
|
|
3234
|
+
functionalHarmonyFactory,
|
|
3235
|
+
getKeyboardLayout,
|
|
3236
|
+
getLayerFactory,
|
|
3237
|
+
getNotationEngraving,
|
|
3238
|
+
harmonyDemoSpec,
|
|
3239
|
+
highlightIntensity,
|
|
3240
|
+
hookFactory,
|
|
3241
|
+
identityCamera,
|
|
3242
|
+
inRange,
|
|
3243
|
+
invLerp,
|
|
3244
|
+
isBlackKey,
|
|
3245
|
+
kenBurns,
|
|
3246
|
+
keyCenterX,
|
|
3247
|
+
keyColumnWidth,
|
|
3248
|
+
keyRect,
|
|
3249
|
+
keySlot,
|
|
3250
|
+
keyboardFactory,
|
|
3251
|
+
keyboardLayout,
|
|
3252
|
+
lerp,
|
|
3253
|
+
lerpBox,
|
|
3254
|
+
lerpCamera,
|
|
3255
|
+
linear,
|
|
3256
|
+
mapBoxThroughLayout,
|
|
3257
|
+
mcqCardFactory,
|
|
3258
|
+
mcqDemoSpec,
|
|
3259
|
+
measureColumnsFromLayout,
|
|
3260
|
+
measureCount,
|
|
3261
|
+
measureSpanBox,
|
|
3262
|
+
measureSpans,
|
|
3263
|
+
timeToX as minimapTimeToX,
|
|
3264
|
+
msPerBeat,
|
|
3265
|
+
notationFactory,
|
|
3266
|
+
notationLayout,
|
|
3267
|
+
noteColor,
|
|
3268
|
+
noteSetXRange,
|
|
3269
|
+
parseKey,
|
|
3270
|
+
parseTimeSig,
|
|
3271
|
+
pcToSlot,
|
|
3272
|
+
pitchAt,
|
|
3273
|
+
pitchContourFactory,
|
|
3274
|
+
pitchRange,
|
|
3275
|
+
playheadLine,
|
|
3276
|
+
portraitFactory,
|
|
3277
|
+
progress01,
|
|
3278
|
+
projectPoint,
|
|
3279
|
+
promoCardsDemoSpec,
|
|
3280
|
+
quizPhase,
|
|
3281
|
+
rayEndpoints,
|
|
3282
|
+
rayPointAt,
|
|
3283
|
+
recordSceneSpec,
|
|
3284
|
+
registerLayer,
|
|
3285
|
+
registeredKeys,
|
|
3286
|
+
resolveAnchor,
|
|
3287
|
+
resolveKeyboardLayout,
|
|
3288
|
+
resolveTimeline,
|
|
3289
|
+
revealFactory,
|
|
3290
|
+
revealProgress,
|
|
3291
|
+
runGate,
|
|
3292
|
+
safeGuidesFactory,
|
|
3293
|
+
scoreFromMusicXML,
|
|
3294
|
+
scorePitchSpan,
|
|
3295
|
+
scrollCursorFactory,
|
|
3296
|
+
sectionMinimapFactory,
|
|
3297
|
+
setFollowLayoutProvider,
|
|
3298
|
+
setKeyboardLayout,
|
|
3299
|
+
setNotationEngraving,
|
|
3300
|
+
slotAngle,
|
|
3301
|
+
slotPc,
|
|
3302
|
+
slotPoint,
|
|
3303
|
+
spectrumFactory,
|
|
3304
|
+
staffAnchor,
|
|
3305
|
+
staffKeyboardRayFactory,
|
|
3306
|
+
staffRayDemoSpec,
|
|
3307
|
+
validateChordTrack,
|
|
3308
|
+
validateQuiz,
|
|
3309
|
+
validateSections,
|
|
3310
|
+
visualTimelineMs,
|
|
3311
|
+
whiteKeys,
|
|
3312
|
+
worldToViewport
|
|
3313
|
+
};
|
|
3314
|
+
//# sourceMappingURL=index.js.map
|