@signalsandsorcery/plugin-sdk 2.3.1 → 2.24.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
@@ -65,414 +65,49 @@ var EMPTY_FX_DETAIL_STATE = {
65
65
  };
66
66
 
67
67
  // src/components/TrackRow.tsx
68
- import { AlertCircle } from "lucide-react";
68
+ import React8 from "react";
69
+ import { AlertCircle, ChevronDown, GripVertical } from "lucide-react";
69
70
 
70
- // src/components/InstrumentDrawer.tsx
71
- import { useState, useMemo } from "react";
72
- import { jsx, jsxs } from "react/jsx-runtime";
73
- function InstrumentDrawer({
74
- instruments,
75
- currentPluginId,
76
- isLoading,
77
- onSelect,
78
- onRefresh,
79
- stage = "instruments",
80
- onShowEditor,
81
- onBackToInstruments,
82
- selectedInstrumentName
83
- }) {
84
- const [search, setSearch] = useState("");
85
- const SURGE_XT_DEFAULT_ID = "Surge XT";
86
- const filtered = useMemo(() => {
87
- let all = instruments.filter(
88
- (i) => i.name !== "Surge XT"
89
- );
90
- if (search.trim()) {
91
- const q = search.toLowerCase();
92
- all = all.filter(
93
- (i) => i.name.toLowerCase().includes(q) || i.manufacturer.toLowerCase().includes(q)
94
- );
95
- }
96
- if (currentPluginId) {
97
- const selectedIdx = all.findIndex((i) => i.pluginId === currentPluginId);
98
- if (selectedIdx > 0) {
99
- const [selected] = all.splice(selectedIdx, 1);
100
- all.unshift(selected);
101
- }
102
- }
103
- return all;
104
- }, [instruments, search, currentPluginId]);
105
- const isDefaultSelected = currentPluginId === null;
106
- const isSelected = (pluginId) => {
107
- return pluginId === currentPluginId;
108
- };
109
- if (stage === "editor") {
110
- return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2", children: [
111
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
112
- /* @__PURE__ */ jsx(
113
- "button",
114
- {
115
- onClick: () => onBackToInstruments?.(),
116
- className: "px-2 py-1 text-xs rounded-sm border border-sas-border text-sas-muted hover:text-sas-accent hover:border-sas-accent transition-colors",
117
- children: "\u2190 Back"
118
- }
119
- ),
120
- /* @__PURE__ */ jsx("span", { className: "text-xs text-sas-muted font-medium truncate flex-1", children: selectedInstrumentName ?? "Plugin" })
121
- ] }),
122
- /* @__PURE__ */ jsx(
123
- "button",
124
- {
125
- onClick: () => onShowEditor?.(),
126
- className: "w-full py-2 text-xs font-medium rounded-sm border border-sas-accent bg-sas-accent/20 text-sas-accent hover:bg-sas-accent/40 transition-colors",
127
- children: "Open Plugin Editor"
128
- }
129
- )
130
- ] });
131
- }
132
- return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2", children: [
133
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
134
- /* @__PURE__ */ jsx(
135
- "input",
136
- {
137
- type: "text",
138
- value: search,
139
- onChange: (e) => setSearch(e.target.value),
140
- placeholder: "Search instruments...",
141
- className: "sas-input flex-1 px-2 py-1 text-xs"
142
- }
143
- ),
144
- /* @__PURE__ */ jsx(
145
- "button",
146
- {
147
- onClick: onRefresh,
148
- disabled: isLoading,
149
- className: "px-2 py-1 text-xs rounded-sm border border-sas-border text-sas-muted hover:text-sas-accent hover:border-sas-accent transition-colors disabled:opacity-50",
150
- title: "Re-scan plugins",
151
- children: isLoading ? "..." : "Refresh"
152
- }
153
- )
154
- ] }),
155
- isLoading && instruments.length === 0 ? /* @__PURE__ */ jsx("div", { className: "text-xs text-sas-muted/60 text-center py-3", children: "Scanning plugins..." }) : /* @__PURE__ */ jsxs("div", { className: "grid grid-cols-3 gap-1 max-h-[140px] overflow-y-auto", children: [
156
- /* @__PURE__ */ jsxs(
157
- "button",
158
- {
159
- onClick: () => onSelect(SURGE_XT_DEFAULT_ID),
160
- className: `flex flex-col items-start px-2 py-1.5 rounded-sm border text-left transition-colors ${isDefaultSelected ? "border-sas-accent bg-sas-accent/20 text-sas-accent" : "border-sas-border bg-sas-panel-alt text-sas-muted hover:border-sas-accent hover:text-sas-accent"}`,
161
- title: "Surge XT \u2014 Default instrument",
162
- children: [
163
- /* @__PURE__ */ jsxs("span", { className: "text-xs font-medium truncate w-full", children: [
164
- isDefaultSelected && "\u2713 ",
165
- "Surge XT"
166
- ] }),
167
- /* @__PURE__ */ jsx("span", { className: "text-[9px] text-sas-muted/50 truncate w-full", children: "Default" })
168
- ]
169
- },
170
- "__surge-xt-default__"
171
- ),
172
- filtered.map((inst) => {
173
- const selected = isSelected(inst.pluginId);
174
- return /* @__PURE__ */ jsxs(
175
- "button",
176
- {
177
- onClick: () => onSelect(inst.pluginId),
178
- className: `flex flex-col items-start px-2 py-1.5 rounded-sm border text-left transition-colors ${selected ? "border-sas-accent bg-sas-accent/20 text-sas-accent" : inst.missing ? "border-amber-500/50 bg-amber-500/10 text-amber-400 hover:border-amber-500" : "border-sas-border bg-sas-panel-alt text-sas-muted hover:border-sas-accent hover:text-sas-accent"}`,
179
- title: `${inst.name} by ${inst.manufacturer} (${inst.type.toUpperCase()})${inst.missing ? " \u2014 MISSING" : ""}`,
180
- children: [
181
- /* @__PURE__ */ jsxs("span", { className: "text-xs font-medium truncate w-full", children: [
182
- selected && "\u2713 ",
183
- inst.name
184
- ] }),
185
- /* @__PURE__ */ jsx("span", { className: "text-[9px] text-sas-muted/50 truncate w-full", children: inst.manufacturer || inst.type.toUpperCase() })
186
- ]
187
- },
188
- inst.pluginId
189
- );
190
- }),
191
- filtered.length === 0 && /* @__PURE__ */ jsx("div", { className: "col-span-2 text-xs text-sas-muted/60 text-center py-2", children: search.trim() ? "No matches" : "No other plugins found" })
192
- ] })
193
- ] });
194
- }
195
-
196
- // src/components/VolumeSlider.tsx
197
- import { useCallback, useState as useState2, useRef, useEffect } from "react";
71
+ // src/components/TrackDrawer.tsx
72
+ import { useState as useState2, useMemo as useMemo2 } from "react";
198
73
 
