@real-music-packages/web-core 0.22.0 → 0.24.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.
@@ -1,3 +1,10 @@
1
+ import {
2
+ intervalBySemitones
3
+ } from "../chunk-N56UTMWA.js";
4
+ import {
5
+ noteNameToIndex,
6
+ pitchClass
7
+ } from "../chunk-25RUSM2X.js";
1
8
  import {
2
9
  ctaScene,
3
10
  drawSafeGuides,
@@ -3003,6 +3010,1066 @@ var sectionMinimapFactory = {
3003
3010
  }
3004
3011
  };
3005
3012
 
3013
+ // src/scene/layers/beatPulse.ts
3014
+ var DEFAULTS = {
3015
+ styles: ["bloom"],
3016
+ intensity: 0.42,
3017
+ attackMs: 0,
3018
+ decayMs: 170,
3019
+ blend: "source-over"
3020
+ };
3021
+ function envAt(tMs, onsetMs, attackMs, decayMs) {
3022
+ const dt = tMs - onsetMs;
3023
+ if (dt < 0) return 0;
3024
+ if (dt < attackMs) return attackMs <= 0 ? 1 : dt / attackMs;
3025
+ return Math.exp(-(dt - attackMs) / decayMs);
3026
+ }
3027
+ function pulseAt(onsetsMs, tMs, attackMs = DEFAULTS.attackMs, decayMs = DEFAULTS.decayMs) {
3028
+ let p = 0;
3029
+ for (let i = onsetsMs.length - 1; i >= 0; i--) {
3030
+ const on = onsetsMs[i];
3031
+ if (on > tMs) continue;
3032
+ const e = envAt(tMs, on, attackMs, decayMs);
3033
+ if (e > p) p = e;
3034
+ if (tMs - on > attackMs + decayMs * 6) break;
3035
+ }
3036
+ return p;
3037
+ }
3038
+ function rgba2(hex, a) {
3039
+ const h = hex.replace("#", "");
3040
+ const n = h.length === 3 ? parseInt(h.split("").map((c) => c + c).join(""), 16) : parseInt(h, 16);
3041
+ const r = n >> 16 & 255, g = n >> 8 & 255, b = n & 255;
3042
+ return `rgba(${r},${g},${b},${a})`;
3043
+ }
3044
+ function beatPulseLayer() {
3045
+ let onsets = [];
3046
+ let styles = DEFAULTS.styles;
3047
+ let intensity = DEFAULTS.intensity;
3048
+ let attackMs = DEFAULTS.attackMs;
3049
+ let decayMs = DEFAULTS.decayMs;
3050
+ let color;
3051
+ let blend = DEFAULTS.blend;
3052
+ return {
3053
+ key: "beat-pulse",
3054
+ init(ctx, props) {
3055
+ onsets = props.onsetsMs ?? (ctx.score ? distinctOnsets(ctx.score.notes) : []);
3056
+ onsets = [...onsets].sort((a, b) => a - b);
3057
+ styles = props.styles ?? DEFAULTS.styles;
3058
+ intensity = props.intensity ?? DEFAULTS.intensity;
3059
+ attackMs = props.attackMs ?? DEFAULTS.attackMs;
3060
+ decayMs = props.decayMs ?? DEFAULTS.decayMs;
3061
+ color = props.color;
3062
+ blend = props.blend ?? DEFAULTS.blend;
3063
+ },
3064
+ draw(ctx, tMs) {
3065
+ if (!onsets.length) return;
3066
+ const p = pulseAt(onsets, tMs, attackMs, decayMs);
3067
+ if (p <= 4e-3) return;
3068
+ const c = ctx.ctx2d;
3069
+ const W = ctx.W, H = ctx.H;
3070
+ const col = color ?? ctx.theme.accent;
3071
+ const a = intensity * p;
3072
+ c.save();
3073
+ c.globalCompositeOperation = blend;
3074
+ if (styles.includes("bloom")) {
3075
+ const cx = W / 2, cy = H * 0.46;
3076
+ const rad = Math.max(W, H) * (0.3 + 0.1 * p);
3077
+ const g = c.createRadialGradient(cx, cy, 0, cx, cy, rad);
3078
+ g.addColorStop(0, rgba2(col, a * 0.55));
3079
+ g.addColorStop(0.55, rgba2(col, a * 0.18));
3080
+ g.addColorStop(1, rgba2(col, 0));
3081
+ c.fillStyle = g;
3082
+ c.fillRect(0, 0, W, H);
3083
+ }
3084
+ if (styles.includes("vignette")) {
3085
+ const cx = W / 2, cy = H / 2;
3086
+ const inner = Math.min(W, H) * 0.42;
3087
+ const outer = Math.max(W, H) * 0.72;
3088
+ const g = c.createRadialGradient(cx, cy, inner, cx, cy, outer);
3089
+ g.addColorStop(0, rgba2(col, 0));
3090
+ g.addColorStop(1, rgba2(col, a * 0.6));
3091
+ c.fillStyle = g;
3092
+ c.fillRect(0, 0, W, H);
3093
+ }
3094
+ c.restore();
3095
+ }
3096
+ };
3097
+ }
3098
+ var beatPulseFactory = {
3099
+ key: "beat-pulse",
3100
+ create: beatPulseLayer,
3101
+ validateProps(props) {
3102
+ if (props == null || typeof props !== "object") return ["beat-pulse: props must be an object"];
3103
+ const p = props;
3104
+ const errs = [];
3105
+ if (p.onsetsMs != null && (!Array.isArray(p.onsetsMs) || p.onsetsMs.some((x) => typeof x !== "number")))
3106
+ errs.push("beat-pulse.onsetsMs must be a number[]");
3107
+ if (p.styles != null) {
3108
+ if (!Array.isArray(p.styles)) errs.push("beat-pulse.styles must be an array");
3109
+ else if (p.styles.some((s) => s !== "bloom" && s !== "vignette"))
3110
+ errs.push('beat-pulse.styles items must be "bloom" | "vignette"');
3111
+ }
3112
+ if (p.intensity != null && (typeof p.intensity !== "number" || p.intensity < 0 || p.intensity > 1))
3113
+ errs.push("beat-pulse.intensity must be in [0,1]");
3114
+ if (p.attackMs != null && (typeof p.attackMs !== "number" || p.attackMs < 0))
3115
+ errs.push("beat-pulse.attackMs must be a number >= 0");
3116
+ if (p.decayMs != null && (typeof p.decayMs !== "number" || p.decayMs <= 0))
3117
+ errs.push("beat-pulse.decayMs must be a positive number");
3118
+ if (p.color != null && typeof p.color !== "string") errs.push("beat-pulse.color must be a string");
3119
+ if (p.blend != null && p.blend !== "source-over" && p.blend !== "lighter")
3120
+ errs.push('beat-pulse.blend must be "source-over" | "lighter"');
3121
+ return errs;
3122
+ }
3123
+ };
3124
+
3125
+ // src/scene/layers/chordRibbon.ts
3126
+ var DEFAULTS2 = { pxPerMs: 0.06, playheadFrac: 0.32, height: 84, showFunction: true };
3127
+ function rgba3(hex, a) {
3128
+ const h = hex.replace("#", "");
3129
+ const n = h.length === 3 ? parseInt(h.split("").map((c) => c + c).join(""), 16) : parseInt(h, 16);
3130
+ return `rgba(${n >> 16 & 255},${n >> 8 & 255},${n & 255},${a})`;
3131
+ }
3132
+ function roundRectPath(c, x, y, w, h, r) {
3133
+ if (typeof c.roundRect === "function") {
3134
+ c.beginPath();
3135
+ c.roundRect(x, y, w, h, r);
3136
+ return;
3137
+ }
3138
+ const rr = Math.min(r, w / 2, h / 2);
3139
+ c.beginPath();
3140
+ c.moveTo(x + rr, y);
3141
+ c.arcTo(x + w, y, x + w, y + h, rr);
3142
+ c.arcTo(x + w, y + h, x, y + h, rr);
3143
+ c.arcTo(x, y + h, x, y, rr);
3144
+ c.arcTo(x, y, x + w, y, rr);
3145
+ c.closePath();
3146
+ }
3147
+ function chordRibbonLayer() {
3148
+ let track = [];
3149
+ let pxPerMs = DEFAULTS2.pxPerMs;
3150
+ let playheadFrac = DEFAULTS2.playheadFrac;
3151
+ let yProp;
3152
+ let height = DEFAULTS2.height;
3153
+ let showFunction = DEFAULTS2.showFunction;
3154
+ let colors = DEFAULT_FUNCTION_COLORS;
3155
+ return {
3156
+ key: "chord-ribbon",
3157
+ init(_ctx, props) {
3158
+ track = (props.chordTrack ?? []).slice().sort((a, b) => a.startMs - b.startMs);
3159
+ pxPerMs = props.pxPerMs ?? DEFAULTS2.pxPerMs;
3160
+ playheadFrac = props.playheadFrac ?? DEFAULTS2.playheadFrac;
3161
+ yProp = props.y;
3162
+ height = props.height ?? DEFAULTS2.height;
3163
+ showFunction = props.showFunction ?? DEFAULTS2.showFunction;
3164
+ colors = props.colors ?? DEFAULT_FUNCTION_COLORS;
3165
+ },
3166
+ draw(ctx, tMs) {
3167
+ if (!track.length) return;
3168
+ const c = ctx.ctx2d;
3169
+ const sb = ctx.safeBox;
3170
+ const left = sb.left;
3171
+ const right = sb.right;
3172
+ const span = right - left;
3173
+ const playX = left + span * playheadFrac;
3174
+ const y = yProp ?? sb.bottom - 260;
3175
+ const h = height;
3176
+ const pad = 8;
3177
+ c.save();
3178
+ c.beginPath();
3179
+ c.rect(left, y - pad, span, h + pad * 2);
3180
+ c.clip();
3181
+ for (const s of track) {
3182
+ const x0 = playX + (s.startMs - tMs) * pxPerMs;
3183
+ const w = Math.max(36, (s.endMs - s.startMs) * pxPerMs - 6);
3184
+ if (x0 + w < left || x0 > right) continue;
3185
+ const active = tMs >= s.startMs && tMs < s.endMs;
3186
+ const played = tMs >= s.endMs;
3187
+ const fnCol = showFunction ? functionColor(s.fn, colors) : ctx.theme.accent;
3188
+ const chipAlpha = active ? 0.95 : played ? 0.28 : 0.55;
3189
+ roundRectPath(c, x0, y, w, h, 14);
3190
+ c.fillStyle = rgba3(fnCol, chipAlpha);
3191
+ c.fill();
3192
+ if (active) {
3193
+ c.lineWidth = 3;
3194
+ c.strokeStyle = rgba3(ctx.theme.paper ?? "#ffffff", 0.9);
3195
+ c.stroke();
3196
+ }
3197
+ const label = s.label ?? "";
3198
+ if (label) {
3199
+ c.fillStyle = rgba3(ctx.theme.paper ?? "#ffffff", active ? 1 : 0.85);
3200
+ c.font = `${active ? 700 : 600} ${Math.round(h * 0.42)}px ${ctx.theme.fontDisplay}`;
3201
+ c.textAlign = "center";
3202
+ c.textBaseline = "middle";
3203
+ c.fillText(label, x0 + w / 2, y + h / 2);
3204
+ }
3205
+ }
3206
+ c.strokeStyle = rgba3(ctx.theme.gold ?? "#d4af37", 0.95);
3207
+ c.lineWidth = 3;
3208
+ c.beginPath();
3209
+ c.moveTo(playX, y - pad);
3210
+ c.lineTo(playX, y + h + pad);
3211
+ c.stroke();
3212
+ c.restore();
3213
+ }
3214
+ };
3215
+ }
3216
+ var chordRibbonFactory = {
3217
+ key: "chord-ribbon",
3218
+ create: chordRibbonLayer,
3219
+ validateProps(props) {
3220
+ if (props == null || typeof props !== "object") return ["chord-ribbon: props must be an object"];
3221
+ const p = props;
3222
+ const errs = validateChordTrack(p.chordTrack);
3223
+ if (p.pxPerMs != null && (typeof p.pxPerMs !== "number" || p.pxPerMs <= 0))
3224
+ errs.push("chord-ribbon.pxPerMs must be a positive number");
3225
+ if (p.playheadFrac != null && (typeof p.playheadFrac !== "number" || p.playheadFrac < 0 || p.playheadFrac > 1))
3226
+ errs.push("chord-ribbon.playheadFrac must be in [0,1]");
3227
+ if (p.y != null && typeof p.y !== "number") errs.push("chord-ribbon.y must be a number");
3228
+ if (p.height != null && (typeof p.height !== "number" || p.height <= 0))
3229
+ errs.push("chord-ribbon.height must be a positive number");
3230
+ if (p.showFunction != null && typeof p.showFunction !== "boolean")
3231
+ errs.push("chord-ribbon.showFunction must be a boolean");
3232
+ return errs;
3233
+ }
3234
+ };
3235
+
3236
+ // src/scene/layers/progressRing.ts
3237
+ var DEFAULTS3 = { radius: 46, thickness: 9, showCountdown: false };
3238
+ function rgba4(hex, a) {
3239
+ const h = hex.replace("#", "");
3240
+ const n = h.length === 3 ? parseInt(h.split("").map((c) => c + c).join(""), 16) : parseInt(h, 16);
3241
+ return `rgba(${n >> 16 & 255},${n >> 8 & 255},${n & 255},${a})`;
3242
+ }
3243
+ function progressRingLayer() {
3244
+ let totalMsProp;
3245
+ let radius = DEFAULTS3.radius;
3246
+ let thickness = DEFAULTS3.thickness;
3247
+ let cxProp;
3248
+ let cyProp;
3249
+ let color;
3250
+ let trackColor;
3251
+ let showCountdown = DEFAULTS3.showCountdown;
3252
+ return {
3253
+ key: "progress-ring",
3254
+ init(ctx, props) {
3255
+ totalMsProp = props.totalMs ?? ctx.score?.durationMs;
3256
+ radius = props.radius ?? DEFAULTS3.radius;
3257
+ thickness = props.thickness ?? DEFAULTS3.thickness;
3258
+ cxProp = props.cx;
3259
+ cyProp = props.cy;
3260
+ color = props.color;
3261
+ trackColor = props.trackColor;
3262
+ showCountdown = props.showCountdown ?? DEFAULTS3.showCountdown;
3263
+ },
3264
+ draw(ctx, tMs) {
3265
+ const total = totalMsProp;
3266
+ if (!total || total <= 0) return;
3267
+ const c = ctx.ctx2d;
3268
+ const sb = ctx.safeBox;
3269
+ const cx = cxProp ?? sb.right - radius - 16;
3270
+ const cy = cyProp ?? sb.top + radius + 16;
3271
+ const p = Math.min(1, Math.max(0, tMs / total));
3272
+ const start = -Math.PI / 2;
3273
+ const end = start + p * Math.PI * 2;
3274
+ const fill = color ?? ctx.theme.gold;
3275
+ const track = trackColor ?? rgba4(ctx.theme.paper, 0.22);
3276
+ c.save();
3277
+ c.lineCap = "round";
3278
+ c.beginPath();
3279
+ c.arc(cx, cy, radius, 0, Math.PI * 2);
3280
+ c.strokeStyle = track;
3281
+ c.lineWidth = thickness;
3282
+ c.stroke();
3283
+ if (p > 0) {
3284
+ c.beginPath();
3285
+ c.arc(cx, cy, radius, start, end);
3286
+ c.strokeStyle = fill;
3287
+ c.lineWidth = thickness;
3288
+ c.stroke();
3289
+ }
3290
+ if (showCountdown) {
3291
+ const remain = Math.max(0, Math.ceil((total - tMs) / 1e3));
3292
+ c.fillStyle = ctx.theme.paper;
3293
+ c.font = `700 ${Math.round(radius * 0.7)}px ${ctx.theme.fontDisplay}`;
3294
+ c.textAlign = "center";
3295
+ c.textBaseline = "middle";
3296
+ c.fillText(String(remain), cx, cy + 1);
3297
+ }
3298
+ c.restore();
3299
+ }
3300
+ };
3301
+ }
3302
+ var progressRingFactory = {
3303
+ key: "progress-ring",
3304
+ create: progressRingLayer,
3305
+ validateProps(props) {
3306
+ if (props == null || typeof props !== "object") return ["progress-ring: props must be an object"];
3307
+ const p = props;
3308
+ const errs = [];
3309
+ if (p.totalMs != null && (typeof p.totalMs !== "number" || p.totalMs <= 0))
3310
+ errs.push("progress-ring.totalMs must be a positive number");
3311
+ if (p.radius != null && (typeof p.radius !== "number" || p.radius <= 0))
3312
+ errs.push("progress-ring.radius must be a positive number");
3313
+ if (p.thickness != null && (typeof p.thickness !== "number" || p.thickness <= 0))
3314
+ errs.push("progress-ring.thickness must be a positive number");
3315
+ if (p.cx != null && typeof p.cx !== "number") errs.push("progress-ring.cx must be a number");
3316
+ if (p.cy != null && typeof p.cy !== "number") errs.push("progress-ring.cy must be a number");
3317
+ if (p.color != null && typeof p.color !== "string") errs.push("progress-ring.color must be a string");
3318
+ if (p.trackColor != null && typeof p.trackColor !== "string") errs.push("progress-ring.trackColor must be a string");
3319
+ if (p.showCountdown != null && typeof p.showCountdown !== "boolean")
3320
+ errs.push("progress-ring.showCountdown must be a boolean");
3321
+ return errs;
3322
+ }
3323
+ };
3324
+
3325
+ // src/scene/layers/radialSpectrum.ts
3326
+ var SPEC_BARS2 = 64;
3327
+ function clamp013(x) {
3328
+ return x < 0 ? 0 : x > 1 ? 1 : x;
3329
+ }
3330
+ function lerpHex2(a, b, f) {
3331
+ const pa = parseInt(a.slice(1), 16);
3332
+ const pb = parseInt(b.slice(1), 16);
3333
+ const r = Math.round((pa >> 16 & 255) + ((pb >> 16 & 255) - (pa >> 16 & 255)) * f);
3334
+ const g = Math.round((pa >> 8 & 255) + ((pb >> 8 & 255) - (pa >> 8 & 255)) * f);
3335
+ const bl = Math.round((pa & 255) + ((pb & 255) - (pa & 255)) * f);
3336
+ return `rgb(${r},${g},${bl})`;
3337
+ }
3338
+ function syntheticMagnitude2(tSec, b, n) {
3339
+ const phase = tSec * 5 + b * 0.45;
3340
+ let m = 0.22 + 0.16 * Math.sin(phase) + 0.1 * Math.sin(phase * 1.7 + 1.2);
3341
+ m *= 0.5 + 0.5 * Math.sin(b / (n - 1) * Math.PI);
3342
+ return m;
3343
+ }
3344
+ function binByteFreq2(freq, n) {
3345
+ let peak = 0;
3346
+ for (let i = 0; i < freq.length; i++) if (freq[i] > peak) peak = freq[i];
3347
+ if (peak <= 4) return null;
3348
+ const lo = 2, hi = 440;
3349
+ const out = [];
3350
+ for (let b = 0; b < n; b++) {
3351
+ const f0 = lo * Math.pow(hi / lo, b / n);
3352
+ const f1 = lo * Math.pow(hi / lo, (b + 1) / n);
3353
+ const i0 = Math.floor(f0);
3354
+ const i1 = Math.max(i0 + 1, Math.floor(f1));
3355
+ let sum = 0, cnt = 0;
3356
+ for (let i = i0; i < i1 && i < freq.length; i++) {
3357
+ sum += freq[i];
3358
+ cnt++;
3359
+ }
3360
+ let m = cnt ? sum / cnt / 255 : 0;
3361
+ m = Math.pow(m, 0.78);
3362
+ out.push(m);
3363
+ }
3364
+ return out;
3365
+ }
3366
+ function radialSpectrumLayer() {
3367
+ let n = SPEC_BARS2;
3368
+ let cxFrac = 0.5, cyFrac = 0.42, innerFrac = 0.2, maxLenFrac = 0.13;
3369
+ let colorLow, colorHigh;
3370
+ let levelsFn;
3371
+ function magnitudesAt(ctx, tMs) {
3372
+ const fromProp = levelsFn?.(tMs, n);
3373
+ const src = fromProp ?? resolveFromCtx(ctx, tMs);
3374
+ if (src && src.length) {
3375
+ const out = new Array(n);
3376
+ for (let b = 0; b < n; b++) out[b] = clamp013(src[Math.min(src.length - 1, b)] ?? 0);
3377
+ return out;
3378
+ }
3379
+ const tSec = tMs / 1e3;
3380
+ return Array.from({ length: n }, (_, b) => clamp013(syntheticMagnitude2(tSec, b, n)));
3381
+ }
3382
+ function resolveFromCtx(ctx, tMs) {
3383
+ const sp = ctx.spectrum;
3384
+ if (!sp) return null;
3385
+ const lv = sp.levels?.(tMs, n);
3386
+ if (lv && lv.length) return Array.from(lv);
3387
+ const bf = sp.byteFreq?.(tMs);
3388
+ if (bf && bf.length) return binByteFreq2(bf, n);
3389
+ return null;
3390
+ }
3391
+ return {
3392
+ key: "radial-spectrum",
3393
+ init(_ctx, props) {
3394
+ n = props.bars ?? SPEC_BARS2;
3395
+ cxFrac = props.cxFrac ?? 0.5;
3396
+ cyFrac = props.cyFrac ?? 0.42;
3397
+ innerFrac = props.innerFrac ?? 0.2;
3398
+ maxLenFrac = props.maxLenFrac ?? 0.13;
3399
+ colorLow = props.colorLow;
3400
+ colorHigh = props.colorHigh;
3401
+ levelsFn = props.levelsFn;
3402
+ },
3403
+ draw(ctx, tMs) {
3404
+ const c = ctx.ctx2d;
3405
+ const W = ctx.W, H = ctx.H;
3406
+ const cx = W * cxFrac, cy = H * cyFrac;
3407
+ const unit = Math.min(W, H);
3408
+ const inner = unit * innerFrac;
3409
+ const maxLen = unit * maxLenFrac;
3410
+ const lo = colorLow ?? ctx.theme.accent;
3411
+ const hi = colorHigh ?? ctx.theme.gold;
3412
+ const mags = magnitudesAt(ctx, tMs);
3413
+ const barW = Math.max(3, 2 * Math.PI * inner / n * 0.6);
3414
+ c.save();
3415
+ c.lineCap = "round";
3416
+ for (let b = 0; b < n; b++) {
3417
+ const m = mags[b];
3418
+ const ang = b / n * Math.PI * 2 - Math.PI / 2;
3419
+ const len = Math.max(barW * 0.5, m * maxLen);
3420
+ const x0 = cx + Math.cos(ang) * inner;
3421
+ const y0 = cy + Math.sin(ang) * inner;
3422
+ const x1 = cx + Math.cos(ang) * (inner + len);
3423
+ const y1 = cy + Math.sin(ang) * (inner + len);
3424
+ c.strokeStyle = lerpHex2(lo, hi, m);
3425
+ c.lineWidth = barW;
3426
+ c.beginPath();
3427
+ c.moveTo(x0, y0);
3428
+ c.lineTo(x1, y1);
3429
+ c.stroke();
3430
+ }
3431
+ c.restore();
3432
+ }
3433
+ };
3434
+ }
3435
+ var radialSpectrumFactory = {
3436
+ key: "radial-spectrum",
3437
+ create: radialSpectrumLayer,
3438
+ validateProps(props) {
3439
+ if (props == null || typeof props !== "object") return ["radial-spectrum: props must be an object"];
3440
+ const p = props;
3441
+ const errs = [];
3442
+ if (p.bars != null && (typeof p.bars !== "number" || p.bars < 6)) errs.push("radial-spectrum.bars must be a number >= 6");
3443
+ for (const k of ["cxFrac", "cyFrac", "innerFrac", "maxLenFrac"]) {
3444
+ if (p[k] != null && (typeof p[k] !== "number" || p[k] < 0 || p[k] > 1))
3445
+ errs.push(`radial-spectrum.${k} must be in [0,1]`);
3446
+ }
3447
+ if (p.colorLow != null && typeof p.colorLow !== "string") errs.push("radial-spectrum.colorLow must be a string");
3448
+ if (p.colorHigh != null && typeof p.colorHigh !== "string") errs.push("radial-spectrum.colorHigh must be a string");
3449
+ if (p.levelsFn != null && typeof p.levelsFn !== "function") errs.push("radial-spectrum.levelsFn must be a function");
3450
+ return errs;
3451
+ }
3452
+ };
3453
+
3454
+ // src/scene/layers/karaokeCaption.ts
3455
+ var DEFAULTS4 = { centerFrac: 0.5, fontPx: 64, activeHoldMs: 320 };
3456
+ function rgba5(hex, a) {
3457
+ const h = hex.replace("#", "");
3458
+ const n = h.length === 3 ? parseInt(h.split("").map((ch) => ch + ch).join(""), 16) : parseInt(h, 16);
3459
+ return `rgba(${n >> 16 & 255},${n >> 8 & 255},${n & 255},${a})`;
3460
+ }
3461
+ function karaokeCaptionLayer() {
3462
+ let words = [];
3463
+ let centerFrac = DEFAULTS4.centerFrac;
3464
+ let fontPx = DEFAULTS4.fontPx;
3465
+ let sungColor;
3466
+ let upcomingColor;
3467
+ let activeColor;
3468
+ let activeHoldMs = DEFAULTS4.activeHoldMs;
3469
+ return {
3470
+ key: "karaoke-caption",
3471
+ init(_ctx, props) {
3472
+ words = (props.words ?? []).slice().sort((a, b) => a.atMs - b.atMs);
3473
+ centerFrac = props.centerFrac ?? DEFAULTS4.centerFrac;
3474
+ fontPx = props.fontPx ?? DEFAULTS4.fontPx;
3475
+ sungColor = props.sungColor;
3476
+ upcomingColor = props.upcomingColor;
3477
+ activeColor = props.activeColor;
3478
+ activeHoldMs = props.activeHoldMs ?? DEFAULTS4.activeHoldMs;
3479
+ },
3480
+ draw(ctx, tMs) {
3481
+ if (!words.length) return;
3482
+ const c = ctx.ctx2d;
3483
+ const sb = ctx.safeBox;
3484
+ c.save();
3485
+ c.font = `700 ${fontPx}px ${ctx.theme.fontDisplay}`;
3486
+ c.textBaseline = "middle";
3487
+ c.textAlign = "left";
3488
+ const spaceW = c.measureText(" ").width;
3489
+ const maxW = sb.w;
3490
+ const lines = [];
3491
+ let cur = { items: [], width: 0 };
3492
+ for (const w of words) {
3493
+ const ww = c.measureText(w.text).width;
3494
+ const add = cur.items.length ? spaceW + ww : ww;
3495
+ if (cur.items.length && cur.width + add > maxW) {
3496
+ lines.push(cur);
3497
+ cur = { items: [], width: 0 };
3498
+ }
3499
+ cur.items.push({ w, width: ww });
3500
+ cur.width += cur.items.length === 1 ? ww : spaceW + ww;
3501
+ }
3502
+ if (cur.items.length) lines.push(cur);
3503
+ const lineH = fontPx * 1.3;
3504
+ const totalH = lines.length * lineH;
3505
+ let y = H_center(ctx, centerFrac) - totalH / 2 + lineH / 2;
3506
+ const sung = sungColor ?? ctx.theme.ink;
3507
+ const upcoming = upcomingColor ?? rgba5(ctx.theme.ink, 0.28);
3508
+ const active = activeColor ?? ctx.theme.accent;
3509
+ for (const line of lines) {
3510
+ let x = sb.left + (maxW - line.width) / 2;
3511
+ for (const it of line.items) {
3512
+ const lit = tMs >= it.w.atMs;
3513
+ const fresh = lit && tMs - it.w.atMs < activeHoldMs;
3514
+ c.fillStyle = fresh ? active : lit ? sung : upcoming;
3515
+ c.fillText(it.w.text, x, y);
3516
+ x += it.width + spaceW;
3517
+ }
3518
+ y += lineH;
3519
+ }
3520
+ c.restore();
3521
+ }
3522
+ };
3523
+ }
3524
+ function H_center(ctx, frac) {
3525
+ return ctx.H * frac;
3526
+ }
3527
+ var karaokeCaptionFactory = {
3528
+ key: "karaoke-caption",
3529
+ create: karaokeCaptionLayer,
3530
+ validateProps(props) {
3531
+ if (props == null || typeof props !== "object") return ["karaoke-caption: props must be an object"];
3532
+ const p = props;
3533
+ const errs = [];
3534
+ if (!Array.isArray(p.words)) {
3535
+ errs.push("karaoke-caption.words must be an array of {text, atMs}");
3536
+ } else {
3537
+ p.words.forEach((raw, i) => {
3538
+ const w = raw;
3539
+ if (typeof w?.text !== "string" || typeof w?.atMs !== "number")
3540
+ errs.push(`karaoke-caption.words[${i}] must be {text:string, atMs:number}`);
3541
+ });
3542
+ }
3543
+ if (p.centerFrac != null && (typeof p.centerFrac !== "number" || p.centerFrac < 0 || p.centerFrac > 1))
3544
+ errs.push("karaoke-caption.centerFrac must be in [0,1]");
3545
+ if (p.fontPx != null && (typeof p.fontPx !== "number" || p.fontPx <= 0))
3546
+ errs.push("karaoke-caption.fontPx must be a positive number");
3547
+ if (p.activeHoldMs != null && (typeof p.activeHoldMs !== "number" || p.activeHoldMs < 0))
3548
+ errs.push("karaoke-caption.activeHoldMs must be a number >= 0");
3549
+ for (const k of ["sungColor", "upcomingColor", "activeColor"]) {
3550
+ if (p[k] != null && typeof p[k] !== "string") errs.push(`karaoke-caption.${k} must be a string`);
3551
+ }
3552
+ return errs;
3553
+ }
3554
+ };
3555
+
3556
+ // src/scene/layers/intervalArcs.ts
3557
+ function intervalLabel(deltaSemis) {
3558
+ const semi = Math.abs(deltaSemis);
3559
+ const within = semi % 12;
3560
+ if (semi > 0 && within === 0) return "P8";
3561
+ return intervalBySemitones(within)?.label ?? `${within}`;
3562
+ }
3563
+ function intervalArcsLayer() {
3564
+ let topProp;
3565
+ let heightProp;
3566
+ let upColor;
3567
+ let downColor;
3568
+ let showLabels = true;
3569
+ let fontPx = 30;
3570
+ let dotRadius = 9;
3571
+ let points = [];
3572
+ return {
3573
+ key: "interval-arcs",
3574
+ init(ctx, props) {
3575
+ topProp = props.top;
3576
+ heightProp = props.height;
3577
+ upColor = props.upColor;
3578
+ downColor = props.downColor;
3579
+ showLabels = props.showLabels ?? true;
3580
+ fontPx = props.fontPx ?? 30;
3581
+ dotRadius = props.dotRadius ?? 9;
3582
+ points = contourPoints(ctx.score?.notes ?? []);
3583
+ },
3584
+ draw(ctx, tMs) {
3585
+ if (points.length < 2) return;
3586
+ const c = ctx.ctx2d;
3587
+ const sb = ctx.safeBox;
3588
+ const top = topProp ?? sb.top + sb.h * 0.18;
3589
+ const height = heightProp ?? sb.h * 0.34;
3590
+ const range = pitchRange(points);
3591
+ const plot = {
3592
+ left: sb.left,
3593
+ right: sb.right,
3594
+ top,
3595
+ bottom: top + height,
3596
+ minPitch: range.min,
3597
+ maxPitch: range.max,
3598
+ durationMs: ctx.score?.durationMs ?? points[points.length - 1].tMs
3599
+ };
3600
+ const up = upColor ?? ctx.theme.accent;
3601
+ const down = downColor ?? ctx.theme.sepia;
3602
+ c.save();
3603
+ c.lineCap = "round";
3604
+ c.font = `700 ${fontPx}px ${ctx.theme.fontDisplay}`;
3605
+ c.textAlign = "center";
3606
+ c.textBaseline = "bottom";
3607
+ for (let i = 1; i < points.length; i++) {
3608
+ const b = points[i];
3609
+ if (b.tMs > tMs) break;
3610
+ const a = points[i - 1];
3611
+ const pa = projectPoint(plot, a.tMs, a.pitchMidi);
3612
+ const pb = projectPoint(plot, b.tMs, b.pitchMidi);
3613
+ const delta = b.pitchMidi - a.pitchMidi;
3614
+ const ascending = delta >= 0;
3615
+ const col = ascending ? up : down;
3616
+ const midX = (pa.x + pb.x) / 2;
3617
+ const bow = Math.min(70, Math.max(28, Math.abs(pb.x - pa.x) * 0.4));
3618
+ const ctrlY = Math.min(pa.y, pb.y) - bow;
3619
+ c.strokeStyle = col;
3620
+ c.lineWidth = 4;
3621
+ c.globalAlpha = 0.9;
3622
+ c.beginPath();
3623
+ c.moveTo(pa.x, pa.y);
3624
+ c.quadraticCurveTo(midX, ctrlY, pb.x, pb.y);
3625
+ c.stroke();
3626
+ if (showLabels && delta !== 0) {
3627
+ c.globalAlpha = 1;
3628
+ c.fillStyle = col;
3629
+ c.fillText(intervalLabel(delta), midX, ctrlY - 2);
3630
+ }
3631
+ }
3632
+ c.globalAlpha = 1;
3633
+ c.fillStyle = ctx.theme.ink;
3634
+ for (const p of points) {
3635
+ if (p.tMs > tMs) break;
3636
+ const pt = projectPoint(plot, p.tMs, p.pitchMidi);
3637
+ c.beginPath();
3638
+ c.arc(pt.x, pt.y, dotRadius, 0, Math.PI * 2);
3639
+ c.fill();
3640
+ }
3641
+ c.restore();
3642
+ }
3643
+ };
3644
+ }
3645
+ var intervalArcsFactory = {
3646
+ key: "interval-arcs",
3647
+ create: intervalArcsLayer,
3648
+ validateProps(props) {
3649
+ if (props == null || typeof props !== "object") return ["interval-arcs: props must be an object"];
3650
+ const p = props;
3651
+ const errs = [];
3652
+ if (p.top != null && typeof p.top !== "number") errs.push("interval-arcs.top must be a number");
3653
+ if (p.height != null && (typeof p.height !== "number" || p.height <= 0))
3654
+ errs.push("interval-arcs.height must be a positive number");
3655
+ if (p.upColor != null && typeof p.upColor !== "string") errs.push("interval-arcs.upColor must be a string");
3656
+ if (p.downColor != null && typeof p.downColor !== "string") errs.push("interval-arcs.downColor must be a string");
3657
+ if (p.showLabels != null && typeof p.showLabels !== "boolean") errs.push("interval-arcs.showLabels must be a boolean");
3658
+ if (p.fontPx != null && (typeof p.fontPx !== "number" || p.fontPx <= 0)) errs.push("interval-arcs.fontPx must be a positive number");
3659
+ if (p.dotRadius != null && (typeof p.dotRadius !== "number" || p.dotRadius <= 0)) errs.push("interval-arcs.dotRadius must be a positive number");
3660
+ return errs;
3661
+ }
3662
+ };
3663
+
3664
+ // src/scene/layers/kineticText.ts
3665
+ var DEFAULTS5 = { mode: "word-pop", startMs: 0, revealMs: 1400, fontPx: 88, centerFrac: 0.42, cursor: true };
3666
+ function clamp014(x) {
3667
+ return x < 0 ? 0 : x > 1 ? 1 : x;
3668
+ }
3669
+ function wrap2(c, words, maxW) {
3670
+ const lines = [];
3671
+ let cur = [];
3672
+ let curW = 0;
3673
+ const spaceW = c.measureText(" ").width;
3674
+ for (const w of words) {
3675
+ const ww = c.measureText(w).width;
3676
+ const add = cur.length ? spaceW + ww : ww;
3677
+ if (cur.length && curW + add > maxW) {
3678
+ lines.push(cur);
3679
+ cur = [];
3680
+ curW = 0;
3681
+ }
3682
+ cur.push(w);
3683
+ curW += cur.length === 1 ? ww : spaceW + ww;
3684
+ }
3685
+ if (cur.length) lines.push(cur);
3686
+ return lines;
3687
+ }
3688
+ function kineticTextLayer() {
3689
+ let text = "";
3690
+ let mode = DEFAULTS5.mode;
3691
+ let startMs = DEFAULTS5.startMs;
3692
+ let revealMs = DEFAULTS5.revealMs;
3693
+ let fontPx = DEFAULTS5.fontPx;
3694
+ let color;
3695
+ let centerFrac = DEFAULTS5.centerFrac;
3696
+ let cursor = DEFAULTS5.cursor;
3697
+ return {
3698
+ key: "kinetic-text",
3699
+ init(_ctx, props) {
3700
+ text = props.text ?? "";
3701
+ mode = props.mode ?? DEFAULTS5.mode;
3702
+ startMs = props.startMs ?? DEFAULTS5.startMs;
3703
+ revealMs = props.revealMs ?? DEFAULTS5.revealMs;
3704
+ fontPx = props.fontPx ?? DEFAULTS5.fontPx;
3705
+ color = props.color;
3706
+ centerFrac = props.centerFrac ?? DEFAULTS5.centerFrac;
3707
+ cursor = props.cursor ?? DEFAULTS5.cursor;
3708
+ },
3709
+ draw(ctx, tMs) {
3710
+ if (!text) return;
3711
+ const c = ctx.ctx2d;
3712
+ const sb = ctx.safeBox;
3713
+ const col = color ?? ctx.theme.ink;
3714
+ c.save();
3715
+ c.font = `700 ${fontPx}px ${ctx.theme.fontDisplay}`;
3716
+ c.textBaseline = "middle";
3717
+ const rawLines = text.split("\n");
3718
+ const lines = [];
3719
+ for (const rl of rawLines) lines.push(...wrap2(c, rl.split(/\s+/).filter(Boolean), sb.w));
3720
+ const lineH = fontPx * 1.25;
3721
+ const totalH = lines.length * lineH;
3722
+ const top = ctx.H * centerFrac - totalH / 2 + lineH / 2;
3723
+ const p = clamp014((tMs - startMs) / Math.max(1, revealMs));
3724
+ if (mode === "typewriter") {
3725
+ const full = lines.map((l) => l.join(" "));
3726
+ const totalChars = full.reduce((n, l) => n + l.length, 0);
3727
+ let shown = Math.floor(p * totalChars);
3728
+ c.textAlign = "center";
3729
+ c.fillStyle = col;
3730
+ let y = top;
3731
+ let lastDrawn = { x: 0, y: 0, end: false };
3732
+ for (const line of full) {
3733
+ const take = Math.max(0, Math.min(line.length, shown));
3734
+ const sub = line.slice(0, take);
3735
+ if (sub) c.fillText(sub, ctx.W / 2, y);
3736
+ shown -= line.length;
3737
+ if (take < line.length && take >= 0 && !lastDrawn.end) {
3738
+ lastDrawn = { x: ctx.W / 2 + c.measureText(sub).width / 2, y, end: true };
3739
+ }
3740
+ y += lineH;
3741
+ }
3742
+ if (cursor && p < 1 && Math.floor(tMs / 450) % 2 === 0) {
3743
+ c.fillRect(lastDrawn.x + 6, lastDrawn.y - fontPx * 0.42, 6, fontPx * 0.8);
3744
+ }
3745
+ } else {
3746
+ const allWords = lines.flat();
3747
+ const n = allWords.length || 1;
3748
+ const stagger = revealMs / n;
3749
+ const POP = 280;
3750
+ c.textAlign = "left";
3751
+ const spaceW = c.measureText(" ").width;
3752
+ let wi = 0;
3753
+ let y = top;
3754
+ for (const line of lines) {
3755
+ const lineW = line.reduce((w, word, i) => w + c.measureText(word).width + (i ? spaceW : 0), 0);
3756
+ let x = sb.left + (sb.w - lineW) / 2;
3757
+ for (const word of line) {
3758
+ const ww = c.measureText(word).width;
3759
+ const wp = clamp014((tMs - startMs - wi * stagger) / POP);
3760
+ if (wp > 0) {
3761
+ const scale = 0.7 + 0.3 * wp;
3762
+ c.save();
3763
+ c.globalAlpha = wp;
3764
+ c.translate(x + ww / 2, y);
3765
+ c.scale(scale, scale);
3766
+ c.fillStyle = col;
3767
+ c.fillText(word, -ww / 2, 0);
3768
+ c.restore();
3769
+ }
3770
+ x += ww + spaceW;
3771
+ wi++;
3772
+ }
3773
+ y += lineH;
3774
+ }
3775
+ }
3776
+ c.restore();
3777
+ }
3778
+ };
3779
+ }
3780
+ var kineticTextFactory = {
3781
+ key: "kinetic-text",
3782
+ create: kineticTextLayer,
3783
+ validateProps(props) {
3784
+ if (props == null || typeof props !== "object") return ["kinetic-text: props must be an object"];
3785
+ const p = props;
3786
+ const errs = [];
3787
+ if (typeof p.text !== "string" || !p.text) errs.push("kinetic-text.text must be a non-empty string");
3788
+ if (p.mode != null && p.mode !== "typewriter" && p.mode !== "word-pop") errs.push('kinetic-text.mode must be "typewriter" | "word-pop"');
3789
+ if (p.startMs != null && typeof p.startMs !== "number") errs.push("kinetic-text.startMs must be a number");
3790
+ if (p.revealMs != null && (typeof p.revealMs !== "number" || p.revealMs <= 0)) errs.push("kinetic-text.revealMs must be a positive number");
3791
+ if (p.fontPx != null && (typeof p.fontPx !== "number" || p.fontPx <= 0)) errs.push("kinetic-text.fontPx must be a positive number");
3792
+ if (p.color != null && typeof p.color !== "string") errs.push("kinetic-text.color must be a string");
3793
+ if (p.centerFrac != null && (typeof p.centerFrac !== "number" || p.centerFrac < 0 || p.centerFrac > 1)) errs.push("kinetic-text.centerFrac must be in [0,1]");
3794
+ if (p.cursor != null && typeof p.cursor !== "boolean") errs.push("kinetic-text.cursor must be a boolean");
3795
+ return errs;
3796
+ }
3797
+ };
3798
+
3799
+ // src/scene/layers/countdown.ts
3800
+ var DEFAULTS6 = { from: 3, startMs: 0, durationMs: 3e3, fontPx: 280, centerFrac: 0.46, ring: true };
3801
+ function rgba6(hex, a) {
3802
+ const h = hex.replace("#", "");
3803
+ const n = h.length === 3 ? parseInt(h.split("").map((c) => c + c).join(""), 16) : parseInt(h, 16);
3804
+ return `rgba(${n >> 16 & 255},${n >> 8 & 255},${n & 255},${a})`;
3805
+ }
3806
+ function countdownLayer() {
3807
+ let from = DEFAULTS6.from;
3808
+ let startMs = DEFAULTS6.startMs;
3809
+ let durationMs = DEFAULTS6.durationMs;
3810
+ let color;
3811
+ let fontPx = DEFAULTS6.fontPx;
3812
+ let centerFrac = DEFAULTS6.centerFrac;
3813
+ let ring = DEFAULTS6.ring;
3814
+ return {
3815
+ key: "countdown",
3816
+ init(_ctx, props) {
3817
+ from = props.from ?? DEFAULTS6.from;
3818
+ startMs = props.startMs ?? DEFAULTS6.startMs;
3819
+ durationMs = props.durationMs ?? DEFAULTS6.durationMs;
3820
+ color = props.color;
3821
+ fontPx = props.fontPx ?? DEFAULTS6.fontPx;
3822
+ centerFrac = props.centerFrac ?? DEFAULTS6.centerFrac;
3823
+ ring = props.ring ?? DEFAULTS6.ring;
3824
+ },
3825
+ draw(ctx, tMs) {
3826
+ const elapsed = tMs - startMs;
3827
+ if (elapsed < 0 || elapsed >= durationMs) return;
3828
+ const perTick = durationMs / from;
3829
+ const idx = Math.floor(elapsed / perTick);
3830
+ const n = from - idx;
3831
+ if (n < 1 || n > from) return;
3832
+ const tickElapsed = elapsed - idx * perTick;
3833
+ const tickFrac = tickElapsed / perTick;
3834
+ const c = ctx.ctx2d;
3835
+ const cx = ctx.W / 2;
3836
+ const cy = ctx.H * centerFrac;
3837
+ const col = color ?? ctx.theme.accent;
3838
+ const pop = 1 + 0.28 * Math.exp(-tickElapsed / 120);
3839
+ const fade = 1 - Math.max(0, (tickFrac - 0.7) / 0.3) * 0.85;
3840
+ c.save();
3841
+ if (ring) {
3842
+ const r = fontPx * 0.62;
3843
+ c.lineWidth = 10;
3844
+ c.lineCap = "round";
3845
+ c.strokeStyle = rgba6(col, 0.18 * fade);
3846
+ c.beginPath();
3847
+ c.arc(cx, cy, r, 0, Math.PI * 2);
3848
+ c.stroke();
3849
+ c.strokeStyle = rgba6(col, 0.9 * fade);
3850
+ c.beginPath();
3851
+ c.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + (1 - tickFrac) * Math.PI * 2);
3852
+ c.stroke();
3853
+ }
3854
+ c.globalAlpha = fade;
3855
+ c.fillStyle = col;
3856
+ c.font = `800 ${Math.round(fontPx * pop)}px ${ctx.theme.fontDisplay}`;
3857
+ c.textAlign = "center";
3858
+ c.textBaseline = "middle";
3859
+ c.fillText(String(n), cx, cy + 2);
3860
+ c.restore();
3861
+ }
3862
+ };
3863
+ }
3864
+ var countdownFactory = {
3865
+ key: "countdown",
3866
+ create: countdownLayer,
3867
+ validateProps(props) {
3868
+ if (props == null || typeof props !== "object") return ["countdown: props must be an object"];
3869
+ const p = props;
3870
+ const errs = [];
3871
+ if (p.from != null && (typeof p.from !== "number" || p.from < 1 || !Number.isInteger(p.from))) errs.push("countdown.from must be an integer >= 1");
3872
+ if (p.startMs != null && typeof p.startMs !== "number") errs.push("countdown.startMs must be a number");
3873
+ if (p.durationMs != null && (typeof p.durationMs !== "number" || p.durationMs <= 0)) errs.push("countdown.durationMs must be a positive number");
3874
+ if (p.color != null && typeof p.color !== "string") errs.push("countdown.color must be a string");
3875
+ if (p.fontPx != null && (typeof p.fontPx !== "number" || p.fontPx <= 0)) errs.push("countdown.fontPx must be a positive number");
3876
+ if (p.centerFrac != null && (typeof p.centerFrac !== "number" || p.centerFrac < 0 || p.centerFrac > 1)) errs.push("countdown.centerFrac must be in [0,1]");
3877
+ if (p.ring != null && typeof p.ring !== "boolean") errs.push("countdown.ring must be a boolean");
3878
+ return errs;
3879
+ }
3880
+ };
3881
+
3882
+ // src/scene/layers/waveform.ts
3883
+ var SAMPLES = 128;
3884
+ function clampUnit(x) {
3885
+ return x < -1 ? -1 : x > 1 ? 1 : x;
3886
+ }
3887
+ function syntheticSample(tSec, u) {
3888
+ const phase = u * Math.PI * 2;
3889
+ return 0.55 * Math.sin(phase * 3 + tSec * 6) + 0.28 * Math.sin(phase * 5 + tSec * 9 + 1.1) + 0.14 * Math.sin(phase * 8 + tSec * 4 + 2.3);
3890
+ }
3891
+ function waveformLayer() {
3892
+ let n = SAMPLES;
3893
+ let centerFrac = 0.46;
3894
+ let amplitudeFrac = 0.1;
3895
+ let lineWidth = 5;
3896
+ let color;
3897
+ let samplesFn;
3898
+ function samplesAt(ctx, tMs) {
3899
+ const fromProp = samplesFn?.(tMs, n);
3900
+ if (fromProp && fromProp.length) {
3901
+ return Array.from({ length: n }, (_, i) => clampUnit(fromProp[Math.min(fromProp.length - 1, i)] ?? 0));
3902
+ }
3903
+ const bt = ctx.waveform?.byteTime?.(tMs);
3904
+ if (bt && bt.length) {
3905
+ return Array.from({ length: n }, (_, i) => clampUnit((bt[Math.floor(i / n * bt.length)] - 128) / 128));
3906
+ }
3907
+ const tSec = tMs / 1e3;
3908
+ return Array.from({ length: n }, (_, i) => clampUnit(syntheticSample(tSec, i / (n - 1))));
3909
+ }
3910
+ return {
3911
+ key: "waveform",
3912
+ init(_ctx, props) {
3913
+ n = props.samples ?? SAMPLES;
3914
+ centerFrac = props.centerFrac ?? 0.46;
3915
+ amplitudeFrac = props.amplitudeFrac ?? 0.1;
3916
+ lineWidth = props.lineWidth ?? 5;
3917
+ color = props.color;
3918
+ samplesFn = props.samplesFn;
3919
+ },
3920
+ draw(ctx, tMs) {
3921
+ const c = ctx.ctx2d;
3922
+ const sb = ctx.safeBox;
3923
+ const left = sb.left, right = sb.right;
3924
+ const cy = ctx.H * centerFrac;
3925
+ const amp = ctx.H * amplitudeFrac;
3926
+ const s = samplesAt(ctx, tMs);
3927
+ c.save();
3928
+ c.lineJoin = "round";
3929
+ c.lineCap = "round";
3930
+ c.strokeStyle = color ?? ctx.theme.accent;
3931
+ c.lineWidth = lineWidth;
3932
+ c.beginPath();
3933
+ for (let i = 0; i < n; i++) {
3934
+ const x = left + (right - left) * (i / (n - 1));
3935
+ const y = cy + s[i] * amp;
3936
+ if (i === 0) c.moveTo(x, y);
3937
+ else c.lineTo(x, y);
3938
+ }
3939
+ c.stroke();
3940
+ c.restore();
3941
+ }
3942
+ };
3943
+ }
3944
+ var waveformFactory = {
3945
+ key: "waveform",
3946
+ create: waveformLayer,
3947
+ validateProps(props) {
3948
+ if (props == null || typeof props !== "object") return ["waveform: props must be an object"];
3949
+ const p = props;
3950
+ const errs = [];
3951
+ if (p.samples != null && (typeof p.samples !== "number" || p.samples < 8)) errs.push("waveform.samples must be a number >= 8");
3952
+ if (p.centerFrac != null && (typeof p.centerFrac !== "number" || p.centerFrac < 0 || p.centerFrac > 1)) errs.push("waveform.centerFrac must be in [0,1]");
3953
+ if (p.amplitudeFrac != null && (typeof p.amplitudeFrac !== "number" || p.amplitudeFrac <= 0)) errs.push("waveform.amplitudeFrac must be a positive number");
3954
+ if (p.lineWidth != null && (typeof p.lineWidth !== "number" || p.lineWidth <= 0)) errs.push("waveform.lineWidth must be a positive number");
3955
+ if (p.color != null && typeof p.color !== "string") errs.push("waveform.color must be a string");
3956
+ if (p.samplesFn != null && typeof p.samplesFn !== "function") errs.push("waveform.samplesFn must be a function");
3957
+ return errs;
3958
+ }
3959
+ };
3960
+
3961
+ // src/scene/layers/scaleHighlight.ts
3962
+ var SCALE_INTERVALS = {
3963
+ major: [0, 2, 4, 5, 7, 9, 11],
3964
+ ionian: [0, 2, 4, 5, 7, 9, 11],
3965
+ minor: [0, 2, 3, 5, 7, 8, 10],
3966
+ aeolian: [0, 2, 3, 5, 7, 8, 10],
3967
+ dorian: [0, 2, 3, 5, 7, 9, 10],
3968
+ phrygian: [0, 1, 3, 5, 7, 8, 10],
3969
+ lydian: [0, 2, 4, 6, 7, 9, 11],
3970
+ mixolydian: [0, 2, 4, 5, 7, 9, 10],
3971
+ locrian: [0, 1, 3, 5, 6, 8, 10],
3972
+ harmonicMinor: [0, 2, 3, 5, 7, 8, 11],
3973
+ melodicMinor: [0, 2, 3, 5, 7, 9, 11],
3974
+ pentatonicMajor: [0, 2, 4, 7, 9],
3975
+ pentatonicMinor: [0, 3, 5, 7, 10],
3976
+ blues: [0, 3, 5, 6, 7, 10],
3977
+ chromatic: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
3978
+ };
3979
+ var PRETTY = {
3980
+ major: "Major",
3981
+ ionian: "Ionian",
3982
+ minor: "Minor",
3983
+ aeolian: "Aeolian",
3984
+ dorian: "Dorian",
3985
+ phrygian: "Phrygian",
3986
+ lydian: "Lydian",
3987
+ mixolydian: "Mixolydian",
3988
+ locrian: "Locrian",
3989
+ harmonicMinor: "Harmonic Minor",
3990
+ melodicMinor: "Melodic Minor",
3991
+ pentatonicMajor: "Pentatonic",
3992
+ pentatonicMinor: "Minor Pentatonic",
3993
+ blues: "Blues",
3994
+ chromatic: "Chromatic"
3995
+ };
3996
+ function rootPc(root) {
3997
+ if (typeof root === "number") return (root % 12 + 12) % 12;
3998
+ return noteNameToIndex(root);
3999
+ }
4000
+ function scaleHighlightLayer() {
4001
+ let rootProp = "C";
4002
+ let scaleKey = "major";
4003
+ let scaleColor;
4004
+ let tonicColor;
4005
+ let alpha = 0.55;
4006
+ let label = true;
4007
+ let labelTextProp;
4008
+ return {
4009
+ key: "scale-highlight",
4010
+ init(_ctx, props) {
4011
+ rootProp = props.root ?? "C";
4012
+ scaleKey = props.scale ?? "major";
4013
+ scaleColor = props.scaleColor;
4014
+ tonicColor = props.tonicColor;
4015
+ alpha = props.alpha ?? 0.55;
4016
+ label = props.label ?? true;
4017
+ labelTextProp = props.labelText;
4018
+ },
4019
+ draw(ctx, _tMs) {
4020
+ const c = ctx.ctx2d;
4021
+ const intervals = SCALE_INTERVALS[scaleKey] ?? SCALE_INTERVALS.major;
4022
+ const root = rootPc(rootProp);
4023
+ const inScale = new Set(intervals.map((iv) => (root + iv) % 12));
4024
+ const sc = scaleColor ?? ctx.theme.accent;
4025
+ const tc = tonicColor ?? ctx.theme.gold;
4026
+ const layout = getKeyboardLayout(ctx);
4027
+ if (layout) {
4028
+ c.save();
4029
+ c.globalAlpha = alpha;
4030
+ for (let m = layout.lowMidi; m <= layout.highMidi; m++) {
4031
+ if (!inRange(layout, m)) continue;
4032
+ const pc = pitchClass(m);
4033
+ if (!inScale.has(pc)) continue;
4034
+ const r = keyRect(layout, m);
4035
+ c.fillStyle = pc === root ? tc : sc;
4036
+ c.fillRect(r.x, r.y, r.w, r.h);
4037
+ }
4038
+ c.restore();
4039
+ }
4040
+ if (label) {
4041
+ const sb = ctx.safeBox;
4042
+ const name = typeof rootProp === "string" ? rootProp : ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"][root];
4043
+ const text = labelTextProp ?? `${name} ${PRETTY[scaleKey] ?? scaleKey}`;
4044
+ c.save();
4045
+ c.fillStyle = ctx.theme.ink;
4046
+ c.font = `700 ${Math.round(ctx.H * 0.03)}px ${ctx.theme.fontDisplay}`;
4047
+ c.textAlign = "center";
4048
+ c.textBaseline = "top";
4049
+ c.fillText(text, ctx.W / 2, sb.top + sb.h * 0.04);
4050
+ c.restore();
4051
+ }
4052
+ }
4053
+ };
4054
+ }
4055
+ var scaleHighlightFactory = {
4056
+ key: "scale-highlight",
4057
+ create: scaleHighlightLayer,
4058
+ validateProps(props) {
4059
+ if (props == null || typeof props !== "object") return ["scale-highlight: props must be an object"];
4060
+ const p = props;
4061
+ const errs = [];
4062
+ if (p.root != null && typeof p.root !== "string" && typeof p.root !== "number") errs.push("scale-highlight.root must be a note name or pitch-class number");
4063
+ if (p.scale != null && (typeof p.scale !== "string" || !(p.scale in SCALE_INTERVALS))) errs.push(`scale-highlight.scale must be one of: ${Object.keys(SCALE_INTERVALS).join(", ")}`);
4064
+ if (p.scaleColor != null && typeof p.scaleColor !== "string") errs.push("scale-highlight.scaleColor must be a string");
4065
+ if (p.tonicColor != null && typeof p.tonicColor !== "string") errs.push("scale-highlight.tonicColor must be a string");
4066
+ if (p.alpha != null && (typeof p.alpha !== "number" || p.alpha < 0 || p.alpha > 1)) errs.push("scale-highlight.alpha must be in [0,1]");
4067
+ if (p.label != null && typeof p.label !== "boolean") errs.push("scale-highlight.label must be a boolean");
4068
+ if (p.labelText != null && typeof p.labelText !== "string") errs.push("scale-highlight.labelText must be a string");
4069
+ return errs;
4070
+ }
4071
+ };
4072
+
3006
4073
  // src/scene/registry.ts
