@signalsandsorcery/plugin-sdk 2.0.2 → 2.24.1

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