@signalsandsorcery/plugin-sdk 2.28.1 → 2.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -30,6 +30,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ AUDIO_EFFECTS: () => AUDIO_EFFECTS,
34
+ AUDIO_EFFECT_LABEL: () => AUDIO_EFFECT_LABEL,
33
35
  ConfirmDialog: () => ConfirmDialog,
34
36
  CrossfadeModal: () => CrossfadeModal,
35
37
  CrossfadeTrackRow: () => CrossfadeTrackRow,
@@ -51,6 +53,7 @@ __export(index_exports, {
51
53
  FadeTrackRow: () => FadeTrackRow,
52
54
  FxToggleBar: () => FxToggleBar,
53
55
  GUTTER_W: () => GUTTER_W,
56
+ GeneratorPanelShell: () => GeneratorPanelShell,
54
57
  ImportTrackModal: () => ImportTrackModal,
55
58
  InstrumentDrawer: () => TrackDrawer,
56
59
  LevelMeter: () => LevelMeter,
@@ -68,44 +71,68 @@ __export(index_exports, {
68
71
  ScrollingWaveform: () => ScrollingWaveform,
69
72
  SorceryProgressBar: () => SorceryProgressBar,
70
73
  TEXTURAL_ROLES: () => TEXTURAL_ROLES,
74
+ TRANSITION_DESIGNER_DRAFT_KEY: () => TRANSITION_DESIGNER_DRAFT_KEY,
71
75
  TrackDrawer: () => TrackDrawer,
72
76
  TrackMeterStrip: () => TrackMeterStrip,
73
77
  TrackRow: () => TrackRow,
78
+ TransitionDesigner: () => TransitionDesigner,
74
79
  VolumeSlider: () => VolumeSlider,
75
80
  WaveformView: () => WaveformView,
76
81
  analyzeWavPeak: () => analyzeWavPeak,
82
+ asAudioEffect: () => asAudioEffect,
77
83
  asCrossfadeMeta: () => asCrossfadeMeta,
78
84
  asFadeMeta: () => asFadeMeta,
85
+ asTransitionDesignerDraft: () => asTransitionDesignerDraft,
79
86
  buildCrossfadeInpaintPrompt: () => buildCrossfadeInpaintPrompt,
80
87
  buildCrossfadeVolumeCurves: () => buildCrossfadeVolumeCurves,
81
88
  buildFadeVolumeCurve: () => buildFadeVolumeCurve,
89
+ buildRowSlots: () => buildRowSlots,
82
90
  calculateTimeBasedTarget: () => calculateTimeBasedTarget,
83
91
  cellToPx: () => cellToPx,
84
92
  centerScrollTop: () => centerScrollTop,
85
93
  computePeaks: () => computePeaks,
94
+ createSurgeSoundAdapter: () => createSurgeSoundAdapter,
95
+ dbIdsFromKeys: () => dbIdsFromKeys,
86
96
  dbToSlider: () => dbToSlider,
87
97
  defaultFadeGesture: () => defaultFadeGesture,
88
98
  drawWaveform: () => drawWaveform,
89
99
  formatConcurrentTracks: () => formatConcurrentTracks,
100
+ hashString: () => hashString,
90
101
  moveItem: () => moveItem,
102
+ newTrackState: () => newTrackState,
103
+ normalizeSlots: () => normalizeSlots,
104
+ padPair: () => padPair,
105
+ padSlots: () => padSlots,
91
106
  parseCrossfadePairs: () => parseCrossfadePairs,
92
107
  parseFades: () => parseFades,
108
+ parseLLMNoteResponse: () => parseLLMNoteResponse,
109
+ parseTrackGroups: () => parseTrackGroups,
93
110
  pickTopKWeighted: () => pickTopKWeighted,
94
111
  pitchToName: () => pitchToName,
112
+ pluginFxToToggleFx: () => pluginFxToToggleFx,
95
113
  pxToCell: () => pxToCell,
114
+ reconcileSlots: () => reconcileSlots,
96
115
  resizeNoteDuration: () => resizeNoteDuration,
116
+ resolveTrackGroups: () => resolveTrackGroups,
117
+ rowKey: () => rowKey,
118
+ rowType: () => rowType,
97
119
  scorePromptMatch: () => scorePromptMatch,
98
120
  sliderToDb: () => sliderToDb,
121
+ slotsEqual: () => slotsEqual,
122
+ soundIdentity: () => soundIdentity,
99
123
  synthesizeCuePoints: () => synthesizeCuePoints,
100
124
  tokenizePrompt: () => tokenizePrompt,
125
+ trackDataKey: () => trackDataKey,
101
126
  transposeNotes: () => transposeNotes,
102
127
  useAnySolo: () => useAnySolo,
128
+ useGeneratorPanelCore: () => useGeneratorPanelCore,
103
129
  useSceneState: () => useSceneState,
104
130
  useSoundHistory: () => useSoundHistory,
105
131
  useTrackLevel: () => useTrackLevel,
106
132
  useTrackLevels: () => useTrackLevels,
107
133
  useTrackMeter: () => useTrackMeter,
108
134
  useTrackReorder: () => useTrackReorder,
135
+ useTransitionOps: () => useTransitionOps,
109
136
  useTransportPlaying: () => useTransportPlaying
110
137
  });
111
138
  module.exports = __toCommonJS(index_exports);
@@ -1455,6 +1482,7 @@ var LevelMeter = ({
1455
1482
 
1456
1483
  // src/hooks/useTrackLevels.ts
1457
1484
  var import_react5 = require("react");
1485
+ var meterDiagRLast = /* @__PURE__ */ new Map();
1458
1486
  var POLL_INTERVAL_MS = 33;
1459
1487
  var HIDDEN_RECHECK_MS = 250;
1460
1488
  var METER_FLOOR_DB = -120;
@@ -1586,6 +1614,11 @@ function useTrackMeter(handle, trackId) {
1586
1614
  }
1587
1615
  const update = () => {
1588
1616
  const level = handle.getLevel(trackId);
1617
+ const dNow = Date.now();
1618
+ if ((meterDiagRLast.get(trackId) ?? 0) < dNow - 3e3) {
1619
+ meterDiagRLast.set(trackId, dNow);
1620
+ console.log(`[MeterDiagR] lookup trackId=${trackId} \u2192 ${level === null ? "MISS (no level for this id)" : "hit"}`);
1621
+ }
1589
1622
  const now = performance.now();
1590
1623
  const dtSec = lastTickRef.current ? Math.max(0, (now - lastTickRef.current) / 1e3) : 0;
1591
1624
  lastTickRef.current = now;
@@ -2287,8 +2320,7 @@ function TrackRow({
2287
2320
  {
2288
2321
  "data-testid": "sdk-mute-button",
2289
2322
  onClick: onMuteToggle,
2290
- disabled: isGenerating,
2291
- className: `px-1.5 py-0.5 text-xs font-bold rounded transition-colors ${isGenerating ? "bg-sas-panel text-sas-muted/50 cursor-not-allowed" : isMuted ? "bg-red-600 text-white" : "bg-sas-panel-alt text-sas-muted hover:bg-sas-border"}`,
2323
+ className: `px-1.5 py-0.5 text-xs font-bold rounded transition-colors ${isMuted ? "bg-red-600 text-white" : "bg-sas-panel-alt text-sas-muted hover:bg-sas-border"}`,
2292
2324
  title: isMuted ? "Unmute track" : "Mute track",
2293
2325
  children: "M"
2294
2326
  }
@@ -2539,6 +2571,18 @@ function CrossfadeTrackRow({
2539
2571
  }
2540
2572
 
2541
2573
  // src/crossfade-meta.ts
2574
+ function hashString(s) {
2575
+ let h = 5381;
2576
+ for (let i = 0; i < s.length; i++) h = (h << 5) + h ^ s.charCodeAt(i) | 0;
2577
+ return (h >>> 0).toString(36);
2578
+ }
2579
+ function soundIdentity(snap) {
2580
+ if (!snap) return "";
2581
+ if (snap.kind === "preset") return `p:${hashString(snap.state)}`;
2582
+ if (snap.kind === "sample") return `s:${snap.samplePath}`;
2583
+ if (snap.kind === "instrument") return `i:${snap.instrumentId ?? ""}:${hashString(JSON.stringify(snap.zones))}`;
2584
+ return "";
2585
+ }
2542
2586
  var EQUAL_POWER_GAIN = 0.707;
2543
2587
  function asCrossfadeMeta(val) {
2544
2588
  if (!val || typeof val !== "object") return null;
@@ -2688,9 +2732,11 @@ function asFadeMeta(val) {
2688
2732
  const m = val;
2689
2733
  if (m.direction !== "in" && m.direction !== "out") return null;
2690
2734
  if (m.gesture !== "volume" && m.gesture !== "build") return null;
2735
+ const effect = m.effect === "stutter" || m.effect === "chopped" || m.effect === "delay" || m.effect === "fade" ? m.effect : void 0;
2691
2736
  return {
2692
2737
  direction: m.direction,
2693
2738
  gesture: m.gesture,
2739
+ effect,
2694
2740
  sourceTrackDbId: typeof m.sourceTrackDbId === "string" ? m.sourceTrackDbId : "",
2695
2741
  sourceSceneId: typeof m.sourceSceneId === "string" ? m.sourceSceneId : "",
2696
2742
  sourceName: typeof m.sourceName === "string" ? m.sourceName : "",
@@ -2777,6 +2823,7 @@ function FadeTrackRow({
2777
2823
  layer,
2778
2824
  direction,
2779
2825
  gesture,
2826
+ effect,
2780
2827
  sliderPos = 0.5,
2781
2828
  onMuteToggle,
2782
2829
  onSoloToggle,
@@ -2790,7 +2837,8 @@ function FadeTrackRow({
2790
2837
  const [confirmDelete, setConfirmDelete] = import_react11.default.useState(false);
2791
2838
  const leftLabel = direction === "in" ? "(silent)" : layer.sourceName ?? layer.name;
2792
2839
  const rightLabel = direction === "in" ? layer.sourceName ?? layer.name : "(silent)";
2793
- const badge = direction === "in" ? "\u2197 Fade in" : "\u2198 Fade out";
2840
+ const verb = effect && effect !== "fade" ? effect.charAt(0).toUpperCase() + effect.slice(1) : "Fade";
2841
+ const badge = direction === "in" ? `\u2197 ${verb} in` : `\u2198 ${verb} out`;
2794
2842
  return /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)(
2795
2843
  "div",
2796
2844
  {
@@ -3525,880 +3573,3809 @@ function CrossfadeModal({
3525
3573
  ) });
3526
3574
  }
3527
3575
 
3528
- // src/components/DownloadPackButton.tsx
3576
+ // src/components/TransitionDesigner.tsx
3577
+ var import_react16 = require("react");
3578
+
3579
+ // src/hooks/useTrackReorder.ts
3529
3580
  var import_react15 = require("react");
3530
- var import_jsx_runtime17 = require("react/jsx-runtime");
3531
- function formatSize(bytes) {
3532
- if (!bytes || bytes <= 0) return "";
3533
- const gb = bytes / 1024 ** 3;
3534
- if (gb >= 1) return `${gb.toFixed(1)} GB`;
3535
- const mb = bytes / 1024 ** 2;
3536
- return `${Math.round(mb)} MB`;
3581
+ function moveItem(arr, from, to) {
3582
+ const next = arr.slice();
3583
+ if (from === to || from < 0 || to < 0 || from >= next.length || to >= next.length) {
3584
+ return next;
3585
+ }
3586
+ const [moved] = next.splice(from, 1);
3587
+ next.splice(to, 0, moved);
3588
+ return next;
3537
3589
  }
3538
- var DownloadPackButton = ({
3590
+ function useTrackReorder({
3539
3591
  host,
3540
- packId,
3541
- displayName,
3542
- sizeBytes,
3543
- variant = "compact",
3544
- onDownloadComplete
3545
- }) => {
3546
- const [status, setStatus] = (0, import_react15.useState)("idle");
3547
- const [progress, setProgress] = (0, import_react15.useState)(0);
3548
- const [errorMessage, setErrorMessage] = (0, import_react15.useState)(null);
3549
- (0, import_react15.useEffect)(() => {
3550
- const unsub = host.onSamplePackProgress(packId, (p) => {
3551
- setStatus(p.status);
3552
- setProgress(p.progress);
3553
- if (p.status === "error") {
3554
- setErrorMessage(p.message || "Download failed");
3555
- } else if (p.status === "complete") {
3556
- setErrorMessage(null);
3557
- setTimeout(() => onDownloadComplete?.(), 250);
3558
- } else {
3559
- setErrorMessage(null);
3560
- }
3561
- });
3562
- return unsub;
3563
- }, [host, packId, onDownloadComplete]);
3564
- const handleClick = (0, import_react15.useCallback)(async () => {
3565
- if (status !== "idle" && status !== "error") return;
3566
- try {
3567
- setStatus("downloading");
3568
- setProgress(0);
3569
- setErrorMessage(null);
3570
- const result = await host.startSamplePackDownload(packId);
3571
- if (!result.success) {
3572
- setStatus("error");
3573
- setErrorMessage(result.error || "Download failed");
3592
+ items,
3593
+ setItems,
3594
+ getId,
3595
+ onError
3596
+ }) {
3597
+ const [draggingIndex, setDraggingIndex] = (0, import_react15.useState)(null);
3598
+ const [dragOverIndex, setDragOverIndex] = (0, import_react15.useState)(null);
3599
+ const fromRef = (0, import_react15.useRef)(null);
3600
+ const itemsRef = (0, import_react15.useRef)(items);
3601
+ itemsRef.current = items;
3602
+ const dragPropsFor = (0, import_react15.useCallback)(
3603
+ (index) => ({
3604
+ handleProps: {
3605
+ draggable: true,
3606
+ onDragStart: (e) => {
3607
+ fromRef.current = index;
3608
+ setDraggingIndex(index);
3609
+ if (e.dataTransfer) {
3610
+ e.dataTransfer.effectAllowed = "move";
3611
+ try {
3612
+ e.dataTransfer.setData("text/plain", String(index));
3613
+ } catch {
3614
+ }
3615
+ }
3616
+ },
3617
+ onDragEnd: () => {
3618
+ fromRef.current = null;
3619
+ setDraggingIndex(null);
3620
+ setDragOverIndex(null);
3621
+ }
3622
+ },
3623
+ rowProps: {
3624
+ onDragEnter: (e) => {
3625
+ if (fromRef.current === null) return;
3626
+ e.preventDefault();
3627
+ setDragOverIndex(index);
3628
+ },
3629
+ onDragOver: (e) => {
3630
+ if (fromRef.current === null) return;
3631
+ e.preventDefault();
3632
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
3633
+ setDragOverIndex((cur) => cur === index ? cur : index);
3634
+ },
3635
+ onDragLeave: () => {
3636
+ setDragOverIndex((cur) => cur === index ? null : cur);
3637
+ },
3638
+ onDrop: (e) => {
3639
+ e.preventDefault();
3640
+ const from = fromRef.current;
3641
+ fromRef.current = null;
3642
+ setDraggingIndex(null);
3643
+ setDragOverIndex(null);
3644
+ if (from === null || from === index) return;
3645
+ const prev = itemsRef.current;
3646
+ const next = moveItem(prev, from, index);
3647
+ setItems(next);
3648
+ const ids = next.map(getId);
3649
+ Promise.resolve(host.reorderTracks(ids)).catch((err) => {
3650
+ setItems(prev);
3651
+ onError?.(err);
3652
+ });
3653
+ }
3654
+ },
3655
+ isDragging: draggingIndex === index,
3656
+ isDragTarget: dragOverIndex === index && draggingIndex !== index
3657
+ }),
3658
+ [host, setItems, getId, onError, draggingIndex, dragOverIndex]
3659
+ );
3660
+ return { dragPropsFor, draggingIndex, dragOverIndex };
3661
+ }
3662
+
3663
+ // src/transition-designer-meta.ts
3664
+ var TRANSITION_DESIGNER_DRAFT_KEY = "transitionDesigner:draft";
3665
+ var AUDIO_EFFECTS = ["fade", "stutter", "chopped", "delay"];
3666
+ var AUDIO_EFFECT_LABEL = {
3667
+ fade: "Fade",
3668
+ stutter: "Stutter",
3669
+ chopped: "Chopped",
3670
+ delay: "Delay"
3671
+ };
3672
+ function asAudioEffect(v) {
3673
+ return v === "fade" || v === "stutter" || v === "chopped" || v === "delay" ? v : null;
3674
+ }
3675
+ function rowType(hasOrigin, hasTarget) {
3676
+ if (hasOrigin && hasTarget) return "crossfade";
3677
+ if (hasOrigin) return "fade-out";
3678
+ if (hasTarget) return "fade-in";
3679
+ return null;
3680
+ }
3681
+ function asTransitionDesignerDraft(val) {
3682
+ if (!val || typeof val !== "object") return null;
3683
+ const d = val;
3684
+ const clean = (a) => Array.isArray(a) ? a.filter((x) => x === null || typeof x === "string") : [];
3685
+ const cleanEffects = (e) => {
3686
+ const out = {};
3687
+ if (e && typeof e === "object") {
3688
+ for (const [k, v] of Object.entries(e)) {
3689
+ const eff = asAudioEffect(v);
3690
+ if (eff) out[k] = eff;
3574
3691
  }
3575
- } catch (err) {
3576
- console.error("[DownloadPackButton] start failed:", err);
3577
- setStatus("error");
3578
- setErrorMessage(err instanceof Error ? err.message : String(err));
3579
3692
  }
3580
- }, [host, packId, status]);
3581
- const isWorking = status === "downloading" || status === "verifying" || status === "extracting" || status === "installing";
3582
- const isDisabled = isWorking || status === "complete";
3583
- const buttonLabel = (() => {
3584
- switch (status) {
3585
- case "downloading":
3586
- return `${progress}%`;
3587
- case "verifying":
3588
- return "Verifying...";
3589
- case "extracting":
3590
- return "Extracting...";
3591
- case "installing":
3592
- return "Installing...";
3593
- case "complete":
3594
- return "Done!";
3595
- case "error":
3596
- return "Retry";
3597
- default:
3598
- return variant === "large" ? `Download ${displayName}${sizeBytes ? ` (${formatSize(sizeBytes)})` : ""}` : "Download";
3693
+ return out;
3694
+ };
3695
+ return {
3696
+ originOrder: clean(d.originOrder),
3697
+ targetOrder: clean(d.targetOrder),
3698
+ rowEffects: cleanEffects(d.rowEffects)
3699
+ };
3700
+ }
3701
+ function reconcileSlots(saved, poolIds) {
3702
+ const pool = new Set(poolIds);
3703
+ const seen = /* @__PURE__ */ new Set();
3704
+ const out = [];
3705
+ for (const slot of saved ?? []) {
3706
+ if (slot === null) {
3707
+ out.push(null);
3708
+ continue;
3709
+ }
3710
+ if (pool.has(slot) && !seen.has(slot)) {
3711
+ out.push(slot);
3712
+ seen.add(slot);
3599
3713
  }
3600
- })();
3601
- const tooltip = (() => {
3602
- if (status === "error") return errorMessage || "Download failed. Click to retry.";
3603
- if (isWorking) return `${buttonLabel} \u2014 ${displayName}`;
3604
- if (status === "complete") return "Installation complete";
3605
- return `Download ${displayName}${sizeBytes ? ` (${formatSize(sizeBytes)})` : ""}`;
3606
- })();
3607
- const baseClasses = variant === "large" ? "px-4 py-2 text-sm font-medium rounded border transition-colors" : "px-2 py-0.5 text-[10px] uppercase tracking-wide rounded-sm border transition-colors";
3608
- let className;
3609
- if (status === "error") {
3610
- className = `${baseClasses} text-red-400 border-red-400/50 hover:text-red-300 hover:border-red-300`;
3611
- } else if (status === "complete") {
3612
- className = `${baseClasses} text-green-400 border-green-400/50`;
3613
- } else if (isDisabled) {
3614
- className = `${baseClasses} text-sas-accent border-sas-accent/50 cursor-wait`;
3615
- } else {
3616
- className = `${baseClasses} text-sas-muted hover:text-sas-accent border-sas-border hover:border-sas-accent`;
3617
- }
3618
- return /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { children: [
3619
- /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
3620
- "button",
3621
- {
3622
- "data-testid": `download-pack-button-${packId}`,
3623
- onClick: handleClick,
3624
- disabled: isDisabled,
3625
- className,
3626
- title: tooltip,
3627
- children: buttonLabel
3628
- }
3629
- ),
3630
- variant === "large" && status === "error" && errorMessage && /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { className: "text-xs text-sas-danger mt-2", "data-testid": `download-pack-error-${packId}`, children: errorMessage })
3631
- ] });
3632
- };
3633
-
3634
- // src/components/SamplePackCTACard.tsx
3635
- var import_jsx_runtime18 = require("react/jsx-runtime");
3636
- var SamplePackCTACard = ({
3637
- host,
3638
- pack,
3639
- status,
3640
- onDownloadComplete
3641
- }) => {
3642
- if (status === "checking") {
3643
- return /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
3644
- "div",
3645
- {
3646
- "data-testid": `sample-pack-cta-checking-${pack.packId}`,
3647
- className: "flex items-center justify-center py-16 text-sas-muted text-sm",
3648
- children: "Checking sample library..."
3649
- }
3650
- );
3651
3714
  }
3652
- const headline = status === "stale" ? `${pack.displayName} update available` : `${pack.displayName} not installed`;
3653
- const sublabel = status === "stale" ? `A newer version is available for download.` : pack.description;
3654
- return /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)(
3655
- "div",
3656
- {
3657
- "data-testid": `sample-pack-cta-${pack.packId}`,
3658
- className: "flex flex-col items-center justify-center py-12 px-6 text-center",
3659
- children: [
3660
- /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { className: "text-sm uppercase tracking-wide text-sas-muted mb-2", children: status === "stale" ? "Update available" : "Sample library not installed" }),
3661
- /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { className: "text-base text-sas-text mb-1", children: headline }),
3662
- /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { className: "text-xs text-sas-muted mb-6 max-w-md", children: sublabel }),
3663
- /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
3664
- DownloadPackButton,
3665
- {
3666
- host,
3667
- packId: pack.packId,
3668
- displayName: pack.displayName,
3669
- sizeBytes: pack.sizeBytes,
3670
- variant: "large",
3671
- onDownloadComplete
3672
- }
3673
- )
3674
- ]
3715
+ for (const id of poolIds) {
3716
+ if (!seen.has(id)) {
3717
+ out.push(id);
3718
+ seen.add(id);
3675
3719
  }
3720
+ }
3721
+ return out;
3722
+ }
3723
+ function buildRowSlots(originSlots, targetSlots) {
3724
+ const n = Math.max(originSlots.length, targetSlots.length);
3725
+ const rows = [];
3726
+ for (let i = 0; i < n; i++) {
3727
+ const originId = originSlots[i] ?? null;
3728
+ const targetId = targetSlots[i] ?? null;
3729
+ rows.push({ originId, targetId, type: rowType(originId !== null, targetId !== null) });
3730
+ }
3731
+ return rows;
3732
+ }
3733
+ function normalizeSlots(originSlots, targetSlots) {
3734
+ const rows = buildRowSlots(originSlots, targetSlots).filter(
3735
+ (r) => r.originId !== null || r.targetId !== null
3676
3736
  );
3677
- };
3678
-
3679
- // src/components/WaveformView.tsx
3680
- var import_react16 = require("react");
3681
-
3682
- // src/components/waveform.ts
3683
- function computePeaks(audioBuffer, bins, targetSamples) {
3684
- const { length, numberOfChannels, sampleRate } = audioBuffer;
3685
- const channels = [];
3686
- for (let c = 0; c < numberOfChannels; c++) {
3687
- channels.push(audioBuffer.getChannelData(c));
3737
+ const trimTrailing = (a) => {
3738
+ let end = a.length;
3739
+ while (end > 0 && a[end - 1] === null) end--;
3740
+ return a.slice(0, end);
3741
+ };
3742
+ return {
3743
+ originOrder: trimTrailing(rows.map((r) => r.originId)),
3744
+ targetOrder: trimTrailing(rows.map((r) => r.targetId))
3745
+ };
3746
+ }
3747
+ function padSlots(slots, n) {
3748
+ if (slots.length >= n) return slots.slice();
3749
+ return [...slots, ...new Array(n - slots.length).fill(null)];
3750
+ }
3751
+ function padPair(originSlots, targetSlots) {
3752
+ const n = Math.max(originSlots.length, targetSlots.length);
3753
+ return [padSlots(originSlots, n), padSlots(targetSlots, n)];
3754
+ }
3755
+ function slotsEqual(a, b) {
3756
+ if (a.length !== b.length) return false;
3757
+ for (let i = 0; i < a.length; i++) {
3758
+ if (a[i] !== b[i]) return false;
3688
3759
  }
3689
- const totalForBinning = typeof targetSamples === "number" && targetSamples > length ? targetSamples : length;
3690
- const samplesPerBin = Math.max(1, Math.floor(totalForBinning / bins));
3691
- const out = new Float32Array(bins * 2);
3692
- for (let i = 0; i < bins; i++) {
3693
- const startIdx = i * samplesPerBin;
3694
- const endIdx = Math.min(length, startIdx + samplesPerBin);
3695
- if (startIdx >= length) {
3696
- out[i * 2] = 0;
3697
- out[i * 2 + 1] = 0;
3698
- continue;
3760
+ return true;
3761
+ }
3762
+ function rowKey(row) {
3763
+ if (row.type === "crossfade") return `xf:${row.originId}|${row.targetId}`;
3764
+ if (row.type === "fade-out") return `fo:${row.originId}`;
3765
+ if (row.type === "fade-in") return `fi:${row.targetId}`;
3766
+ return null;
3767
+ }
3768
+ function dbIdsFromKeys(keys) {
3769
+ const out = /* @__PURE__ */ new Set();
3770
+ for (const k of keys) {
3771
+ const body = k.slice(3);
3772
+ if (k.startsWith("xf:")) {
3773
+ const sep = body.indexOf("|");
3774
+ out.add(body.slice(0, sep));
3775
+ out.add(body.slice(sep + 1));
3776
+ } else {
3777
+ out.add(body);
3699
3778
  }
3700
- let mn = Infinity;
3701
- let mx = -Infinity;
3702
- for (let j = startIdx; j < endIdx; j++) {
3703
- let v = 0;
3704
- for (let c = 0; c < numberOfChannels; c++) {
3705
- v += channels[c][j];
3706
- }
3707
- v /= numberOfChannels;
3708
- if (v < mn) mn = v;
3709
- if (v > mx) mx = v;
3710
- }
3711
- if (!Number.isFinite(mn)) mn = 0;
3712
- if (!Number.isFinite(mx)) mx = 0;
3713
- out[i * 2] = mn;
3714
- out[i * 2 + 1] = mx;
3715
- }
3716
- return { sampleRate, totalSamples: totalForBinning, peaks: out };
3717
- }
3718
- function drawWaveform(canvas, peaks, options = {}) {
3719
- const dpr = window.devicePixelRatio || 1;
3720
- const cssWidth = canvas.clientWidth;
3721
- const cssHeight = canvas.clientHeight;
3722
- if (cssWidth === 0 || cssHeight === 0) return;
3723
- canvas.width = Math.floor(cssWidth * dpr);
3724
- canvas.height = Math.floor(cssHeight * dpr);
3725
- const ctx = canvas.getContext("2d");
3726
- if (!ctx) return;
3727
- ctx.scale(dpr, dpr);
3728
- ctx.clearRect(0, 0, cssWidth, cssHeight);
3729
- ctx.fillStyle = options.fillStyle ?? "rgba(255, 255, 255, 0.4)";
3730
- const bins = peaks.peaks.length / 2;
3731
- const mid = cssHeight / 2;
3732
- for (let x = 0; x < cssWidth; x++) {
3733
- const binIdx = Math.floor(x / cssWidth * bins);
3734
- const mn = peaks.peaks[binIdx * 2];
3735
- const mx = peaks.peaks[binIdx * 2 + 1];
3736
- const yTop = mid - mx * mid;
3737
- const yBot = mid - mn * mid;
3738
- ctx.fillRect(x, yTop, 1, Math.max(1, yBot - yTop));
3739
3779
  }
3780
+ return out;
3740
3781
  }
3741
3782
 
3742
- // src/components/WaveformView.tsx
3743
- var import_jsx_runtime19 = require("react/jsx-runtime");
3744
- var WaveformView = ({
3783
+ // src/components/TransitionDesigner.tsx
3784
+ var import_jsx_runtime17 = require("react/jsx-runtime");
3785
+ var CROSSFADE_ESTIMATE_MS = 15e3;
3786
+ var FADE_ESTIMATE_MS = 11e3;
3787
+ var CREATE_ALL_CONCURRENCY = 5;
3788
+ var TYPE_LABEL = {
3789
+ crossfade: "Crossfade",
3790
+ "fade-out": "Fade out",
3791
+ "fade-in": "Fade in"
3792
+ };
3793
+ function shortId3(dbId) {
3794
+ return dbId.length > 8 ? dbId.slice(0, 8) : dbId;
3795
+ }
3796
+ function TransitionDesigner({
3745
3797
  host,
3746
- filePath,
3747
- bins = 256,
3748
- className,
3749
- fillStyle,
3750
- targetSamples
3751
- }) => {
3752
- const canvasRef = (0, import_react16.useRef)(null);
3753
- const [peaks, setPeaks] = (0, import_react16.useState)(null);
3798
+ fromSceneId,
3799
+ toSceneId,
3800
+ transitionSceneId,
3801
+ excludeSourceDbIds,
3802
+ onCreateCrossfade,
3803
+ onCreateFade,
3804
+ onCreateAudioTransition,
3805
+ familyLabel,
3806
+ testIdPrefix = "transition-designer"
3807
+ }) {
3808
+ const [load, setLoad] = (0, import_react16.useState)({ status: "loading" });
3809
+ const [fromName, setFromName] = (0, import_react16.useState)(null);
3810
+ const [toName, setToName] = (0, import_react16.useState)(null);
3811
+ const [originSlots, setOriginSlots] = (0, import_react16.useState)([]);
3812
+ const [targetSlots, setTargetSlots] = (0, import_react16.useState)([]);
3813
+ const [creatingKeys, setCreatingKeys] = (0, import_react16.useState)(() => /* @__PURE__ */ new Set());
3814
+ const [rowErrors, setRowErrors] = (0, import_react16.useState)({});
3815
+ const [rowEffects, setRowEffects] = (0, import_react16.useState)({});
3816
+ const rowEffectsRef = (0, import_react16.useRef)(rowEffects);
3817
+ rowEffectsRef.current = rowEffects;
3818
+ const audioEffectsEnabled = !!onCreateAudioTransition;
3819
+ const excludeRef = (0, import_react16.useRef)(excludeSourceDbIds);
3820
+ excludeRef.current = excludeSourceDbIds;
3821
+ const originSlotsRef = (0, import_react16.useRef)(originSlots);
3822
+ originSlotsRef.current = originSlots;
3823
+ const targetSlotsRef = (0, import_react16.useRef)(targetSlots);
3824
+ targetSlotsRef.current = targetSlots;
3825
+ const creatingKeysRef = (0, import_react16.useRef)(creatingKeys);
3826
+ creatingKeysRef.current = creatingKeys;
3827
+ const dragRef = (0, import_react16.useRef)(null);
3828
+ const [dragging, setDragging] = (0, import_react16.useState)(null);
3829
+ const [dragOver, setDragOver] = (0, import_react16.useState)(null);
3830
+ const excludeSet = (0, import_react16.useMemo)(() => new Set(excludeSourceDbIds ?? []), [excludeSourceDbIds]);
3831
+ const originPool = (0, import_react16.useMemo)(
3832
+ () => load.status === "ready" ? load.origin.filter((t) => !excludeSet.has(t.dbId)) : [],
3833
+ [load, excludeSet]
3834
+ );
3835
+ const targetPool = (0, import_react16.useMemo)(
3836
+ () => load.status === "ready" ? load.target.filter((t) => !excludeSet.has(t.dbId)) : [],
3837
+ [load, excludeSet]
3838
+ );
3839
+ const originById = (0, import_react16.useMemo)(() => new Map(originPool.map((t) => [t.dbId, t])), [originPool]);
3840
+ const targetById = (0, import_react16.useMemo)(() => new Map(targetPool.map((t) => [t.dbId, t])), [targetPool]);
3841
+ const originByIdRef = (0, import_react16.useRef)(originById);
3842
+ originByIdRef.current = originById;
3843
+ const targetByIdRef = (0, import_react16.useRef)(targetById);
3844
+ targetByIdRef.current = targetById;
3845
+ const refresh = (0, import_react16.useCallback)(async () => {
3846
+ if (!host.listSceneFamilyTracks) {
3847
+ setLoad({ status: "error", message: "This host does not support transition tracks." });
3848
+ return;
3849
+ }
3850
+ setLoad({ status: "loading" });
3851
+ try {
3852
+ const [origin, target, fName, tName, draftRaw] = await Promise.all([
3853
+ host.listSceneFamilyTracks(fromSceneId),
3854
+ host.listSceneFamilyTracks(toSceneId),
3855
+ host.getSceneName ? host.getSceneName(fromSceneId) : Promise.resolve(null),
3856
+ host.getSceneName ? host.getSceneName(toSceneId) : Promise.resolve(null),
3857
+ host.getSceneData ? host.getSceneData(transitionSceneId, TRANSITION_DESIGNER_DRAFT_KEY) : Promise.resolve(null)
3858
+ ]);
3859
+ const draft = asTransitionDesignerDraft(draftRaw);
3860
+ const exSet = new Set(excludeRef.current ?? []);
3861
+ const originIds = origin.filter((t) => !exSet.has(t.dbId)).map((t) => t.dbId);
3862
+ const targetIds = target.filter((t) => !exSet.has(t.dbId)).map((t) => t.dbId);
3863
+ const [po, pt] = padPair(
3864
+ reconcileSlots(draft?.originOrder, originIds),
3865
+ reconcileSlots(draft?.targetOrder, targetIds)
3866
+ );
3867
+ setOriginSlots(po);
3868
+ setTargetSlots(pt);
3869
+ setRowEffects(draft?.rowEffects ?? {});
3870
+ setFromName(fName);
3871
+ setToName(tName);
3872
+ setLoad({ status: "ready", origin, target });
3873
+ } catch (err) {
3874
+ setLoad({
3875
+ status: "error",
3876
+ message: err instanceof Error ? err.message : "Failed to load tracks."
3877
+ });
3878
+ }
3879
+ }, [host, fromSceneId, toSceneId, transitionSceneId]);
3754
3880
  (0, import_react16.useEffect)(() => {
3755
- let cancelled = false;
3756
- let audioContext = null;
3757
- (async () => {
3758
- try {
3759
- const bytes = await host.getAudioFileBytes(filePath);
3760
- if (cancelled) return;
3761
- const ContextCtor = window.AudioContext ?? window.webkitAudioContext;
3762
- audioContext = new ContextCtor();
3763
- const audioBuffer = await audioContext.decodeAudioData(bytes.slice(0));
3764
- if (cancelled) return;
3765
- const computed = computePeaks(audioBuffer, bins, targetSamples);
3766
- setPeaks(computed);
3767
- } catch (err) {
3768
- console.warn("[WaveformView] failed to decode", filePath, err);
3769
- } finally {
3770
- if (audioContext) {
3771
- audioContext.close().catch(() => {
3881
+ void refresh();
3882
+ }, [refresh]);
3883
+ (0, import_react16.useEffect)(() => {
3884
+ if (load.status !== "ready") return;
3885
+ const [po, pt] = padPair(
3886
+ reconcileSlots(originSlotsRef.current, originPool.map((t) => t.dbId)),
3887
+ reconcileSlots(targetSlotsRef.current, targetPool.map((t) => t.dbId))
3888
+ );
3889
+ if (!slotsEqual(po, originSlotsRef.current)) setOriginSlots(po);
3890
+ if (!slotsEqual(pt, targetSlotsRef.current)) setTargetSlots(pt);
3891
+ }, [originPool, targetPool, load.status]);
3892
+ const mutate = (0, import_react16.useCallback)(
3893
+ (nextOrigin, nextTarget) => {
3894
+ const norm = normalizeSlots(nextOrigin, nextTarget);
3895
+ const [po, pt] = padPair(norm.originOrder, norm.targetOrder);
3896
+ setOriginSlots(po);
3897
+ setTargetSlots(pt);
3898
+ if (host.setSceneData) {
3899
+ host.setSceneData(transitionSceneId, TRANSITION_DESIGNER_DRAFT_KEY, { ...norm, rowEffects: rowEffectsRef.current }).catch(() => {
3900
+ });
3901
+ }
3902
+ },
3903
+ [host, transitionSceneId]
3904
+ );
3905
+ const setRowEffect = (0, import_react16.useCallback)(
3906
+ (sourceDbId, effect) => {
3907
+ setRowEffects((prev) => {
3908
+ const next = { ...prev, [sourceDbId]: effect };
3909
+ if (host.setSceneData) {
3910
+ const norm = normalizeSlots(originSlotsRef.current, targetSlotsRef.current);
3911
+ host.setSceneData(transitionSceneId, TRANSITION_DESIGNER_DRAFT_KEY, { ...norm, rowEffects: next }).catch(() => {
3772
3912
  });
3773
3913
  }
3774
- }
3775
- })();
3776
- return () => {
3777
- cancelled = true;
3778
- };
3779
- }, [host, filePath, bins, targetSamples]);
3780
- (0, import_react16.useEffect)(() => {
3781
- if (!peaks) return;
3782
- const canvas = canvasRef.current;
3783
- if (!canvas) return;
3784
- drawWaveform(canvas, peaks, fillStyle ? { fillStyle } : void 0);
3785
- const observer = new ResizeObserver(() => {
3786
- drawWaveform(canvas, peaks, fillStyle ? { fillStyle } : void 0);
3787
- });
3788
- observer.observe(canvas);
3789
- return () => observer.disconnect();
3790
- }, [peaks, fillStyle]);
3791
- return /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
3792
- "canvas",
3793
- {
3794
- ref: canvasRef,
3795
- "data-testid": "waveform-view",
3796
- className: className ?? "w-full h-10"
3797
- }
3914
+ return next;
3915
+ });
3916
+ },
3917
+ [host, transitionSceneId]
3798
3918
  );
3799
- };
3800
-
3801
- // src/components/ScrollingWaveform.tsx
3802
- var import_react17 = require("react");
3803
- var import_jsx_runtime20 = require("react/jsx-runtime");
3804
- var ScrollingWaveform = ({
3805
- getPeakDb,
3806
- active,
3807
- columns = 256,
3808
- className,
3809
- fillStyle
3810
- }) => {
3811
- const canvasRef = (0, import_react17.useRef)(null);
3812
- const ringRef = (0, import_react17.useRef)(new Float32Array(columns));
3813
- const writeIdxRef = (0, import_react17.useRef)(0);
3814
- const rafRef = (0, import_react17.useRef)(null);
3815
- (0, import_react17.useEffect)(() => {
3816
- if (ringRef.current.length !== columns) {
3817
- const next = new Float32Array(columns);
3818
- const prev = ringRef.current;
3819
- const copyLen = Math.min(prev.length, columns);
3820
- for (let i = 0; i < copyLen; i++) {
3821
- next[i] = prev[i];
3822
- }
3823
- ringRef.current = next;
3824
- writeIdxRef.current = writeIdxRef.current % columns;
3825
- }
3826
- }, [columns]);
3827
- (0, import_react17.useEffect)(() => {
3828
- if (!active) {
3829
- if (rafRef.current !== null) {
3830
- cancelAnimationFrame(rafRef.current);
3831
- rafRef.current = null;
3832
- }
3833
- return;
3834
- }
3835
- const tick = () => {
3836
- const peakDb = getPeakDb();
3837
- const amp = peakDb <= -120 ? 0 : Math.max(0, Math.min(1, (peakDb + 60) / 60));
3838
- const ring = ringRef.current;
3839
- ring[writeIdxRef.current] = amp;
3840
- writeIdxRef.current = (writeIdxRef.current + 1) % ring.length;
3841
- const canvas = canvasRef.current;
3842
- if (canvas) {
3843
- const dpr = window.devicePixelRatio || 1;
3844
- const cssW = canvas.clientWidth;
3845
- const cssH = canvas.clientHeight;
3846
- if (cssW > 0 && cssH > 0) {
3847
- if (canvas.width !== Math.floor(cssW * dpr) || canvas.height !== Math.floor(cssH * dpr)) {
3848
- canvas.width = Math.floor(cssW * dpr);
3849
- canvas.height = Math.floor(cssH * dpr);
3919
+ const insertGapAbove = (0, import_react16.useCallback)(
3920
+ (col, index) => {
3921
+ const slots = col === "origin" ? originSlots : targetSlots;
3922
+ const next = [...slots.slice(0, index), null, ...slots.slice(index)];
3923
+ if (col === "origin") mutate(next, targetSlots);
3924
+ else mutate(originSlots, next);
3925
+ },
3926
+ [originSlots, targetSlots, mutate]
3927
+ );
3928
+ const removeGap = (0, import_react16.useCallback)(
3929
+ (col, index) => {
3930
+ const slots = col === "origin" ? originSlots : targetSlots;
3931
+ const next = slots.filter((_, i) => i !== index);
3932
+ if (col === "origin") mutate(next, targetSlots);
3933
+ else mutate(originSlots, next);
3934
+ },
3935
+ [originSlots, targetSlots, mutate]
3936
+ );
3937
+ const handleDrop = (0, import_react16.useCallback)(
3938
+ (col, to) => {
3939
+ const from = dragRef.current;
3940
+ dragRef.current = null;
3941
+ setDragging(null);
3942
+ setDragOver(null);
3943
+ if (!from || from.col !== col || from.index === to) return;
3944
+ if (col === "origin") mutate(moveItem(originSlots, from.index, to), targetSlots);
3945
+ else mutate(originSlots, moveItem(targetSlots, from.index, to));
3946
+ },
3947
+ [originSlots, targetSlots, mutate]
3948
+ );
3949
+ const rows = (0, import_react16.useMemo)(() => buildRowSlots(originSlots, targetSlots), [originSlots, targetSlots]);
3950
+ const creatingDbIds = (0, import_react16.useMemo)(() => dbIdsFromKeys(creatingKeys), [creatingKeys]);
3951
+ const eligibleCount = (0, import_react16.useMemo)(
3952
+ () => rows.filter((r) => {
3953
+ const k = rowKey(r);
3954
+ return k !== null && !creatingKeys.has(k);
3955
+ }).length,
3956
+ [rows, creatingKeys]
3957
+ );
3958
+ const createRow = (0, import_react16.useCallback)(
3959
+ async (row) => {
3960
+ const key = rowKey(row);
3961
+ if (!key || !row.type || creatingKeysRef.current.has(key)) return;
3962
+ setCreatingKeys((prev) => new Set(prev).add(key));
3963
+ setRowErrors((prev) => {
3964
+ if (!(key in prev)) return prev;
3965
+ const next = { ...prev };
3966
+ delete next[key];
3967
+ return next;
3968
+ });
3969
+ try {
3970
+ if (row.type === "crossfade") {
3971
+ const o = row.originId ? originByIdRef.current.get(row.originId) : void 0;
3972
+ const t = row.targetId ? targetByIdRef.current.get(row.targetId) : void 0;
3973
+ if (!o || !t) throw new Error("Track is no longer available \u2014 refresh and retry.");
3974
+ await onCreateCrossfade(
3975
+ { dbId: o.dbId, name: o.name, role: o.role },
3976
+ { dbId: t.dbId, name: t.name, role: t.role }
3977
+ );
3978
+ } else if (row.type === "fade-out") {
3979
+ const o = row.originId ? originByIdRef.current.get(row.originId) : void 0;
3980
+ if (!o) throw new Error("Track is no longer available \u2014 refresh and retry.");
3981
+ const eff = rowEffectsRef.current[o.dbId] ?? "fade";
3982
+ if (eff !== "fade" && onCreateAudioTransition) {
3983
+ await onCreateAudioTransition({ dbId: o.dbId, name: o.name, role: o.role }, "out", eff);
3984
+ } else {
3985
+ await onCreateFade({ dbId: o.dbId, name: o.name, role: o.role }, "out", defaultFadeGesture(o.role));
3850
3986
  }
3851
- const ctx = canvas.getContext("2d");
3852
- if (ctx) {
3853
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
3854
- ctx.clearRect(0, 0, cssW, cssH);
3855
- ctx.fillStyle = fillStyle ?? "#6af2c5";
3856
- const mid = cssH / 2;
3857
- const cols = ring.length;
3858
- const colW = cssW / cols;
3859
- const start = writeIdxRef.current;
3860
- for (let x = 0; x < cols; x++) {
3861
- const ringIdx = (start + x) % cols;
3862
- const a = ring[ringIdx];
3863
- const half = a * mid;
3864
- ctx.fillRect(x * colW, mid - half, Math.max(1, colW), Math.max(1, half * 2));
3865
- }
3987
+ } else {
3988
+ const t = row.targetId ? targetByIdRef.current.get(row.targetId) : void 0;
3989
+ if (!t) throw new Error("Track is no longer available \u2014 refresh and retry.");
3990
+ const eff = rowEffectsRef.current[t.dbId] ?? "fade";
3991
+ if (eff !== "fade" && onCreateAudioTransition) {
3992
+ await onCreateAudioTransition({ dbId: t.dbId, name: t.name, role: t.role }, "in", eff);
3993
+ } else {
3994
+ await onCreateFade({ dbId: t.dbId, name: t.name, role: t.role }, "in", defaultFadeGesture(t.role));
3866
3995
  }
3867
3996
  }
3997
+ } catch (err) {
3998
+ setRowErrors((prev) => ({
3999
+ ...prev,
4000
+ [key]: err instanceof Error ? err.message : "Failed to create transition."
4001
+ }));
4002
+ } finally {
4003
+ setCreatingKeys((prev) => {
4004
+ const next = new Set(prev);
4005
+ next.delete(key);
4006
+ return next;
4007
+ });
3868
4008
  }
3869
- rafRef.current = requestAnimationFrame(tick);
3870
- };
3871
- rafRef.current = requestAnimationFrame(tick);
3872
- return () => {
3873
- if (rafRef.current !== null) {
3874
- cancelAnimationFrame(rafRef.current);
3875
- rafRef.current = null;
4009
+ },
4010
+ [onCreateCrossfade, onCreateFade, onCreateAudioTransition]
4011
+ );
4012
+ const createAll = (0, import_react16.useCallback)(async () => {
4013
+ const eligible = buildRowSlots(originSlotsRef.current, targetSlotsRef.current).filter((r) => {
4014
+ const k = rowKey(r);
4015
+ return k !== null && !creatingKeysRef.current.has(k);
4016
+ });
4017
+ if (eligible.length === 0) return;
4018
+ let cursor = 0;
4019
+ const worker = async () => {
4020
+ while (cursor < eligible.length) {
4021
+ const row = eligible[cursor];
4022
+ cursor += 1;
4023
+ await createRow(row);
3876
4024
  }
3877
4025
  };
3878
- }, [active, getPeakDb, fillStyle]);
3879
- return /* @__PURE__ */ (0, import_jsx_runtime20.jsx)(
3880
- "canvas",
3881
- {
3882
- ref: canvasRef,
3883
- "data-testid": "scrolling-waveform",
3884
- className: className ?? "w-full h-12"
3885
- }
3886
- );
3887
- };
3888
-
3889
- // src/components/OffsetScrubber.tsx
3890
- var import_react18 = require("react");
3891
- var import_jsx_runtime21 = require("react/jsx-runtime");
3892
- var SLIDER_HEIGHT_PX = 28;
3893
- var TICK_HEIGHT_PX = 14;
3894
- var DOWNBEAT_TICK_HEIGHT_PX = 22;
3895
- var THUMB_WIDTH_PX = 4;
3896
- function OffsetScrubber({
3897
- cuePoints,
3898
- offsetSamples,
3899
- projectBpm,
3900
- meter = 4,
3901
- onChange,
3902
- disabled = false
3903
- }) {
3904
- const trackRef = (0, import_react18.useRef)(null);
3905
- const [draftOffset, setDraftOffset] = (0, import_react18.useState)(offsetSamples);
3906
- const [isDragging, setIsDragging] = (0, import_react18.useState)(false);
3907
- (0, import_react18.useEffect)(() => {
3908
- if (!isDragging) setDraftOffset(offsetSamples);
3909
- }, [offsetSamples, isDragging]);
3910
- const sampleRate = cuePoints?.sample_rate ?? 44100;
3911
- const detectedBpm = cuePoints?.detected_bpm ?? projectBpm;
3912
- const beatsForRange = (0, import_react18.useMemo)(() => {
3913
- return Math.round(60 / projectBpm * sampleRate);
3914
- }, [projectBpm, sampleRate]);
3915
- const rangeSamples = beatsForRange * meter;
3916
- const sampleToFraction = (0, import_react18.useCallback)(
3917
- (sample) => {
3918
- const clamped = Math.max(-rangeSamples, Math.min(rangeSamples, sample));
3919
- return (clamped + rangeSamples) / (2 * rangeSamples);
4026
+ await Promise.all(
4027
+ Array.from({ length: Math.min(CREATE_ALL_CONCURRENCY, eligible.length) }, () => worker())
4028
+ );
4029
+ }, [createRow]);
4030
+ const fromLabel = fromName ?? "origin";
4031
+ const toLabel = toName ?? "target";
4032
+ const cellDragProps = (col, index, locked) => ({
4033
+ draggable: !locked,
4034
+ onDragStart: (e) => {
4035
+ if (locked) return;
4036
+ dragRef.current = { col, index };
4037
+ setDragging({ col, index });
4038
+ if (e.dataTransfer) {
4039
+ e.dataTransfer.effectAllowed = "move";
4040
+ try {
4041
+ e.dataTransfer.setData("text/plain", String(index));
4042
+ } catch {
4043
+ }
4044
+ }
3920
4045
  },
3921
- [rangeSamples]
3922
- );
3923
- const fractionToSample = (0, import_react18.useCallback)(
3924
- (fraction) => {
3925
- const clamped = Math.max(0, Math.min(1, fraction));
3926
- return Math.round(clamped * 2 * rangeSamples - rangeSamples);
4046
+ onDragEnd: () => {
4047
+ dragRef.current = null;
4048
+ setDragging(null);
4049
+ setDragOver(null);
3927
4050
  },
3928
- [rangeSamples]
3929
- );
3930
- const snapTargets = (0, import_react18.useMemo)(() => {
3931
- if (!cuePoints || cuePoints.beats.length === 0) return [];
3932
- const downbeat = cuePoints.beats[0];
3933
- const positives = cuePoints.beats.map((b) => b - downbeat);
3934
- const negatives = positives.slice(1).map((p) => -p);
3935
- return [...negatives, ...positives].sort((a, b) => a - b);
3936
- }, [cuePoints]);
3937
- const snapToBeat = (0, import_react18.useCallback)(
3938
- (sample) => {
3939
- if (snapTargets.length === 0) return sample;
3940
- let best = snapTargets[0];
3941
- let bestDist = Math.abs(sample - best);
3942
- for (const t of snapTargets) {
3943
- const d = Math.abs(sample - t);
3944
- if (d < bestDist) {
3945
- best = t;
3946
- bestDist = d;
3947
- }
3948
- }
3949
- return best;
4051
+ onDragEnter: (e) => {
4052
+ const d = dragRef.current;
4053
+ if (!d || d.col !== col) return;
4054
+ e.preventDefault();
4055
+ setDragOver({ col, index });
3950
4056
  },
3951
- [snapTargets]
3952
- );
3953
- const handlePointerDown = (0, import_react18.useCallback)(
3954
- (e) => {
3955
- if (disabled || !cuePoints) return;
4057
+ onDragOver: (e) => {
4058
+ const d = dragRef.current;
4059
+ if (!d || d.col !== col) return;
3956
4060
  e.preventDefault();
3957
- const track = trackRef.current;
3958
- if (!track) return;
3959
- track.setPointerCapture(e.pointerId);
3960
- setIsDragging(true);
3961
- const updateFromEvent = (clientX, shiftHeld) => {
3962
- const rect = track.getBoundingClientRect();
3963
- const fraction = (clientX - rect.left) / rect.width;
3964
- const raw = fractionToSample(fraction);
3965
- return shiftHeld ? raw : snapToBeat(raw);
3966
- };
3967
- setDraftOffset(updateFromEvent(e.clientX, e.shiftKey));
3968
- const onMove = (ev) => {
3969
- setDraftOffset(updateFromEvent(ev.clientX, ev.shiftKey));
3970
- };
3971
- const onUp = (ev) => {
3972
- const final = updateFromEvent(ev.clientX, ev.shiftKey);
3973
- track.releasePointerCapture(e.pointerId);
3974
- track.removeEventListener("pointermove", onMove);
3975
- track.removeEventListener("pointerup", onUp);
3976
- track.removeEventListener("pointercancel", onUp);
3977
- setIsDragging(false);
3978
- setDraftOffset(final);
3979
- onChange(final);
3980
- };
3981
- track.addEventListener("pointermove", onMove);
3982
- track.addEventListener("pointerup", onUp);
3983
- track.addEventListener("pointercancel", onUp);
4061
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
3984
4062
  },
3985
- [disabled, cuePoints, fractionToSample, onChange, snapToBeat]
3986
- );
3987
- const handleResetToZero = (0, import_react18.useCallback)(() => {
3988
- if (disabled) return;
3989
- setDraftOffset(0);
3990
- onChange(0);
3991
- }, [disabled, onChange]);
3992
- const thumbFraction = sampleToFraction(draftOffset);
3993
- const thumbLeftPct = `${(thumbFraction * 100).toFixed(2)}%`;
3994
- const bpmMismatch = cuePoints?.detected_bpm != null && Math.abs(cuePoints.detected_bpm - projectBpm) > 1;
3995
- const ticks = (0, import_react18.useMemo)(() => {
3996
- if (!cuePoints) return [];
3997
- const downbeat = cuePoints.beats[0] ?? 0;
3998
- return cuePoints.beats.map((b, i) => {
3999
- const offsetCandidate = b - downbeat;
4000
- const fraction = sampleToFraction(offsetCandidate);
4001
- const isDownbeat = i === 0;
4002
- return { i, fraction, isDownbeat };
4003
- });
4004
- }, [cuePoints, sampleToFraction]);
4005
- const isDisabled = disabled || !cuePoints || cuePoints.beats.length === 0;
4006
- return /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("div", { "data-testid": "offset-scrubber", className: "flex items-center gap-2 w-full", children: [
4007
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("span", { className: "text-[9px] text-sas-muted/60 uppercase tracking-wide flex-shrink-0", children: "Align" }),
4008
- /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)(
4063
+ onDragLeave: () => {
4064
+ setDragOver((cur) => cur && cur.col === col && cur.index === index ? null : cur);
4065
+ },
4066
+ onDrop: (e) => {
4067
+ e.preventDefault();
4068
+ handleDrop(col, index);
4069
+ }
4070
+ });
4071
+ const renderCell = (col, index, slotId) => {
4072
+ const byId = col === "origin" ? originById : targetById;
4073
+ const track = slotId ? byId.get(slotId) : void 0;
4074
+ const locked = slotId !== null && creatingDbIds.has(slotId);
4075
+ const isDragging = dragging?.col === col && dragging.index === index;
4076
+ const isDragTarget = dragOver?.col === col && dragOver.index === index && !isDragging;
4077
+ const base = "group relative rounded-sm border px-2 py-1.5 text-left transition-colors select-none";
4078
+ const tone = isDragTarget ? "border-sas-accent bg-sas-accent/10" : "border-sas-border bg-sas-panel";
4079
+ if (slotId === null) {
4080
+ return /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)(
4081
+ "div",
4082
+ {
4083
+ ...cellDragProps(col, index, false),
4084
+ "data-testid": `${testIdPrefix}-${col}-gap-${index}`,
4085
+ className: `${base} ${tone} border-dashed flex items-center justify-between ${isDragging ? "opacity-40" : "opacity-70"}`,
4086
+ children: [
4087
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: "\u2014 gap \u2014" }),
4088
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
4089
+ "button",
4090
+ {
4091
+ type: "button",
4092
+ "data-testid": `${testIdPrefix}-${col}-remove-gap-${index}`,
4093
+ onClick: () => removeGap(col, index),
4094
+ title: "Remove gap",
4095
+ className: "text-[10px] text-sas-muted hover:text-sas-danger",
4096
+ children: "\u2715"
4097
+ }
4098
+ )
4099
+ ]
4100
+ }
4101
+ );
4102
+ }
4103
+ const primary = track ? track.prompt?.trim() || track.name : slotId;
4104
+ const meta = track ? [track.role, shortId3(track.dbId)].filter(Boolean).join(" \xB7 ") : "missing";
4105
+ return /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
4009
4106
  "div",
4010
4107
  {
4011
- ref: trackRef,
4012
- "data-testid": "offset-scrubber-track",
4013
- onPointerDown: handlePointerDown,
4014
- className: `relative flex-1 min-w-0 rounded-sm select-none ${isDisabled ? "bg-sas-panel cursor-not-allowed opacity-40" : "bg-sas-bg cursor-pointer"}`,
4015
- style: { height: SLIDER_HEIGHT_PX },
4016
- title: isDisabled ? "Generate audio first to enable offset alignment" : "Drag to align beat 1. Hold Shift for free, no-snap movement.",
4017
- role: "slider",
4018
- "aria-label": "Audio offset alignment",
4019
- "aria-valuemin": -rangeSamples,
4020
- "aria-valuemax": rangeSamples,
4021
- "aria-valuenow": draftOffset,
4022
- "aria-disabled": isDisabled,
4023
- children: [
4024
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
4025
- "div",
4026
- {
4027
- "aria-hidden": "true",
4028
- className: "absolute top-0 bottom-0 w-px bg-sas-accent/40",
4029
- style: { left: "50%" }
4030
- }
4031
- ),
4032
- ticks.map((t) => /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
4033
- "div",
4034
- {
4035
- "data-testid": t.isDownbeat ? "offset-tick-downbeat" : "offset-tick",
4036
- "aria-hidden": "true",
4037
- className: t.isDownbeat ? "absolute bg-sas-accent" : "absolute bg-sas-muted/50",
4038
- style: {
4039
- left: `${(t.fraction * 100).toFixed(2)}%`,
4040
- top: (SLIDER_HEIGHT_PX - (t.isDownbeat ? DOWNBEAT_TICK_HEIGHT_PX : TICK_HEIGHT_PX)) / 2,
4041
- width: 1,
4042
- height: t.isDownbeat ? DOWNBEAT_TICK_HEIGHT_PX : TICK_HEIGHT_PX
4043
- }
4044
- },
4045
- t.i
4046
- )),
4047
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
4048
- "div",
4108
+ ...cellDragProps(col, index, locked),
4109
+ "data-testid": `${testIdPrefix}-${col}-cell-${slotId}`,
4110
+ "data-value": slotId,
4111
+ className: `${base} ${tone} ${isDragging ? "opacity-40" : ""} ${locked ? "opacity-60" : "cursor-grab active:cursor-grabbing"}`,
4112
+ title: track ? track.dbId : "Track no longer available",
4113
+ children: /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "flex items-start gap-1", children: [
4114
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("span", { className: "text-sas-muted/60 text-xs leading-tight pt-0.5", "aria-hidden": true, children: "\u283F" }),
4115
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "min-w-0 flex-1", children: [
4116
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { className: "text-xs text-sas-text truncate", children: primary }),
4117
+ meta && /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { className: "text-[10px] text-sas-muted truncate mt-0.5", children: meta })
4118
+ ] }),
4119
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
4120
+ "button",
4049
4121
  {
4050
- "data-testid": "offset-scrubber-thumb",
4051
- "aria-hidden": "true",
4052
- className: `absolute top-0 bottom-0 rounded-sm ${isDragging ? "bg-sas-accent" : "bg-sas-accent/80"}`,
4053
- style: {
4054
- left: thumbLeftPct,
4055
- width: THUMB_WIDTH_PX,
4056
- transform: "translateX(-50%)",
4057
- pointerEvents: "none"
4058
- }
4122
+ type: "button",
4123
+ "data-testid": `${testIdPrefix}-${col}-insert-gap-${index}`,
4124
+ onClick: () => insertGapAbove(col, index),
4125
+ disabled: locked,
4126
+ title: "Insert a gap above (make this a fade)",
4127
+ className: "text-[10px] text-sas-muted opacity-0 group-hover:opacity-100 hover:text-sas-accent disabled:opacity-30",
4128
+ children: "+gap"
4059
4129
  }
4060
4130
  )
4061
- ]
4062
- }
4063
- ),
4064
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
4065
- "span",
4066
- {
4067
- "data-testid": "offset-scrubber-readout",
4068
- className: "text-[10px] text-sas-muted/70 tabular-nums flex-shrink-0 min-w-[64px] text-right",
4069
- children: formatOffset(draftOffset, sampleRate)
4070
- }
4071
- ),
4072
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
4073
- "button",
4074
- {
4075
- type: "button",
4076
- "data-testid": "offset-scrubber-reset",
4077
- onClick: handleResetToZero,
4078
- disabled: isDisabled || draftOffset === 0,
4079
- className: `text-[10px] px-1 py-0.5 rounded-sm border transition-colors flex-shrink-0 ${isDisabled || draftOffset === 0 ? "border-sas-border text-sas-muted/30 cursor-not-allowed" : "border-sas-border text-sas-muted/70 hover:border-sas-accent hover:text-sas-accent"}`,
4080
- title: "Reset offset to 0 (bar 1)",
4081
- children: "\u2316"
4082
- }
4083
- ),
4084
- bpmMismatch && /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
4085
- "span",
4086
- {
4087
- "data-testid": "offset-bpm-mismatch",
4088
- className: "text-[9px] px-1 py-0.5 rounded-sm bg-amber-500/15 text-amber-400 border border-amber-500/30 flex-shrink-0",
4089
- title: `Detected ${detectedBpm.toFixed(1)} BPM \u2014 beats may not align with project ${projectBpm} BPM grid`,
4090
- children: "BPM \u2260"
4131
+ ] })
4091
4132
  }
4092
- )
4133
+ );
4134
+ };
4135
+ return /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "space-y-2", "data-testid": `${testIdPrefix}-box`, children: [
4136
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "flex items-center justify-between gap-3 pb-1 border-b border-sas-border", children: [
4137
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("p", { className: "text-[11px] text-sas-muted leading-snug min-w-0", children: [
4138
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("span", { className: "text-sas-text", children: fromLabel }),
4139
+ " \u2192",
4140
+ " ",
4141
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("span", { className: "text-sas-text", children: toLabel }),
4142
+ familyLabel ? ` \xB7 ${familyLabel}` : "",
4143
+ " \xB7 line up a track on each side to crossfade; leave one blank (or insert a gap) to fade."
4144
+ ] }),
4145
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "flex items-center gap-2 shrink-0", children: [
4146
+ creatingKeys.size > 0 && /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("span", { className: "text-[10px] text-sas-accent whitespace-nowrap", "data-testid": `${testIdPrefix}-creating-count`, children: [
4147
+ creatingKeys.size,
4148
+ " creating\u2026"
4149
+ ] }),
4150
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)(
4151
+ "button",
4152
+ {
4153
+ type: "button",
4154
+ "data-testid": `${testIdPrefix}-create-all`,
4155
+ onClick: createAll,
4156
+ disabled: eligibleCount === 0,
4157
+ title: "Create every staged transition at once (runs several concurrently)",
4158
+ className: `px-2 py-0.5 text-[10px] font-medium rounded-sm border transition-colors whitespace-nowrap ${eligibleCount > 0 ? "bg-sas-accent/20 border-sas-accent text-sas-accent hover:bg-sas-accent hover:text-sas-bg" : "bg-sas-panel border-sas-border text-sas-muted/50 cursor-not-allowed"}`,
4159
+ children: [
4160
+ "Create all",
4161
+ eligibleCount > 0 ? ` (${eligibleCount})` : ""
4162
+ ]
4163
+ }
4164
+ )
4165
+ ] })
4166
+ ] }),
4167
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "grid grid-cols-[1fr_auto_1fr] gap-2", children: [
4168
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted truncate", children: [
4169
+ "Origin (",
4170
+ fromLabel,
4171
+ ")"
4172
+ ] }),
4173
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted text-center px-2", children: "Transition" }),
4174
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted truncate text-right", children: [
4175
+ "Target (",
4176
+ toLabel,
4177
+ ")"
4178
+ ] })
4179
+ ] }),
4180
+ load.status === "loading" && /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { className: "text-xs text-sas-muted py-6 text-center", children: "Loading tracks\u2026" }),
4181
+ load.status === "error" && /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { className: "text-xs text-sas-danger py-6 text-center", "data-testid": `${testIdPrefix}-error`, children: load.message }),
4182
+ load.status === "ready" && (rows.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "text-xs text-sas-muted py-6 text-center", "data-testid": `${testIdPrefix}-empty`, children: [
4183
+ "No tracks to arrange in this panel for either scene. Add tracks to ",
4184
+ fromLabel,
4185
+ " or ",
4186
+ toLabel,
4187
+ " ",
4188
+ "first (or free one by deleting an existing crossfade/fade)."
4189
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { className: "space-y-2", children: rows.map((row, i) => {
4190
+ const key = rowKey(row);
4191
+ const isCreatingThis = key !== null && creatingKeys.has(key);
4192
+ const errMsg = key !== null ? rowErrors[key] : void 0;
4193
+ return /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)(
4194
+ "div",
4195
+ {
4196
+ "data-testid": `${testIdPrefix}-row-${i}`,
4197
+ className: "grid grid-cols-[1fr_auto_1fr] gap-2 items-center",
4198
+ children: [
4199
+ renderCell("origin", i, row.originId),
4200
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "w-[160px] flex flex-col items-center gap-1", children: [
4201
+ !row.type ? /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("span", { className: "text-[10px] text-sas-muted/50", children: "\u2014" }) : row.type === "crossfade" ? /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
4202
+ "span",
4203
+ {
4204
+ "data-testid": `${testIdPrefix}-type-${i}`,
4205
+ className: "text-[10px] font-medium px-1.5 py-0.5 rounded-sm border border-sas-accent/50 text-sas-accent",
4206
+ children: TYPE_LABEL[row.type]
4207
+ }
4208
+ ) : audioEffectsEnabled ? /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "flex items-center gap-1", "data-testid": `${testIdPrefix}-type-${i}`, children: [
4209
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
4210
+ "select",
4211
+ {
4212
+ "data-testid": `${testIdPrefix}-effect-${i}`,
4213
+ value: rowEffects[row.originId ?? row.targetId] ?? "fade",
4214
+ onChange: (e) => {
4215
+ const id = row.originId ?? row.targetId;
4216
+ if (id) setRowEffect(id, e.target.value);
4217
+ },
4218
+ className: "text-[10px] bg-sas-panel border border-sas-border rounded-sm px-1 py-0.5 text-sas-text",
4219
+ children: AUDIO_EFFECTS.map((eff) => /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("option", { value: eff, children: AUDIO_EFFECT_LABEL[eff] }, eff))
4220
+ }
4221
+ ),
4222
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("span", { className: "text-[9px] text-sas-muted", children: row.type === "fade-out" ? "out" : "in" })
4223
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
4224
+ "span",
4225
+ {
4226
+ "data-testid": `${testIdPrefix}-type-${i}`,
4227
+ className: "text-[10px] font-medium px-1.5 py-0.5 rounded-sm border border-sas-border text-sas-muted",
4228
+ children: TYPE_LABEL[row.type]
4229
+ }
4230
+ ),
4231
+ isCreatingThis ? /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("div", { className: "w-full", children: /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
4232
+ SorceryProgressBar,
4233
+ {
4234
+ isLoading: true,
4235
+ heightClass: "h-5",
4236
+ statusText: "CREATING",
4237
+ estimatedDurationMs: row.type === "crossfade" ? CROSSFADE_ESTIMATE_MS : FADE_ESTIMATE_MS
4238
+ }
4239
+ ) }) : /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
4240
+ "button",
4241
+ {
4242
+ type: "button",
4243
+ "data-testid": `${testIdPrefix}-create-${i}`,
4244
+ onClick: () => createRow(row),
4245
+ disabled: !row.type,
4246
+ className: `w-full px-2 py-0.5 text-[10px] font-medium rounded-sm border transition-colors ${row.type ? "bg-sas-accent/20 border-sas-accent text-sas-accent hover:bg-sas-accent hover:text-sas-bg" : "bg-sas-panel border-sas-border text-sas-muted/50 cursor-not-allowed"}`,
4247
+ children: "Create"
4248
+ }
4249
+ ),
4250
+ errMsg && /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
4251
+ "span",
4252
+ {
4253
+ "data-testid": `${testIdPrefix}-row-error-${i}`,
4254
+ className: "text-[10px] text-sas-danger text-center leading-tight",
4255
+ children: errMsg
4256
+ }
4257
+ )
4258
+ ] }),
4259
+ renderCell("target", i, row.targetId)
4260
+ ]
4261
+ },
4262
+ i
4263
+ );
4264
+ }) }))
4093
4265
  ] });
