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