@real-music-packages/web-core 0.12.0 → 0.13.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.
@@ -2617,6 +2617,96 @@ registerLayer(circleOfFifthsFactory);
2617
2617
  registerLayer(pitchContourFactory);
2618
2618
  registerLayer(sectionMinimapFactory);
2619
2619
 
2620
+ // src/scene/audioLayers.ts
2621
+ function countInSchedule(opts) {
2622
+ const beats = opts.beats ?? 4;
2623
+ const accent = opts.accentDownbeat ?? true;
2624
+ const beatSec = 60 / opts.bpm;
2625
+ const events = [];
2626
+ for (let i = 0; i < beats; i++) {
2627
+ const isDownbeat = accent && i === 0;
2628
+ events.push({
2629
+ atSec: i * beatSec,
2630
+ note: isDownbeat ? "C6" : "C5",
2631
+ durSec: 0.05,
2632
+ gain: isDownbeat ? 1 : 0.7,
2633
+ kind: "count"
2634
+ });
2635
+ }
2636
+ return events;
2637
+ }
2638
+ function countInLeadSec(opts) {
2639
+ return (opts.beats ?? 4) * (60 / opts.bpm);
2640
+ }
2641
+ function clickTrackSchedule(opts) {
2642
+ const beatSec = 60 / opts.bpm;
2643
+ const bpb = opts.beatsPerBar ?? 4;
2644
+ const start = opts.startSec ?? 0;
2645
+ const events = [];
2646
+ const n = Math.floor(opts.durationSec / beatSec);
2647
+ for (let i = 0; i < n; i++) {
2648
+ const isDownbeat = i % bpb === 0;
2649
+ events.push({
2650
+ atSec: start + i * beatSec,
2651
+ note: isDownbeat ? "C6" : "C5",
2652
+ durSec: 0.03,
2653
+ gain: isDownbeat ? 0.6 : 0.4,
2654
+ kind: "click"
2655
+ });
2656
+ }
2657
+ return events;
2658
+ }
2659
+ function droneSchedule(opts) {
2660
+ const gain = opts.gain ?? 0.15;
2661
+ const events = [
2662
+ { atSec: 0, note: opts.root, durSec: opts.durationSec, gain, kind: "drone" }
2663
+ ];
2664
+ if (opts.fifth ?? true) {
2665
+ events.push({ atSec: 0, note: transposeFifth(opts.root), durSec: opts.durationSec, gain, kind: "drone" });
2666
+ }
2667
+ return events;
2668
+ }
2669
+ function transposeFifth(note) {
2670
+ const m = /^([A-G])(#|b)?(\d)$/.exec(note);
2671
+ if (!m) return note;
2672
+ const order = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
2673
+ const pcMap = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
2674
+ let pc = pcMap[m[1]] + (m[2] === "#" ? 1 : m[2] === "b" ? -1 : 0);
2675
+ let oct = parseInt(m[3], 10);
2676
+ pc += 7;
2677
+ if (pc >= 12) {
2678
+ pc -= 12;
2679
+ oct += 1;
2680
+ }
2681
+ return `${order[pc]}${oct}`;
2682
+ }
2683
+ function duckGainAt(windows, tSec, floor = 0.25, rampSec = 0.2) {
2684
+ for (const w of windows) {
2685
+ if (tSec >= w.startSec - rampSec && tSec <= w.endSec + rampSec) {
2686
+ if (tSec < w.startSec) return lerpGain(1, floor, (tSec - (w.startSec - rampSec)) / rampSec);
2687
+ if (tSec > w.endSec) return lerpGain(floor, 1, (tSec - w.endSec) / rampSec);
2688
+ return floor;
2689
+ }
2690
+ }
2691
+ return 1;
2692
+ }
2693
+ function lerpGain(a, b, t) {
2694
+ const k = t < 0 ? 0 : t > 1 ? 1 : t;
2695
+ return a + (b - a) * k;
2696
+ }
2697
+ function applySchedule(instrument, schedule, startTime) {
2698
+ for (const ev of schedule) {
2699
+ if (ev.note == null) continue;
2700
+ instrument.triggerAttackRelease(
2701
+ ev.note,
2702
+ ev.durSec ?? 0.05,
2703
+ startTime + ev.atSec,
2704
+ ev.gain ?? 1
2705
+ );
2706
+ }
2707
+ return schedule.length;
2708
+ }
2709
+
2620
2710
  // src/scene/camera.ts
2621
2711
  function cameraTransform(cam, W, H) {
2622
2712
  const s = cam.zoom;
@@ -2658,7 +2748,7 @@ function resolveTimeline(spec, totalSec) {
2658
2748
  return spec.timeline.map((seg) => {
2659
2749
  const startSec = resolveAnchor(seg.at[0], totalSec);
2660
2750
  const endSec = resolveAnchor(seg.at[1], totalSec);
2661
- return { startMs: startSec * 1e3, endMs: endSec * 1e3, layers: seg.layers };
2751
+ return { startMs: startSec * 1e3, endMs: endSec * 1e3, layers: seg.layers, audio: seg.audio };
2662
2752
  });
2663
2753
  }
2664
2754
  function visualTimelineMs(resolved) {
@@ -2734,6 +2824,21 @@ async function buildScene(opts) {
2734
2824
  }
2735
2825
  ctx2d.restore();
2736
2826
  }
2827
+ function scheduleAudio(instrument, baseSec) {
2828
+ let fired = 0;
2829
+ for (const seg of resolved) {
2830
+ if (!seg.audio) continue;
2831
+ const segStartSec = baseSec + seg.startMs / 1e3;
2832
+ if (seg.audio.schedule?.length) {
2833
+ fired += applySchedule(instrument, seg.audio.schedule, segStartSec);
2834
+ }
2835
+ if (seg.audio.onEnter) {
2836
+ const segClock = { nowMs: () => seg.startMs };
2837
+ seg.audio.onEnter(segClock, { instrument, startSec: segStartSec, score });
2838
+ }
2839
+ }
2840
+ return fired;
2841
+ }
2737
2842
  return {
2738
2843
  W,
2739
2844
  H,
@@ -2741,6 +2846,7 @@ async function buildScene(opts) {
2741
2846
  durationMs,
2742
2847
  resolved,
2743
2848
  renderFrame,
2849
+ scheduleAudio,
2744
2850
  dispose() {
2745
2851
  for (const b of bound) b.layer.dispose?.();
2746
2852
  }
@@ -2751,6 +2857,13 @@ async function recordSceneSpec(opts) {
2751
2857
  const record = opts.record ?? recordScenes;
2752
2858
  const composite = {
2753
2859
  durationMs: built.durationMs,
2860
+ // Schedule any per-segment audio against the capture clock exactly when the
2861
+ // first frame is entered, so it's anchored to the same start the recorder
2862
+ // uses. onEnter fires once per Scene (see recordScenes), so this runs once.
2863
+ onEnter: opts.segmentAudio ? () => {
2864
+ const sa = opts.segmentAudio;
2865
+ built.scheduleAudio(sa.instrument, sa.audioNow() + (sa.lookaheadSec ?? 0.1));
2866
+ } : void 0,
2754
2867
  draw: (ctx, t01) => built.renderFrame(ctx, t01 * built.durationMs)
2755
2868
  };
2756
2869
  return record([composite], {
@@ -2763,96 +2876,6 @@ async function recordSceneSpec(opts) {
2763
2876
  });
2764
2877
  }
2765
2878
 
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
2879
  // src/scene/gate.ts
2857
2880
  var AV_TOLERANCE_MS = 60;
2858
2881
  async function runGate(input) {