4094
4266
  }
4095
- function formatOffset(samples, sampleRate) {
4096
- const sign = samples > 0 ? "+" : samples < 0 ? "-" : "";
4097
- const abs = Math.abs(samples);
4098
- const ms = Math.round(abs / sampleRate * 1e3);
4099
- return `${sign}${abs} spl (${sign}${ms} ms)`;
4100
- }
4101
4267
 
4102
- // src/components/wavPeakAnalyzer.ts
4103
- var CLIP_THRESHOLD_LINEAR = 0.891;
4104
- async function analyzeWavPeak(host, filePath) {
4105
- const bytes = await host.getAudioFileBytes(filePath);
4106
- const ContextCtor = window.AudioContext ?? window.webkitAudioContext;
4107
- const audioContext = new ContextCtor();
4108
- try {
4109
- const audioBuffer = await audioContext.decodeAudioData(bytes.slice(0));
4110
- let peak = 0;
4111
- for (let c = 0; c < audioBuffer.numberOfChannels; c++) {
4112
- const data = audioBuffer.getChannelData(c);
4113
- for (let i = 0; i < data.length; i++) {
4114
- const a = Math.abs(data[i]);
4115
- if (a > peak) peak = a;
4116
- }
4117
- }
4118
- const peakDb = peak > 1e-6 ? 20 * Math.log10(peak) : -120;
4119
- return {
4120
- peakLinear: peak,
4121
- peakDb,
4122
- clipped: peak >= CLIP_THRESHOLD_LINEAR - 5e-3
4123
- };
4124
- } finally {
4125
- await audioContext.close().catch(() => {
4126
- });
4127
- }
4268
+ // src/components/DownloadPackButton.tsx
4269
+ var import_react17 = require("react");
4270
+ var import_jsx_runtime18 = require("react/jsx-runtime");
4271
+ function formatSize(bytes) {
4272
+ if (!bytes || bytes <= 0) return "";
4273
+ const gb = bytes / 1024 ** 3;
4274
+ if (gb >= 1) return `${gb.toFixed(1)} GB`;
4275
+ const mb = bytes / 1024 ** 2;
4276
+ return `${Math.round(mb)} MB`;
4128
4277
  }
4129
-
4130
- // src/components/synthesizeCuePoints.ts
4131
- function synthesizeCuePoints({
4132
- bpm,
4133
- sampleRate,
4134
- bars,
4135
- meter = 4
4136
- }) {
4137
- const safeBpm = bpm > 0 ? bpm : 120;
4138
- const safeSampleRate = sampleRate > 0 ? sampleRate : 48e3;
4139
- const samplesPerBeat = Math.round(60 / safeBpm * safeSampleRate);
4140
- const totalBeats = Math.max(1, Math.round(bars * meter));
4278
+ var DownloadPackButton = ({
4279
+ host,
4280
+ packId,
4281
+ displayName,
4282
+ sizeBytes,
4283
+ variant = "compact",
4284
+ onDownloadComplete
4285
+ }) => {
4286
+ const [status, setStatus] = (0, import_react17.useState)("idle");
4287
+ const [progress, setProgress] = (0, import_react17.useState)(0);
4288
+ const [errorMessage, setErrorMessage] = (0, import_react17.useState)(null);
4289
+ (0, import_react17.useEffect)(() => {
4290
+ const unsub = host.onSamplePackProgress(packId, (p) => {
4291
+ setStatus(p.status);
4292
+ setProgress(p.progress);
4293
+ if (p.status === "error") {
4294
+ setErrorMessage(p.message || "Download failed");
4295
+ } else if (p.status === "complete") {
4296
+ setErrorMessage(null);
4297
+ setTimeout(() => onDownloadComplete?.(), 250);
4298
+ } else {
4299
+ setErrorMessage(null);
4300
+ }
4301
+ });
4302
+ return unsub;
4303
+ }, [host, packId, onDownloadComplete]);
4304
+ const handleClick = (0, import_react17.useCallback)(async () => {
4305
+ if (status !== "idle" && status !== "error") return;
4306
+ try {
4307
+ setStatus("downloading");
4308
+ setProgress(0);
4309
+ setErrorMessage(null);
4310
+ const result = await host.startSamplePackDownload(packId);
4311
+ if (!result.success) {
4312
+ setStatus("error");
4313
+ setErrorMessage(result.error || "Download failed");
4314
+ }
4315
+ } catch (err) {
4316
+ console.error("[DownloadPackButton] start failed:", err);
4317
+ setStatus("error");
4318
+ setErrorMessage(err instanceof Error ? err.message : String(err));
4319
+ }
4320
+ }, [host, packId, status]);
4321
+ const isWorking = status === "downloading" || status === "verifying" || status === "extracting" || status === "installing";
4322
+ const isDisabled = isWorking || status === "complete";
4323
+ const buttonLabel = (() => {
4324
+ switch (status) {
4325
+ case "downloading":
4326
+ return `${progress}%`;
4327
+ case "verifying":
4328
+ return "Verifying...";
4329
+ case "extracting":
4330
+ return "Extracting...";
4331
+ case "installing":
4332
+ return "Installing...";
4333
+ case "complete":
4334
+ return "Done!";
4335
+ case "error":
4336
+ return "Retry";
4337
+ default:
4338
+ return variant === "large" ? `Download ${displayName}${sizeBytes ? ` (${formatSize(sizeBytes)})` : ""}` : "Download";
4339
+ }
4340
+ })();
4341
+ const tooltip = (() => {
4342
+ if (status === "error") return errorMessage || "Download failed. Click to retry.";
4343
+ if (isWorking) return `${buttonLabel} \u2014 ${displayName}`;
4344
+ if (status === "complete") return "Installation complete";
4345
+ return `Download ${displayName}${sizeBytes ? ` (${formatSize(sizeBytes)})` : ""}`;
4346
+ })();
4347
+ const baseClasses = variant === "large" ? "px-4 py-2 text-sm font-medium rounded border transition-colors" : "px-2 py-0.5 text-[10px] uppercase tracking-wide rounded-sm border transition-colors";
4348
+ let className;
4349
+ if (status === "error") {
4350
+ className = `${baseClasses} text-red-400 border-red-400/50 hover:text-red-300 hover:border-red-300`;
4351
+ } else if (status === "complete") {
4352
+ className = `${baseClasses} text-green-400 border-green-400/50`;
4353
+ } else if (isDisabled) {
4354
+ className = `${baseClasses} text-sas-accent border-sas-accent/50 cursor-wait`;
4355
+ } else {
4356
+ className = `${baseClasses} text-sas-muted hover:text-sas-accent border-sas-border hover:border-sas-accent`;
4357
+ }
4358
+ return /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { children: [
4359
+ /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
4360
+ "button",
4361
+ {
4362
+ "data-testid": `download-pack-button-${packId}`,
4363
+ onClick: handleClick,
4364
+ disabled: isDisabled,
4365
+ className,
4366
+ title: tooltip,
4367
+ children: buttonLabel
4368
+ }
4369
+ ),
4370
+ variant === "large" && status === "error" && errorMessage && /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("div", { className: "text-xs text-sas-danger mt-2", "data-testid": `download-pack-error-${packId}`, children: errorMessage })
4371
+ ] });
4372
+ };
4373
+
4374
+ // src/components/SamplePackCTACard.tsx
4375
+ var import_jsx_runtime19 = require("react/jsx-runtime");
4376
+ var SamplePackCTACard = ({
4377
+ host,
4378
+ pack,
4379
+ status,
4380
+ onDownloadComplete
4381
+ }) => {
4382
+ if (status === "checking") {
4383
+ return /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
4384
+ "div",
4385
+ {
4386
+ "data-testid": `sample-pack-cta-checking-${pack.packId}`,
4387
+ className: "flex items-center justify-center py-16 text-sas-muted text-sm",
4388
+ children: "Checking sample library..."
4389
+ }
4390
+ );
4391
+ }
4392
+ const headline = status === "stale" ? `${pack.displayName} update available` : `${pack.displayName} not installed`;
4393
+ const sublabel = status === "stale" ? `A newer version is available for download.` : pack.description;
4394
+ return /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)(
4395
+ "div",
4396
+ {
4397
+ "data-testid": `sample-pack-cta-${pack.packId}`,
4398
+ className: "flex flex-col items-center justify-center py-12 px-6 text-center",
4399
+ children: [
4400
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("div", { className: "text-sm uppercase tracking-wide text-sas-muted mb-2", children: status === "stale" ? "Update available" : "Sample library not installed" }),
4401
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("div", { className: "text-base text-sas-text mb-1", children: headline }),
4402
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("div", { className: "text-xs text-sas-muted mb-6 max-w-md", children: sublabel }),
4403
+ /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
4404
+ DownloadPackButton,
4405
+ {
4406
+ host,
4407
+ packId: pack.packId,
4408
+ displayName: pack.displayName,
4409
+ sizeBytes: pack.sizeBytes,
4410
+ variant: "large",
4411
+ onDownloadComplete
4412
+ }
4413
+ )
4414
+ ]
4415
+ }
4416
+ );
4417
+ };
4418
+
4419
+ // src/components/WaveformView.tsx
4420
+ var import_react18 = require("react");
4421
+
4422
+ // src/components/waveform.ts
4423
+ function computePeaks(audioBuffer, bins, targetSamples) {
4424
+ const { length, numberOfChannels, sampleRate } = audioBuffer;
4425
+ const channels = [];
4426
+ for (let c = 0; c < numberOfChannels; c++) {
4427
+ channels.push(audioBuffer.getChannelData(c));
4428
+ }
4429
+ const totalForBinning = typeof targetSamples === "number" && targetSamples > length ? targetSamples : length;
4430
+ const samplesPerBin = Math.max(1, Math.floor(totalForBinning / bins));
4431
+ const out = new Float32Array(bins * 2);
4432
+ for (let i = 0; i < bins; i++) {
4433
+ const startIdx = i * samplesPerBin;
4434
+ const endIdx = Math.min(length, startIdx + samplesPerBin);
4435
+ if (startIdx >= length) {
4436
+ out[i * 2] = 0;
4437
+ out[i * 2 + 1] = 0;
4438
+ continue;
4439
+ }
4440
+ let mn = Infinity;
4441
+ let mx = -Infinity;
4442
+ for (let j = startIdx; j < endIdx; j++) {
4443
+ let v = 0;
4444
+ for (let c = 0; c < numberOfChannels; c++) {
4445
+ v += channels[c][j];
4446
+ }
4447
+ v /= numberOfChannels;
4448
+ if (v < mn) mn = v;
4449
+ if (v > mx) mx = v;
4450
+ }
4451
+ if (!Number.isFinite(mn)) mn = 0;
4452
+ if (!Number.isFinite(mx)) mx = 0;
4453
+ out[i * 2] = mn;
4454
+ out[i * 2 + 1] = mx;
4455
+ }
4456
+ return { sampleRate, totalSamples: totalForBinning, peaks: out };
4457
+ }
4458
+ function drawWaveform(canvas, peaks, options = {}) {
4459
+ const dpr = window.devicePixelRatio || 1;
4460
+ const cssWidth = canvas.clientWidth;
4461
+ const cssHeight = canvas.clientHeight;
4462
+ if (cssWidth === 0 || cssHeight === 0) return;
4463
+ canvas.width = Math.floor(cssWidth * dpr);
4464
+ canvas.height = Math.floor(cssHeight * dpr);
4465
+ const ctx = canvas.getContext("2d");
4466
+ if (!ctx) return;
4467
+ ctx.scale(dpr, dpr);
4468
+ ctx.clearRect(0, 0, cssWidth, cssHeight);
4469
+ ctx.fillStyle = options.fillStyle ?? "rgba(255, 255, 255, 0.4)";
4470
+ const bins = peaks.peaks.length / 2;
4471
+ const mid = cssHeight / 2;
4472
+ for (let x = 0; x < cssWidth; x++) {
4473
+ const binIdx = Math.floor(x / cssWidth * bins);
4474
+ const mn = peaks.peaks[binIdx * 2];
4475
+ const mx = peaks.peaks[binIdx * 2 + 1];
4476
+ const yTop = mid - mx * mid;
4477
+ const yBot = mid - mn * mid;
4478
+ ctx.fillRect(x, yTop, 1, Math.max(1, yBot - yTop));
4479
+ }
4480
+ }
4481
+
4482
+ // src/components/WaveformView.tsx
4483
+ var import_jsx_runtime20 = require("react/jsx-runtime");
4484
+ var WaveformView = ({
4485
+ host,
4486
+ filePath,
4487
+ bins = 256,
4488
+ className,
4489
+ fillStyle,
4490
+ targetSamples
4491
+ }) => {
4492
+ const canvasRef = (0, import_react18.useRef)(null);
4493
+ const [peaks, setPeaks] = (0, import_react18.useState)(null);
4494
+ (0, import_react18.useEffect)(() => {
4495
+ let cancelled = false;
4496
+ let audioContext = null;
4497
+ (async () => {
4498
+ try {
4499
+ const bytes = await host.getAudioFileBytes(filePath);
4500
+ if (cancelled) return;
4501
+ const ContextCtor = window.AudioContext ?? window.webkitAudioContext;
4502
+ audioContext = new ContextCtor();
4503
+ const audioBuffer = await audioContext.decodeAudioData(bytes.slice(0));
4504
+ if (cancelled) return;
4505
+ const computed = computePeaks(audioBuffer, bins, targetSamples);
4506
+ setPeaks(computed);
4507
+ } catch (err) {
4508
+ console.warn("[WaveformView] failed to decode", filePath, err);
4509
+ } finally {
4510
+ if (audioContext) {
4511
+ audioContext.close().catch(() => {
4512
+ });
4513
+ }
4514
+ }
4515
+ })();
4516
+ return () => {
4517
+ cancelled = true;
4518
+ };
4519
+ }, [host, filePath, bins, targetSamples]);
4520
+ (0, import_react18.useEffect)(() => {
4521
+ if (!peaks) return;
4522
+ const canvas = canvasRef.current;
4523
+ if (!canvas) return;
4524
+ drawWaveform(canvas, peaks, fillStyle ? { fillStyle } : void 0);
4525
+ const observer = new ResizeObserver(() => {
4526
+ drawWaveform(canvas, peaks, fillStyle ? { fillStyle } : void 0);
4527
+ });
4528
+ observer.observe(canvas);
4529
+ return () => observer.disconnect();
4530
+ }, [peaks, fillStyle]);
4531
+ return /* @__PURE__ */ (0, import_jsx_runtime20.jsx)(
4532
+ "canvas",
4533
+ {
4534
+ ref: canvasRef,
4535
+ "data-testid": "waveform-view",
4536
+ className: className ?? "w-full h-10"
4537
+ }
4538
+ );
4539
+ };
4540
+
4541
+ // src/components/ScrollingWaveform.tsx
4542
+ var import_react19 = require("react");
4543
+ var import_jsx_runtime21 = require("react/jsx-runtime");
4544
+ var ScrollingWaveform = ({
4545
+ getPeakDb,
4546
+ active,
4547
+ columns = 256,
4548
+ className,
4549
+ fillStyle
4550
+ }) => {
4551
+ const canvasRef = (0, import_react19.useRef)(null);
4552
+ const ringRef = (0, import_react19.useRef)(new Float32Array(columns));
4553
+ const writeIdxRef = (0, import_react19.useRef)(0);
4554
+ const rafRef = (0, import_react19.useRef)(null);
4555
+ (0, import_react19.useEffect)(() => {
4556
+ if (ringRef.current.length !== columns) {
4557
+ const next = new Float32Array(columns);
4558
+ const prev = ringRef.current;
4559
+ const copyLen = Math.min(prev.length, columns);
4560
+ for (let i = 0; i < copyLen; i++) {
4561
+ next[i] = prev[i];
4562
+ }
4563
+ ringRef.current = next;
4564
+ writeIdxRef.current = writeIdxRef.current % columns;
4565
+ }
4566
+ }, [columns]);
4567
+ (0, import_react19.useEffect)(() => {
4568
+ if (!active) {
4569
+ if (rafRef.current !== null) {
4570
+ cancelAnimationFrame(rafRef.current);
4571
+ rafRef.current = null;
4572
+ }
4573
+ return;
4574
+ }
4575
+ const tick = () => {
4576
+ const peakDb = getPeakDb();
4577
+ const amp = peakDb <= -120 ? 0 : Math.max(0, Math.min(1, (peakDb + 60) / 60));
4578
+ const ring = ringRef.current;
4579
+ ring[writeIdxRef.current] = amp;
4580
+ writeIdxRef.current = (writeIdxRef.current + 1) % ring.length;
4581
+ const canvas = canvasRef.current;
4582
+ if (canvas) {
4583
+ const dpr = window.devicePixelRatio || 1;
4584
+ const cssW = canvas.clientWidth;
4585
+ const cssH = canvas.clientHeight;
4586
+ if (cssW > 0 && cssH > 0) {
4587
+ if (canvas.width !== Math.floor(cssW * dpr) || canvas.height !== Math.floor(cssH * dpr)) {
4588
+ canvas.width = Math.floor(cssW * dpr);
4589
+ canvas.height = Math.floor(cssH * dpr);
4590
+ }
4591
+ const ctx = canvas.getContext("2d");
4592
+ if (ctx) {
4593
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
4594
+ ctx.clearRect(0, 0, cssW, cssH);
4595
+ ctx.fillStyle = fillStyle ?? "#6af2c5";
4596
+ const mid = cssH / 2;
4597
+ const cols = ring.length;
4598
+ const colW = cssW / cols;
4599
+ const start = writeIdxRef.current;
4600
+ for (let x = 0; x < cols; x++) {
4601
+ const ringIdx = (start + x) % cols;
4602
+ const a = ring[ringIdx];
4603
+ const half = a * mid;
4604
+ ctx.fillRect(x * colW, mid - half, Math.max(1, colW), Math.max(1, half * 2));
4605
+ }
4606
+ }
4607
+ }
4608
+ }
4609
+ rafRef.current = requestAnimationFrame(tick);
4610
+ };
4611
+ rafRef.current = requestAnimationFrame(tick);
4612
+ return () => {
4613
+ if (rafRef.current !== null) {
4614
+ cancelAnimationFrame(rafRef.current);
4615
+ rafRef.current = null;
4616
+ }
4617
+ };
4618
+ }, [active, getPeakDb, fillStyle]);
4619
+ return /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
4620
+ "canvas",
4621
+ {
4622
+ ref: canvasRef,
4623
+ "data-testid": "scrolling-waveform",
4624
+ className: className ?? "w-full h-12"
4625
+ }
4626
+ );
4627
+ };
4628
+
4629
+ // src/components/OffsetScrubber.tsx
4630
+ var import_react20 = require("react");
4631
+ var import_jsx_runtime22 = require("react/jsx-runtime");
4632
+ var SLIDER_HEIGHT_PX = 28;
4633
+ var TICK_HEIGHT_PX = 14;
4634
+ var DOWNBEAT_TICK_HEIGHT_PX = 22;
4635
+ var THUMB_WIDTH_PX = 4;
4636
+ function OffsetScrubber({
4637
+ cuePoints,
4638
+ offsetSamples,
4639
+ projectBpm,
4640
+ meter = 4,
4641
+ onChange,
4642
+ disabled = false
4643
+ }) {
4644
+ const trackRef = (0, import_react20.useRef)(null);
4645
+ const [draftOffset, setDraftOffset] = (0, import_react20.useState)(offsetSamples);
4646
+ const [isDragging, setIsDragging] = (0, import_react20.useState)(false);
4647
+ (0, import_react20.useEffect)(() => {
4648
+ if (!isDragging) setDraftOffset(offsetSamples);
4649
+ }, [offsetSamples, isDragging]);
4650
+ const sampleRate = cuePoints?.sample_rate ?? 44100;
4651
+ const detectedBpm = cuePoints?.detected_bpm ?? projectBpm;
4652
+ const beatsForRange = (0, import_react20.useMemo)(() => {
4653
+ return Math.round(60 / projectBpm * sampleRate);
4654
+ }, [projectBpm, sampleRate]);
4655
+ const rangeSamples = beatsForRange * meter;
4656
+ const sampleToFraction = (0, import_react20.useCallback)(
4657
+ (sample) => {
4658
+ const clamped = Math.max(-rangeSamples, Math.min(rangeSamples, sample));
4659
+ return (clamped + rangeSamples) / (2 * rangeSamples);
4660
+ },
4661
+ [rangeSamples]
4662
+ );
4663
+ const fractionToSample = (0, import_react20.useCallback)(
4664
+ (fraction) => {
4665
+ const clamped = Math.max(0, Math.min(1, fraction));
4666
+ return Math.round(clamped * 2 * rangeSamples - rangeSamples);
4667
+ },
4668
+ [rangeSamples]
4669
+ );
4670
+ const snapTargets = (0, import_react20.useMemo)(() => {
4671
+ if (!cuePoints || cuePoints.beats.length === 0) return [];
4672
+ const downbeat = cuePoints.beats[0];
4673
+ const positives = cuePoints.beats.map((b) => b - downbeat);
4674
+ const negatives = positives.slice(1).map((p) => -p);
4675
+ return [...negatives, ...positives].sort((a, b) => a - b);
4676
+ }, [cuePoints]);
4677
+ const snapToBeat = (0, import_react20.useCallback)(
4678
+ (sample) => {
4679
+ if (snapTargets.length === 0) return sample;
4680
+ let best = snapTargets[0];
4681
+ let bestDist = Math.abs(sample - best);
4682
+ for (const t of snapTargets) {
4683
+ const d = Math.abs(sample - t);
4684
+ if (d < bestDist) {
4685
+ best = t;
4686
+ bestDist = d;
4687
+ }
4688
+ }
4689
+ return best;
4690
+ },
4691
+ [snapTargets]
4692
+ );
4693
+ const handlePointerDown = (0, import_react20.useCallback)(
4694
+ (e) => {
4695
+ if (disabled || !cuePoints) return;
4696
+ e.preventDefault();
4697
+ const track = trackRef.current;
4698
+ if (!track) return;
4699
+ track.setPointerCapture(e.pointerId);
4700
+ setIsDragging(true);
4701
+ const updateFromEvent = (clientX, shiftHeld) => {
4702
+ const rect = track.getBoundingClientRect();
4703
+ const fraction = (clientX - rect.left) / rect.width;
4704
+ const raw = fractionToSample(fraction);
4705
+ return shiftHeld ? raw : snapToBeat(raw);
4706
+ };
4707
+ setDraftOffset(updateFromEvent(e.clientX, e.shiftKey));
4708
+ const onMove = (ev) => {
4709
+ setDraftOffset(updateFromEvent(ev.clientX, ev.shiftKey));
4710
+ };
4711
+ const onUp = (ev) => {
4712
+ const final = updateFromEvent(ev.clientX, ev.shiftKey);
4713
+ track.releasePointerCapture(e.pointerId);
4714
+ track.removeEventListener("pointermove", onMove);
4715
+ track.removeEventListener("pointerup", onUp);
4716
+ track.removeEventListener("pointercancel", onUp);
4717
+ setIsDragging(false);
4718
+ setDraftOffset(final);
4719
+ onChange(final);
4720
+ };
4721
+ track.addEventListener("pointermove", onMove);
4722
+ track.addEventListener("pointerup", onUp);
4723
+ track.addEventListener("pointercancel", onUp);
4724
+ },
4725
+ [disabled, cuePoints, fractionToSample, onChange, snapToBeat]
4726
+ );
4727
+ const handleResetToZero = (0, import_react20.useCallback)(() => {
4728
+ if (disabled) return;
4729
+ setDraftOffset(0);
4730
+ onChange(0);
4731
+ }, [disabled, onChange]);
4732
+ const thumbFraction = sampleToFraction(draftOffset);
4733
+ const thumbLeftPct = `${(thumbFraction * 100).toFixed(2)}%`;
4734
+ const bpmMismatch = cuePoints?.detected_bpm != null && Math.abs(cuePoints.detected_bpm - projectBpm) > 1;
4735
+ const ticks = (0, import_react20.useMemo)(() => {
4736
+ if (!cuePoints) return [];
4737
+ const downbeat = cuePoints.beats[0] ?? 0;
4738
+ return cuePoints.beats.map((b, i) => {
4739
+ const offsetCandidate = b - downbeat;
4740
+ const fraction = sampleToFraction(offsetCandidate);
4741
+ const isDownbeat = i === 0;
4742
+ return { i, fraction, isDownbeat };
4743
+ });
4744
+ }, [cuePoints, sampleToFraction]);
4745
+ const isDisabled = disabled || !cuePoints || cuePoints.beats.length === 0;
4746
+ return /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)("div", { "data-testid": "offset-scrubber", className: "flex items-center gap-2 w-full", children: [
4747
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)("span", { className: "text-[9px] text-sas-muted/60 uppercase tracking-wide flex-shrink-0", children: "Align" }),
4748
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)(
4749
+ "div",
4750
+ {
4751
+ ref: trackRef,
4752
+ "data-testid": "offset-scrubber-track",
4753
+ onPointerDown: handlePointerDown,
4754
+ className: `relative flex-1 min-w-0 rounded-sm select-none ${isDisabled ? "bg-sas-panel cursor-not-allowed opacity-40" : "bg-sas-bg cursor-pointer"}`,
4755
+ style: { height: SLIDER_HEIGHT_PX },
4756
+ title: isDisabled ? "Generate audio first to enable offset alignment" : "Drag to align beat 1. Hold Shift for free, no-snap movement.",
4757
+ role: "slider",
4758
+ "aria-label": "Audio offset alignment",
4759
+ "aria-valuemin": -rangeSamples,
4760
+ "aria-valuemax": rangeSamples,
4761
+ "aria-valuenow": draftOffset,
4762
+ "aria-disabled": isDisabled,
4763
+ children: [
4764
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
4765
+ "div",
4766
+ {
4767
+ "aria-hidden": "true",
4768
+ className: "absolute top-0 bottom-0 w-px bg-sas-accent/40",
4769
+ style: { left: "50%" }
4770
+ }
4771
+ ),
4772
+ ticks.map((t) => /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
4773
+ "div",
4774
+ {
4775
+ "data-testid": t.isDownbeat ? "offset-tick-downbeat" : "offset-tick",
4776
+ "aria-hidden": "true",
4777
+ className: t.isDownbeat ? "absolute bg-sas-accent" : "absolute bg-sas-muted/50",
4778
+ style: {
4779
+ left: `${(t.fraction * 100).toFixed(2)}%`,
4780
+ top: (SLIDER_HEIGHT_PX - (t.isDownbeat ? DOWNBEAT_TICK_HEIGHT_PX : TICK_HEIGHT_PX)) / 2,
4781
+ width: 1,
4782
+ height: t.isDownbeat ? DOWNBEAT_TICK_HEIGHT_PX : TICK_HEIGHT_PX
4783
+ }
4784
+ },
4785
+ t.i
4786
+ )),
4787
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
4788
+ "div",
4789
+ {
4790
+ "data-testid": "offset-scrubber-thumb",
4791
+ "aria-hidden": "true",
4792
+ className: `absolute top-0 bottom-0 rounded-sm ${isDragging ? "bg-sas-accent" : "bg-sas-accent/80"}`,
4793
+ style: {
4794
+ left: thumbLeftPct,
4795
+ width: THUMB_WIDTH_PX,
4796
+ transform: "translateX(-50%)",
4797
+ pointerEvents: "none"
4798
+ }
4799
+ }
4800
+ )
4801
+ ]
4802
+ }
4803
+ ),
4804
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
4805
+ "span",
4806
+ {
4807
+ "data-testid": "offset-scrubber-readout",
4808
+ className: "text-[10px] text-sas-muted/70 tabular-nums flex-shrink-0 min-w-[64px] text-right",
4809
+ children: formatOffset(draftOffset, sampleRate)
4810
+ }
4811
+ ),
4812
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
4813
+ "button",
4814
+ {
4815
+ type: "button",
4816
+ "data-testid": "offset-scrubber-reset",
4817
+ onClick: handleResetToZero,
4818
+ disabled: isDisabled || draftOffset === 0,
4819
+ className: `text-[10px] px-1 py-0.5 rounded-sm border transition-colors flex-shrink-0 ${isDisabled || draftOffset === 0 ? "border-sas-border text-sas-muted/30 cursor-not-allowed" : "border-sas-border text-sas-muted/70 hover:border-sas-accent hover:text-sas-accent"}`,
4820
+ title: "Reset offset to 0 (bar 1)",
4821
+ children: "\u2316"
4822
+ }
4823
+ ),
4824
+ bpmMismatch && /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
4825
+ "span",
4826
+ {
4827
+ "data-testid": "offset-bpm-mismatch",
4828
+ className: "text-[9px] px-1 py-0.5 rounded-sm bg-amber-500/15 text-amber-400 border border-amber-500/30 flex-shrink-0",
4829
+ title: `Detected ${detectedBpm.toFixed(1)} BPM \u2014 beats may not align with project ${projectBpm} BPM grid`,
4830
+ children: "BPM \u2260"
4831
+ }
4832
+ )
4833
+ ] });
4834
+ }
4835
+ function formatOffset(samples, sampleRate) {
4836
+ const sign = samples > 0 ? "+" : samples < 0 ? "-" : "";
4837
+ const abs = Math.abs(samples);
4838
+ const ms = Math.round(abs / sampleRate * 1e3);
4839
+ return `${sign}${abs} spl (${sign}${ms} ms)`;
4840
+ }
4841
+
4842
+ // src/components/wavPeakAnalyzer.ts
4843
+ var CLIP_THRESHOLD_LINEAR = 0.891;
4844
+ async function analyzeWavPeak(host, filePath) {
4845
+ const bytes = await host.getAudioFileBytes(filePath);
4846
+ const ContextCtor = window.AudioContext ?? window.webkitAudioContext;
4847
+ const audioContext = new ContextCtor();
4848
+ try {
4849
+ const audioBuffer = await audioContext.decodeAudioData(bytes.slice(0));
4850
+ let peak = 0;
4851
+ for (let c = 0; c < audioBuffer.numberOfChannels; c++) {
4852
+ const data = audioBuffer.getChannelData(c);
4853
+ for (let i = 0; i < data.length; i++) {
4854
+ const a = Math.abs(data[i]);
4855
+ if (a > peak) peak = a;
4856
+ }
4857
+ }
4858
+ const peakDb = peak > 1e-6 ? 20 * Math.log10(peak) : -120;
4859
+ return {
4860
+ peakLinear: peak,
4861
+ peakDb,
4862
+ clipped: peak >= CLIP_THRESHOLD_LINEAR - 5e-3
4863
+ };
4864
+ } finally {
4865
+ await audioContext.close().catch(() => {
4866
+ });
4867
+ }
4868
+ }
4869
+
4870
+ // src/components/synthesizeCuePoints.ts
4871
+ function synthesizeCuePoints({
4872
+ bpm,
4873
+ sampleRate,
4874
+ bars,
4875
+ meter = 4
4876
+ }) {
4877
+ const safeBpm = bpm > 0 ? bpm : 120;
4878
+ const safeSampleRate = sampleRate > 0 ? sampleRate : 48e3;
4879
+ const samplesPerBeat = Math.round(60 / safeBpm * safeSampleRate);
4880
+ const totalBeats = Math.max(1, Math.round(bars * meter));
4141
4881
  const beats = [];
4142
4882
  for (let i = 0; i < totalBeats; i++) {
4143
4883
  beats.push(i * samplesPerBeat);
4144
4884
  }
4145
4885
  return {
4146
- schema: 1,
4147
- sample_rate: safeSampleRate,
4148
- detected_bpm: safeBpm,
4149
- downbeat_sample: 0,
4150
- beats,
4151
- detected_at: (/* @__PURE__ */ new Date()).toISOString()
4886
+ schema: 1,
4887
+ sample_rate: safeSampleRate,
4888
+ detected_bpm: safeBpm,
4889
+ downbeat_sample: 0,
4890
+ beats,
4891
+ detected_at: (/* @__PURE__ */ new Date()).toISOString()
4892
+ };
4893
+ }
4894
+
4895
+ // src/panel-core/useGeneratorPanelCore.tsx
4896
+ var import_react25 = require("react");
4897
+
4898
+ // src/hooks/useSceneState.ts
4899
+ var import_react21 = require("react");
4900
+ function useSceneState(activeSceneId, initialValue) {
4901
+ const [stateMap, setStateMap] = (0, import_react21.useState)(() => /* @__PURE__ */ new Map());
4902
+ const activeSceneIdRef = (0, import_react21.useRef)(activeSceneId);
4903
+ activeSceneIdRef.current = activeSceneId;
4904
+ const currentValue = activeSceneId !== null && stateMap.has(activeSceneId) ? stateMap.get(activeSceneId) : initialValue;
4905
+ const setForCurrentScene = (0, import_react21.useCallback)((value) => {
4906
+ const sid = activeSceneIdRef.current;
4907
+ if (sid === null) return;
4908
+ setStateMap((prev) => {
4909
+ const current = prev.has(sid) ? prev.get(sid) : initialValue;
4910
+ const next = typeof value === "function" ? value(current) : value;
4911
+ const newMap = new Map(prev);
4912
+ newMap.set(sid, next);
4913
+ return newMap;
4914
+ });
4915
+ }, [initialValue]);
4916
+ const setForScene = (0, import_react21.useCallback)((sceneId, value) => {
4917
+ setStateMap((prev) => {
4918
+ const current = prev.has(sceneId) ? prev.get(sceneId) : initialValue;
4919
+ const next = typeof value === "function" ? value(current) : value;
4920
+ const newMap = new Map(prev);
4921
+ newMap.set(sceneId, next);
4922
+ return newMap;
4923
+ });
4924
+ }, [initialValue]);
4925
+ return [currentValue, setForCurrentScene, setForScene];
4926
+ }
4927
+
4928
+ // src/hooks/useAnySolo.ts
4929
+ var import_react22 = require("react");
4930
+ function useAnySolo(host) {
4931
+ const [anySolo, setAnySolo] = (0, import_react22.useState)(false);
4932
+ (0, import_react22.useEffect)(() => {
4933
+ let active = true;
4934
+ const refresh = () => {
4935
+ host.isAnySoloActive().then((v) => {
4936
+ if (active) setAnySolo(v);
4937
+ }).catch(() => {
4938
+ });
4939
+ };
4940
+ refresh();
4941
+ const unsub = host.onTrackStateChange(() => refresh());
4942
+ return () => {
4943
+ active = false;
4944
+ unsub();
4945
+ };
4946
+ }, [host]);
4947
+ return anySolo;
4948
+ }
4949
+
4950
+ // src/hooks/useSoundHistory.ts
4951
+ var import_react23 = require("react");
4952
+ var EMPTY = { entries: [], cursor: -1 };
4953
+ function sameDescriptor(a, b) {
4954
+ if (a === b) return true;
4955
+ try {
4956
+ return JSON.stringify(a) === JSON.stringify(b);
4957
+ } catch {
4958
+ return false;
4959
+ }
4960
+ }
4961
+ function useSoundHistory(applySound, opts = {}) {
4962
+ const max = Math.max(2, opts.max ?? 24);
4963
+ const applyRef = (0, import_react23.useRef)(applySound);
4964
+ applyRef.current = applySound;
4965
+ const onChangeRef = (0, import_react23.useRef)(opts.onChange);
4966
+ onChangeRef.current = opts.onChange;
4967
+ const dataRef = (0, import_react23.useRef)({});
4968
+ const [, setVersion] = (0, import_react23.useState)(0);
4969
+ const bump = (0, import_react23.useCallback)(() => setVersion((v) => v + 1), []);
4970
+ const commit = (0, import_react23.useCallback)(
4971
+ (trackId, next, notify) => {
4972
+ dataRef.current = { ...dataRef.current, [trackId]: next };
4973
+ bump();
4974
+ if (notify) onChangeRef.current?.(trackId, next);
4975
+ },
4976
+ [bump]
4977
+ );
4978
+ const record = (0, import_react23.useCallback)(
4979
+ (trackId, descriptor, label) => {
4980
+ const h = dataRef.current[trackId];
4981
+ const current = h && h.cursor >= 0 ? h.entries[h.cursor] : void 0;
4982
+ if (current && sameDescriptor(current.descriptor, descriptor)) return;
4983
+ const entries = [...h ? h.entries : [], { descriptor, label }];
4984
+ while (entries.length > max) {
4985
+ const victim = entries.findIndex((e) => !e.favorite);
4986
+ if (victim === -1) break;
4987
+ entries.splice(victim, 1);
4988
+ }
4989
+ commit(trackId, { entries, cursor: entries.length - 1 }, true);
4990
+ },
4991
+ [max, commit]
4992
+ );
4993
+ const restoreTo = (0, import_react23.useCallback)(
4994
+ async (trackId, index) => {
4995
+ const h = dataRef.current[trackId];
4996
+ if (!h || index < 0 || index >= h.entries.length || index === h.cursor) return false;
4997
+ await applyRef.current(trackId, h.entries[index].descriptor);
4998
+ commit(trackId, { entries: h.entries, cursor: index }, true);
4999
+ return true;
5000
+ },
5001
+ [commit]
5002
+ );
5003
+ const undo = (0, import_react23.useCallback)(
5004
+ (trackId) => {
5005
+ const h = dataRef.current[trackId];
5006
+ if (!h || h.cursor <= 0) return Promise.resolve(false);
5007
+ return restoreTo(trackId, h.cursor - 1);
5008
+ },
5009
+ [restoreTo]
5010
+ );
5011
+ const toggleFavorite = (0, import_react23.useCallback)(
5012
+ (trackId, index) => {
5013
+ const h = dataRef.current[trackId];
5014
+ if (!h || index < 0 || index >= h.entries.length) return;
5015
+ const entries = h.entries.map((e, i) => i === index ? { ...e, favorite: !e.favorite } : e);
5016
+ commit(trackId, { entries, cursor: h.cursor }, true);
5017
+ },
5018
+ [commit]
5019
+ );
5020
+ const restore = (0, import_react23.useCallback)(
5021
+ (trackId, state) => {
5022
+ const entries = Array.isArray(state?.entries) ? [...state.entries] : [];
5023
+ const raw = typeof state?.cursor === "number" ? state.cursor : entries.length - 1;
5024
+ const cursor = entries.length === 0 ? -1 : Math.min(Math.max(raw, 0), entries.length - 1);
5025
+ commit(trackId, { entries, cursor }, false);
5026
+ },
5027
+ [commit]
5028
+ );
5029
+ const list = (0, import_react23.useCallback)(
5030
+ (trackId) => dataRef.current[trackId] ?? EMPTY,
5031
+ []
5032
+ );
5033
+ const canUndo = (0, import_react23.useCallback)((trackId) => {
5034
+ const h = dataRef.current[trackId];
5035
+ return !!h && h.cursor > 0;
5036
+ }, []);
5037
+ const clear = (0, import_react23.useCallback)(
5038
+ (trackId) => {
5039
+ if (dataRef.current[trackId]) {
5040
+ const next = { ...dataRef.current };
5041
+ delete next[trackId];
5042
+ dataRef.current = next;
5043
+ bump();
5044
+ }
5045
+ onChangeRef.current?.(trackId, EMPTY);
5046
+ },
5047
+ [bump]
5048
+ );
5049
+ const reset = (0, import_react23.useCallback)(() => {
5050
+ dataRef.current = {};
5051
+ bump();
5052
+ }, [bump]);
5053
+ return (0, import_react23.useMemo)(
5054
+ () => ({ record, undo, restoreTo, list, canUndo, clear, reset, restore, toggleFavorite }),
5055
+ [record, undo, restoreTo, list, canUndo, clear, reset, restore, toggleFavorite]
5056
+ );
5057
+ }
5058
+
5059
+ // src/panel-core/track-state.ts
5060
+ function newTrackState(handle, overrides = {}) {
5061
+ return {
5062
+ handle,
5063
+ prompt: "",
5064
+ role: "",
5065
+ runtimeState: { id: handle.id, muted: false, solo: false, volume: 0.75, pan: 0 },
5066
+ fxDetailState: { ...EMPTY_FX_DETAIL_STATE },
5067
+ drawerOpen: false,
5068
+ drawerTab: "fx",
5069
+ editorStage: false,
5070
+ isGenerating: false,
5071
+ error: null,
5072
+ hasMidi: false,
5073
+ generationProgress: 0,
5074
+ editNotes: [],
5075
+ editBars: 4,
5076
+ editBpm: 120,
5077
+ instrumentPluginId: handle.instrumentPluginId ?? null,
5078
+ instrumentName: handle.instrumentName ?? null,
5079
+ instrumentMissing: false,
5080
+ shuffleHistory: /* @__PURE__ */ new Set(),
5081
+ ...overrides
5082
+ };
5083
+ }
5084
+
5085
+ // src/panel-core/panel-helpers.ts
5086
+ function trackDataKey(dbId, suffix) {
5087
+ return `track:${dbId}:${suffix}`;
5088
+ }
5089
+ function pluginFxToToggleFx(sdkState) {
5090
+ const result = { ...EMPTY_FX_DETAIL_STATE };
5091
+ for (const category of ["eq", "compressor", "chorus", "phaser", "delay", "reverb"]) {
5092
+ const sdkCat = sdkState[category];
5093
+ if (sdkCat) {
5094
+ result[category] = {
5095
+ enabled: sdkCat.enabled,
5096
+ presetIndex: sdkCat.presetIndex,
5097
+ dryWet: sdkCat.dryWet
5098
+ };
5099
+ }
5100
+ }
5101
+ return result;
5102
+ }
5103
+ function parseLLMNoteResponse(content) {
5104
+ try {
5105
+ let jsonStr = content.trim();
5106
+ const fenceMatch = jsonStr.match(/```(?:json)?\s*\n?([\s\S]*?)```/);
5107
+ if (fenceMatch) {
5108
+ jsonStr = fenceMatch[1].trim();
5109
+ }
5110
+ const parsed = JSON.parse(jsonStr);
5111
+ if (typeof parsed !== "object" || parsed === null || !("notes" in parsed)) {
5112
+ return null;
5113
+ }
5114
+ const obj = parsed;
5115
+ if (!Array.isArray(obj.notes)) {
5116
+ return null;
5117
+ }
5118
+ const validNotes = [];
5119
+ for (const raw of obj.notes) {
5120
+ if (typeof raw !== "object" || raw === null) continue;
5121
+ const note = raw;
5122
+ const pitch = typeof note.pitch === "number" ? note.pitch : NaN;
5123
+ const startBeat = typeof note.startBeat === "number" ? note.startBeat : NaN;
5124
+ const durationBeats = typeof note.durationBeats === "number" ? note.durationBeats : NaN;
5125
+ const velocity = typeof note.velocity === "number" ? note.velocity : NaN;
5126
+ if (!isNaN(pitch) && pitch >= 0 && pitch <= 127 && !isNaN(startBeat) && startBeat >= 0 && !isNaN(durationBeats) && durationBeats > 0 && !isNaN(velocity) && velocity >= 1 && velocity <= 127) {
5127
+ validNotes.push({
5128
+ pitch: Math.round(pitch),
5129
+ startBeat,
5130
+ durationBeats,
5131
+ velocity: Math.round(velocity)
5132
+ });
5133
+ }
5134
+ }
5135
+ const role = typeof obj.role === "string" ? obj.role : void 0;
5136
+ return { notes: validNotes, role };
5137
+ } catch {
5138
+ return null;
5139
+ }
5140
+ }
5141
+
5142
+ // src/panel-core/group-meta.ts
5143
+ function parseTrackGroups(sceneData, spec) {
5144
+ const pattern = new RegExp(`^track:(.+):${spec.metaKey}$`);
5145
+ const groups = /* @__PURE__ */ new Map();
5146
+ for (const [key, val] of Object.entries(sceneData)) {
5147
+ const match = pattern.exec(key);
5148
+ if (!match) continue;
5149
+ const meta = spec.asMeta(val);
5150
+ if (!meta) continue;
5151
+ const groupId = spec.groupIdOf(meta);
5152
+ const list = groups.get(groupId) ?? [];
5153
+ list.push({ dbId: match[1], meta });
5154
+ groups.set(groupId, list);
5155
+ }
5156
+ const out = [];
5157
+ for (const [groupId, members] of groups) {
5158
+ if (spec.sortMembers) members.sort(spec.sortMembers);
5159
+ out.push({ groupId, members });
5160
+ }
5161
+ return out;
5162
+ }
5163
+ function resolveTrackGroups(parsedGroups, tracks, getDbId, opts = {}) {
5164
+ const byDbId = /* @__PURE__ */ new Map();
5165
+ for (const t of tracks) byDbId.set(getDbId(t), t);
5166
+ const resolved = [];
5167
+ const memberDbIds = /* @__PURE__ */ new Set();
5168
+ const staleMemberDbIds = [];
5169
+ for (const parsed of parsedGroups) {
5170
+ const live = { groupId: parsed.groupId, members: [] };
5171
+ for (const member of parsed.members) {
5172
+ const track = byDbId.get(member.dbId);
5173
+ if (track) live.members.push({ dbId: member.dbId, meta: member.meta, track });
5174
+ else staleMemberDbIds.push(member.dbId);
5175
+ }
5176
+ if (live.members.length === 0) continue;
5177
+ const complete = opts.isComplete ? opts.isComplete(live, parsed) : live.members.length === parsed.members.length;
5178
+ if (!complete) continue;
5179
+ resolved.push(live);
5180
+ for (const m of live.members) memberDbIds.add(m.dbId);
5181
+ }
5182
+ return { resolved, memberDbIds, staleMemberDbIds };
5183
+ }
5184
+
5185
+ // src/panel-core/useTransitionOps.ts
5186
+ var import_react24 = require("react");
5187
+ function useTransitionOps({
5188
+ host,
5189
+ adapter,
5190
+ activeSceneId,
5191
+ isConnected,
5192
+ isAuthenticated,
5193
+ sceneContext,
5194
+ tracks,
5195
+ setTracks,
5196
+ loadTracks,
5197
+ setCrossfadePairsMeta,
5198
+ setFadesMeta,
5199
+ resolvedCrossfadePairs,
5200
+ resolvedFades
5201
+ }) {
5202
+ const { identity } = adapter;
5203
+ const appliedFadeAutomationRef = (0, import_react24.useRef)(/* @__PURE__ */ new Set());
5204
+ const applyCrossfadeAutomation = (0, import_react24.useCallback)(
5205
+ async (originTrackId, targetTrackId, bars, bpm, sliderPos) => {
5206
+ if (host.setTrackVolumeAutomation) {
5207
+ const curves = buildCrossfadeVolumeCurves(bars, bpm, sliderPos);
5208
+ await host.setTrackVolumeAutomation(originTrackId, curves.origin).catch(() => {
5209
+ });
5210
+ await host.setTrackVolumeAutomation(targetTrackId, curves.target).catch(() => {
5211
+ });
5212
+ } else {
5213
+ await host.setTrackVolume(originTrackId, EQUAL_POWER_GAIN).catch(() => {
5214
+ });
5215
+ await host.setTrackVolume(targetTrackId, EQUAL_POWER_GAIN).catch(() => {
5216
+ });
5217
+ }
5218
+ },
5219
+ [host]
5220
+ );
5221
+ const applyFadeAutomation = (0, import_react24.useCallback)(
5222
+ async (trackId, direction, bars, bpm, sliderPos, gesture) => {
5223
+ if (!host.setTrackVolumeAutomation) return;
5224
+ const points = buildFadeVolumeCurve(bars, bpm, direction, sliderPos, gesture);
5225
+ await host.setTrackVolumeAutomation(trackId, points).catch(() => {
5226
+ });
5227
+ },
5228
+ [host]
5229
+ );
5230
+ const [isCreatingCrossfade, setIsCreatingCrossfade] = (0, import_react24.useState)(false);
5231
+ const handleCreateCrossfade = (0, import_react24.useCallback)(
5232
+ async (origin, target) => {
5233
+ const scene = activeSceneId;
5234
+ const fromSceneId = sceneContext?.transitionFromSceneId ?? "";
5235
+ const toSceneId = sceneContext?.transitionToSceneId ?? "";
5236
+ if (!scene) throw new Error("No active scene.");
5237
+ if (!isConnected) throw new Error("Systems not connected.");
5238
+ if (!isAuthenticated) throw new Error("Please sign in to generate the bridge.");
5239
+ if (tracks.length + 2 > identity.maxTracks) {
5240
+ throw new Error("Not enough track slots for a crossfade.");
5241
+ }
5242
+ setIsCreatingCrossfade(true);
5243
+ const created = [];
5244
+ try {
5245
+ const role = target.role ?? origin.role ?? "";
5246
+ const mc = await host.getMusicalContext();
5247
+ const [originMidi, targetMidi, originKey, targetKey] = await Promise.all([
5248
+ host.readImportableTrackMidi ? host.readImportableTrackMidi(origin.dbId) : Promise.resolve({ clips: [] }),
5249
+ host.readImportableTrackMidi ? host.readImportableTrackMidi(target.dbId) : Promise.resolve({ clips: [] }),
5250
+ host.getSceneKey ? host.getSceneKey(fromSceneId) : Promise.resolve(null),
5251
+ host.getSceneKey ? host.getSceneKey(toSceneId) : Promise.resolve(null)
5252
+ ]);
5253
+ const userPrompt = buildCrossfadeInpaintPrompt({
5254
+ role,
5255
+ bars: mc.bars,
5256
+ originName: origin.name,
5257
+ targetName: target.name,
5258
+ originKey: originKey ? `${originKey.key} ${originKey.mode}` : null,
5259
+ targetKey: targetKey ? `${targetKey.key} ${targetKey.mode}` : null,
5260
+ originNotes: originMidi.clips[0]?.notes ?? [],
5261
+ targetNotes: targetMidi.clips[0]?.notes ?? []
5262
+ });
5263
+ const llm = await host.generateWithLLM({
5264
+ system: adapter.buildSystemPrompt(host.getValidRoles()),
5265
+ user: userPrompt,
5266
+ responseFormat: "json"
5267
+ });
5268
+ const parsed = adapter.parseNotesResponse(llm.content);
5269
+ if (!parsed || parsed.notes.length === 0) {
5270
+ throw new Error("The bridge generator returned no notes.");
5271
+ }
5272
+ const notes = await host.postProcessMidi(parsed.notes, {
5273
+ quantize: true,
5274
+ removeOverlaps: true
5275
+ });
5276
+ const clip = {
5277
+ startTime: 0,
5278
+ endTime: mc.bars * 4 * 60 / mc.bpm,
5279
+ tempo: mc.bpm,
5280
+ notes
5281
+ };
5282
+ const top = await host.createTrack({
5283
+ name: `${identity.trackNamePrefix}-${Date.now()}-xf-o`,
5284
+ ...adapter.createTrackOptions()
5285
+ });
5286
+ created.push(top);
5287
+ const bottom = await host.createTrack({
5288
+ name: `${identity.trackNamePrefix}-${Date.now()}-xf-t`,
5289
+ ...adapter.createTrackOptions()
5290
+ });
5291
+ created.push(bottom);
5292
+ if (role) {
5293
+ await host.setTrackRole(top.id, role).catch(() => {
5294
+ });
5295
+ await host.setTrackRole(bottom.id, role).catch(() => {
5296
+ });
5297
+ }
5298
+ await host.writeMidiClip(top.id, clip);
5299
+ await host.writeMidiClip(bottom.id, clip);
5300
+ const copySound = async (newTrackId, sourceDbId) => {
5301
+ if (!host.getTrackSound) return "default";
5302
+ const snap = await host.getTrackSound(sourceDbId);
5303
+ if (!snap || snap.kind !== adapter.sound.acceptedSnapshotKind) return "default";
5304
+ return adapter.sound.copySnapshot(newTrackId, snap);
5305
+ };
5306
+ const originLabel = await copySound(top.id, origin.dbId);
5307
+ const targetLabel = await copySound(bottom.id, target.dbId);
5308
+ await applyCrossfadeAutomation(top.id, bottom.id, mc.bars, mc.bpm, 0.5);
5309
+ const groupId = top.dbId;
5310
+ const originMeta = {
5311
+ groupId,
5312
+ slot: "origin",
5313
+ partnerDbId: bottom.dbId,
5314
+ sourceTrackDbId: origin.dbId,
5315
+ sourceSceneId: fromSceneId,
5316
+ sourceName: origin.name,
5317
+ soundLabel: originLabel,
5318
+ sliderPos: 0.5
5319
+ };
5320
+ const targetMeta = {
5321
+ groupId,
5322
+ slot: "target",
5323
+ partnerDbId: top.dbId,
5324
+ sourceTrackDbId: target.dbId,
5325
+ sourceSceneId: toSceneId,
5326
+ sourceName: target.name,
5327
+ soundLabel: targetLabel,
5328
+ sliderPos: 0.5
5329
+ };
5330
+ await host.setSceneData(scene, `track:${top.dbId}:crossfade`, originMeta);
5331
+ await host.setSceneData(scene, `track:${bottom.dbId}:crossfade`, targetMeta);
5332
+ await loadTracks(true);
5333
+ host.showToast("success", "Crossfade created", `${origin.name} \u2192 ${target.name}`);
5334
+ } catch (err) {
5335
+ for (const h of [...created].reverse()) {
5336
+ try {
5337
+ await host.deleteTrack(h.id);
5338
+ } catch {
5339
+ }
5340
+ }
5341
+ throw err instanceof Error ? err : new Error(String(err));
5342
+ } finally {
5343
+ setIsCreatingCrossfade(false);
5344
+ }
5345
+ },
5346
+ [
5347
+ host,
5348
+ adapter,
5349
+ identity,
5350
+ activeSceneId,
5351
+ isConnected,
5352
+ isAuthenticated,
5353
+ tracks.length,
5354
+ sceneContext,
5355
+ applyCrossfadeAutomation,
5356
+ loadTracks
5357
+ ]
5358
+ );
5359
+ const [isCreatingFade, setIsCreatingFade] = (0, import_react24.useState)(false);
5360
+ const handleCreateFade = (0, import_react24.useCallback)(
5361
+ async (selection, direction, gesture) => {
5362
+ const scene = activeSceneId;
5363
+ const fromSceneId = sceneContext?.transitionFromSceneId ?? "";
5364
+ const toSceneId = sceneContext?.transitionToSceneId ?? "";
5365
+ if (!scene) throw new Error("No active scene.");
5366
+ if (!isConnected) throw new Error("Systems not connected.");
5367
+ if (!isAuthenticated) throw new Error("Please sign in to generate the fade.");
5368
+ if (tracks.length + 1 > identity.maxTracks) {
5369
+ throw new Error("Not enough track slots for a fade.");
5370
+ }
5371
+ setIsCreatingFade(true);
5372
+ const created = [];
5373
+ try {
5374
+ const role = selection.role ?? "";
5375
+ const sourceSceneId = direction === "out" ? fromSceneId : toSceneId;
5376
+ const mc = await host.getMusicalContext();
5377
+ const [srcMidi, srcKey] = await Promise.all([
5378
+ host.readImportableTrackMidi ? host.readImportableTrackMidi(selection.dbId) : Promise.resolve({ clips: [] }),
5379
+ host.getSceneKey ? host.getSceneKey(sourceSceneId) : Promise.resolve(null)
5380
+ ]);
5381
+ const srcNotes = srcMidi.clips[0]?.notes ?? [];
5382
+ const keyStr = srcKey ? `${srcKey.key} ${srcKey.mode}` : null;
5383
+ const userPrompt = buildCrossfadeInpaintPrompt({
5384
+ role,
5385
+ bars: mc.bars,
5386
+ originName: direction === "out" ? selection.name : "silence",
5387
+ targetName: direction === "in" ? selection.name : "silence",
5388
+ originKey: direction === "out" ? keyStr : null,
5389
+ targetKey: direction === "in" ? keyStr : null,
5390
+ originNotes: direction === "out" ? srcNotes : [],
5391
+ targetNotes: direction === "in" ? srcNotes : []
5392
+ });
5393
+ const llm = await host.generateWithLLM({
5394
+ system: adapter.buildSystemPrompt(host.getValidRoles()),
5395
+ user: userPrompt,
5396
+ responseFormat: "json"
5397
+ });
5398
+ const parsed = adapter.parseNotesResponse(llm.content);
5399
+ if (!parsed || parsed.notes.length === 0) {
5400
+ throw new Error("The fade generator returned no notes.");
5401
+ }
5402
+ const notes = await host.postProcessMidi(parsed.notes, {
5403
+ quantize: true,
5404
+ removeOverlaps: true
5405
+ });
5406
+ const clip = {
5407
+ startTime: 0,
5408
+ endTime: mc.bars * 4 * 60 / mc.bpm,
5409
+ tempo: mc.bpm,
5410
+ notes
5411
+ };
5412
+ const track = await host.createTrack({
5413
+ name: `${identity.trackNamePrefix}-${Date.now()}-fade-${direction}`,
5414
+ ...adapter.createTrackOptions()
5415
+ });
5416
+ created.push(track);
5417
+ if (role) await host.setTrackRole(track.id, role).catch(() => {
5418
+ });
5419
+ await host.writeMidiClip(track.id, clip);
5420
+ let soundLabel = "default";
5421
+ if (host.getTrackSound) {
5422
+ const snap = await host.getTrackSound(selection.dbId);
5423
+ if (snap && snap.kind === adapter.sound.acceptedSnapshotKind) {
5424
+ soundLabel = await adapter.sound.copySnapshot(track.id, snap);
5425
+ }
5426
+ }
5427
+ await applyFadeAutomation(track.id, direction, mc.bars, mc.bpm, 0.5, gesture);
5428
+ appliedFadeAutomationRef.current.add(track.id);
5429
+ const meta = {
5430
+ direction,
5431
+ gesture,
5432
+ sourceTrackDbId: selection.dbId,
5433
+ sourceSceneId,
5434
+ sourceName: selection.name,
5435
+ soundLabel,
5436
+ sliderPos: 0.5
5437
+ };
5438
+ await host.setSceneData(scene, `track:${track.dbId}:fade`, meta);
5439
+ await loadTracks(true);
5440
+ host.showToast(
5441
+ "success",
5442
+ direction === "in" ? "Fade in created" : "Fade out created",
5443
+ selection.name
5444
+ );
5445
+ } catch (err) {
5446
+ for (const h of [...created].reverse()) {
5447
+ try {
5448
+ await host.deleteTrack(h.id);
5449
+ } catch {
5450
+ }
5451
+ }
5452
+ throw err instanceof Error ? err : new Error(String(err));
5453
+ } finally {
5454
+ setIsCreatingFade(false);
5455
+ }
5456
+ },
5457
+ [
5458
+ host,
5459
+ adapter,
5460
+ identity,
5461
+ activeSceneId,
5462
+ isConnected,
5463
+ isAuthenticated,
5464
+ tracks.length,
5465
+ sceneContext,
5466
+ applyFadeAutomation,
5467
+ loadTracks
5468
+ ]
5469
+ );
5470
+ const handleCrossfadeMute = (0, import_react24.useCallback)(
5471
+ (pair) => {
5472
+ const newMuted = !pair.origin.runtimeState.muted;
5473
+ for (const id of [pair.origin.handle.id, pair.target.handle.id]) {
5474
+ setTracks(
5475
+ (prev) => prev.map(
5476
+ (t) => t.handle.id === id ? { ...t, runtimeState: { ...t.runtimeState, muted: newMuted } } : t
5477
+ )
5478
+ );
5479
+ host.setTrackMute(id, newMuted).catch(() => {
5480
+ });
5481
+ }
5482
+ },
5483
+ [host, setTracks]
5484
+ );
5485
+ const handleCrossfadeSolo = (0, import_react24.useCallback)(
5486
+ (pair) => {
5487
+ const newSolo = !pair.origin.runtimeState.solo;
5488
+ for (const id of [pair.origin.handle.id, pair.target.handle.id]) {
5489
+ setTracks(
5490
+ (prev) => prev.map(
5491
+ (t) => t.handle.id === id ? { ...t, runtimeState: { ...t.runtimeState, solo: newSolo } } : t
5492
+ )
5493
+ );
5494
+ host.setTrackSolo(id, newSolo).catch(() => {
5495
+ });
5496
+ }
5497
+ },
5498
+ [host, setTracks]
5499
+ );
5500
+ const handleCrossfadeDelete = (0, import_react24.useCallback)(
5501
+ async (pair) => {
5502
+ try {
5503
+ for (const member of [pair.origin, pair.target]) {
5504
+ await host.deleteTrack(member.handle.id);
5505
+ if (activeSceneId) {
5506
+ await host.deleteSceneData(activeSceneId, `track:${member.handle.dbId}:crossfade`);
5507
+ }
5508
+ }
5509
+ setCrossfadePairsMeta((prev) => prev.filter((p) => p.groupId !== pair.groupId));
5510
+ setTracks(
5511
+ (prev) => prev.filter(
5512
+ (t) => t.handle.id !== pair.origin.handle.id && t.handle.id !== pair.target.handle.id
5513
+ )
5514
+ );
5515
+ host.showToast("success", "Crossfade removed");
5516
+ } catch (err) {
5517
+ host.showToast(
5518
+ "error",
5519
+ "Failed to delete crossfade",
5520
+ err instanceof Error ? err.message : String(err)
5521
+ );
5522
+ }
5523
+ },
5524
+ [host, activeSceneId, setCrossfadePairsMeta, setTracks]
5525
+ );
5526
+ const crossfadeSliderTimers = (0, import_react24.useRef)({});
5527
+ const handleCrossfadeSlider = (0, import_react24.useCallback)(
5528
+ (pair, pos) => {
5529
+ setCrossfadePairsMeta(
5530
+ (prev) => prev.map((p) => p.groupId === pair.groupId ? { ...p, sliderPos: pos } : p)
5531
+ );
5532
+ if (crossfadeSliderTimers.current[pair.groupId]) {
5533
+ clearTimeout(crossfadeSliderTimers.current[pair.groupId]);
5534
+ }
5535
+ crossfadeSliderTimers.current[pair.groupId] = setTimeout(() => {
5536
+ void (async () => {
5537
+ const mc = await host.getMusicalContext();
5538
+ await applyCrossfadeAutomation(
5539
+ pair.origin.handle.id,
5540
+ pair.target.handle.id,
5541
+ mc.bars,
5542
+ mc.bpm,
5543
+ pos
5544
+ );
5545
+ if (activeSceneId) {
5546
+ const sceneData = await host.getAllSceneData(activeSceneId);
5547
+ for (const dbId of [pair.originDbId, pair.targetDbId]) {
5548
+ const meta = asCrossfadeMeta(sceneData[`track:${dbId}:crossfade`]);
5549
+ if (meta) {
5550
+ host.setSceneData(activeSceneId, `track:${dbId}:crossfade`, { ...meta, sliderPos: pos }).catch(() => {
5551
+ });
5552
+ }
5553
+ }
5554
+ }
5555
+ })();
5556
+ }, 200);
5557
+ },
5558
+ [host, activeSceneId, applyCrossfadeAutomation, setCrossfadePairsMeta]
5559
+ );
5560
+ const handleFadeDelete = (0, import_react24.useCallback)(
5561
+ async (fade) => {
5562
+ try {
5563
+ await host.deleteTrack(fade.track.handle.id);
5564
+ if (activeSceneId) {
5565
+ await host.deleteSceneData(activeSceneId, `track:${fade.dbId}:fade`);
5566
+ }
5567
+ setFadesMeta((prev) => prev.filter((f) => f.dbId !== fade.dbId));
5568
+ setTracks((prev) => prev.filter((t) => t.handle.id !== fade.track.handle.id));
5569
+ host.showToast("success", "Fade removed");
5570
+ } catch (err) {
5571
+ host.showToast(
5572
+ "error",
5573
+ "Failed to delete fade",
5574
+ err instanceof Error ? err.message : String(err)
5575
+ );
5576
+ }
5577
+ },
5578
+ [host, activeSceneId, setFadesMeta, setTracks]
5579
+ );
5580
+ const fadeSliderTimers = (0, import_react24.useRef)({});
5581
+ const handleFadeSlider = (0, import_react24.useCallback)(
5582
+ (fade, pos) => {
5583
+ setFadesMeta(
5584
+ (prev) => prev.map((f) => f.dbId === fade.dbId ? { ...f, meta: { ...f.meta, sliderPos: pos } } : f)
5585
+ );
5586
+ if (fadeSliderTimers.current[fade.dbId]) clearTimeout(fadeSliderTimers.current[fade.dbId]);
5587
+ fadeSliderTimers.current[fade.dbId] = setTimeout(() => {
5588
+ void (async () => {
5589
+ const mc = await host.getMusicalContext();
5590
+ await applyFadeAutomation(
5591
+ fade.track.handle.id,
5592
+ fade.meta.direction,
5593
+ mc.bars,
5594
+ mc.bpm,
5595
+ pos,
5596
+ fade.meta.gesture
5597
+ );
5598
+ if (activeSceneId) {
5599
+ const sceneData = await host.getAllSceneData(activeSceneId);
5600
+ const meta = asFadeMeta(sceneData[`track:${fade.dbId}:fade`]);
5601
+ if (meta) {
5602
+ host.setSceneData(activeSceneId, `track:${fade.dbId}:fade`, { ...meta, sliderPos: pos }).catch(() => {
5603
+ });
5604
+ }
5605
+ }
5606
+ })();
5607
+ }, 200);
5608
+ },
5609
+ [host, activeSceneId, applyFadeAutomation, setFadesMeta]
5610
+ );
5611
+ const lastResyncKeyRef = (0, import_react24.useRef)("");
5612
+ (0, import_react24.useEffect)(() => {
5613
+ if (!host.getTrackSound || resolvedCrossfadePairs.length === 0 && resolvedFades.length === 0) {
5614
+ return;
5615
+ }
5616
+ const resyncKey = [
5617
+ ...resolvedCrossfadePairs.map(
5618
+ (p) => `${p.origin.handle.dbId}<${p.originSourceDbId}|${p.target.handle.dbId}<${p.targetSourceDbId}`
5619
+ ),
5620
+ ...resolvedFades.map((f) => `${f.track.handle.dbId}<${f.meta.sourceTrackDbId}`)
5621
+ ].join(",");
5622
+ if (resyncKey === lastResyncKeyRef.current) return;
5623
+ lastResyncKeyRef.current = resyncKey;
5624
+ let cancelled = false;
5625
+ const reapplyIfDrifted = async (layerTrackId, layerDbId, sourceDbId) => {
5626
+ if (!host.getTrackSound || cancelled) return;
5627
+ const [sourceSnap, layerSnap] = await Promise.all([
5628
+ host.getTrackSound(sourceDbId),
5629
+ host.getTrackSound(layerDbId)
5630
+ ]);
5631
+ if (cancelled || !sourceSnap || sourceSnap.kind !== adapter.sound.acceptedSnapshotKind) {
5632
+ return;
5633
+ }
5634
+ if (soundIdentity(sourceSnap) === soundIdentity(layerSnap)) return;
5635
+ try {
5636
+ await adapter.sound.copySnapshot(layerTrackId, sourceSnap);
5637
+ } catch {
5638
+ }
5639
+ };
5640
+ void (async () => {
5641
+ for (const pair of resolvedCrossfadePairs) {
5642
+ await reapplyIfDrifted(pair.origin.handle.id, pair.origin.handle.dbId, pair.originSourceDbId);
5643
+ await reapplyIfDrifted(pair.target.handle.id, pair.target.handle.dbId, pair.targetSourceDbId);
5644
+ }
5645
+ for (const fade of resolvedFades) {
5646
+ await reapplyIfDrifted(fade.track.handle.id, fade.track.handle.dbId, fade.meta.sourceTrackDbId);
5647
+ }
5648
+ })();
5649
+ return () => {
5650
+ cancelled = true;
5651
+ };
5652
+ }, [resolvedCrossfadePairs, resolvedFades, host, adapter]);
5653
+ (0, import_react24.useEffect)(() => {
5654
+ if (!host.setTrackVolumeAutomation || resolvedFades.length === 0) return;
5655
+ void (async () => {
5656
+ const mc = await host.getMusicalContext();
5657
+ for (const fade of resolvedFades) {
5658
+ const id = fade.track.handle.id;
5659
+ if (appliedFadeAutomationRef.current.has(id)) continue;
5660
+ appliedFadeAutomationRef.current.add(id);
5661
+ await applyFadeAutomation(
5662
+ id,
5663
+ fade.meta.direction,
5664
+ mc.bars,
5665
+ mc.bpm,
5666
+ fade.meta.sliderPos,
5667
+ fade.meta.gesture
5668
+ );
5669
+ }
5670
+ })();
5671
+ }, [resolvedFades, host, applyFadeAutomation]);
5672
+ return {
5673
+ isCreatingCrossfade,
5674
+ isCreatingFade,
5675
+ handleCreateCrossfade,
5676
+ handleCreateFade,
5677
+ handleCrossfadeMute,
5678
+ handleCrossfadeSolo,
5679
+ handleCrossfadeDelete,
5680
+ handleCrossfadeSlider,
5681
+ handleFadeDelete,
5682
+ handleFadeSlider
4152
5683
  };
4153
5684
  }
