@real-music-packages/web-core 0.12.0 → 0.14.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.
@@ -177,6 +177,10 @@ var easeOut = (t) => {
177
177
  const c = clamp(t, 0, 1);
178
178
  return 1 - (1 - c) * (1 - c);
179
179
  };
180
+ var transitionProgress = (tMs, startMs, durationMs, easing = easeInOut) => {
181
+ if (durationMs <= 0) return 1;
182
+ return easing(invLerp(startMs, startMs + durationMs, tMs));
183
+ };
180
184
 
181
185
  // src/scene/caption.ts
182
186
  function activeCue(script, tMs) {
@@ -2617,6 +2621,96 @@ registerLayer(circleOfFifthsFactory);
2617
2621
  registerLayer(pitchContourFactory);
2618
2622
  registerLayer(sectionMinimapFactory);
2619
2623
 
2624
+ // src/scene/audioLayers.ts
2625
+ function countInSchedule(opts) {
2626
+ const beats = opts.beats ?? 4;
2627
+ const accent = opts.accentDownbeat ?? true;
2628
+ const beatSec = 60 / opts.bpm;
2629
+ const events = [];
2630
+ for (let i = 0; i < beats; i++) {
2631
+ const isDownbeat = accent && i === 0;
2632
+ events.push({
2633
+ atSec: i * beatSec,
2634
+ note: isDownbeat ? "C6" : "C5",
2635
+ durSec: 0.05,
2636
+ gain: isDownbeat ? 1 : 0.7,
2637
+ kind: "count"
2638
+ });
2639
+ }
2640
+ return events;
2641
+ }
2642
+ function countInLeadSec(opts) {
2643
+ return (opts.beats ?? 4) * (60 / opts.bpm);
2644
+ }
2645
+ function clickTrackSchedule(opts) {
2646
+ const beatSec = 60 / opts.bpm;
2647
+ const bpb = opts.beatsPerBar ?? 4;
2648
+ const start = opts.startSec ?? 0;
2649
+ const events = [];
2650
+ const n = Math.floor(opts.durationSec / beatSec);
2651
+ for (let i = 0; i < n; i++) {
2652
+ const isDownbeat = i % bpb === 0;
2653
+ events.push({
2654
+ atSec: start + i * beatSec,
2655
+ note: isDownbeat ? "C6" : "C5",
2656
+ durSec: 0.03,
2657
+ gain: isDownbeat ? 0.6 : 0.4,
2658
+ kind: "click"
2659
+ });
2660
+ }
2661
+ return events;
2662
+ }
2663
+ function droneSchedule(opts) {
2664
+ const gain = opts.gain ?? 0.15;
2665
+ const events = [
2666
+ { atSec: 0, note: opts.root, durSec: opts.durationSec, gain, kind: "drone" }
2667
+ ];
2668
+ if (opts.fifth ?? true) {
2669
+ events.push({ atSec: 0, note: transposeFifth(opts.root), durSec: opts.durationSec, gain, kind: "drone" });
2670
+ }
2671
+ return events;
2672
+ }
2673
+ function transposeFifth(note) {
2674
+ const m = /^([A-G])(#|b)?(\d)$/.exec(note);
2675
+ if (!m) return note;
2676
+ const order = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
2677
+ const pcMap = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
2678
+ let pc = pcMap[m[1]] + (m[2] === "#" ? 1 : m[2] === "b" ? -1 : 0);
2679
+ let oct = parseInt(m[3], 10);
2680
+ pc += 7;
2681
+ if (pc >= 12) {
2682
+ pc -= 12;
2683
+ oct += 1;
2684
+ }
2685
+ return `${order[pc]}${oct}`;
2686
+ }
2687
+ function duckGainAt(windows, tSec, floor = 0.25, rampSec = 0.2) {
2688
+ for (const w of windows) {
2689
+ if (tSec >= w.startSec - rampSec && tSec <= w.endSec + rampSec) {
2690
+ if (tSec < w.startSec) return lerpGain(1, floor, (tSec - (w.startSec - rampSec)) / rampSec);
2691
+ if (tSec > w.endSec) return lerpGain(floor, 1, (tSec - w.endSec) / rampSec);
2692
+ return floor;
2693
+ }
2694
+ }
2695
+ return 1;
2696
+ }
2697
+ function lerpGain(a, b, t) {
2698
+ const k = t < 0 ? 0 : t > 1 ? 1 : t;
2699
+ return a + (b - a) * k;
2700
+ }
2701
+ function applySchedule(instrument, schedule, startTime) {
2702
+ for (const ev of schedule) {
2703
+ if (ev.note == null) continue;
2704
+ instrument.triggerAttackRelease(
2705
+ ev.note,
2706
+ ev.durSec ?? 0.05,
2707
+ startTime + ev.atSec,
2708
+ ev.gain ?? 1
2709
+ );
2710
+ }
2711
+ return schedule.length;
2712
+ }
2713
+
2620
2714
  // src/scene/camera.ts
2621
2715
  function cameraTransform(cam, W, H) {
2622
2716
  const s = cam.zoom;
@@ -2658,7 +2752,13 @@ function resolveTimeline(spec, totalSec) {
2658
2752
  return spec.timeline.map((seg) => {
2659
2753
  const startSec = resolveAnchor(seg.at[0], totalSec);
2660
2754
  const endSec = resolveAnchor(seg.at[1], totalSec);
2661
- return { startMs: startSec * 1e3, endMs: endSec * 1e3, layers: seg.layers };
2755
+ return {
2756
+ startMs: startSec * 1e3,
2757
+ endMs: endSec * 1e3,
2758
+ layers: seg.layers,
2759
+ audio: seg.audio,
2760
+ transition: seg.transition
2761
+ };
2662
2762
  });
2663
2763
  }
2664
2764
  function visualTimelineMs(resolved) {
@@ -2674,6 +2774,24 @@ var SCREEN_PINNED_KEYS = /* @__PURE__ */ new Set([
2674
2774
  "safe-guides",
2675
2775
  "mcq-card"
2676
2776
  ]);
2777
+ function defaultBufferFactory() {
2778
+ const g = globalThis;
2779
+ if (typeof g.OffscreenCanvas === "function") {
2780
+ return (w, h) => {
2781
+ const c = new g.OffscreenCanvas(w, h);
2782
+ return { ctx: c.getContext("2d"), image: c };
2783
+ };
2784
+ }
2785
+ if (typeof g.document !== "undefined" && g.document?.createElement) {
2786
+ return (w, h) => {
2787
+ const c = g.document.createElement("canvas");
2788
+ c.width = w;
2789
+ c.height = h;
2790
+ return { ctx: c.getContext("2d"), image: c };
2791
+ };
2792
+ }
2793
+ return void 0;
2794
+ }
2677
2795
  async function buildScene(opts) {
2678
2796
  const { spec, theme, score } = opts;
2679
2797
  const [W, H] = spec.size;
@@ -2681,6 +2799,8 @@ async function buildScene(opts) {
2681
2799
  const resolved = resolveTimeline(spec, opts.totalSec);
2682
2800
  const safe = safeBox(W, H);
2683
2801
  const camera = opts.camera ?? (() => identityCamera(W, H));
2802
+ const bufferFactory = opts.bufferFactory ?? defaultBufferFactory();
2803
+ const hasTransitions = resolved.some((s) => s.transition && s.transition.durationMs > 0);
2684
2804
  const clock = { nowMs: () => 0 };
2685
2805
  const baseCtx = {
2686
2806
  W,
@@ -2692,7 +2812,8 @@ async function buildScene(opts) {
2692
2812
  fps
2693
2813
  };
2694
2814
  const bound = [];
2695
- for (const seg of resolved) {
2815
+ for (let segIndex = 0; segIndex < resolved.length; segIndex++) {
2816
+ const seg = resolved[segIndex];
2696
2817
  for (const sl of seg.layers) {
2697
2818
  const factory = getLayerFactory(sl.k);
2698
2819
  if (!factory) {
@@ -2708,32 +2829,175 @@ async function buildScene(opts) {
2708
2829
  layer,
2709
2830
  startMs: seg.startMs,
2710
2831
  endMs: seg.endMs,
2832
+ segIndex,
2711
2833
  screenPinned: SCREEN_PINNED_KEYS.has(sl.k)
2712
2834
  });
2713
2835
  }
2714
2836
  }
2715
2837
  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);
2838
+ function dirVec(dir) {
2839
+ switch (dir) {
2840
+ case "right":
2841
+ return { x: 1, y: 0 };
2842
+ case "up":
2843
+ return { x: 0, y: -1 };
2844
+ case "down":
2845
+ return { x: 0, y: 1 };
2846
+ case "left":
2847
+ default:
2848
+ return { x: -1, y: 0 };
2849
+ }
2850
+ }
2851
+ function transitionEnvs(tr, p) {
2852
+ if (tr.type === "push") {
2853
+ const v = dirVec(tr.direction);
2854
+ const span = Math.abs(v.x) ? W : H;
2855
+ return {
2856
+ incoming: { alpha: 1, dx: -v.x * span * (1 - p), dy: -v.y * span * (1 - p) },
2857
+ outgoing: { alpha: 1, dx: v.x * span * p, dy: v.y * span * p }
2858
+ };
2859
+ }
2860
+ if (tr.type === "wipe") {
2861
+ const v = dirVec(tr.direction);
2862
+ let inClip;
2863
+ let outClip;
2864
+ if (v.x !== 0) {
2865
+ const w = W * p;
2866
+ if (v.x < 0) {
2867
+ inClip = { x: 0, y: 0, w, h: H };
2868
+ outClip = { x: w, y: 0, w: W - w, h: H };
2869
+ } else {
2870
+ inClip = { x: W - w, y: 0, w, h: H };
2871
+ outClip = { x: 0, y: 0, w: W - w, h: H };
2872
+ }
2873
+ } else {
2874
+ const h = H * p;
2875
+ if (v.y < 0) {
2876
+ inClip = { x: 0, y: 0, w: W, h };
2877
+ outClip = { x: 0, y: h, w: W, h: H - h };
2878
+ } else {
2879
+ inClip = { x: 0, y: H - h, w: W, h };
2880
+ outClip = { x: 0, y: 0, w: W, h: H - h };
2881
+ }
2882
+ }
2883
+ return {
2884
+ incoming: { alpha: 1, dx: 0, dy: 0, clip: inClip },
2885
+ outgoing: { alpha: 1, dx: 0, dy: 0, clip: outClip }
2886
+ };
2887
+ }
2888
+ return {
2889
+ incoming: { alpha: p, dx: 0, dy: 0 },
2890
+ outgoing: { alpha: 1 - p, dx: 0, dy: 0 }
2891
+ };
2892
+ }
2893
+ function frameplan(tMs) {
2894
+ const plan = [];
2895
+ for (let i = 0; i < resolved.length; i++) {
2896
+ const seg = resolved[i];
2897
+ if (tMs < seg.startMs || tMs >= seg.endMs) continue;
2898
+ const tr = seg.transition;
2899
+ const prev = i > 0 ? resolved[i - 1] : void 0;
2900
+ const enterEnd = tr ? seg.startMs + Math.max(0, tr.durationMs) : seg.startMs;
2901
+ const inEnter = tr && tr.durationMs > 0 && tMs < enterEnd && prev;
2902
+ if (!inEnter) {
2903
+ plan.push({ segIndex: i, drawTMs: tMs, env: null });
2904
+ continue;
2905
+ }
2906
+ const p = transitionProgress(tMs, seg.startMs, tr.durationMs, tr.easing ?? easeInOut);
2907
+ const { incoming, outgoing } = transitionEnvs(tr, p);
2908
+ const heldTMs = clamp(tMs, prev.startMs, prev.endMs - 1);
2909
+ plan.push({ segIndex: i - 1, drawTMs: heldTMs, env: outgoing });
2910
+ plan.push({ segIndex: i, drawTMs: tMs, env: incoming });
2911
+ }
2912
+ return plan;
2913
+ }
2914
+ function drawSegmentInto(target, segIndex, drawTMs) {
2915
+ const ctx = { ...baseCtx, ctx2d: target };
2916
+ const cam = camera(drawTMs);
2917
+ target.save();
2918
+ applyToContext(target, cam, W, H);
2722
2919
  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);
2920
+ if (b.segIndex !== segIndex || b.screenPinned) continue;
2921
+ if (drawTMs < b.startMs || drawTMs >= b.endMs) continue;
2922
+ b.layer.draw(ctx, drawTMs);
2726
2923
  }
2727
- ctx2d.restore();
2924
+ target.restore();
2925
+ target.save();
2926
+ target.setTransform(1, 0, 0, 1, 0, 0);
2927
+ for (const b of bound) {
2928
+ if (b.segIndex !== segIndex || !b.screenPinned) continue;
2929
+ if (drawTMs < b.startMs || drawTMs >= b.endMs) continue;
2930
+ b.layer.draw(ctx, drawTMs);
2931
+ }
2932
+ target.restore();
2933
+ }
2934
+ function compositeEntry(ctx2d, e) {
2935
+ if (e.env === null) {
2936
+ drawSegmentInto(ctx2d, e.segIndex, e.drawTMs);
2937
+ return;
2938
+ }
2939
+ const env = e.env;
2940
+ if (!bufferFactory) {
2941
+ ctx2d.save();
2942
+ ctx2d.globalAlpha = ctx2d.globalAlpha * env.alpha;
2943
+ drawSegmentInto(ctx2d, e.segIndex, e.drawTMs);
2944
+ ctx2d.restore();
2945
+ return;
2946
+ }
2947
+ const buf = bufferFactory(W, H);
2948
+ drawSegmentInto(buf.ctx, e.segIndex, e.drawTMs);
2728
2949
  ctx2d.save();
2729
2950
  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);
2951
+ if (env.alpha !== 1) ctx2d.globalAlpha = ctx2d.globalAlpha * env.alpha;
2952
+ if (env.clip) {
2953
+ ctx2d.beginPath();
2954
+ ctx2d.rect(env.clip.x, env.clip.y, env.clip.w, env.clip.h);
2955
+ ctx2d.clip();
2734
2956
  }
2957
+ ctx2d.drawImage(buf.image, env.dx, env.dy, W, H);
2735
2958
  ctx2d.restore();
2736
2959
  }
2960
+ function renderFrame(ctx2d, tMs) {
2961
+ clock.nowMs = () => tMs;
2962
+ const ctx = { ...baseCtx, ctx2d };
2963
+ const cam = camera(tMs);
2964
+ if (!hasTransitions) {
2965
+ ctx2d.save();
2966
+ applyToContext(ctx2d, cam, W, H);
2967
+ for (const b of bound) {
2968
+ if (b.screenPinned) continue;
2969
+ if (tMs < b.startMs || tMs >= b.endMs) continue;
2970
+ b.layer.draw(ctx, tMs);
2971
+ }
2972
+ ctx2d.restore();
2973
+ ctx2d.save();
2974
+ ctx2d.setTransform(1, 0, 0, 1, 0, 0);
2975
+ for (const b of bound) {
2976
+ if (!b.screenPinned) continue;
2977
+ if (tMs < b.startMs || tMs >= b.endMs) continue;
2978
+ b.layer.draw(ctx, tMs);
2979
+ }
2980
+ ctx2d.restore();
2981
+ return;
2982
+ }
2983
+ const plan = frameplan(tMs);
2984
+ for (const e of plan) compositeEntry(ctx2d, e);
2985
+ }
2986
+ function scheduleAudio(instrument, baseSec) {
2987
+ let fired = 0;
2988
+ for (const seg of resolved) {
2989
+ if (!seg.audio) continue;
2990
+ const segStartSec = baseSec + seg.startMs / 1e3;
2991
+ if (seg.audio.schedule?.length) {
2992
+ fired += applySchedule(instrument, seg.audio.schedule, segStartSec);
2993
+ }
2994
+ if (seg.audio.onEnter) {
2995
+ const segClock = { nowMs: () => seg.startMs };
2996
+ seg.audio.onEnter(segClock, { instrument, startSec: segStartSec, score });
2997
+ }
2998
+ }
2999
+ return fired;
3000
+ }
2737
3001
  return {
2738
3002
  W,
2739
3003
  H,
@@ -2741,6 +3005,7 @@ async function buildScene(opts) {
2741
3005
  durationMs,
2742
3006
  resolved,
2743
3007
  renderFrame,
3008
+ scheduleAudio,
2744
3009
  dispose() {
2745
3010
  for (const b of bound) b.layer.dispose?.();
2746
3011
  }
@@ -2751,6 +3016,13 @@ async function recordSceneSpec(opts) {
2751
3016
  const record = opts.record ?? recordScenes;
2752
3017
  const composite = {
2753
3018
  durationMs: built.durationMs,
3019
+ // Schedule any per-segment audio against the capture clock exactly when the
3020
+ // first frame is entered, so it's anchored to the same start the recorder
3021
+ // uses. onEnter fires once per Scene (see recordScenes), so this runs once.
3022
+ onEnter: opts.segmentAudio ? () => {
3023
+ const sa = opts.segmentAudio;
3024
+ built.scheduleAudio(sa.instrument, sa.audioNow() + (sa.lookaheadSec ?? 0.1));
3025
+ } : void 0,
2754
3026
  draw: (ctx, t01) => built.renderFrame(ctx, t01 * built.durationMs)
2755
3027
  };
2756
3028
  return record([composite], {
@@ -2763,96 +3035,6 @@ async function recordSceneSpec(opts) {
2763
3035
  });
2764
3036
  }
2765
3037
 
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
3038
  // src/scene/gate.ts
2857
3039
  var AV_TOLERANCE_MS = 60;
2858
3040
  async function runGate(input) {
@@ -3304,6 +3486,7 @@ export {
3304
3486
  staffAnchor,
3305
3487
  staffKeyboardRayFactory,
3306
3488
  staffRayDemoSpec,
3489
+ transitionProgress,
3307
3490
  validateChordTrack,
3308
3491
  validateQuiz,
3309
3492
  validateSections,