199
- // src/utils/volume-conversion.ts
200
- var SLIDER_UNITY = 0.75;
201
- var DB_MAX = 6;
202
- var DB_MIN = -60;
203
- var EXPONENT = Math.log(Math.pow(10, DB_MAX / 20)) / Math.log(1 / SLIDER_UNITY);
204
- function sliderToDb(slider) {
205
- if (slider <= 0) return DB_MIN;
206
- const gain = Math.pow(slider / SLIDER_UNITY, EXPONENT);
207
- const db = 20 * Math.log10(gain);
208
- return Math.max(DB_MIN, Math.min(DB_MAX, db));
209
- }
210
- function dbToSlider(db) {
211
- if (db <= DB_MIN) return 0;
212
- if (db >= DB_MAX) return 1;
213
- const gain = Math.pow(10, db / 20);
214
- const slider = SLIDER_UNITY * Math.pow(gain, 1 / EXPONENT);
215
- return Math.min(1, Math.max(0, slider));
216
- }
217
-
218
- // src/components/VolumeSlider.tsx
219
- import { jsx as jsx2 } from "react/jsx-runtime";
220
- function formatDb(value) {
221
- const db = sliderToDb(value);
222
- if (db <= -60) return "-\u221E dB";
223
- const sign = db >= 0 ? "+" : "";
224
- return `${sign}${db.toFixed(1)} dB`;
225
- }
226
- function useDebouncedCallback(callback, delay) {
227
- const timeoutRef = useRef(null);
228
- const callbackRef = useRef(callback);
229
- useEffect(() => {
230
- callbackRef.current = callback;
231
- }, [callback]);
232
- const debouncedCallback = useCallback(
233
- (...args) => {
234
- if (timeoutRef.current) {
235
- clearTimeout(timeoutRef.current);
74
+ // src/constants/fx-presets.ts
75
+ var EQ_PRESETS = {
76
+ presets: [
77
+ {
78
+ name: "The Smiley",
79
+ shortLabel: "SM",
80
+ params: {
81
+ "Low-shelf freq": 80,
82
+ "Low-shelf gain": 4,
83
+ "Low-shelf Q": 0.5,
84
+ "Mid freq 1": 500,
85
+ "Mid gain 1": -3,
86
+ "Mid Q 1": 0.7,
87
+ "Mid freq 2": 2e3,
88
+ "Mid gain 2": -2,
89
+ "Mid Q 2": 0.7,
90
+ "High-shelf freq": 12e3,
91
+ "High-shelf gain": 4,
92
+ "High-shelf Q": 0.5
236
93
  }
237
- timeoutRef.current = setTimeout(() => {
238
- callbackRef.current(...args);
239
- }, delay);
240
94
  },
241
- [delay]
242
- );
243
- useEffect(() => {
244
- return () => {
245
- if (timeoutRef.current) {
246
- clearTimeout(timeoutRef.current);
247
- }
248
- };
249
- }, []);
250
- return debouncedCallback;
251
- }
252
- var VolumeSlider = ({
253
- value,
254
- onChange,
255
- disabled = false,
256
- className = ""
257
- }) => {
258
- const [localValue, setLocalValue] = useState2(value);
259
- const [isDragging, setIsDragging] = useState2(false);
260
- useEffect(() => {
261
- if (!isDragging) {
262
- setLocalValue(value);
263
- }
264
- }, [value, isDragging]);
265
- const debouncedOnChange = useDebouncedCallback(onChange, 50);
266
- const handleChange = useCallback(
267
- (e) => {
268
- const newValue = parseFloat(e.target.value);
269
- setLocalValue(newValue);
270
- debouncedOnChange(newValue);
271
- },
272
- [debouncedOnChange]
273
- );
274
- const handleMouseDown = useCallback(() => {
275
- setIsDragging(true);
276
- }, []);
277
- const handleMouseUp = useCallback(() => {
278
- setIsDragging(false);
279
- onChange(localValue);
280
- }, [localValue, onChange]);
281
- return /* @__PURE__ */ jsx2(
282
- "div",
283
- {
284
- className: `flex items-center ${className}`,
285
- title: `Volume: ${formatDb(localValue)}`,
286
- children: /* @__PURE__ */ jsx2(
287
- "input",
288
- {
289
- type: "range",
290
- min: "0",
291
- max: "1",
292
- step: "0.01",
293
- value: localValue,
294
- onChange: handleChange,
295
- onMouseDown: handleMouseDown,
296
- onMouseUp: handleMouseUp,
297
- onTouchStart: handleMouseDown,
298
- onTouchEnd: handleMouseUp,
299
- disabled,
300
- className: `
301
- w-full h-1.5 rounded-full appearance-none cursor-pointer
302
- bg-gray-700
303
- disabled:opacity-50 disabled:cursor-not-allowed
304
- [&::-webkit-slider-thumb]:appearance-none
305
- [&::-webkit-slider-thumb]:w-3
306
- [&::-webkit-slider-thumb]:h-3
307
- [&::-webkit-slider-thumb]:rounded-full
308
- [&::-webkit-slider-thumb]:bg-sas-accent
309
- [&::-webkit-slider-thumb]:cursor-pointer
310
- [&::-webkit-slider-thumb]:transition-transform
311
- [&::-webkit-slider-thumb]:hover:scale-110
312
- [&::-moz-range-thumb]:w-3
313
- [&::-moz-range-thumb]:h-3
314
- [&::-moz-range-thumb]:rounded-full
315
- [&::-moz-range-thumb]:bg-sas-accent
316
- [&::-moz-range-thumb]:border-0
317
- [&::-moz-range-thumb]:cursor-pointer
318
- `
319
- }
320
- )
321
- }
322
- );
323
- };
324
-
325
- // src/components/PanSlider.tsx
326
- import { useCallback as useCallback2, useState as useState3, useRef as useRef2, useEffect as useEffect2 } from "react";
327
- import { jsx as jsx3 } from "react/jsx-runtime";
328
- function toPanDisplay(value) {
329
- if (Math.abs(value) < 0.02) {
330
- return "Center";
331
- }
332
- const percent = Math.abs(Math.round(value * 100));
333
- return value < 0 ? `L${percent}` : `R${percent}`;
334
- }
335
- function useDebouncedCallback2(callback, delay) {
336
- const timeoutRef = useRef2(null);
337
- const callbackRef = useRef2(callback);
338
- useEffect2(() => {
339
- callbackRef.current = callback;
340
- }, [callback]);
341
- const debouncedCallback = useCallback2(
342
- (...args) => {
343
- if (timeoutRef.current) {
344
- clearTimeout(timeoutRef.current);
345
- }
346
- timeoutRef.current = setTimeout(() => {
347
- callbackRef.current(...args);
348
- }, delay);
349
- },
350
- [delay]
351
- );
352
- useEffect2(() => {
353
- return () => {
354
- if (timeoutRef.current) {
355
- clearTimeout(timeoutRef.current);
356
- }
357
- };
358
- }, []);
359
- return debouncedCallback;
360
- }
361
- var PanSlider = ({
362
- value,
363
- onChange,
364
- disabled = false,
365
- className = ""
366
- }) => {
367
- const [localValue, setLocalValue] = useState3(value);
368
- const [isDragging, setIsDragging] = useState3(false);
369
- useEffect2(() => {
370
- if (!isDragging) {
371
- setLocalValue(value);
372
- }
373
- }, [value, isDragging]);
374
- const debouncedOnChange = useDebouncedCallback2(onChange, 50);
375
- const handleChange = useCallback2(
376
- (e) => {
377
- const newValue = parseFloat(e.target.value);
378
- setLocalValue(newValue);
379
- debouncedOnChange(newValue);
380
- },
381
- [debouncedOnChange]
382
- );
383
- const handleMouseDown = useCallback2(() => {
384
- setIsDragging(true);
385
- }, []);
386
- const handleMouseUp = useCallback2(() => {
387
- setIsDragging(false);
388
- onChange(localValue);
389
- }, [localValue, onChange]);
390
- const handleDoubleClick = useCallback2(() => {
391
- setLocalValue(0);
392
- onChange(0);
393
- }, [onChange]);
394
- return /* @__PURE__ */ jsx3(
395
- "div",
396
- {
397
- className: `flex items-center ${className}`,
398
- title: `Pan: ${toPanDisplay(localValue)}`,
399
- children: /* @__PURE__ */ jsx3(
400
- "input",
401
- {
402
- type: "range",
403
- min: "-1",
404
- max: "1",
405
- step: "0.01",
406
- value: localValue,
407
- onChange: handleChange,
408
- onMouseDown: handleMouseDown,
409
- onMouseUp: handleMouseUp,
410
- onTouchStart: handleMouseDown,
411
- onTouchEnd: handleMouseUp,
412
- onDoubleClick: handleDoubleClick,
413
- disabled,
414
- className: `
415
- w-full h-1.5 rounded-full appearance-none cursor-pointer
416
- bg-gray-700
417
- disabled:opacity-50 disabled:cursor-not-allowed
418
- [&::-webkit-slider-thumb]:appearance-none
419
- [&::-webkit-slider-thumb]:w-3
420
- [&::-webkit-slider-thumb]:h-3
421
- [&::-webkit-slider-thumb]:rounded-full
422
- [&::-webkit-slider-thumb]:bg-sas-accent
423
- [&::-webkit-slider-thumb]:cursor-pointer
424
- [&::-webkit-slider-thumb]:transition-transform
425
- [&::-webkit-slider-thumb]:hover:scale-110
426
- [&::-moz-range-thumb]:w-3
427
- [&::-moz-range-thumb]:h-3
428
- [&::-moz-range-thumb]:rounded-full
429
- [&::-moz-range-thumb]:bg-sas-accent
430
- [&::-moz-range-thumb]:border-0
431
- [&::-moz-range-thumb]:cursor-pointer
432
- `
433
- }
434
- )
435
- }
436
- );
437
- };
438
-
439
- // src/constants/fx-presets.ts
440
- var EQ_PRESETS = {
441
- presets: [
442
- {
443
- name: "The Smiley",
444
- shortLabel: "SM",
445
- params: {
446
- "Low-shelf freq": 80,
447
- "Low-shelf gain": 4,
448
- "Low-shelf Q": 0.5,
449
- "Mid freq 1": 500,
450
- "Mid gain 1": -3,
451
- "Mid Q 1": 0.7,
452
- "Mid freq 2": 2e3,
453
- "Mid gain 2": -2,
454
- "Mid Q 2": 0.7,
455
- "High-shelf freq": 12e3,
456
- "High-shelf gain": 4,
457
- "High-shelf Q": 0.5
458
- }
459
- },
460
- {
461
- name: "Telephone",
462
- shortLabel: "TP",
463
- params: {
464
- "Low-shelf freq": 400,
465
- "Low-shelf gain": -20,
466
- "Low-shelf Q": 1,
467
- "Mid freq 1": 1e3,
468
- "Mid gain 1": 5,
469
- "Mid Q 1": 2,
470
- "Mid freq 2": 3e3,
471
- "Mid gain 2": -5,
472
- "Mid Q 2": 1,
473
- "High-shelf freq": 5e3,
474
- "High-shelf gain": -20,
475
- "High-shelf Q": 1
95
+ {
96
+ name: "Telephone",
97
+ shortLabel: "TP",
98
+ params: {
99
+ "Low-shelf freq": 400,
100
+ "Low-shelf gain": -20,
101
+ "Low-shelf Q": 1,
102
+ "Mid freq 1": 1e3,
103
+ "Mid gain 1": 5,
104
+ "Mid Q 1": 2,
105
+ "Mid freq 2": 3e3,
106
+ "Mid gain 2": -5,
107
+ "Mid Q 2": 1,
108
+ "High-shelf freq": 5e3,
109
+ "High-shelf gain": -20,
110
+ "High-shelf Q": 1
476
111
  }
477
112
  },
478
113
  {
@@ -715,7 +350,7 @@ var FX_PRESET_CONFIGS = {
715
350
  };
716
351
 
717
352
  // src/components/FxToggleBar.tsx
718
- import { jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
353
+ import { jsx, jsxs } from "react/jsx-runtime";
719
354
  var FX_COLORS = {
720
355
  eq: "bg-blue-500",
721
356
  compressor: "bg-orange-500",
@@ -732,14 +367,14 @@ var FxToggleBar = ({
732
367
  onDryWetChange,
733
368
  disabled = false
734
369
  }) => {
735
- return /* @__PURE__ */ jsx4("div", { className: "flex flex-col gap-1", "data-testid": "fx-toggle-bar", children: FX_CATEGORIES.map((category) => {
370
+ return /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-1", "data-testid": "fx-toggle-bar", children: FX_CATEGORIES.map((category) => {
736
371
  const detail = fxState[category];
737
372
  const isActive = detail.enabled;
738
373
  const label = FX_DISPLAY_LABELS[category];
739
374
  const activeColor = FX_COLORS[category];
740
375
  const config = FX_PRESET_CONFIGS[category];
741
- return /* @__PURE__ */ jsxs2("div", { className: "flex items-center gap-0.5", children: [
742
- /* @__PURE__ */ jsx4(
376
+ return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-0.5", children: [
377
+ /* @__PURE__ */ jsx(
743
378
  "button",
744
379
  {
745
380
  "data-testid": `fx-toggle-${category}`,
@@ -750,7 +385,7 @@ var FxToggleBar = ({
750
385
  children: label
751
386
  }
752
387
  ),
753
- config.presets.map((preset, idx) => /* @__PURE__ */ jsx4(
388
+ config.presets.map((preset, idx) => /* @__PURE__ */ jsx(
754
389
  "button",
755
390
  {
756
391
  "data-testid": `fx-preset-${category}-${idx}`,
@@ -762,7 +397,7 @@ var FxToggleBar = ({
762
397
  },
763
398
  idx
764
399
  )),
765
- /* @__PURE__ */ jsx4(
400
+ /* @__PURE__ */ jsx(
766
401
  "input",
767
402
  {
768
403
  type: "range",
@@ -776,7 +411,7 @@ var FxToggleBar = ({
776
411
  title: `Dry/Wet: ${Math.round(detail.dryWet * 100)}%`
777
412
  }
778
413
  ),
779
- /* @__PURE__ */ jsxs2("span", { className: "text-[8px] text-sas-muted/50 w-6 text-right flex-shrink-0", children: [
414
+ /* @__PURE__ */ jsxs("span", { className: "text-[8px] text-sas-muted/50 w-6 text-right flex-shrink-0", children: [
780
415
  Math.round(detail.dryWet * 100),
781
416
  "%"
782
417
  ] })
@@ -784,443 +419,3113 @@ var FxToggleBar = ({
784
419
  }) });
785
420
  };
786
421
 
787
- // src/components/SorceryProgressBar.tsx
788
- import { useState as useState4, useEffect as useEffect3, useRef as useRef3 } from "react";
789
- import { jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
790
- function calculateTimeBasedTarget(elapsedMs, estimatedDurationMs) {
791
- const t = elapsedMs / estimatedDurationMs;
792
- if (t <= 0) return 0;
793
- if (t <= 1) {
794
- return 90 * (1 - Math.pow(1 - t, 2.5));
422
+ // src/components/PianoRollEditor.tsx
423
+ import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
424
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
425
+ var PX_PER_BEAT = 24;
426
+ var ROW_HEIGHT = 12;
427
+ var GUTTER_W = 28;
428
+ var DRAG_DEAD_ZONE = 4;
429
+ var RESIZE_HANDLE_PX = 6;
430
+ var SCROLL_MAX_H = 150;
431
+ var NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
432
+ var BLACK_KEYS = /* @__PURE__ */ new Set([1, 3, 6, 8, 10]);
433
+ var SNAP_LABELS = {
434
+ "2": "1/2",
435
+ "1": "1/4",
436
+ "0.5": "1/8",
437
+ "0.25": "1/16",
438
+ "0.125": "1/32"
439
+ };
440
+ function clamp(v, lo, hi) {
441
+ return Math.max(lo, Math.min(hi, v));
442
+ }
443
+ function snapLabel(s) {
444
+ return SNAP_LABELS[String(s)] ?? `${s}`;
445
+ }
446
+ function pitchToName(pitch) {
447
+ const name = NOTE_NAMES[(pitch % 12 + 12) % 12];
448
+ const octave = Math.floor(pitch / 12) - 1;
449
+ return `${name}${octave}`;
450
+ }
451
+ function cellToPx(pitch, startBeat, hi) {
452
+ return { left: startBeat * PX_PER_BEAT, top: (hi - pitch) * ROW_HEIGHT };
453
+ }
454
+ function pxToCell(localX, localY, hi, snap, bars, beatsPerBar) {
455
+ const totalBeats = bars * beatsPerBar;
456
+ const pitch = clamp(hi - Math.floor(localY / ROW_HEIGHT), 0, 127);
457
+ const rawBeat = localX / PX_PER_BEAT;
458
+ const snapped = Math.round(rawBeat / snap) * snap;
459
+ const startBeat = clamp(snapped, 0, Math.max(0, totalBeats - snap));
460
+ return { pitch, startBeat };
461
+ }
462
+ function resizeNoteDuration(startBeat, localX, snap, bars, beatsPerBar) {
463
+ const totalBeats = bars * beatsPerBar;
464
+ const snappedEnd = Math.round(localX / PX_PER_BEAT / snap) * snap;
465
+ const end = clamp(snappedEnd, startBeat + snap, totalBeats);
466
+ return end - startBeat;
467
+ }
468
+ function centerScrollTop(pitches, hi, rowCount, viewportH) {
469
+ if (pitches.length === 0) return 0;
470
+ const sorted = [...pitches].sort((a, b) => a - b);
471
+ const mid = Math.floor(sorted.length / 2);
472
+ const median = sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
473
+ const medianRowCenterPx = (hi - median) * ROW_HEIGHT + ROW_HEIGHT / 2;
474
+ const maxScroll = Math.max(0, rowCount * ROW_HEIGHT - viewportH);
475
+ return clamp(medianRowCenterPx - viewportH / 2, 0, maxScroll);
476
+ }
477
+ function transposeNotes(notes, semitones) {
478
+ return notes.map((n) => ({ ...n, pitch: clamp(n.pitch + semitones, 0, 127) }));
479
+ }
480
+ function PianoRollEditor({
481
+ notes,
482
+ onChange,
483
+ bars,
484
+ bpm,
485
+ beatsPerBar = 4,
486
+ snap = 0.25,
487
+ snapOptions = [1, 0.5, 0.25],
488
+ onSnapChange,
489
+ minPitch = 36,
490
+ maxPitch = 84,
491
+ autoFit = true,
492
+ onAuditionNote,
493
+ defaultVelocity = 100,
494
+ disabled = false,
495
+ className,
496
+ testId = "sdk-piano-roll"
497
+ }) {
498
+ const [snapState, setSnapState] = useState(snap);
499
+ const gridRef = useRef(null);
500
+ const scrollRef = useRef(null);
501
+ const dragRef = useRef(null);
502
+ const didCenterRef = useRef(false);
503
+ const { lo, hi } = useMemo(() => {
504
+ if (autoFit && notes.length > 0) {
505
+ const ps = notes.map((n) => n.pitch);
506
+ return {
507
+ lo: Math.max(0, Math.min(minPitch, Math.min(...ps) - 2)),
508
+ hi: Math.min(127, Math.max(maxPitch, Math.max(...ps) + 2))
509
+ };
510
+ }
511
+ return { lo: minPitch, hi: maxPitch };
512
+ }, [autoFit, notes, minPitch, maxPitch]);
513
+ const rowCount = hi - lo + 1;
514
+ const totalBeats = bars * beatsPerBar;
515
+ const gridWidth = totalBeats * PX_PER_BEAT;
516
+ const gridHeight = rowCount * ROW_HEIGHT;
517
+ const stateRef = useRef({
518
+ notes,
519
+ onChange,
520
+ snapState,
521
+ hi,
522
+ bars,
523
+ beatsPerBar,
524
+ defaultVelocity,
525
+ bpm,
526
+ onAuditionNote,
527
+ disabled
528
+ });
529
+ stateRef.current = {
530
+ notes,
531
+ onChange,
532
+ snapState,
533
+ hi,
534
+ bars,
535
+ beatsPerBar,
536
+ defaultVelocity,
537
+ bpm,
538
+ onAuditionNote,
539
+ disabled
540
+ };
541
+ const localCoords = useCallback((clientX, clientY) => {
542
+ const rect = gridRef.current?.getBoundingClientRect();
543
+ return { x: clientX - (rect?.left ?? 0), y: clientY - (rect?.top ?? 0) };
544
+ }, []);
545
+ const handlePointerDown = useCallback((e) => {
546
+ if (stateRef.current.disabled) return;
547
+ const target = e.target;
548
+ const noteEl = target.closest('[data-testid="sdk-pr-note"]');
549
+ const idxAttr = noteEl?.getAttribute("data-index");
550
+ const onResizeHandle = idxAttr != null && target.closest("[data-resize-handle]") != null;
551
+ dragRef.current = {
552
+ mode: idxAttr == null ? "pending-add" : onResizeHandle ? "pending-resize" : "pending-note",
553
+ index: idxAttr != null ? Number(idxAttr) : -1,
554
+ startX: e.clientX,
555
+ startY: e.clientY
556
+ };
557
+ try {
558
+ e.currentTarget.setPointerCapture?.(e.pointerId);
559
+ } catch {
560
+ }
561
+ }, []);
562
+ const handlePointerMove = useCallback((e) => {
563
+ const drag = dragRef.current;
564
+ if (!drag) return;
565
+ const dist = Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY);
566
+ if (dist > DRAG_DEAD_ZONE) {
567
+ if (drag.mode === "pending-note") drag.mode = "drag";
568
+ else if (drag.mode === "pending-resize") drag.mode = "resize";
569
+ }
570
+ const s = stateRef.current;
571
+ const { x, y } = localCoords(e.clientX, e.clientY);
572
+ if (drag.mode === "resize") {
573
+ const note = s.notes[drag.index];
574
+ if (!note) return;
575
+ const durationBeats = resizeNoteDuration(note.startBeat, x, s.snapState, s.bars, s.beatsPerBar);
576
+ if (durationBeats === note.durationBeats) return;
577
+ const next2 = s.notes.map((n, i) => i === drag.index ? { ...n, durationBeats } : n);
578
+ s.onChange(next2);
579
+ return;
580
+ }
581
+ if (drag.mode !== "drag") return;
582
+ const { pitch, startBeat } = pxToCell(x, y, s.hi, s.snapState, s.bars, s.beatsPerBar);
583
+ const next = s.notes.map((n, i) => i === drag.index ? { ...n, pitch, startBeat } : n);
584
+ s.onChange(next);
585
+ }, [localCoords]);
586
+ const handlePointerUp = useCallback((e) => {
587
+ const drag = dragRef.current;
588
+ dragRef.current = null;
589
+ if (!drag) return;
590
+ const s = stateRef.current;
591
+ if (s.disabled) return;
592
+ if (drag.mode === "pending-note" || drag.mode === "pending-resize") {
593
+ s.onChange(s.notes.filter((_, i) => i !== drag.index));
594
+ return;
595
+ }
596
+ if (drag.mode === "pending-add") {
597
+ const { x, y } = localCoords(e.clientX, e.clientY);
598
+ const { pitch, startBeat } = pxToCell(x, y, s.hi, s.snapState, s.bars, s.beatsPerBar);
599
+ const note = {
600
+ pitch,
601
+ startBeat,
602
+ durationBeats: s.snapState,
603
+ velocity: s.defaultVelocity,
604
+ channel: 0
605
+ };
606
+ s.onChange([...s.notes, note]);
607
+ s.onAuditionNote?.(pitch, s.defaultVelocity, Math.max(1, s.snapState * (60 / s.bpm) * 1e3));
608
+ }
609
+ }, [localCoords]);
610
+ const handlePointerCancel = useCallback(() => {
611
+ dragRef.current = null;
612
+ }, []);
613
+ const handleOctave = useCallback((delta) => {
614
+ const s = stateRef.current;
615
+ if (s.disabled) return;
616
+ didCenterRef.current = false;
617
+ s.onChange(transposeNotes(s.notes, delta));
618
+ }, []);
619
+ const handleSnapChange = useCallback((e) => {
620
+ const v = Number(e.target.value);
621
+ setSnapState(v);
622
+ onSnapChange?.(v);
623
+ }, [onSnapChange]);
624
+ useLayoutEffect(() => {
625
+ const el = scrollRef.current;
626
+ if (!el) return;
627
+ if (notes.length === 0) {
628
+ didCenterRef.current = false;
629
+ return;
630
+ }
631
+ if (didCenterRef.current || dragRef.current) return;
632
+ didCenterRef.current = true;
633
+ const viewportH = el.clientHeight || SCROLL_MAX_H;
634
+ el.scrollTop = centerScrollTop(
635
+ notes.map((n) => n.pitch),
636
+ hi,
637
+ rowCount,
638
+ viewportH
639
+ );
640
+ }, [notes, hi, rowCount]);
641
+ const rows = useMemo(() => {
642
+ const out = [];
643
+ for (let p = hi; p >= lo; p--) out.push(p);
644
+ return out;
645
+ }, [hi, lo]);
646
+ const gridBg = useMemo(() => {
647
+ const beatPx = PX_PER_BEAT;
648
+ const barPx = PX_PER_BEAT * beatsPerBar;
649
+ return [
650
+ `repeating-linear-gradient(to right, transparent 0 ${beatPx - 1}px, rgba(255,255,255,0.06) ${beatPx - 1}px ${beatPx}px)`,
651
+ `repeating-linear-gradient(to right, transparent 0 ${barPx - 1}px, rgba(255,255,255,0.16) ${barPx - 1}px ${barPx}px)`,
652
+ `repeating-linear-gradient(to bottom, transparent 0 ${ROW_HEIGHT - 1}px, rgba(255,255,255,0.04) ${ROW_HEIGHT - 1}px ${ROW_HEIGHT}px)`
653
+ ].join(", ");
654
+ }, [beatsPerBar]);
655
+ const octaveDisabled = disabled || notes.length === 0;
656
+ return /* @__PURE__ */ jsxs2("div", { className: `flex flex-col gap-1 ${className ?? ""}`, "data-testid": testId, children: [
657
+ /* @__PURE__ */ jsxs2("div", { className: "flex items-center gap-1", "data-testid": "sdk-pr-toolbar", children: [
658
+ /* @__PURE__ */ jsx2(
659
+ "button",
660
+ {
661
+ type: "button",
662
+ "data-testid": "sdk-pr-octave-down",
663
+ disabled: octaveDisabled,
664
+ onClick: () => handleOctave(-12),
665
+ className: "px-1.5 py-0.5 text-[10px] rounded-sm border border-sas-border text-sas-muted hover:text-sas-accent hover:border-sas-accent transition-colors disabled:opacity-40",
666
+ title: "Octave down (\u221212 semitones)",
667
+ children: "Oct \u2212"
668
+ }
669
+ ),
670
+ /* @__PURE__ */ jsx2(
671
+ "button",
672
+ {
673
+ type: "button",
674
+ "data-testid": "sdk-pr-octave-up",
675
+ disabled: octaveDisabled,
676
+ onClick: () => handleOctave(12),
677
+ className: "px-1.5 py-0.5 text-[10px] rounded-sm border border-sas-border text-sas-muted hover:text-sas-accent hover:border-sas-accent transition-colors disabled:opacity-40",
678
+ title: "Octave up (+12 semitones)",
679
+ children: "Oct +"
680
+ }
681
+ ),
682
+ /* @__PURE__ */ jsxs2("label", { className: "flex items-center gap-1 text-[10px] text-sas-muted/70 ml-1", children: [
683
+ "Snap",
684
+ /* @__PURE__ */ jsx2(
685
+ "select",
686
+ {
687
+ "data-testid": "sdk-pr-snap",
688
+ value: snapState,
689
+ disabled,
690
+ onChange: handleSnapChange,
691
+ className: "sas-input px-1 py-0.5 text-[10px]",
692
+ children: snapOptions.map((s) => /* @__PURE__ */ jsx2("option", { value: s, children: snapLabel(s) }, s))
693
+ }
694
+ )
695
+ ] }),
696
+ /* @__PURE__ */ jsxs2("span", { className: "text-[10px] text-sas-muted/60 ml-auto", "data-testid": "sdk-pr-note-count", children: [
697
+ notes.length,
698
+ " ",
699
+ notes.length === 1 ? "note" : "notes"
700
+ ] })
701
+ ] }),
702
+ /* @__PURE__ */ jsx2(
703
+ "div",
704
+ {
705
+ ref: scrollRef,
706
+ className: "overflow-auto border border-sas-border rounded-sm bg-sas-bg",
707
+ style: { maxHeight: SCROLL_MAX_H },
708
+ "data-testid": "sdk-pr-scroll",
709
+ children: /* @__PURE__ */ jsxs2("div", { className: "flex", style: { width: GUTTER_W + gridWidth }, children: [
710
+ /* @__PURE__ */ jsx2(
711
+ "div",
712
+ {
713
+ "data-testid": "sdk-pr-gutter",
714
+ className: "sticky left-0 z-10 flex-shrink-0 bg-sas-panel-alt",
715
+ style: { width: GUTTER_W },
716
+ children: rows.map((p) => /* @__PURE__ */ jsx2(
717
+ "div",
718
+ {
719
+ "data-testid": "sdk-pr-key",
720
+ "data-pitch": p,
721
+ className: `flex items-center justify-end pr-1 text-[8px] leading-none border-b border-sas-border/30 ${BLACK_KEYS.has((p % 12 + 12) % 12) ? "bg-sas-bg text-sas-muted/40" : "text-sas-muted/70"}`,
722
+ style: { height: ROW_HEIGHT },
723
+ children: p % 12 === 0 ? pitchToName(p) : ""
724
+ },
725
+ p
726
+ ))
727
+ }
728
+ ),
729
+ /* @__PURE__ */ jsxs2(
730
+ "div",
731
+ {
732
+ ref: gridRef,
733
+ "data-testid": "sdk-pr-grid",
734
+ className: "relative flex-shrink-0",
735
+ style: {
736
+ width: gridWidth,
737
+ height: gridHeight,
738
+ backgroundImage: gridBg,
739
+ cursor: disabled ? "not-allowed" : "crosshair",
740
+ touchAction: "none"
741
+ },
742
+ onPointerDown: handlePointerDown,
743
+ onPointerMove: handlePointerMove,
744
+ onPointerUp: handlePointerUp,
745
+ onPointerCancel: handlePointerCancel,
746
+ children: [
747
+ notes.map((n, i) => {
748
+ const { left, top } = cellToPx(n.pitch, n.startBeat, hi);
749
+ const width = Math.max(3, n.durationBeats * PX_PER_BEAT);
750
+ const handleW = Math.min(RESIZE_HANDLE_PX, width / 2);
751
+ return /* @__PURE__ */ jsx2(
752
+ "div",
753
+ {
754
+ "data-testid": "sdk-pr-note",
755
+ "data-index": i,
756
+ "data-pitch": n.pitch,
757
+ "data-start-beat": n.startBeat,
758
+ "data-duration-beats": n.durationBeats,
759
+ className: "absolute rounded-[2px] bg-sas-accent/80 border border-sas-accent hover:bg-sas-accent",
760
+ style: { left, top, width, height: ROW_HEIGHT },
761
+ title: `${pitchToName(n.pitch)} \xB7 beat ${n.startBeat} \xB7 ${n.durationBeats}\u266A \xB7 vel ${n.velocity}`,
762
+ children: !disabled && /* @__PURE__ */ jsx2(
763
+ "div",
764
+ {
765
+ "data-resize-handle": "",
766
+ "data-testid": "sdk-pr-note-resize",
767
+ className: "absolute top-0 right-0 h-full rounded-r-[2px] hover:bg-sas-bg/40",
768
+ style: { width: handleW, cursor: "ew-resize" }
769
+ }
770
+ )
771
+ },
772
+ i
773
+ );
774
+ }),
775
+ notes.length === 0 && /* @__PURE__ */ jsx2(
776
+ "div",
777
+ {
778
+ "data-testid": "sdk-pr-empty",
779
+ className: "absolute inset-0 flex items-center justify-center text-[10px] text-sas-muted/50 pointer-events-none",
780
+ children: "No notes \u2014 click to add"
781
+ }
782
+ )
783
+ ]
784
+ }
785
+ )
786
+ ] })
787
+ }
788
+ )
789
+ ] });
790
+ }
791
+
792
+ // src/components/TrackDrawer.tsx
793
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
794
+ var TAB_LABELS = {
795
+ fx: "FX",
796
+ pick: "Pick",
797
+ history: "History",
798
+ import: "Import",
799
+ edit: "Edit"
800
+ };
801
+ function TrackDrawer({
802
+ activeTab,
803
+ onTabChange,
804
+ trackId,
805
+ fxState,
806
+ onFxToggle,
807
+ onFxPresetChange,
808
+ onFxDryWetChange,
809
+ fxDisabled = false,
810
+ instruments = [],
811
+ currentPluginId = null,
812
+ isLoading = false,
813
+ onSelect,
814
+ onRefresh,
815
+ editorStage = false,
816
+ onShowEditor,
817
+ onBackToInstruments,
818
+ selectedInstrumentName,
819
+ soundHistory,
820
+ soundHistoryCursor = -1,
821
+ onRestoreSound,
822
+ onToggleFavorite,
823
+ onImportSound,
824
+ importSoundLabel,
825
+ editNotes,
826
+ onNotesChange,
827
+ editBars,
828
+ editBpm,
829
+ editSnap,
830
+ onAuditionNote
831
+ }) {
832
+ const [search, setSearch] = useState2("");
833
+ const fxEnabled = !!onFxToggle;
834
+ const pickEnabled = !!onSelect;
835
+ const historyEnabled = !!onRestoreSound;
836
+ const importEnabled = !!onImportSound;
837
+ const editEnabled = !!onNotesChange;
838
+ const enabledTabs = useMemo2(() => {
839
+ const tabs = [];
840
+ if (fxEnabled) tabs.push("fx");
841
+ if (pickEnabled) tabs.push("pick");
842
+ if (historyEnabled) tabs.push("history");
843
+ if (importEnabled) tabs.push("import");
844
+ if (editEnabled) tabs.push("edit");
845
+ return tabs;
846
+ }, [fxEnabled, pickEnabled, historyEnabled, importEnabled, editEnabled]);
847
+ const SURGE_XT_DEFAULT_ID = "Surge XT";
848
+ const filtered = useMemo2(() => {
849
+ let all = instruments.filter((i) => i.name !== "Surge XT");
850
+ if (search.trim()) {
851
+ const q = search.toLowerCase();
852
+ all = all.filter(
853
+ (i) => i.name.toLowerCase().includes(q) || i.manufacturer.toLowerCase().includes(q)
854
+ );
855
+ }
856
+ if (currentPluginId) {
857
+ const selectedIdx = all.findIndex((i) => i.pluginId === currentPluginId);
858
+ if (selectedIdx > 0) {
859
+ const [selected] = all.splice(selectedIdx, 1);
860
+ all.unshift(selected);
861
+ }
862
+ }
863
+ return all;
864
+ }, [instruments, search, currentPluginId]);
865
+ const history = soundHistory ?? [];
866
+ const effectiveTab = enabledTabs.includes(activeTab) ? activeTab : enabledTabs[0] ?? "fx";
867
+ const tabClass = (active) => `px-2 py-0.5 text-xs rounded-sm transition-colors ${active ? "bg-sas-accent/20 text-sas-accent font-medium" : "text-sas-muted hover:text-sas-accent"}`;
868
+ const strip = enabledTabs.length > 1 ? /* @__PURE__ */ jsx3(
869
+ "div",
870
+ {
871
+ className: "flex items-center gap-1 border-b border-sas-border pb-1",
872
+ "data-testid": "sdk-drawer-tabs",
873
+ children: enabledTabs.map((tab) => /* @__PURE__ */ jsx3(
874
+ "button",
875
+ {
876
+ type: "button",
877
+ "data-testid": `sdk-drawer-tab-${tab}`,
878
+ onClick: () => onTabChange?.(tab),
879
+ className: tabClass(effectiveTab === tab),
880
+ children: tab === "history" && history.length > 0 ? `History (${history.length})` : TAB_LABELS[tab]
881
+ },
882
+ tab
883
+ ))
884
+ }
885
+ ) : null;
886
+ const currentSound = soundHistoryCursor >= 0 && soundHistoryCursor < history.length ? history[soundHistoryCursor].label : null;
887
+ const header = strip || currentSound ? /* @__PURE__ */ jsxs3("div", { className: "flex flex-col gap-1", "data-testid": "sdk-drawer-header", children: [
888
+ strip,
889
+ currentSound && /* @__PURE__ */ jsx3(
890
+ "span",
891
+ {
892
+ className: "text-[10px] text-sas-muted/60 truncate px-0.5",
893
+ title: currentSound,
894
+ children: currentSound
895
+ }
896
+ )
897
+ ] }) : null;
898
+ if (effectiveTab === "edit") {
899
+ return /* @__PURE__ */ jsxs3("div", { className: "flex flex-col gap-2", "data-testid": "sdk-drawer-edit", children: [
900
+ header,
901
+ /* @__PURE__ */ jsx3(
902
+ PianoRollEditor,
903
+ {
904
+ notes: editNotes ?? [],
905
+ onChange: onNotesChange ?? (() => {
906
+ }),
907
+ bars: editBars ?? 4,
908
+ bpm: editBpm ?? 120,
909
+ snap: editSnap,
910
+ onAuditionNote
911
+ }
912
+ )
913
+ ] });
795
914
  }
796
- const overshootRatio = (elapsedMs - estimatedDurationMs) / estimatedDurationMs;
797
- return 90 + 5 * (1 - Math.exp(-overshootRatio * 3));
915
+ if (effectiveTab === "fx") {
916
+ return /* @__PURE__ */ jsxs3("div", { className: "flex flex-col gap-2", "data-testid": "sdk-drawer-fx", children: [
917
+ header,
918
+ /* @__PURE__ */ jsx3(
919
+ FxToggleBar,
920
+ {
921
+ trackId,
922
+ fxState,
923
+ onToggle: (_t, category, enabled) => onFxToggle?.(category, enabled),
924
+ onPresetChange: (_t, category, presetIndex) => onFxPresetChange?.(category, presetIndex),
925
+ onDryWetChange: (_t, category, value) => onFxDryWetChange?.(category, value),
926
+ disabled: fxDisabled
927
+ }
928
+ )
929
+ ] });
930
+ }
931
+ if (effectiveTab === "import") {
932
+ const soundNoun = /preset/i.test(importSoundLabel ?? "") ? "preset" : /sample/i.test(importSoundLabel ?? "") ? "sample" : "sound";
933
+ return /* @__PURE__ */ jsxs3("div", { className: "flex flex-col gap-2", "data-testid": "sdk-drawer-import", children: [
934
+ header,
935
+ /* @__PURE__ */ jsxs3("p", { className: "text-[11px] text-sas-muted/70 leading-snug", children: [
936
+ "Copy the sound from a matching track in another scene \u2014 your MIDI stays, only the",
937
+ " ",
938
+ soundNoun,
939
+ " changes."
940
+ ] }),
941
+ /* @__PURE__ */ jsxs3(
942
+ "button",
943
+ {
944
+ type: "button",
945
+ "data-testid": "sdk-drawer-import-sound",
946
+ onClick: onImportSound,
947
+ className: "w-full px-2 py-1.5 text-[11px] rounded-sm border border-sas-border text-sas-muted hover:border-sas-accent hover:text-sas-accent transition-colors",
948
+ title: "Copy a sound from a track in another scene (ignores contract)",
949
+ children: [
950
+ "\u21EA ",
951
+ importSoundLabel ?? "Import Sound"
952
+ ]
953
+ }
954
+ )
955
+ ] });
956
+ }
957
+ if (effectiveTab === "history") {
958
+ const order = history.map((_, i) => i).reverse();
959
+ return /* @__PURE__ */ jsxs3("div", { className: "flex flex-col gap-2", children: [
960
+ header,
961
+ history.length === 0 ? /* @__PURE__ */ jsx3(
962
+ "div",
963
+ {
964
+ className: "text-xs text-sas-muted/60 text-center py-3",
965
+ "data-testid": "sdk-history-empty",
966
+ children: "No sounds yet \u2014 shuffle to build history."
967
+ }
968
+ ) : /* @__PURE__ */ jsx3(
969
+ "ul",
970
+ {
971
+ className: "flex flex-col gap-1 max-h-[160px] overflow-y-auto",
972
+ "data-testid": "sdk-history-list",
973
+ children: order.map((i) => {
974
+ const entry = history[i];
975
+ const isCurrent = i === soundHistoryCursor;
976
+ return /* @__PURE__ */ jsxs3("li", { className: "flex items-center gap-1", children: [
977
+ /* @__PURE__ */ jsxs3(
978
+ "button",
979
+ {
980
+ type: "button",
981
+ "data-testid": "sdk-history-entry",
982
+ disabled: isCurrent,
983
+ onClick: () => onRestoreSound?.(i),
984
+ className: `flex-1 min-w-0 flex items-center justify-between px-2 py-1.5 rounded-sm border text-left text-xs transition-colors ${isCurrent ? "border-sas-accent bg-sas-accent/20 text-sas-accent cursor-default" : "border-sas-border bg-sas-panel-alt text-sas-muted hover:border-sas-accent hover:text-sas-accent"}`,
985
+ title: isCurrent ? "Current sound" : `Restore: ${entry.label}`,
986
+ children: [
987
+ /* @__PURE__ */ jsx3("span", { className: "truncate", children: entry.label }),
988
+ /* @__PURE__ */ jsx3("span", { className: "text-[10px] text-sas-muted/60 flex-shrink-0 ml-2", children: isCurrent ? "\u25CF current" : "restore" })
989
+ ]
990
+ }
991
+ ),
992
+ onToggleFavorite && /* @__PURE__ */ jsx3(
993
+ "button",
994
+ {
995
+ type: "button",
996
+ "data-testid": "sdk-history-favorite",
997
+ onClick: () => onToggleFavorite(i),
998
+ className: `flex-shrink-0 px-1 py-0.5 text-sm leading-none transition-colors ${entry.favorite ? "text-yellow-400" : "text-sas-muted/40 hover:text-yellow-400"}`,
999
+ title: entry.favorite ? "Unfavorite" : "Favorite (keeps it from being evicted)",
1000
+ children: entry.favorite ? "\u2605" : "\u2606"
1001
+ }
1002
+ )
1003
+ ] }, i);
1004
+ })
1005
+ }
1006
+ )
1007
+ ] });
1008
+ }
1009
+ if (effectiveTab === "pick" && editorStage) {
1010
+ return /* @__PURE__ */ jsxs3("div", { className: "flex flex-col gap-2", children: [
1011
+ header,
1012
+ /* @__PURE__ */ jsxs3("div", { className: "flex items-center gap-2", children: [
1013
+ /* @__PURE__ */ jsx3(
1014
+ "button",
1015
+ {
1016
+ onClick: () => onBackToInstruments?.(),
1017
+ className: "px-2 py-1 text-xs rounded-sm border border-sas-border text-sas-muted hover:text-sas-accent hover:border-sas-accent transition-colors",
1018
+ children: "\u2190 Back"
1019
+ }
1020
+ ),
1021
+ /* @__PURE__ */ jsx3("span", { className: "text-xs text-sas-muted font-medium truncate flex-1", children: selectedInstrumentName ?? "Plugin" })
1022
+ ] }),
1023
+ /* @__PURE__ */ jsx3(
1024
+ "button",
1025
+ {
1026
+ onClick: () => onShowEditor?.(),
1027
+ className: "w-full py-2 text-xs font-medium rounded-sm border border-sas-accent bg-sas-accent/20 text-sas-accent hover:bg-sas-accent/40 transition-colors",
1028
+ children: "Open Plugin Editor"
1029
+ }
1030
+ )
1031
+ ] });
1032
+ }
1033
+ const isDefaultSelected = currentPluginId === null;
1034
+ const isSelected = (pluginId) => pluginId === currentPluginId;
1035
+ return /* @__PURE__ */ jsxs3("div", { className: "flex flex-col gap-2", children: [
1036
+ header,
1037
+ /* @__PURE__ */ jsxs3("div", { className: "flex items-center gap-2", children: [
1038
+ /* @__PURE__ */ jsx3(
1039
+ "input",
1040
+ {
1041
+ type: "text",
1042
+ value: search,
1043
+ onChange: (e) => setSearch(e.target.value),
1044
+ placeholder: "Search instruments...",
1045
+ className: "sas-input flex-1 px-2 py-1 text-xs"
1046
+ }
1047
+ ),
1048
+ /* @__PURE__ */ jsx3(
1049
+ "button",
1050
+ {
1051
+ onClick: () => onRefresh?.(),
1052
+ disabled: isLoading,
1053
+ className: "px-2 py-1 text-xs rounded-sm border border-sas-border text-sas-muted hover:text-sas-accent hover:border-sas-accent transition-colors disabled:opacity-50",
1054
+ title: "Re-scan plugins",
1055
+ children: isLoading ? "..." : "Refresh"
1056
+ }
1057
+ )
1058
+ ] }),
1059
+ isLoading && instruments.length === 0 ? /* @__PURE__ */ jsx3("div", { className: "text-xs text-sas-muted/60 text-center py-3", children: "Scanning plugins..." }) : /* @__PURE__ */ jsxs3("div", { className: "grid grid-cols-3 gap-1 max-h-[140px] overflow-y-auto", children: [
1060
+ /* @__PURE__ */ jsxs3(
1061
+ "button",
1062
+ {
1063
+ onClick: () => onSelect?.(SURGE_XT_DEFAULT_ID),
1064
+ className: `flex flex-col items-start px-2 py-1.5 rounded-sm border text-left transition-colors ${isDefaultSelected ? "border-sas-accent bg-sas-accent/20 text-sas-accent" : "border-sas-border bg-sas-panel-alt text-sas-muted hover:border-sas-accent hover:text-sas-accent"}`,
1065
+ title: "Surge XT \u2014 Default instrument",
1066
+ children: [
1067
+ /* @__PURE__ */ jsxs3("span", { className: "text-xs font-medium truncate w-full", children: [
1068
+ isDefaultSelected && "\u2713 ",
1069
+ "Surge XT"
1070
+ ] }),
1071
+ /* @__PURE__ */ jsx3("span", { className: "text-[9px] text-sas-muted/50 truncate w-full", children: "Default" })
1072
+ ]
1073
+ },
1074
+ "__surge-xt-default__"
1075
+ ),
1076
+ filtered.map((inst) => {
1077
+ const selected = isSelected(inst.pluginId);
1078
+ return /* @__PURE__ */ jsxs3(
1079
+ "button",
1080
+ {
1081
+ onClick: () => onSelect?.(inst.pluginId),
1082
+ className: `flex flex-col items-start px-2 py-1.5 rounded-sm border text-left transition-colors ${selected ? "border-sas-accent bg-sas-accent/20 text-sas-accent" : inst.missing ? "border-amber-500/50 bg-amber-500/10 text-amber-400 hover:border-amber-500" : "border-sas-border bg-sas-panel-alt text-sas-muted hover:border-sas-accent hover:text-sas-accent"}`,
1083
+ title: `${inst.name} by ${inst.manufacturer} (${inst.type.toUpperCase()})${inst.missing ? " \u2014 MISSING" : ""}`,
1084
+ children: [
1085
+ /* @__PURE__ */ jsxs3("span", { className: "text-xs font-medium truncate w-full", children: [
1086
+ selected && "\u2713 ",
1087
+ inst.name
1088
+ ] }),
1089
+ /* @__PURE__ */ jsx3("span", { className: "text-[9px] text-sas-muted/50 truncate w-full", children: inst.manufacturer || inst.type.toUpperCase() })
1090
+ ]
1091
+ },
1092
+ inst.pluginId
1093
+ );
1094
+ }),
1095
+ filtered.length === 0 && /* @__PURE__ */ jsx3("div", { className: "col-span-2 text-xs text-sas-muted/60 text-center py-2", children: search.trim() ? "No matches" : "No other plugins found" })
1096
+ ] })
1097
+ ] });
1098
+ }
1099
+
1100
+ // src/components/ConfirmDialog.tsx
1101
+ import { useRef as useRef2 } from "react";
1102
+
1103
+ // src/components/Modal.tsx
1104
+ import { useEffect } from "react";
1105
+ import { createPortal } from "react-dom";
1106
+ import { jsx as jsx4 } from "react/jsx-runtime";
1107
+ function Modal({
1108
+ open,
1109
+ onClose,
1110
+ children,
1111
+ testIdPrefix = "modal",
1112
+ closeOnBackdrop = true,
1113
+ closeOnEscape = true,
1114
+ initialFocusRef
1115
+ }) {
1116
+ useEffect(() => {
1117
+ if (!open) return void 0;
1118
+ const onKey = (e) => {
1119
+ if (closeOnEscape && e.key === "Escape") {
1120
+ e.preventDefault();
1121
+ onClose();
1122
+ }
1123
+ };
1124
+ window.addEventListener("keydown", onKey);
1125
+ initialFocusRef?.current?.focus();
1126
+ return () => window.removeEventListener("keydown", onKey);
1127
+ }, [open, onClose, closeOnEscape, initialFocusRef]);
1128
+ if (!open) return null;
1129
+ return createPortal(
1130
+ /* @__PURE__ */ jsx4(
1131
+ "div",
1132
+ {
1133
+ className: "fixed inset-0 z-[1000] flex items-center justify-center bg-black/60",
1134
+ "data-testid": `${testIdPrefix}-overlay`,
1135
+ onClick: closeOnBackdrop ? onClose : void 0,
1136
+ children
1137
+ }
1138
+ ),
1139
+ document.body
1140
+ );
1141
+ }
1142
+
1143
+ // src/components/ConfirmDialog.tsx
1144
+ import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
1145
+ function ConfirmDialog({
1146
+ open,
1147
+ title,
1148
+ message,
1149
+ confirmLabel = "Delete",
1150
+ cancelLabel = "Cancel",
1151
+ destructive = true,
1152
+ onConfirm,
1153
+ onCancel,
1154
+ testIdPrefix = "confirm-dialog"
1155
+ }) {
1156
+ const cancelRef = useRef2(null);
1157
+ return /* @__PURE__ */ jsx5(Modal, { open, onClose: onCancel, testIdPrefix, initialFocusRef: cancelRef, children: /* @__PURE__ */ jsxs4(
1158
+ "div",
1159
+ {
1160
+ className: "w-[360px] max-w-[90vw] flex flex-col rounded-md border border-sas-border bg-sas-panel shadow-xl",
1161
+ onClick: (e) => e.stopPropagation(),
1162
+ role: "dialog",
1163
+ "aria-modal": "true",
1164
+ "aria-label": title,
1165
+ "data-testid": `${testIdPrefix}-modal`,
1166
+ children: [
1167
+ /* @__PURE__ */ jsx5("div", { className: "px-4 py-3 border-b border-sas-border", children: /* @__PURE__ */ jsx5("span", { className: "text-sm font-medium text-sas-text", "data-testid": `${testIdPrefix}-title`, children: title }) }),
1168
+ /* @__PURE__ */ jsx5(
1169
+ "div",
1170
+ {
1171
+ className: "px-4 py-3 text-xs text-sas-muted leading-relaxed break-words",
1172
+ "data-testid": `${testIdPrefix}-message`,
1173
+ children: message
1174
+ }
1175
+ ),
1176
+ /* @__PURE__ */ jsxs4("div", { className: "flex justify-end gap-2 px-4 py-3 border-t border-sas-border", children: [
1177
+ /* @__PURE__ */ jsx5(
1178
+ "button",
1179
+ {
1180
+ ref: cancelRef,
1181
+ type: "button",
1182
+ className: "px-3 py-1 rounded-sm text-xs font-medium border border-sas-border bg-sas-panel-alt text-sas-text hover:border-sas-accent hover:text-sas-accent transition-colors",
1183
+ onClick: onCancel,
1184
+ "data-testid": `${testIdPrefix}-cancel`,
1185
+ children: cancelLabel
1186
+ }
1187
+ ),
1188
+ /* @__PURE__ */ jsx5(
1189
+ "button",
1190
+ {
1191
+ type: "button",
1192
+ className: `px-3 py-1 rounded-sm text-xs font-medium border transition-colors ${destructive ? "border-sas-danger bg-sas-danger/20 text-sas-danger hover:bg-sas-danger hover:text-sas-bg" : "border-sas-accent bg-sas-accent/20 text-sas-accent hover:bg-sas-accent hover:text-sas-bg"}`,
1193
+ onClick: onConfirm,
1194
+ "data-testid": `${testIdPrefix}-confirm`,
1195
+ children: confirmLabel
1196
+ }
1197
+ )
1198
+ ] })
1199
+ ]
1200
+ }
1201
+ ) });
1202
+ }
1203
+
1204
+ // src/components/LevelMeter.tsx
1205
+ import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
1206
+ var COLOR_GREEN = "#2BD576";
1207
+ var COLOR_ORANGE = "#F5A623";
1208
+ var COLOR_RED = "#FF4D5E";
1209
+ var COLOR_TRACK_BG = "#121822";
1210
+ var COLOR_TRACK_BORDER = "#1F2A3A";
1211
+ var COLOR_SEGMENT_GAP = "#0A0E14";
1212
+ var COLOR_PEAK = "#F7FFFB";
1213
+ var METER_GRADIENT = `linear-gradient(90deg, ${COLOR_GREEN} 0%, ${COLOR_GREEN} 45%, ${COLOR_ORANGE} 72%, ${COLOR_RED} 90%, ${COLOR_RED} 100%)`;
1214
+ var SEGMENTS = 22;
1215
+ var SEGMENT_GAP_PX = 2;
1216
+ function dbToPct(db) {
1217
+ return Math.max(0, Math.min(100, (db + 60) / 60 * 100));
1218
+ }
1219
+ var LevelMeter = ({
1220
+ peakDb,
1221
+ active,
1222
+ peakHoldDb,
1223
+ clipped,
1224
+ onClearClip,
1225
+ compact = false,
1226
+ className,
1227
+ "data-testid": testId
1228
+ }) => {
1229
+ const id = testId ?? "sas-level-meter";
1230
+ const widthPct = active ? dbToPct(peakDb) : 0;
1231
+ const showPeak = peakHoldDb != null && active && peakHoldDb > -60;
1232
+ const peakHoldPct = showPeak ? dbToPct(peakHoldDb) : 0;
1233
+ return /* @__PURE__ */ jsxs5(
1234
+ "div",
1235
+ {
1236
+ className: `sas-level-meter ${className ?? ""}`,
1237
+ "data-testid": id,
1238
+ style: {
1239
+ display: "flex",
1240
+ alignItems: "center",
1241
+ gap: compact ? 0 : 6
1242
+ },
1243
+ children: [
1244
+ /* @__PURE__ */ jsxs5(
1245
+ "div",
1246
+ {
1247
+ style: {
1248
+ position: "relative",
1249
+ flex: 1,
1250
+ height: compact ? 5 : 7,
1251
+ background: COLOR_TRACK_BG,
1252
+ border: `1px solid ${COLOR_TRACK_BORDER}`,
1253
+ borderRadius: 2,
1254
+ overflow: "hidden",
1255
+ minWidth: compact ? 0 : 60
1256
+ },
1257
+ children: [
1258
+ /* @__PURE__ */ jsx6("div", { style: { position: "absolute", inset: 0, background: METER_GRADIENT } }),
1259
+ /* @__PURE__ */ jsx6(
1260
+ "div",
1261
+ {
1262
+ style: {
1263
+ position: "absolute",
1264
+ top: 0,
1265
+ bottom: 0,
1266
+ left: `${widthPct}%`,
1267
+ right: 0,
1268
+ background: COLOR_TRACK_BG,
1269
+ transition: "left 30ms linear"
1270
+ }
1271
+ }
1272
+ ),
1273
+ /* @__PURE__ */ jsx6(
1274
+ "div",
1275
+ {
1276
+ "data-testid": `${id}-segments`,
1277
+ style: {
1278
+ position: "absolute",
1279
+ inset: 0,
1280
+ pointerEvents: "none",
1281
+ backgroundImage: `linear-gradient(90deg, transparent 0, transparent calc(100% - ${SEGMENT_GAP_PX}px), ${COLOR_SEGMENT_GAP} calc(100% - ${SEGMENT_GAP_PX}px), ${COLOR_SEGMENT_GAP} 100%)`,
1282
+ backgroundSize: `calc(100% / ${SEGMENTS}) 100%`
1283
+ }
1284
+ }
1285
+ ),
1286
+ showPeak && /* @__PURE__ */ jsx6(
1287
+ "div",
1288
+ {
1289
+ "data-testid": `${id}-peak`,
1290
+ style: {
1291
+ position: "absolute",
1292
+ top: -1,
1293
+ bottom: -1,
1294
+ left: `${peakHoldPct}%`,
1295
+ width: 2,
1296
+ marginLeft: -1,
1297
+ background: COLOR_PEAK,
1298
+ boxShadow: "0 0 4px rgba(247, 255, 251, 0.7)",
1299
+ transition: "left 80ms linear"
1300
+ },
1301
+ title: "Peak"
1302
+ }
1303
+ )
1304
+ ]
1305
+ }
1306
+ ),
1307
+ !compact && /* @__PURE__ */ jsx6(
1308
+ "span",
1309
+ {
1310
+ style: {
1311
+ fontSize: 10,
1312
+ color: "var(--sas-muted, #888)",
1313
+ fontVariantNumeric: "tabular-nums",
1314
+ minWidth: 48,
1315
+ textAlign: "right"
1316
+ },
1317
+ children: active && peakDb > -120 ? `${peakDb.toFixed(0)} dB` : "\u2014"
1318
+ }
1319
+ ),
1320
+ clipped && /* @__PURE__ */ jsx6(
1321
+ "span",
1322
+ {
1323
+ "data-testid": `${id}-clip`,
1324
+ onClick: onClearClip,
1325
+ style: {
1326
+ padding: "1px 5px",
1327
+ fontSize: 9,
1328
+ fontWeight: "bold",
1329
+ background: COLOR_RED,
1330
+ color: "#0A0E14",
1331
+ borderRadius: 2,
1332
+ cursor: onClearClip ? "pointer" : "default",
1333
+ marginLeft: compact ? 3 : 0
1334
+ },
1335
+ title: onClearClip ? "Clipped \u2014 click to clear" : "Clipped",
1336
+ children: "CLIP"
1337
+ }
1338
+ )
1339
+ ]
1340
+ }
1341
+ );
1342
+ };
1343
+
1344
+ // src/hooks/useTrackLevels.ts
1345
+ import { useEffect as useEffect2, useRef as useRef3, useState as useState3 } from "react";
1346
+ var POLL_INTERVAL_MS = 33;
1347
+ var HIDDEN_RECHECK_MS = 250;
1348
+ var METER_FLOOR_DB = -120;
1349
+ var PEAK_HOLD_MS = 1500;
1350
+ var PEAK_DECAY_DB_PER_SEC = 24;
1351
+ function isHidden() {
1352
+ return typeof document !== "undefined" && document.hidden === true;
1353
+ }
1354
+ function useTrackLevels(host, enabled = true) {
1355
+ const mapRef = useRef3(/* @__PURE__ */ new Map());
1356
+ const listenersRef = useRef3(/* @__PURE__ */ new Set());
1357
+ const handleRef = useRef3(null);
1358
+ if (handleRef.current === null) {
1359
+ handleRef.current = {
1360
+ getLevel: (trackId) => mapRef.current.get(trackId) ?? null,
1361
+ subscribe: (listener) => {
1362
+ listenersRef.current.add(listener);
1363
+ return () => {
1364
+ listenersRef.current.delete(listener);
1365
+ };
1366
+ }
1367
+ };
1368
+ }
1369
+ useEffect2(() => {
1370
+ const notify = () => {
1371
+ listenersRef.current.forEach((l) => l());
1372
+ };
1373
+ const clearToIdle = () => {
1374
+ if (mapRef.current.size > 0) {
1375
+ mapRef.current.clear();
1376
+ notify();
1377
+ }
1378
+ };
1379
+ const canPoll = enabled && !!host && typeof host.getTrackLevels === "function";
1380
+ if (!canPoll) {
1381
+ clearToIdle();
1382
+ return;
1383
+ }
1384
+ let stopped = false;
1385
+ let timer = null;
1386
+ const schedule = (delay) => {
1387
+ if (stopped) return;
1388
+ timer = setTimeout(tick, delay);
1389
+ };
1390
+ const tick = async () => {
1391
+ if (stopped) return;
1392
+ if (isHidden()) {
1393
+ schedule(HIDDEN_RECHECK_MS);
1394
+ return;
1395
+ }
1396
+ try {
1397
+ const levels = await host.getTrackLevels();
1398
+ if (stopped) return;
1399
+ const seen = /* @__PURE__ */ new Set();
1400
+ for (const lvl of levels) {
1401
+ mapRef.current.set(lvl.trackId, lvl);
1402
+ seen.add(lvl.trackId);
1403
+ }
1404
+ for (const key of Array.from(mapRef.current.keys())) {
1405
+ if (!seen.has(key)) mapRef.current.delete(key);
1406
+ }
1407
+ notify();
1408
+ } catch {
1409
+ }
1410
+ schedule(POLL_INTERVAL_MS);
1411
+ };
1412
+ const onVisibility = () => {
1413
+ if (stopped) return;
1414
+ if (!isHidden()) {
1415
+ if (timer) clearTimeout(timer);
1416
+ void tick();
1417
+ }
1418
+ };
1419
+ if (typeof document !== "undefined") {
1420
+ document.addEventListener("visibilitychange", onVisibility);
1421
+ }
1422
+ void tick();
1423
+ return () => {
1424
+ stopped = true;
1425
+ if (timer) clearTimeout(timer);
1426
+ if (typeof document !== "undefined") {
1427
+ document.removeEventListener("visibilitychange", onVisibility);
1428
+ }
1429
+ };
1430
+ }, [host, enabled]);
1431
+ return handleRef.current;
1432
+ }
1433
+ function sameLevel(a, b) {
1434
+ if (a === b) return true;
1435
+ if (a === null || b === null) return false;
1436
+ return a.peakDb === b.peakDb && a.clipped === b.clipped;
1437
+ }
1438
+ function useTrackLevel(handle, trackId) {
1439
+ const [level, setLevel] = useState3(null);
1440
+ useEffect2(() => {
1441
+ if (!handle) {
1442
+ setLevel(null);
1443
+ return;
1444
+ }
1445
+ const update = () => {
1446
+ const next = handle.getLevel(trackId);
1447
+ setLevel((prev) => sameLevel(prev, next) ? prev : next);
1448
+ };
1449
+ update();
1450
+ return handle.subscribe(update);
1451
+ }, [handle, trackId]);
1452
+ return level;
1453
+ }
1454
+ var IDLE_METER_VIEW = {
1455
+ peakDb: METER_FLOOR_DB,
1456
+ peakHoldDb: METER_FLOOR_DB,
1457
+ clipped: false,
1458
+ active: false
1459
+ };
1460
+ function sameMeter(a, b) {
1461
+ return a.active === b.active && a.clipped === b.clipped && a.peakDb === b.peakDb && Math.round(a.peakHoldDb * 2) === Math.round(b.peakHoldDb * 2);
1462
+ }
1463
+ function useTrackMeter(handle, trackId) {
1464
+ const [view, setView] = useState3(IDLE_METER_VIEW);
1465
+ const heldDbRef = useRef3(METER_FLOOR_DB);
1466
+ const heldAtRef = useRef3(0);
1467
+ const lastTickRef = useRef3(0);
1468
+ useEffect2(() => {
1469
+ if (!handle) {
1470
+ heldDbRef.current = METER_FLOOR_DB;
1471
+ lastTickRef.current = 0;
1472
+ setView(IDLE_METER_VIEW);
1473
+ return;
1474
+ }
1475
+ const update = () => {
1476
+ const level = handle.getLevel(trackId);
1477
+ const now = performance.now();
1478
+ const dtSec = lastTickRef.current ? Math.max(0, (now - lastTickRef.current) / 1e3) : 0;
1479
+ lastTickRef.current = now;
1480
+ if (level === null) {
1481
+ heldDbRef.current = METER_FLOOR_DB;
1482
+ setView((prev) => sameMeter(prev, IDLE_METER_VIEW) ? prev : IDLE_METER_VIEW);
1483
+ return;
1484
+ }
1485
+ const p = level.peakDb;
1486
+ if (p >= heldDbRef.current) {
1487
+ heldDbRef.current = p;
1488
+ heldAtRef.current = now;
1489
+ } else if (now - heldAtRef.current > PEAK_HOLD_MS) {
1490
+ heldDbRef.current = Math.max(p, heldDbRef.current - PEAK_DECAY_DB_PER_SEC * dtSec);
1491
+ }
1492
+ const next = {
1493
+ peakDb: p,
1494
+ peakHoldDb: heldDbRef.current,
1495
+ clipped: level.clipped,
1496
+ active: true
1497
+ };
1498
+ setView((prev) => sameMeter(prev, next) ? prev : next);
1499
+ };
1500
+ update();
1501
+ return handle.subscribe(update);
1502
+ }, [handle, trackId]);
1503
+ return view;
1504
+ }
1505
+ function useTransportPlaying(host) {
1506
+ const [playing, setPlaying] = useState3(false);
1507
+ useEffect2(() => {
1508
+ if (!host) {
1509
+ setPlaying(false);
1510
+ return;
1511
+ }
1512
+ let cancelled = false;
1513
+ host.getTransportState().then((state) => {
1514
+ if (!cancelled) setPlaying(!!state.isPlaying);
1515
+ }).catch(() => {
1516
+ });
1517
+ const unsub = host.onTransportEvent?.((evt) => {
1518
+ if (typeof evt.isPlaying === "boolean") {
1519
+ setPlaying(evt.isPlaying);
1520
+ } else if (evt.type === "play") {
1521
+ setPlaying(true);
1522
+ } else if (evt.type === "stop" || evt.type === "pause") {
1523
+ setPlaying(false);
1524
+ }
1525
+ });
1526
+ return () => {
1527
+ cancelled = true;
1528
+ unsub?.();
1529
+ };
1530
+ }, [host]);
1531
+ return playing;
1532
+ }
1533
+
1534
+ // src/components/TrackMeterStrip.tsx
1535
+ import { jsx as jsx7 } from "react/jsx-runtime";
1536
+ var TrackMeterStrip = ({
1537
+ levels,
1538
+ trackId,
1539
+ roundBottom = true,
1540
+ className
1541
+ }) => {
1542
+ const meter = useTrackMeter(levels, trackId);
1543
+ return /* @__PURE__ */ jsx7(
1544
+ "div",
1545
+ {
1546
+ "data-testid": "sdk-track-meter",
1547
+ className: `w-full px-2 py-1 bg-sas-panel-alt border border-t-0 border-sas-border ${roundBottom ? "rounded-b-sm" : ""} ${className ?? ""}`,
1548
+ children: /* @__PURE__ */ jsx7(
1549
+ LevelMeter,
1550
+ {
1551
+ compact: true,
1552
+ active: meter.active,
1553
+ peakDb: meter.peakDb,
1554
+ peakHoldDb: meter.peakHoldDb,
1555
+ clipped: meter.clipped,
1556
+ "data-testid": `sdk-track-meter-bar-${trackId}`
1557
+ }
1558
+ )
1559
+ }
1560
+ );
1561
+ };
1562
+
1563
+ // src/components/VolumeSlider.tsx
1564
+ import { useCallback as useCallback2, useState as useState4, useRef as useRef4, useEffect as useEffect3 } from "react";
1565
+
1566
+ // src/utils/volume-conversion.ts
1567
+ var SLIDER_UNITY = 0.75;
1568
+ var DB_MAX = 6;
1569
+ var DB_MIN = -60;
1570
+ var EXPONENT = Math.log(Math.pow(10, DB_MAX / 20)) / Math.log(1 / SLIDER_UNITY);
1571
+ function sliderToDb(slider) {
1572
+ if (slider <= 0) return DB_MIN;
1573
+ const gain = Math.pow(slider / SLIDER_UNITY, EXPONENT);
1574
+ const db = 20 * Math.log10(gain);
1575
+ return Math.max(DB_MIN, Math.min(DB_MAX, db));
1576
+ }
1577
+ function dbToSlider(db) {
1578
+ if (db <= DB_MIN) return 0;
1579
+ if (db >= DB_MAX) return 1;
1580
+ const gain = Math.pow(10, db / 20);
1581
+ const slider = SLIDER_UNITY * Math.pow(gain, 1 / EXPONENT);
1582
+ return Math.min(1, Math.max(0, slider));
1583
+ }
1584
+
1585
+ // src/components/VolumeSlider.tsx
1586
+ import { jsx as jsx8 } from "react/jsx-runtime";
1587
+ function formatDb(value) {
1588
+ const db = sliderToDb(value);
1589
+ if (db <= -60) return "-\u221E dB";
1590
+ const sign = db >= 0 ? "+" : "";
1591
+ return `${sign}${db.toFixed(1)} dB`;
1592
+ }
1593
+ function useDebouncedCallback(callback, delay) {
1594
+ const timeoutRef = useRef4(null);
1595
+ const callbackRef = useRef4(callback);
1596
+ useEffect3(() => {
1597
+ callbackRef.current = callback;
1598
+ }, [callback]);
1599
+ const debouncedCallback = useCallback2(
1600
+ (...args) => {
1601
+ if (timeoutRef.current) {
1602
+ clearTimeout(timeoutRef.current);
1603
+ }
1604
+ timeoutRef.current = setTimeout(() => {
1605
+ callbackRef.current(...args);
1606
+ }, delay);
1607
+ },
1608
+ [delay]
1609
+ );
1610
+ useEffect3(() => {
1611
+ return () => {
1612
+ if (timeoutRef.current) {
1613
+ clearTimeout(timeoutRef.current);
1614
+ }
1615
+ };
1616
+ }, []);
1617
+ return debouncedCallback;
1618
+ }
1619
+ var VolumeSlider = ({
1620
+ value,
1621
+ onChange,
1622
+ disabled = false,
1623
+ className = ""
1624
+ }) => {
1625
+ const [localValue, setLocalValue] = useState4(value);
1626
+ const [isDragging, setIsDragging] = useState4(false);
1627
+ useEffect3(() => {
1628
+ if (!isDragging) {
1629
+ setLocalValue(value);
1630
+ }
1631
+ }, [value, isDragging]);
1632
+ const debouncedOnChange = useDebouncedCallback(onChange, 50);
1633
+ const handleChange = useCallback2(
1634
+ (e) => {
1635
+ const newValue = parseFloat(e.target.value);
1636
+ setLocalValue(newValue);
1637
+ debouncedOnChange(newValue);
1638
+ },
1639
+ [debouncedOnChange]
1640
+ );
1641
+ const handleMouseDown = useCallback2(() => {
1642
+ setIsDragging(true);
1643
+ }, []);
1644
+ const handleMouseUp = useCallback2(() => {
1645
+ setIsDragging(false);
1646
+ onChange(localValue);
1647
+ }, [localValue, onChange]);
1648
+ return /* @__PURE__ */ jsx8(
1649
+ "div",
1650
+ {
1651
+ className: `flex items-center ${className}`,
1652
+ title: `Volume: ${formatDb(localValue)}`,
1653
+ children: /* @__PURE__ */ jsx8(
1654
+ "input",
1655
+ {
1656
+ type: "range",
1657
+ min: "0",
1658
+ max: "1",
1659
+ step: "0.01",
1660
+ value: localValue,
1661
+ onChange: handleChange,
1662
+ onMouseDown: handleMouseDown,
1663
+ onMouseUp: handleMouseUp,
1664
+ onTouchStart: handleMouseDown,
1665
+ onTouchEnd: handleMouseUp,
1666
+ disabled,
1667
+ className: `
1668
+ w-full h-1.5 rounded-full appearance-none cursor-pointer
1669
+ bg-gray-700
1670
+ disabled:opacity-50 disabled:cursor-not-allowed
1671
+ [&::-webkit-slider-thumb]:appearance-none
1672
+ [&::-webkit-slider-thumb]:w-3
1673
+ [&::-webkit-slider-thumb]:h-3
1674
+ [&::-webkit-slider-thumb]:rounded-full
1675
+ [&::-webkit-slider-thumb]:bg-sas-accent
1676
+ [&::-webkit-slider-thumb]:cursor-pointer
1677
+ [&::-webkit-slider-thumb]:transition-transform
1678
+ [&::-webkit-slider-thumb]:hover:scale-110
1679
+ [&::-moz-range-thumb]:w-3
1680
+ [&::-moz-range-thumb]:h-3
1681
+ [&::-moz-range-thumb]:rounded-full
1682
+ [&::-moz-range-thumb]:bg-sas-accent
1683
+ [&::-moz-range-thumb]:border-0
1684
+ [&::-moz-range-thumb]:cursor-pointer
1685
+ `
1686
+ }
1687
+ )
1688
+ }
1689
+ );
1690
+ };
1691
+
1692
+ // src/components/PanSlider.tsx
1693
+ import { useCallback as useCallback3, useState as useState5, useRef as useRef5, useEffect as useEffect4 } from "react";
1694
+ import { jsx as jsx9 } from "react/jsx-runtime";
1695
+ function toPanDisplay(value) {
1696
+ if (Math.abs(value) < 0.02) {
1697
+ return "Center";
1698
+ }
1699
+ const percent = Math.abs(Math.round(value * 100));
1700
+ return value < 0 ? `L${percent}` : `R${percent}`;
1701
+ }
1702
+ function useDebouncedCallback2(callback, delay) {
1703
+ const timeoutRef = useRef5(null);
1704
+ const callbackRef = useRef5(callback);
1705
+ useEffect4(() => {
1706
+ callbackRef.current = callback;
1707
+ }, [callback]);
1708
+ const debouncedCallback = useCallback3(
1709
+ (...args) => {
1710
+ if (timeoutRef.current) {
1711
+ clearTimeout(timeoutRef.current);
1712
+ }
1713
+ timeoutRef.current = setTimeout(() => {
1714
+ callbackRef.current(...args);
1715
+ }, delay);
1716
+ },
1717
+ [delay]
1718
+ );
1719
+ useEffect4(() => {
1720
+ return () => {
1721
+ if (timeoutRef.current) {
1722
+ clearTimeout(timeoutRef.current);
1723
+ }
1724
+ };
1725
+ }, []);
1726
+ return debouncedCallback;
1727
+ }
1728
+ var PanSlider = ({
1729
+ value,
1730
+ onChange,
1731
+ disabled = false,
1732
+ className = ""
1733
+ }) => {
1734
+ const [localValue, setLocalValue] = useState5(value);
1735
+ const [isDragging, setIsDragging] = useState5(false);
1736
+ useEffect4(() => {
1737
+ if (!isDragging) {
1738
+ setLocalValue(value);
1739
+ }
1740
+ }, [value, isDragging]);
1741
+ const debouncedOnChange = useDebouncedCallback2(onChange, 50);
1742
+ const handleChange = useCallback3(
1743
+ (e) => {
1744
+ const newValue = parseFloat(e.target.value);
1745
+ setLocalValue(newValue);
1746
+ debouncedOnChange(newValue);
1747
+ },
1748
+ [debouncedOnChange]
1749
+ );
1750
+ const handleMouseDown = useCallback3(() => {
1751
+ setIsDragging(true);
1752
+ }, []);
1753
+ const handleMouseUp = useCallback3(() => {
1754
+ setIsDragging(false);
1755
+ onChange(localValue);
1756
+ }, [localValue, onChange]);
1757
+ const handleDoubleClick = useCallback3(() => {
1758
+ setLocalValue(0);
1759
+ onChange(0);
1760
+ }, [onChange]);
1761
+ return /* @__PURE__ */ jsx9(
1762
+ "div",
1763
+ {
1764
+ className: `flex items-center ${className}`,
1765
+ title: `Pan: ${toPanDisplay(localValue)}`,
1766
+ children: /* @__PURE__ */ jsx9(
1767
+ "input",
1768
+ {
1769
+ type: "range",
1770
+ min: "-1",
1771
+ max: "1",
1772
+ step: "0.01",
1773
+ value: localValue,
1774
+ onChange: handleChange,
1775
+ onMouseDown: handleMouseDown,
1776
+ onMouseUp: handleMouseUp,
1777
+ onTouchStart: handleMouseDown,
1778
+ onTouchEnd: handleMouseUp,
1779
+ onDoubleClick: handleDoubleClick,
1780
+ disabled,
1781
+ className: `
1782
+ w-full h-1.5 rounded-full appearance-none cursor-pointer
1783
+ bg-gray-700
1784
+ disabled:opacity-50 disabled:cursor-not-allowed
1785
+ [&::-webkit-slider-thumb]:appearance-none
1786
+ [&::-webkit-slider-thumb]:w-3
1787
+ [&::-webkit-slider-thumb]:h-3
1788
+ [&::-webkit-slider-thumb]:rounded-full
1789
+ [&::-webkit-slider-thumb]:bg-sas-accent
1790
+ [&::-webkit-slider-thumb]:cursor-pointer
1791
+ [&::-webkit-slider-thumb]:transition-transform
1792
+ [&::-webkit-slider-thumb]:hover:scale-110
1793
+ [&::-moz-range-thumb]:w-3
1794
+ [&::-moz-range-thumb]:h-3
1795
+ [&::-moz-range-thumb]:rounded-full
1796
+ [&::-moz-range-thumb]:bg-sas-accent
1797
+ [&::-moz-range-thumb]:border-0
1798
+ [&::-moz-range-thumb]:cursor-pointer
1799
+ `
1800
+ }
1801
+ )
1802
+ }
1803
+ );
1804
+ };
1805
+
1806
+ // src/components/SorceryProgressBar.tsx
1807
+ import { useState as useState6, useEffect as useEffect5, useRef as useRef6 } from "react";
1808
+ import { jsx as jsx10, jsxs as jsxs6 } from "react/jsx-runtime";
1809
+ function calculateTimeBasedTarget(elapsedMs, estimatedDurationMs) {
1810
+ const t = elapsedMs / estimatedDurationMs;
1811
+ if (t <= 0) return 0;
1812
+ if (t <= 1) {
1813
+ return 90 * (1 - Math.pow(1 - t, 2.5));
1814
+ }
1815
+ const overshootRatio = (elapsedMs - estimatedDurationMs) / estimatedDurationMs;
1816
+ return 90 + 5 * (1 - Math.exp(-overshootRatio * 3));
1817
+ }
1818
+ function calculateNextProgress(currentProgress) {
1819
+ if (currentProgress < 20) {
1820
+ return currentProgress + Math.random() * 10 + 5;
1821
+ }
1822
+ if (currentProgress < 60) {
1823
+ return currentProgress + Math.random() * 5 + 2;
1824
+ }
1825
+ if (currentProgress < 95) {
1826
+ const remaining = 95 - currentProgress;
1827
+ const increment = remaining * (Math.random() * 0.2 + 0.1);
1828
+ return currentProgress + Math.max(increment, 0.1);
1829
+ }
1830
+ return 95;
1831
+ }
1832
+ function calculateNextTickInterval(progress) {
1833
+ if (progress < 30) {
1834
+ return Math.random() * 200 + 150;
1835
+ }
1836
+ if (progress < 70) {
1837
+ return Math.random() * 300 + 200;
1838
+ }
1839
+ return Math.random() * 600 + 400;
1840
+ }
1841
+ var TIME_BASED_TICK_MIN = 200;
1842
+ var TIME_BASED_TICK_RANGE = 100;
1843
+ function SorceryProgressBar({
1844
+ isLoading,
1845
+ statusText = "CONJURING...",
1846
+ completeText = "COMPLETE",
1847
+ onComplete,
1848
+ heightClass = "h-10",
1849
+ initialProgress = 0,
1850
+ onProgressChange,
1851
+ estimatedDurationMs
1852
+ }) {
1853
+ const [progress, setProgress] = useState6(initialProgress);
1854
+ const timerRef = useRef6(null);
1855
+ const isLoadingRef = useRef6(false);
1856
+ const hasStartedRef = useRef6(false);
1857
+ const startTimeRef = useRef6(0);
1858
+ const onProgressChangeRef = useRef6(onProgressChange);
1859
+ const onCompleteRef = useRef6(onComplete);
1860
+ onProgressChangeRef.current = onProgressChange;
1861
+ onCompleteRef.current = onComplete;
1862
+ const initialProgressRef = useRef6(initialProgress);
1863
+ initialProgressRef.current = initialProgress;
1864
+ const estimatedDurationMsRef = useRef6(estimatedDurationMs);
1865
+ estimatedDurationMsRef.current = estimatedDurationMs;
1866
+ useEffect5(() => {
1867
+ const wasLoading = isLoadingRef.current;
1868
+ isLoadingRef.current = isLoading;
1869
+ if (isLoading && !wasLoading) {
1870
+ hasStartedRef.current = true;
1871
+ startTimeRef.current = Date.now();
1872
+ const startProgress = initialProgressRef.current > 0 ? initialProgressRef.current : 0;
1873
+ setProgress(startProgress);
1874
+ const duration = estimatedDurationMsRef.current;
1875
+ if (duration && duration > 0) {
1876
+ const tick = () => {
1877
+ setProgress((prev) => {
1878
+ const elapsed = Date.now() - startTimeRef.current;
1879
+ const target = calculateTimeBasedTarget(elapsed, duration);
1880
+ const jitter = (Math.random() - 0.5) * 1;
1881
+ const next = Math.min(Math.max(target + jitter, prev + 0.05), 95);
1882
+ onProgressChangeRef.current?.(next);
1883
+ timerRef.current = setTimeout(tick, TIME_BASED_TICK_MIN + Math.random() * TIME_BASED_TICK_RANGE);
1884
+ return next;
1885
+ });
1886
+ };
1887
+ timerRef.current = setTimeout(tick, TIME_BASED_TICK_MIN);
1888
+ } else {
1889
+ const tick = () => {
1890
+ setProgress((prev) => {
1891
+ if (prev >= 95) {
1892
+ timerRef.current = setTimeout(tick, 1e3);
1893
+ return 95;
1894
+ }
1895
+ const next = Math.min(calculateNextProgress(prev), 95);
1896
+ onProgressChangeRef.current?.(next);
1897
+ const interval = calculateNextTickInterval(next);
1898
+ timerRef.current = setTimeout(tick, interval);
1899
+ return next;
1900
+ });
1901
+ };
1902
+ const firstInterval = calculateNextTickInterval(startProgress);
1903
+ timerRef.current = setTimeout(tick, firstInterval);
1904
+ }
1905
+ } else if (!isLoading && wasLoading && hasStartedRef.current) {
1906
+ if (timerRef.current) {
1907
+ clearTimeout(timerRef.current);
1908
+ timerRef.current = null;
1909
+ }
1910
+ setProgress(100);
1911
+ onProgressChangeRef.current?.(100);
1912
+ onCompleteRef.current?.();
1913
+ hasStartedRef.current = false;
1914
+ }
1915
+ return () => {
1916
+ if (timerRef.current) {
1917
+ clearTimeout(timerRef.current);
1918
+ timerRef.current = null;
1919
+ }
1920
+ };
1921
+ }, [isLoading]);
1922
+ if (!isLoading && progress === 0) {
1923
+ return null;
1924
+ }
1925
+ const displayProgress = Math.floor(progress);
1926
+ const isComplete = !isLoading && progress === 100;
1927
+ const transitionDuration = progress < 50 ? "300ms" : progress < 80 ? "500ms" : "700ms";
1928
+ return /* @__PURE__ */ jsxs6(
1929
+ "div",
1930
+ {
1931
+ className: `relative w-full ${heightClass} bg-sas-panel-alt border border-sas-border rounded-sm overflow-hidden shadow-inner`,
1932
+ children: [
1933
+ /* @__PURE__ */ jsx10(
1934
+ "div",
1935
+ {
1936
+ className: `
1937
+ h-full
1938
+ bg-gradient-to-r from-sas-accent/70 to-sas-accent
1939
+ shadow-glow-soft
1940
+ sorcery-progress-fill
1941
+ animate-progress-stripes
1942
+ ${progress > 70 ? "animate-progress-pulse" : ""}
1943
+ transition-all ease-out
1944
+ `,
1945
+ style: {
1946
+ width: `${progress}%`,
1947
+ transitionDuration
1948
+ }
1949
+ }
1950
+ ),
1951
+ /* @__PURE__ */ jsx10("div", { className: "absolute inset-0 flex items-center justify-center", children: isLoading && progress < 100 ? /* @__PURE__ */ jsxs6("span", { className: "font-mono text-xs text-sas-accent font-bold drop-shadow-md tracking-wider", children: [
1952
+ statusText,
1953
+ " ",
1954
+ displayProgress,
1955
+ "%"
1956
+ ] }) : isComplete ? /* @__PURE__ */ jsx10("span", { className: "font-mono text-xs text-sas-text font-bold drop-shadow-md tracking-wider", children: completeText }) : null }),
1957
+ /* @__PURE__ */ jsx10(
1958
+ "div",
1959
+ {
1960
+ className: "absolute inset-0 pointer-events-none opacity-10",
1961
+ style: {
1962
+ backgroundImage: `repeating-linear-gradient(
1963
+ to bottom,
1964
+ transparent,
1965
+ transparent 2px,
1966
+ rgba(0, 0, 0, 0.3) 2px,
1967
+ rgba(0, 0, 0, 0.3) 4px
1968
+ )`
1969
+ }
1970
+ }
1971
+ )
1972
+ ]
1973
+ }
1974
+ );
1975
+ }
1976
+
1977
+ // src/components/TrackRow.tsx
1978
+ import { Fragment, jsx as jsx11, jsxs as jsxs7 } from "react/jsx-runtime";
1979
+ function TrackRow({
1980
+ track,
1981
+ prompt,
1982
+ runtimeState,
1983
+ soloedOut = false,
1984
+ fxDetailState,
1985
+ drawerOpen,
1986
+ drawerTab,
1987
+ onTabChange,
1988
+ isGenerating = false,
1989
+ isAuthenticated = false,
1990
+ error,
1991
+ hasMidi = false,
1992
+ generationProgress = 0,
1993
+ estimatedGenerationMs = 15e3,
1994
+ onPromptChange,
1995
+ onGenerate,
1996
+ onShuffle,
1997
+ onCopy,
1998
+ onDelete,
1999
+ contentSlot,
2000
+ onMuteToggle,
2001
+ onSoloToggle,
2002
+ onVolumeChange,
2003
+ onPanChange,
2004
+ onFxToggle,
2005
+ onFxPresetChange,
2006
+ onFxDryWetChange,
2007
+ onToggleFxDrawer,
2008
+ onProgressChange,
2009
+ accentColor = "#A78BFA",
2010
+ instrumentName,
2011
+ instrumentMissing,
2012
+ onToggleDrawer,
2013
+ availableInstruments,
2014
+ currentInstrumentPluginId,
2015
+ onInstrumentSelect,
2016
+ instrumentsLoading,
2017
+ onRefreshInstruments,
2018
+ editorStage,
2019
+ onShowEditor,
2020
+ onBackToInstruments,
2021
+ soundHistory,
2022
+ soundHistoryCursor,
2023
+ onRestoreSound,
2024
+ onToggleFavorite,
2025
+ onImportSound,
2026
+ importSoundLabel,
2027
+ editNotes,
2028
+ onNotesChange,
2029
+ editBars,
2030
+ editBpm,
2031
+ editSnap,
2032
+ onAuditionNote,
2033
+ drag,
2034
+ levels
2035
+ }) {
2036
+ const { muted: isMuted, solo: isSoloed, volume: currentVolume, pan: currentPan } = runtimeState;
2037
+ const [confirmDelete, setConfirmDelete] = React8.useState(false);
2038
+ const needsGeneration = !!(prompt?.trim() && !hasMidi && !isGenerating);
2039
+ const hasFxActive = Object.values(fxDetailState).some(
2040
+ (d) => d.enabled
2041
+ );
2042
+ const fxTabOpen = drawerOpen && drawerTab === "fx";
2043
+ const soundTabOpen = drawerOpen && drawerTab !== "fx";
2044
+ const handleKeyDown = (e) => {
2045
+ if (e.key === "Enter" && !e.shiftKey && onGenerate) {
2046
+ e.preventDefault();
2047
+ onGenerate();
2048
+ }
2049
+ };
2050
+ const borderColorStyle = needsGeneration ? void 0 : accentColor;
2051
+ const borderClass = needsGeneration ? "border-amber-400 animate-pulse" : "border-sas-border";
2052
+ return /* @__PURE__ */ jsxs7("div", { "data-testid": "sdk-track-row-wrapper", className: "w-full", ...drag?.rowProps ?? {}, children: [
2053
+ /* @__PURE__ */ jsxs7(
2054
+ "div",
2055
+ {
2056
+ "data-testid": "sdk-track-row",
2057
+ className: `relative flex items-stretch gap-1 p-2 ${levels ? "rounded-t-sm" : "rounded-sm"} border w-full overflow-hidden ${borderClass} bg-sas-panel-alt ${drag?.isDragging ? "opacity-40" : ""} ${drag?.isDragTarget ? "ring-2 ring-sas-accent ring-inset" : ""}`,
2058
+ style: {
2059
+ borderLeftColor: needsGeneration ? "#f59e0b" : borderColorStyle,
2060
+ borderLeftWidth: "3px"
2061
+ },
2062
+ children: [
2063
+ drag && /* @__PURE__ */ jsx11(
2064
+ "div",
2065
+ {
2066
+ "data-testid": "sdk-drag-handle",
2067
+ ...drag.handleProps,
2068
+ className: "flex-shrink-0 self-stretch flex items-center -ml-0.5 pr-0.5 text-sas-muted/40 hover:text-sas-muted cursor-grab active:cursor-grabbing relative z-30",
2069
+ title: "Drag to reorder",
2070
+ "aria-label": "Drag to reorder track",
2071
+ children: /* @__PURE__ */ jsx11(GripVertical, { className: "w-3.5 h-3.5", strokeWidth: 2 })
2072
+ }
2073
+ ),
2074
+ isGenerating && /* @__PURE__ */ jsx11("div", { className: "absolute left-0 top-0 bottom-0 right-44 z-20", children: /* @__PURE__ */ jsx11(
2075
+ SorceryProgressBar,
2076
+ {
2077
+ isLoading: true,
2078
+ statusText: "CONJURING MIDI...",
2079
+ heightClass: "h-full",
2080
+ initialProgress: generationProgress,
2081
+ onProgressChange,
2082
+ estimatedDurationMs: estimatedGenerationMs
2083
+ }
2084
+ ) }),
2085
+ /* @__PURE__ */ jsxs7(
2086
+ "div",
2087
+ {
2088
+ "data-testid": "sdk-track-content",
2089
+ className: `flex flex-col flex-1 min-w-0 relative z-10 transition-opacity ${soloedOut ? "opacity-40" : ""}`,
2090
+ title: soloedOut ? "Silenced \u2014 another track is soloed" : void 0,
2091
+ children: [
2092
+ contentSlot ? contentSlot : onPromptChange ? /* @__PURE__ */ jsx11(
2093
+ "input",
2094
+ {
2095
+ type: "text",
2096
+ "data-testid": "sdk-prompt-input",
2097
+ value: prompt ?? "",
2098
+ onChange: (e) => onPromptChange(e.target.value),
2099
+ onKeyDown: handleKeyDown,
2100
+ placeholder: "Describe your part...",
2101
+ disabled: isGenerating,
2102
+ className: "sas-input w-full px-2 py-1 text-xs disabled:opacity-50 disabled:cursor-not-allowed"
2103
+ }
2104
+ ) : null,
2105
+ /* @__PURE__ */ jsxs7("div", { className: "flex items-center gap-2 mt-1", children: [
2106
+ track.name && /* @__PURE__ */ jsx11("span", { className: "text-[10px] text-sas-muted/60 truncate pl-2 flex-shrink-0 max-w-[80px]", title: track.name, children: track.name }),
2107
+ /* @__PURE__ */ jsx11("span", { className: "text-[9px] text-sas-muted/50 flex-shrink-0", children: "vol:" }),
2108
+ /* @__PURE__ */ jsx11(
2109
+ VolumeSlider,
2110
+ {
2111
+ value: currentVolume,
2112
+ onChange: onVolumeChange,
2113
+ disabled: isGenerating,
2114
+ className: "flex-1 min-w-[40px]"
2115
+ }
2116
+ ),
2117
+ /* @__PURE__ */ jsx11("span", { className: "text-[9px] text-sas-muted/50 flex-shrink-0", children: "pan:" }),
2118
+ /* @__PURE__ */ jsx11(
2119
+ PanSlider,
2120
+ {
2121
+ value: currentPan,
2122
+ onChange: onPanChange,
2123
+ disabled: isGenerating,
2124
+ className: "w-10 flex-shrink-0"
2125
+ }
2126
+ )
2127
+ ] })
2128
+ ]
2129
+ }
2130
+ ),
2131
+ error && /* @__PURE__ */ jsx11(
2132
+ "div",
2133
+ {
2134
+ "data-testid": "sdk-error-indicator",
2135
+ className: "flex-shrink-0 relative z-10 self-stretch flex items-center px-1 group cursor-help",
2136
+ title: error,
2137
+ children: /* @__PURE__ */ jsxs7("div", { className: "relative", children: [
2138
+ /* @__PURE__ */ jsx11(
2139
+ AlertCircle,
2140
+ {
2141
+ className: "w-5 h-5 text-red-500 animate-pulse",
2142
+ strokeWidth: 2.5
2143
+ }
2144
+ ),
2145
+ /* @__PURE__ */ jsx11("div", { className: "absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-red-900/95 text-red-100 text-xs rounded shadow-lg whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50 max-w-[200px] truncate", children: error })
2146
+ ] })
2147
+ }
2148
+ ),
2149
+ /* @__PURE__ */ jsxs7("div", { className: "flex flex-col gap-0.5 flex-shrink-0 relative z-30 justify-center", children: [
2150
+ /* @__PURE__ */ jsxs7("div", { className: "flex gap-1 items-center", children: [
2151
+ onGenerate && /* @__PURE__ */ jsx11(
2152
+ "button",
2153
+ {
2154
+ "data-testid": "sdk-generate-button",
2155
+ onClick: onGenerate,
2156
+ disabled: !isAuthenticated || isGenerating || !prompt?.trim(),
2157
+ className: `w-14 py-0.5 rounded-sm text-xs font-medium transition-colors border ${!isAuthenticated || isGenerating ? "bg-sas-panel border-sas-border text-sas-muted/50 cursor-not-allowed" : needsGeneration ? "bg-amber-500/30 border-amber-500 text-amber-400 hover:bg-amber-500 hover:text-sas-bg animate-pulse" : prompt?.trim() ? "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"}`,
2158
+ title: !isAuthenticated ? "Please log in" : isGenerating ? "Generating..." : "Generate MIDI",
2159
+ children: "Create"
2160
+ }
2161
+ ),
2162
+ onCopy && /* @__PURE__ */ jsx11(
2163
+ "button",
2164
+ {
2165
+ "data-testid": "sdk-copy-button",
2166
+ onClick: onCopy,
2167
+ disabled: !hasMidi || isGenerating,
2168
+ className: `w-14 py-0.5 rounded-sm text-xs font-medium transition-colors border ${!hasMidi || isGenerating ? "bg-sas-panel border-sas-border text-sas-muted/30 cursor-not-allowed" : "bg-sas-panel-alt border-sas-border text-sas-muted hover:border-sas-accent hover:text-sas-accent"}`,
2169
+ title: hasMidi ? "Duplicate track with different preset" : "Generate MIDI first",
2170
+ children: "Copy"
2171
+ }
2172
+ ),
2173
+ /* @__PURE__ */ jsx11(
2174
+ "button",
2175
+ {
2176
+ "data-testid": "sdk-mute-button",
2177
+ 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"}`,
2180
+ title: isMuted ? "Unmute track" : "Mute track",
2181
+ children: "M"
2182
+ }
2183
+ ),
2184
+ onDelete && /* @__PURE__ */ jsx11(
2185
+ "button",
2186
+ {
2187
+ "data-testid": "sdk-delete-button",
2188
+ onClick: () => setConfirmDelete(true),
2189
+ className: "text-sas-danger/70 hover:text-sas-danger px-1 py-0.5 transition-colors text-sm",
2190
+ title: "Delete track",
2191
+ children: "x"
2192
+ }
2193
+ )
2194
+ ] }),
2195
+ /* @__PURE__ */ jsxs7("div", { className: "flex gap-1 items-center", children: [
2196
+ onShuffle && /* @__PURE__ */ jsx11(
2197
+ "button",
2198
+ {
2199
+ "data-testid": "sdk-shuffle-button",
2200
+ onClick: onShuffle,
2201
+ disabled: !hasMidi || isGenerating || !!currentInstrumentPluginId,
2202
+ className: `w-14 py-0.5 rounded-sm text-xs font-medium transition-colors border ${!hasMidi || isGenerating || !!currentInstrumentPluginId ? "bg-sas-panel border-sas-border text-sas-muted/30 cursor-not-allowed" : "bg-sas-panel-alt border-sas-border text-sas-muted hover:border-sas-accent hover:text-sas-accent"}`,
2203
+ title: currentInstrumentPluginId ? "Shuffle only works with default Surge XT" : hasMidi ? "Re-roll sound (keep MIDI)" : "Generate MIDI first",
2204
+ children: "Shuffle"
2205
+ }
2206
+ ),
2207
+ onToggleFxDrawer && /* @__PURE__ */ jsx11(
2208
+ "button",
2209
+ {
2210
+ "data-testid": "sdk-fx-button",
2211
+ onClick: onToggleFxDrawer,
2212
+ disabled: isGenerating,
2213
+ className: `w-14 py-0.5 rounded-sm text-xs font-medium transition-colors border ${isGenerating ? "bg-sas-panel border-sas-border text-sas-muted/50 cursor-not-allowed" : fxTabOpen ? "bg-sas-accent border-sas-accent text-sas-bg" : hasFxActive ? "bg-sas-accent/20 border-sas-accent text-sas-accent hover:bg-sas-accent hover:text-sas-bg" : "bg-sas-panel-alt border-sas-border text-sas-muted hover:border-sas-accent hover:text-sas-accent"}`,
2214
+ title: fxTabOpen ? "Hide FX controls" : "Show FX controls",
2215
+ children: "FX"
2216
+ }
2217
+ ),
2218
+ /* @__PURE__ */ jsx11(
2219
+ "button",
2220
+ {
2221
+ "data-testid": "sdk-solo-button",
2222
+ onClick: onSoloToggle,
2223
+ disabled: isGenerating,
2224
+ 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" : isSoloed ? "bg-yellow-500 text-black" : "bg-sas-panel-alt text-sas-muted hover:bg-sas-border"}`,
2225
+ title: isSoloed ? "Unsolo track" : "Solo track",
2226
+ children: "S"
2227
+ }
2228
+ ),
2229
+ onToggleDrawer && /* @__PURE__ */ jsx11(
2230
+ "button",
2231
+ {
2232
+ "data-testid": "sdk-plugin-button",
2233
+ onClick: onToggleDrawer,
2234
+ disabled: isGenerating,
2235
+ 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" : soundTabOpen ? "bg-sas-accent border-sas-accent text-sas-bg" : instrumentMissing ? "bg-amber-500/20 text-amber-400 hover:bg-amber-500/40" : "bg-sas-panel-alt text-sas-muted hover:bg-sas-border"}`,
2236
+ title: `Sound \u2014 presets & history${instrumentMissing ? " (instrument missing)" : ""}`,
2237
+ children: /* @__PURE__ */ jsx11(ChevronDown, { className: "w-3 h-3", strokeWidth: 2.5 })
2238
+ }
2239
+ )
2240
+ ] })
2241
+ ] })
2242
+ ]
2243
+ }
2244
+ ),
2245
+ levels && /* @__PURE__ */ jsx11(TrackMeterStrip, { levels, trackId: track.id, roundBottom: !drawerOpen }),
2246
+ drawerOpen && /* @__PURE__ */ jsx11(
2247
+ "div",
2248
+ {
2249
+ "data-testid": "sdk-track-drawer",
2250
+ className: "border border-t-0 border-sas-border bg-sas-bg rounded-b-sm px-3 py-2 max-h-[260px] overflow-y-auto",
2251
+ children: /* @__PURE__ */ jsx11(
2252
+ TrackDrawer,
2253
+ {
2254
+ activeTab: drawerTab,
2255
+ onTabChange,
2256
+ trackId: track.id,
2257
+ fxState: fxDetailState,
2258
+ onFxToggle,
2259
+ onFxPresetChange,
2260
+ onFxDryWetChange,
2261
+ fxDisabled: isGenerating,
2262
+ instruments: availableInstruments,
2263
+ currentPluginId: currentInstrumentPluginId ?? null,
2264
+ isLoading: instrumentsLoading ?? false,
2265
+ onSelect: onInstrumentSelect,
2266
+ onRefresh: onRefreshInstruments,
2267
+ editorStage,
2268
+ onShowEditor,
2269
+ onBackToInstruments,
2270
+ selectedInstrumentName: instrumentName,
2271
+ soundHistory,
2272
+ soundHistoryCursor,
2273
+ onRestoreSound,
2274
+ onToggleFavorite,
2275
+ onImportSound,
2276
+ importSoundLabel,
2277
+ editNotes,
2278
+ onNotesChange,
2279
+ editBars,
2280
+ editBpm,
2281
+ editSnap,
2282
+ onAuditionNote
2283
+ }
2284
+ )
2285
+ }
2286
+ ),
2287
+ /* @__PURE__ */ jsx11(
2288
+ ConfirmDialog,
2289
+ {
2290
+ open: confirmDelete,
2291
+ title: "Delete track?",
2292
+ message: /* @__PURE__ */ jsxs7(Fragment, { children: [
2293
+ /* @__PURE__ */ jsx11("span", { className: "text-sas-text", children: track.name?.trim() || "This track" }),
2294
+ " will be permanently removed from this scene. This cannot be undone."
2295
+ ] }),
2296
+ confirmLabel: "Delete",
2297
+ onConfirm: () => {
2298
+ setConfirmDelete(false);
2299
+ onDelete?.();
2300
+ },
2301
+ onCancel: () => setConfirmDelete(false),
2302
+ testIdPrefix: "track-delete-confirm"
2303
+ }
2304
+ )
2305
+ ] });
2306
+ }
2307
+
2308
+ // src/components/CrossfadeTrackRow.tsx
2309
+ import React9 from "react";
2310
+ import { Fragment as Fragment2, jsx as jsx12, jsxs as jsxs8 } from "react/jsx-runtime";
2311
+ function LayerCaption({ tag, layer }) {
2312
+ return /* @__PURE__ */ jsxs8("div", { className: "flex items-center gap-1.5 min-w-0 px-2 py-0.5", children: [
2313
+ /* @__PURE__ */ jsx12("span", { className: "text-[9px] font-bold uppercase tracking-wide text-sas-accent flex-shrink-0", children: tag }),
2314
+ /* @__PURE__ */ jsx12("span", { className: "text-[11px] text-sas-text truncate", title: layer.sourceName ?? layer.name, children: layer.sourceName ?? layer.name }),
2315
+ layer.soundLabel && /* @__PURE__ */ jsxs8("span", { className: "text-[9px] text-sas-muted/60 truncate flex-shrink-0", title: layer.soundLabel, children: [
2316
+ "\xB7 ",
2317
+ layer.soundLabel
2318
+ ] })
2319
+ ] });
2320
+ }
2321
+ function CrossfadeTrackRow({
2322
+ origin,
2323
+ target,
2324
+ sliderPos = 0.5,
2325
+ onMuteToggle,
2326
+ onSoloToggle,
2327
+ onVolumeChange,
2328
+ onPanChange,
2329
+ onDelete,
2330
+ onSliderChange,
2331
+ levels,
2332
+ accentColor = "#9333EA"
2333
+ }) {
2334
+ const [confirmDelete, setConfirmDelete] = React9.useState(false);
2335
+ const renderLayer = (layer, slot, tag) => /* @__PURE__ */ jsx12(
2336
+ TrackRow,
2337
+ {
2338
+ track: { id: layer.trackId, name: "", role: layer.role },
2339
+ runtimeState: layer.runtimeState,
2340
+ fxDetailState: EMPTY_FX_DETAIL_STATE,
2341
+ drawerOpen: false,
2342
+ drawerTab: "fx",
2343
+ levels,
2344
+ accentColor,
2345
+ contentSlot: /* @__PURE__ */ jsx12(LayerCaption, { tag, layer }),
2346
+ onMuteToggle,
2347
+ onSoloToggle,
2348
+ onVolumeChange: (v) => onVolumeChange(slot, v),
2349
+ onPanChange: (p) => onPanChange(slot, p)
2350
+ }
2351
+ );
2352
+ return /* @__PURE__ */ jsxs8(
2353
+ "div",
2354
+ {
2355
+ "data-testid": "crossfade-track-row",
2356
+ className: "w-full rounded-sm border border-sas-border bg-sas-panel/40 overflow-hidden",
2357
+ style: { borderLeftColor: accentColor, borderLeftWidth: "3px" },
2358
+ children: [
2359
+ /* @__PURE__ */ jsxs8("div", { className: "flex items-center justify-between px-2 py-1 bg-sas-panel-alt/60", children: [
2360
+ /* @__PURE__ */ jsx12("span", { className: "text-[10px] font-bold uppercase tracking-wide", style: { color: accentColor }, children: "\u21C4 Crossfade" }),
2361
+ /* @__PURE__ */ jsx12(
2362
+ "button",
2363
+ {
2364
+ "data-testid": "crossfade-delete-button",
2365
+ onClick: () => setConfirmDelete(true),
2366
+ className: "text-sas-danger/70 hover:text-sas-danger px-1 transition-colors text-sm",
2367
+ title: "Delete crossfade pair",
2368
+ "aria-label": "Delete crossfade pair",
2369
+ children: "x"
2370
+ }
2371
+ )
2372
+ ] }),
2373
+ renderLayer(origin, "origin", "Origin"),
2374
+ /* @__PURE__ */ jsxs8("div", { className: "flex items-center gap-2 px-3 py-1.5", "data-testid": "crossfade-slider-row", children: [
2375
+ /* @__PURE__ */ jsx12(
2376
+ "span",
2377
+ {
2378
+ className: "text-[9px] text-sas-muted/60 truncate max-w-[70px] text-right flex-shrink-0",
2379
+ title: origin.sourceName ?? origin.name,
2380
+ children: origin.sourceName ?? origin.name
2381
+ }
2382
+ ),
2383
+ /* @__PURE__ */ jsx12(
2384
+ "input",
2385
+ {
2386
+ type: "range",
2387
+ "data-testid": "crossfade-slider",
2388
+ min: 0,
2389
+ max: 1,
2390
+ step: 0.01,
2391
+ value: sliderPos,
2392
+ disabled: !onSliderChange,
2393
+ onChange: onSliderChange ? (e) => onSliderChange(Number(e.target.value)) : void 0,
2394
+ style: { accentColor },
2395
+ className: "flex-1 disabled:opacity-60 disabled:cursor-not-allowed",
2396
+ "aria-label": "Crossfade position"
2397
+ }
2398
+ ),
2399
+ /* @__PURE__ */ jsx12(
2400
+ "span",
2401
+ {
2402
+ className: "text-[9px] text-sas-muted/60 truncate max-w-[70px] flex-shrink-0",
2403
+ title: target.sourceName ?? target.name,
2404
+ children: target.sourceName ?? target.name
2405
+ }
2406
+ )
2407
+ ] }),
2408
+ renderLayer(target, "target", "Target"),
2409
+ /* @__PURE__ */ jsx12(
2410
+ ConfirmDialog,
2411
+ {
2412
+ open: confirmDelete,
2413
+ title: "Delete crossfade?",
2414
+ message: /* @__PURE__ */ jsx12(Fragment2, { children: "This crossfade pair (both layers) will be permanently removed from this scene. This cannot be undone." }),
2415
+ confirmLabel: "Delete",
2416
+ onConfirm: () => {
2417
+ setConfirmDelete(false);
2418
+ onDelete();
2419
+ },
2420
+ onCancel: () => setConfirmDelete(false),
2421
+ testIdPrefix: "crossfade-delete-confirm"
2422
+ }
2423
+ )
2424
+ ]
2425
+ }
2426
+ );
798
2427
  }
799
- function calculateNextProgress(currentProgress) {
800
- if (currentProgress < 20) {
801
- return currentProgress + Math.random() * 10 + 5;
802
- }
803
- if (currentProgress < 60) {
804
- return currentProgress + Math.random() * 5 + 2;
2428
+
2429
+ // src/crossfade-meta.ts
2430
+ var EQUAL_POWER_GAIN = 0.707;
2431
+ function asCrossfadeMeta(val) {
2432
+ if (!val || typeof val !== "object") return null;
2433
+ const m = val;
2434
+ if (typeof m.groupId !== "string" || m.slot !== "origin" && m.slot !== "target") return null;
2435
+ if (typeof m.partnerDbId !== "string") return null;
2436
+ return {
2437
+ groupId: m.groupId,
2438
+ slot: m.slot,
2439
+ partnerDbId: m.partnerDbId,
2440
+ sourceTrackDbId: typeof m.sourceTrackDbId === "string" ? m.sourceTrackDbId : "",
2441
+ sourceSceneId: typeof m.sourceSceneId === "string" ? m.sourceSceneId : "",
2442
+ sourceName: typeof m.sourceName === "string" ? m.sourceName : "",
2443
+ soundLabel: typeof m.soundLabel === "string" ? m.soundLabel : "",
2444
+ sliderPos: typeof m.sliderPos === "number" ? m.sliderPos : 0.5
2445
+ };
2446
+ }
2447
+ function parseCrossfadePairs(sceneData) {
2448
+ const groups = /* @__PURE__ */ new Map();
2449
+ for (const [key, val] of Object.entries(sceneData)) {
2450
+ const match = /^track:(.+):crossfade$/.exec(key);
2451
+ if (!match) continue;
2452
+ const meta = asCrossfadeMeta(val);
2453
+ if (!meta) continue;
2454
+ const dbId = match[1];
2455
+ const g = groups.get(meta.groupId) ?? {};
2456
+ if (meta.slot === "origin") g.origin = { dbId, meta };
2457
+ else g.target = { dbId, meta };
2458
+ groups.set(meta.groupId, g);
805
2459
  }
806
- if (currentProgress < 95) {
807
- const remaining = 95 - currentProgress;
808
- const increment = remaining * (Math.random() * 0.2 + 0.1);
809
- return currentProgress + Math.max(increment, 0.1);
2460
+ const pairs = [];
2461
+ for (const [groupId, g] of groups) {
2462
+ if (!g.origin || !g.target) continue;
2463
+ pairs.push({
2464
+ groupId,
2465
+ sliderPos: g.origin.meta.sliderPos,
2466
+ originDbId: g.origin.dbId,
2467
+ targetDbId: g.target.dbId,
2468
+ originSourceName: g.origin.meta.sourceName,
2469
+ originSoundLabel: g.origin.meta.soundLabel,
2470
+ targetSourceName: g.target.meta.sourceName,
2471
+ targetSoundLabel: g.target.meta.soundLabel
2472
+ });
810
2473
  }
811
- return 95;
2474
+ return pairs;
812
2475
  }
813
- function calculateNextTickInterval(progress) {
814
- if (progress < 30) {
815
- return Math.random() * 200 + 150;
2476
+
2477
+ // src/crossfade-inpaint.ts
2478
+ var PITCH_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
2479
+ function round3(n) {
2480
+ return Math.round(n * 1e3) / 1e3;
2481
+ }
2482
+ function pitchName(p) {
2483
+ return `${PITCH_NAMES[(p % 12 + 12) % 12]}${Math.floor(p / 12) - 1}`;
2484
+ }
2485
+ function compactNote(n) {
2486
+ return { pitch: n.pitch, startBeat: round3(n.startBeat), durationBeats: round3(n.durationBeats), velocity: n.velocity };
2487
+ }
2488
+ function summarize(notes, percussive) {
2489
+ if (notes.length === 0) return "empty (no notes)";
2490
+ const span = round3(Math.max(...notes.map((n) => n.startBeat + n.durationBeats)));
2491
+ if (percussive) return `${notes.length} hits, spans ~${span} beats`;
2492
+ const pitches = notes.map((n) => n.pitch);
2493
+ return `${notes.length} notes, ${pitchName(Math.min(...pitches))}\u2013${pitchName(Math.max(...pitches))}, spans ~${span} beats`;
2494
+ }
2495
+ function gloss(notes, percussive) {
2496
+ const sorted = [...notes].sort((a, b) => a.startBeat - b.startBeat);
2497
+ const maxEnd = Math.max(...sorted.map((n) => n.startBeat + n.durationBeats));
2498
+ const bars = Math.max(1, Math.ceil(maxEnd / 4));
2499
+ const lines = [];
2500
+ for (let b = 0; b < bars; b++) {
2501
+ const inBar = sorted.filter((n) => n.startBeat >= b * 4 && n.startBeat < (b + 1) * 4);
2502
+ if (inBar.length === 0) continue;
2503
+ const body = percussive ? inBar.map((n) => `${round3(n.startBeat)}(v${n.velocity})`).join(" ") : inBar.map((n) => `${pitchName(n.pitch)}@${round3(n.startBeat)}`).join(" ");
2504
+ lines.push(` Bar ${b + 1}: ${body}`);
816
2505
  }
817
- if (progress < 70) {
818
- return Math.random() * 300 + 200;
2506
+ return lines.join("\n");
2507
+ }
2508
+ function patternBlock(label, name, key, notes, percussive) {
2509
+ const keyLabel = key ? ` in ${key}` : "";
2510
+ const header = `${label} \u2014 "${name}"${keyLabel} (${summarize(notes, percussive)}):`;
2511
+ if (notes.length === 0) return `${header}
2512
+ (no notes \u2014 treat this end as open)`;
2513
+ return `${header}
2514
+ ${gloss(notes, percussive)}
2515
+ exact JSON: ${JSON.stringify(notes.map(compactNote))}`;
2516
+ }
2517
+ function buildCrossfadeInpaintPrompt(input) {
2518
+ const { role, bars, originName, targetName, originKey, targetKey, originNotes, targetNotes } = input;
2519
+ const percussive = input.percussive ?? false;
2520
+ const part = role || (percussive ? "drum" : "melodic");
2521
+ const modulation = originKey && targetKey ? originKey === targetKey ? `stays in ${targetKey}` : `modulates from ${originKey} toward ${targetKey}` : "resolves toward the destination key";
2522
+ const lines = [
2523
+ `TASK \u2014 TRANSITION BRIDGE (musical inpainting).`,
2524
+ `Compose a ${part} part that MORPHS from the ORIGIN pattern into the TARGET pattern across the ${bars} bars`,
2525
+ `of this transition. The Key / BPM / chord progression are given above \u2014 it ${modulation}; honour that`,
2526
+ `frame, don't restate it. Each pattern below is shown as a pitch/rhythm gloss for musicality plus its exact`,
2527
+ `JSON; output your bridge in the same JSON note schema (per the system prompt).`,
2528
+ ``,
2529
+ patternBlock("ORIGIN pattern (where the bridge BEGINS)", originName, originKey, originNotes, percussive),
2530
+ ``,
2531
+ patternBlock("TARGET pattern (where the bridge must ARRIVE)", targetName, targetKey, targetNotes, percussive),
2532
+ ``,
2533
+ `Requirements:`,
2534
+ `- The FIRST bar feels continuous with the ORIGIN \u2014 borrow its register, rhythm, and contour so the seam`,
2535
+ ` from the previous scene is seamless.`,
2536
+ `- Across the middle bars, gradually transform toward the TARGET (shift register / rhythm / motifs step by step).`,
2537
+ `- The LAST bar lands on the TARGET's material and resolves onto the destination chord, so the seam into the`,
2538
+ ` next scene is seamless.`,
2539
+ `- Stay within the transition chord progression above; favour chord tones at the bar boundaries.`,
2540
+ `- This is inpainting between two FIXED endpoints \u2014 a listener should not be able to point to where the`,
2541
+ ` origin ends or the target begins.`
2542
+ ];
2543
+ if (originNotes.length === 0 || targetNotes.length === 0) {
2544
+ lines.push(
2545
+ ``,
2546
+ originNotes.length === 0 && targetNotes.length === 0 ? `(Both endpoints are empty \u2014 compose a short ${part} bridge from the chords alone.)` : originNotes.length === 0 ? `(The ORIGIN is empty \u2014 begin sparse and grow INTO the TARGET.)` : `(The TARGET is empty \u2014 begin from the ORIGIN and dissolve toward the destination chord.)`
2547
+ );
819
2548
  }
820
- return Math.random() * 600 + 400;
2549
+ return lines.join("\n");
821
2550
  }
822
- var TIME_BASED_TICK_MIN = 200;
823
- var TIME_BASED_TICK_RANGE = 100;
824
- function SorceryProgressBar({
825
- isLoading,
826
- statusText = "CONJURING...",
827
- completeText = "COMPLETE",
828
- onComplete,
829
- heightClass = "h-10",
830
- initialProgress = 0,
831
- onProgressChange,
832
- estimatedDurationMs
2551
+
2552
+ // src/components/ImportTrackModal.tsx
2553
+ import { useCallback as useCallback4, useEffect as useEffect6, useState as useState7 } from "react";
2554
+ import { jsx as jsx13, jsxs as jsxs9 } from "react/jsx-runtime";
2555
+ function ImportTrackModal({
2556
+ host,
2557
+ open,
2558
+ onClose,
2559
+ onImported,
2560
+ title = "Import track from scene (must match contract)",
2561
+ testIdPrefix = "import-track",
2562
+ mode = "track",
2563
+ onPick,
2564
+ onPortTrack
833
2565
  }) {
834
- const [progress, setProgress] = useState4(initialProgress);
835
- const timerRef = useRef3(null);
836
- const isLoadingRef = useRef3(false);
837
- const hasStartedRef = useRef3(false);
838
- const startTimeRef = useRef3(0);
839
- const onProgressChangeRef = useRef3(onProgressChange);
840
- const onCompleteRef = useRef3(onComplete);
841
- onProgressChangeRef.current = onProgressChange;
842
- onCompleteRef.current = onComplete;
843
- const initialProgressRef = useRef3(initialProgress);
844
- initialProgressRef.current = initialProgress;
845
- const estimatedDurationMsRef = useRef3(estimatedDurationMs);
846
- estimatedDurationMsRef.current = estimatedDurationMs;
847
- useEffect3(() => {
848
- const wasLoading = isLoadingRef.current;
849
- isLoadingRef.current = isLoading;
850
- if (isLoading && !wasLoading) {
851
- hasStartedRef.current = true;
852
- startTimeRef.current = Date.now();
853
- const startProgress = initialProgressRef.current > 0 ? initialProgressRef.current : 0;
854
- setProgress(startProgress);
855
- const duration = estimatedDurationMsRef.current;
856
- if (duration && duration > 0) {
857
- const tick = () => {
858
- setProgress((prev) => {
859
- const elapsed = Date.now() - startTimeRef.current;
860
- const target = calculateTimeBasedTarget(elapsed, duration);
861
- const jitter = (Math.random() - 0.5) * 1;
862
- const next = Math.min(Math.max(target + jitter, prev + 0.05), 95);
863
- onProgressChangeRef.current?.(next);
864
- timerRef.current = setTimeout(tick, TIME_BASED_TICK_MIN + Math.random() * TIME_BASED_TICK_RANGE);
865
- return next;
866
- });
867
- };
868
- timerRef.current = setTimeout(tick, TIME_BASED_TICK_MIN);
869
- } else {
870
- const tick = () => {
871
- setProgress((prev) => {
872
- if (prev >= 95) {
873
- timerRef.current = setTimeout(tick, 1e3);
874
- return 95;
875
- }
876
- const next = Math.min(calculateNextProgress(prev), 95);
877
- onProgressChangeRef.current?.(next);
878
- const interval = calculateNextTickInterval(next);
879
- timerRef.current = setTimeout(tick, interval);
880
- return next;
881
- });
882
- };
883
- const firstInterval = calculateNextTickInterval(startProgress);
884
- timerRef.current = setTimeout(tick, firstInterval);
2566
+ const [load, setLoad] = useState7({ status: "loading" });
2567
+ const [selectedSceneId, setSelectedSceneId] = useState7(null);
2568
+ const [importingTrackId, setImportingTrackId] = useState7(null);
2569
+ const refresh = useCallback4(async () => {
2570
+ if (!host.listImportableTracks) {
2571
+ setLoad({ status: "error", message: "This host does not support importing tracks." });
2572
+ return;
2573
+ }
2574
+ setLoad({ status: "loading" });
2575
+ try {
2576
+ const wantsPort = mode === "track" && !!onPortTrack;
2577
+ const scenes2 = await host.listImportableTracks(wantsPort ? { includeSameScene: true } : void 0);
2578
+ setLoad({ status: "ready", scenes: scenes2 });
2579
+ const sameScene = scenes2.find((s) => s.sameScene);
2580
+ if (sameScene) setSelectedSceneId(sameScene.sceneId);
2581
+ } catch (err) {
2582
+ setLoad({ status: "error", message: err instanceof Error ? err.message : "Failed to load scenes." });
2583
+ }
2584
+ }, [host, mode, onPortTrack]);
2585
+ useEffect6(() => {
2586
+ if (open) {
2587
+ setSelectedSceneId(null);
2588
+ setImportingTrackId(null);
2589
+ void refresh();
2590
+ }
2591
+ }, [open, refresh]);
2592
+ const handleImport = useCallback4(
2593
+ async (track, sourceSceneId, sceneName, isSameScene) => {
2594
+ if (isSameScene && onPortTrack) {
2595
+ if (!track.importable) return;
2596
+ setImportingTrackId(track.trackId);
2597
+ try {
2598
+ await onPortTrack({ sourceTrackDbId: track.dbId, trackName: track.name, role: track.role });
2599
+ onClose();
2600
+ } catch (err) {
2601
+ host.showToast?.("error", err instanceof Error ? err.message : "Import failed");
2602
+ setImportingTrackId(null);
2603
+ }
2604
+ return;
885
2605
  }
886
- } else if (!isLoading && wasLoading && hasStartedRef.current) {
887
- if (timerRef.current) {
888
- clearTimeout(timerRef.current);
889
- timerRef.current = null;
2606
+ if (mode === "sound") {
2607
+ setImportingTrackId(track.trackId);
2608
+ try {
2609
+ await onPick?.({ sourceTrackDbId: track.dbId, trackName: track.name, sceneName });
2610
+ onClose();
2611
+ } catch (err) {
2612
+ host.showToast?.("error", err instanceof Error ? err.message : "Import failed");
2613
+ setImportingTrackId(null);
2614
+ }
2615
+ return;
890
2616
  }
891
- setProgress(100);
892
- onProgressChangeRef.current?.(100);
893
- onCompleteRef.current?.();
894
- hasStartedRef.current = false;
895
- }
896
- return () => {
897
- if (timerRef.current) {
898
- clearTimeout(timerRef.current);
899
- timerRef.current = null;
2617
+ if (!track.importable || !host.importTrack) return;
2618
+ setImportingTrackId(track.trackId);
2619
+ try {
2620
+ const handle = await host.importTrack({ sourceSceneId, sourceTrackId: track.trackId });
2621
+ onImported(handle);
2622
+ onClose();
2623
+ } catch (err) {
2624
+ host.showToast?.("error", err instanceof Error ? err.message : "Import failed");
2625
+ setImportingTrackId(null);
900
2626
  }
901
- };
902
- }, [isLoading]);
903
- if (!isLoading && progress === 0) {
904
- return null;
905
- }
906
- const displayProgress = Math.floor(progress);
907
- const isComplete = !isLoading && progress === 100;
908
- const transitionDuration = progress < 50 ? "300ms" : progress < 80 ? "500ms" : "700ms";
909
- return /* @__PURE__ */ jsxs3(
2627
+ },
2628
+ [host, onImported, onClose, mode, onPick, onPortTrack]
2629
+ );
2630
+ if (!open) return null;
2631
+ const scenes = load.status === "ready" ? load.scenes : [];
2632
+ const selectedScene = scenes.find((s) => s.sceneId === selectedSceneId) ?? null;
2633
+ return /* @__PURE__ */ jsx13(Modal, { open, onClose, testIdPrefix, children: /* @__PURE__ */ jsxs9(
910
2634
  "div",
911
2635
  {
912
- className: `relative w-full ${heightClass} bg-sas-panel-alt border border-sas-border rounded-sm overflow-hidden shadow-inner`,
2636
+ className: "w-[420px] max-h-[70vh] overflow-hidden flex flex-col rounded-md border border-sas-border bg-sas-panel shadow-xl",
2637
+ onClick: (e) => e.stopPropagation(),
2638
+ "data-testid": `${testIdPrefix}-modal`,
913
2639
  children: [
914
- /* @__PURE__ */ jsx5(
915
- "div",
916
- {
917
- className: `
918
- h-full
919
- bg-gradient-to-r from-sas-accent/70 to-sas-accent
920
- shadow-glow-soft
921
- sorcery-progress-fill
922
- animate-progress-stripes
923
- ${progress > 70 ? "animate-progress-pulse" : ""}
924
- transition-all ease-out
925
- `,
926
- style: {
927
- width: `${progress}%`,
928
- transitionDuration
2640
+ /* @__PURE__ */ jsxs9("div", { className: "flex items-center justify-between px-3 py-2 border-b border-sas-border", children: [
2641
+ /* @__PURE__ */ jsxs9("div", { className: "flex items-center gap-2", children: [
2642
+ selectedScene && /* @__PURE__ */ jsx13(
2643
+ "button",
2644
+ {
2645
+ className: "text-sas-muted hover:text-sas-accent text-xs",
2646
+ onClick: () => setSelectedSceneId(null),
2647
+ "data-testid": `${testIdPrefix}-back`,
2648
+ children: "\u2190"
2649
+ }
2650
+ ),
2651
+ /* @__PURE__ */ jsx13("span", { className: "text-sm font-medium text-sas-text", children: selectedScene ? selectedScene.sceneName : title })
2652
+ ] }),
2653
+ /* @__PURE__ */ jsx13(
2654
+ "button",
2655
+ {
2656
+ className: "text-sas-muted hover:text-sas-accent text-sm",
2657
+ onClick: onClose,
2658
+ "data-testid": `${testIdPrefix}-close`,
2659
+ children: "\u2715"
929
2660
  }
930
- }
931
- ),
932
- /* @__PURE__ */ jsx5("div", { className: "absolute inset-0 flex items-center justify-center", children: isLoading && progress < 100 ? /* @__PURE__ */ jsxs3("span", { className: "font-mono text-xs text-sas-accent font-bold drop-shadow-md tracking-wider", children: [
933
- statusText,
2661
+ )
2662
+ ] }),
2663
+ /* @__PURE__ */ jsxs9("div", { className: "overflow-y-auto p-2 flex-1", children: [
2664
+ load.status === "loading" && /* @__PURE__ */ jsx13("div", { className: "py-8 text-center text-xs text-sas-muted", "data-testid": `${testIdPrefix}-loading`, children: "Loading scenes\u2026" }),
2665
+ load.status === "error" && /* @__PURE__ */ jsx13("div", { className: "py-8 text-center text-xs text-red-400", "data-testid": `${testIdPrefix}-error`, children: load.message }),
2666
+ load.status === "ready" && scenes.length === 0 && /* @__PURE__ */ jsx13("div", { className: "py-8 text-center text-xs text-sas-muted", "data-testid": `${testIdPrefix}-empty`, children: mode === "sound" ? "No other scenes have a sound to import." : "No other scenes have a compatible track to import." }),
2667
+ load.status === "ready" && scenes.length > 0 && !selectedScene && /* @__PURE__ */ jsx13("ul", { className: "flex flex-col gap-1", "data-testid": `${testIdPrefix}-scene-list`, children: scenes.map((scene) => /* @__PURE__ */ jsx13("li", { children: /* @__PURE__ */ jsxs9(
2668
+ "button",
2669
+ {
2670
+ className: "w-full flex items-center justify-between px-2 py-1.5 rounded-sm border border-sas-border bg-sas-panel-alt text-left text-xs text-sas-text hover:border-sas-accent hover:text-sas-accent transition-colors",
2671
+ onClick: () => setSelectedSceneId(scene.sceneId),
2672
+ "data-testid": `${testIdPrefix}-scene`,
2673
+ children: [
2674
+ /* @__PURE__ */ jsx13("span", { className: "truncate", children: scene.sceneName }),
2675
+ /* @__PURE__ */ jsxs9("span", { className: "text-sas-muted", children: [
2676
+ scene.tracks.length,
2677
+ " \u2192"
2678
+ ] })
2679
+ ]
2680
+ }
2681
+ ) }, scene.sceneId)) }),
2682
+ selectedScene && /* @__PURE__ */ jsx13("ul", { className: "flex flex-col gap-1", "data-testid": `${testIdPrefix}-track-list`, children: selectedScene.tracks.map((track) => {
2683
+ const busy = importingTrackId === track.trackId;
2684
+ const gated = mode === "track" && !track.importable;
2685
+ const disabled = gated || busy;
2686
+ return /* @__PURE__ */ jsx13("li", { children: /* @__PURE__ */ jsxs9(
2687
+ "button",
2688
+ {
2689
+ className: `w-full flex items-center justify-between px-2 py-1.5 rounded-sm border text-left text-xs transition-colors ${disabled ? "bg-sas-panel border-sas-border text-sas-muted/50 cursor-not-allowed" : "bg-sas-panel-alt border-sas-border text-sas-text hover:border-sas-accent hover:text-sas-accent"}`,
2690
+ disabled,
2691
+ title: gated ? track.disabledReason : void 0,
2692
+ onClick: () => void handleImport(track, selectedScene.sceneId, selectedScene.sceneName, !!selectedScene.sameScene),
2693
+ "data-testid": `${testIdPrefix}-track`,
2694
+ "data-importable": mode === "sound" || track.importable ? "true" : "false",
2695
+ children: [
2696
+ /* @__PURE__ */ jsxs9("span", { className: "truncate", children: [
2697
+ track.name,
2698
+ track.role ? /* @__PURE__ */ jsxs9("span", { className: "text-sas-muted", children: [
2699
+ " \xB7 ",
2700
+ track.role
2701
+ ] }) : null
2702
+ ] }),
2703
+ busy ? /* @__PURE__ */ jsx13("span", { className: "text-sas-muted", children: "\u2026" }) : gated ? /* @__PURE__ */ jsx13("span", { className: "text-sas-muted", children: "\u2298" }) : null
2704
+ ]
2705
+ }
2706
+ ) }, track.dbId);
2707
+ }) })
2708
+ ] })
2709
+ ]
2710
+ }
2711
+ ) });
2712
+ }
2713
+
2714
+ // src/components/CrossfadeModal.tsx
2715
+ import { useCallback as useCallback5, useEffect as useEffect7, useMemo as useMemo3, useRef as useRef7, useState as useState8 } from "react";
2716
+ import { Fragment as Fragment3, jsx as jsx14, jsxs as jsxs10 } from "react/jsx-runtime";
2717
+ function CrossfadeModal({
2718
+ host,
2719
+ open,
2720
+ fromSceneId,
2721
+ toSceneId,
2722
+ fromSceneName,
2723
+ toSceneName,
2724
+ onClose,
2725
+ onCreate,
2726
+ testIdPrefix = "crossfade-modal"
2727
+ }) {
2728
+ const [load, setLoad] = useState8({ status: "loading" });
2729
+ const [originDbId, setOriginDbId] = useState8("");
2730
+ const [targetDbId, setTargetDbId] = useState8("");
2731
+ const [isCreating, setIsCreating] = useState8(false);
2732
+ const [error, setError] = useState8(null);
2733
+ const cancelRef = useRef7(null);
2734
+ const refresh = useCallback5(async () => {
2735
+ if (!host.listSceneFamilyTracks) {
2736
+ setLoad({ status: "error", message: "This host does not support crossfade tracks." });
2737
+ return;
2738
+ }
2739
+ setLoad({ status: "loading" });
2740
+ try {
2741
+ const [origin, target] = await Promise.all([
2742
+ host.listSceneFamilyTracks(fromSceneId),
2743
+ host.listSceneFamilyTracks(toSceneId)
2744
+ ]);
2745
+ setLoad({ status: "ready", origin, target });
2746
+ setOriginDbId(origin[0]?.dbId ?? "");
2747
+ } catch (err) {
2748
+ setLoad({ status: "error", message: err instanceof Error ? err.message : "Failed to load tracks." });
2749
+ }
2750
+ }, [host, fromSceneId, toSceneId]);
2751
+ useEffect7(() => {
2752
+ if (open) {
2753
+ setError(null);
2754
+ setIsCreating(false);
2755
+ setOriginDbId("");
2756
+ setTargetDbId("");
2757
+ void refresh();
2758
+ }
2759
+ }, [open, refresh]);
2760
+ const originTrack = useMemo3(
2761
+ () => load.status === "ready" ? load.origin.find((t) => t.dbId === originDbId) ?? null : null,
2762
+ [load, originDbId]
2763
+ );
2764
+ const originRole = originTrack?.role;
2765
+ const targetCandidates = useMemo3(() => {
2766
+ if (load.status !== "ready") return [];
2767
+ if (!originRole) return load.target;
2768
+ return load.target.filter((t) => t.role === originRole);
2769
+ }, [load, originRole]);
2770
+ useEffect7(() => {
2771
+ if (!targetCandidates.some((t) => t.dbId === targetDbId)) {
2772
+ setTargetDbId(targetCandidates[0]?.dbId ?? "");
2773
+ }
2774
+ }, [targetCandidates, targetDbId]);
2775
+ const targetTrack = targetCandidates.find((t) => t.dbId === targetDbId) ?? null;
2776
+ const canCreate = !isCreating && !!originTrack && !!targetTrack;
2777
+ const handleClose = useCallback5(() => {
2778
+ if (!isCreating) onClose();
2779
+ }, [isCreating, onClose]);
2780
+ const handleCreate = useCallback5(async () => {
2781
+ if (!originTrack || !targetTrack) return;
2782
+ setIsCreating(true);
2783
+ setError(null);
2784
+ try {
2785
+ await onCreate(
2786
+ { dbId: originTrack.dbId, name: originTrack.name, role: originTrack.role },
2787
+ { dbId: targetTrack.dbId, name: targetTrack.name, role: targetTrack.role }
2788
+ );
2789
+ onClose();
2790
+ } catch (err) {
2791
+ setError(err instanceof Error ? err.message : "Failed to create crossfade.");
2792
+ setIsCreating(false);
2793
+ }
2794
+ }, [originTrack, targetTrack, onCreate, onClose]);
2795
+ if (!open) return null;
2796
+ return /* @__PURE__ */ jsx14(Modal, { open, onClose: handleClose, testIdPrefix, initialFocusRef: cancelRef, children: /* @__PURE__ */ jsxs10(
2797
+ "div",
2798
+ {
2799
+ className: "bg-sas-panel border border-sas-border rounded-md shadow-xl w-[420px] max-w-[92vw] p-4 space-y-3",
2800
+ onClick: (e) => e.stopPropagation(),
2801
+ "data-testid": `${testIdPrefix}-box`,
2802
+ children: [
2803
+ /* @__PURE__ */ jsx14("h3", { className: "text-sm font-bold text-sas-text", children: "Add crossfade" }),
2804
+ /* @__PURE__ */ jsxs10("p", { className: "text-[11px] text-sas-muted leading-relaxed", children: [
2805
+ "Bridge a track from",
934
2806
  " ",
935
- displayProgress,
936
- "%"
937
- ] }) : isComplete ? /* @__PURE__ */ jsx5("span", { className: "font-mono text-xs text-sas-text font-bold drop-shadow-md tracking-wider", children: completeText }) : null }),
938
- /* @__PURE__ */ jsx5(
2807
+ /* @__PURE__ */ jsx14("span", { className: "text-sas-text", children: fromSceneName ?? "the origin scene" }),
2808
+ " into one from",
2809
+ " ",
2810
+ /* @__PURE__ */ jsx14("span", { className: "text-sas-text", children: toSceneName ?? "the target scene" }),
2811
+ ". Both layers share one generated part; each keeps its own preset."
2812
+ ] }),
2813
+ load.status === "loading" && /* @__PURE__ */ jsx14("div", { className: "text-xs text-sas-muted py-4 text-center", children: "Loading tracks\u2026" }),
2814
+ load.status === "error" && /* @__PURE__ */ jsx14("div", { className: "text-xs text-sas-danger py-4 text-center", children: load.message }),
2815
+ load.status === "ready" && (load.origin.length === 0 ? /* @__PURE__ */ jsx14(
939
2816
  "div",
940
2817
  {
941
- className: "absolute inset-0 pointer-events-none opacity-10",
942
- style: {
943
- backgroundImage: `repeating-linear-gradient(
944
- to bottom,
945
- transparent,
946
- transparent 2px,
947
- rgba(0, 0, 0, 0.3) 2px,
948
- rgba(0, 0, 0, 0.3) 4px
949
- )`
2818
+ className: "text-xs text-sas-muted py-4 text-center",
2819
+ "data-testid": `${testIdPrefix}-empty-origin`,
2820
+ children: "No matching tracks in the origin scene. Add one there first."
2821
+ }
2822
+ ) : /* @__PURE__ */ jsxs10(Fragment3, { children: [
2823
+ /* @__PURE__ */ jsxs10("label", { className: "block", children: [
2824
+ /* @__PURE__ */ jsx14("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: "Origin (top)" }),
2825
+ /* @__PURE__ */ jsx14(
2826
+ "select",
2827
+ {
2828
+ "data-testid": `${testIdPrefix}-origin-select`,
2829
+ value: originDbId,
2830
+ onChange: (e) => setOriginDbId(e.target.value),
2831
+ disabled: isCreating,
2832
+ className: "sas-input w-full mt-0.5 text-xs",
2833
+ children: load.origin.map((t) => /* @__PURE__ */ jsxs10("option", { value: t.dbId, children: [
2834
+ t.name,
2835
+ t.role ? ` \xB7 ${t.role}` : ""
2836
+ ] }, t.dbId))
2837
+ }
2838
+ )
2839
+ ] }),
2840
+ /* @__PURE__ */ jsxs10("label", { className: "block", children: [
2841
+ /* @__PURE__ */ jsxs10("span", { className: "text-[10px] uppercase tracking-wide text-sas-muted", children: [
2842
+ "Target (bottom)",
2843
+ originRole ? ` \xB7 ${originRole}` : ""
2844
+ ] }),
2845
+ targetCandidates.length === 0 ? /* @__PURE__ */ jsxs10("div", { className: "text-xs text-sas-danger mt-0.5", "data-testid": `${testIdPrefix}-empty-target`, children: [
2846
+ "No ",
2847
+ originRole ?? "matching",
2848
+ " track in the target scene to crossfade into."
2849
+ ] }) : /* @__PURE__ */ jsx14(
2850
+ "select",
2851
+ {
2852
+ "data-testid": `${testIdPrefix}-target-select`,
2853
+ value: targetDbId,
2854
+ onChange: (e) => setTargetDbId(e.target.value),
2855
+ disabled: isCreating,
2856
+ className: "sas-input w-full mt-0.5 text-xs",
2857
+ children: targetCandidates.map((t) => /* @__PURE__ */ jsxs10("option", { value: t.dbId, children: [
2858
+ t.name,
2859
+ t.role ? ` \xB7 ${t.role}` : ""
2860
+ ] }, t.dbId))
2861
+ }
2862
+ )
2863
+ ] })
2864
+ ] })),
2865
+ error && /* @__PURE__ */ jsx14("div", { className: "text-xs text-sas-danger", "data-testid": `${testIdPrefix}-error`, children: error }),
2866
+ /* @__PURE__ */ jsxs10("div", { className: "flex justify-end gap-2 pt-1", children: [
2867
+ /* @__PURE__ */ jsx14(
2868
+ "button",
2869
+ {
2870
+ ref: cancelRef,
2871
+ "data-testid": `${testIdPrefix}-cancel`,
2872
+ onClick: onClose,
2873
+ disabled: isCreating,
2874
+ className: "px-3 py-1 text-xs rounded-sm border border-sas-border text-sas-muted hover:text-sas-text disabled:opacity-50",
2875
+ children: "Cancel"
2876
+ }
2877
+ ),
2878
+ /* @__PURE__ */ jsx14(
2879
+ "button",
2880
+ {
2881
+ "data-testid": `${testIdPrefix}-confirm`,
2882
+ onClick: handleCreate,
2883
+ disabled: !canCreate,
2884
+ className: `px-3 py-1 text-xs rounded-sm border transition-colors ${canCreate ? "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"}`,
2885
+ children: isCreating ? "Generating bridge\u2026" : "Create crossfade"
950
2886
  }
2887
+ )
2888
+ ] })
2889
+ ]
2890
+ }
2891
+ ) });
2892
+ }
2893
+
2894
+ // src/components/DownloadPackButton.tsx
2895
+ import { useCallback as useCallback6, useEffect as useEffect8, useState as useState9 } from "react";
2896
+ import { jsx as jsx15, jsxs as jsxs11 } from "react/jsx-runtime";
2897
+ function formatSize(bytes) {
2898
+ if (!bytes || bytes <= 0) return "";
2899
+ const gb = bytes / 1024 ** 3;
2900
+ if (gb >= 1) return `${gb.toFixed(1)} GB`;
2901
+ const mb = bytes / 1024 ** 2;
2902
+ return `${Math.round(mb)} MB`;
2903
+ }
2904
+ var DownloadPackButton = ({
2905
+ host,
2906
+ packId,
2907
+ displayName,
2908
+ sizeBytes,
2909
+ variant = "compact",
2910
+ onDownloadComplete
2911
+ }) => {
2912
+ const [status, setStatus] = useState9("idle");
2913
+ const [progress, setProgress] = useState9(0);
2914
+ const [errorMessage, setErrorMessage] = useState9(null);
2915
+ useEffect8(() => {
2916
+ const unsub = host.onSamplePackProgress(packId, (p) => {
2917
+ setStatus(p.status);
2918
+ setProgress(p.progress);
2919
+ if (p.status === "error") {
2920
+ setErrorMessage(p.message || "Download failed");
2921
+ } else if (p.status === "complete") {
2922
+ setErrorMessage(null);
2923
+ setTimeout(() => onDownloadComplete?.(), 250);
2924
+ } else {
2925
+ setErrorMessage(null);
2926
+ }
2927
+ });
2928
+ return unsub;
2929
+ }, [host, packId, onDownloadComplete]);
2930
+ const handleClick = useCallback6(async () => {
2931
+ if (status !== "idle" && status !== "error") return;
2932
+ try {
2933
+ setStatus("downloading");
2934
+ setProgress(0);
2935
+ setErrorMessage(null);
2936
+ const result = await host.startSamplePackDownload(packId);
2937
+ if (!result.success) {
2938
+ setStatus("error");
2939
+ setErrorMessage(result.error || "Download failed");
2940
+ }
2941
+ } catch (err) {
2942
+ console.error("[DownloadPackButton] start failed:", err);
2943
+ setStatus("error");
2944
+ setErrorMessage(err instanceof Error ? err.message : String(err));
2945
+ }
2946
+ }, [host, packId, status]);
2947
+ const isWorking = status === "downloading" || status === "verifying" || status === "extracting" || status === "installing";
2948
+ const isDisabled = isWorking || status === "complete";
2949
+ const buttonLabel = (() => {
2950
+ switch (status) {
2951
+ case "downloading":
2952
+ return `${progress}%`;
2953
+ case "verifying":
2954
+ return "Verifying...";
2955
+ case "extracting":
2956
+ return "Extracting...";
2957
+ case "installing":
2958
+ return "Installing...";
2959
+ case "complete":
2960
+ return "Done!";
2961
+ case "error":
2962
+ return "Retry";
2963
+ default:
2964
+ return variant === "large" ? `Download ${displayName}${sizeBytes ? ` (${formatSize(sizeBytes)})` : ""}` : "Download";
2965
+ }
2966
+ })();
2967
+ const tooltip = (() => {
2968
+ if (status === "error") return errorMessage || "Download failed. Click to retry.";
2969
+ if (isWorking) return `${buttonLabel} \u2014 ${displayName}`;
2970
+ if (status === "complete") return "Installation complete";
2971
+ return `Download ${displayName}${sizeBytes ? ` (${formatSize(sizeBytes)})` : ""}`;
2972
+ })();
2973
+ 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";
2974
+ let className;
2975
+ if (status === "error") {
2976
+ className = `${baseClasses} text-red-400 border-red-400/50 hover:text-red-300 hover:border-red-300`;
2977
+ } else if (status === "complete") {
2978
+ className = `${baseClasses} text-green-400 border-green-400/50`;
2979
+ } else if (isDisabled) {
2980
+ className = `${baseClasses} text-sas-accent border-sas-accent/50 cursor-wait`;
2981
+ } else {
2982
+ className = `${baseClasses} text-sas-muted hover:text-sas-accent border-sas-border hover:border-sas-accent`;
2983
+ }
2984
+ return /* @__PURE__ */ jsxs11("div", { children: [
2985
+ /* @__PURE__ */ jsx15(
2986
+ "button",
2987
+ {
2988
+ "data-testid": `download-pack-button-${packId}`,
2989
+ onClick: handleClick,
2990
+ disabled: isDisabled,
2991
+ className,
2992
+ title: tooltip,
2993
+ children: buttonLabel
2994
+ }
2995
+ ),
2996
+ variant === "large" && status === "error" && errorMessage && /* @__PURE__ */ jsx15("div", { className: "text-xs text-sas-danger mt-2", "data-testid": `download-pack-error-${packId}`, children: errorMessage })
2997
+ ] });
2998
+ };
2999
+
3000
+ // src/components/SamplePackCTACard.tsx
3001
+ import { jsx as jsx16, jsxs as jsxs12 } from "react/jsx-runtime";
3002
+ var SamplePackCTACard = ({
3003
+ host,
3004
+ pack,
3005
+ status,
3006
+ onDownloadComplete
3007
+ }) => {
3008
+ if (status === "checking") {
3009
+ return /* @__PURE__ */ jsx16(
3010
+ "div",
3011
+ {
3012
+ "data-testid": `sample-pack-cta-checking-${pack.packId}`,
3013
+ className: "flex items-center justify-center py-16 text-sas-muted text-sm",
3014
+ children: "Checking sample library..."
3015
+ }
3016
+ );
3017
+ }
3018
+ const headline = status === "stale" ? `${pack.displayName} update available` : `${pack.displayName} not installed`;
3019
+ const sublabel = status === "stale" ? `A newer version is available for download.` : pack.description;
3020
+ return /* @__PURE__ */ jsxs12(
3021
+ "div",
3022
+ {
3023
+ "data-testid": `sample-pack-cta-${pack.packId}`,
3024
+ className: "flex flex-col items-center justify-center py-12 px-6 text-center",
3025
+ children: [
3026
+ /* @__PURE__ */ jsx16("div", { className: "text-sm uppercase tracking-wide text-sas-muted mb-2", children: status === "stale" ? "Update available" : "Sample library not installed" }),
3027
+ /* @__PURE__ */ jsx16("div", { className: "text-base text-sas-text mb-1", children: headline }),
3028
+ /* @__PURE__ */ jsx16("div", { className: "text-xs text-sas-muted mb-6 max-w-md", children: sublabel }),
3029
+ /* @__PURE__ */ jsx16(
3030
+ DownloadPackButton,
3031
+ {
3032
+ host,
3033
+ packId: pack.packId,
3034
+ displayName: pack.displayName,
3035
+ sizeBytes: pack.sizeBytes,
3036
+ variant: "large",
3037
+ onDownloadComplete
951
3038
  }
952
3039
  )
953
3040
  ]
954
3041
  }
955
3042
  );
3043
+ };
3044
+
3045
+ // src/components/WaveformView.tsx
3046
+ import { useEffect as useEffect9, useRef as useRef8, useState as useState10 } from "react";
3047
+
3048
+ // src/components/waveform.ts
3049
+ function computePeaks(audioBuffer, bins, targetSamples) {
3050
+ const { length, numberOfChannels, sampleRate } = audioBuffer;
3051
+ const channels = [];
3052
+ for (let c = 0; c < numberOfChannels; c++) {
3053
+ channels.push(audioBuffer.getChannelData(c));
3054
+ }
3055
+ const totalForBinning = typeof targetSamples === "number" && targetSamples > length ? targetSamples : length;
3056
+ const samplesPerBin = Math.max(1, Math.floor(totalForBinning / bins));
3057
+ const out = new Float32Array(bins * 2);
3058
+ for (let i = 0; i < bins; i++) {
3059
+ const startIdx = i * samplesPerBin;
3060
+ const endIdx = Math.min(length, startIdx + samplesPerBin);
3061
+ if (startIdx >= length) {
3062
+ out[i * 2] = 0;
3063
+ out[i * 2 + 1] = 0;
3064
+ continue;
3065
+ }
3066
+ let mn = Infinity;
3067
+ let mx = -Infinity;
3068
+ for (let j = startIdx; j < endIdx; j++) {
3069
+ let v = 0;
3070
+ for (let c = 0; c < numberOfChannels; c++) {
3071
+ v += channels[c][j];
3072
+ }
3073
+ v /= numberOfChannels;
3074
+ if (v < mn) mn = v;
3075
+ if (v > mx) mx = v;
3076
+ }
3077
+ if (!Number.isFinite(mn)) mn = 0;
3078
+ if (!Number.isFinite(mx)) mx = 0;
3079
+ out[i * 2] = mn;
3080
+ out[i * 2 + 1] = mx;
3081
+ }
3082
+ return { sampleRate, totalSamples: totalForBinning, peaks: out };
3083
+ }
3084
+ function drawWaveform(canvas, peaks, options = {}) {
3085
+ const dpr = window.devicePixelRatio || 1;
3086
+ const cssWidth = canvas.clientWidth;
3087
+ const cssHeight = canvas.clientHeight;
3088
+ if (cssWidth === 0 || cssHeight === 0) return;
3089
+ canvas.width = Math.floor(cssWidth * dpr);
3090
+ canvas.height = Math.floor(cssHeight * dpr);
3091
+ const ctx = canvas.getContext("2d");
3092
+ if (!ctx) return;
3093
+ ctx.scale(dpr, dpr);
3094
+ ctx.clearRect(0, 0, cssWidth, cssHeight);
3095
+ ctx.fillStyle = options.fillStyle ?? "rgba(255, 255, 255, 0.4)";
3096
+ const bins = peaks.peaks.length / 2;
3097
+ const mid = cssHeight / 2;
3098
+ for (let x = 0; x < cssWidth; x++) {
3099
+ const binIdx = Math.floor(x / cssWidth * bins);
3100
+ const mn = peaks.peaks[binIdx * 2];
3101
+ const mx = peaks.peaks[binIdx * 2 + 1];
3102
+ const yTop = mid - mx * mid;
3103
+ const yBot = mid - mn * mid;
3104
+ ctx.fillRect(x, yTop, 1, Math.max(1, yBot - yTop));
3105
+ }
956
3106
  }
957
3107
 
958
- // src/components/TrackRow.tsx
959
- import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
960
- function TrackRow({
961
- track,
962
- prompt,
963
- runtimeState,
964
- fxDetailState,
965
- fxDrawerOpen,
966
- isGenerating = false,
967
- isAuthenticated = false,
968
- error,
969
- hasMidi = false,
970
- generationProgress = 0,
971
- estimatedGenerationMs = 15e3,
972
- onPromptChange,
973
- onGenerate,
974
- onShuffle,
975
- onCopy,
976
- onDelete,
977
- contentSlot,
978
- onMuteToggle,
979
- onSoloToggle,
980
- onVolumeChange,
981
- onPanChange,
982
- onFxToggle,
983
- onFxPresetChange,
984
- onFxDryWetChange,
985
- onToggleFxDrawer,
986
- onProgressChange,
987
- accentColor = "#A78BFA",
988
- instrumentName,
989
- instrumentMissing,
990
- instrumentDrawerOpen,
991
- onToggleInstrumentDrawer,
992
- availableInstruments,
993
- currentInstrumentPluginId,
994
- onInstrumentSelect,
995
- instrumentsLoading,
996
- onRefreshInstruments,
997
- instrumentDrawerStage,
998
- onShowEditor,
999
- onBackToInstruments
3108
+ // src/components/WaveformView.tsx
3109
+ import { jsx as jsx17 } from "react/jsx-runtime";
3110
+ var WaveformView = ({
3111
+ host,
3112
+ filePath,
3113
+ bins = 256,
3114
+ className,
3115
+ fillStyle,
3116
+ targetSamples
3117
+ }) => {
3118
+ const canvasRef = useRef8(null);
3119
+ const [peaks, setPeaks] = useState10(null);
3120
+ useEffect9(() => {
3121
+ let cancelled = false;
3122
+ let audioContext = null;
3123
+ (async () => {
3124
+ try {
3125
+ const bytes = await host.getAudioFileBytes(filePath);
3126
+ if (cancelled) return;
3127
+ const ContextCtor = window.AudioContext ?? window.webkitAudioContext;
3128
+ audioContext = new ContextCtor();
3129
+ const audioBuffer = await audioContext.decodeAudioData(bytes.slice(0));
3130
+ if (cancelled) return;
3131
+ const computed = computePeaks(audioBuffer, bins, targetSamples);
3132
+ setPeaks(computed);
3133
+ } catch (err) {
3134
+ console.warn("[WaveformView] failed to decode", filePath, err);
3135
+ } finally {
3136
+ if (audioContext) {
3137
+ audioContext.close().catch(() => {
3138
+ });
3139
+ }
3140
+ }
3141
+ })();
3142
+ return () => {
3143
+ cancelled = true;
3144
+ };
3145
+ }, [host, filePath, bins, targetSamples]);
3146
+ useEffect9(() => {
3147
+ if (!peaks) return;
3148
+ const canvas = canvasRef.current;
3149
+ if (!canvas) return;
3150
+ drawWaveform(canvas, peaks, fillStyle ? { fillStyle } : void 0);
3151
+ const observer = new ResizeObserver(() => {
3152
+ drawWaveform(canvas, peaks, fillStyle ? { fillStyle } : void 0);
3153
+ });
3154
+ observer.observe(canvas);
3155
+ return () => observer.disconnect();
3156
+ }, [peaks, fillStyle]);
3157
+ return /* @__PURE__ */ jsx17(
3158
+ "canvas",
3159
+ {
3160
+ ref: canvasRef,
3161
+ "data-testid": "waveform-view",
3162
+ className: className ?? "w-full h-10"
3163
+ }
3164
+ );
3165
+ };
3166
+
3167
+ // src/components/ScrollingWaveform.tsx
3168
+ import { useEffect as useEffect10, useRef as useRef9 } from "react";
3169
+ import { jsx as jsx18 } from "react/jsx-runtime";
3170
+ var ScrollingWaveform = ({
3171
+ getPeakDb,
3172
+ active,
3173
+ columns = 256,
3174
+ className,
3175
+ fillStyle
3176
+ }) => {
3177
+ const canvasRef = useRef9(null);
3178
+ const ringRef = useRef9(new Float32Array(columns));
3179
+ const writeIdxRef = useRef9(0);
3180
+ const rafRef = useRef9(null);
3181
+ useEffect10(() => {
3182
+ if (ringRef.current.length !== columns) {
3183
+ const next = new Float32Array(columns);
3184
+ const prev = ringRef.current;
3185
+ const copyLen = Math.min(prev.length, columns);
3186
+ for (let i = 0; i < copyLen; i++) {
3187
+ next[i] = prev[i];
3188
+ }
3189
+ ringRef.current = next;
3190
+ writeIdxRef.current = writeIdxRef.current % columns;
3191
+ }
3192
+ }, [columns]);
3193
+ useEffect10(() => {
3194
+ if (!active) {
3195
+ if (rafRef.current !== null) {
3196
+ cancelAnimationFrame(rafRef.current);
3197
+ rafRef.current = null;
3198
+ }
3199
+ return;
3200
+ }
3201
+ const tick = () => {
3202
+ const peakDb = getPeakDb();
3203
+ const amp = peakDb <= -120 ? 0 : Math.max(0, Math.min(1, (peakDb + 60) / 60));
3204
+ const ring = ringRef.current;
3205
+ ring[writeIdxRef.current] = amp;
3206
+ writeIdxRef.current = (writeIdxRef.current + 1) % ring.length;
3207
+ const canvas = canvasRef.current;
3208
+ if (canvas) {
3209
+ const dpr = window.devicePixelRatio || 1;
3210
+ const cssW = canvas.clientWidth;
3211
+ const cssH = canvas.clientHeight;
3212
+ if (cssW > 0 && cssH > 0) {
3213
+ if (canvas.width !== Math.floor(cssW * dpr) || canvas.height !== Math.floor(cssH * dpr)) {
3214
+ canvas.width = Math.floor(cssW * dpr);
3215
+ canvas.height = Math.floor(cssH * dpr);
3216
+ }
3217
+ const ctx = canvas.getContext("2d");
3218
+ if (ctx) {
3219
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
3220
+ ctx.clearRect(0, 0, cssW, cssH);
3221
+ ctx.fillStyle = fillStyle ?? "#6af2c5";
3222
+ const mid = cssH / 2;
3223
+ const cols = ring.length;
3224
+ const colW = cssW / cols;
3225
+ const start = writeIdxRef.current;
3226
+ for (let x = 0; x < cols; x++) {
3227
+ const ringIdx = (start + x) % cols;
3228
+ const a = ring[ringIdx];
3229
+ const half = a * mid;
3230
+ ctx.fillRect(x * colW, mid - half, Math.max(1, colW), Math.max(1, half * 2));
3231
+ }
3232
+ }
3233
+ }
3234
+ }
3235
+ rafRef.current = requestAnimationFrame(tick);
3236
+ };
3237
+ rafRef.current = requestAnimationFrame(tick);
3238
+ return () => {
3239
+ if (rafRef.current !== null) {
3240
+ cancelAnimationFrame(rafRef.current);
3241
+ rafRef.current = null;
3242
+ }
3243
+ };
3244
+ }, [active, getPeakDb, fillStyle]);
3245
+ return /* @__PURE__ */ jsx18(
3246
+ "canvas",
3247
+ {
3248
+ ref: canvasRef,
3249
+ "data-testid": "scrolling-waveform",
3250
+ className: className ?? "w-full h-12"
3251
+ }
3252
+ );
3253
+ };
3254
+
3255
+ // src/components/OffsetScrubber.tsx
3256
+ import { useCallback as useCallback7, useEffect as useEffect11, useMemo as useMemo4, useRef as useRef10, useState as useState11 } from "react";
3257
+ import { jsx as jsx19, jsxs as jsxs13 } from "react/jsx-runtime";
3258
+ var SLIDER_HEIGHT_PX = 28;
3259
+ var TICK_HEIGHT_PX = 14;
3260
+ var DOWNBEAT_TICK_HEIGHT_PX = 22;
3261
+ var THUMB_WIDTH_PX = 4;
3262
+ function OffsetScrubber({
3263
+ cuePoints,
3264
+ offsetSamples,
3265
+ projectBpm,
3266
+ meter = 4,
3267
+ onChange,
3268
+ disabled = false
1000
3269
  }) {
1001
- const { muted: isMuted, solo: isSoloed, volume: currentVolume, pan: currentPan } = runtimeState;
1002
- const needsGeneration = !!(prompt?.trim() && !hasMidi && !isGenerating);
1003
- const hasFxActive = Object.values(fxDetailState).some(
1004
- (d) => d.enabled
3270
+ const trackRef = useRef10(null);
3271
+ const [draftOffset, setDraftOffset] = useState11(offsetSamples);
3272
+ const [isDragging, setIsDragging] = useState11(false);
3273
+ useEffect11(() => {
3274
+ if (!isDragging) setDraftOffset(offsetSamples);
3275
+ }, [offsetSamples, isDragging]);
3276
+ const sampleRate = cuePoints?.sample_rate ?? 44100;
3277
+ const detectedBpm = cuePoints?.detected_bpm ?? projectBpm;
3278
+ const beatsForRange = useMemo4(() => {
3279
+ return Math.round(60 / projectBpm * sampleRate);
3280
+ }, [projectBpm, sampleRate]);
3281
+ const rangeSamples = beatsForRange * meter;
3282
+ const sampleToFraction = useCallback7(
3283
+ (sample) => {
3284
+ const clamped = Math.max(-rangeSamples, Math.min(rangeSamples, sample));
3285
+ return (clamped + rangeSamples) / (2 * rangeSamples);
3286
+ },
3287
+ [rangeSamples]
1005
3288
  );
1006
- const handleKeyDown = (e) => {
1007
- if (e.key === "Enter" && !e.shiftKey && onGenerate) {
3289
+ const fractionToSample = useCallback7(
3290
+ (fraction) => {
3291
+ const clamped = Math.max(0, Math.min(1, fraction));
3292
+ return Math.round(clamped * 2 * rangeSamples - rangeSamples);
3293
+ },
3294
+ [rangeSamples]
3295
+ );
3296
+ const snapTargets = useMemo4(() => {
3297
+ if (!cuePoints || cuePoints.beats.length === 0) return [];
3298
+ const downbeat = cuePoints.beats[0];
3299
+ const positives = cuePoints.beats.map((b) => b - downbeat);
3300
+ const negatives = positives.slice(1).map((p) => -p);
3301
+ return [...negatives, ...positives].sort((a, b) => a - b);
3302
+ }, [cuePoints]);
3303
+ const snapToBeat = useCallback7(
3304
+ (sample) => {
3305
+ if (snapTargets.length === 0) return sample;
3306
+ let best = snapTargets[0];
3307
+ let bestDist = Math.abs(sample - best);
3308
+ for (const t of snapTargets) {
3309
+ const d = Math.abs(sample - t);
3310
+ if (d < bestDist) {
3311
+ best = t;
3312
+ bestDist = d;
3313
+ }
3314
+ }
3315
+ return best;
3316
+ },
3317
+ [snapTargets]
3318
+ );
3319
+ const handlePointerDown = useCallback7(
3320
+ (e) => {
3321
+ if (disabled || !cuePoints) return;
1008
3322
  e.preventDefault();
1009
- onGenerate();
1010
- }
1011
- };
1012
- const borderColorStyle = needsGeneration ? void 0 : accentColor;
1013
- const borderClass = needsGeneration ? "border-amber-400 animate-pulse" : "border-sas-border";
1014
- return /* @__PURE__ */ jsxs4("div", { "data-testid": "sdk-track-row-wrapper", className: "w-full", children: [
1015
- /* @__PURE__ */ jsxs4(
3323
+ const track = trackRef.current;
3324
+ if (!track) return;
3325
+ track.setPointerCapture(e.pointerId);
3326
+ setIsDragging(true);
3327
+ const updateFromEvent = (clientX, shiftHeld) => {
3328
+ const rect = track.getBoundingClientRect();
3329
+ const fraction = (clientX - rect.left) / rect.width;
3330
+ const raw = fractionToSample(fraction);
3331
+ return shiftHeld ? raw : snapToBeat(raw);
3332
+ };
3333
+ setDraftOffset(updateFromEvent(e.clientX, e.shiftKey));
3334
+ const onMove = (ev) => {
3335
+ setDraftOffset(updateFromEvent(ev.clientX, ev.shiftKey));
3336
+ };
3337
+ const onUp = (ev) => {
3338
+ const final = updateFromEvent(ev.clientX, ev.shiftKey);
3339
+ track.releasePointerCapture(e.pointerId);
3340
+ track.removeEventListener("pointermove", onMove);
3341
+ track.removeEventListener("pointerup", onUp);
3342
+ track.removeEventListener("pointercancel", onUp);
3343
+ setIsDragging(false);
3344
+ setDraftOffset(final);
3345
+ onChange(final);
3346
+ };
3347
+ track.addEventListener("pointermove", onMove);
3348
+ track.addEventListener("pointerup", onUp);
3349
+ track.addEventListener("pointercancel", onUp);
3350
+ },
3351
+ [disabled, cuePoints, fractionToSample, onChange, snapToBeat]
3352
+ );
3353
+ const handleResetToZero = useCallback7(() => {
3354
+ if (disabled) return;
3355
+ setDraftOffset(0);
3356
+ onChange(0);
3357
+ }, [disabled, onChange]);
3358
+ const thumbFraction = sampleToFraction(draftOffset);
3359
+ const thumbLeftPct = `${(thumbFraction * 100).toFixed(2)}%`;
3360
+ const bpmMismatch = cuePoints?.detected_bpm != null && Math.abs(cuePoints.detected_bpm - projectBpm) > 1;
3361
+ const ticks = useMemo4(() => {
3362
+ if (!cuePoints) return [];
3363
+ const downbeat = cuePoints.beats[0] ?? 0;
3364
+ return cuePoints.beats.map((b, i) => {
3365
+ const offsetCandidate = b - downbeat;
3366
+ const fraction = sampleToFraction(offsetCandidate);
3367
+ const isDownbeat = i === 0;
3368
+ return { i, fraction, isDownbeat };
3369
+ });
3370
+ }, [cuePoints, sampleToFraction]);
3371
+ const isDisabled = disabled || !cuePoints || cuePoints.beats.length === 0;
3372
+ return /* @__PURE__ */ jsxs13("div", { "data-testid": "offset-scrubber", className: "flex items-center gap-2 w-full", children: [
3373
+ /* @__PURE__ */ jsx19("span", { className: "text-[9px] text-sas-muted/60 uppercase tracking-wide flex-shrink-0", children: "Align" }),
3374
+ /* @__PURE__ */ jsxs13(
1016
3375
  "div",
1017
3376
  {
1018
- "data-testid": "sdk-track-row",
1019
- className: `relative flex items-stretch gap-1 p-2 rounded-sm border w-full overflow-hidden ${borderClass} bg-sas-panel-alt`,
1020
- style: {
1021
- borderLeftColor: needsGeneration ? "#f59e0b" : borderColorStyle,
1022
- borderLeftWidth: "3px"
1023
- },
3377
+ ref: trackRef,
3378
+ "data-testid": "offset-scrubber-track",
3379
+ onPointerDown: handlePointerDown,
3380
+ 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"}`,
3381
+ style: { height: SLIDER_HEIGHT_PX },
3382
+ title: isDisabled ? "Generate audio first to enable offset alignment" : "Drag to align beat 1. Hold Shift for free, no-snap movement.",
3383
+ role: "slider",
3384
+ "aria-label": "Audio offset alignment",
3385
+ "aria-valuemin": -rangeSamples,
3386
+ "aria-valuemax": rangeSamples,
3387
+ "aria-valuenow": draftOffset,
3388
+ "aria-disabled": isDisabled,
1024
3389
  children: [
1025
- isGenerating && /* @__PURE__ */ jsx6("div", { className: "absolute left-0 top-0 bottom-0 right-44 z-20", children: /* @__PURE__ */ jsx6(
1026
- SorceryProgressBar,
3390
+ /* @__PURE__ */ jsx19(
3391
+ "div",
1027
3392
  {
1028
- isLoading: true,
1029
- statusText: "CONJURING MIDI...",
1030
- heightClass: "h-full",
1031
- initialProgress: generationProgress,
1032
- onProgressChange,
1033
- estimatedDurationMs: estimatedGenerationMs
3393
+ "aria-hidden": "true",
3394
+ className: "absolute top-0 bottom-0 w-px bg-sas-accent/40",
3395
+ style: { left: "50%" }
1034
3396
  }
1035
- ) }),
1036
- /* @__PURE__ */ jsxs4("div", { className: "flex flex-col flex-1 min-w-0 relative z-10", children: [
1037
- contentSlot ? contentSlot : onPromptChange ? /* @__PURE__ */ jsx6(
1038
- "input",
1039
- {
1040
- type: "text",
1041
- "data-testid": "sdk-prompt-input",
1042
- value: prompt ?? "",
1043
- onChange: (e) => onPromptChange(e.target.value),
1044
- onKeyDown: handleKeyDown,
1045
- placeholder: "Describe your part...",
1046
- disabled: isGenerating,
1047
- className: "sas-input w-full px-2 py-1 text-xs disabled:opacity-50 disabled:cursor-not-allowed"
3397
+ ),
3398
+ ticks.map((t) => /* @__PURE__ */ jsx19(
3399
+ "div",
3400
+ {
3401
+ "data-testid": t.isDownbeat ? "offset-tick-downbeat" : "offset-tick",
3402
+ "aria-hidden": "true",
3403
+ className: t.isDownbeat ? "absolute bg-sas-accent" : "absolute bg-sas-muted/50",
3404
+ style: {
3405
+ left: `${(t.fraction * 100).toFixed(2)}%`,
3406
+ top: (SLIDER_HEIGHT_PX - (t.isDownbeat ? DOWNBEAT_TICK_HEIGHT_PX : TICK_HEIGHT_PX)) / 2,
3407
+ width: 1,
3408
+ height: t.isDownbeat ? DOWNBEAT_TICK_HEIGHT_PX : TICK_HEIGHT_PX
1048
3409
  }
1049
- ) : null,
1050
- /* @__PURE__ */ jsxs4("div", { className: "flex items-center gap-2 mt-1", children: [
1051
- track.name && /* @__PURE__ */ jsx6("span", { className: "text-[10px] text-sas-muted/60 truncate pl-2 flex-shrink-0 max-w-[80px]", title: track.name, children: track.name }),
1052
- /* @__PURE__ */ jsx6("span", { className: "text-[9px] text-sas-muted/50 flex-shrink-0", children: "vol:" }),
1053
- /* @__PURE__ */ jsx6(
1054
- VolumeSlider,
1055
- {
1056
- value: currentVolume,
1057
- onChange: onVolumeChange,
1058
- disabled: isGenerating,
1059
- className: "flex-1 min-w-[40px]"
1060
- }
1061
- ),
1062
- /* @__PURE__ */ jsx6("span", { className: "text-[9px] text-sas-muted/50 flex-shrink-0", children: "pan:" }),
1063
- /* @__PURE__ */ jsx6(
1064
- PanSlider,
1065
- {
1066
- value: currentPan,
1067
- onChange: onPanChange,
1068
- disabled: isGenerating,
1069
- className: "w-10 flex-shrink-0"
1070
- }
1071
- )
1072
- ] })
1073
- ] }),
1074
- error && /* @__PURE__ */ jsx6(
3410
+ },
3411
+ t.i
3412
+ )),
3413
+ /* @__PURE__ */ jsx19(
1075
3414
  "div",
1076
3415
  {
1077
- "data-testid": "sdk-error-indicator",
1078
- className: "flex-shrink-0 relative z-10 self-stretch flex items-center px-1 group cursor-help",
1079
- title: error,
1080
- children: /* @__PURE__ */ jsxs4("div", { className: "relative", children: [
1081
- /* @__PURE__ */ jsx6(
1082
- AlertCircle,
1083
- {
1084
- className: "w-5 h-5 text-red-500 animate-pulse",
1085
- strokeWidth: 2.5
1086
- }
1087
- ),
1088
- /* @__PURE__ */ jsx6("div", { className: "absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-red-900/95 text-red-100 text-xs rounded shadow-lg whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50 max-w-[200px] truncate", children: error })
1089
- ] })
3416
+ "data-testid": "offset-scrubber-thumb",
3417
+ "aria-hidden": "true",
3418
+ className: `absolute top-0 bottom-0 rounded-sm ${isDragging ? "bg-sas-accent" : "bg-sas-accent/80"}`,
3419
+ style: {
3420
+ left: thumbLeftPct,
3421
+ width: THUMB_WIDTH_PX,
3422
+ transform: "translateX(-50%)",
3423
+ pointerEvents: "none"
3424
+ }
1090
3425
  }
1091
- ),
1092
- /* @__PURE__ */ jsxs4("div", { className: "flex flex-col gap-0.5 flex-shrink-0 relative z-30 justify-center", children: [
1093
- /* @__PURE__ */ jsxs4("div", { className: "flex gap-1 items-center", children: [
1094
- onGenerate && /* @__PURE__ */ jsx6(
1095
- "button",
1096
- {
1097
- "data-testid": "sdk-generate-button",
1098
- onClick: onGenerate,
1099
- disabled: !isAuthenticated || isGenerating || !prompt?.trim(),
1100
- className: `w-14 py-0.5 rounded-sm text-xs font-medium transition-colors border ${!isAuthenticated || isGenerating ? "bg-sas-panel border-sas-border text-sas-muted/50 cursor-not-allowed" : needsGeneration ? "bg-amber-500/30 border-amber-500 text-amber-400 hover:bg-amber-500 hover:text-sas-bg animate-pulse" : prompt?.trim() ? "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"}`,
1101
- title: !isAuthenticated ? "Please log in" : isGenerating ? "Generating..." : "Generate MIDI",
1102
- children: "Create"
1103
- }
1104
- ),
1105
- onCopy && /* @__PURE__ */ jsx6(
1106
- "button",
1107
- {
1108
- "data-testid": "sdk-copy-button",
1109
- onClick: onCopy,
1110
- disabled: !hasMidi || isGenerating,
1111
- className: `w-14 py-0.5 rounded-sm text-xs font-medium transition-colors border ${!hasMidi || isGenerating ? "bg-sas-panel border-sas-border text-sas-muted/30 cursor-not-allowed" : "bg-sas-panel-alt border-sas-border text-sas-muted hover:border-sas-accent hover:text-sas-accent"}`,
1112
- title: hasMidi ? "Duplicate track with different preset" : "Generate MIDI first",
1113
- children: "Copy"
1114
- }
1115
- ),
1116
- /* @__PURE__ */ jsx6(
1117
- "button",
1118
- {
1119
- "data-testid": "sdk-mute-button",
1120
- onClick: onMuteToggle,
1121
- disabled: isGenerating,
1122
- 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"}`,
1123
- title: isMuted ? "Unmute track" : "Mute track",
1124
- children: "M"
1125
- }
1126
- ),
1127
- /* @__PURE__ */ jsx6(
1128
- "button",
1129
- {
1130
- "data-testid": "sdk-delete-button",
1131
- onClick: onDelete,
1132
- className: "text-sas-danger/70 hover:text-sas-danger px-1 py-0.5 transition-colors text-sm",
1133
- title: "Delete track",
1134
- children: "x"
1135
- }
1136
- )
1137
- ] }),
1138
- /* @__PURE__ */ jsxs4("div", { className: "flex gap-1 items-center", children: [
1139
- onShuffle && /* @__PURE__ */ jsx6(
1140
- "button",
1141
- {
1142
- "data-testid": "sdk-shuffle-button",
1143
- onClick: onShuffle,
1144
- disabled: !hasMidi || isGenerating || !!currentInstrumentPluginId,
1145
- className: `w-14 py-0.5 rounded-sm text-xs font-medium transition-colors border ${!hasMidi || isGenerating || !!currentInstrumentPluginId ? "bg-sas-panel border-sas-border text-sas-muted/30 cursor-not-allowed" : "bg-sas-panel-alt border-sas-border text-sas-muted hover:border-sas-accent hover:text-sas-accent"}`,
1146
- title: currentInstrumentPluginId ? "Shuffle only works with default Surge XT" : hasMidi ? "Re-roll sound (keep MIDI)" : "Generate MIDI first",
1147
- children: "Shuffle"
1148
- }
1149
- ),
1150
- onToggleFxDrawer && /* @__PURE__ */ jsx6(
1151
- "button",
1152
- {
1153
- "data-testid": "sdk-fx-button",
1154
- onClick: onToggleFxDrawer,
1155
- disabled: isGenerating,
1156
- className: `w-14 py-0.5 rounded-sm text-xs font-medium transition-colors border ${isGenerating ? "bg-sas-panel border-sas-border text-sas-muted/50 cursor-not-allowed" : fxDrawerOpen ? "bg-sas-accent border-sas-accent text-sas-bg" : hasFxActive ? "bg-sas-accent/20 border-sas-accent text-sas-accent hover:bg-sas-accent hover:text-sas-bg" : "bg-sas-panel-alt border-sas-border text-sas-muted hover:border-sas-accent hover:text-sas-accent"}`,
1157
- title: fxDrawerOpen ? "Hide FX controls" : "Show FX controls",
1158
- children: "FX"
1159
- }
1160
- ),
1161
- /* @__PURE__ */ jsx6(
1162
- "button",
1163
- {
1164
- "data-testid": "sdk-solo-button",
1165
- onClick: onSoloToggle,
1166
- disabled: isGenerating,
1167
- 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" : isSoloed ? "bg-yellow-500 text-black" : "bg-sas-panel-alt text-sas-muted hover:bg-sas-border"}`,
1168
- title: isSoloed ? "Unsolo track" : "Solo track",
1169
- children: "S"
1170
- }
1171
- ),
1172
- onToggleInstrumentDrawer && /* @__PURE__ */ jsx6(
1173
- "button",
1174
- {
1175
- "data-testid": "sdk-plugin-button",
1176
- onClick: onToggleInstrumentDrawer,
1177
- disabled: isGenerating,
1178
- 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" : instrumentDrawerOpen ? "bg-sas-accent border-sas-accent text-sas-bg" : instrumentMissing ? "bg-amber-500/20 text-amber-400 hover:bg-amber-500/40" : "bg-sas-panel-alt text-sas-muted hover:bg-sas-border"}`,
1179
- title: `Plugin: ${instrumentName ?? "Surge XT"}${instrumentMissing ? " (missing)" : ""}`,
1180
- children: "P"
1181
- }
1182
- )
1183
- ] })
1184
- ] })
3426
+ )
1185
3427
  ]
1186
3428
  }
1187
3429
  ),
1188
- fxDrawerOpen && !instrumentDrawerOpen && /* @__PURE__ */ jsx6("div", { "data-testid": "sdk-fx-drawer", className: "border border-t-0 border-sas-border bg-sas-bg rounded-b-sm px-3 py-2 max-h-[180px] overflow-y-auto", children: /* @__PURE__ */ jsx6(
1189
- FxToggleBar,
3430
+ /* @__PURE__ */ jsx19(
3431
+ "span",
3432
+ {
3433
+ "data-testid": "offset-scrubber-readout",
3434
+ className: "text-[10px] text-sas-muted/70 tabular-nums flex-shrink-0 min-w-[64px] text-right",
3435
+ children: formatOffset(draftOffset, sampleRate)
3436
+ }
3437
+ ),
3438
+ /* @__PURE__ */ jsx19(
3439
+ "button",
1190
3440
  {
1191
- trackId: track.id,
1192
- fxState: fxDetailState,
1193
- onToggle: (_trackId, category, enabled) => onFxToggle?.(category, enabled),
1194
- onPresetChange: (_trackId, category, presetIndex) => onFxPresetChange?.(category, presetIndex),
1195
- onDryWetChange: (_trackId, category, value) => onFxDryWetChange?.(category, value),
1196
- disabled: isGenerating
3441
+ type: "button",
3442
+ "data-testid": "offset-scrubber-reset",
3443
+ onClick: handleResetToZero,
3444
+ disabled: isDisabled || draftOffset === 0,
3445
+ 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"}`,
3446
+ title: "Reset offset to 0 (bar 1)",
3447
+ children: "\u2316"
1197
3448
  }
1198
- ) }),
1199
- instrumentDrawerOpen && !fxDrawerOpen && availableInstruments && onInstrumentSelect && onRefreshInstruments && /* @__PURE__ */ jsx6("div", { "data-testid": "sdk-instrument-drawer", className: "border border-t-0 border-sas-border bg-sas-bg rounded-b-sm px-3 py-2", children: /* @__PURE__ */ jsx6(
1200
- InstrumentDrawer,
3449
+ ),
3450
+ bpmMismatch && /* @__PURE__ */ jsx19(
3451
+ "span",
1201
3452
  {
1202
- instruments: availableInstruments,
1203
- currentPluginId: currentInstrumentPluginId ?? null,
1204
- isLoading: instrumentsLoading ?? false,
1205
- onSelect: onInstrumentSelect,
1206
- onRefresh: onRefreshInstruments,
1207
- stage: instrumentDrawerStage,
1208
- onShowEditor,
1209
- onBackToInstruments,
1210
- selectedInstrumentName: instrumentName
3453
+ "data-testid": "offset-bpm-mismatch",
3454
+ 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",
3455
+ title: `Detected ${detectedBpm.toFixed(1)} BPM \u2014 beats may not align with project ${projectBpm} BPM grid`,
3456
+ children: "BPM \u2260"
1211
3457
  }
1212
- ) })
3458
+ )
1213
3459
  ] });
1214
3460
  }
3461
+ function formatOffset(samples, sampleRate) {
3462
+ const sign = samples > 0 ? "+" : samples < 0 ? "-" : "";
3463
+ const abs = Math.abs(samples);
3464
+ const ms = Math.round(abs / sampleRate * 1e3);
3465
+ return `${sign}${abs} spl (${sign}${ms} ms)`;
3466
+ }
3467
+
3468
+ // src/components/wavPeakAnalyzer.ts
3469
+ var CLIP_THRESHOLD_LINEAR = 0.891;
3470
+ async function analyzeWavPeak(host, filePath) {
3471
+ const bytes = await host.getAudioFileBytes(filePath);
3472
+ const ContextCtor = window.AudioContext ?? window.webkitAudioContext;
3473
+ const audioContext = new ContextCtor();
3474
+ try {
3475
+ const audioBuffer = await audioContext.decodeAudioData(bytes.slice(0));
3476
+ let peak = 0;
3477
+ for (let c = 0; c < audioBuffer.numberOfChannels; c++) {
3478
+ const data = audioBuffer.getChannelData(c);
3479
+ for (let i = 0; i < data.length; i++) {
3480
+ const a = Math.abs(data[i]);
3481
+ if (a > peak) peak = a;
3482
+ }
3483
+ }
3484
+ const peakDb = peak > 1e-6 ? 20 * Math.log10(peak) : -120;
3485
+ return {
3486
+ peakLinear: peak,
3487
+ peakDb,
3488
+ clipped: peak >= CLIP_THRESHOLD_LINEAR - 5e-3
3489
+ };
3490
+ } finally {
3491
+ await audioContext.close().catch(() => {
3492
+ });
3493
+ }
3494
+ }
3495
+
3496
+ // src/components/synthesizeCuePoints.ts
3497
+ function synthesizeCuePoints({
3498
+ bpm,
3499
+ sampleRate,
3500
+ bars,
3501
+ meter = 4
3502
+ }) {
3503
+ const safeBpm = bpm > 0 ? bpm : 120;
3504
+ const safeSampleRate = sampleRate > 0 ? sampleRate : 48e3;
3505
+ const samplesPerBeat = Math.round(60 / safeBpm * safeSampleRate);
3506
+ const totalBeats = Math.max(1, Math.round(bars * meter));
3507
+ const beats = [];
3508
+ for (let i = 0; i < totalBeats; i++) {
3509
+ beats.push(i * samplesPerBeat);
3510
+ }
3511
+ return {
3512
+ schema: 1,
3513
+ sample_rate: safeSampleRate,
3514
+ detected_bpm: safeBpm,
3515
+ downbeat_sample: 0,
3516
+ beats,
3517
+ detected_at: (/* @__PURE__ */ new Date()).toISOString()
3518
+ };
3519
+ }
1215
3520
 
1216
3521
  // src/hooks/useSceneState.ts
1217
- import { useState as useState5, useCallback as useCallback3, useRef as useRef4 } from "react";
3522
+ import { useState as useState12, useCallback as useCallback8, useRef as useRef11 } from "react";
1218
3523
  function useSceneState(activeSceneId, initialValue) {
1219
- const [stateMap, setStateMap] = useState5(() => /* @__PURE__ */ new Map());
1220
- const activeSceneIdRef = useRef4(activeSceneId);
3524
+ const [stateMap, setStateMap] = useState12(() => /* @__PURE__ */ new Map());
3525
+ const activeSceneIdRef = useRef11(activeSceneId);
1221
3526
  activeSceneIdRef.current = activeSceneId;
1222
3527
  const currentValue = activeSceneId !== null && stateMap.has(activeSceneId) ? stateMap.get(activeSceneId) : initialValue;
1223
- const setForCurrentScene = useCallback3((value) => {
3528
+ const setForCurrentScene = useCallback8((value) => {
1224
3529
  const sid = activeSceneIdRef.current;
1225
3530
  if (sid === null) return;
1226
3531
  setStateMap((prev) => {
@@ -1231,7 +3536,7 @@ function useSceneState(activeSceneId, initialValue) {
1231
3536
  return newMap;
1232
3537
  });
1233
3538
  }, [initialValue]);
1234
- const setForScene = useCallback3((sceneId, value) => {
3539
+ const setForScene = useCallback8((sceneId, value) => {
1235
3540
  setStateMap((prev) => {
1236
3541
  const current = prev.has(sceneId) ? prev.get(sceneId) : initialValue;
1237
3542
  const next = typeof value === "function" ? value(current) : value;
@@ -1243,32 +3548,433 @@ function useSceneState(activeSceneId, initialValue) {
1243
3548
  return [currentValue, setForCurrentScene, setForScene];
1244
3549
  }
1245
3550
 
3551
+ // src/hooks/useAnySolo.ts
3552
+ import { useEffect as useEffect12, useState as useState13 } from "react";
3553
+ function useAnySolo(host) {
3554
+ const [anySolo, setAnySolo] = useState13(false);
3555
+ useEffect12(() => {
3556
+ let active = true;
3557
+ const refresh = () => {
3558
+ host.isAnySoloActive().then((v) => {
3559
+ if (active) setAnySolo(v);
3560
+ }).catch(() => {
3561
+ });
3562
+ };
3563
+ refresh();
3564
+ const unsub = host.onTrackStateChange(() => refresh());
3565
+ return () => {
3566
+ active = false;
3567
+ unsub();
3568
+ };
3569
+ }, [host]);
3570
+ return anySolo;
3571
+ }
3572
+
3573
+ // src/hooks/useSoundHistory.ts
3574
+ import { useCallback as useCallback9, useMemo as useMemo5, useRef as useRef12, useState as useState14 } from "react";
3575
+ var EMPTY = { entries: [], cursor: -1 };
3576
+ function sameDescriptor(a, b) {
3577
+ if (a === b) return true;
3578
+ try {
3579
+ return JSON.stringify(a) === JSON.stringify(b);
3580
+ } catch {
3581
+ return false;
3582
+ }
3583
+ }
3584
+ function useSoundHistory(applySound, opts = {}) {
3585
+ const max = Math.max(2, opts.max ?? 24);
3586
+ const applyRef = useRef12(applySound);
3587
+ applyRef.current = applySound;
3588
+ const onChangeRef = useRef12(opts.onChange);
3589
+ onChangeRef.current = opts.onChange;
3590
+ const dataRef = useRef12({});
3591
+ const [, setVersion] = useState14(0);
3592
+ const bump = useCallback9(() => setVersion((v) => v + 1), []);
3593
+ const commit = useCallback9(
3594
+ (trackId, next, notify) => {
3595
+ dataRef.current = { ...dataRef.current, [trackId]: next };
3596
+ bump();
3597
+ if (notify) onChangeRef.current?.(trackId, next);
3598
+ },
3599
+ [bump]
3600
+ );
3601
+ const record = useCallback9(
3602
+ (trackId, descriptor, label) => {
3603
+ const h = dataRef.current[trackId];
3604
+ const current = h && h.cursor >= 0 ? h.entries[h.cursor] : void 0;
3605
+ if (current && sameDescriptor(current.descriptor, descriptor)) return;
3606
+ const entries = [...h ? h.entries : [], { descriptor, label }];
3607
+ while (entries.length > max) {
3608
+ const victim = entries.findIndex((e) => !e.favorite);
3609
+ if (victim === -1) break;
3610
+ entries.splice(victim, 1);
3611
+ }
3612
+ commit(trackId, { entries, cursor: entries.length - 1 }, true);
3613
+ },
3614
+ [max, commit]
3615
+ );
3616
+ const restoreTo = useCallback9(
3617
+ async (trackId, index) => {
3618
+ const h = dataRef.current[trackId];
3619
+ if (!h || index < 0 || index >= h.entries.length || index === h.cursor) return false;
3620
+ await applyRef.current(trackId, h.entries[index].descriptor);
3621
+ commit(trackId, { entries: h.entries, cursor: index }, true);
3622
+ return true;
3623
+ },
3624
+ [commit]
3625
+ );
3626
+ const undo = useCallback9(
3627
+ (trackId) => {
3628
+ const h = dataRef.current[trackId];
3629
+ if (!h || h.cursor <= 0) return Promise.resolve(false);
3630
+ return restoreTo(trackId, h.cursor - 1);
3631
+ },
3632
+ [restoreTo]
3633
+ );
3634
+ const toggleFavorite = useCallback9(
3635
+ (trackId, index) => {
3636
+ const h = dataRef.current[trackId];
3637
+ if (!h || index < 0 || index >= h.entries.length) return;
3638
+ const entries = h.entries.map((e, i) => i === index ? { ...e, favorite: !e.favorite } : e);
3639
+ commit(trackId, { entries, cursor: h.cursor }, true);
3640
+ },
3641
+ [commit]
3642
+ );
3643
+ const restore = useCallback9(
3644
+ (trackId, state) => {
3645
+ const entries = Array.isArray(state?.entries) ? [...state.entries] : [];
3646
+ const raw = typeof state?.cursor === "number" ? state.cursor : entries.length - 1;
3647
+ const cursor = entries.length === 0 ? -1 : Math.min(Math.max(raw, 0), entries.length - 1);
3648
+ commit(trackId, { entries, cursor }, false);
3649
+ },
3650
+ [commit]
3651
+ );
3652
+ const list = useCallback9(
3653
+ (trackId) => dataRef.current[trackId] ?? EMPTY,
3654
+ []
3655
+ );
3656
+ const canUndo = useCallback9((trackId) => {
3657
+ const h = dataRef.current[trackId];
3658
+ return !!h && h.cursor > 0;
3659
+ }, []);
3660
+ const clear = useCallback9(
3661
+ (trackId) => {
3662
+ if (dataRef.current[trackId]) {
3663
+ const next = { ...dataRef.current };
3664
+ delete next[trackId];
3665
+ dataRef.current = next;
3666
+ bump();
3667
+ }
3668
+ onChangeRef.current?.(trackId, EMPTY);
3669
+ },
3670
+ [bump]
3671
+ );
3672
+ const reset = useCallback9(() => {
3673
+ dataRef.current = {};
3674
+ bump();
3675
+ }, [bump]);
3676
+ return useMemo5(
3677
+ () => ({ record, undo, restoreTo, list, canUndo, clear, reset, restore, toggleFavorite }),
3678
+ [record, undo, restoreTo, list, canUndo, clear, reset, restore, toggleFavorite]
3679
+ );
3680
+ }
3681
+
3682
+ // src/hooks/useTrackReorder.ts
3683
+ import { useCallback as useCallback10, useRef as useRef13, useState as useState15 } from "react";
3684
+ function moveItem(arr, from, to) {
3685
+ const next = arr.slice();
3686
+ if (from === to || from < 0 || to < 0 || from >= next.length || to >= next.length) {
3687
+ return next;
3688
+ }
3689
+ const [moved] = next.splice(from, 1);
3690
+ next.splice(to, 0, moved);
3691
+ return next;
3692
+ }
3693
+ function useTrackReorder({
3694
+ host,
3695
+ items,
3696
+ setItems,
3697
+ getId,
3698
+ onError
3699
+ }) {
3700
+ const [draggingIndex, setDraggingIndex] = useState15(null);
3701
+ const [dragOverIndex, setDragOverIndex] = useState15(null);
3702
+ const fromRef = useRef13(null);
3703
+ const itemsRef = useRef13(items);
3704
+ itemsRef.current = items;
3705
+ const dragPropsFor = useCallback10(
3706
+ (index) => ({
3707
+ handleProps: {
3708
+ draggable: true,
3709
+ onDragStart: (e) => {
3710
+ fromRef.current = index;
3711
+ setDraggingIndex(index);
3712
+ if (e.dataTransfer) {
3713
+ e.dataTransfer.effectAllowed = "move";
3714
+ try {
3715
+ e.dataTransfer.setData("text/plain", String(index));
3716
+ } catch {
3717
+ }
3718
+ }
3719
+ },
3720
+ onDragEnd: () => {
3721
+ fromRef.current = null;
3722
+ setDraggingIndex(null);
3723
+ setDragOverIndex(null);
3724
+ }
3725
+ },
3726
+ rowProps: {
3727
+ onDragEnter: (e) => {
3728
+ if (fromRef.current === null) return;
3729
+ e.preventDefault();
3730
+ setDragOverIndex(index);
3731
+ },
3732
+ onDragOver: (e) => {
3733
+ if (fromRef.current === null) return;
3734
+ e.preventDefault();
3735
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
3736
+ setDragOverIndex((cur) => cur === index ? cur : index);
3737
+ },
3738
+ onDragLeave: () => {
3739
+ setDragOverIndex((cur) => cur === index ? null : cur);
3740
+ },
3741
+ onDrop: (e) => {
3742
+ e.preventDefault();
3743
+ const from = fromRef.current;
3744
+ fromRef.current = null;
3745
+ setDraggingIndex(null);
3746
+ setDragOverIndex(null);
3747
+ if (from === null || from === index) return;
3748
+ const prev = itemsRef.current;
3749
+ const next = moveItem(prev, from, index);
3750
+ setItems(next);
3751
+ const ids = next.map(getId);
3752
+ Promise.resolve(host.reorderTracks(ids)).catch((err) => {
3753
+ setItems(prev);
3754
+ onError?.(err);
3755
+ });
3756
+ }
3757
+ },
3758
+ isDragging: draggingIndex === index,
3759
+ isDragTarget: dragOverIndex === index && draggingIndex !== index
3760
+ }),
3761
+ [host, setItems, getId, onError, draggingIndex, dragOverIndex]
3762
+ );
3763
+ return { dragPropsFor, draggingIndex, dragOverIndex };
3764
+ }
3765
+
1246
3766
  // src/constants/sdk-version.ts
1247
- var PLUGIN_SDK_VERSION = "2.3.0";
3767
+ var PLUGIN_SDK_VERSION = "2.24.0";
3768
+
3769
+ // src/utils/format-concurrent-tracks.ts
3770
+ function formatConcurrentTracks(ctx) {
3771
+ const tracks = ctx.concurrentTracks;
3772
+ if (!tracks || tracks.length === 0) return "";
3773
+ const lines = [`Concurrent tracks in scene (already generated):`];
3774
+ for (const track of tracks) {
3775
+ const promptStr = track.prompt ? ` prompt="${escapeQuotes(track.prompt)}"` : "";
3776
+ lines.push(` - role=${track.role ?? "unknown"}${promptStr}`);
3777
+ if (track.notesByChord.length === 0) {
3778
+ lines.push(` (no notes)`);
3779
+ } else {
3780
+ for (const segment of track.notesByChord) {
3781
+ if (segment.notes.length === 0) continue;
3782
+ lines.push(` ${formatChordSegment(segment)}`);
3783
+ }
3784
+ }
3785
+ if (track.truncated && typeof track.originalNoteCount === "number") {
3786
+ const dropped = track.originalNoteCount - sumKeptNotes(track.notesByChord);
3787
+ if (dropped > 0) {
3788
+ lines.push(` \u2026 (${dropped} more notes truncated)`);
3789
+ }
3790
+ }
3791
+ }
3792
+ if (ctx.truncatedTrackCount && ctx.truncatedTrackCount > 0) {
3793
+ lines.push(
3794
+ ` \u2026 (${ctx.truncatedTrackCount} additional track${ctx.truncatedTrackCount === 1 ? "" : "s"} omitted to fit token budget)`
3795
+ );
3796
+ }
3797
+ return lines.join("\n");
3798
+ }
3799
+ function formatChordSegment(segment) {
3800
+ const [start, end] = segment.chordRangeQn;
3801
+ const notesJson = JSON.stringify(segment.notes.map(compactNote2));
3802
+ return `${segment.chord} (beats ${start}-${end}): ${notesJson}`;
3803
+ }
3804
+ function compactNote2(n) {
3805
+ return {
3806
+ pitch: n.pitch,
3807
+ startBeat: n.startBeat,
3808
+ durationBeats: n.durationBeats,
3809
+ velocity: n.velocity
3810
+ };
3811
+ }
3812
+ function escapeQuotes(s) {
3813
+ return s.replace(/"/g, '\\"');
3814
+ }
3815
+ function sumKeptNotes(segments) {
3816
+ let total = 0;
3817
+ for (const s of segments) total += s.notes.length;
3818
+ return total;
3819
+ }
3820
+
3821
+ // src/utils/semantic-match.ts
3822
+ var STOP_WORDS = /* @__PURE__ */ new Set([
3823
+ "a",
3824
+ "an",
3825
+ "the",
3826
+ "and",
3827
+ "or",
3828
+ "but",
3829
+ "with",
3830
+ "for",
3831
+ "to",
3832
+ "of",
3833
+ "in",
3834
+ "on",
3835
+ "at",
3836
+ "by",
3837
+ "is",
3838
+ "it",
3839
+ "this",
3840
+ "that",
3841
+ "i",
3842
+ "my",
3843
+ "me",
3844
+ "make",
3845
+ "please",
3846
+ "give",
3847
+ "want",
3848
+ "need",
3849
+ "some",
3850
+ "like",
3851
+ "get",
3852
+ "something"
3853
+ ]);
3854
+ function tokenizePrompt(text) {
3855
+ if (!text) return [];
3856
+ const withoutNegatives = text.split(",").map((clause) => clause.trim()).filter((clause) => clause.length > 0 && !/^no\s/i.test(clause)).join(" ");
3857
+ return withoutNegatives.toLowerCase().split(/[^a-z0-9]+/u).filter((tok) => {
3858
+ if (!tok) return false;
3859
+ if (STOP_WORDS.has(tok)) return false;
3860
+ if (/^\d{1,2}$/.test(tok)) return false;
3861
+ return true;
3862
+ });
3863
+ }
3864
+ function scorePromptMatch(query, candidatePrompts) {
3865
+ const n = candidatePrompts.length;
3866
+ if (n === 0) return [];
3867
+ const queryTokens = Array.from(new Set(tokenizePrompt(query)));
3868
+ if (queryTokens.length === 0) return candidatePrompts.map(() => 0);
3869
+ const candidateTokenSets = candidatePrompts.map((p) => new Set(tokenizePrompt(p)));
3870
+ const idf = /* @__PURE__ */ new Map();
3871
+ for (const token of queryTokens) {
3872
+ let df = 0;
3873
+ for (const set of candidateTokenSets) {
3874
+ if (set.has(token)) df += 1;
3875
+ }
3876
+ if (df > 0) idf.set(token, Math.log(1 + n / df));
3877
+ }
3878
+ let denominator = 0;
3879
+ for (const weight of idf.values()) denominator += weight;
3880
+ if (denominator === 0) return candidatePrompts.map(() => 0);
3881
+ return candidateTokenSets.map((set) => {
3882
+ let numerator = 0;
3883
+ for (const [token, weight] of idf) {
3884
+ if (set.has(token)) numerator += weight;
3885
+ }
3886
+ return numerator / denominator;
3887
+ });
3888
+ }
3889
+ function pickTopKWeighted(scored, options = {}) {
3890
+ const { k = 5, temperature = 0.3, excludeKeys, rng = Math.random } = options;
3891
+ let pool = scored;
3892
+ if (excludeKeys && excludeKeys.size > 0) {
3893
+ pool = pool.filter((c) => c.key === void 0 || !excludeKeys.has(c.key));
3894
+ }
3895
+ if (pool.length === 0) return null;
3896
+ const sorted = [...pool].sort((a, b) => b.score - a.score);
3897
+ const top = sorted.slice(0, Math.max(1, k));
3898
+ const maxScore = top[0].score;
3899
+ const safeTemp = Math.max(1e-6, temperature);
3900
+ const weights = top.map((c) => Math.exp((c.score - maxScore) / safeTemp));
3901
+ const totalWeight = weights.reduce((sum, w) => sum + w, 0);
3902
+ let threshold = rng() * totalWeight;
3903
+ for (let i = 0; i < top.length; i += 1) {
3904
+ threshold -= weights[i];
3905
+ if (threshold <= 0) return top[i].item;
3906
+ }
3907
+ return top[top.length - 1].item;
3908
+ }
1248
3909
  export {
3910
+ ConfirmDialog,
3911
+ CrossfadeModal,
3912
+ CrossfadeTrackRow,
1249
3913
  DB_MAX,
1250
3914
  DB_MIN,
1251
3915
  DEFAULT_FX_CATEGORY_DETAIL,
1252
3916
  DEFAULT_FX_DRY_WET,
3917
+ DRAG_DEAD_ZONE,
3918
+ DownloadPackButton,
1253
3919
  EMPTY_FX_DETAIL_STATE,
1254
3920
  EMPTY_FX_STATE,
3921
+ EQUAL_POWER_GAIN,
1255
3922
  FX_CATEGORIES,
1256
3923
  FX_CHAIN_ORDER,
1257
3924
  FX_DISPLAY_LABELS,
1258
3925
  FX_ENGINE_PLUGIN_NAMES,
1259
3926
  FX_PRESET_CONFIGS,
1260
3927
  FxToggleBar,
1261
- InstrumentDrawer,
3928
+ GUTTER_W,
3929
+ ImportTrackModal,
3930
+ TrackDrawer as InstrumentDrawer,
3931
+ LevelMeter,
3932
+ Modal,
3933
+ OffsetScrubber,
1262
3934
  PLUGIN_SDK_VERSION,
3935
+ PX_PER_BEAT,
1263
3936
  PanSlider,
3937
+ PianoRollEditor,
1264
3938
  PluginError,
3939
+ RESIZE_HANDLE_PX,
3940
+ ROW_HEIGHT,
1265
3941
  SLIDER_UNITY,
3942
+ SamplePackCTACard,
3943
+ ScrollingWaveform,
1266
3944
  SorceryProgressBar,
3945
+ TrackDrawer,
3946
+ TrackMeterStrip,
1267
3947
  TrackRow,
1268
3948
  VolumeSlider,
3949
+ WaveformView,
3950
+ analyzeWavPeak,
3951
+ asCrossfadeMeta,
3952
+ buildCrossfadeInpaintPrompt,
1269
3953
  calculateTimeBasedTarget,
3954
+ cellToPx,
3955
+ centerScrollTop,
3956
+ computePeaks,
1270
3957
  dbToSlider,
3958
+ drawWaveform,
3959
+ formatConcurrentTracks,
3960
+ moveItem,
3961
+ parseCrossfadePairs,
3962
+ pickTopKWeighted,
3963
+ pitchToName,
3964
+ pxToCell,
3965
+ resizeNoteDuration,
3966
+ scorePromptMatch,
1271
3967
  sliderToDb,
1272
- useSceneState
3968
+ synthesizeCuePoints,
3969
+ tokenizePrompt,
3970
+ transposeNotes,
3971
+ useAnySolo,
3972
+ useSceneState,
3973
+ useSoundHistory,
3974
+ useTrackLevel,
3975
+ useTrackLevels,
3976
+ useTrackMeter,
3977
+ useTrackReorder,
3978
+ useTransportPlaying
1273
3979
  };
1274
3980
  //# sourceMappingURL=index.mjs.map