3007
4074
  var REGISTRY = /* @__PURE__ */ new Map();
3008
4075
  function registerLayer(factory) {
@@ -3035,6 +4102,16 @@ registerLayer(mcqCardFactory);
3035
4102
  registerLayer(circleOfFifthsFactory);
3036
4103
  registerLayer(pitchContourFactory);
3037
4104
  registerLayer(sectionMinimapFactory);
4105
+ registerLayer(beatPulseFactory);
4106
+ registerLayer(chordRibbonFactory);
4107
+ registerLayer(progressRingFactory);
4108
+ registerLayer(radialSpectrumFactory);
4109
+ registerLayer(karaokeCaptionFactory);
4110
+ registerLayer(intervalArcsFactory);
4111
+ registerLayer(kineticTextFactory);
4112
+ registerLayer(countdownFactory);
4113
+ registerLayer(waveformFactory);
4114
+ registerLayer(scaleHighlightFactory);
3038
4115
 
3039
4116
  // src/scene/audioLayers.ts
3040
4117
  function countInSchedule(opts) {
@@ -3187,7 +4264,13 @@ var SCREEN_PINNED_KEYS = /* @__PURE__ */ new Set([
3187
4264
  "reveal",
3188
4265
  "portrait",
3189
4266
  "safe-guides",
3190
- "mcq-card"
4267
+ "mcq-card",
4268
+ "beat-pulse",
4269
+ "chord-ribbon",
4270
+ "progress-ring",
4271
+ "karaoke-caption",
4272
+ "kinetic-text",
4273
+ "countdown"
3191
4274
  ]);
3192
4275
  function defaultBufferFactory() {
3193
4276
  const g = globalThis;
@@ -3774,6 +4857,7 @@ export {
3774
4857
  FOLLOW_PAD,
3775
4858
  PIANO_HIGH,
3776
4859
  PIANO_LOW,
4860
+ SCALE_INTERVALS,
3777
4861
  activeChord,
3778
4862
  activeCue,
3779
4863
  activeSection,
@@ -3786,12 +4870,14 @@ export {
3786
4870
  ballX,
3787
4871
  beatGrid,
3788
4872
  beatPhase,
4873
+ beatPulseFactory,
3789
4874
  blackKeys,
3790
4875
  bpmOf,
3791
4876
  brandingFactory,
3792
4877
  buildScene,
3793
4878
  cameraForFollow,
3794
4879
  cameraTransform,
4880
+ chordRibbonFactory,
3795
4881
  circleOfFifthsDemoSpec,
3796
4882
  circleOfFifthsFactory,
3797
4883
  clamp,
@@ -3801,6 +4887,7 @@ export {
3801
4887
  contourPolyline,
3802
4888
  countInLeadSec,
3803
4889
  countInSchedule,
4890
+ countdownFactory,
3804
4891
  countdownRemaining,
3805
4892
  countdownSeconds,
3806
4893
  countingDegreeDemoSpec,
@@ -3840,8 +4927,10 @@ export {
3840
4927
  hookFactory,
3841
4928
  identityCamera,
3842
4929
  inRange,
4930
+ intervalArcsFactory,
3843
4931
  invLerp,
3844
4932
  isBlackKey,
4933
+ karaokeCaptionFactory,
3845
4934
  kenBurns,
3846
4935
  keyCenterX,
3847
4936
  keyColumnWidth,
@@ -3849,6 +4938,7 @@ export {
3849
4938
  keySlot,
3850
4939
  keyboardFactory,
3851
4940
  keyboardLayout,
4941
+ kineticTextFactory,
3852
4942
  lerp,
3853
4943
  lerpBox,
3854
4944
  lerpCamera,
@@ -3876,9 +4966,12 @@ export {
3876
4966
  playheadLine,
3877
4967
  portraitFactory,
3878
4968
  progress01,
4969
+ progressRingFactory,
3879
4970
  projectPoint,
3880
4971
  promoCardsDemoSpec,
4972
+ pulseAt,
3881
4973
  quizPhase,
4974
+ radialSpectrumFactory,
3882
4975
  rayEndpoints,
3883
4976
  rayPointAt,
3884
4977
  recordSceneSpec,
@@ -3891,6 +4984,7 @@ export {
3891
4984
  revealProgress,
3892
4985
  runGate,
3893
4986
  safeGuidesFactory,
4987
+ scaleHighlightFactory,
3894
4988
  scoreFromMusicXML,
3895
4989
  scorePitchSpan,
3896
4990
  scrollCursorFactory,
@@ -3913,6 +5007,7 @@ export {
3913
5007
  visualTimelineMs,
3914
5008
  vstackAudioPlayheadLine,
3915
5009
  vstackFollowBox,
5010
+ waveformFactory,
3916
5011
  whiteKeys,
3917
5012
  worldToViewport
3918
5013
  };