@signalsandsorcery/plugin-sdk 2.38.2 → 2.40.2

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
@@ -39,10 +39,12 @@ __export(index_exports, {
39
39
  DB_MIN: () => DB_MIN,
40
40
  DEFAULT_FX_CATEGORY_DETAIL: () => DEFAULT_FX_CATEGORY_DETAIL,
41
41
  DEFAULT_FX_DRY_WET: () => DEFAULT_FX_DRY_WET,
42
+ DEFAULT_STYLE_FOR_INSTRUMENTATION: () => DEFAULT_STYLE_FOR_INSTRUMENTATION,
42
43
  DRAG_DEAD_ZONE: () => DRAG_DEAD_ZONE,
43
44
  DownloadPackButton: () => DownloadPackButton,
44
45
  EMPTY_FX_DETAIL_STATE: () => EMPTY_FX_DETAIL_STATE,
45
46
  EMPTY_FX_STATE: () => EMPTY_FX_STATE,
47
+ ENSEMBLE_INSTRUMENTATIONS: () => ENSEMBLE_INSTRUMENTATIONS,
46
48
  ENSEMBLE_MAX_VOICES: () => ENSEMBLE_MAX_VOICES,
47
49
  ENSEMBLE_MIN_VOICES: () => ENSEMBLE_MIN_VOICES,
48
50
  ENSEMBLE_STYLES: () => ENSEMBLE_STYLES,
@@ -57,7 +59,9 @@ __export(index_exports, {
57
59
  FxToggleBar: () => FxToggleBar,
58
60
  GUTTER_W: () => GUTTER_W,
59
61
  GeneratorPanelShell: () => GeneratorPanelShell,
62
+ GroupCollapseChevron: () => GroupCollapseChevron,
60
63
  GroupFadeTrackRow: () => GroupFadeTrackRow,
64
+ HORN_MAX_NOTES_PER_BAR: () => HORN_MAX_NOTES_PER_BAR,
61
65
  ImportTrackModal: () => ImportTrackModal,
62
66
  InstrumentDrawer: () => TrackDrawer,
63
67
  LevelMeter: () => LevelMeter,
@@ -71,8 +75,12 @@ __export(index_exports, {
71
75
  PianoRollEditor: () => PianoRollEditor,
72
76
  PluginError: () => PluginError,
73
77
  RESIZE_HANDLE_PX: () => RESIZE_HANDLE_PX,
78
+ RIFF_MAX_ONSET_INDEPENDENCE: () => RIFF_MAX_ONSET_INDEPENDENCE,
74
79
  ROW_HEIGHT: () => ROW_HEIGHT,
75
80
  SLIDER_UNITY: () => SLIDER_UNITY,
81
+ STAB_MAX_NOTE_DURATION_BEATS: () => STAB_MAX_NOTE_DURATION_BEATS,
82
+ STAB_MAX_ONSET_INDEPENDENCE: () => STAB_MAX_ONSET_INDEPENDENCE,
83
+ STYLES_FOR_INSTRUMENTATION: () => STYLES_FOR_INSTRUMENTATION,
76
84
  STYLE_RULES: () => STYLE_RULES,
77
85
  SUBMIT_ENSEMBLE_TOOL_NAME: () => SUBMIT_ENSEMBLE_TOOL_NAME,
78
86
  SamplePackCTACard: () => SamplePackCTACard,
@@ -86,6 +94,7 @@ __export(index_exports, {
86
94
  TrackMeterStrip: () => TrackMeterStrip,
87
95
  TrackRow: () => TrackRow,
88
96
  TransitionDesigner: () => TransitionDesigner,
97
+ UNISON_MAX_ONSET_INDEPENDENCE: () => UNISON_MAX_ONSET_INDEPENDENCE,
89
98
  VolumeSlider: () => VolumeSlider,
90
99
  WaveformView: () => WaveformView,
91
100
  analyzeEnsemble: () => analyzeEnsemble,
@@ -120,6 +129,7 @@ __export(index_exports, {
120
129
  moveItem: () => moveItem,
121
130
  nearestPitchWithPc: () => nearestPitchWithPc,
122
131
  newTrackState: () => newTrackState,
132
+ normalizeInstrumentation: () => normalizeInstrumentation,
123
133
  normalizeSlots: () => normalizeSlots,
124
134
  padPair: () => padPair,
125
135
  padSlots: () => padSlots,
@@ -143,6 +153,7 @@ __export(index_exports, {
143
153
  slotsEqual: () => slotsEqual,
144
154
  soundIdentity: () => soundIdentity,
145
155
  splitFadeEntries: () => splitFadeEntries,
156
+ styleForInstrumentation: () => styleForInstrumentation,
146
157
  synthesizeCuePoints: () => synthesizeCuePoints,
147
158
  tokenizePrompt: () => tokenizePrompt,
148
159
  trackDataKey: () => trackDataKey,
@@ -1369,6 +1380,7 @@ function TrackDrawer({
1369
1380
  onToggleFavorite,
1370
1381
  onImportSound,
1371
1382
  importSoundLabel,
1383
+ linkedSoundHint,
1372
1384
  editNotes,
1373
1385
  onNotesChange,
1374
1386
  editBars,
@@ -1435,7 +1447,7 @@ function TrackDrawer({
1435
1447
  }
1436
1448
  ) : null;
1437
1449
  const currentSound = soundHistoryCursor >= 0 && soundHistoryCursor < history.length ? history[soundHistoryCursor].label : null;
1438
- const header = strip || currentSound ? /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "flex flex-col gap-1", "data-testid": "sdk-drawer-header", children: [
1450
+ const header = strip || currentSound || linkedSoundHint ? /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "flex flex-col gap-1", "data-testid": "sdk-drawer-header", children: [
1439
1451
  strip,
1440
1452
  currentSound && /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
1441
1453
  "span",
@@ -1444,6 +1456,14 @@ function TrackDrawer({
1444
1456
  title: currentSound,
1445
1457
  children: currentSound
1446
1458
  }
1459
+ ),
1460
+ linkedSoundHint && /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
1461
+ "span",
1462
+ {
1463
+ "data-testid": "sdk-drawer-linked-hint",
1464
+ className: "text-[10px] text-sas-accent/80 px-0.5",
1465
+ children: linkedSoundHint
1466
+ }
1447
1467
  )
1448
1468
  ] }) : null;
1449
1469
  if (effectiveTab === "freeze") {
@@ -2679,6 +2699,8 @@ function TrackRow({
2679
2699
  onToggleFavorite,
2680
2700
  onImportSound,
2681
2701
  importSoundLabel,
2702
+ linkedSoundHint,
2703
+ onSyncSoundToGroup,
2682
2704
  editNotes,
2683
2705
  onNotesChange,
2684
2706
  editBars,
@@ -2844,15 +2866,37 @@ function TrackRow({
2844
2866
  )
2845
2867
  ] }),
2846
2868
  /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "flex gap-1 items-center", children: [
2847
- onShuffle && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
2869
+ onShuffle && /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)(
2848
2870
  "button",
2849
2871
  {
2850
2872
  "data-testid": "sdk-shuffle-button",
2851
2873
  onClick: onShuffle,
2852
2874
  disabled: !hasMidi || isGenerating || !!currentInstrumentPluginId,
2853
- 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"}`,
2854
- title: currentInstrumentPluginId ? "Shuffle only works with default Surge XT" : hasMidi ? "Re-roll sound (keep MIDI)" : "Generate MIDI first",
2855
- children: "Shuffle"
2875
+ className: `relative 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"}`,
2876
+ title: currentInstrumentPluginId ? "Shuffle only works with default Surge XT" : hasMidi ? linkedSoundHint ? `Re-roll sound (keep MIDI) \u2014 ${linkedSoundHint}` : "Re-roll sound (keep MIDI)" : "Generate MIDI first",
2877
+ children: [
2878
+ "Shuffle",
2879
+ linkedSoundHint && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
2880
+ "span",
2881
+ {
2882
+ "aria-hidden": true,
2883
+ "data-testid": "sdk-shuffle-linked-marker",
2884
+ className: "absolute -top-1 -right-1 text-[8px] leading-none",
2885
+ children: "\u{1F517}"
2886
+ }
2887
+ )
2888
+ ]
2889
+ }
2890
+ ),
2891
+ onSyncSoundToGroup && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
2892
+ "button",
2893
+ {
2894
+ "data-testid": "sdk-sync-sound-button",
2895
+ onClick: onSyncSoundToGroup,
2896
+ disabled: isGenerating,
2897
+ className: `px-1.5 py-0.5 rounded-sm text-xs font-medium transition-colors border whitespace-nowrap ${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"}`,
2898
+ title: "Apply THIS part's current sound to all linked parts \u2014 use after picking a patch inside the plugin's own editor",
2899
+ children: "\u2192 All"
2856
2900
  }
2857
2901
  ),
2858
2902
  freezeBadge && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
@@ -2936,6 +2980,7 @@ function TrackRow({
2936
2980
  onToggleFavorite,
2937
2981
  onImportSound,
2938
2982
  importSoundLabel,
2983
+ linkedSoundHint,
2939
2984
  editNotes,
2940
2985
  onNotesChange,
2941
2986
  editBars,
@@ -5163,9 +5208,38 @@ function TransitionDesigner({
5163
5208
  ] });
5164
5209
  }
5165
5210
 
5211
+ // src/components/GroupCollapseChevron.tsx
5212
+ var import_lucide_react2 = require("lucide-react");
5213
+ var import_jsx_runtime21 = require("react/jsx-runtime");
5214
+ function GroupCollapseChevron({
5215
+ collapsed = false,
5216
+ onToggle,
5217
+ what = "group"
5218
+ }) {
5219
+ if (!onToggle) return null;
5220
+ return /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
5221
+ "button",
5222
+ {
5223
+ type: "button",
5224
+ "data-testid": "sdk-group-collapse",
5225
+ onClick: onToggle,
5226
+ "aria-expanded": !collapsed,
5227
+ title: collapsed ? `Expand ${what}` : `Collapse ${what}`,
5228
+ className: "shrink-0 px-0.5 py-0.5 rounded-sm text-sas-muted hover:text-sas-accent transition-colors",
5229
+ children: /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
5230
+ import_lucide_react2.ChevronDown,
5231
+ {
5232
+ className: `w-3 h-3 transition-transform ${collapsed ? "-rotate-90" : ""}`,
5233
+ strokeWidth: 2.5
5234
+ }
5235
+ )
5236
+ }
5237
+ );
5238
+ }
5239
+
5166
5240
  // src/components/PanelMasterStrip.tsx
5167
5241
  var import_react21 = require("react");
5168
- var import_jsx_runtime21 = require("react/jsx-runtime");
5242
+ var import_jsx_runtime22 = require("react/jsx-runtime");
5169
5243
  function PanelMasterStrip({
5170
5244
  bus,
5171
5245
  levels = null,
@@ -5192,14 +5266,14 @@ function PanelMasterStrip({
5192
5266
  (fx) => fx.name.toLowerCase().includes(q) || fx.manufacturer.toLowerCase().includes(q)
5193
5267
  );
5194
5268
  }, [availableFx, search]);
5195
- return /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)(
5269
+ return /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)(
5196
5270
  "div",
5197
5271
  {
5198
5272
  "data-testid": "panel-master-strip",
5199
5273
  className: `flex flex-col gap-1 px-2 py-1.5 rounded-sm border border-sas-border border-l-2 border-l-sas-accent/50 bg-sas-accent/5 transition-opacity ${soloedOut ? "opacity-40" : ""}`,
5200
5274
  children: [
5201
- /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("div", { className: "flex items-center gap-2", children: [
5202
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
5275
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)("div", { className: "flex items-center gap-2", children: [
5276
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
5203
5277
  "span",
5204
5278
  {
5205
5279
  className: "text-[9px] font-bold tracking-widest text-sas-muted/70 select-none",
@@ -5207,8 +5281,8 @@ function PanelMasterStrip({
5207
5281
  children: "BUS"
5208
5282
  }
5209
5283
  ),
5210
- /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("div", { className: "flex-1 min-w-[8rem] flex flex-col gap-0.5", children: [
5211
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
5284
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)("div", { className: "flex-1 min-w-[8rem] flex flex-col gap-0.5", children: [
5285
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
5212
5286
  VolumeSlider,
5213
5287
  {
5214
5288
  value: dbToSlider(bus.volume),
@@ -5216,8 +5290,8 @@ function PanelMasterStrip({
5216
5290
  disabled
5217
5291
  }
5218
5292
  ),
5219
- /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("div", { className: "flex flex-col gap-px", "data-testid": "bus-meter", children: [
5220
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
5293
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)("div", { className: "flex flex-col gap-px", "data-testid": "bus-meter", children: [
5294
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
5221
5295
  LevelMeter,
5222
5296
  {
5223
5297
  peakDb: levels?.leftDb ?? -120,
@@ -5227,7 +5301,7 @@ function PanelMasterStrip({
5227
5301
  "data-testid": "bus-meter-left"
5228
5302
  }
5229
5303
  ),
5230
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
5304
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
5231
5305
  LevelMeter,
5232
5306
  {
5233
5307
  peakDb: levels?.rightDb ?? -120,
@@ -5238,7 +5312,7 @@ function PanelMasterStrip({
5238
5312
  )
5239
5313
  ] })
5240
5314
  ] }),
5241
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
5315
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
5242
5316
  "button",
5243
5317
  {
5244
5318
  "data-testid": "bus-mute-button",
@@ -5249,7 +5323,7 @@ function PanelMasterStrip({
5249
5323
  children: "M"
5250
5324
  }
5251
5325
  ),
5252
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
5326
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
5253
5327
  "button",
5254
5328
  {
5255
5329
  "data-testid": "bus-solo-button",
@@ -5260,14 +5334,14 @@ function PanelMasterStrip({
5260
5334
  children: "S"
5261
5335
  }
5262
5336
  ),
5263
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("div", { className: "flex items-center gap-1 max-w-[45%] min-w-0 overflow-x-auto", children: bus.fx.map((fx) => /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)(
5337
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)("div", { className: "flex items-center gap-1 max-w-[45%] min-w-0 overflow-x-auto", children: bus.fx.map((fx) => /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)(
5264
5338
  "span",
5265
5339
  {
5266
5340
  "data-testid": `bus-fx-chip-${fx.index}`,
5267
5341
  className: `flex items-center gap-1 px-1.5 py-0.5 rounded-sm border text-[10px] whitespace-nowrap ${fx.enabled ? "border-sas-accent/60 text-sas-accent bg-sas-accent/10" : "border-sas-border text-sas-muted/50 bg-sas-panel"}`,
5268
5342
  title: `${fx.name}${fx.enabled ? "" : " (bypassed)"}`,
5269
5343
  children: [
5270
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
5344
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
5271
5345
  "button",
5272
5346
  {
5273
5347
  "data-testid": `bus-fx-toggle-${fx.index}`,
@@ -5278,7 +5352,7 @@ function PanelMasterStrip({
5278
5352
  children: fx.enabled ? "\u25CF" : "\u25CB"
5279
5353
  }
5280
5354
  ),
5281
- onShowFxEditor ? /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
5355
+ onShowFxEditor ? /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
5282
5356
  "button",
5283
5357
  {
5284
5358
  "data-testid": `bus-fx-edit-${fx.index}`,
@@ -5288,8 +5362,8 @@ function PanelMasterStrip({
5288
5362
  title: `Open ${fx.name} editor`,
5289
5363
  children: fx.name
5290
5364
  }
5291
- ) : /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("span", { className: "max-w-[80px] truncate", children: fx.name }),
5292
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
5365
+ ) : /* @__PURE__ */ (0, import_jsx_runtime22.jsx)("span", { className: "max-w-[80px] truncate", children: fx.name }),
5366
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
5293
5367
  "button",
5294
5368
  {
5295
5369
  "data-testid": `bus-fx-remove-${fx.index}`,
@@ -5304,7 +5378,7 @@ function PanelMasterStrip({
5304
5378
  },
5305
5379
  `${fx.index}:${fx.pluginId}`
5306
5380
  )) }),
5307
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
5381
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
5308
5382
  "button",
5309
5383
  {
5310
5384
  "data-testid": "bus-fx-add-button",
@@ -5316,9 +5390,9 @@ function PanelMasterStrip({
5316
5390
  }
5317
5391
  )
5318
5392
  ] }),
5319
- fxPickerOpen && /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("div", { "data-testid": "bus-fx-picker", className: "flex flex-col gap-2 pt-1", children: [
5320
- /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("div", { className: "flex items-center gap-2", children: [
5321
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
5393
+ fxPickerOpen && /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)("div", { "data-testid": "bus-fx-picker", className: "flex flex-col gap-2 pt-1", children: [
5394
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)("div", { className: "flex items-center gap-2", children: [
5395
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
5322
5396
  "input",
5323
5397
  {
5324
5398
  type: "text",
@@ -5328,7 +5402,7 @@ function PanelMasterStrip({
5328
5402
  className: "sas-input flex-1 px-2 py-1 text-xs"
5329
5403
  }
5330
5404
  ),
5331
- onRefreshFx && /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
5405
+ onRefreshFx && /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
5332
5406
  "button",
5333
5407
  {
5334
5408
  onClick: () => onRefreshFx(),
@@ -5339,8 +5413,8 @@ function PanelMasterStrip({
5339
5413
  }
5340
5414
  )
5341
5415
  ] }),
5342
- fxLoading && availableFx.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("div", { className: "text-xs text-sas-muted/60 text-center py-3", children: "Scanning plugins..." }) : /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("div", { className: "grid grid-cols-3 gap-1 max-h-[140px] overflow-y-auto", children: [
5343
- filtered.map((fx) => /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)(
5416
+ fxLoading && availableFx.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime22.jsx)("div", { className: "text-xs text-sas-muted/60 text-center py-3", children: "Scanning plugins..." }) : /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)("div", { className: "grid grid-cols-3 gap-1 max-h-[140px] overflow-y-auto", children: [
5417
+ filtered.map((fx) => /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)(
5344
5418
  "button",
5345
5419
  {
5346
5420
  "data-testid": `bus-fx-pick-${fx.pluginId}`,
@@ -5348,13 +5422,13 @@ function PanelMasterStrip({
5348
5422
  className: "flex flex-col items-start px-2 py-1.5 rounded-sm border text-left transition-colors border-sas-border bg-sas-panel-alt text-sas-muted hover:border-sas-accent hover:text-sas-accent",
5349
5423
  title: `${fx.name} by ${fx.manufacturer} (${fx.type.toUpperCase()})`,
5350
5424
  children: [
5351
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("span", { className: "text-xs font-medium truncate w-full", children: fx.name }),
5352
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("span", { className: "text-[9px] text-sas-muted/50 truncate w-full", children: fx.manufacturer || fx.type.toUpperCase() })
5425
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)("span", { className: "text-xs font-medium truncate w-full", children: fx.name }),
5426
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)("span", { className: "text-[9px] text-sas-muted/50 truncate w-full", children: fx.manufacturer || fx.type.toUpperCase() })
5353
5427
  ]
5354
5428
  },
5355
5429
  fx.pluginId
5356
5430
  )),
5357
- filtered.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("div", { className: "col-span-3 text-xs text-sas-muted/60 text-center py-2", children: search.trim() ? "No matches" : "No FX plugins found" })
5431
+ filtered.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime22.jsx)("div", { className: "col-span-3 text-xs text-sas-muted/60 text-center py-2", children: search.trim() ? "No matches" : "No FX plugins found" })
5358
5432
  ] })
5359
5433
  ] })
5360
5434
  ]
@@ -5481,7 +5555,7 @@ function usePanelBus(host, activeSceneId) {
5481
5555
 
5482
5556
  // src/components/DownloadPackButton.tsx
5483
5557
  var import_react23 = require("react");
5484
- var import_jsx_runtime22 = require("react/jsx-runtime");
5558
+ var import_jsx_runtime23 = require("react/jsx-runtime");
5485
5559
  function formatSize(bytes) {
5486
5560
  if (!bytes || bytes <= 0) return "";
5487
5561
  const gb = bytes / 1024 ** 3;
@@ -5569,8 +5643,8 @@ var DownloadPackButton = ({
5569
5643
  } else {
5570
5644
  className = `${baseClasses} text-sas-muted hover:text-sas-accent border-sas-border hover:border-sas-accent`;
5571
5645
  }
5572
- return /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)("div", { children: [
5573
- /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
5646
+ return /* @__PURE__ */ (0, import_jsx_runtime23.jsxs)("div", { children: [
5647
+ /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(
5574
5648
  "button",
5575
5649
  {
5576
5650
  "data-testid": `download-pack-button-${packId}`,
@@ -5581,12 +5655,12 @@ var DownloadPackButton = ({
5581
5655
  children: buttonLabel
5582
5656
  }
5583
5657
  ),
5584
- variant === "large" && status === "error" && errorMessage && /* @__PURE__ */ (0, import_jsx_runtime22.jsx)("div", { className: "text-xs text-sas-danger mt-2", "data-testid": `download-pack-error-${packId}`, children: errorMessage })
5658
+ variant === "large" && status === "error" && errorMessage && /* @__PURE__ */ (0, import_jsx_runtime23.jsx)("div", { className: "text-xs text-sas-danger mt-2", "data-testid": `download-pack-error-${packId}`, children: errorMessage })
5585
5659
  ] });
5586
5660
  };
5587
5661
 
5588
5662
  // src/components/SamplePackCTACard.tsx
5589
- var import_jsx_runtime23 = require("react/jsx-runtime");
5663
+ var import_jsx_runtime24 = require("react/jsx-runtime");
5590
5664
  var SamplePackCTACard = ({
5591
5665
  host,
5592
5666
  pack,
@@ -5594,7 +5668,7 @@ var SamplePackCTACard = ({
5594
5668
  onDownloadComplete
5595
5669
  }) => {
5596
5670
  if (status === "checking") {
5597
- return /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(
5671
+ return /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
5598
5672
  "div",
5599
5673
  {
5600
5674
  "data-testid": `sample-pack-cta-checking-${pack.packId}`,
@@ -5605,16 +5679,16 @@ var SamplePackCTACard = ({
5605
5679
  }
5606
5680
  const headline = status === "stale" ? `${pack.displayName} update available` : `${pack.displayName} not installed`;
5607
5681
  const sublabel = status === "stale" ? `A newer version is available for download.` : pack.description;
5608
- return /* @__PURE__ */ (0, import_jsx_runtime23.jsxs)(
5682
+ return /* @__PURE__ */ (0, import_jsx_runtime24.jsxs)(
5609
5683
  "div",
5610
5684
  {
5611
5685
  "data-testid": `sample-pack-cta-${pack.packId}`,
5612
5686
  className: "flex flex-col items-center justify-center py-12 px-6 text-center",
5613
5687
  children: [
5614
- /* @__PURE__ */ (0, import_jsx_runtime23.jsx)("div", { className: "text-sm uppercase tracking-wide text-sas-muted mb-2", children: status === "stale" ? "Update available" : "Sample library not installed" }),
5615
- /* @__PURE__ */ (0, import_jsx_runtime23.jsx)("div", { className: "text-base text-sas-text mb-1", children: headline }),
5616
- /* @__PURE__ */ (0, import_jsx_runtime23.jsx)("div", { className: "text-xs text-sas-muted mb-6 max-w-md", children: sublabel }),
5617
- /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(
5688
+ /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("div", { className: "text-sm uppercase tracking-wide text-sas-muted mb-2", children: status === "stale" ? "Update available" : "Sample library not installed" }),
5689
+ /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("div", { className: "text-base text-sas-text mb-1", children: headline }),
5690
+ /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("div", { className: "text-xs text-sas-muted mb-6 max-w-md", children: sublabel }),
5691
+ /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
5618
5692
  DownloadPackButton,
5619
5693
  {
5620
5694
  host,
@@ -5694,7 +5768,7 @@ function drawWaveform(canvas, peaks, options = {}) {
5694
5768
  }
5695
5769
 
5696
5770
  // src/components/WaveformView.tsx
5697
- var import_jsx_runtime24 = require("react/jsx-runtime");
5771
+ var import_jsx_runtime25 = require("react/jsx-runtime");
5698
5772
  var WaveformView = ({
5699
5773
  host,
5700
5774
  filePath,
@@ -5742,7 +5816,7 @@ var WaveformView = ({
5742
5816
  observer.observe(canvas);
5743
5817
  return () => observer.disconnect();
5744
5818
  }, [peaks, fillStyle]);
5745
- return /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
5819
+ return /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(
5746
5820
  "canvas",
5747
5821
  {
5748
5822
  ref: canvasRef,
@@ -5754,7 +5828,7 @@ var WaveformView = ({
5754
5828
 
5755
5829
  // src/components/ScrollingWaveform.tsx
5756
5830
  var import_react25 = require("react");
5757
- var import_jsx_runtime25 = require("react/jsx-runtime");
5831
+ var import_jsx_runtime26 = require("react/jsx-runtime");
5758
5832
  var ScrollingWaveform = ({
5759
5833
  getPeakDb,
5760
5834
  active,
@@ -5830,7 +5904,7 @@ var ScrollingWaveform = ({
5830
5904
  }
5831
5905
  };
5832
5906
  }, [active, getPeakDb, fillStyle]);
5833
- return /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(
5907
+ return /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(
5834
5908
  "canvas",
5835
5909
  {
5836
5910
  ref: canvasRef,
@@ -5842,7 +5916,7 @@ var ScrollingWaveform = ({
5842
5916
 
5843
5917
  // src/components/OffsetScrubber.tsx
5844
5918
  var import_react26 = require("react");
5845
- var import_jsx_runtime26 = require("react/jsx-runtime");
5919
+ var import_jsx_runtime27 = require("react/jsx-runtime");
5846
5920
  var SLIDER_HEIGHT_PX = 28;
5847
5921
  var TICK_HEIGHT_PX = 14;
5848
5922
  var DOWNBEAT_TICK_HEIGHT_PX = 22;
@@ -5957,9 +6031,9 @@ function OffsetScrubber({
5957
6031
  });
5958
6032
  }, [cuePoints, sampleToFraction]);
5959
6033
  const isDisabled = disabled || !cuePoints || cuePoints.beats.length === 0;
5960
- return /* @__PURE__ */ (0, import_jsx_runtime26.jsxs)("div", { "data-testid": "offset-scrubber", className: "flex items-center gap-2 w-full", children: [
5961
- /* @__PURE__ */ (0, import_jsx_runtime26.jsx)("span", { className: "text-[9px] text-sas-muted/60 uppercase tracking-wide flex-shrink-0", children: "Align" }),
5962
- /* @__PURE__ */ (0, import_jsx_runtime26.jsxs)(
6034
+ return /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)("div", { "data-testid": "offset-scrubber", className: "flex items-center gap-2 w-full", children: [
6035
+ /* @__PURE__ */ (0, import_jsx_runtime27.jsx)("span", { className: "text-[9px] text-sas-muted/60 uppercase tracking-wide flex-shrink-0", children: "Align" }),
6036
+ /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)(
5963
6037
  "div",
5964
6038
  {
5965
6039
  ref: trackRef,
@@ -5975,7 +6049,7 @@ function OffsetScrubber({
5975
6049
  "aria-valuenow": draftOffset,
5976
6050
  "aria-disabled": isDisabled,
5977
6051
  children: [
5978
- /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(
6052
+ /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
5979
6053
  "div",
5980
6054
  {
5981
6055
  "aria-hidden": "true",
@@ -5983,7 +6057,7 @@ function OffsetScrubber({
5983
6057
  style: { left: "50%" }
5984
6058
  }
5985
6059
  ),
5986
- ticks.map((t) => /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(
6060
+ ticks.map((t) => /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
5987
6061
  "div",
5988
6062
  {
5989
6063
  "data-testid": t.isDownbeat ? "offset-tick-downbeat" : "offset-tick",
@@ -5998,7 +6072,7 @@ function OffsetScrubber({
5998
6072
  },
5999
6073
  t.i
6000
6074
  )),
6001
- /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(
6075
+ /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
6002
6076
  "div",
6003
6077
  {
6004
6078
  "data-testid": "offset-scrubber-thumb",
@@ -6015,7 +6089,7 @@ function OffsetScrubber({
6015
6089
  ]
6016
6090
  }
6017
6091
  ),
6018
- /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(
6092
+ /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
6019
6093
  "span",
6020
6094
  {
6021
6095
  "data-testid": "offset-scrubber-readout",
@@ -6023,7 +6097,7 @@ function OffsetScrubber({
6023
6097
  children: formatOffset(draftOffset, sampleRate)
6024
6098
  }
6025
6099
  ),
6026
- /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(
6100
+ /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
6027
6101
  "button",
6028
6102
  {
6029
6103
  type: "button",
@@ -6035,7 +6109,7 @@ function OffsetScrubber({
6035
6109
  children: "\u2316"
6036
6110
  }
6037
6111
  ),
6038
- bpmMismatch && /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(
6112
+ bpmMismatch && /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
6039
6113
  "span",
6040
6114
  {
6041
6115
  "data-testid": "offset-bpm-mismatch",
@@ -7048,7 +7122,7 @@ function useTransitionOps({
7048
7122
  }
7049
7123
 
7050
7124
  // src/panel-core/useGeneratorPanelCore.tsx
7051
- var import_jsx_runtime27 = require("react/jsx-runtime");
7125
+ var import_jsx_runtime28 = require("react/jsx-runtime");
7052
7126
  var EMPTY_PLACEHOLDERS = [];
7053
7127
  function useGeneratorPanelCore({
7054
7128
  ui,
@@ -7459,37 +7533,6 @@ function useGeneratorPanelCore({
7459
7533
  },
7460
7534
  [host, adapter, identity, activeSceneId, isConnected, tracks.length, loadTracks]
7461
7535
  );
7462
- const handleSoundImportPick = (0, import_react31.useCallback)(
7463
- async (sel) => {
7464
- const target = soundImportTarget;
7465
- if (!target || !host.getTrackSound) {
7466
- setSoundImportTarget(null);
7467
- return;
7468
- }
7469
- const noun = adapter.sound.importNoun;
7470
- const nounTitle = noun.charAt(0).toUpperCase() + noun.slice(1);
7471
- try {
7472
- const snap = await host.getTrackSound(sel.sourceTrackDbId);
7473
- if (!snap || snap.kind !== adapter.sound.acceptedSnapshotKind) {
7474
- host.showToast(
7475
- "error",
7476
- `No ${noun} to import`,
7477
- `${sel.trackName} has no ${identity.familyKey} ${noun}.`
7478
- );
7479
- return;
7480
- }
7481
- const descriptor = adapter.sound.descriptorFromSnapshot(snap);
7482
- await adapter.sound.applySound(target.handle.id, descriptor);
7483
- soundHistory.record(target.handle.id, descriptor, snap.label);
7484
- host.showToast("success", `${nounTitle} imported`, `${snap.label} \u2192 ${target.handle.name}`);
7485
- } catch (err) {
7486
- host.showToast("error", "Import failed", err instanceof Error ? err.message : String(err));
7487
- } finally {
7488
- setSoundImportTarget(null);
7489
- }
7490
- },
7491
- [soundImportTarget, host, adapter, identity.familyKey, soundHistory]
7492
- );
7493
7536
  const [isExportingMidi, setIsExportingMidi] = (0, import_react31.useState)(false);
7494
7537
  const handleExportMidi = (0, import_react31.useCallback)(async () => {
7495
7538
  if (isExportingMidi) return;
@@ -7545,8 +7588,8 @@ function useGeneratorPanelCore({
7545
7588
  if (!onHeaderContent) return;
7546
7589
  const addDisabled = needsContract || !isConnected || !activeSceneId || tracks.length >= identity.maxTracks || isAddingTrack;
7547
7590
  onHeaderContent(
7548
- /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)("div", { className: "flex gap-1 items-center", children: [
7549
- features.importTracks && (!canCrossfade || !designerView) && host.listImportableTracks && /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
7591
+ /* @__PURE__ */ (0, import_jsx_runtime28.jsxs)("div", { className: "flex gap-1 items-center", children: [
7592
+ features.importTracks && (!canCrossfade || !designerView) && host.listImportableTracks && /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
7550
7593
  "button",
7551
7594
  {
7552
7595
  "data-testid": `import-from-scene-${identity.familyKey}-button`,
@@ -7560,7 +7603,7 @@ function useGeneratorPanelCore({
7560
7603
  children: identity.importTrackLabel ?? "Import Track"
7561
7604
  }
7562
7605
  ),
7563
- (!canCrossfade || !designerView) && /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
7606
+ (!canCrossfade || !designerView) && /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
7564
7607
  "button",
7565
7608
  {
7566
7609
  "data-testid": `add-${identity.familyKey}-track-button`,
@@ -7576,7 +7619,7 @@ function useGeneratorPanelCore({
7576
7619
  children: identity.addTrackLabel ?? "Add Track"
7577
7620
  }
7578
7621
  ),
7579
- canCrossfade && /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)(
7622
+ canCrossfade && /* @__PURE__ */ (0, import_jsx_runtime28.jsxs)(
7580
7623
  "button",
7581
7624
  {
7582
7625
  "data-testid": `${identity.familyKey}-view-toggle`,
@@ -7595,7 +7638,7 @@ function useGeneratorPanelCore({
7595
7638
  title: designerView ? "Back to the track list" : "Open the transition designer",
7596
7639
  className: "relative overflow-hidden px-2 py-0.5 text-[10px] font-medium rounded-sm border border-sas-accent/40 text-sas-accent transition-colors hover:border-sas-accent disabled:opacity-50",
7597
7640
  children: [
7598
- transitionSourceTotal > 0 && /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
7641
+ transitionSourceTotal > 0 && /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
7599
7642
  "span",
7600
7643
  {
7601
7644
  className: "absolute inset-y-0 left-0 bg-sas-accent/25",
@@ -7603,7 +7646,7 @@ function useGeneratorPanelCore({
7603
7646
  "aria-hidden": true
7604
7647
  }
7605
7648
  ),
7606
- /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)("span", { className: "relative", children: [
7649
+ /* @__PURE__ */ (0, import_jsx_runtime28.jsxs)("span", { className: "relative", children: [
7607
7650
  "\u21C4 ",
7608
7651
  designerView ? "Transition" : "Tracks",
7609
7652
  transitionSourceTotal > 0 ? ` ${transitionDone}/${transitionSourceTotal}` : ""
@@ -7736,9 +7779,183 @@ function useGeneratorPanelCore({
7736
7779
  name: `${identity.trackNamePrefix}-${Date.now()}${nameSuffix}`,
7737
7780
  ...adapter.createTrackOptions()
7738
7781
  }),
7739
- resolvedGroups: (metaKey) => resolvedGenericGroupsRef.current[metaKey]?.resolved ?? []
7782
+ resolvedGroups: (metaKey) => resolvedGenericGroupsRef.current[metaKey]?.resolved ?? [],
7783
+ sound: adapter.sound
7740
7784
  };
7741
7785
  }, [host, activeSceneId, updateTrack, loadTracks, soundHistory, engineToDbId, markEditLoaded, identity, adapter]);
7786
+ const broadcastSoundFromTrack = (0, import_react31.useCallback)(
7787
+ async (sourceTrackId, descriptor, label) => {
7788
+ const targetsOf = adapter.sound.broadcastTargets;
7789
+ if (!targetsOf) return;
7790
+ const source = tracksRef.current.find((t) => t.handle.id === sourceTrackId);
7791
+ if (!source) return;
7792
+ let targets;
7793
+ try {
7794
+ targets = await targetsOf(source, makeServices());
7795
+ } catch (err) {
7796
+ console.warn(`[${logTag}] broadcastTargets failed:`, err);
7797
+ return;
7798
+ }
7799
+ const sourceInstrument = source.instrumentPluginId ?? null;
7800
+ const others = (targets ?? []).filter((t) => {
7801
+ if (t.engineId === sourceTrackId) return false;
7802
+ const live = tracksRef.current.find((x) => x.handle.id === t.engineId);
7803
+ return (live?.instrumentPluginId ?? null) === sourceInstrument;
7804
+ });
7805
+ if (others.length === 0) return;
7806
+ try {
7807
+ await adapter.sound.persistDescriptor?.(sourceTrackId, descriptor, label);
7808
+ } catch {
7809
+ }
7810
+ const appliedIds = /* @__PURE__ */ new Set();
7811
+ const failed = [];
7812
+ for (const target of others) {
7813
+ try {
7814
+ if (soundHistory.list(target.engineId).entries.length === 0) {
7815
+ try {
7816
+ const cap = await adapter.sound.captureSoundDescriptor(target.engineId);
7817
+ if (cap) {
7818
+ soundHistory.record(target.engineId, cap.descriptor, adapter.sound.previousSoundLabel);
7819
+ }
7820
+ } catch {
7821
+ }
7822
+ }
7823
+ await adapter.sound.applySound(target.engineId, descriptor);
7824
+ try {
7825
+ await adapter.sound.persistDescriptor?.(target.engineId, descriptor, label);
7826
+ } catch {
7827
+ }
7828
+ soundHistory.record(target.engineId, descriptor, label);
7829
+ appliedIds.add(target.engineId);
7830
+ } catch (err) {
7831
+ failed.push(target.label ?? target.engineId);
7832
+ console.warn(`[${logTag}] Linked sound apply failed for ${target.engineId}:`, err);
7833
+ }
7834
+ }
7835
+ if (appliedIds.size > 0) {
7836
+ setTracks(
7837
+ (prev) => prev.map(
7838
+ (t) => appliedIds.has(t.handle.id) ? { ...t, shuffleHistory: /* @__PURE__ */ new Set([...t.shuffleHistory, label]) } : t
7839
+ )
7840
+ );
7841
+ }
7842
+ if (failed.length > 0) {
7843
+ host.showToast(
7844
+ "warning",
7845
+ "Linked sound applied to some parts only",
7846
+ `Skipped: ${failed.join(", ")}`
7847
+ );
7848
+ }
7849
+ },
7850
+ [adapter, makeServices, soundHistory, host, logTag]
7851
+ );
7852
+ const broadcastInstrumentFromTrack = (0, import_react31.useCallback)(
7853
+ async (sourceTrackId, pluginId) => {
7854
+ const targetsOf = adapter.sound.broadcastTargets;
7855
+ if (!targetsOf) return;
7856
+ const source = tracksRef.current.find((t) => t.handle.id === sourceTrackId);
7857
+ if (!source) return;
7858
+ let targets;
7859
+ try {
7860
+ targets = await targetsOf(source, makeServices());
7861
+ } catch (err) {
7862
+ console.warn(`[${logTag}] broadcastTargets failed:`, err);
7863
+ return;
7864
+ }
7865
+ const others = (targets ?? []).filter((t) => t.engineId !== sourceTrackId);
7866
+ if (others.length === 0) return;
7867
+ const failed = [];
7868
+ for (const target of others) {
7869
+ try {
7870
+ await host.setTrackInstrument(target.engineId, pluginId);
7871
+ const descriptor = await host.getTrackInstrument(target.engineId);
7872
+ setTracks(
7873
+ (prev) => prev.map(
7874
+ (t) => t.handle.id === target.engineId ? {
7875
+ ...t,
7876
+ instrumentPluginId: descriptor?.pluginId ?? null,
7877
+ instrumentName: descriptor?.name ?? null,
7878
+ instrumentMissing: descriptor?.missing ?? false
7879
+ } : t
7880
+ )
7881
+ );
7882
+ } catch (err) {
7883
+ failed.push(target.label ?? target.engineId);
7884
+ console.warn(`[${logTag}] Linked instrument apply failed for ${target.engineId}:`, err);
7885
+ }
7886
+ }
7887
+ if (failed.length > 0) {
7888
+ host.showToast(
7889
+ "warning",
7890
+ "Instrument applied to some parts only",
7891
+ `Skipped: ${failed.join(", ")}`
7892
+ );
7893
+ }
7894
+ },
7895
+ [adapter, makeServices, host, logTag]
7896
+ );
7897
+ const handleSyncSoundToGroup = (0, import_react31.useCallback)(
7898
+ async (trackId) => {
7899
+ const track = tracksRef.current.find((t) => t.handle.id === trackId);
7900
+ if (!track) return;
7901
+ let cap = null;
7902
+ try {
7903
+ cap = await adapter.sound.captureSoundDescriptor(trackId);
7904
+ } catch {
7905
+ cap = null;
7906
+ }
7907
+ if (!cap) {
7908
+ host.showToast("warning", "Nothing to sync", "This track has no sound to copy yet.");
7909
+ return;
7910
+ }
7911
+ const hist = soundHistory.list(trackId);
7912
+ const label = (hist.cursor >= 0 ? hist.entries[hist.cursor]?.label : void 0) ?? track.instrumentName ?? "Current sound";
7913
+ await broadcastSoundFromTrack(trackId, cap.descriptor, label);
7914
+ },
7915
+ [adapter, soundHistory, host, broadcastSoundFromTrack]
7916
+ );
7917
+ const handleRestoreSound = (0, import_react31.useCallback)(
7918
+ async (trackId, index) => {
7919
+ const entry = soundHistory.list(trackId).entries[index];
7920
+ const moved = await soundHistory.restoreTo(trackId, index);
7921
+ if (moved && entry) {
7922
+ await broadcastSoundFromTrack(trackId, entry.descriptor, entry.label);
7923
+ }
7924
+ },
7925
+ [soundHistory, broadcastSoundFromTrack]
7926
+ );
7927
+ const handleSoundImportPick = (0, import_react31.useCallback)(
7928
+ async (sel) => {
7929
+ const target = soundImportTarget;
7930
+ if (!target || !host.getTrackSound) {
7931
+ setSoundImportTarget(null);
7932
+ return;
7933
+ }
7934
+ const noun = adapter.sound.importNoun;
7935
+ const nounTitle = noun.charAt(0).toUpperCase() + noun.slice(1);
7936
+ try {
7937
+ const snap = await host.getTrackSound(sel.sourceTrackDbId);
7938
+ if (!snap || snap.kind !== adapter.sound.acceptedSnapshotKind) {
7939
+ host.showToast(
7940
+ "error",
7941
+ `No ${noun} to import`,
7942
+ `${sel.trackName} has no ${identity.familyKey} ${noun}.`
7943
+ );
7944
+ return;
7945
+ }
7946
+ const descriptor = adapter.sound.descriptorFromSnapshot(snap);
7947
+ await adapter.sound.applySound(target.handle.id, descriptor);
7948
+ soundHistory.record(target.handle.id, descriptor, snap.label);
7949
+ host.showToast("success", `${nounTitle} imported`, `${snap.label} \u2192 ${target.handle.name}`);
7950
+ await broadcastSoundFromTrack(target.handle.id, descriptor, snap.label);
7951
+ } catch (err) {
7952
+ host.showToast("error", "Import failed", err instanceof Error ? err.message : String(err));
7953
+ } finally {
7954
+ setSoundImportTarget(null);
7955
+ }
7956
+ },
7957
+ [soundImportTarget, host, adapter, identity.familyKey, soundHistory, broadcastSoundFromTrack]
7958
+ );
7742
7959
  const handleGenerate = (0, import_react31.useCallback)(
7743
7960
  async (trackId) => {
7744
7961
  const track = tracks.find((t) => t.handle.id === trackId);
@@ -7857,7 +8074,10 @@ function useGeneratorPanelCore({
7857
8074
  );
7858
8075
  try {
7859
8076
  const cap = await adapter.sound.captureSoundDescriptor(trackId);
7860
- if (cap) soundHistory.record(trackId, cap.descriptor, result.appliedName);
8077
+ if (cap) {
8078
+ soundHistory.record(trackId, cap.descriptor, result.appliedName);
8079
+ await broadcastSoundFromTrack(trackId, cap.descriptor, result.appliedName);
8080
+ }
7861
8081
  } catch {
7862
8082
  }
7863
8083
  console.log(`[${logTag}] Sound shuffled: ${result.appliedName} (history ${nextHistory.size})`);
@@ -7866,7 +8086,7 @@ function useGeneratorPanelCore({
7866
8086
  host.showToast("error", "Shuffle failed", msg);
7867
8087
  }
7868
8088
  },
7869
- [host, adapter, tracks, soundHistory, logTag]
8089
+ [host, adapter, tracks, soundHistory, logTag, broadcastSoundFromTrack]
7870
8090
  );
7871
8091
  const handleCopy = (0, import_react31.useCallback)(
7872
8092
  async (trackId) => {
@@ -8069,6 +8289,7 @@ function useGeneratorPanelCore({
8069
8289
  } : t
8070
8290
  )
8071
8291
  );
8292
+ await broadcastInstrumentFromTrack(trackId, pluginId);
8072
8293
  } catch (err) {
8073
8294
  const msg = err instanceof Error ? err.message : "Failed to load instrument";
8074
8295
  host.showToast("error", "Instrument load failed", msg);
@@ -8091,6 +8312,7 @@ function useGeneratorPanelCore({
8091
8312
  } : t
8092
8313
  )
8093
8314
  );
8315
+ await broadcastInstrumentFromTrack(trackId, pluginId);
8094
8316
  } catch (err) {
8095
8317
  const msg = err instanceof Error ? err.message : "Failed to load instrument";
8096
8318
  console.error(`[${logTag}] Failed to set instrument:`, err);
@@ -8100,7 +8322,7 @@ function useGeneratorPanelCore({
8100
8322
  );
8101
8323
  }
8102
8324
  },
8103
- [host, identity.defaultInstrumentPluginId, logTag]
8325
+ [host, identity.defaultInstrumentPluginId, logTag, broadcastInstrumentFromTrack]
8104
8326
  );
8105
8327
  const handleShowEditor = (0, import_react31.useCallback)(
8106
8328
  async (trackId) => {
@@ -8233,6 +8455,9 @@ function useGeneratorPanelCore({
8233
8455
  shuffle: (trackId) => {
8234
8456
  void handleShuffle(trackId);
8235
8457
  },
8458
+ syncSoundToGroup: (trackId) => {
8459
+ void handleSyncSoundToGroup(trackId);
8460
+ },
8236
8461
  copy: (trackId) => {
8237
8462
  void handleCopy(trackId);
8238
8463
  },
@@ -8253,6 +8478,7 @@ function useGeneratorPanelCore({
8253
8478
  handlePromptChange,
8254
8479
  handleGenerate,
8255
8480
  handleShuffle,
8481
+ handleSyncSoundToGroup,
8256
8482
  handleCopy,
8257
8483
  handleDeleteTrack,
8258
8484
  handleMuteToggle,
@@ -8310,6 +8536,8 @@ function useGeneratorPanelCore({
8310
8536
  handlers,
8311
8537
  handleGenerate,
8312
8538
  handleShuffle,
8539
+ handleRestoreSound,
8540
+ handleSyncSoundToGroup,
8313
8541
  handleAddTrack,
8314
8542
  handleDeleteTrack,
8315
8543
  handleExportMidi,
@@ -8340,8 +8568,41 @@ function useGeneratorPanelCore({
8340
8568
  }
8341
8569
 
8342
8570
  // src/panel-core/GeneratorPanelShell.tsx
8343
- var import_react32 = __toESM(require("react"));
8344
- var import_jsx_runtime28 = require("react/jsx-runtime");
8571
+ var import_react32 = require("react");
8572
+ var import_jsx_runtime29 = require("react/jsx-runtime");
8573
+ var GROUP_UI_KEY = "groupUi";
8574
+ function CollapsibleGroup({
8575
+ ext,
8576
+ group,
8577
+ groupCtx
8578
+ }) {
8579
+ const { host, activeSceneId, trackDataKey: trackDataKey2 } = groupCtx.services;
8580
+ const uiKey = trackDataKey2(group.groupId, GROUP_UI_KEY);
8581
+ const [collapsed, setCollapsed] = (0, import_react32.useState)(false);
8582
+ (0, import_react32.useEffect)(() => {
8583
+ let cancelled = false;
8584
+ if (!activeSceneId) return void 0;
8585
+ void host.getSceneData(activeSceneId, uiKey).then((raw) => {
8586
+ if (cancelled || !raw || typeof raw !== "object") return;
8587
+ setCollapsed(raw.collapsed === true);
8588
+ }).catch(() => {
8589
+ });
8590
+ return () => {
8591
+ cancelled = true;
8592
+ };
8593
+ }, [host, activeSceneId, uiKey]);
8594
+ const onToggleCollapse = (0, import_react32.useCallback)(() => {
8595
+ setCollapsed((prev) => {
8596
+ const next = !prev;
8597
+ if (activeSceneId) {
8598
+ void host.setSceneData(activeSceneId, uiKey, { collapsed: next }).catch(() => {
8599
+ });
8600
+ }
8601
+ return next;
8602
+ });
8603
+ }, [host, activeSceneId, uiKey]);
8604
+ return /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(import_jsx_runtime29.Fragment, { children: ext.renderGroup(group, { ...groupCtx, collapsed, onToggleCollapse }) });
8605
+ }
8345
8606
  function GeneratorPanelShell({ core, slots }) {
8346
8607
  const {
8347
8608
  ui,
@@ -8364,6 +8625,7 @@ function GeneratorPanelShell({ core, slots }) {
8364
8625
  soundImportTarget,
8365
8626
  setSoundImportTarget,
8366
8627
  handleSoundImportPick,
8628
+ handleRestoreSound,
8367
8629
  handlePortTrack,
8368
8630
  transition,
8369
8631
  crossfadePairsMeta,
@@ -8459,7 +8721,7 @@ function GeneratorPanelShell({ core, slots }) {
8459
8721
  soundHistory: soundHistory.list(id).entries,
8460
8722
  soundHistoryCursor: soundHistory.list(id).cursor,
8461
8723
  onRestoreSound: (i) => {
8462
- void soundHistory.restoreTo(id, i);
8724
+ void handleRestoreSound(id, i);
8463
8725
  },
8464
8726
  onToggleFavorite: (i) => soundHistory.toggleFavorite(id, i),
8465
8727
  ...importSoundProps,
@@ -8490,6 +8752,7 @@ function GeneratorPanelShell({ core, slots }) {
8490
8752
  handleBackToInstruments,
8491
8753
  setSoundImportTarget,
8492
8754
  soundHistory,
8755
+ handleRestoreSound,
8493
8756
  handleFxToggle,
8494
8757
  handleFxPresetChange,
8495
8758
  handleFxDryWetChange,
@@ -8497,12 +8760,12 @@ function GeneratorPanelShell({ core, slots }) {
8497
8760
  ]
8498
8761
  );
8499
8762
  if (!activeSceneId) {
8500
- return /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
8763
+ return /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8501
8764
  "div",
8502
8765
  {
8503
8766
  "data-testid": `no-scene-placeholder-${identity.familyKey}`,
8504
8767
  className: "flex items-center justify-center py-8",
8505
- children: /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
8768
+ children: /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8506
8769
  "button",
8507
8770
  {
8508
8771
  onClick: () => onSelectScene?.(),
@@ -8514,12 +8777,12 @@ function GeneratorPanelShell({ core, slots }) {
8514
8777
  );
8515
8778
  }
8516
8779
  if (!sceneContext?.hasContract) {
8517
- return /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
8780
+ return /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8518
8781
  "div",
8519
8782
  {
8520
8783
  "data-testid": `no-contract-placeholder-${identity.familyKey}`,
8521
8784
  className: "flex items-center justify-center py-8",
8522
- children: /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
8785
+ children: /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8523
8786
  "button",
8524
8787
  {
8525
8788
  onClick: () => onOpenContract?.(),
@@ -8531,7 +8794,7 @@ function GeneratorPanelShell({ core, slots }) {
8531
8794
  );
8532
8795
  }
8533
8796
  if (features.bulkComposePlaceholders && isComposing) {
8534
- return /* @__PURE__ */ (0, import_jsx_runtime28.jsx)("div", { "data-testid": `${identity.familyKey}-section`, className: "p-2", children: /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(SorceryProgressBar, { isLoading: true, statusText: "COMPOSING...", heightClass: "h-10" }) });
8797
+ return /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("div", { "data-testid": `${identity.familyKey}-section`, className: "p-2", children: /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(SorceryProgressBar, { isLoading: true, statusText: "COMPOSING...", heightClass: "h-10" }) });
8535
8798
  }
8536
8799
  const activePlaceholders = features.bulkComposePlaceholders ? placeholders : [];
8537
8800
  if (activePlaceholders.length > 0) {
@@ -8542,18 +8805,18 @@ function GeneratorPanelShell({ core, slots }) {
8542
8805
  tracksByDbId.set(t.handle.id, t);
8543
8806
  }
8544
8807
  }
8545
- return /* @__PURE__ */ (0, import_jsx_runtime28.jsx)("div", { "data-testid": `${identity.familyKey}-section`, className: "p-2 space-y-2", children: activePlaceholders.map((ph) => {
8808
+ return /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("div", { "data-testid": `${identity.familyKey}-section`, className: "p-2 space-y-2", children: activePlaceholders.map((ph) => {
8546
8809
  const loadedTrack = ph.status === "completed" ? tracksByDbId.get(ph.id) : void 0;
8547
8810
  if (loadedTrack) {
8548
- return /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(TrackRow, { ...buildRowProps(loadedTrack) }, ph.id);
8811
+ return /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(TrackRow, { ...buildRowProps(loadedTrack) }, ph.id);
8549
8812
  }
8550
- return /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
8813
+ return /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8551
8814
  "div",
8552
8815
  {
8553
8816
  "data-testid": "bulk-placeholder-track",
8554
8817
  className: "relative rounded-sm border w-full overflow-hidden border-sas-border bg-sas-panel-alt",
8555
8818
  style: { borderLeftColor: identity.placeholderAccentColor, borderLeftWidth: "3px" },
8556
- children: /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(SorceryProgressBar, { isLoading: true, statusText: "CONJURING MIDI...", heightClass: "h-10" })
8819
+ children: /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(SorceryProgressBar, { isLoading: true, statusText: "CONJURING MIDI...", heightClass: "h-10" })
8557
8820
  },
8558
8821
  ph.id
8559
8822
  );
@@ -8565,13 +8828,13 @@ function GeneratorPanelShell({ core, slots }) {
8565
8828
  supportsMeters,
8566
8829
  levels: supportsMeters ? trackLevels : void 0,
8567
8830
  handlers,
8568
- renderDefaultTrackRow: (track, overrides, drag) => /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(TrackRow, { ...{ ...buildRowProps(track, drag), ...overrides ?? {} } }, track.handle.id),
8831
+ renderDefaultTrackRow: (track, overrides, drag) => /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(TrackRow, { ...{ ...buildRowProps(track, drag), ...overrides ?? {} } }, track.handle.id),
8569
8832
  setGroupMute,
8570
8833
  setGroupSolo,
8571
8834
  deleteGroup
8572
8835
  };
8573
- return /* @__PURE__ */ (0, import_jsx_runtime28.jsxs)("div", { "data-testid": `${identity.familyKey}-section`, className: "p-2 space-y-2", children: [
8574
- features.importTracks && host.listImportableTracks && /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
8836
+ return /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)("div", { "data-testid": `${identity.familyKey}-section`, className: "p-2 space-y-2", children: [
8837
+ features.importTracks && host.listImportableTracks && /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8575
8838
  ImportTrackModal,
8576
8839
  {
8577
8840
  host,
@@ -8584,7 +8847,7 @@ function GeneratorPanelShell({ core, slots }) {
8584
8847
  testIdPrefix: `${identity.familyKey}-import`
8585
8848
  }
8586
8849
  ),
8587
- features.importTracks && host.listImportableTracks && host.getTrackSound && /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
8850
+ features.importTracks && host.listImportableTracks && host.getTrackSound && /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8588
8851
  ImportTrackModal,
8589
8852
  {
8590
8853
  host,
@@ -8599,7 +8862,7 @@ function GeneratorPanelShell({ core, slots }) {
8599
8862
  }
8600
8863
  ),
8601
8864
  slots?.modals,
8602
- canCrossfade && xfFromId && xfToId && /* @__PURE__ */ (0, import_jsx_runtime28.jsx)("div", { className: designerView ? "contents" : "hidden", children: /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
8865
+ canCrossfade && xfFromId && xfToId && /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("div", { className: designerView ? "contents" : "hidden", children: /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8603
8866
  TransitionDesigner,
8604
8867
  {
8605
8868
  host,
@@ -8618,8 +8881,8 @@ function GeneratorPanelShell({ core, slots }) {
8618
8881
  testIdPrefix: `${identity.familyKey}-transition-designer`
8619
8882
  }
8620
8883
  ) }),
8621
- !(designerView && canCrossfade) && (isLoadingTracks ? /* @__PURE__ */ (0, import_jsx_runtime28.jsx)("div", { className: "text-sas-muted text-xs text-center py-4", children: "Loading tracks..." }) : /* @__PURE__ */ (0, import_jsx_runtime28.jsxs)(import_jsx_runtime28.Fragment, { children: [
8622
- panelBus.supported && panelBus.bus && /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
8884
+ !(designerView && canCrossfade) && (isLoadingTracks ? /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("div", { className: "text-sas-muted text-xs text-center py-4", children: "Loading tracks..." }) : /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)(import_jsx_runtime29.Fragment, { children: [
8885
+ panelBus.supported && panelBus.bus && /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8623
8886
  PanelMasterStrip,
8624
8887
  {
8625
8888
  bus: panelBus.bus,
@@ -8640,7 +8903,7 @@ function GeneratorPanelShell({ core, slots }) {
8640
8903
  }
8641
8904
  ),
8642
8905
  slots?.beforeRows,
8643
- resolvedCrossfadePairs.map((pair) => /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
8906
+ resolvedCrossfadePairs.map((pair) => /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8644
8907
  CrossfadeTrackRow,
8645
8908
  {
8646
8909
  accentColor: identity.transitionAccentColor,
@@ -8677,7 +8940,7 @@ function GeneratorPanelShell({ core, slots }) {
8677
8940
  },
8678
8941
  pair.groupId
8679
8942
  )),
8680
- resolvedSingleFades.map((fade) => /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
8943
+ resolvedSingleFades.map((fade) => /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8681
8944
  FadeTrackRow,
8682
8945
  {
8683
8946
  accentColor: identity.transitionAccentColor,
@@ -8702,7 +8965,7 @@ function GeneratorPanelShell({ core, slots }) {
8702
8965
  },
8703
8966
  fade.dbId
8704
8967
  )),
8705
- resolvedGroupFades.map((group) => /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
8968
+ resolvedGroupFades.map((group) => /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8706
8969
  GroupFadeTrackRow,
8707
8970
  {
8708
8971
  accentColor: identity.transitionAccentColor,
@@ -8738,20 +9001,28 @@ function GeneratorPanelShell({ core, slots }) {
8738
9001
  group.groupId
8739
9002
  )),
8740
9003
  (adapter.groupExtensions ?? []).flatMap(
8741
- (ext) => (resolvedGenericGroups[ext.metaKey]?.resolved ?? []).filter((group) => !group.members.every((m) => fadeMemberDbIds.has(m.dbId))).map((group) => /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(import_react32.default.Fragment, { children: ext.renderGroup(group, groupCtx) }, `${ext.metaKey}:${group.groupId}`))
9004
+ (ext) => (resolvedGenericGroups[ext.metaKey]?.resolved ?? []).filter((group) => !group.members.every((m) => fadeMemberDbIds.has(m.dbId))).map((group) => /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
9005
+ CollapsibleGroup,
9006
+ {
9007
+ ext,
9008
+ group,
9009
+ groupCtx
9010
+ },
9011
+ `${ext.metaKey}:${group.groupId}`
9012
+ ))
8742
9013
  ),
8743
9014
  tracks.map((track, index) => {
8744
9015
  if (crossfadeMemberDbIds.has(track.handle.dbId) || fadeMemberDbIds.has(track.handle.dbId) || genericGroupMemberDbIds.has(track.handle.dbId)) {
8745
9016
  return null;
8746
9017
  }
8747
- return /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(TrackRow, { ...buildRowProps(track, reorder.dragPropsFor(index)) }, track.handle.id);
9018
+ return /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(TrackRow, { ...buildRowProps(track, reorder.dragPropsFor(index)) }, track.handle.id);
8748
9019
  }),
8749
9020
  slots?.afterRows
8750
9021
  ] })),
8751
9022
  features.exportMidi && !designerView && !isLoadingTracks && tracks.length > 0 && (() => {
8752
9023
  const hasAnyMidi = tracks.some((t) => t.hasMidi);
8753
9024
  const exportDisabled = isExportingMidi || !hasAnyMidi;
8754
- return /* @__PURE__ */ (0, import_jsx_runtime28.jsx)("div", { className: "pt-2", children: /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
9025
+ return /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("div", { className: "pt-2", children: /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8755
9026
  "button",
8756
9027
  {
8757
9028
  "data-testid": "export-midi-tracks-button",
@@ -8806,6 +9077,15 @@ function createSurgeSoundAdapter(host, overrides = {}) {
8806
9077
  });
8807
9078
  return snap.label;
8808
9079
  },
9080
+ persistDescriptor: async (trackId, descriptor, label) => {
9081
+ const { state, stateType } = descriptor;
9082
+ await host.persistTrackPresetState?.(trackId, {
9083
+ state,
9084
+ stateType: stateType ?? "valuetree",
9085
+ name: label
9086
+ }).catch(() => {
9087
+ });
9088
+ },
8809
9089
  descriptorFromSnapshot: (snap) => {
8810
9090
  const preset = snap;
8811
9091
  return { state: preset.state, stateType: preset.stateType };
@@ -8890,18 +9170,183 @@ var SUB = {
8890
9170
  rootOnly: true,
8891
9171
  monoPreference: "low"
8892
9172
  };
9173
+ var HORN_MAX_NOTES_PER_BAR = 12;
9174
+ var HORN_FOLLOW_PALETTE = "EXACTLY the lead voice's rhythm \u2014 every attack lands together with voice 1";
9175
+ var HORN_LEAD = {
9176
+ label: "lead trumpet",
9177
+ role: "brass",
9178
+ registerLow: 60,
9179
+ // C4
9180
+ registerHigh: 84,
9181
+ // C6
9182
+ maxNotesPerBar: HORN_MAX_NOTES_PER_BAR,
9183
+ rhythmPalette: "tight funk syncopation \u2014 8ths and 16ths, staccato punches, off-beat anticipations",
9184
+ harmonicDiscipline: "the section's melodic top \u2014 chord tones on every hit; quick chromatic approach tones only on pickups",
9185
+ monoPreference: "high"
9186
+ };
9187
+ var HORN_TRUMPET_2 = {
9188
+ label: "second trumpet",
9189
+ role: "brass",
9190
+ registerLow: 55,
9191
+ // G3
9192
+ registerHigh: 79,
9193
+ // G5
9194
+ maxNotesPerBar: HORN_MAX_NOTES_PER_BAR,
9195
+ rhythmPalette: HORN_FOLLOW_PALETTE,
9196
+ harmonicDiscipline: "nearest chord tone directly below voice 1 \u2014 tight close voicing",
9197
+ monoPreference: "high"
9198
+ };
9199
+ var HORN_ALTO = {
9200
+ label: "alto sax",
9201
+ role: "brass",
9202
+ registerLow: 53,
9203
+ // F3
9204
+ registerHigh: 77,
9205
+ // F5
9206
+ maxNotesPerBar: HORN_MAX_NOTES_PER_BAR,
9207
+ rhythmPalette: HORN_FOLLOW_PALETTE,
9208
+ harmonicDiscipline: "chord tone below the trumpets \u2014 keep the upper stack inside one octave",
9209
+ monoPreference: "high"
9210
+ };
9211
+ var HORN_TENOR = {
9212
+ label: "tenor sax",
9213
+ role: "brass",
9214
+ registerLow: 46,
9215
+ // Bb2
9216
+ registerHigh: 72,
9217
+ // C5
9218
+ maxNotesPerBar: HORN_MAX_NOTES_PER_BAR,
9219
+ rhythmPalette: HORN_FOLLOW_PALETTE,
9220
+ harmonicDiscipline: "chord tone under the upper horns \u2014 complete the chord",
9221
+ monoPreference: "low"
9222
+ };
9223
+ var HORN_TROMBONE = {
9224
+ label: "trombone",
9225
+ role: "brass",
9226
+ registerLow: 43,
9227
+ // G2
9228
+ registerHigh: 67,
9229
+ // G4
9230
+ maxNotesPerBar: HORN_MAX_NOTES_PER_BAR,
9231
+ rhythmPalette: HORN_FOLLOW_PALETTE,
9232
+ harmonicDiscipline: "chord tone or root below the saxes \u2014 weight in the middle-low stack",
9233
+ monoPreference: "low"
9234
+ };
9235
+ var HORN_BARI = {
9236
+ label: "baritone sax",
9237
+ role: "brass",
9238
+ registerLow: 36,
9239
+ // C2
9240
+ registerHigh: 60,
9241
+ // C4
9242
+ maxNotesPerBar: HORN_MAX_NOTES_PER_BAR,
9243
+ rhythmPalette: HORN_FOLLOW_PALETTE,
9244
+ harmonicDiscipline: "chord roots and lower chord tones \u2014 the section's bottom anchor",
9245
+ monoPreference: "low"
9246
+ };
9247
+ var WIND_FLUTE = {
9248
+ label: "flute",
9249
+ role: "winds",
9250
+ registerLow: 60,
9251
+ // C4
9252
+ registerHigh: 93,
9253
+ // A6
9254
+ maxNotesPerBar: 8,
9255
+ rhythmPalette: "8ths and 16ths; runs, turns and trills welcome",
9256
+ harmonicDiscipline: "freest voice \u2014 non-chord tones as passing/neighbor tones on weak beats, resolving by step",
9257
+ monoPreference: "high"
9258
+ };
9259
+ var WIND_FLUTE_2 = {
9260
+ label: "second flute",
9261
+ role: "winds",
9262
+ registerLow: 60,
9263
+ // C4
9264
+ registerHigh: 86,
9265
+ // D6
9266
+ maxNotesPerBar: 5,
9267
+ rhythmPalette: "quarters with occasional 8th-note motion",
9268
+ harmonicDiscipline: "chord tones; fill gaps the other inner voices leave",
9269
+ monoPreference: "high"
9270
+ };
9271
+ var WIND_OBOE = {
9272
+ label: "oboe",
9273
+ role: "winds",
9274
+ registerLow: 58,
9275
+ // Bb3
9276
+ registerHigh: 84,
9277
+ // C6
9278
+ maxNotesPerBar: 6,
9279
+ rhythmPalette: "8ths and quarters; move when the flute rests",
9280
+ harmonicDiscipline: "mostly chord tones; may imitate the flute's motifs a bar later",
9281
+ monoPreference: "high"
9282
+ };
9283
+ var WIND_CLARINET = {
9284
+ label: "clarinet",
9285
+ role: "winds",
9286
+ registerLow: 50,
9287
+ // D3
9288
+ registerHigh: 81,
9289
+ // A5
9290
+ maxNotesPerBar: 4,
9291
+ rhythmPalette: "quarters and halves",
9292
+ harmonicDiscipline: "chord tones with smooth stepwise motion between them",
9293
+ monoPreference: "high"
9294
+ };
9295
+ var WIND_FRENCH_HORN = {
9296
+ label: "french horn",
9297
+ role: "winds",
9298
+ registerLow: 41,
9299
+ // F2
9300
+ registerHigh: 72,
9301
+ // C5
9302
+ maxNotesPerBar: 4,
9303
+ rhythmPalette: "quarters and halves; long held tones welcome",
9304
+ harmonicDiscipline: "chord tones \u2014 the warm glue in the middle of the ensemble",
9305
+ monoPreference: "low"
9306
+ };
9307
+ var WIND_BASSOON = {
9308
+ label: "bassoon",
9309
+ role: "winds",
9310
+ registerLow: 34,
9311
+ // Bb1
9312
+ registerHigh: 62,
9313
+ // D4
9314
+ maxNotesPerBar: 3,
9315
+ rhythmPalette: "quarters and halves; brief walking figures at cadences",
9316
+ harmonicDiscipline: "roots and fifths emphasized; passing tones only between chord tones",
9317
+ monoPreference: "low"
9318
+ };
8893
9319
  var ENSEMBLE_MIN_VOICES = 2;
8894
9320
  var ENSEMBLE_MAX_VOICES = 6;
8895
- var SPEC_TABLES = {
9321
+ var STRINGS_SPEC_TABLES = {
8896
9322
  2: [TOP, BASS],
8897
9323
  3: [TOP, INNER, BASS],
8898
9324
  4: [TOP, COUNTER, TENOR, BASS],
8899
9325
  5: [TOP, COUNTER, INNER, TENOR, SUB],
8900
9326
  6: [TOP, COUNTER, INNER_2, INNER, TENOR, SUB]
8901
9327
  };
8902
- function defaultVoiceSpecs(voiceCount) {
9328
+ var HORNS_SPEC_TABLES = {
9329
+ 2: [HORN_LEAD, HORN_TENOR],
9330
+ 3: [HORN_LEAD, HORN_TENOR, HORN_BARI],
9331
+ 4: [HORN_LEAD, HORN_TRUMPET_2, HORN_TENOR, HORN_BARI],
9332
+ 5: [HORN_LEAD, HORN_TRUMPET_2, HORN_TENOR, HORN_TROMBONE, HORN_BARI],
9333
+ 6: [HORN_LEAD, HORN_TRUMPET_2, HORN_ALTO, HORN_TENOR, HORN_TROMBONE, HORN_BARI]
9334
+ };
9335
+ var WINDS_SPEC_TABLES = {
9336
+ 2: [WIND_FLUTE, WIND_BASSOON],
9337
+ 3: [WIND_FLUTE, WIND_CLARINET, WIND_BASSOON],
9338
+ 4: [WIND_FLUTE, WIND_OBOE, WIND_FRENCH_HORN, WIND_BASSOON],
9339
+ 5: [WIND_FLUTE, WIND_OBOE, WIND_CLARINET, WIND_FRENCH_HORN, WIND_BASSOON],
9340
+ 6: [WIND_FLUTE, WIND_FLUTE_2, WIND_OBOE, WIND_CLARINET, WIND_FRENCH_HORN, WIND_BASSOON]
9341
+ };
9342
+ var SPEC_TABLES_BY_INSTRUMENTATION = {
9343
+ strings: STRINGS_SPEC_TABLES,
9344
+ horns: HORNS_SPEC_TABLES,
9345
+ winds: WINDS_SPEC_TABLES
9346
+ };
9347
+ function defaultVoiceSpecs(voiceCount, instrumentation = "strings") {
8903
9348
  const n = Math.max(ENSEMBLE_MIN_VOICES, Math.min(ENSEMBLE_MAX_VOICES, Math.round(voiceCount)));
8904
- return SPEC_TABLES[n].map((spec, voiceIndex) => ({ ...spec, voiceIndex }));
9349
+ return SPEC_TABLES_BY_INSTRUMENTATION[instrumentation][n].map((spec, voiceIndex) => ({ ...spec, voiceIndex }));
8905
9350
  }
8906
9351
 
8907
9352
  // src/ensemble-core/enforce-voice.ts
@@ -8941,10 +9386,14 @@ function enforceVoice(rawNotes, spec, opts) {
8941
9386
  repairs.push(`voice ${spec.voiceIndex}: dropped note outside the ${opts.bars}-bar clip (start ${n.startBeat})`);
8942
9387
  continue;
8943
9388
  }
8944
- const durationBeats = Math.max(
9389
+ let durationBeats = Math.max(
8945
9390
  MIN_NOTE_DURATION_BEATS,
8946
9391
  Math.min(n.durationBeats, clipEnd - n.startBeat)
8947
9392
  );
9393
+ if (opts.maxNoteDurationBeats !== void 0 && durationBeats > opts.maxNoteDurationBeats) {
9394
+ durationBeats = Math.max(MIN_NOTE_DURATION_BEATS, opts.maxNoteDurationBeats);
9395
+ repairs.push(`voice ${spec.voiceIndex}: trimmed note at beat ${n.startBeat} to the style's ${opts.maxNoteDurationBeats}-beat stab ceiling`);
9396
+ }
8948
9397
  notes.push({ ...n, durationBeats });
8949
9398
  }
8950
9399
  notes = notes.map((n) => {
@@ -9105,12 +9554,19 @@ function describeViolations(analysis, rules) {
9105
9554
  if (pair.onsetIndependence < rules.minOnsetIndependence) {
9106
9555
  out.push(`Voices ${pair.upperVoice + 1} and ${pair.upperVoice + 2} attack together too often (independence ${pair.onsetIndependence.toFixed(2)} < ${rules.minOnsetIndependence}) \u2014 stagger entrances and let one voice move while the other holds.`);
9107
9556
  }
9557
+ if (rules.maxOnsetIndependence !== void 0 && pair.onsetIndependence > rules.maxOnsetIndependence) {
9558
+ out.push(`Voices ${pair.upperVoice + 1} and ${pair.upperVoice + 2} don't attack together enough (independence ${pair.onsetIndependence.toFixed(2)} > ${rules.maxOnsetIndependence}) \u2014 this is a section: give every voice the SAME rhythm as the lead so the hits land as one.`);
9559
+ }
9108
9560
  }
9109
9561
  return out;
9110
9562
  }
9111
9563
 
9112
9564
  // src/ensemble-core/styles.ts
9113
9565
  var ENSEMBLE_STYLES = ["counterpoint", "chorale", "interlock"];
9566
+ var STAB_MAX_ONSET_INDEPENDENCE = 0.25;
9567
+ var RIFF_MAX_ONSET_INDEPENDENCE = 0.3;
9568
+ var UNISON_MAX_ONSET_INDEPENDENCE = 0.15;
9569
+ var STAB_MAX_NOTE_DURATION_BEATS = 1;
9114
9570
  var STYLE_RULES = {
9115
9571
  counterpoint: {
9116
9572
  forbidParallelPerfects: true,
@@ -9129,9 +9585,55 @@ var STYLE_RULES = {
9129
9585
  forbidVoiceCrossing: false,
9130
9586
  minOnsetIndependence: 0.6,
9131
9587
  promptParagraph: "STYLE \u2014 INTERLOCK (minimal / systems music): short repeating cells that mesh like gears. Each voice keeps its own ostinato; onsets rarely coincide with the neighboring voice; parallel motion and doubling are welcome when the composite rhythm stays busy and even."
9588
+ },
9589
+ stabs: {
9590
+ forbidParallelPerfects: false,
9591
+ forbidVoiceCrossing: true,
9592
+ minOnsetIndependence: 0,
9593
+ maxOnsetIndependence: STAB_MAX_ONSET_INDEPENDENCE,
9594
+ maxNoteDurationBeats: STAB_MAX_NOTE_DURATION_BEATS,
9595
+ promptParagraph: `STYLE \u2014 STABS (James Brown funk-45 horn punches): short accented chord hits \u2014 single punches and two-to-four-note kicks \u2014 placed on the groove's pressure points: the one, off-beat "ands", and the pickup into the next bar. Every hit is staccato (a 16th to an 8th long) and voiced as ONE tight chord under the lead. Leave REAL space between hits \u2014 the section punctuates the groove, it never carpets it.`
9596
+ },
9597
+ riffs: {
9598
+ forbidParallelPerfects: false,
9599
+ forbidVoiceCrossing: true,
9600
+ minOnsetIndependence: 0,
9601
+ maxOnsetIndependence: RIFF_MAX_ONSET_INDEPENDENCE,
9602
+ promptParagraph: 'STYLE \u2014 RIFFS (funk/soul section soli): ONE syncopated 8th/16th riff, one or two bars long, repeated with small variations for the whole clip \u2014 the entire section plays it together, harmonized in tight close voicings under the lead. Anticipate downbeats (attack the "and" just before the barline), leave air at phrase ends, and let the final hit of a phrase ring a little longer.'
9603
+ },
9604
+ unison: {
9605
+ forbidParallelPerfects: false,
9606
+ forbidVoiceCrossing: false,
9607
+ minOnsetIndependence: 0,
9608
+ maxOnsetIndependence: UNISON_MAX_ONSET_INDEPENDENCE,
9609
+ promptParagraph: "STYLE \u2014 UNISON (the Cold Sweat power line): every voice doubles the SAME riff in octaves \u2014 no harmony, one line, maximum punch. Keep it syncopated and riff-based with space between phrases; octave doubling and parallel motion are the entire point."
9132
9610
  }
9133
9611
  };
9134
9612
 
9613
+ // src/ensemble-core/instrumentation.ts
9614
+ var ENSEMBLE_INSTRUMENTATIONS = [
9615
+ "strings",
9616
+ "horns",
9617
+ "winds"
9618
+ ];
9619
+ var STYLES_FOR_INSTRUMENTATION = {
9620
+ strings: ["counterpoint", "chorale", "interlock"],
9621
+ horns: ["stabs", "riffs", "unison"],
9622
+ winds: ["counterpoint", "chorale", "interlock"]
9623
+ };
9624
+ var DEFAULT_STYLE_FOR_INSTRUMENTATION = {
9625
+ strings: "counterpoint",
9626
+ horns: "stabs",
9627
+ winds: "chorale"
9628
+ };
9629
+ function normalizeInstrumentation(raw) {
9630
+ return ENSEMBLE_INSTRUMENTATIONS.includes(raw) ? raw : "strings";
9631
+ }
9632
+ function styleForInstrumentation(instrumentation, rawStyle) {
9633
+ const allowed = STYLES_FOR_INSTRUMENTATION[instrumentation];
9634
+ return allowed.includes(rawStyle ?? "") ? rawStyle : DEFAULT_STYLE_FOR_INSTRUMENTATION[instrumentation];
9635
+ }
9636
+
9135
9637
  // src/ensemble-core/ensemble-schema.ts
9136
9638
  var SUBMIT_ENSEMBLE_TOOL_NAME = "submit_ensemble";
9137
9639
  function buildSubmitEnsembleParameters(voiceCount) {
@@ -9213,9 +9715,13 @@ function voiceContractLine(spec) {
9213
9715
  const root = spec.rootOnly ? " ROOT PITCH CLASS ONLY (each bar's chord root)." : "";
9214
9716
  return `- Voice ${spec.voiceIndex + 1} (${spec.label}): MIDI ${spec.registerLow}-${spec.registerHigh}, max ${spec.maxNotesPerBar} notes/bar, rhythm: ${spec.rhythmPalette}. ${spec.harmonicDiscipline}.${root}`;
9215
9717
  }
9216
- function buildEnsembleSystemPrompt(specs, style) {
9718
+ var WOVEN_PERSONA = {
9719
+ strings: "You are an ensemble composer.",
9720
+ winds: "You are a wind-ensemble composer writing for chamber winds (flutes, oboe, clarinet, french horn, bassoon)."
9721
+ };
9722
+ function buildWovenPrompt(specs, style, instrumentation) {
9217
9723
  const styleParagraph = STYLE_RULES[style].promptParagraph;
9218
- return `You are an ensemble composer. Compose ${specs.length} voices as ONE piece of music \u2014 not ${specs.length} independent parts.
9724
+ return `${WOVEN_PERSONA[instrumentation]} Compose ${specs.length} voices as ONE piece of music \u2014 not ${specs.length} independent parts.
9219
9725
 
9220
9726
  Submit your composition by calling the ${SUBMIT_ENSEMBLE_TOOL_NAME} tool with all ${specs.length} voices.
9221
9727
 
@@ -9232,6 +9738,28 @@ ${styleParagraph}
9232
9738
  PER-VOICE CONTRACTS:
9233
9739
  ${specs.map(voiceContractLine).join("\n")}`;
9234
9740
  }
9741
+ function buildSectionPrompt(specs, style) {
9742
+ const styleParagraph = STYLE_RULES[style].promptParagraph;
9743
+ return `You are a funk horn-section arranger. Compose ${specs.length} horn parts as ONE section \u2014 ${specs.length} players phrasing like a single instrument, not ${specs.length} independent parts.
9744
+
9745
+ Submit your composition by calling the ${SUBMIT_ENSEMBLE_TOOL_NAME} tool with all ${specs.length} voices.
9746
+
9747
+ THE SECTION RULES (most important):
9748
+ 1. The section speaks with ONE rhythm: every voice attacks on the same beats as Voice 1 (the lead). Compose the lead line first, then stack the other voices onto its rhythm exactly \u2014 the style below dictates chord voicing vs octave doubling.
9749
+ 2. Each voice is a SINGLE monophonic line \u2014 within one voice, no two notes overlap in time and no chords. The chord is the STACK of voices hitting together.
9750
+ 3. SPACE IS THE GROOVE: short phrases and punches separated by real rests. Never carpet the bar \u2014 the section punctuates around the rhythm section, and the silence between hits is part of the riff.
9751
+ 4. Follow the chord progression in the musical context exactly \u2014 every hit is voiced from the chord sounding at that beat.
9752
+ 5. Make it DANCE: this is funk. Syncopate, anticipate downbeats (attack the "and" just before the barline), and accent with velocity \u2014 105-120 on accented hits, 70-90 on lighter punches. Repeat the riff so the clip locks like a loop.
9753
+ 6. startBeat/durationBeats are in quarter-note beats from the start of the clip; respect each voice's register window and notes-per-bar cap exactly; fill the stated bar length and land the first hit in bar 1 (no long silent intro).
9754
+
9755
+ ${styleParagraph}
9756
+
9757
+ PER-VOICE CONTRACTS:
9758
+ ${specs.map(voiceContractLine).join("\n")}`;
9759
+ }
9760
+ function buildEnsembleSystemPrompt(specs, style, instrumentation = "strings") {
9761
+ return instrumentation === "horns" ? buildSectionPrompt(specs, style) : buildWovenPrompt(specs, style, instrumentation);
9762
+ }
9235
9763
  function buildViolationRetrySuffix(violations) {
9236
9764
  if (violations.length === 0) return "";
9237
9765
  return `
@@ -9407,10 +9935,12 @@ function pickTopKWeighted(scored, options = {}) {
9407
9935
  DB_MIN,
9408
9936
  DEFAULT_FX_CATEGORY_DETAIL,
9409
9937
  DEFAULT_FX_DRY_WET,
9938
+ DEFAULT_STYLE_FOR_INSTRUMENTATION,
9410
9939
  DRAG_DEAD_ZONE,
9411
9940
  DownloadPackButton,
9412
9941
  EMPTY_FX_DETAIL_STATE,
9413
9942
  EMPTY_FX_STATE,
9943
+ ENSEMBLE_INSTRUMENTATIONS,
9414
9944
  ENSEMBLE_MAX_VOICES,
9415
9945
  ENSEMBLE_MIN_VOICES,
9416
9946
  ENSEMBLE_STYLES,
@@ -9425,7 +9955,9 @@ function pickTopKWeighted(scored, options = {}) {
9425
9955
  FxToggleBar,
9426
9956
  GUTTER_W,
9427
9957
  GeneratorPanelShell,
9958
+ GroupCollapseChevron,
9428
9959
  GroupFadeTrackRow,
9960
+ HORN_MAX_NOTES_PER_BAR,
9429
9961
  ImportTrackModal,
9430
9962
  InstrumentDrawer,
9431
9963
  LevelMeter,
@@ -9439,8 +9971,12 @@ function pickTopKWeighted(scored, options = {}) {
9439
9971
  PianoRollEditor,
9440
9972
  PluginError,
9441
9973
  RESIZE_HANDLE_PX,
9974
+ RIFF_MAX_ONSET_INDEPENDENCE,
9442
9975
  ROW_HEIGHT,
9443
9976
  SLIDER_UNITY,
9977
+ STAB_MAX_NOTE_DURATION_BEATS,
9978
+ STAB_MAX_ONSET_INDEPENDENCE,
9979
+ STYLES_FOR_INSTRUMENTATION,
9444
9980
  STYLE_RULES,
9445
9981
  SUBMIT_ENSEMBLE_TOOL_NAME,
9446
9982
  SamplePackCTACard,
@@ -9454,6 +9990,7 @@ function pickTopKWeighted(scored, options = {}) {
9454
9990
  TrackMeterStrip,
9455
9991
  TrackRow,
9456
9992
  TransitionDesigner,
9993
+ UNISON_MAX_ONSET_INDEPENDENCE,
9457
9994
  VolumeSlider,
9458
9995
  WaveformView,
9459
9996
  analyzeEnsemble,
@@ -9488,6 +10025,7 @@ function pickTopKWeighted(scored, options = {}) {
9488
10025
  moveItem,
9489
10026
  nearestPitchWithPc,
9490
10027
  newTrackState,
10028
+ normalizeInstrumentation,
9491
10029
  normalizeSlots,
9492
10030
  padPair,
9493
10031
  padSlots,
@@ -9511,6 +10049,7 @@ function pickTopKWeighted(scored, options = {}) {
9511
10049
  slotsEqual,
9512
10050
  soundIdentity,
9513
10051
  splitFadeEntries,
10052
+ styleForInstrumentation,
9514
10053
  synthesizeCuePoints,
9515
10054
  tokenizePrompt,
9516
10055
  trackDataKey,