4154
5685
 
4155
- // src/hooks/useSceneState.ts
4156
- var import_react19 = require("react");
4157
- function useSceneState(activeSceneId, initialValue) {
4158
- const [stateMap, setStateMap] = (0, import_react19.useState)(() => /* @__PURE__ */ new Map());
4159
- const activeSceneIdRef = (0, import_react19.useRef)(activeSceneId);
4160
- activeSceneIdRef.current = activeSceneId;
4161
- const currentValue = activeSceneId !== null && stateMap.has(activeSceneId) ? stateMap.get(activeSceneId) : initialValue;
4162
- const setForCurrentScene = (0, import_react19.useCallback)((value) => {
4163
- const sid = activeSceneIdRef.current;
4164
- if (sid === null) return;
4165
- setStateMap((prev) => {
4166
- const current = prev.has(sid) ? prev.get(sid) : initialValue;
4167
- const next = typeof value === "function" ? value(current) : value;
4168
- const newMap = new Map(prev);
4169
- newMap.set(sid, next);
4170
- return newMap;
5686
+ // src/panel-core/useGeneratorPanelCore.tsx
5687
+ var import_jsx_runtime23 = require("react/jsx-runtime");
5688
+ var EMPTY_PLACEHOLDERS = [];
5689
+ function useGeneratorPanelCore({
5690
+ ui,
5691
+ adapter
5692
+ }) {
5693
+ const {
5694
+ host,
5695
+ activeSceneId,
5696
+ isAuthenticated,
5697
+ isConnected,
5698
+ onHeaderContent,
5699
+ onLoading,
5700
+ sceneContext,
5701
+ onOpenContract,
5702
+ onExpandSelf,
5703
+ isExpanded
5704
+ } = ui;
5705
+ const { identity, features } = adapter;
5706
+ const logTag = identity.logTag;
5707
+ const adapterRef = (0, import_react25.useRef)(adapter);
5708
+ (0, import_react25.useEffect)(() => {
5709
+ if (adapterRef.current !== adapter) {
5710
+ adapterRef.current = adapter;
5711
+ console.warn(
5712
+ `[${logTag}] GeneratorPanelAdapter identity changed between renders \u2014 wrap it in useMemo(() => createAdapter(host), [host]) to avoid load loops.`
5713
+ );
5714
+ }
5715
+ }, [adapter, logTag]);
5716
+ const supportsMeters = typeof host.getTrackLevels === "function";
5717
+ const trackLevels = useTrackLevels(host, isExpanded);
5718
+ const [tracks, setTracks] = (0, import_react25.useState)([]);
5719
+ const [isLoadingTracks, setIsLoadingTracks] = (0, import_react25.useState)(false);
5720
+ const [importOpen, setImportOpen] = (0, import_react25.useState)(false);
5721
+ const [soundImportTarget, setSoundImportTarget] = (0, import_react25.useState)(null);
5722
+ const [designerView, setDesignerView] = (0, import_react25.useState)(false);
5723
+ const [transitionSourceTotal, setTransitionSourceTotal] = (0, import_react25.useState)(0);
5724
+ const [crossfadePairsMeta, setCrossfadePairsMeta] = (0, import_react25.useState)([]);
5725
+ const [fadesMeta, setFadesMeta] = (0, import_react25.useState)([]);
5726
+ const [genericGroupMetas, setGenericGroupMetas] = (0, import_react25.useState)({});
5727
+ const [isComposing, , setIsComposingForScene] = useSceneState(activeSceneId, false);
5728
+ const [placeholders, , setPlaceholdersForScene] = useSceneState(
5729
+ activeSceneId,
5730
+ EMPTY_PLACEHOLDERS
5731
+ );
5732
+ const saveTimeoutRefs = (0, import_react25.useRef)({});
5733
+ const editLoadStartedRef = (0, import_react25.useRef)(/* @__PURE__ */ new Set());
5734
+ const [availableInstruments, setAvailableInstruments] = (0, import_react25.useState)([]);
5735
+ const [instrumentsLoading, setInstrumentsLoading] = (0, import_react25.useState)(false);
5736
+ const engineToDbIdRef = (0, import_react25.useRef)(/* @__PURE__ */ new Map());
5737
+ const tracksLoadedForSceneRef = (0, import_react25.useRef)(null);
5738
+ const persistSoundHistory = (0, import_react25.useCallback)(
5739
+ (trackId, state) => {
5740
+ if (!activeSceneId) return;
5741
+ const dbId = engineToDbIdRef.current.get(trackId) ?? trackId;
5742
+ host.setSceneData(activeSceneId, trackDataKey(dbId, "soundHistory"), state).catch(() => {
5743
+ });
5744
+ },
5745
+ [host, activeSceneId]
5746
+ );
5747
+ const soundHistory = useSoundHistory(adapter.sound.applySound, {
5748
+ max: adapter.sound.historyMax,
5749
+ onChange: persistSoundHistory
5750
+ });
5751
+ const anySolo = useAnySolo(host);
5752
+ const reorder = useTrackReorder({
5753
+ host,
5754
+ items: tracks,
5755
+ setItems: setTracks,
5756
+ getId: (t) => t.handle.dbId
5757
+ });
5758
+ const loadTracks = (0, import_react25.useCallback)(
5759
+ async (incremental = false) => {
5760
+ const sceneAtStart = activeSceneId;
5761
+ if (!sceneAtStart) {
5762
+ setTracks([]);
5763
+ setCrossfadePairsMeta([]);
5764
+ setFadesMeta([]);
5765
+ setGenericGroupMetas({});
5766
+ tracksLoadedForSceneRef.current = null;
5767
+ setIsLoadingTracks(false);
5768
+ return;
5769
+ }
5770
+ if (!incremental && tracksLoadedForSceneRef.current !== sceneAtStart) {
5771
+ setTracks([]);
5772
+ }
5773
+ tracksLoadedForSceneRef.current = sceneAtStart;
5774
+ if (!incremental) soundHistory.reset();
5775
+ const isStale = () => tracksLoadedForSceneRef.current !== sceneAtStart;
5776
+ if (!incremental) setIsLoadingTracks(true);
5777
+ try {
5778
+ await host.adoptSceneTracks();
5779
+ if (isStale()) return;
5780
+ const handles = await host.getPluginTracks();
5781
+ if (isStale()) return;
5782
+ const sceneData = await host.getAllSceneData(sceneAtStart);
5783
+ if (isStale()) return;
5784
+ const idMap = /* @__PURE__ */ new Map();
5785
+ for (const h of handles) {
5786
+ idMap.set(h.id, h.dbId);
5787
+ }
5788
+ engineToDbIdRef.current = idMap;
5789
+ const trackStates = [];
5790
+ for (const handle of handles) {
5791
+ let runtimeState = {
5792
+ id: handle.id,
5793
+ muted: false,
5794
+ solo: false,
5795
+ volume: 0.75,
5796
+ pan: 0
5797
+ };
5798
+ let hasMidi = false;
5799
+ try {
5800
+ const info = await host.getTrackInfo(handle.id);
5801
+ runtimeState = {
5802
+ id: handle.id,
5803
+ muted: info.muted,
5804
+ solo: info.soloed,
5805
+ volume: info.volume,
5806
+ pan: info.pan
5807
+ };
5808
+ hasMidi = info.hasMidi;
5809
+ } catch {
5810
+ }
5811
+ let fxDetailState = newTrackState(handle).fxDetailState;
5812
+ try {
5813
+ const fxState = await host.getTrackFxState(handle.id);
5814
+ fxDetailState = pluginFxToToggleFx(fxState);
5815
+ } catch {
5816
+ }
5817
+ const promptKey = trackDataKey(handle.dbId, "prompt");
5818
+ let prompt = typeof sceneData[promptKey] === "string" ? sceneData[promptKey] : "";
5819
+ if (!prompt && handle.prompt) {
5820
+ prompt = handle.prompt;
5821
+ host.setSceneData(sceneAtStart, promptKey, prompt).catch(() => {
5822
+ });
5823
+ }
5824
+ if (!hasMidi && handle.role) {
5825
+ hasMidi = true;
5826
+ }
5827
+ let instrumentMissing = false;
5828
+ if (handle.instrumentPluginId) {
5829
+ try {
5830
+ const instrDescriptor = await host.getTrackInstrument(handle.id);
5831
+ if (instrDescriptor?.missing) {
5832
+ instrumentMissing = true;
5833
+ }
5834
+ } catch {
5835
+ }
5836
+ }
5837
+ trackStates.push(
5838
+ newTrackState(handle, {
5839
+ prompt,
5840
+ role: handle.role ?? "",
5841
+ runtimeState,
5842
+ fxDetailState,
5843
+ hasMidi,
5844
+ instrumentMissing
5845
+ })
5846
+ );
5847
+ }
5848
+ if (isStale()) return;
5849
+ setTracks((prev) => {
5850
+ const prevByDbId = new Map(prev.map((p) => [p.handle.dbId, p]));
5851
+ return trackStates.map((ts) => {
5852
+ const carry = prevByDbId.get(ts.handle.dbId);
5853
+ return carry ? { ...ts, editNotes: carry.editNotes, editBars: carry.editBars, editBpm: carry.editBpm } : ts;
5854
+ });
5855
+ });
5856
+ for (const ts of trackStates) {
5857
+ const persisted = sceneData[trackDataKey(ts.handle.dbId, "soundHistory")];
5858
+ if (persisted && typeof persisted === "object") {
5859
+ soundHistory.restore(ts.handle.id, persisted);
5860
+ }
5861
+ }
5862
+ if (!isStale()) {
5863
+ setCrossfadePairsMeta(parseCrossfadePairs(sceneData));
5864
+ setFadesMeta(parseFades(sceneData));
5865
+ if (adapter.groupExtensions && adapter.groupExtensions.length > 0) {
5866
+ const map = {};
5867
+ for (const ext of adapter.groupExtensions) {
5868
+ map[ext.metaKey] = parseTrackGroups(sceneData, ext);
5869
+ }
5870
+ setGenericGroupMetas(map);
5871
+ }
5872
+ }
5873
+ } catch (error) {
5874
+ console.error(`[${logTag}] Failed to load tracks:`, error);
5875
+ } finally {
5876
+ if (tracksLoadedForSceneRef.current === sceneAtStart) {
5877
+ setIsLoadingTracks(false);
5878
+ }
5879
+ }
5880
+ },
5881
+ [host, activeSceneId, soundHistory, adapter, logTag]
5882
+ );
5883
+ (0, import_react25.useEffect)(() => {
5884
+ loadTracks();
5885
+ }, [loadTracks]);
5886
+ (0, import_react25.useEffect)(() => {
5887
+ const map = /* @__PURE__ */ new Map();
5888
+ for (const t of tracks) {
5889
+ map.set(t.handle.id, t.handle.dbId);
5890
+ }
5891
+ engineToDbIdRef.current = map;
5892
+ }, [tracks]);
5893
+ const loadedCompletedIdsRef = (0, import_react25.useRef)(/* @__PURE__ */ new Set());
5894
+ (0, import_react25.useEffect)(() => {
5895
+ if (placeholders.length === 0) {
5896
+ loadedCompletedIdsRef.current.clear();
5897
+ return;
5898
+ }
5899
+ const newCompleted = placeholders.filter(
5900
+ (ph) => ph.status === "completed" && !loadedCompletedIdsRef.current.has(ph.id)
5901
+ );
5902
+ if (newCompleted.length > 0) {
5903
+ for (const ph of newCompleted) {
5904
+ loadedCompletedIdsRef.current.add(ph.id);
5905
+ }
5906
+ console.log(
5907
+ `[${logTag}] ${newCompleted.length} track(s) completed, reloading. IDs:`,
5908
+ newCompleted.map((ph) => ph.id)
5909
+ );
5910
+ loadTracks(true);
5911
+ }
5912
+ }, [placeholders, loadTracks, logTag]);
5913
+ const adoptAndLoad = (0, import_react25.useCallback)(() => {
5914
+ loadTracks(true);
5915
+ }, [loadTracks]);
5916
+ (0, import_react25.useEffect)(() => {
5917
+ const unsub = host.onEngineReady(() => {
5918
+ adoptAndLoad();
4171
5919
  });
4172
- }, [initialValue]);
4173
- const setForScene = (0, import_react19.useCallback)((sceneId, value) => {
4174
- setStateMap((prev) => {
4175
- const current = prev.has(sceneId) ? prev.get(sceneId) : initialValue;
4176
- const next = typeof value === "function" ? value(current) : value;
4177
- const newMap = new Map(prev);
4178
- newMap.set(sceneId, next);
4179
- return newMap;
5920
+ return unsub;
5921
+ }, [host, adoptAndLoad]);
5922
+ (0, import_react25.useEffect)(() => {
5923
+ if (typeof host.onAfterAgentMutation !== "function") return;
5924
+ let timer = null;
5925
+ const unsub = host.onAfterAgentMutation(() => {
5926
+ if (timer) clearTimeout(timer);
5927
+ timer = setTimeout(() => {
5928
+ timer = null;
5929
+ loadTracks(true);
5930
+ }, 500);
4180
5931
  });
4181
- }, [initialValue]);
4182
- return [currentValue, setForCurrentScene, setForScene];
4183
- }
4184
-
4185
- // src/hooks/useAnySolo.ts
4186
- var import_react20 = require("react");
4187
- function useAnySolo(host) {
4188
- const [anySolo, setAnySolo] = (0, import_react20.useState)(false);
4189
- (0, import_react20.useEffect)(() => {
4190
- let active = true;
4191
- const refresh = () => {
4192
- host.isAnySoloActive().then((v) => {
4193
- if (active) setAnySolo(v);
4194
- }).catch(() => {
5932
+ return () => {
5933
+ unsub?.();
5934
+ if (timer) clearTimeout(timer);
5935
+ };
5936
+ }, [host, loadTracks]);
5937
+ (0, import_react25.useEffect)(() => {
5938
+ const unsub = host.onTrackStateChange((trackId, state) => {
5939
+ setTracks((prev) => prev.map((t) => t.handle.id === trackId ? { ...t, runtimeState: state } : t));
5940
+ });
5941
+ return unsub;
5942
+ }, [host]);
5943
+ (0, import_react25.useEffect)(() => {
5944
+ if (!features.bulkComposePlaceholders) return;
5945
+ console.log(`[${logTag}] Subscribing to composeProgress`);
5946
+ const unsub = host.onComposeProgress((event) => {
5947
+ const targetScene = event.sceneId;
5948
+ if (!targetScene) return;
5949
+ console.log(
5950
+ `[${logTag}] composeProgress event:`,
5951
+ event.phase,
5952
+ "sceneId:",
5953
+ targetScene,
5954
+ "placeholders:",
5955
+ event.placeholders?.length ?? "none"
5956
+ );
5957
+ switch (event.phase) {
5958
+ case "planning":
5959
+ setIsComposingForScene(targetScene, true);
5960
+ setPlaceholdersForScene(targetScene, []);
5961
+ break;
5962
+ case "generating":
5963
+ setIsComposingForScene(targetScene, false);
5964
+ if (event.placeholders) {
5965
+ setPlaceholdersForScene(targetScene, event.placeholders);
5966
+ }
5967
+ break;
5968
+ case "complete":
5969
+ case "error":
5970
+ setIsComposingForScene(targetScene, false);
5971
+ setPlaceholdersForScene(targetScene, EMPTY_PLACEHOLDERS);
5972
+ break;
5973
+ }
5974
+ });
5975
+ return unsub;
5976
+ }, [host, setIsComposingForScene, setPlaceholdersForScene, features.bulkComposePlaceholders, logTag]);
5977
+ (0, import_react25.useEffect)(() => {
5978
+ const refs = saveTimeoutRefs;
5979
+ return () => {
5980
+ for (const timeout of Object.values(refs.current)) {
5981
+ clearTimeout(timeout);
5982
+ }
5983
+ };
5984
+ }, []);
5985
+ const isAddingTrackRef = (0, import_react25.useRef)(false);
5986
+ const [isAddingTrack, setIsAddingTrack] = (0, import_react25.useState)(false);
5987
+ const handleAddTrack = (0, import_react25.useCallback)(async () => {
5988
+ if (isAddingTrackRef.current) return;
5989
+ if (!activeSceneId) {
5990
+ host.showToast("warning", "Select SCENE");
5991
+ return;
5992
+ }
5993
+ if (!isConnected) {
5994
+ host.showToast("warning", "Systems not connected");
5995
+ return;
5996
+ }
5997
+ if (!isAuthenticated) {
5998
+ host.showToast("warning", "Sign In Required", "Please sign in to add tracks");
5999
+ return;
6000
+ }
6001
+ if (tracks.length >= identity.maxTracks) return;
6002
+ isAddingTrackRef.current = true;
6003
+ setIsAddingTrack(true);
6004
+ try {
6005
+ const handle = await host.createTrack({
6006
+ name: `${identity.trackNamePrefix}-${Date.now()}`,
6007
+ ...adapter.createTrackOptions()
6008
+ });
6009
+ setTracks((prev) => [...prev, newTrackState(handle)]);
6010
+ onExpandSelf?.();
6011
+ setTimeout(() => {
6012
+ const inputs = document.querySelectorAll(
6013
+ `[data-testid="${identity.familyKey}-section"] [data-testid="sdk-prompt-input"]`
6014
+ );
6015
+ if (inputs.length > 0) {
6016
+ inputs[inputs.length - 1].focus();
6017
+ }
6018
+ }, 350);
6019
+ } catch (error) {
6020
+ const msg = error instanceof Error ? error.message : "Unknown error";
6021
+ host.showToast("error", "Failed to create track", msg);
6022
+ } finally {
6023
+ isAddingTrackRef.current = false;
6024
+ setIsAddingTrack(false);
6025
+ }
6026
+ }, [host, adapter, identity, activeSceneId, isConnected, isAuthenticated, tracks.length, onExpandSelf]);
6027
+ const handlePortTrack = (0, import_react25.useCallback)(
6028
+ async (sel) => {
6029
+ if (!activeSceneId) {
6030
+ host.showToast("warning", "Select SCENE");
6031
+ return;
6032
+ }
6033
+ if (!isConnected) {
6034
+ host.showToast("warning", "Systems not connected");
6035
+ return;
6036
+ }
6037
+ if (tracks.length >= identity.maxTracks) {
6038
+ host.showToast("warning", "Track limit reached");
6039
+ return;
6040
+ }
6041
+ if (!host.readImportableTrackMidi) return;
6042
+ let handle = null;
6043
+ try {
6044
+ handle = await host.createTrack({
6045
+ name: `${identity.trackNamePrefix}-${Date.now()}`,
6046
+ ...adapter.createTrackOptions()
6047
+ });
6048
+ if (sel.role) {
6049
+ try {
6050
+ await host.setTrackRole(handle.id, sel.role);
6051
+ } catch {
6052
+ }
6053
+ }
6054
+ const midi = await host.readImportableTrackMidi(sel.sourceTrackDbId);
6055
+ const notes = midi.clips[0]?.notes ?? [];
6056
+ if (notes.length > 0) {
6057
+ const mc = await host.getMusicalContext();
6058
+ await host.writeMidiClip(handle.id, {
6059
+ startTime: 0,
6060
+ endTime: mc.bars * 4 * 60 / mc.bpm,
6061
+ tempo: mc.bpm,
6062
+ notes
6063
+ });
6064
+ }
6065
+ await adapter.applyPortedTrackSound(handle, sel.role);
6066
+ host.showToast(
6067
+ "success",
6068
+ `Imported to ${identity.familyKey}`,
6069
+ notes.length ? `${sel.trackName} \u2192 ${identity.familyKey}` : `${sel.trackName} (no MIDI yet)`
6070
+ );
6071
+ await loadTracks(true);
6072
+ } catch (err) {
6073
+ if (handle) {
6074
+ try {
6075
+ await host.deleteTrack(handle.id);
6076
+ } catch {
6077
+ }
6078
+ }
6079
+ host.showToast("error", "Import failed", err instanceof Error ? err.message : String(err));
6080
+ }
6081
+ },
6082
+ [host, adapter, identity, activeSceneId, isConnected, tracks.length, loadTracks]
6083
+ );
6084
+ const handleSoundImportPick = (0, import_react25.useCallback)(
6085
+ async (sel) => {
6086
+ const target = soundImportTarget;
6087
+ if (!target || !host.getTrackSound) {
6088
+ setSoundImportTarget(null);
6089
+ return;
6090
+ }
6091
+ const noun = adapter.sound.importNoun;
6092
+ const nounTitle = noun.charAt(0).toUpperCase() + noun.slice(1);
6093
+ try {
6094
+ const snap = await host.getTrackSound(sel.sourceTrackDbId);
6095
+ if (!snap || snap.kind !== adapter.sound.acceptedSnapshotKind) {
6096
+ host.showToast(
6097
+ "error",
6098
+ `No ${noun} to import`,
6099
+ `${sel.trackName} has no ${identity.familyKey} ${noun}.`
6100
+ );
6101
+ return;
6102
+ }
6103
+ const descriptor = adapter.sound.descriptorFromSnapshot(snap);
6104
+ await adapter.sound.applySound(target.handle.id, descriptor);
6105
+ soundHistory.record(target.handle.id, descriptor, snap.label);
6106
+ host.showToast("success", `${nounTitle} imported`, `${snap.label} \u2192 ${target.handle.name}`);
6107
+ } catch (err) {
6108
+ host.showToast("error", "Import failed", err instanceof Error ? err.message : String(err));
6109
+ } finally {
6110
+ setSoundImportTarget(null);
6111
+ }
6112
+ },
6113
+ [soundImportTarget, host, adapter, identity.familyKey, soundHistory]
6114
+ );
6115
+ const [isExportingMidi, setIsExportingMidi] = (0, import_react25.useState)(false);
6116
+ const handleExportMidi = (0, import_react25.useCallback)(async () => {
6117
+ if (isExportingMidi) return;
6118
+ setIsExportingMidi(true);
6119
+ try {
6120
+ const result = await host.exportTracksAsMidiBundle({
6121
+ defaultName: identity.exportDefaultName ?? "midi-tracks"
4195
6122
  });
6123
+ if (result.success) {
6124
+ const filename = result.filePath.split("/").pop() || result.filePath;
6125
+ const skippedNote = result.skippedCount > 0 ? ` (${result.skippedCount} empty track${result.skippedCount === 1 ? "" : "s"} skipped)` : "";
6126
+ host.showToast(
6127
+ "success",
6128
+ "MIDI exported",
6129
+ `${result.trackCount} track${result.trackCount === 1 ? "" : "s"} \u2192 ${filename}${skippedNote}`
6130
+ );
6131
+ } else if (!("canceled" in result && result.canceled)) {
6132
+ const errMsg = "error" in result ? result.error : "Unknown error";
6133
+ host.showToast("error", "Export failed", errMsg);
6134
+ }
6135
+ } catch (error) {
6136
+ const msg = error instanceof Error ? error.message : String(error);
6137
+ host.showToast("error", "Export failed", msg);
6138
+ } finally {
6139
+ setIsExportingMidi(false);
6140
+ }
6141
+ }, [host, identity.exportDefaultName, isExportingMidi]);
6142
+ const isBulkActive = !!(isComposing || placeholders.length > 0);
6143
+ const needsContract = !sceneContext?.hasContract;
6144
+ const xfFromId = sceneContext?.transitionFromSceneId ?? null;
6145
+ const xfToId = sceneContext?.transitionToSceneId ?? null;
6146
+ const canCrossfade = features.transitionDesigner && sceneContext?.sceneType === "transition" && !!xfFromId && !!xfToId && !!host.listSceneFamilyTracks;
6147
+ (0, import_react25.useEffect)(() => {
6148
+ if (!canCrossfade) setDesignerView(false);
6149
+ }, [canCrossfade]);
6150
+ (0, import_react25.useEffect)(() => {
6151
+ if (!canCrossfade || !xfFromId || !xfToId || !host.listSceneFamilyTracks) {
6152
+ setTransitionSourceTotal(0);
6153
+ return;
6154
+ }
6155
+ let cancelled = false;
6156
+ void Promise.all([host.listSceneFamilyTracks(xfFromId), host.listSceneFamilyTracks(xfToId)]).then(([a, b]) => {
6157
+ if (!cancelled) setTransitionSourceTotal(a.length + b.length);
6158
+ }).catch(() => {
6159
+ if (!cancelled) setTransitionSourceTotal(0);
6160
+ });
6161
+ return () => {
6162
+ cancelled = true;
4196
6163
  };
4197
- refresh();
4198
- const unsub = host.onTrackStateChange(() => refresh());
6164
+ }, [canCrossfade, xfFromId, xfToId, host]);
6165
+ const transitionDone = crossfadePairsMeta.length * 2 + fadesMeta.length;
6166
+ (0, import_react25.useEffect)(() => {
6167
+ if (!onHeaderContent) return;
6168
+ const addDisabled = needsContract || !isConnected || !activeSceneId || tracks.length >= identity.maxTracks || isAddingTrack;
6169
+ onHeaderContent(
6170
+ /* @__PURE__ */ (0, import_jsx_runtime23.jsxs)("div", { className: "flex gap-1 items-center", children: [
6171
+ features.importTracks && (!canCrossfade || !designerView) && host.listImportableTracks && /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(
6172
+ "button",
6173
+ {
6174
+ "data-testid": `import-from-scene-${identity.familyKey}-button`,
6175
+ onClick: (e) => {
6176
+ e.stopPropagation();
6177
+ onExpandSelf?.();
6178
+ setImportOpen(true);
6179
+ },
6180
+ disabled: !activeSceneId || needsContract,
6181
+ className: `px-2 py-0.5 text-[10px] font-medium rounded-sm border transition-colors ${!activeSceneId || needsContract ? "bg-sas-panel border-sas-border text-sas-muted/50 cursor-not-allowed" : "bg-sas-panel-alt border-sas-border text-sas-muted hover:border-sas-accent hover:text-sas-accent"}`,
6182
+ children: identity.importTrackLabel ?? "Import Track"
6183
+ }
6184
+ ),
6185
+ (!canCrossfade || !designerView) && /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(
6186
+ "button",
6187
+ {
6188
+ "data-testid": `add-${identity.familyKey}-track-button`,
6189
+ onClick: (e) => {
6190
+ e.stopPropagation();
6191
+ if (needsContract) {
6192
+ onOpenContract?.();
6193
+ return;
6194
+ }
6195
+ handleAddTrack();
6196
+ },
6197
+ className: `px-2 py-0.5 text-[10px] font-medium rounded-sm border transition-colors ${addDisabled ? "bg-sas-panel border-sas-border text-sas-muted/50 cursor-not-allowed" : "bg-sas-accent/10 border-sas-accent/30 text-sas-accent hover:bg-sas-accent/20"}`,
6198
+ children: identity.addTrackLabel ?? "Add Track"
6199
+ }
6200
+ ),
6201
+ canCrossfade && /* @__PURE__ */ (0, import_jsx_runtime23.jsxs)(
6202
+ "button",
6203
+ {
6204
+ "data-testid": `${identity.familyKey}-view-toggle`,
6205
+ onClick: (e) => {
6206
+ e.stopPropagation();
6207
+ if (!designerView) {
6208
+ if (needsContract) {
6209
+ onOpenContract?.();
6210
+ return;
6211
+ }
6212
+ onExpandSelf?.();
6213
+ }
6214
+ setDesignerView((v) => !v);
6215
+ },
6216
+ disabled: !designerView && needsContract,
6217
+ title: designerView ? "Back to the track list" : "Open the transition designer",
6218
+ className: "relative overflow-hidden px-2 py-0.5 text-[10px] font-medium rounded-sm border border-sas-accent/40 text-sas-accent transition-colors hover:border-sas-accent disabled:opacity-50",
6219
+ children: [
6220
+ transitionSourceTotal > 0 && /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(
6221
+ "span",
6222
+ {
6223
+ className: "absolute inset-y-0 left-0 bg-sas-accent/25",
6224
+ style: { width: `${Math.min(100, transitionDone / transitionSourceTotal * 100)}%` },
6225
+ "aria-hidden": true
6226
+ }
6227
+ ),
6228
+ /* @__PURE__ */ (0, import_jsx_runtime23.jsxs)("span", { className: "relative", children: [
6229
+ "\u21C4 ",
6230
+ designerView ? "Transition" : "Tracks",
6231
+ transitionSourceTotal > 0 ? ` ${transitionDone}/${transitionSourceTotal}` : ""
6232
+ ] })
6233
+ ]
6234
+ }
6235
+ )
6236
+ ] })
6237
+ );
4199
6238
  return () => {
4200
- active = false;
4201
- unsub();
6239
+ onHeaderContent(null);
4202
6240
  };
4203
- }, [host]);
4204
- return anySolo;
4205
- }
4206
-
4207
- // src/hooks/useSoundHistory.ts
4208
- var import_react21 = require("react");
4209
- var EMPTY = { entries: [], cursor: -1 };
4210
- function sameDescriptor(a, b) {
4211
- if (a === b) return true;
4212
- try {
4213
- return JSON.stringify(a) === JSON.stringify(b);
4214
- } catch {
4215
- return false;
4216
- }
4217
- }
4218
- function useSoundHistory(applySound, opts = {}) {
4219
- const max = Math.max(2, opts.max ?? 24);
4220
- const applyRef = (0, import_react21.useRef)(applySound);
4221
- applyRef.current = applySound;
4222
- const onChangeRef = (0, import_react21.useRef)(opts.onChange);
4223
- onChangeRef.current = opts.onChange;
4224
- const dataRef = (0, import_react21.useRef)({});
4225
- const [, setVersion] = (0, import_react21.useState)(0);
4226
- const bump = (0, import_react21.useCallback)(() => setVersion((v) => v + 1), []);
4227
- const commit = (0, import_react21.useCallback)(
4228
- (trackId, next, notify) => {
4229
- dataRef.current = { ...dataRef.current, [trackId]: next };
4230
- bump();
4231
- if (notify) onChangeRef.current?.(trackId, next);
6241
+ }, [
6242
+ onHeaderContent,
6243
+ needsContract,
6244
+ isConnected,
6245
+ activeSceneId,
6246
+ tracks.length,
6247
+ isAddingTrack,
6248
+ handleAddTrack,
6249
+ onOpenContract,
6250
+ host,
6251
+ canCrossfade,
6252
+ designerView,
6253
+ transitionDone,
6254
+ transitionSourceTotal,
6255
+ onExpandSelf,
6256
+ identity,
6257
+ features.importTracks
6258
+ ]);
6259
+ (0, import_react25.useEffect)(() => {
6260
+ if (!onLoading) return;
6261
+ const anyGenerating = tracks.some((t) => t.isGenerating);
6262
+ onLoading(isLoadingTracks || anyGenerating || isBulkActive);
6263
+ return () => {
6264
+ onLoading(false);
6265
+ };
6266
+ }, [onLoading, isLoadingTracks, tracks, isBulkActive]);
6267
+ const handleDeleteTrack = (0, import_react25.useCallback)(
6268
+ async (trackId) => {
6269
+ try {
6270
+ await host.deleteTrack(trackId);
6271
+ const dbId = engineToDbIdRef.current.get(trackId) ?? trackId;
6272
+ if (activeSceneId) {
6273
+ await host.deleteSceneData(activeSceneId, trackDataKey(dbId, "prompt"));
6274
+ }
6275
+ setTracks((prev) => prev.filter((t) => t.handle.id !== trackId));
6276
+ } catch (error) {
6277
+ const msg = error instanceof Error ? error.message : "Unknown error";
6278
+ host.showToast("error", "Failed to delete track", msg);
6279
+ }
4232
6280
  },
4233
- [bump]
6281
+ [host, activeSceneId]
6282
+ );
6283
+ const handlePromptChange = (0, import_react25.useCallback)(
6284
+ (trackId, prompt) => {
6285
+ setTracks((prev) => prev.map((t) => t.handle.id === trackId ? { ...t, prompt } : t));
6286
+ const dbId = engineToDbIdRef.current.get(trackId) ?? trackId;
6287
+ if (saveTimeoutRefs.current[trackId]) {
6288
+ clearTimeout(saveTimeoutRefs.current[trackId]);
6289
+ }
6290
+ saveTimeoutRefs.current[trackId] = setTimeout(() => {
6291
+ if (activeSceneId) {
6292
+ host.setSceneData(activeSceneId, trackDataKey(dbId, "prompt"), prompt).catch(() => {
6293
+ });
6294
+ }
6295
+ }, 500);
6296
+ },
6297
+ [host, activeSceneId]
6298
+ );
6299
+ const resolvedGenericGroups = (0, import_react25.useMemo)(() => {
6300
+ const out = {};
6301
+ for (const ext of adapter.groupExtensions ?? []) {
6302
+ out[ext.metaKey] = resolveTrackGroups(
6303
+ genericGroupMetas[ext.metaKey] ?? [],
6304
+ tracks,
6305
+ (t) => t.handle.dbId,
6306
+ {
6307
+ isComplete: ext.isComplete
6308
+ }
6309
+ );
6310
+ }
6311
+ return out;
6312
+ }, [adapter, genericGroupMetas, tracks]);
6313
+ const genericGroupMemberDbIds = (0, import_react25.useMemo)(() => {
6314
+ const s = /* @__PURE__ */ new Set();
6315
+ for (const r of Object.values(resolvedGenericGroups)) {
6316
+ for (const dbId of r.memberDbIds) s.add(dbId);
6317
+ }
6318
+ return s;
6319
+ }, [resolvedGenericGroups]);
6320
+ const engineToDbId = (0, import_react25.useCallback)(
6321
+ (trackId) => engineToDbIdRef.current.get(trackId) ?? trackId,
6322
+ []
6323
+ );
6324
+ const updateTrack = (0, import_react25.useCallback)(
6325
+ (trackId, patch) => {
6326
+ setTracks(
6327
+ (prev) => prev.map(
6328
+ (t) => t.handle.id === trackId ? typeof patch === "function" ? patch(t) : { ...t, ...patch } : t
6329
+ )
6330
+ );
6331
+ },
6332
+ []
6333
+ );
6334
+ const markEditLoaded = (0, import_react25.useCallback)((trackId) => {
6335
+ editLoadStartedRef.current.add(trackId);
6336
+ }, []);
6337
+ const tracksRef = (0, import_react25.useRef)(tracks);
6338
+ (0, import_react25.useEffect)(() => {
6339
+ tracksRef.current = tracks;
6340
+ }, [tracks]);
6341
+ const resolvedGenericGroupsRef = (0, import_react25.useRef)(resolvedGenericGroups);
6342
+ (0, import_react25.useEffect)(() => {
6343
+ resolvedGenericGroupsRef.current = resolvedGenericGroups;
6344
+ }, [resolvedGenericGroups]);
6345
+ const makeServices = (0, import_react25.useCallback)(() => {
6346
+ return {
6347
+ host,
6348
+ activeSceneId,
6349
+ tracks: tracksRef.current,
6350
+ updateTrack,
6351
+ setTracks,
6352
+ reloadTracks: loadTracks,
6353
+ soundHistory,
6354
+ engineToDbId,
6355
+ trackDataKey,
6356
+ markEditLoaded,
6357
+ createFamilyTrack: (nameSuffix = "") => host.createTrack({
6358
+ name: `${identity.trackNamePrefix}-${Date.now()}${nameSuffix}`,
6359
+ ...adapter.createTrackOptions()
6360
+ }),
6361
+ resolvedGroups: (metaKey) => resolvedGenericGroupsRef.current[metaKey]?.resolved ?? []
6362
+ };
6363
+ }, [host, activeSceneId, updateTrack, loadTracks, soundHistory, engineToDbId, markEditLoaded, identity, adapter]);
6364
+ const handleGenerate = (0, import_react25.useCallback)(
6365
+ async (trackId) => {
6366
+ const track = tracks.find((t) => t.handle.id === trackId);
6367
+ if (!track || !track.prompt.trim()) return;
6368
+ if (!isAuthenticated) {
6369
+ host.showToast("warning", "Sign In Required", "Please sign in to generate MIDI");
6370
+ return;
6371
+ }
6372
+ setTracks(
6373
+ (prev) => prev.map(
6374
+ (t) => t.handle.id === trackId ? { ...t, isGenerating: true, error: null, generationProgress: 0 } : t
6375
+ )
6376
+ );
6377
+ try {
6378
+ await adapter.generation.generate(track, makeServices());
6379
+ } catch (error) {
6380
+ const msg = error instanceof Error ? error.message : "Generation failed";
6381
+ setTracks(
6382
+ (prev) => prev.map(
6383
+ (t) => t.handle.id === trackId ? { ...t, isGenerating: false, error: msg, generationProgress: 0 } : t
6384
+ )
6385
+ );
6386
+ host.showToast("error", "Generation failed", msg);
6387
+ }
6388
+ },
6389
+ [host, adapter, tracks, isAuthenticated, makeServices]
6390
+ );
6391
+ const handleMuteToggle = (0, import_react25.useCallback)(
6392
+ (trackId) => {
6393
+ const track = tracks.find((t) => t.handle.id === trackId);
6394
+ if (!track) return;
6395
+ const newMuted = !track.runtimeState.muted;
6396
+ setTracks(
6397
+ (prev) => prev.map(
6398
+ (t) => t.handle.id === trackId ? { ...t, runtimeState: { ...t.runtimeState, muted: newMuted } } : t
6399
+ )
6400
+ );
6401
+ host.setTrackMute(trackId, newMuted).catch(() => {
6402
+ setTracks(
6403
+ (prev) => prev.map(
6404
+ (t) => t.handle.id === trackId ? { ...t, runtimeState: { ...t.runtimeState, muted: !newMuted } } : t
6405
+ )
6406
+ );
6407
+ });
6408
+ },
6409
+ [host, tracks]
6410
+ );
6411
+ const handleSoloToggle = (0, import_react25.useCallback)(
6412
+ (trackId) => {
6413
+ const track = tracks.find((t) => t.handle.id === trackId);
6414
+ if (!track) return;
6415
+ const newSolo = !track.runtimeState.solo;
6416
+ setTracks(
6417
+ (prev) => prev.map(
6418
+ (t) => t.handle.id === trackId ? { ...t, runtimeState: { ...t.runtimeState, solo: newSolo } } : t
6419
+ )
6420
+ );
6421
+ host.setTrackSolo(trackId, newSolo).catch(() => {
6422
+ setTracks(
6423
+ (prev) => prev.map(
6424
+ (t) => t.handle.id === trackId ? { ...t, runtimeState: { ...t.runtimeState, solo: !newSolo } } : t
6425
+ )
6426
+ );
6427
+ });
6428
+ },
6429
+ [host, tracks]
6430
+ );
6431
+ const handleVolumeChange = (0, import_react25.useCallback)(
6432
+ (trackId, volume) => {
6433
+ setTracks(
6434
+ (prev) => prev.map((t) => t.handle.id === trackId ? { ...t, runtimeState: { ...t.runtimeState, volume } } : t)
6435
+ );
6436
+ host.setTrackVolume(trackId, volume).catch(() => {
6437
+ });
6438
+ },
6439
+ [host]
6440
+ );
6441
+ const handlePanChange = (0, import_react25.useCallback)(
6442
+ (trackId, pan) => {
6443
+ setTracks(
6444
+ (prev) => prev.map((t) => t.handle.id === trackId ? { ...t, runtimeState: { ...t.runtimeState, pan } } : t)
6445
+ );
6446
+ host.setTrackPan(trackId, pan).catch(() => {
6447
+ });
6448
+ },
6449
+ [host]
6450
+ );
6451
+ const handleShuffle = (0, import_react25.useCallback)(
6452
+ async (trackId) => {
6453
+ const track = tracks.find((t) => t.handle.id === trackId);
6454
+ if (!track) return;
6455
+ if (soundHistory.list(trackId).entries.length === 0) {
6456
+ try {
6457
+ const cap = await adapter.sound.captureSoundDescriptor(trackId);
6458
+ if (cap) soundHistory.record(trackId, cap.descriptor, adapter.sound.previousSoundLabel);
6459
+ } catch {
6460
+ }
6461
+ }
6462
+ try {
6463
+ let result;
6464
+ let nextHistory;
6465
+ try {
6466
+ result = await adapter.shuffle.shuffle(track, Array.from(track.shuffleHistory));
6467
+ nextHistory = new Set(track.shuffleHistory);
6468
+ } catch (firstErr) {
6469
+ if (adapter.shuffle.isExhaustedError(firstErr)) {
6470
+ nextHistory = /* @__PURE__ */ new Set();
6471
+ result = await adapter.shuffle.shuffle(track, []);
6472
+ } else {
6473
+ throw firstErr;
6474
+ }
6475
+ }
6476
+ nextHistory.add(result.appliedName);
6477
+ setTracks(
6478
+ (prev) => prev.map((t) => t.handle.id === trackId ? { ...t, shuffleHistory: nextHistory } : t)
6479
+ );
6480
+ try {
6481
+ const cap = await adapter.sound.captureSoundDescriptor(trackId);
6482
+ if (cap) soundHistory.record(trackId, cap.descriptor, result.appliedName);
6483
+ } catch {
6484
+ }
6485
+ console.log(`[${logTag}] Sound shuffled: ${result.appliedName} (history ${nextHistory.size})`);
6486
+ } catch (error) {
6487
+ const msg = error instanceof Error ? error.message : "Shuffle failed";
6488
+ host.showToast("error", "Shuffle failed", msg);
6489
+ }
6490
+ },
6491
+ [host, adapter, tracks, soundHistory, logTag]
6492
+ );
6493
+ const handleCopy = (0, import_react25.useCallback)(
6494
+ async (trackId) => {
6495
+ try {
6496
+ const newHandle = await host.duplicateTrack(trackId);
6497
+ await loadTracks();
6498
+ host.showToast("success", "Track duplicated", newHandle.name);
6499
+ } catch (error) {
6500
+ const msg = error instanceof Error ? error.message : "Copy failed";
6501
+ host.showToast("error", "Copy failed", msg);
6502
+ }
6503
+ },
6504
+ [host, loadTracks]
6505
+ );
6506
+ const handleFxToggle = (0, import_react25.useCallback)(
6507
+ (trackId, category, enabled) => {
6508
+ setTracks(
6509
+ (prev) => prev.map(
6510
+ (t) => t.handle.id === trackId ? { ...t, fxDetailState: { ...t.fxDetailState, [category]: { ...t.fxDetailState[category], enabled } } } : t
6511
+ )
6512
+ );
6513
+ host.toggleTrackFx(trackId, category, enabled).catch(() => {
6514
+ setTracks(
6515
+ (prev) => prev.map(
6516
+ (t) => t.handle.id === trackId ? {
6517
+ ...t,
6518
+ fxDetailState: {
6519
+ ...t.fxDetailState,
6520
+ [category]: { ...t.fxDetailState[category], enabled: !enabled }
6521
+ }
6522
+ } : t
6523
+ )
6524
+ );
6525
+ });
6526
+ },
6527
+ [host]
6528
+ );
6529
+ const handleFxPresetChange = (0, import_react25.useCallback)(
6530
+ (trackId, category, presetIndex) => {
6531
+ setTracks(
6532
+ (prev) => prev.map(
6533
+ (t) => t.handle.id === trackId ? { ...t, fxDetailState: { ...t.fxDetailState, [category]: { ...t.fxDetailState[category], presetIndex } } } : t
6534
+ )
6535
+ );
6536
+ host.setTrackFxPreset(trackId, category, presetIndex).then((result) => {
6537
+ if (result.dryWet !== void 0) {
6538
+ setTracks(
6539
+ (prev) => prev.map(
6540
+ (t) => t.handle.id === trackId ? {
6541
+ ...t,
6542
+ fxDetailState: {
6543
+ ...t.fxDetailState,
6544
+ [category]: { ...t.fxDetailState[category], dryWet: result.dryWet }
6545
+ }
6546
+ } : t
6547
+ )
6548
+ );
6549
+ }
6550
+ }).catch(() => {
6551
+ });
6552
+ },
6553
+ [host]
6554
+ );
6555
+ const handleFxDryWetChange = (0, import_react25.useCallback)(
6556
+ (trackId, category, value) => {
6557
+ setTracks(
6558
+ (prev) => prev.map(
6559
+ (t) => t.handle.id === trackId ? { ...t, fxDetailState: { ...t.fxDetailState, [category]: { ...t.fxDetailState[category], dryWet: value } } } : t
6560
+ )
6561
+ );
6562
+ host.setTrackFxDryWet(trackId, category, value).catch(() => {
6563
+ });
6564
+ },
6565
+ [host]
4234
6566
  );
4235
- const record = (0, import_react21.useCallback)(
4236
- (trackId, descriptor, label) => {
4237
- const h = dataRef.current[trackId];
4238
- const current = h && h.cursor >= 0 ? h.entries[h.cursor] : void 0;
4239
- if (current && sameDescriptor(current.descriptor, descriptor)) return;
4240
- const entries = [...h ? h.entries : [], { descriptor, label }];
4241
- while (entries.length > max) {
4242
- const victim = entries.findIndex((e) => !e.favorite);
4243
- if (victim === -1) break;
4244
- entries.splice(victim, 1);
6567
+ const toggleFxDrawer = (0, import_react25.useCallback)(
6568
+ (trackId) => {
6569
+ setTracks(
6570
+ (prev) => prev.map((t) => {
6571
+ if (t.handle.id !== trackId) return t;
6572
+ const onFx = t.drawerOpen && t.drawerTab === "fx";
6573
+ return { ...t, drawerOpen: !onFx, drawerTab: "fx", editorStage: false };
6574
+ })
6575
+ );
6576
+ const track = tracks.find((t) => t.handle.id === trackId);
6577
+ const wasOnFx = !!track && track.drawerOpen && track.drawerTab === "fx";
6578
+ if (track && !wasOnFx) {
6579
+ host.getTrackFxState(trackId).then((fxState) => {
6580
+ setTracks(
6581
+ (prev) => prev.map((t) => t.handle.id === trackId ? { ...t, fxDetailState: pluginFxToToggleFx(fxState) } : t)
6582
+ );
6583
+ }).catch(() => {
6584
+ });
4245
6585
  }
4246
- commit(trackId, { entries, cursor: entries.length - 1 }, true);
4247
6586
  },
4248
- [max, commit]
6587
+ [host, tracks]
4249
6588
  );
4250
- const restoreTo = (0, import_react21.useCallback)(
4251
- async (trackId, index) => {
4252
- const h = dataRef.current[trackId];
4253
- if (!h || index < 0 || index >= h.entries.length || index === h.cursor) return false;
4254
- await applyRef.current(trackId, h.entries[index].descriptor);
4255
- commit(trackId, { entries: h.entries, cursor: index }, true);
4256
- return true;
6589
+ const loadEditNotes = (0, import_react25.useCallback)(
6590
+ async (trackId) => {
6591
+ try {
6592
+ const mc = await host.getMusicalContext();
6593
+ let notes = [];
6594
+ if (typeof host.readMidiNotes === "function") {
6595
+ const result = await host.readMidiNotes(trackId);
6596
+ notes = result.clips[0]?.notes ?? [];
6597
+ }
6598
+ setTracks(
6599
+ (prev) => prev.map((t) => t.handle.id === trackId ? { ...t, editNotes: notes, editBars: mc.bars, editBpm: mc.bpm } : t)
6600
+ );
6601
+ } catch (err) {
6602
+ console.warn(`[${logTag}] Failed to load MIDI for editing:`, err);
6603
+ }
4257
6604
  },
4258
- [commit]
6605
+ [host, logTag]
4259
6606
  );
4260
- const undo = (0, import_react21.useCallback)(
4261
- (trackId) => {
4262
- const h = dataRef.current[trackId];
4263
- if (!h || h.cursor <= 0) return Promise.resolve(false);
4264
- return restoreTo(trackId, h.cursor - 1);
6607
+ const handleNotesChange = (0, import_react25.useCallback)(
6608
+ (trackId, notes) => {
6609
+ setTracks((prev) => prev.map((t) => t.handle.id === trackId ? { ...t, editNotes: notes } : t));
6610
+ const key = `edit:${trackId}`;
6611
+ if (saveTimeoutRefs.current[key]) {
6612
+ clearTimeout(saveTimeoutRefs.current[key]);
6613
+ }
6614
+ saveTimeoutRefs.current[key] = setTimeout(() => {
6615
+ void (async () => {
6616
+ try {
6617
+ if (notes.length === 0) {
6618
+ await host.clearMidi(trackId);
6619
+ } else {
6620
+ const mc = await host.getMusicalContext();
6621
+ await host.writeMidiClip(trackId, {
6622
+ startTime: 0,
6623
+ endTime: mc.bars * 4 * 60 / mc.bpm,
6624
+ tempo: mc.bpm,
6625
+ notes
6626
+ });
6627
+ }
6628
+ } catch (err) {
6629
+ const msg = err instanceof Error ? err.message : String(err);
6630
+ host.showToast("error", "Failed to save edit", msg);
6631
+ }
6632
+ })();
6633
+ }, 300);
4265
6634
  },
4266
- [restoreTo]
6635
+ [host]
4267
6636
  );
4268
- const toggleFavorite = (0, import_react21.useCallback)(
4269
- (trackId, index) => {
4270
- const h = dataRef.current[trackId];
4271
- if (!h || index < 0 || index >= h.entries.length) return;
4272
- const entries = h.entries.map((e, i) => i === index ? { ...e, favorite: !e.favorite } : e);
4273
- commit(trackId, { entries, cursor: h.cursor }, true);
6637
+ const handleTabChange = (0, import_react25.useCallback)(
6638
+ (trackId, tab) => {
6639
+ setTracks((prev) => prev.map((t) => t.handle.id === trackId ? { ...t, drawerOpen: true, drawerTab: tab } : t));
6640
+ if (tab === "fx") {
6641
+ host.getTrackFxState(trackId).then((fxState) => {
6642
+ setTracks(
6643
+ (prev) => prev.map((t) => t.handle.id === trackId ? { ...t, fxDetailState: pluginFxToToggleFx(fxState) } : t)
6644
+ );
6645
+ }).catch(() => {
6646
+ });
6647
+ } else if (tab === "pick" && availableInstruments.length === 0 && !instrumentsLoading) {
6648
+ setInstrumentsLoading(true);
6649
+ host.getAvailableInstruments().then((instruments) => {
6650
+ setAvailableInstruments(instruments);
6651
+ }).catch(() => {
6652
+ }).finally(() => {
6653
+ setInstrumentsLoading(false);
6654
+ });
6655
+ } else if (tab === "edit" && !editLoadStartedRef.current.has(trackId)) {
6656
+ editLoadStartedRef.current.add(trackId);
6657
+ void loadEditNotes(trackId);
6658
+ }
4274
6659
  },
4275
- [commit]
6660
+ [host, availableInstruments.length, instrumentsLoading, loadEditNotes]
4276
6661
  );
4277
- const restore = (0, import_react21.useCallback)(
4278
- (trackId, state) => {
4279
- const entries = Array.isArray(state?.entries) ? [...state.entries] : [];
4280
- const raw = typeof state?.cursor === "number" ? state.cursor : entries.length - 1;
4281
- const cursor = entries.length === 0 ? -1 : Math.min(Math.max(raw, 0), entries.length - 1);
4282
- commit(trackId, { entries, cursor }, false);
6662
+ const handleProgressChange = (0, import_react25.useCallback)((trackId, pct) => {
6663
+ setTracks((prev) => prev.map((t) => t.handle.id === trackId ? { ...t, generationProgress: pct } : t));
6664
+ }, []);
6665
+ const handleToggleDrawer = (0, import_react25.useCallback)((trackId) => {
6666
+ setTracks(
6667
+ (prev) => prev.map((t) => {
6668
+ if (t.handle.id !== trackId) return t;
6669
+ const onSound = t.drawerOpen && t.drawerTab !== "fx";
6670
+ return { ...t, drawerOpen: !onSound, drawerTab: "history", editorStage: false };
6671
+ })
6672
+ );
6673
+ }, []);
6674
+ const handleInstrumentSelect = (0, import_react25.useCallback)(
6675
+ async (trackId, pluginId) => {
6676
+ const isDefaultInstrument = pluginId === (identity.defaultInstrumentPluginId ?? "Surge XT");
6677
+ if (isDefaultInstrument) {
6678
+ setTracks(
6679
+ (prev) => prev.map((t) => t.handle.id === trackId ? { ...t, drawerOpen: false, editorStage: false } : t)
6680
+ );
6681
+ try {
6682
+ await host.setTrackInstrument(trackId, pluginId);
6683
+ const descriptor = await host.getTrackInstrument(trackId);
6684
+ setTracks(
6685
+ (prev) => prev.map(
6686
+ (t) => t.handle.id === trackId ? {
6687
+ ...t,
6688
+ instrumentPluginId: descriptor?.pluginId ?? null,
6689
+ instrumentName: descriptor?.name ?? null,
6690
+ instrumentMissing: descriptor?.missing ?? false
6691
+ } : t
6692
+ )
6693
+ );
6694
+ } catch (err) {
6695
+ const msg = err instanceof Error ? err.message : "Failed to load instrument";
6696
+ host.showToast("error", "Instrument load failed", msg);
6697
+ }
6698
+ return;
6699
+ }
6700
+ setTracks(
6701
+ (prev) => prev.map((t) => t.handle.id === trackId ? { ...t, drawerTab: "pick", editorStage: true } : t)
6702
+ );
6703
+ try {
6704
+ await host.setTrackInstrument(trackId, pluginId);
6705
+ const descriptor = await host.getTrackInstrument(trackId);
6706
+ setTracks(
6707
+ (prev) => prev.map(
6708
+ (t) => t.handle.id === trackId ? {
6709
+ ...t,
6710
+ instrumentPluginId: descriptor?.pluginId ?? null,
6711
+ instrumentName: descriptor?.name ?? null,
6712
+ instrumentMissing: descriptor?.missing ?? false
6713
+ } : t
6714
+ )
6715
+ );
6716
+ } catch (err) {
6717
+ const msg = err instanceof Error ? err.message : "Failed to load instrument";
6718
+ console.error(`[${logTag}] Failed to set instrument:`, err);
6719
+ host.showToast("error", "Instrument load failed", msg);
6720
+ setTracks(
6721
+ (prev) => prev.map((t) => t.handle.id === trackId ? { ...t, editorStage: false } : t)
6722
+ );
6723
+ }
4283
6724
  },
4284
- [commit]
6725
+ [host, identity.defaultInstrumentPluginId, logTag]
4285
6726
  );
4286
- const list = (0, import_react21.useCallback)(
4287
- (trackId) => dataRef.current[trackId] ?? EMPTY,
4288
- []
6727
+ const handleShowEditor = (0, import_react25.useCallback)(
6728
+ async (trackId) => {
6729
+ try {
6730
+ await host.showInstrumentEditor(trackId);
6731
+ } catch (err) {
6732
+ const msg = err instanceof Error ? err.message : "Failed to open editor";
6733
+ host.showToast("error", "Editor failed", msg);
6734
+ }
6735
+ },
6736
+ [host]
4289
6737
  );
4290
- const canUndo = (0, import_react21.useCallback)((trackId) => {
4291
- const h = dataRef.current[trackId];
4292
- return !!h && h.cursor > 0;
6738
+ const handleBackToInstruments = (0, import_react25.useCallback)((trackId) => {
6739
+ setTracks(
6740
+ (prev) => prev.map((t) => t.handle.id === trackId ? { ...t, editorStage: false } : t)
6741
+ );
4293
6742
  }, []);
4294
- const clear = (0, import_react21.useCallback)(
4295
- (trackId) => {
4296
- if (dataRef.current[trackId]) {
4297
- const next = { ...dataRef.current };
4298
- delete next[trackId];
4299
- dataRef.current = next;
4300
- bump();
6743
+ const handleRefreshInstruments = (0, import_react25.useCallback)(() => {
6744
+ setInstrumentsLoading(true);
6745
+ host.getAvailableInstruments().then((instruments) => {
6746
+ setAvailableInstruments(instruments);
6747
+ }).catch(() => {
6748
+ }).finally(() => {
6749
+ setInstrumentsLoading(false);
6750
+ });
6751
+ }, [host]);
6752
+ const onAuditionNote = (0, import_react25.useCallback)(
6753
+ (trackId, pitch, velocity, ms) => {
6754
+ void host.auditionNote(trackId, pitch, velocity, ms);
6755
+ },
6756
+ [host]
6757
+ );
6758
+ const { resolvedCrossfadePairs, crossfadeMemberDbIds } = (0, import_react25.useMemo)(() => {
6759
+ const byDbId = new Map(tracks.map((t) => [t.handle.dbId, t]));
6760
+ const pairs = [];
6761
+ const members = /* @__PURE__ */ new Set();
6762
+ for (const p of crossfadePairsMeta) {
6763
+ const origin = byDbId.get(p.originDbId);
6764
+ const target = byDbId.get(p.targetDbId);
6765
+ if (origin && target) {
6766
+ pairs.push({ ...p, origin, target });
6767
+ members.add(p.originDbId);
6768
+ members.add(p.targetDbId);
6769
+ }
6770
+ }
6771
+ return { resolvedCrossfadePairs: pairs, crossfadeMemberDbIds: members };
6772
+ }, [tracks, crossfadePairsMeta]);
6773
+ const { resolvedFades, fadeMemberDbIds } = (0, import_react25.useMemo)(() => {
6774
+ const byDbId = new Map(tracks.map((t) => [t.handle.dbId, t]));
6775
+ const list = [];
6776
+ const members = /* @__PURE__ */ new Set();
6777
+ for (const f of fadesMeta) {
6778
+ const track = byDbId.get(f.dbId);
6779
+ if (track) {
6780
+ list.push({ ...f, track });
6781
+ members.add(f.dbId);
6782
+ }
6783
+ }
6784
+ return { resolvedFades: list, fadeMemberDbIds: members };
6785
+ }, [tracks, fadesMeta]);
6786
+ const transition = useTransitionOps({
6787
+ host,
6788
+ adapter,
6789
+ activeSceneId,
6790
+ isConnected,
6791
+ isAuthenticated,
6792
+ sceneContext,
6793
+ tracks,
6794
+ setTracks,
6795
+ loadTracks,
6796
+ setCrossfadePairsMeta,
6797
+ setFadesMeta,
6798
+ resolvedCrossfadePairs,
6799
+ resolvedFades
6800
+ });
6801
+ const setGroupMute = (0, import_react25.useCallback)(
6802
+ (trackIds, muted) => {
6803
+ for (const id of trackIds) {
6804
+ setTracks(
6805
+ (prev) => prev.map((t) => t.handle.id === id ? { ...t, runtimeState: { ...t.runtimeState, muted } } : t)
6806
+ );
6807
+ host.setTrackMute(id, muted).catch(() => {
6808
+ });
4301
6809
  }
4302
- onChangeRef.current?.(trackId, EMPTY);
4303
6810
  },
4304
- [bump]
6811
+ [host]
4305
6812
  );
4306
- const reset = (0, import_react21.useCallback)(() => {
4307
- dataRef.current = {};
4308
- bump();
4309
- }, [bump]);
4310
- return (0, import_react21.useMemo)(
4311
- () => ({ record, undo, restoreTo, list, canUndo, clear, reset, restore, toggleFavorite }),
4312
- [record, undo, restoreTo, list, canUndo, clear, reset, restore, toggleFavorite]
6813
+ const setGroupSolo = (0, import_react25.useCallback)(
6814
+ (trackIds, solo) => {
6815
+ for (const id of trackIds) {
6816
+ setTracks(
6817
+ (prev) => prev.map((t) => t.handle.id === id ? { ...t, runtimeState: { ...t.runtimeState, solo } } : t)
6818
+ );
6819
+ host.setTrackSolo(id, solo).catch(() => {
6820
+ });
6821
+ }
6822
+ },
6823
+ [host]
6824
+ );
6825
+ const deleteGroup = (0, import_react25.useCallback)(
6826
+ async (members, cleanupKeySuffixes) => {
6827
+ for (const member of members) {
6828
+ try {
6829
+ await host.deleteTrack(member.engineId);
6830
+ } catch {
6831
+ }
6832
+ if (activeSceneId) {
6833
+ for (const suffix of cleanupKeySuffixes) {
6834
+ await host.deleteSceneData(activeSceneId, trackDataKey(member.dbId, suffix)).catch(() => {
6835
+ });
6836
+ }
6837
+ }
6838
+ }
6839
+ const gone = new Set(members.map((m) => m.engineId));
6840
+ setTracks((prev) => prev.filter((t) => !gone.has(t.handle.id)));
6841
+ await loadTracks(true);
6842
+ },
6843
+ [host, activeSceneId, loadTracks]
6844
+ );
6845
+ const handlers = (0, import_react25.useMemo)(
6846
+ () => ({
6847
+ promptChange: handlePromptChange,
6848
+ generate: (trackId) => {
6849
+ void handleGenerate(trackId);
6850
+ },
6851
+ shuffle: (trackId) => {
6852
+ void handleShuffle(trackId);
6853
+ },
6854
+ copy: (trackId) => {
6855
+ void handleCopy(trackId);
6856
+ },
6857
+ delete: (trackId) => {
6858
+ void handleDeleteTrack(trackId);
6859
+ },
6860
+ muteToggle: handleMuteToggle,
6861
+ soloToggle: handleSoloToggle,
6862
+ volumeChange: handleVolumeChange,
6863
+ panChange: handlePanChange,
6864
+ tabChange: handleTabChange,
6865
+ toggleDrawer: handleToggleDrawer,
6866
+ toggleFxDrawer,
6867
+ notesChange: handleNotesChange,
6868
+ progressChange: handleProgressChange
6869
+ }),
6870
+ [
6871
+ handlePromptChange,
6872
+ handleGenerate,
6873
+ handleShuffle,
6874
+ handleCopy,
6875
+ handleDeleteTrack,
6876
+ handleMuteToggle,
6877
+ handleSoloToggle,
6878
+ handleVolumeChange,
6879
+ handlePanChange,
6880
+ handleTabChange,
6881
+ handleToggleDrawer,
6882
+ toggleFxDrawer,
6883
+ handleNotesChange,
6884
+ handleProgressChange
6885
+ ]
4313
6886
  );
6887
+ return {
6888
+ ui,
6889
+ adapter,
6890
+ tracks,
6891
+ setTracks,
6892
+ isLoadingTracks,
6893
+ loadTracks,
6894
+ engineToDbId,
6895
+ supportsMeters,
6896
+ trackLevels,
6897
+ anySolo,
6898
+ reorder,
6899
+ soundHistory,
6900
+ isComposing,
6901
+ placeholders,
6902
+ isAddingTrack,
6903
+ isExportingMidi,
6904
+ designerView,
6905
+ canCrossfade,
6906
+ needsContract,
6907
+ xfFromId,
6908
+ xfToId,
6909
+ importOpen,
6910
+ setImportOpen,
6911
+ soundImportTarget,
6912
+ setSoundImportTarget,
6913
+ handleSoundImportPick,
6914
+ handlePortTrack,
6915
+ transition,
6916
+ crossfadePairsMeta,
6917
+ fadesMeta,
6918
+ resolvedCrossfadePairs,
6919
+ crossfadeMemberDbIds,
6920
+ resolvedFades,
6921
+ fadeMemberDbIds,
6922
+ resolvedGenericGroups,
6923
+ genericGroupMemberDbIds,
6924
+ availableInstruments,
6925
+ instrumentsLoading,
6926
+ handlers,
6927
+ handleGenerate,
6928
+ handleShuffle,
6929
+ handleAddTrack,
6930
+ handleDeleteTrack,
6931
+ handleExportMidi,
6932
+ handlePromptChange,
6933
+ handleMuteToggle,
6934
+ handleSoloToggle,
6935
+ handleVolumeChange,
6936
+ handlePanChange,
6937
+ handleTabChange,
6938
+ handleToggleDrawer,
6939
+ toggleFxDrawer,
6940
+ handleNotesChange,
6941
+ handleProgressChange,
6942
+ handleCopy,
6943
+ handleFxToggle,
6944
+ handleFxPresetChange,
6945
+ handleFxDryWetChange,
6946
+ handleInstrumentSelect,
6947
+ handleShowEditor,
6948
+ handleBackToInstruments,
6949
+ handleRefreshInstruments,
6950
+ onAuditionNote,
6951
+ makeServices,
6952
+ setGroupMute,
6953
+ setGroupSolo,
6954
+ deleteGroup
6955
+ };
4314
6956
  }
4315
6957
 
4316
- // src/hooks/useTrackReorder.ts
4317
- var import_react22 = require("react");
4318
- function moveItem(arr, from, to) {
4319
- const next = arr.slice();
4320
- if (from === to || from < 0 || to < 0 || from >= next.length || to >= next.length) {
4321
- return next;
6958
+ // src/panel-core/GeneratorPanelShell.tsx
6959
+ var import_react26 = __toESM(require("react"));
6960
+ var import_jsx_runtime24 = require("react/jsx-runtime");
6961
+ function GeneratorPanelShell({ core, slots }) {
6962
+ const {
6963
+ ui,
6964
+ adapter,
6965
+ tracks,
6966
+ isLoadingTracks,
6967
+ supportsMeters,
6968
+ trackLevels,
6969
+ anySolo,
6970
+ reorder,
6971
+ soundHistory,
6972
+ isComposing,
6973
+ placeholders,
6974
+ designerView,
6975
+ canCrossfade,
6976
+ xfFromId,
6977
+ xfToId,
6978
+ importOpen,
6979
+ setImportOpen,
6980
+ soundImportTarget,
6981
+ setSoundImportTarget,
6982
+ handleSoundImportPick,
6983
+ handlePortTrack,
6984
+ transition,
6985
+ crossfadePairsMeta,
6986
+ fadesMeta,
6987
+ resolvedCrossfadePairs,
6988
+ crossfadeMemberDbIds,
6989
+ resolvedFades,
6990
+ fadeMemberDbIds,
6991
+ resolvedGenericGroups,
6992
+ genericGroupMemberDbIds,
6993
+ availableInstruments,
6994
+ instrumentsLoading,
6995
+ handlers,
6996
+ isExportingMidi,
6997
+ handleExportMidi,
6998
+ handleFxToggle,
6999
+ handleFxPresetChange,
7000
+ handleFxDryWetChange,
7001
+ handleInstrumentSelect,
7002
+ handleShowEditor,
7003
+ handleBackToInstruments,
7004
+ handleRefreshInstruments,
7005
+ onAuditionNote,
7006
+ loadTracks,
7007
+ makeServices,
7008
+ setGroupMute,
7009
+ setGroupSolo,
7010
+ deleteGroup
7011
+ } = core;
7012
+ const { host, activeSceneId, isAuthenticated, sceneContext, onSelectScene, onOpenContract } = ui;
7013
+ const { identity, features } = adapter;
7014
+ const buildRowProps = (0, import_react26.useCallback)(
7015
+ (track, drag) => {
7016
+ const id = track.handle.id;
7017
+ const pickerProps = features.instrumentPicker ? {
7018
+ instrumentName: track.instrumentName,
7019
+ instrumentMissing: track.instrumentMissing,
7020
+ onToggleDrawer: () => handlers.toggleDrawer(id),
7021
+ availableInstruments,
7022
+ currentInstrumentPluginId: track.instrumentPluginId,
7023
+ onInstrumentSelect: (pluginId) => handleInstrumentSelect(id, pluginId),
7024
+ instrumentsLoading,
7025
+ onRefreshInstruments: handleRefreshInstruments,
7026
+ editorStage: track.editorStage,
7027
+ onShowEditor: () => handleShowEditor(id),
7028
+ onBackToInstruments: () => handleBackToInstruments(id)
7029
+ } : {};
7030
+ const importSoundProps = features.importTracks ? {
7031
+ onImportSound: () => setSoundImportTarget(track),
7032
+ importSoundLabel: adapter.sound.importSoundLabel
7033
+ } : {};
7034
+ const props = {
7035
+ ...drag ? { drag } : {},
7036
+ track: { id, name: track.handle.name, role: track.role },
7037
+ levels: supportsMeters ? trackLevels : void 0,
7038
+ prompt: track.prompt,
7039
+ runtimeState: {
7040
+ muted: track.runtimeState.muted,
7041
+ solo: track.runtimeState.solo,
7042
+ volume: track.runtimeState.volume,
7043
+ pan: track.runtimeState.pan
7044
+ },
7045
+ soloedOut: anySolo && !track.runtimeState.solo,
7046
+ fxDetailState: track.fxDetailState,
7047
+ drawerOpen: track.drawerOpen,
7048
+ drawerTab: track.drawerTab,
7049
+ onTabChange: (tab) => handlers.tabChange(id, tab),
7050
+ isGenerating: track.isGenerating,
7051
+ isAuthenticated,
7052
+ error: track.error,
7053
+ hasMidi: track.hasMidi,
7054
+ generationProgress: track.generationProgress,
7055
+ estimatedGenerationMs: identity.estimatedGenerationMs,
7056
+ onPromptChange: (prompt) => handlers.promptChange(id, prompt),
7057
+ onGenerate: () => handlers.generate(id),
7058
+ onShuffle: () => handlers.shuffle(id),
7059
+ onCopy: () => handlers.copy(id),
7060
+ onDelete: () => handlers.delete(id),
7061
+ onMuteToggle: () => handlers.muteToggle(id),
7062
+ onSoloToggle: () => handlers.soloToggle(id),
7063
+ onVolumeChange: (vol) => handlers.volumeChange(id, vol),
7064
+ onPanChange: (pan) => handlers.panChange(id, pan),
7065
+ onFxToggle: (cat, enabled) => handleFxToggle(id, cat, enabled),
7066
+ onFxPresetChange: (cat, idx) => handleFxPresetChange(id, cat, idx),
7067
+ onFxDryWetChange: (cat, val) => handleFxDryWetChange(id, cat, val),
7068
+ onToggleFxDrawer: () => handlers.toggleFxDrawer(id),
7069
+ onProgressChange: (pct) => handlers.progressChange(id, pct),
7070
+ accentColor: identity.accentColor,
7071
+ ...pickerProps,
7072
+ soundHistory: soundHistory.list(id).entries,
7073
+ soundHistoryCursor: soundHistory.list(id).cursor,
7074
+ onRestoreSound: (i) => {
7075
+ void soundHistory.restoreTo(id, i);
7076
+ },
7077
+ onToggleFavorite: (i) => soundHistory.toggleFavorite(id, i),
7078
+ ...importSoundProps,
7079
+ editNotes: track.editNotes,
7080
+ onNotesChange: (notes) => handlers.notesChange(id, notes),
7081
+ editBars: track.editBars,
7082
+ editBpm: track.editBpm,
7083
+ editSnap: 0.25,
7084
+ onAuditionNote: (pitch, vel, ms) => onAuditionNote(id, pitch, vel, ms)
7085
+ };
7086
+ return adapter.mapTrackRowProps ? adapter.mapTrackRowProps(track, props) : props;
7087
+ },
7088
+ [
7089
+ features.instrumentPicker,
7090
+ features.importTracks,
7091
+ adapter,
7092
+ supportsMeters,
7093
+ trackLevels,
7094
+ anySolo,
7095
+ isAuthenticated,
7096
+ identity,
7097
+ handlers,
7098
+ availableInstruments,
7099
+ instrumentsLoading,
7100
+ handleInstrumentSelect,
7101
+ handleRefreshInstruments,
7102
+ handleShowEditor,
7103
+ handleBackToInstruments,
7104
+ setSoundImportTarget,
7105
+ soundHistory,
7106
+ handleFxToggle,
7107
+ handleFxPresetChange,
7108
+ handleFxDryWetChange,
7109
+ onAuditionNote
7110
+ ]
7111
+ );
7112
+ if (!activeSceneId) {
7113
+ return /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
7114
+ "div",
7115
+ {
7116
+ "data-testid": `no-scene-placeholder-${identity.familyKey}`,
7117
+ className: "flex items-center justify-center py-8",
7118
+ children: /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
7119
+ "button",
7120
+ {
7121
+ onClick: () => onSelectScene?.(),
7122
+ className: "text-sas-muted text-xs hover:text-sas-accent transition-colors underline underline-offset-2",
7123
+ children: "Select a Scene"
7124
+ }
7125
+ )
7126
+ }
7127
+ );
4322
7128
  }
4323
- const [moved] = next.splice(from, 1);
4324
- next.splice(to, 0, moved);
4325
- return next;
4326
- }
4327
- function useTrackReorder({
4328
- host,
4329
- items,
4330
- setItems,
4331
- getId,
4332
- onError
4333
- }) {
4334
- const [draggingIndex, setDraggingIndex] = (0, import_react22.useState)(null);
4335
- const [dragOverIndex, setDragOverIndex] = (0, import_react22.useState)(null);
4336
- const fromRef = (0, import_react22.useRef)(null);
4337
- const itemsRef = (0, import_react22.useRef)(items);
4338
- itemsRef.current = items;
4339
- const dragPropsFor = (0, import_react22.useCallback)(
4340
- (index) => ({
4341
- handleProps: {
4342
- draggable: true,
4343
- onDragStart: (e) => {
4344
- fromRef.current = index;
4345
- setDraggingIndex(index);
4346
- if (e.dataTransfer) {
4347
- e.dataTransfer.effectAllowed = "move";
4348
- try {
4349
- e.dataTransfer.setData("text/plain", String(index));
4350
- } catch {
4351
- }
7129
+ if (!sceneContext?.hasContract) {
7130
+ return /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
7131
+ "div",
7132
+ {
7133
+ "data-testid": `no-contract-placeholder-${identity.familyKey}`,
7134
+ className: "flex items-center justify-center py-8",
7135
+ children: /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
7136
+ "button",
7137
+ {
7138
+ onClick: () => onOpenContract?.(),
7139
+ className: "text-sas-muted text-xs hover:text-sas-accent transition-colors underline underline-offset-2",
7140
+ children: "Generate a Contract"
4352
7141
  }
7142
+ )
7143
+ }
7144
+ );
7145
+ }
7146
+ if (features.bulkComposePlaceholders && isComposing) {
7147
+ return /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("div", { "data-testid": `${identity.familyKey}-section`, className: "p-2", children: /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(SorceryProgressBar, { isLoading: true, statusText: "COMPOSING...", heightClass: "h-10" }) });
7148
+ }
7149
+ const activePlaceholders = features.bulkComposePlaceholders ? placeholders : [];
7150
+ if (activePlaceholders.length > 0) {
7151
+ const tracksByDbId = /* @__PURE__ */ new Map();
7152
+ for (const t of tracks) {
7153
+ tracksByDbId.set(t.handle.dbId, t);
7154
+ if (t.handle.id !== t.handle.dbId) {
7155
+ tracksByDbId.set(t.handle.id, t);
7156
+ }
7157
+ }
7158
+ return /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("div", { "data-testid": `${identity.familyKey}-section`, className: "p-2 space-y-2", children: activePlaceholders.map((ph) => {
7159
+ const loadedTrack = ph.status === "completed" ? tracksByDbId.get(ph.id) : void 0;
7160
+ if (loadedTrack) {
7161
+ return /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(TrackRow, { ...buildRowProps(loadedTrack) }, ph.id);
7162
+ }
7163
+ return /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
7164
+ "div",
7165
+ {
7166
+ "data-testid": "bulk-placeholder-track",
7167
+ className: "relative rounded-sm border w-full overflow-hidden border-sas-border bg-sas-panel-alt",
7168
+ style: { borderLeftColor: identity.placeholderAccentColor, borderLeftWidth: "3px" },
7169
+ children: /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(SorceryProgressBar, { isLoading: true, statusText: "CONJURING MIDI...", heightClass: "h-10" })
4353
7170
  },
4354
- onDragEnd: () => {
4355
- fromRef.current = null;
4356
- setDraggingIndex(null);
4357
- setDragOverIndex(null);
4358
- }
4359
- },
4360
- rowProps: {
4361
- onDragEnter: (e) => {
4362
- if (fromRef.current === null) return;
4363
- e.preventDefault();
4364
- setDragOverIndex(index);
7171
+ ph.id
7172
+ );
7173
+ }) });
7174
+ }
7175
+ const groupCtx = {
7176
+ services: makeServices(),
7177
+ anySolo,
7178
+ supportsMeters,
7179
+ levels: supportsMeters ? trackLevels : void 0,
7180
+ handlers,
7181
+ renderDefaultTrackRow: (track, overrides, drag) => /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(TrackRow, { ...{ ...buildRowProps(track, drag), ...overrides ?? {} } }, track.handle.id),
7182
+ setGroupMute,
7183
+ setGroupSolo,
7184
+ deleteGroup
7185
+ };
7186
+ return /* @__PURE__ */ (0, import_jsx_runtime24.jsxs)("div", { "data-testid": `${identity.familyKey}-section`, className: "p-2 space-y-2", children: [
7187
+ features.importTracks && host.listImportableTracks && /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
7188
+ ImportTrackModal,
7189
+ {
7190
+ host,
7191
+ open: importOpen,
7192
+ onClose: () => setImportOpen(false),
7193
+ onImported: () => {
7194
+ void loadTracks(true);
4365
7195
  },
4366
- onDragOver: (e) => {
4367
- if (fromRef.current === null) return;
4368
- e.preventDefault();
4369
- if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
4370
- setDragOverIndex((cur) => cur === index ? cur : index);
7196
+ onPortTrack: host.readImportableTrackMidi ? handlePortTrack : void 0,
7197
+ testIdPrefix: `${identity.familyKey}-import`
7198
+ }
7199
+ ),
7200
+ features.importTracks && host.listImportableTracks && host.getTrackSound && /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
7201
+ ImportTrackModal,
7202
+ {
7203
+ host,
7204
+ mode: "sound",
7205
+ open: !!soundImportTarget,
7206
+ title: adapter.sound.importSoundLabel,
7207
+ onClose: () => setSoundImportTarget(null),
7208
+ onImported: () => {
4371
7209
  },
4372
- onDragLeave: () => {
4373
- setDragOverIndex((cur) => cur === index ? null : cur);
7210
+ onPick: handleSoundImportPick,
7211
+ testIdPrefix: `${identity.familyKey}-sound-import`
7212
+ }
7213
+ ),
7214
+ slots?.modals,
7215
+ canCrossfade && xfFromId && xfToId && /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("div", { className: designerView ? "contents" : "hidden", children: /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
7216
+ TransitionDesigner,
7217
+ {
7218
+ host,
7219
+ fromSceneId: xfFromId,
7220
+ toSceneId: xfToId,
7221
+ transitionSceneId: activeSceneId ?? "",
7222
+ excludeSourceDbIds: [
7223
+ ...crossfadePairsMeta.flatMap((p) => [p.originSourceDbId, p.targetSourceDbId]),
7224
+ ...fadesMeta.map((f) => f.meta.sourceTrackDbId)
7225
+ ],
7226
+ onCreateCrossfade: transition.handleCreateCrossfade,
7227
+ onCreateFade: transition.handleCreateFade,
7228
+ familyLabel: identity.familyLabel,
7229
+ testIdPrefix: `${identity.familyKey}-transition-designer`
7230
+ }
7231
+ ) }),
7232
+ !(designerView && canCrossfade) && (isLoadingTracks ? /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("div", { className: "text-sas-muted text-xs text-center py-4", children: "Loading tracks..." }) : /* @__PURE__ */ (0, import_jsx_runtime24.jsxs)(import_jsx_runtime24.Fragment, { children: [
7233
+ slots?.beforeRows,
7234
+ resolvedCrossfadePairs.map((pair) => /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
7235
+ CrossfadeTrackRow,
7236
+ {
7237
+ accentColor: identity.transitionAccentColor,
7238
+ levels: supportsMeters ? trackLevels : void 0,
7239
+ sliderPos: pair.sliderPos,
7240
+ origin: {
7241
+ trackId: pair.origin.handle.id,
7242
+ name: pair.origin.handle.name,
7243
+ role: pair.origin.role,
7244
+ sourceName: pair.originSourceName,
7245
+ soundLabel: pair.originSoundLabel,
7246
+ runtimeState: pair.origin.runtimeState
7247
+ },
7248
+ target: {
7249
+ trackId: pair.target.handle.id,
7250
+ name: pair.target.handle.name,
7251
+ role: pair.target.role,
7252
+ sourceName: pair.targetSourceName,
7253
+ soundLabel: pair.targetSoundLabel,
7254
+ runtimeState: pair.target.runtimeState
7255
+ },
7256
+ onMuteToggle: () => transition.handleCrossfadeMute(pair),
7257
+ onSoloToggle: () => transition.handleCrossfadeSolo(pair),
7258
+ onVolumeChange: (slot, vol) => handlers.volumeChange(
7259
+ slot === "origin" ? pair.origin.handle.id : pair.target.handle.id,
7260
+ vol
7261
+ ),
7262
+ onPanChange: (slot, pan) => handlers.panChange(
7263
+ slot === "origin" ? pair.origin.handle.id : pair.target.handle.id,
7264
+ pan
7265
+ ),
7266
+ onSliderChange: (pos) => transition.handleCrossfadeSlider(pair, pos),
7267
+ onDelete: () => transition.handleCrossfadeDelete(pair)
4374
7268
  },
4375
- onDrop: (e) => {
4376
- e.preventDefault();
4377
- const from = fromRef.current;
4378
- fromRef.current = null;
4379
- setDraggingIndex(null);
4380
- setDragOverIndex(null);
4381
- if (from === null || from === index) return;
4382
- const prev = itemsRef.current;
4383
- const next = moveItem(prev, from, index);
4384
- setItems(next);
4385
- const ids = next.map(getId);
4386
- Promise.resolve(host.reorderTracks(ids)).catch((err) => {
4387
- setItems(prev);
4388
- onError?.(err);
4389
- });
7269
+ pair.groupId
7270
+ )),
7271
+ resolvedFades.map((fade) => /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
7272
+ FadeTrackRow,
7273
+ {
7274
+ accentColor: identity.transitionAccentColor,
7275
+ levels: supportsMeters ? trackLevels : void 0,
7276
+ direction: fade.meta.direction,
7277
+ gesture: fade.meta.gesture,
7278
+ sliderPos: fade.meta.sliderPos,
7279
+ layer: {
7280
+ trackId: fade.track.handle.id,
7281
+ name: fade.track.handle.name,
7282
+ role: fade.track.role,
7283
+ sourceName: fade.meta.sourceName,
7284
+ soundLabel: fade.meta.soundLabel,
7285
+ runtimeState: fade.track.runtimeState
7286
+ },
7287
+ onMuteToggle: () => handlers.muteToggle(fade.track.handle.id),
7288
+ onSoloToggle: () => handlers.soloToggle(fade.track.handle.id),
7289
+ onVolumeChange: (vol) => handlers.volumeChange(fade.track.handle.id, vol),
7290
+ onPanChange: (pan) => handlers.panChange(fade.track.handle.id, pan),
7291
+ onSliderChange: (pos) => transition.handleFadeSlider(fade, pos),
7292
+ onDelete: () => transition.handleFadeDelete(fade)
7293
+ },
7294
+ fade.dbId
7295
+ )),
7296
+ (adapter.groupExtensions ?? []).flatMap(
7297
+ (ext) => (resolvedGenericGroups[ext.metaKey]?.resolved ?? []).map((group) => /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(import_react26.default.Fragment, { children: ext.renderGroup(group, groupCtx) }, `${ext.metaKey}:${group.groupId}`))
7298
+ ),
7299
+ tracks.map((track, index) => {
7300
+ if (crossfadeMemberDbIds.has(track.handle.dbId) || fadeMemberDbIds.has(track.handle.dbId) || genericGroupMemberDbIds.has(track.handle.dbId)) {
7301
+ return null;
4390
7302
  }
4391
- },
4392
- isDragging: draggingIndex === index,
4393
- isDragTarget: dragOverIndex === index && draggingIndex !== index
4394
- }),
4395
- [host, setItems, getId, onError, draggingIndex, dragOverIndex]
4396
- );
4397
- return { dragPropsFor, draggingIndex, dragOverIndex };
7303
+ return /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(TrackRow, { ...buildRowProps(track, reorder.dragPropsFor(index)) }, track.handle.id);
7304
+ }),
7305
+ slots?.afterRows
7306
+ ] })),
7307
+ features.exportMidi && !designerView && !isLoadingTracks && tracks.length > 0 && (() => {
7308
+ const hasAnyMidi = tracks.some((t) => t.hasMidi);
7309
+ const exportDisabled = isExportingMidi || !hasAnyMidi;
7310
+ return /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("div", { className: "pt-2", children: /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
7311
+ "button",
7312
+ {
7313
+ "data-testid": "export-midi-tracks-button",
7314
+ onClick: handleExportMidi,
7315
+ disabled: exportDisabled,
7316
+ title: isExportingMidi ? "Exporting..." : !hasAnyMidi ? "Generate MIDI on at least one track first" : "Export all tracks as a ZIP of .mid files",
7317
+ className: `w-full px-2 py-1.5 text-[10px] uppercase tracking-wide rounded-sm border transition-colors ${exportDisabled ? "text-sas-muted/40 border-transparent hover:border-sas-accent cursor-not-allowed" : "text-sas-muted hover:text-sas-accent border-sas-border hover:border-sas-accent"}`,
7318
+ children: isExportingMidi ? "Exporting..." : "Export Tracks"
7319
+ }
7320
+ ) });
7321
+ })()
7322
+ ] });
7323
+ }
7324
+
7325
+ // src/panel-core/surge-sound-adapter.ts
7326
+ async function getInstrument(host, trackId) {
7327
+ try {
7328
+ const plugins = await host.getTrackPlugins(trackId);
7329
+ const instrument = plugins.find(
7330
+ (p) => !p.name.includes("Volume") && !p.name.includes("Pan") && !p.name.includes("Level")
7331
+ );
7332
+ if (!instrument) return null;
7333
+ return { index: instrument.index, isRaw: !instrument.name.includes("Surge") };
7334
+ } catch {
7335
+ return null;
7336
+ }
7337
+ }
7338
+ function createSurgeSoundAdapter(host, overrides = {}) {
7339
+ const applySound = async (trackId, descriptor) => {
7340
+ const { state, stateType } = descriptor;
7341
+ const inst = await getInstrument(host, trackId);
7342
+ if (!inst) return;
7343
+ if (stateType === "raw") await host.setRawPluginState(trackId, inst.index, state);
7344
+ else await host.setPluginState(trackId, inst.index, state);
7345
+ };
7346
+ return {
7347
+ applySound,
7348
+ captureSoundDescriptor: async (trackId) => {
7349
+ const inst = await getInstrument(host, trackId);
7350
+ if (!inst) return null;
7351
+ const state = inst.isRaw ? await host.getRawPluginState(trackId, inst.index) : await host.getPluginState(trackId, inst.index);
7352
+ return { descriptor: { state, stateType: inst.isRaw ? "raw" : "valuetree" } };
7353
+ },
7354
+ copySnapshot: async (trackId, snap) => {
7355
+ if (snap.kind !== "preset") return "default";
7356
+ await applySound(trackId, { state: snap.state, stateType: snap.stateType });
7357
+ await host.persistTrackPresetState?.(trackId, {
7358
+ state: snap.state,
7359
+ stateType: snap.stateType ?? "valuetree",
7360
+ name: snap.label
7361
+ }).catch(() => {
7362
+ });
7363
+ return snap.label;
7364
+ },
7365
+ descriptorFromSnapshot: (snap) => {
7366
+ const preset = snap;
7367
+ return { state: preset.state, stateType: preset.stateType };
7368
+ },
7369
+ acceptedSnapshotKind: "preset",
7370
+ historyMax: overrides.historyMax ?? 12,
7371
+ importSoundLabel: overrides.importSoundLabel ?? "Import Preset",
7372
+ importNoun: "preset",
7373
+ previousSoundLabel: "Previous preset"
7374
+ };
4398
7375
  }
4399
7376
 
4400
7377
  // src/constants/sdk-version.ts
4401
- var PLUGIN_SDK_VERSION = "2.28.0";
7378
+ var PLUGIN_SDK_VERSION = "2.35.0";
4402
7379
 
4403
7380
  // src/utils/format-concurrent-tracks.ts
4404
7381
  function formatConcurrentTracks(ctx) {
@@ -4542,6 +7519,8 @@ function pickTopKWeighted(scored, options = {}) {
4542
7519
  }
4543
7520
  // Annotate the CommonJS export names for ESM import in node:
4544
7521
  0 && (module.exports = {
7522
+ AUDIO_EFFECTS,
7523
+ AUDIO_EFFECT_LABEL,
4545
7524
  ConfirmDialog,
4546
7525
  CrossfadeModal,
4547
7526
  CrossfadeTrackRow,
@@ -4563,6 +7542,7 @@ function pickTopKWeighted(scored, options = {}) {
4563
7542
  FadeTrackRow,
4564
7543
  FxToggleBar,
4565
7544
  GUTTER_W,
7545
+ GeneratorPanelShell,
4566
7546
  ImportTrackModal,
4567
7547
  InstrumentDrawer,
4568
7548
  LevelMeter,
@@ -4580,44 +7560,68 @@ function pickTopKWeighted(scored, options = {}) {
4580
7560
  ScrollingWaveform,
4581
7561
  SorceryProgressBar,
4582
7562
  TEXTURAL_ROLES,
7563
+ TRANSITION_DESIGNER_DRAFT_KEY,
4583
7564
  TrackDrawer,
4584
7565
  TrackMeterStrip,
4585
7566
  TrackRow,
7567
+ TransitionDesigner,
4586
7568
  VolumeSlider,
4587
7569
  WaveformView,
4588
7570
  analyzeWavPeak,
7571
+ asAudioEffect,
4589
7572
  asCrossfadeMeta,
4590
7573
  asFadeMeta,
7574
+ asTransitionDesignerDraft,
4591
7575
  buildCrossfadeInpaintPrompt,
4592
7576
  buildCrossfadeVolumeCurves,
4593
7577
  buildFadeVolumeCurve,
7578
+ buildRowSlots,
4594
7579
  calculateTimeBasedTarget,
4595
7580
  cellToPx,
4596
7581
  centerScrollTop,
4597
7582
  computePeaks,
7583
+ createSurgeSoundAdapter,
7584
+ dbIdsFromKeys,
4598
7585
  dbToSlider,
4599
7586
  defaultFadeGesture,
4600
7587
  drawWaveform,
4601
7588
  formatConcurrentTracks,
7589
+ hashString,
4602
7590
  moveItem,
7591
+ newTrackState,
7592
+ normalizeSlots,
7593
+ padPair,
7594
+ padSlots,
4603
7595
  parseCrossfadePairs,
4604
7596
  parseFades,
7597
+ parseLLMNoteResponse,
7598
+ parseTrackGroups,
4605
7599
  pickTopKWeighted,
4606
7600
  pitchToName,
7601
+ pluginFxToToggleFx,
4607
7602
  pxToCell,
7603
+ reconcileSlots,
4608
7604
  resizeNoteDuration,
7605
+ resolveTrackGroups,
7606
+ rowKey,
7607
+ rowType,
4609
7608
  scorePromptMatch,
4610
7609
  sliderToDb,
7610
+ slotsEqual,
7611
+ soundIdentity,
4611
7612
  synthesizeCuePoints,
4612
7613
  tokenizePrompt,
7614
+ trackDataKey,
4613
7615
  transposeNotes,
4614
7616
  useAnySolo,
7617
+ useGeneratorPanelCore,
4615
7618
  useSceneState,
4616
7619
  useSoundHistory,
4617
7620
  useTrackLevel,
4618
7621
  useTrackLevels,
4619
7622
  useTrackMeter,
4620
7623
  useTrackReorder,
7624
+ useTransitionOps,
4621
7625
  useTransportPlaying
4622
7626
  });
4623
7627
  //# sourceMappingURL=index.js.map