@real-music-packages/web-core 0.21.0 → 0.23.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,6 @@
1
+ import {
2
+ intervalBySemitones
3
+ } from "../chunk-N56UTMWA.js";
1
4
  import {
2
5
  ctaScene,
3
6
  drawSafeGuides,
@@ -818,25 +821,18 @@ var notationFactory = {
818
821
  };
819
822
 
820
823
  // src/scene/layers/scrollCursor.ts
821
- function followLayoutFor(ctx, camProgress01, scrollMode) {
824
+ function followLayoutFor(ctx, progress012, scrollMode) {
822
825
  const eng = getNotationEngraving(ctx);
823
826
  if (!eng) return null;
824
827
  const nBars = measureCount(eng.rendered);
825
828
  if (nBars <= 0) return eng.base;
826
- const focusBox = scrollMode === "vstack" ? vstackFollowBox(eng.rendered, camProgress01) : followBoxAt(eng.rendered, followWindowStart(eng.rendered, camProgress01));
829
+ const focusBox = scrollMode === "vstack" ? vstackFollowBox(eng.rendered, progress012) : followBoxAt(eng.rendered, followWindowStart(eng.rendered, progress012));
827
830
  return notationLayout(eng.rendered, ctx.W, ctx.H, eng.bandTop, eng.bandHeight, { focusBox });
828
831
  }
829
832
  function scrollCursorLayer() {
830
- let mode = "audio";
831
833
  let scrollMode = "hstack";
832
- let musicMs = 0;
833
834
  let openingZoomMs = 900;
834
835
  let color;
835
- function camProgress(tMs) {
836
- const phMs = tMs - openingZoomMs;
837
- if (phMs < 0 || musicMs <= 0) return 0;
838
- return Math.min(1, phMs / musicMs);
839
- }
840
836
  function onsetsFor(ctx) {
841
837
  const notes = ctx.score?.notes;
842
838
  return notes && notes.length ? distinctOnsets(notes) : [];
@@ -859,9 +855,7 @@ function scrollCursorLayer() {
859
855
  return (lo + segFrac) / (n - 1);
860
856
  }
861
857
  function followProgress(ctx, tMs) {
862
- if (mode === "linear") return camProgress(tMs);
863
- const onsets = onsetsFor(ctx);
864
- return onsets.length ? notesProgress(onsets, tMs) : camProgress(tMs);
858
+ return notesProgress(onsetsFor(ctx), tMs);
865
859
  }
866
860
  function layoutAt(ctx, tMs) {
867
861
  const eng = getNotationEngraving(ctx);
@@ -870,9 +864,7 @@ function scrollCursorLayer() {
870
864
  return {
871
865
  key: "scroll-cursor",
872
866
  init(ctx, props) {
873
- mode = props.mode ?? "audio";
874
867
  scrollMode = props.scrollMode ?? "hstack";
875
- musicMs = props.musicMs ?? 0;
876
868
  openingZoomMs = props.openingZoomMs ?? 900;
877
869
  color = props.color;
878
870
  setFollowLayoutProvider(ctx, layoutAt);
@@ -881,16 +873,12 @@ function scrollCursorLayer() {
881
873
  const eng = getNotationEngraving(ctx);
882
874
  if (!eng) return;
883
875
  const layout = layoutAt(ctx, tMs);
876
+ const onsets = onsetsFor(ctx);
884
877
  let line;
885
- const onsets = mode === "audio" ? onsetsFor(ctx) : [];
886
- if (mode === "audio" && onsets.length) {
887
- if (scrollMode === "vstack") {
888
- line = vstackAudioPlayheadLine(layout, onsets, tMs, measureCount(eng.rendered));
889
- } else {
890
- line = audioPlayheadLine(layout, onsets, tMs);
891
- }
878
+ if (scrollMode === "vstack") {
879
+ line = vstackAudioPlayheadLine(layout, onsets, tMs, measureCount(eng.rendered));
892
880
  } else {
893
- line = playheadLine(layout, camProgress(tMs));
881
+ line = audioPlayheadLine(layout, onsets, tMs);
894
882
  }
895
883
  if (!line) return;
896
884
  const c = ctx.ctx2d;
@@ -917,8 +905,6 @@ var scrollCursorFactory = {
917
905
  errs.push('scroll-cursor.mode must be "audio" | "linear"');
918
906
  if (p.scrollMode != null && p.scrollMode !== "hstack" && p.scrollMode !== "vstack")
919
907
  errs.push('scroll-cursor.scrollMode must be "hstack" | "vstack"');
920
- if (p.mode === "linear" && (typeof p.musicMs !== "number" || !(p.musicMs > 0)))
921
- errs.push('scroll-cursor.musicMs must be a positive number for mode:"linear"');
922
908
  if (p.musicMs != null && (typeof p.musicMs !== "number" || !(p.musicMs > 0)))
923
909
  errs.push("scroll-cursor.musicMs must be a positive number");
924
910
  if (p.followBars != null && (typeof p.followBars !== "number" || p.followBars < 1))
@@ -3020,6 +3006,657 @@ var sectionMinimapFactory = {
3020
3006
  }
3021
3007
  };
3022
3008
 
3009
+ // src/scene/layers/beatPulse.ts
3010
+ var DEFAULTS = {
3011
+ styles: ["bloom"],
3012
+ intensity: 0.42,
3013
+ attackMs: 0,
3014
+ decayMs: 170,
3015
+ blend: "source-over"
3016
+ };
3017
+ function envAt(tMs, onsetMs, attackMs, decayMs) {
3018
+ const dt = tMs - onsetMs;
3019
+ if (dt < 0) return 0;
3020
+ if (dt < attackMs) return attackMs <= 0 ? 1 : dt / attackMs;
3021
+ return Math.exp(-(dt - attackMs) / decayMs);
3022
+ }
3023
+ function pulseAt(onsetsMs, tMs, attackMs = DEFAULTS.attackMs, decayMs = DEFAULTS.decayMs) {
3024
+ let p = 0;
3025
+ for (let i = onsetsMs.length - 1; i >= 0; i--) {
3026
+ const on = onsetsMs[i];
3027
+ if (on > tMs) continue;
3028
+ const e = envAt(tMs, on, attackMs, decayMs);
3029
+ if (e > p) p = e;
3030
+ if (tMs - on > attackMs + decayMs * 6) break;
3031
+ }
3032
+ return p;
3033
+ }
3034
+ function rgba2(hex, a) {
3035
+ const h = hex.replace("#", "");
3036
+ const n = h.length === 3 ? parseInt(h.split("").map((c) => c + c).join(""), 16) : parseInt(h, 16);
3037
+ const r = n >> 16 & 255, g = n >> 8 & 255, b = n & 255;
3038
+ return `rgba(${r},${g},${b},${a})`;
3039
+ }
3040
+ function beatPulseLayer() {
3041
+ let onsets = [];
3042
+ let styles = DEFAULTS.styles;
3043
+ let intensity = DEFAULTS.intensity;
3044
+ let attackMs = DEFAULTS.attackMs;
3045
+ let decayMs = DEFAULTS.decayMs;
3046
+ let color;
3047
+ let blend = DEFAULTS.blend;
3048
+ return {
3049
+ key: "beat-pulse",
3050
+ init(ctx, props) {
3051
+ onsets = props.onsetsMs ?? (ctx.score ? distinctOnsets(ctx.score.notes) : []);
3052
+ onsets = [...onsets].sort((a, b) => a - b);
3053
+ styles = props.styles ?? DEFAULTS.styles;
3054
+ intensity = props.intensity ?? DEFAULTS.intensity;
3055
+ attackMs = props.attackMs ?? DEFAULTS.attackMs;
3056
+ decayMs = props.decayMs ?? DEFAULTS.decayMs;
3057
+ color = props.color;
3058
+ blend = props.blend ?? DEFAULTS.blend;
3059
+ },
3060
+ draw(ctx, tMs) {
3061
+ if (!onsets.length) return;
3062
+ const p = pulseAt(onsets, tMs, attackMs, decayMs);
3063
+ if (p <= 4e-3) return;
3064
+ const c = ctx.ctx2d;
3065
+ const W = ctx.W, H = ctx.H;
3066
+ const col = color ?? ctx.theme.accent;
3067
+ const a = intensity * p;
3068
+ c.save();
3069
+ c.globalCompositeOperation = blend;
3070
+ if (styles.includes("bloom")) {
3071
+ const cx = W / 2, cy = H * 0.46;
3072
+ const rad = Math.max(W, H) * (0.3 + 0.1 * p);
3073
+ const g = c.createRadialGradient(cx, cy, 0, cx, cy, rad);
3074
+ g.addColorStop(0, rgba2(col, a * 0.55));
3075
+ g.addColorStop(0.55, rgba2(col, a * 0.18));
3076
+ g.addColorStop(1, rgba2(col, 0));
3077
+ c.fillStyle = g;
3078
+ c.fillRect(0, 0, W, H);
3079
+ }
3080
+ if (styles.includes("vignette")) {
3081
+ const cx = W / 2, cy = H / 2;
3082
+ const inner = Math.min(W, H) * 0.42;
3083
+ const outer = Math.max(W, H) * 0.72;
3084
+ const g = c.createRadialGradient(cx, cy, inner, cx, cy, outer);
3085
+ g.addColorStop(0, rgba2(col, 0));
3086
+ g.addColorStop(1, rgba2(col, a * 0.6));
3087
+ c.fillStyle = g;
3088
+ c.fillRect(0, 0, W, H);
3089
+ }
3090
+ c.restore();
3091
+ }
3092
+ };
3093
+ }
3094
+ var beatPulseFactory = {
3095
+ key: "beat-pulse",
3096
+ create: beatPulseLayer,
3097
+ validateProps(props) {
3098
+ if (props == null || typeof props !== "object") return ["beat-pulse: props must be an object"];
3099
+ const p = props;
3100
+ const errs = [];
3101
+ if (p.onsetsMs != null && (!Array.isArray(p.onsetsMs) || p.onsetsMs.some((x) => typeof x !== "number")))
3102
+ errs.push("beat-pulse.onsetsMs must be a number[]");
3103
+ if (p.styles != null) {
3104
+ if (!Array.isArray(p.styles)) errs.push("beat-pulse.styles must be an array");
3105
+ else if (p.styles.some((s) => s !== "bloom" && s !== "vignette"))
3106
+ errs.push('beat-pulse.styles items must be "bloom" | "vignette"');
3107
+ }
3108
+ if (p.intensity != null && (typeof p.intensity !== "number" || p.intensity < 0 || p.intensity > 1))
3109
+ errs.push("beat-pulse.intensity must be in [0,1]");
3110
+ if (p.attackMs != null && (typeof p.attackMs !== "number" || p.attackMs < 0))
3111
+ errs.push("beat-pulse.attackMs must be a number >= 0");
3112
+ if (p.decayMs != null && (typeof p.decayMs !== "number" || p.decayMs <= 0))
3113
+ errs.push("beat-pulse.decayMs must be a positive number");
3114
+ if (p.color != null && typeof p.color !== "string") errs.push("beat-pulse.color must be a string");
3115
+ if (p.blend != null && p.blend !== "source-over" && p.blend !== "lighter")
3116
+ errs.push('beat-pulse.blend must be "source-over" | "lighter"');
3117
+ return errs;
3118
+ }
3119
+ };
3120
+
3121
+ // src/scene/layers/chordRibbon.ts
3122
+ var DEFAULTS2 = { pxPerMs: 0.06, playheadFrac: 0.32, height: 84, showFunction: true };
3123
+ function rgba3(hex, a) {
3124
+ const h = hex.replace("#", "");
3125
+ const n = h.length === 3 ? parseInt(h.split("").map((c) => c + c).join(""), 16) : parseInt(h, 16);
3126
+ return `rgba(${n >> 16 & 255},${n >> 8 & 255},${n & 255},${a})`;
3127
+ }
3128
+ function roundRectPath(c, x, y, w, h, r) {
3129
+ if (typeof c.roundRect === "function") {
3130
+ c.beginPath();
3131
+ c.roundRect(x, y, w, h, r);
3132
+ return;
3133
+ }
3134
+ const rr = Math.min(r, w / 2, h / 2);
3135
+ c.beginPath();
3136
+ c.moveTo(x + rr, y);
3137
+ c.arcTo(x + w, y, x + w, y + h, rr);
3138
+ c.arcTo(x + w, y + h, x, y + h, rr);
3139
+ c.arcTo(x, y + h, x, y, rr);
3140
+ c.arcTo(x, y, x + w, y, rr);
3141
+ c.closePath();
3142
+ }
3143
+ function chordRibbonLayer() {
3144
+ let track = [];
3145
+ let pxPerMs = DEFAULTS2.pxPerMs;
3146
+ let playheadFrac = DEFAULTS2.playheadFrac;
3147
+ let yProp;
3148
+ let height = DEFAULTS2.height;
3149
+ let showFunction = DEFAULTS2.showFunction;
3150
+ let colors = DEFAULT_FUNCTION_COLORS;
3151
+ return {
3152
+ key: "chord-ribbon",
3153
+ init(_ctx, props) {
3154
+ track = (props.chordTrack ?? []).slice().sort((a, b) => a.startMs - b.startMs);
3155
+ pxPerMs = props.pxPerMs ?? DEFAULTS2.pxPerMs;
3156
+ playheadFrac = props.playheadFrac ?? DEFAULTS2.playheadFrac;
3157
+ yProp = props.y;
3158
+ height = props.height ?? DEFAULTS2.height;
3159
+ showFunction = props.showFunction ?? DEFAULTS2.showFunction;
3160
+ colors = props.colors ?? DEFAULT_FUNCTION_COLORS;
3161
+ },
3162
+ draw(ctx, tMs) {
3163
+ if (!track.length) return;
3164
+ const c = ctx.ctx2d;
3165
+ const sb = ctx.safeBox;
3166
+ const left = sb.left;
3167
+ const right = sb.right;
3168
+ const span = right - left;
3169
+ const playX = left + span * playheadFrac;
3170
+ const y = yProp ?? sb.bottom - 260;
3171
+ const h = height;
3172
+ const pad = 8;
3173
+ c.save();
3174
+ c.beginPath();
3175
+ c.rect(left, y - pad, span, h + pad * 2);
3176
+ c.clip();
3177
+ for (const s of track) {
3178
+ const x0 = playX + (s.startMs - tMs) * pxPerMs;
3179
+ const w = Math.max(36, (s.endMs - s.startMs) * pxPerMs - 6);
3180
+ if (x0 + w < left || x0 > right) continue;
3181
+ const active = tMs >= s.startMs && tMs < s.endMs;
3182
+ const played = tMs >= s.endMs;
3183
+ const fnCol = showFunction ? functionColor(s.fn, colors) : ctx.theme.accent;
3184
+ const chipAlpha = active ? 0.95 : played ? 0.28 : 0.55;
3185
+ roundRectPath(c, x0, y, w, h, 14);
3186
+ c.fillStyle = rgba3(fnCol, chipAlpha);
3187
+ c.fill();
3188
+ if (active) {
3189
+ c.lineWidth = 3;
3190
+ c.strokeStyle = rgba3(ctx.theme.paper ?? "#ffffff", 0.9);
3191
+ c.stroke();
3192
+ }
3193
+ const label = s.label ?? "";
3194
+ if (label) {
3195
+ c.fillStyle = rgba3(ctx.theme.paper ?? "#ffffff", active ? 1 : 0.85);
3196
+ c.font = `${active ? 700 : 600} ${Math.round(h * 0.42)}px ${ctx.theme.fontDisplay}`;
3197
+ c.textAlign = "center";
3198
+ c.textBaseline = "middle";
3199
+ c.fillText(label, x0 + w / 2, y + h / 2);
3200
+ }
3201
+ }
3202
+ c.strokeStyle = rgba3(ctx.theme.gold ?? "#d4af37", 0.95);
3203
+ c.lineWidth = 3;
3204
+ c.beginPath();
3205
+ c.moveTo(playX, y - pad);
3206
+ c.lineTo(playX, y + h + pad);
3207
+ c.stroke();
3208
+ c.restore();
3209
+ }
3210
+ };
3211
+ }
3212
+ var chordRibbonFactory = {
3213
+ key: "chord-ribbon",
3214
+ create: chordRibbonLayer,
3215
+ validateProps(props) {
3216
+ if (props == null || typeof props !== "object") return ["chord-ribbon: props must be an object"];
3217
+ const p = props;
3218
+ const errs = validateChordTrack(p.chordTrack);
3219
+ if (p.pxPerMs != null && (typeof p.pxPerMs !== "number" || p.pxPerMs <= 0))
3220
+ errs.push("chord-ribbon.pxPerMs must be a positive number");
3221
+ if (p.playheadFrac != null && (typeof p.playheadFrac !== "number" || p.playheadFrac < 0 || p.playheadFrac > 1))
3222
+ errs.push("chord-ribbon.playheadFrac must be in [0,1]");
3223
+ if (p.y != null && typeof p.y !== "number") errs.push("chord-ribbon.y must be a number");
3224
+ if (p.height != null && (typeof p.height !== "number" || p.height <= 0))
3225
+ errs.push("chord-ribbon.height must be a positive number");
3226
+ if (p.showFunction != null && typeof p.showFunction !== "boolean")
3227
+ errs.push("chord-ribbon.showFunction must be a boolean");
3228
+ return errs;
3229
+ }
3230
+ };
3231
+
3232
+ // src/scene/layers/progressRing.ts
3233
+ var DEFAULTS3 = { radius: 46, thickness: 9, showCountdown: false };
3234
+ function rgba4(hex, a) {
3235
+ const h = hex.replace("#", "");
3236
+ const n = h.length === 3 ? parseInt(h.split("").map((c) => c + c).join(""), 16) : parseInt(h, 16);
3237
+ return `rgba(${n >> 16 & 255},${n >> 8 & 255},${n & 255},${a})`;
3238
+ }
3239
+ function progressRingLayer() {
3240
+ let totalMsProp;
3241
+ let radius = DEFAULTS3.radius;
3242
+ let thickness = DEFAULTS3.thickness;
3243
+ let cxProp;
3244
+ let cyProp;
3245
+ let color;
3246
+ let trackColor;
3247
+ let showCountdown = DEFAULTS3.showCountdown;
3248
+ return {
3249
+ key: "progress-ring",
3250
+ init(ctx, props) {
3251
+ totalMsProp = props.totalMs ?? ctx.score?.durationMs;
3252
+ radius = props.radius ?? DEFAULTS3.radius;
3253
+ thickness = props.thickness ?? DEFAULTS3.thickness;
3254
+ cxProp = props.cx;
3255
+ cyProp = props.cy;
3256
+ color = props.color;
3257
+ trackColor = props.trackColor;
3258
+ showCountdown = props.showCountdown ?? DEFAULTS3.showCountdown;
3259
+ },
3260
+ draw(ctx, tMs) {
3261
+ const total = totalMsProp;
3262
+ if (!total || total <= 0) return;
3263
+ const c = ctx.ctx2d;
3264
+ const sb = ctx.safeBox;
3265
+ const cx = cxProp ?? sb.right - radius - 16;
3266
+ const cy = cyProp ?? sb.top + radius + 16;
3267
+ const p = Math.min(1, Math.max(0, tMs / total));
3268
+ const start = -Math.PI / 2;
3269
+ const end = start + p * Math.PI * 2;
3270
+ const fill = color ?? ctx.theme.gold;
3271
+ const track = trackColor ?? rgba4(ctx.theme.paper, 0.22);
3272
+ c.save();
3273
+ c.lineCap = "round";
3274
+ c.beginPath();
3275
+ c.arc(cx, cy, radius, 0, Math.PI * 2);
3276
+ c.strokeStyle = track;
3277
+ c.lineWidth = thickness;
3278
+ c.stroke();
3279
+ if (p > 0) {
3280
+ c.beginPath();
3281
+ c.arc(cx, cy, radius, start, end);
3282
+ c.strokeStyle = fill;
3283
+ c.lineWidth = thickness;
3284
+ c.stroke();
3285
+ }
3286
+ if (showCountdown) {
3287
+ const remain = Math.max(0, Math.ceil((total - tMs) / 1e3));
3288
+ c.fillStyle = ctx.theme.paper;
3289
+ c.font = `700 ${Math.round(radius * 0.7)}px ${ctx.theme.fontDisplay}`;
3290
+ c.textAlign = "center";
3291
+ c.textBaseline = "middle";
3292
+ c.fillText(String(remain), cx, cy + 1);
3293
+ }
3294
+ c.restore();
3295
+ }
3296
+ };
3297
+ }
3298
+ var progressRingFactory = {
3299
+ key: "progress-ring",
3300
+ create: progressRingLayer,
3301
+ validateProps(props) {
3302
+ if (props == null || typeof props !== "object") return ["progress-ring: props must be an object"];
3303
+ const p = props;
3304
+ const errs = [];
3305
+ if (p.totalMs != null && (typeof p.totalMs !== "number" || p.totalMs <= 0))
3306
+ errs.push("progress-ring.totalMs must be a positive number");
3307
+ if (p.radius != null && (typeof p.radius !== "number" || p.radius <= 0))
3308
+ errs.push("progress-ring.radius must be a positive number");
3309
+ if (p.thickness != null && (typeof p.thickness !== "number" || p.thickness <= 0))
3310
+ errs.push("progress-ring.thickness must be a positive number");
3311
+ if (p.cx != null && typeof p.cx !== "number") errs.push("progress-ring.cx must be a number");
3312
+ if (p.cy != null && typeof p.cy !== "number") errs.push("progress-ring.cy must be a number");
3313
+ if (p.color != null && typeof p.color !== "string") errs.push("progress-ring.color must be a string");
3314
+ if (p.trackColor != null && typeof p.trackColor !== "string") errs.push("progress-ring.trackColor must be a string");
3315
+ if (p.showCountdown != null && typeof p.showCountdown !== "boolean")
3316
+ errs.push("progress-ring.showCountdown must be a boolean");
3317
+ return errs;
3318
+ }
3319
+ };
3320
+
3321
+ // src/scene/layers/radialSpectrum.ts
3322
+ var SPEC_BARS2 = 64;
3323
+ function clamp013(x) {
3324
+ return x < 0 ? 0 : x > 1 ? 1 : x;
3325
+ }
3326
+ function lerpHex2(a, b, f) {
3327
+ const pa = parseInt(a.slice(1), 16);
3328
+ const pb = parseInt(b.slice(1), 16);
3329
+ const r = Math.round((pa >> 16 & 255) + ((pb >> 16 & 255) - (pa >> 16 & 255)) * f);
3330
+ const g = Math.round((pa >> 8 & 255) + ((pb >> 8 & 255) - (pa >> 8 & 255)) * f);
3331
+ const bl = Math.round((pa & 255) + ((pb & 255) - (pa & 255)) * f);
3332
+ return `rgb(${r},${g},${bl})`;
3333
+ }
3334
+ function syntheticMagnitude2(tSec, b, n) {
3335
+ const phase = tSec * 5 + b * 0.45;
3336
+ let m = 0.22 + 0.16 * Math.sin(phase) + 0.1 * Math.sin(phase * 1.7 + 1.2);
3337
+ m *= 0.5 + 0.5 * Math.sin(b / (n - 1) * Math.PI);
3338
+ return m;
3339
+ }
3340
+ function binByteFreq2(freq, n) {
3341
+ let peak = 0;
3342
+ for (let i = 0; i < freq.length; i++) if (freq[i] > peak) peak = freq[i];
3343
+ if (peak <= 4) return null;
3344
+ const lo = 2, hi = 440;
3345
+ const out = [];
3346
+ for (let b = 0; b < n; b++) {
3347
+ const f0 = lo * Math.pow(hi / lo, b / n);
3348
+ const f1 = lo * Math.pow(hi / lo, (b + 1) / n);
3349
+ const i0 = Math.floor(f0);
3350
+ const i1 = Math.max(i0 + 1, Math.floor(f1));
3351
+ let sum = 0, cnt = 0;
3352
+ for (let i = i0; i < i1 && i < freq.length; i++) {
3353
+ sum += freq[i];
3354
+ cnt++;
3355
+ }
3356
+ let m = cnt ? sum / cnt / 255 : 0;
3357
+ m = Math.pow(m, 0.78);
3358
+ out.push(m);
3359
+ }
3360
+ return out;
3361
+ }
3362
+ function radialSpectrumLayer() {
3363
+ let n = SPEC_BARS2;
3364
+ let cxFrac = 0.5, cyFrac = 0.42, innerFrac = 0.2, maxLenFrac = 0.13;
3365
+ let colorLow, colorHigh;
3366
+ let levelsFn;
3367
+ function magnitudesAt(ctx, tMs) {
3368
+ const fromProp = levelsFn?.(tMs, n);
3369
+ const src = fromProp ?? resolveFromCtx(ctx, tMs);
3370
+ if (src && src.length) {
3371
+ const out = new Array(n);
3372
+ for (let b = 0; b < n; b++) out[b] = clamp013(src[Math.min(src.length - 1, b)] ?? 0);
3373
+ return out;
3374
+ }
3375
+ const tSec = tMs / 1e3;
3376
+ return Array.from({ length: n }, (_, b) => clamp013(syntheticMagnitude2(tSec, b, n)));
3377
+ }
3378
+ function resolveFromCtx(ctx, tMs) {
3379
+ const sp = ctx.spectrum;
3380
+ if (!sp) return null;
3381
+ const lv = sp.levels?.(tMs, n);
3382
+ if (lv && lv.length) return Array.from(lv);
3383
+ const bf = sp.byteFreq?.(tMs);
3384
+ if (bf && bf.length) return binByteFreq2(bf, n);
3385
+ return null;
3386
+ }
3387
+ return {
3388
+ key: "radial-spectrum",
3389
+ init(_ctx, props) {
3390
+ n = props.bars ?? SPEC_BARS2;
3391
+ cxFrac = props.cxFrac ?? 0.5;
3392
+ cyFrac = props.cyFrac ?? 0.42;
3393
+ innerFrac = props.innerFrac ?? 0.2;
3394
+ maxLenFrac = props.maxLenFrac ?? 0.13;
3395
+ colorLow = props.colorLow;
3396
+ colorHigh = props.colorHigh;
3397
+ levelsFn = props.levelsFn;
3398
+ },
3399
+ draw(ctx, tMs) {
3400
+ const c = ctx.ctx2d;
3401
+ const W = ctx.W, H = ctx.H;
3402
+ const cx = W * cxFrac, cy = H * cyFrac;
3403
+ const unit = Math.min(W, H);
3404
+ const inner = unit * innerFrac;
3405
+ const maxLen = unit * maxLenFrac;
3406
+ const lo = colorLow ?? ctx.theme.accent;
3407
+ const hi = colorHigh ?? ctx.theme.gold;
3408
+ const mags = magnitudesAt(ctx, tMs);
3409
+ const barW = Math.max(3, 2 * Math.PI * inner / n * 0.6);
3410
+ c.save();
3411
+ c.lineCap = "round";
3412
+ for (let b = 0; b < n; b++) {
3413
+ const m = mags[b];
3414
+ const ang = b / n * Math.PI * 2 - Math.PI / 2;
3415
+ const len = Math.max(barW * 0.5, m * maxLen);
3416
+ const x0 = cx + Math.cos(ang) * inner;
3417
+ const y0 = cy + Math.sin(ang) * inner;
3418
+ const x1 = cx + Math.cos(ang) * (inner + len);
3419
+ const y1 = cy + Math.sin(ang) * (inner + len);
3420
+ c.strokeStyle = lerpHex2(lo, hi, m);
3421
+ c.lineWidth = barW;
3422
+ c.beginPath();
3423
+ c.moveTo(x0, y0);
3424
+ c.lineTo(x1, y1);
3425
+ c.stroke();
3426
+ }
3427
+ c.restore();
3428
+ }
3429
+ };
3430
+ }
3431
+ var radialSpectrumFactory = {
3432
+ key: "radial-spectrum",
3433
+ create: radialSpectrumLayer,
3434
+ validateProps(props) {
3435
+ if (props == null || typeof props !== "object") return ["radial-spectrum: props must be an object"];
3436
+ const p = props;
3437
+ const errs = [];
3438
+ if (p.bars != null && (typeof p.bars !== "number" || p.bars < 6)) errs.push("radial-spectrum.bars must be a number >= 6");
3439
+ for (const k of ["cxFrac", "cyFrac", "innerFrac", "maxLenFrac"]) {
3440
+ if (p[k] != null && (typeof p[k] !== "number" || p[k] < 0 || p[k] > 1))
3441
+ errs.push(`radial-spectrum.${k} must be in [0,1]`);
3442
+ }
3443
+ if (p.colorLow != null && typeof p.colorLow !== "string") errs.push("radial-spectrum.colorLow must be a string");
3444
+ if (p.colorHigh != null && typeof p.colorHigh !== "string") errs.push("radial-spectrum.colorHigh must be a string");
3445
+ if (p.levelsFn != null && typeof p.levelsFn !== "function") errs.push("radial-spectrum.levelsFn must be a function");
3446
+ return errs;
3447
+ }
3448
+ };
3449
+
3450
+ // src/scene/layers/karaokeCaption.ts
3451
+ var DEFAULTS4 = { centerFrac: 0.5, fontPx: 64, activeHoldMs: 320 };
3452
+ function rgba5(hex, a) {
3453
+ const h = hex.replace("#", "");
3454
+ const n = h.length === 3 ? parseInt(h.split("").map((ch) => ch + ch).join(""), 16) : parseInt(h, 16);
3455
+ return `rgba(${n >> 16 & 255},${n >> 8 & 255},${n & 255},${a})`;
3456
+ }
3457
+ function karaokeCaptionLayer() {
3458
+ let words = [];
3459
+ let centerFrac = DEFAULTS4.centerFrac;
3460
+ let fontPx = DEFAULTS4.fontPx;
3461
+ let sungColor;
3462
+ let upcomingColor;
3463
+ let activeColor;
3464
+ let activeHoldMs = DEFAULTS4.activeHoldMs;
3465
+ return {
3466
+ key: "karaoke-caption",
3467
+ init(_ctx, props) {
3468
+ words = (props.words ?? []).slice().sort((a, b) => a.atMs - b.atMs);
3469
+ centerFrac = props.centerFrac ?? DEFAULTS4.centerFrac;
3470
+ fontPx = props.fontPx ?? DEFAULTS4.fontPx;
3471
+ sungColor = props.sungColor;
3472
+ upcomingColor = props.upcomingColor;
3473
+ activeColor = props.activeColor;
3474
+ activeHoldMs = props.activeHoldMs ?? DEFAULTS4.activeHoldMs;
3475
+ },
3476
+ draw(ctx, tMs) {
3477
+ if (!words.length) return;
3478
+ const c = ctx.ctx2d;
3479
+ const sb = ctx.safeBox;
3480
+ c.save();
3481
+ c.font = `700 ${fontPx}px ${ctx.theme.fontDisplay}`;
3482
+ c.textBaseline = "middle";
3483
+ c.textAlign = "left";
3484
+ const spaceW = c.measureText(" ").width;
3485
+ const maxW = sb.w;
3486
+ const lines = [];
3487
+ let cur = { items: [], width: 0 };
3488
+ for (const w of words) {
3489
+ const ww = c.measureText(w.text).width;
3490
+ const add = cur.items.length ? spaceW + ww : ww;
3491
+ if (cur.items.length && cur.width + add > maxW) {
3492
+ lines.push(cur);
3493
+ cur = { items: [], width: 0 };
3494
+ }
3495
+ cur.items.push({ w, width: ww });
3496
+ cur.width += cur.items.length === 1 ? ww : spaceW + ww;
3497
+ }
3498
+ if (cur.items.length) lines.push(cur);
3499
+ const lineH = fontPx * 1.3;
3500
+ const totalH = lines.length * lineH;
3501
+ let y = H_center(ctx, centerFrac) - totalH / 2 + lineH / 2;
3502
+ const sung = sungColor ?? ctx.theme.ink;
3503
+ const upcoming = upcomingColor ?? rgba5(ctx.theme.ink, 0.28);
3504
+ const active = activeColor ?? ctx.theme.accent;
3505
+ for (const line of lines) {
3506
+ let x = sb.left + (maxW - line.width) / 2;
3507
+ for (const it of line.items) {
3508
+ const lit = tMs >= it.w.atMs;
3509
+ const fresh = lit && tMs - it.w.atMs < activeHoldMs;
3510
+ c.fillStyle = fresh ? active : lit ? sung : upcoming;
3511
+ c.fillText(it.w.text, x, y);
3512
+ x += it.width + spaceW;
3513
+ }
3514
+ y += lineH;
3515
+ }
3516
+ c.restore();
3517
+ }
3518
+ };
3519
+ }
3520
+ function H_center(ctx, frac) {
3521
+ return ctx.H * frac;
3522
+ }
3523
+ var karaokeCaptionFactory = {
3524
+ key: "karaoke-caption",
3525
+ create: karaokeCaptionLayer,
3526
+ validateProps(props) {
3527
+ if (props == null || typeof props !== "object") return ["karaoke-caption: props must be an object"];
3528
+ const p = props;
3529
+ const errs = [];
3530
+ if (!Array.isArray(p.words)) {
3531
+ errs.push("karaoke-caption.words must be an array of {text, atMs}");
3532
+ } else {
3533
+ p.words.forEach((raw, i) => {
3534
+ const w = raw;
3535
+ if (typeof w?.text !== "string" || typeof w?.atMs !== "number")
3536
+ errs.push(`karaoke-caption.words[${i}] must be {text:string, atMs:number}`);
3537
+ });
3538
+ }
3539
+ if (p.centerFrac != null && (typeof p.centerFrac !== "number" || p.centerFrac < 0 || p.centerFrac > 1))
3540
+ errs.push("karaoke-caption.centerFrac must be in [0,1]");
3541
+ if (p.fontPx != null && (typeof p.fontPx !== "number" || p.fontPx <= 0))
3542
+ errs.push("karaoke-caption.fontPx must be a positive number");
3543
+ if (p.activeHoldMs != null && (typeof p.activeHoldMs !== "number" || p.activeHoldMs < 0))
3544
+ errs.push("karaoke-caption.activeHoldMs must be a number >= 0");
3545
+ for (const k of ["sungColor", "upcomingColor", "activeColor"]) {
3546
+ if (p[k] != null && typeof p[k] !== "string") errs.push(`karaoke-caption.${k} must be a string`);
3547
+ }
3548
+ return errs;
3549
+ }
3550
+ };
3551
+
3552
+ // src/scene/layers/intervalArcs.ts
3553
+ function intervalLabel(deltaSemis) {
3554
+ const semi = Math.abs(deltaSemis);
3555
+ const within = semi % 12;
3556
+ if (semi > 0 && within === 0) return "P8";
3557
+ return intervalBySemitones(within)?.label ?? `${within}`;
3558
+ }
3559
+ function intervalArcsLayer() {
3560
+ let topProp;
3561
+ let heightProp;
3562
+ let upColor;
3563
+ let downColor;
3564
+ let showLabels = true;
3565
+ let fontPx = 30;
3566
+ let dotRadius = 9;
3567
+ let points = [];
3568
+ return {
3569
+ key: "interval-arcs",
3570
+ init(ctx, props) {
3571
+ topProp = props.top;
3572
+ heightProp = props.height;
3573
+ upColor = props.upColor;
3574
+ downColor = props.downColor;
3575
+ showLabels = props.showLabels ?? true;
3576
+ fontPx = props.fontPx ?? 30;
3577
+ dotRadius = props.dotRadius ?? 9;
3578
+ points = contourPoints(ctx.score?.notes ?? []);
3579
+ },
3580
+ draw(ctx, tMs) {
3581
+ if (points.length < 2) return;
3582
+ const c = ctx.ctx2d;
3583
+ const sb = ctx.safeBox;
3584
+ const top = topProp ?? sb.top + sb.h * 0.18;
3585
+ const height = heightProp ?? sb.h * 0.34;
3586
+ const range = pitchRange(points);
3587
+ const plot = {
3588
+ left: sb.left,
3589
+ right: sb.right,
3590
+ top,
3591
+ bottom: top + height,
3592
+ minPitch: range.min,
3593
+ maxPitch: range.max,
3594
+ durationMs: ctx.score?.durationMs ?? points[points.length - 1].tMs
3595
+ };
3596
+ const up = upColor ?? ctx.theme.accent;
3597
+ const down = downColor ?? ctx.theme.sepia;
3598
+ c.save();
3599
+ c.lineCap = "round";
3600
+ c.font = `700 ${fontPx}px ${ctx.theme.fontDisplay}`;
3601
+ c.textAlign = "center";
3602
+ c.textBaseline = "bottom";
3603
+ for (let i = 1; i < points.length; i++) {
3604
+ const b = points[i];
3605
+ if (b.tMs > tMs) break;
3606
+ const a = points[i - 1];
3607
+ const pa = projectPoint(plot, a.tMs, a.pitchMidi);
3608
+ const pb = projectPoint(plot, b.tMs, b.pitchMidi);
3609
+ const delta = b.pitchMidi - a.pitchMidi;
3610
+ const ascending = delta >= 0;
3611
+ const col = ascending ? up : down;
3612
+ const midX = (pa.x + pb.x) / 2;
3613
+ const bow = Math.min(70, Math.max(28, Math.abs(pb.x - pa.x) * 0.4));
3614
+ const ctrlY = Math.min(pa.y, pb.y) - bow;
3615
+ c.strokeStyle = col;
3616
+ c.lineWidth = 4;
3617
+ c.globalAlpha = 0.9;
3618
+ c.beginPath();
3619
+ c.moveTo(pa.x, pa.y);
3620
+ c.quadraticCurveTo(midX, ctrlY, pb.x, pb.y);
3621
+ c.stroke();
3622
+ if (showLabels && delta !== 0) {
3623
+ c.globalAlpha = 1;
3624
+ c.fillStyle = col;
3625
+ c.fillText(intervalLabel(delta), midX, ctrlY - 2);
3626
+ }
3627
+ }
3628
+ c.globalAlpha = 1;
3629
+ c.fillStyle = ctx.theme.ink;
3630
+ for (const p of points) {
3631
+ if (p.tMs > tMs) break;
3632
+ const pt = projectPoint(plot, p.tMs, p.pitchMidi);
3633
+ c.beginPath();
3634
+ c.arc(pt.x, pt.y, dotRadius, 0, Math.PI * 2);
3635
+ c.fill();
3636
+ }
3637
+ c.restore();
3638
+ }
3639
+ };
3640
+ }
3641
+ var intervalArcsFactory = {
3642
+ key: "interval-arcs",
3643
+ create: intervalArcsLayer,
3644
+ validateProps(props) {
3645
+ if (props == null || typeof props !== "object") return ["interval-arcs: props must be an object"];
3646
+ const p = props;
3647
+ const errs = [];
3648
+ if (p.top != null && typeof p.top !== "number") errs.push("interval-arcs.top must be a number");
3649
+ if (p.height != null && (typeof p.height !== "number" || p.height <= 0))
3650
+ errs.push("interval-arcs.height must be a positive number");
3651
+ if (p.upColor != null && typeof p.upColor !== "string") errs.push("interval-arcs.upColor must be a string");
3652
+ if (p.downColor != null && typeof p.downColor !== "string") errs.push("interval-arcs.downColor must be a string");
3653
+ if (p.showLabels != null && typeof p.showLabels !== "boolean") errs.push("interval-arcs.showLabels must be a boolean");
3654
+ if (p.fontPx != null && (typeof p.fontPx !== "number" || p.fontPx <= 0)) errs.push("interval-arcs.fontPx must be a positive number");
3655
+ if (p.dotRadius != null && (typeof p.dotRadius !== "number" || p.dotRadius <= 0)) errs.push("interval-arcs.dotRadius must be a positive number");
3656
+ return errs;
3657
+ }
3658
+ };
3659
+
3023
3660
  // src/scene/registry.ts
3024
3661
  var REGISTRY = /* @__PURE__ */ new Map();
3025
3662
  function registerLayer(factory) {
@@ -3052,6 +3689,12 @@ registerLayer(mcqCardFactory);
3052
3689
  registerLayer(circleOfFifthsFactory);
3053
3690
  registerLayer(pitchContourFactory);
3054
3691
  registerLayer(sectionMinimapFactory);
3692
+ registerLayer(beatPulseFactory);
3693
+ registerLayer(chordRibbonFactory);
3694
+ registerLayer(progressRingFactory);
3695
+ registerLayer(radialSpectrumFactory);
3696
+ registerLayer(karaokeCaptionFactory);
3697
+ registerLayer(intervalArcsFactory);
3055
3698
 
3056
3699
  // src/scene/audioLayers.ts
3057
3700
  function countInSchedule(opts) {
@@ -3204,7 +3847,11 @@ var SCREEN_PINNED_KEYS = /* @__PURE__ */ new Set([
3204
3847
  "reveal",
3205
3848
  "portrait",
3206
3849
  "safe-guides",
3207
- "mcq-card"
3850
+ "mcq-card",
3851
+ "beat-pulse",
3852
+ "chord-ribbon",
3853
+ "progress-ring",
3854
+ "karaoke-caption"
3208
3855
  ]);
3209
3856
  function defaultBufferFactory() {
3210
3857
  const g = globalThis;
@@ -3803,12 +4450,14 @@ export {
3803
4450
  ballX,
3804
4451
  beatGrid,
3805
4452
  beatPhase,
4453
+ beatPulseFactory,
3806
4454
  blackKeys,
3807
4455
  bpmOf,
3808
4456
  brandingFactory,
3809
4457
  buildScene,
3810
4458
  cameraForFollow,
3811
4459
  cameraTransform,
4460
+ chordRibbonFactory,
3812
4461
  circleOfFifthsDemoSpec,
3813
4462
  circleOfFifthsFactory,
3814
4463
  clamp,
@@ -3857,8 +4506,10 @@ export {
3857
4506
  hookFactory,
3858
4507
  identityCamera,
3859
4508
  inRange,
4509
+ intervalArcsFactory,
3860
4510
  invLerp,
3861
4511
  isBlackKey,
4512
+ karaokeCaptionFactory,
3862
4513
  kenBurns,
3863
4514
  keyCenterX,
3864
4515
  keyColumnWidth,
@@ -3893,9 +4544,12 @@ export {
3893
4544
  playheadLine,
3894
4545
  portraitFactory,
3895
4546
  progress01,
4547
+ progressRingFactory,
3896
4548
  projectPoint,
3897
4549
  promoCardsDemoSpec,
4550
+ pulseAt,
3898
4551
  quizPhase,
4552
+ radialSpectrumFactory,
3899
4553
  rayEndpoints,
3900
4554
  rayPointAt,
3901
4555
  recordSceneSpec,