@signalsandsorcery/plugin-sdk 2.3.1 → 2.25.1

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