@signalsandsorcery/plugin-sdk 2.38.2 → 2.40.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
@@ -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,7 @@ function TrackRow({
2679
2699
  onToggleFavorite,
2680
2700
  onImportSound,
2681
2701
  importSoundLabel,
2702
+ linkedSoundHint,
2682
2703
  editNotes,
2683
2704
  onNotesChange,
2684
2705
  editBars,
@@ -2844,15 +2865,26 @@ function TrackRow({
2844
2865
  )
2845
2866
  ] }),
2846
2867
  /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("div", { className: "flex gap-1 items-center", children: [
2847
- onShuffle && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
2868
+ onShuffle && /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)(
2848
2869
  "button",
2849
2870
  {
2850
2871
  "data-testid": "sdk-shuffle-button",
2851
2872
  onClick: onShuffle,
2852
2873
  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"
2874
+ 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"}`,
2875
+ 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",
2876
+ children: [
2877
+ "Shuffle",
2878
+ linkedSoundHint && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
2879
+ "span",
2880
+ {
2881
+ "aria-hidden": true,
2882
+ "data-testid": "sdk-shuffle-linked-marker",
2883
+ className: "absolute -top-1 -right-1 text-[8px] leading-none",
2884
+ children: "\u{1F517}"
2885
+ }
2886
+ )
2887
+ ]
2856
2888
  }
2857
2889
  ),
2858
2890
  freezeBadge && /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(
@@ -2936,6 +2968,7 @@ function TrackRow({
2936
2968
  onToggleFavorite,
2937
2969
  onImportSound,
2938
2970
  importSoundLabel,
2971
+ linkedSoundHint,
2939
2972
  editNotes,
2940
2973
  onNotesChange,
2941
2974
  editBars,
@@ -5163,9 +5196,38 @@ function TransitionDesigner({
5163
5196
  ] });
5164
5197
  }
5165
5198
 
5199
+ // src/components/GroupCollapseChevron.tsx
5200
+ var import_lucide_react2 = require("lucide-react");
5201
+ var import_jsx_runtime21 = require("react/jsx-runtime");
5202
+ function GroupCollapseChevron({
5203
+ collapsed = false,
5204
+ onToggle,
5205
+ what = "group"
5206
+ }) {
5207
+ if (!onToggle) return null;
5208
+ return /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
5209
+ "button",
5210
+ {
5211
+ type: "button",
5212
+ "data-testid": "sdk-group-collapse",
5213
+ onClick: onToggle,
5214
+ "aria-expanded": !collapsed,
5215
+ title: collapsed ? `Expand ${what}` : `Collapse ${what}`,
5216
+ className: "shrink-0 px-0.5 py-0.5 rounded-sm text-sas-muted hover:text-sas-accent transition-colors",
5217
+ children: /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
5218
+ import_lucide_react2.ChevronDown,
5219
+ {
5220
+ className: `w-3 h-3 transition-transform ${collapsed ? "-rotate-90" : ""}`,
5221
+ strokeWidth: 2.5
5222
+ }
5223
+ )
5224
+ }
5225
+ );
5226
+ }
5227
+
5166
5228
  // src/components/PanelMasterStrip.tsx
5167
5229
  var import_react21 = require("react");
5168
- var import_jsx_runtime21 = require("react/jsx-runtime");
5230
+ var import_jsx_runtime22 = require("react/jsx-runtime");
5169
5231
  function PanelMasterStrip({
5170
5232
  bus,
5171
5233
  levels = null,
@@ -5192,14 +5254,14 @@ function PanelMasterStrip({
5192
5254
  (fx) => fx.name.toLowerCase().includes(q) || fx.manufacturer.toLowerCase().includes(q)
5193
5255
  );
5194
5256
  }, [availableFx, search]);
5195
- return /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)(
5257
+ return /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)(
5196
5258
  "div",
5197
5259
  {
5198
5260
  "data-testid": "panel-master-strip",
5199
5261
  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
5262
  children: [
5201
- /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("div", { className: "flex items-center gap-2", children: [
5202
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
5263
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)("div", { className: "flex items-center gap-2", children: [
5264
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
5203
5265
  "span",
5204
5266
  {
5205
5267
  className: "text-[9px] font-bold tracking-widest text-sas-muted/70 select-none",
@@ -5207,8 +5269,8 @@ function PanelMasterStrip({
5207
5269
  children: "BUS"
5208
5270
  }
5209
5271
  ),
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)(
5272
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)("div", { className: "flex-1 min-w-[8rem] flex flex-col gap-0.5", children: [
5273
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
5212
5274
  VolumeSlider,
5213
5275
  {
5214
5276
  value: dbToSlider(bus.volume),
@@ -5216,8 +5278,8 @@ function PanelMasterStrip({
5216
5278
  disabled
5217
5279
  }
5218
5280
  ),
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)(
5281
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)("div", { className: "flex flex-col gap-px", "data-testid": "bus-meter", children: [
5282
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
5221
5283
  LevelMeter,
5222
5284
  {
5223
5285
  peakDb: levels?.leftDb ?? -120,
@@ -5227,7 +5289,7 @@ function PanelMasterStrip({
5227
5289
  "data-testid": "bus-meter-left"
5228
5290
  }
5229
5291
  ),
5230
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
5292
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
5231
5293
  LevelMeter,
5232
5294
  {
5233
5295
  peakDb: levels?.rightDb ?? -120,
@@ -5238,7 +5300,7 @@ function PanelMasterStrip({
5238
5300
  )
5239
5301
  ] })
5240
5302
  ] }),
5241
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
5303
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
5242
5304
  "button",
5243
5305
  {
5244
5306
  "data-testid": "bus-mute-button",
@@ -5249,7 +5311,7 @@ function PanelMasterStrip({
5249
5311
  children: "M"
5250
5312
  }
5251
5313
  ),
5252
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
5314
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
5253
5315
  "button",
5254
5316
  {
5255
5317
  "data-testid": "bus-solo-button",
@@ -5260,14 +5322,14 @@ function PanelMasterStrip({
5260
5322
  children: "S"
5261
5323
  }
5262
5324
  ),
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)(
5325
+ /* @__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
5326
  "span",
5265
5327
  {
5266
5328
  "data-testid": `bus-fx-chip-${fx.index}`,
5267
5329
  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
5330
  title: `${fx.name}${fx.enabled ? "" : " (bypassed)"}`,
5269
5331
  children: [
5270
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
5332
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
5271
5333
  "button",
5272
5334
  {
5273
5335
  "data-testid": `bus-fx-toggle-${fx.index}`,
@@ -5278,7 +5340,7 @@ function PanelMasterStrip({
5278
5340
  children: fx.enabled ? "\u25CF" : "\u25CB"
5279
5341
  }
5280
5342
  ),
5281
- onShowFxEditor ? /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
5343
+ onShowFxEditor ? /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
5282
5344
  "button",
5283
5345
  {
5284
5346
  "data-testid": `bus-fx-edit-${fx.index}`,
@@ -5288,8 +5350,8 @@ function PanelMasterStrip({
5288
5350
  title: `Open ${fx.name} editor`,
5289
5351
  children: fx.name
5290
5352
  }
5291
- ) : /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("span", { className: "max-w-[80px] truncate", children: fx.name }),
5292
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
5353
+ ) : /* @__PURE__ */ (0, import_jsx_runtime22.jsx)("span", { className: "max-w-[80px] truncate", children: fx.name }),
5354
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
5293
5355
  "button",
5294
5356
  {
5295
5357
  "data-testid": `bus-fx-remove-${fx.index}`,
@@ -5304,7 +5366,7 @@ function PanelMasterStrip({
5304
5366
  },
5305
5367
  `${fx.index}:${fx.pluginId}`
5306
5368
  )) }),
5307
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
5369
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
5308
5370
  "button",
5309
5371
  {
5310
5372
  "data-testid": "bus-fx-add-button",
@@ -5316,9 +5378,9 @@ function PanelMasterStrip({
5316
5378
  }
5317
5379
  )
5318
5380
  ] }),
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)(
5381
+ fxPickerOpen && /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)("div", { "data-testid": "bus-fx-picker", className: "flex flex-col gap-2 pt-1", children: [
5382
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)("div", { className: "flex items-center gap-2", children: [
5383
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
5322
5384
  "input",
5323
5385
  {
5324
5386
  type: "text",
@@ -5328,7 +5390,7 @@ function PanelMasterStrip({
5328
5390
  className: "sas-input flex-1 px-2 py-1 text-xs"
5329
5391
  }
5330
5392
  ),
5331
- onRefreshFx && /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
5393
+ onRefreshFx && /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
5332
5394
  "button",
5333
5395
  {
5334
5396
  onClick: () => onRefreshFx(),
@@ -5339,8 +5401,8 @@ function PanelMasterStrip({
5339
5401
  }
5340
5402
  )
5341
5403
  ] }),
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)(
5404
+ 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: [
5405
+ filtered.map((fx) => /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)(
5344
5406
  "button",
5345
5407
  {
5346
5408
  "data-testid": `bus-fx-pick-${fx.pluginId}`,
@@ -5348,13 +5410,13 @@ function PanelMasterStrip({
5348
5410
  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
5411
  title: `${fx.name} by ${fx.manufacturer} (${fx.type.toUpperCase()})`,
5350
5412
  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() })
5413
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)("span", { className: "text-xs font-medium truncate w-full", children: fx.name }),
5414
+ /* @__PURE__ */ (0, import_jsx_runtime22.jsx)("span", { className: "text-[9px] text-sas-muted/50 truncate w-full", children: fx.manufacturer || fx.type.toUpperCase() })
5353
5415
  ]
5354
5416
  },
5355
5417
  fx.pluginId
5356
5418
  )),
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" })
5419
+ 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
5420
  ] })
5359
5421
  ] })
5360
5422
  ]
@@ -5481,7 +5543,7 @@ function usePanelBus(host, activeSceneId) {
5481
5543
 
5482
5544
  // src/components/DownloadPackButton.tsx
5483
5545
  var import_react23 = require("react");
5484
- var import_jsx_runtime22 = require("react/jsx-runtime");
5546
+ var import_jsx_runtime23 = require("react/jsx-runtime");
5485
5547
  function formatSize(bytes) {
5486
5548
  if (!bytes || bytes <= 0) return "";
5487
5549
  const gb = bytes / 1024 ** 3;
@@ -5569,8 +5631,8 @@ var DownloadPackButton = ({
5569
5631
  } else {
5570
5632
  className = `${baseClasses} text-sas-muted hover:text-sas-accent border-sas-border hover:border-sas-accent`;
5571
5633
  }
5572
- return /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)("div", { children: [
5573
- /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
5634
+ return /* @__PURE__ */ (0, import_jsx_runtime23.jsxs)("div", { children: [
5635
+ /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(
5574
5636
  "button",
5575
5637
  {
5576
5638
  "data-testid": `download-pack-button-${packId}`,
@@ -5581,12 +5643,12 @@ var DownloadPackButton = ({
5581
5643
  children: buttonLabel
5582
5644
  }
5583
5645
  ),
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 })
5646
+ 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
5647
  ] });
5586
5648
  };
5587
5649
 
5588
5650
  // src/components/SamplePackCTACard.tsx
5589
- var import_jsx_runtime23 = require("react/jsx-runtime");
5651
+ var import_jsx_runtime24 = require("react/jsx-runtime");
5590
5652
  var SamplePackCTACard = ({
5591
5653
  host,
5592
5654
  pack,
@@ -5594,7 +5656,7 @@ var SamplePackCTACard = ({
5594
5656
  onDownloadComplete
5595
5657
  }) => {
5596
5658
  if (status === "checking") {
5597
- return /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(
5659
+ return /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
5598
5660
  "div",
5599
5661
  {
5600
5662
  "data-testid": `sample-pack-cta-checking-${pack.packId}`,
@@ -5605,16 +5667,16 @@ var SamplePackCTACard = ({
5605
5667
  }
5606
5668
  const headline = status === "stale" ? `${pack.displayName} update available` : `${pack.displayName} not installed`;
5607
5669
  const sublabel = status === "stale" ? `A newer version is available for download.` : pack.description;
5608
- return /* @__PURE__ */ (0, import_jsx_runtime23.jsxs)(
5670
+ return /* @__PURE__ */ (0, import_jsx_runtime24.jsxs)(
5609
5671
  "div",
5610
5672
  {
5611
5673
  "data-testid": `sample-pack-cta-${pack.packId}`,
5612
5674
  className: "flex flex-col items-center justify-center py-12 px-6 text-center",
5613
5675
  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)(
5676
+ /* @__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" }),
5677
+ /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("div", { className: "text-base text-sas-text mb-1", children: headline }),
5678
+ /* @__PURE__ */ (0, import_jsx_runtime24.jsx)("div", { className: "text-xs text-sas-muted mb-6 max-w-md", children: sublabel }),
5679
+ /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
5618
5680
  DownloadPackButton,
5619
5681
  {
5620
5682
  host,
@@ -5694,7 +5756,7 @@ function drawWaveform(canvas, peaks, options = {}) {
5694
5756
  }
5695
5757
 
5696
5758
  // src/components/WaveformView.tsx
5697
- var import_jsx_runtime24 = require("react/jsx-runtime");
5759
+ var import_jsx_runtime25 = require("react/jsx-runtime");
5698
5760
  var WaveformView = ({
5699
5761
  host,
5700
5762
  filePath,
@@ -5742,7 +5804,7 @@ var WaveformView = ({
5742
5804
  observer.observe(canvas);
5743
5805
  return () => observer.disconnect();
5744
5806
  }, [peaks, fillStyle]);
5745
- return /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
5807
+ return /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(
5746
5808
  "canvas",
5747
5809
  {
5748
5810
  ref: canvasRef,
@@ -5754,7 +5816,7 @@ var WaveformView = ({
5754
5816
 
5755
5817
  // src/components/ScrollingWaveform.tsx
5756
5818
  var import_react25 = require("react");
5757
- var import_jsx_runtime25 = require("react/jsx-runtime");
5819
+ var import_jsx_runtime26 = require("react/jsx-runtime");
5758
5820
  var ScrollingWaveform = ({
5759
5821
  getPeakDb,
5760
5822
  active,
@@ -5830,7 +5892,7 @@ var ScrollingWaveform = ({
5830
5892
  }
5831
5893
  };
5832
5894
  }, [active, getPeakDb, fillStyle]);
5833
- return /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(
5895
+ return /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(
5834
5896
  "canvas",
5835
5897
  {
5836
5898
  ref: canvasRef,
@@ -5842,7 +5904,7 @@ var ScrollingWaveform = ({
5842
5904
 
5843
5905
  // src/components/OffsetScrubber.tsx
5844
5906
  var import_react26 = require("react");
5845
- var import_jsx_runtime26 = require("react/jsx-runtime");
5907
+ var import_jsx_runtime27 = require("react/jsx-runtime");
5846
5908
  var SLIDER_HEIGHT_PX = 28;
5847
5909
  var TICK_HEIGHT_PX = 14;
5848
5910
  var DOWNBEAT_TICK_HEIGHT_PX = 22;
@@ -5957,9 +6019,9 @@ function OffsetScrubber({
5957
6019
  });
5958
6020
  }, [cuePoints, sampleToFraction]);
5959
6021
  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)(
6022
+ return /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)("div", { "data-testid": "offset-scrubber", className: "flex items-center gap-2 w-full", children: [
6023
+ /* @__PURE__ */ (0, import_jsx_runtime27.jsx)("span", { className: "text-[9px] text-sas-muted/60 uppercase tracking-wide flex-shrink-0", children: "Align" }),
6024
+ /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)(
5963
6025
  "div",
5964
6026
  {
5965
6027
  ref: trackRef,
@@ -5975,7 +6037,7 @@ function OffsetScrubber({
5975
6037
  "aria-valuenow": draftOffset,
5976
6038
  "aria-disabled": isDisabled,
5977
6039
  children: [
5978
- /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(
6040
+ /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
5979
6041
  "div",
5980
6042
  {
5981
6043
  "aria-hidden": "true",
@@ -5983,7 +6045,7 @@ function OffsetScrubber({
5983
6045
  style: { left: "50%" }
5984
6046
  }
5985
6047
  ),
5986
- ticks.map((t) => /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(
6048
+ ticks.map((t) => /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
5987
6049
  "div",
5988
6050
  {
5989
6051
  "data-testid": t.isDownbeat ? "offset-tick-downbeat" : "offset-tick",
@@ -5998,7 +6060,7 @@ function OffsetScrubber({
5998
6060
  },
5999
6061
  t.i
6000
6062
  )),
6001
- /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(
6063
+ /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
6002
6064
  "div",
6003
6065
  {
6004
6066
  "data-testid": "offset-scrubber-thumb",
@@ -6015,7 +6077,7 @@ function OffsetScrubber({
6015
6077
  ]
6016
6078
  }
6017
6079
  ),
6018
- /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(
6080
+ /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
6019
6081
  "span",
6020
6082
  {
6021
6083
  "data-testid": "offset-scrubber-readout",
@@ -6023,7 +6085,7 @@ function OffsetScrubber({
6023
6085
  children: formatOffset(draftOffset, sampleRate)
6024
6086
  }
6025
6087
  ),
6026
- /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(
6088
+ /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
6027
6089
  "button",
6028
6090
  {
6029
6091
  type: "button",
@@ -6035,7 +6097,7 @@ function OffsetScrubber({
6035
6097
  children: "\u2316"
6036
6098
  }
6037
6099
  ),
6038
- bpmMismatch && /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(
6100
+ bpmMismatch && /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
6039
6101
  "span",
6040
6102
  {
6041
6103
  "data-testid": "offset-bpm-mismatch",
@@ -7048,7 +7110,7 @@ function useTransitionOps({
7048
7110
  }
7049
7111
 
7050
7112
  // src/panel-core/useGeneratorPanelCore.tsx
7051
- var import_jsx_runtime27 = require("react/jsx-runtime");
7113
+ var import_jsx_runtime28 = require("react/jsx-runtime");
7052
7114
  var EMPTY_PLACEHOLDERS = [];
7053
7115
  function useGeneratorPanelCore({
7054
7116
  ui,
@@ -7459,37 +7521,6 @@ function useGeneratorPanelCore({
7459
7521
  },
7460
7522
  [host, adapter, identity, activeSceneId, isConnected, tracks.length, loadTracks]
7461
7523
  );
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
7524
  const [isExportingMidi, setIsExportingMidi] = (0, import_react31.useState)(false);
7494
7525
  const handleExportMidi = (0, import_react31.useCallback)(async () => {
7495
7526
  if (isExportingMidi) return;
@@ -7545,8 +7576,8 @@ function useGeneratorPanelCore({
7545
7576
  if (!onHeaderContent) return;
7546
7577
  const addDisabled = needsContract || !isConnected || !activeSceneId || tracks.length >= identity.maxTracks || isAddingTrack;
7547
7578
  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)(
7579
+ /* @__PURE__ */ (0, import_jsx_runtime28.jsxs)("div", { className: "flex gap-1 items-center", children: [
7580
+ features.importTracks && (!canCrossfade || !designerView) && host.listImportableTracks && /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
7550
7581
  "button",
7551
7582
  {
7552
7583
  "data-testid": `import-from-scene-${identity.familyKey}-button`,
@@ -7560,7 +7591,7 @@ function useGeneratorPanelCore({
7560
7591
  children: identity.importTrackLabel ?? "Import Track"
7561
7592
  }
7562
7593
  ),
7563
- (!canCrossfade || !designerView) && /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
7594
+ (!canCrossfade || !designerView) && /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
7564
7595
  "button",
7565
7596
  {
7566
7597
  "data-testid": `add-${identity.familyKey}-track-button`,
@@ -7576,7 +7607,7 @@ function useGeneratorPanelCore({
7576
7607
  children: identity.addTrackLabel ?? "Add Track"
7577
7608
  }
7578
7609
  ),
7579
- canCrossfade && /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)(
7610
+ canCrossfade && /* @__PURE__ */ (0, import_jsx_runtime28.jsxs)(
7580
7611
  "button",
7581
7612
  {
7582
7613
  "data-testid": `${identity.familyKey}-view-toggle`,
@@ -7595,7 +7626,7 @@ function useGeneratorPanelCore({
7595
7626
  title: designerView ? "Back to the track list" : "Open the transition designer",
7596
7627
  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
7628
  children: [
7598
- transitionSourceTotal > 0 && /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(
7629
+ transitionSourceTotal > 0 && /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
7599
7630
  "span",
7600
7631
  {
7601
7632
  className: "absolute inset-y-0 left-0 bg-sas-accent/25",
@@ -7603,7 +7634,7 @@ function useGeneratorPanelCore({
7603
7634
  "aria-hidden": true
7604
7635
  }
7605
7636
  ),
7606
- /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)("span", { className: "relative", children: [
7637
+ /* @__PURE__ */ (0, import_jsx_runtime28.jsxs)("span", { className: "relative", children: [
7607
7638
  "\u21C4 ",
7608
7639
  designerView ? "Transition" : "Tracks",
7609
7640
  transitionSourceTotal > 0 ? ` ${transitionDone}/${transitionSourceTotal}` : ""
@@ -7736,9 +7767,163 @@ function useGeneratorPanelCore({
7736
7767
  name: `${identity.trackNamePrefix}-${Date.now()}${nameSuffix}`,
7737
7768
  ...adapter.createTrackOptions()
7738
7769
  }),
7739
- resolvedGroups: (metaKey) => resolvedGenericGroupsRef.current[metaKey]?.resolved ?? []
7770
+ resolvedGroups: (metaKey) => resolvedGenericGroupsRef.current[metaKey]?.resolved ?? [],
7771
+ sound: adapter.sound
7740
7772
  };
7741
7773
  }, [host, activeSceneId, updateTrack, loadTracks, soundHistory, engineToDbId, markEditLoaded, identity, adapter]);
7774
+ const broadcastSoundFromTrack = (0, import_react31.useCallback)(
7775
+ async (sourceTrackId, descriptor, label) => {
7776
+ const targetsOf = adapter.sound.broadcastTargets;
7777
+ if (!targetsOf) return;
7778
+ const source = tracksRef.current.find((t) => t.handle.id === sourceTrackId);
7779
+ if (!source) return;
7780
+ let targets;
7781
+ try {
7782
+ targets = await targetsOf(source, makeServices());
7783
+ } catch (err) {
7784
+ console.warn(`[${logTag}] broadcastTargets failed:`, err);
7785
+ return;
7786
+ }
7787
+ const sourceInstrument = source.instrumentPluginId ?? null;
7788
+ const others = (targets ?? []).filter((t) => {
7789
+ if (t.engineId === sourceTrackId) return false;
7790
+ const live = tracksRef.current.find((x) => x.handle.id === t.engineId);
7791
+ return (live?.instrumentPluginId ?? null) === sourceInstrument;
7792
+ });
7793
+ if (others.length === 0) return;
7794
+ try {
7795
+ await adapter.sound.persistDescriptor?.(sourceTrackId, descriptor, label);
7796
+ } catch {
7797
+ }
7798
+ const appliedIds = /* @__PURE__ */ new Set();
7799
+ const failed = [];
7800
+ for (const target of others) {
7801
+ try {
7802
+ if (soundHistory.list(target.engineId).entries.length === 0) {
7803
+ try {
7804
+ const cap = await adapter.sound.captureSoundDescriptor(target.engineId);
7805
+ if (cap) {
7806
+ soundHistory.record(target.engineId, cap.descriptor, adapter.sound.previousSoundLabel);
7807
+ }
7808
+ } catch {
7809
+ }
7810
+ }
7811
+ await adapter.sound.applySound(target.engineId, descriptor);
7812
+ try {
7813
+ await adapter.sound.persistDescriptor?.(target.engineId, descriptor, label);
7814
+ } catch {
7815
+ }
7816
+ soundHistory.record(target.engineId, descriptor, label);
7817
+ appliedIds.add(target.engineId);
7818
+ } catch (err) {
7819
+ failed.push(target.label ?? target.engineId);
7820
+ console.warn(`[${logTag}] Linked sound apply failed for ${target.engineId}:`, err);
7821
+ }
7822
+ }
7823
+ if (appliedIds.size > 0) {
7824
+ setTracks(
7825
+ (prev) => prev.map(
7826
+ (t) => appliedIds.has(t.handle.id) ? { ...t, shuffleHistory: /* @__PURE__ */ new Set([...t.shuffleHistory, label]) } : t
7827
+ )
7828
+ );
7829
+ }
7830
+ if (failed.length > 0) {
7831
+ host.showToast(
7832
+ "warning",
7833
+ "Linked sound applied to some parts only",
7834
+ `Skipped: ${failed.join(", ")}`
7835
+ );
7836
+ }
7837
+ },
7838
+ [adapter, makeServices, soundHistory, host, logTag]
7839
+ );
7840
+ const broadcastInstrumentFromTrack = (0, import_react31.useCallback)(
7841
+ async (sourceTrackId, pluginId) => {
7842
+ const targetsOf = adapter.sound.broadcastTargets;
7843
+ if (!targetsOf) return;
7844
+ const source = tracksRef.current.find((t) => t.handle.id === sourceTrackId);
7845
+ if (!source) return;
7846
+ let targets;
7847
+ try {
7848
+ targets = await targetsOf(source, makeServices());
7849
+ } catch (err) {
7850
+ console.warn(`[${logTag}] broadcastTargets failed:`, err);
7851
+ return;
7852
+ }
7853
+ const others = (targets ?? []).filter((t) => t.engineId !== sourceTrackId);
7854
+ if (others.length === 0) return;
7855
+ const failed = [];
7856
+ for (const target of others) {
7857
+ try {
7858
+ await host.setTrackInstrument(target.engineId, pluginId);
7859
+ const descriptor = await host.getTrackInstrument(target.engineId);
7860
+ setTracks(
7861
+ (prev) => prev.map(
7862
+ (t) => t.handle.id === target.engineId ? {
7863
+ ...t,
7864
+ instrumentPluginId: descriptor?.pluginId ?? null,
7865
+ instrumentName: descriptor?.name ?? null,
7866
+ instrumentMissing: descriptor?.missing ?? false
7867
+ } : t
7868
+ )
7869
+ );
7870
+ } catch (err) {
7871
+ failed.push(target.label ?? target.engineId);
7872
+ console.warn(`[${logTag}] Linked instrument apply failed for ${target.engineId}:`, err);
7873
+ }
7874
+ }
7875
+ if (failed.length > 0) {
7876
+ host.showToast(
7877
+ "warning",
7878
+ "Instrument applied to some parts only",
7879
+ `Skipped: ${failed.join(", ")}`
7880
+ );
7881
+ }
7882
+ },
7883
+ [adapter, makeServices, host, logTag]
7884
+ );
7885
+ const handleRestoreSound = (0, import_react31.useCallback)(
7886
+ async (trackId, index) => {
7887
+ const entry = soundHistory.list(trackId).entries[index];
7888
+ const moved = await soundHistory.restoreTo(trackId, index);
7889
+ if (moved && entry) {
7890
+ await broadcastSoundFromTrack(trackId, entry.descriptor, entry.label);
7891
+ }
7892
+ },
7893
+ [soundHistory, broadcastSoundFromTrack]
7894
+ );
7895
+ const handleSoundImportPick = (0, import_react31.useCallback)(
7896
+ async (sel) => {
7897
+ const target = soundImportTarget;
7898
+ if (!target || !host.getTrackSound) {
7899
+ setSoundImportTarget(null);
7900
+ return;
7901
+ }
7902
+ const noun = adapter.sound.importNoun;
7903
+ const nounTitle = noun.charAt(0).toUpperCase() + noun.slice(1);
7904
+ try {
7905
+ const snap = await host.getTrackSound(sel.sourceTrackDbId);
7906
+ if (!snap || snap.kind !== adapter.sound.acceptedSnapshotKind) {
7907
+ host.showToast(
7908
+ "error",
7909
+ `No ${noun} to import`,
7910
+ `${sel.trackName} has no ${identity.familyKey} ${noun}.`
7911
+ );
7912
+ return;
7913
+ }
7914
+ const descriptor = adapter.sound.descriptorFromSnapshot(snap);
7915
+ await adapter.sound.applySound(target.handle.id, descriptor);
7916
+ soundHistory.record(target.handle.id, descriptor, snap.label);
7917
+ host.showToast("success", `${nounTitle} imported`, `${snap.label} \u2192 ${target.handle.name}`);
7918
+ await broadcastSoundFromTrack(target.handle.id, descriptor, snap.label);
7919
+ } catch (err) {
7920
+ host.showToast("error", "Import failed", err instanceof Error ? err.message : String(err));
7921
+ } finally {
7922
+ setSoundImportTarget(null);
7923
+ }
7924
+ },
7925
+ [soundImportTarget, host, adapter, identity.familyKey, soundHistory, broadcastSoundFromTrack]
7926
+ );
7742
7927
  const handleGenerate = (0, import_react31.useCallback)(
7743
7928
  async (trackId) => {
7744
7929
  const track = tracks.find((t) => t.handle.id === trackId);
@@ -7857,7 +8042,10 @@ function useGeneratorPanelCore({
7857
8042
  );
7858
8043
  try {
7859
8044
  const cap = await adapter.sound.captureSoundDescriptor(trackId);
7860
- if (cap) soundHistory.record(trackId, cap.descriptor, result.appliedName);
8045
+ if (cap) {
8046
+ soundHistory.record(trackId, cap.descriptor, result.appliedName);
8047
+ await broadcastSoundFromTrack(trackId, cap.descriptor, result.appliedName);
8048
+ }
7861
8049
  } catch {
7862
8050
  }
7863
8051
  console.log(`[${logTag}] Sound shuffled: ${result.appliedName} (history ${nextHistory.size})`);
@@ -7866,7 +8054,7 @@ function useGeneratorPanelCore({
7866
8054
  host.showToast("error", "Shuffle failed", msg);
7867
8055
  }
7868
8056
  },
7869
- [host, adapter, tracks, soundHistory, logTag]
8057
+ [host, adapter, tracks, soundHistory, logTag, broadcastSoundFromTrack]
7870
8058
  );
7871
8059
  const handleCopy = (0, import_react31.useCallback)(
7872
8060
  async (trackId) => {
@@ -8069,6 +8257,7 @@ function useGeneratorPanelCore({
8069
8257
  } : t
8070
8258
  )
8071
8259
  );
8260
+ await broadcastInstrumentFromTrack(trackId, pluginId);
8072
8261
  } catch (err) {
8073
8262
  const msg = err instanceof Error ? err.message : "Failed to load instrument";
8074
8263
  host.showToast("error", "Instrument load failed", msg);
@@ -8091,6 +8280,7 @@ function useGeneratorPanelCore({
8091
8280
  } : t
8092
8281
  )
8093
8282
  );
8283
+ await broadcastInstrumentFromTrack(trackId, pluginId);
8094
8284
  } catch (err) {
8095
8285
  const msg = err instanceof Error ? err.message : "Failed to load instrument";
8096
8286
  console.error(`[${logTag}] Failed to set instrument:`, err);
@@ -8100,7 +8290,7 @@ function useGeneratorPanelCore({
8100
8290
  );
8101
8291
  }
8102
8292
  },
8103
- [host, identity.defaultInstrumentPluginId, logTag]
8293
+ [host, identity.defaultInstrumentPluginId, logTag, broadcastInstrumentFromTrack]
8104
8294
  );
8105
8295
  const handleShowEditor = (0, import_react31.useCallback)(
8106
8296
  async (trackId) => {
@@ -8310,6 +8500,7 @@ function useGeneratorPanelCore({
8310
8500
  handlers,
8311
8501
  handleGenerate,
8312
8502
  handleShuffle,
8503
+ handleRestoreSound,
8313
8504
  handleAddTrack,
8314
8505
  handleDeleteTrack,
8315
8506
  handleExportMidi,
@@ -8340,8 +8531,41 @@ function useGeneratorPanelCore({
8340
8531
  }
8341
8532
 
8342
8533
  // src/panel-core/GeneratorPanelShell.tsx
8343
- var import_react32 = __toESM(require("react"));
8344
- var import_jsx_runtime28 = require("react/jsx-runtime");
8534
+ var import_react32 = require("react");
8535
+ var import_jsx_runtime29 = require("react/jsx-runtime");
8536
+ var GROUP_UI_KEY = "groupUi";
8537
+ function CollapsibleGroup({
8538
+ ext,
8539
+ group,
8540
+ groupCtx
8541
+ }) {
8542
+ const { host, activeSceneId, trackDataKey: trackDataKey2 } = groupCtx.services;
8543
+ const uiKey = trackDataKey2(group.groupId, GROUP_UI_KEY);
8544
+ const [collapsed, setCollapsed] = (0, import_react32.useState)(false);
8545
+ (0, import_react32.useEffect)(() => {
8546
+ let cancelled = false;
8547
+ if (!activeSceneId) return void 0;
8548
+ void host.getSceneData(activeSceneId, uiKey).then((raw) => {
8549
+ if (cancelled || !raw || typeof raw !== "object") return;
8550
+ setCollapsed(raw.collapsed === true);
8551
+ }).catch(() => {
8552
+ });
8553
+ return () => {
8554
+ cancelled = true;
8555
+ };
8556
+ }, [host, activeSceneId, uiKey]);
8557
+ const onToggleCollapse = (0, import_react32.useCallback)(() => {
8558
+ setCollapsed((prev) => {
8559
+ const next = !prev;
8560
+ if (activeSceneId) {
8561
+ void host.setSceneData(activeSceneId, uiKey, { collapsed: next }).catch(() => {
8562
+ });
8563
+ }
8564
+ return next;
8565
+ });
8566
+ }, [host, activeSceneId, uiKey]);
8567
+ return /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(import_jsx_runtime29.Fragment, { children: ext.renderGroup(group, { ...groupCtx, collapsed, onToggleCollapse }) });
8568
+ }
8345
8569
  function GeneratorPanelShell({ core, slots }) {
8346
8570
  const {
8347
8571
  ui,
@@ -8364,6 +8588,7 @@ function GeneratorPanelShell({ core, slots }) {
8364
8588
  soundImportTarget,
8365
8589
  setSoundImportTarget,
8366
8590
  handleSoundImportPick,
8591
+ handleRestoreSound,
8367
8592
  handlePortTrack,
8368
8593
  transition,
8369
8594
  crossfadePairsMeta,
@@ -8459,7 +8684,7 @@ function GeneratorPanelShell({ core, slots }) {
8459
8684
  soundHistory: soundHistory.list(id).entries,
8460
8685
  soundHistoryCursor: soundHistory.list(id).cursor,
8461
8686
  onRestoreSound: (i) => {
8462
- void soundHistory.restoreTo(id, i);
8687
+ void handleRestoreSound(id, i);
8463
8688
  },
8464
8689
  onToggleFavorite: (i) => soundHistory.toggleFavorite(id, i),
8465
8690
  ...importSoundProps,
@@ -8490,6 +8715,7 @@ function GeneratorPanelShell({ core, slots }) {
8490
8715
  handleBackToInstruments,
8491
8716
  setSoundImportTarget,
8492
8717
  soundHistory,
8718
+ handleRestoreSound,
8493
8719
  handleFxToggle,
8494
8720
  handleFxPresetChange,
8495
8721
  handleFxDryWetChange,
@@ -8497,12 +8723,12 @@ function GeneratorPanelShell({ core, slots }) {
8497
8723
  ]
8498
8724
  );
8499
8725
  if (!activeSceneId) {
8500
- return /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
8726
+ return /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8501
8727
  "div",
8502
8728
  {
8503
8729
  "data-testid": `no-scene-placeholder-${identity.familyKey}`,
8504
8730
  className: "flex items-center justify-center py-8",
8505
- children: /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
8731
+ children: /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8506
8732
  "button",
8507
8733
  {
8508
8734
  onClick: () => onSelectScene?.(),
@@ -8514,12 +8740,12 @@ function GeneratorPanelShell({ core, slots }) {
8514
8740
  );
8515
8741
  }
8516
8742
  if (!sceneContext?.hasContract) {
8517
- return /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
8743
+ return /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8518
8744
  "div",
8519
8745
  {
8520
8746
  "data-testid": `no-contract-placeholder-${identity.familyKey}`,
8521
8747
  className: "flex items-center justify-center py-8",
8522
- children: /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
8748
+ children: /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8523
8749
  "button",
8524
8750
  {
8525
8751
  onClick: () => onOpenContract?.(),
@@ -8531,7 +8757,7 @@ function GeneratorPanelShell({ core, slots }) {
8531
8757
  );
8532
8758
  }
8533
8759
  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" }) });
8760
+ 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
8761
  }
8536
8762
  const activePlaceholders = features.bulkComposePlaceholders ? placeholders : [];
8537
8763
  if (activePlaceholders.length > 0) {
@@ -8542,18 +8768,18 @@ function GeneratorPanelShell({ core, slots }) {
8542
8768
  tracksByDbId.set(t.handle.id, t);
8543
8769
  }
8544
8770
  }
8545
- return /* @__PURE__ */ (0, import_jsx_runtime28.jsx)("div", { "data-testid": `${identity.familyKey}-section`, className: "p-2 space-y-2", children: activePlaceholders.map((ph) => {
8771
+ return /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("div", { "data-testid": `${identity.familyKey}-section`, className: "p-2 space-y-2", children: activePlaceholders.map((ph) => {
8546
8772
  const loadedTrack = ph.status === "completed" ? tracksByDbId.get(ph.id) : void 0;
8547
8773
  if (loadedTrack) {
8548
- return /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(TrackRow, { ...buildRowProps(loadedTrack) }, ph.id);
8774
+ return /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(TrackRow, { ...buildRowProps(loadedTrack) }, ph.id);
8549
8775
  }
8550
- return /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
8776
+ return /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8551
8777
  "div",
8552
8778
  {
8553
8779
  "data-testid": "bulk-placeholder-track",
8554
8780
  className: "relative rounded-sm border w-full overflow-hidden border-sas-border bg-sas-panel-alt",
8555
8781
  style: { borderLeftColor: identity.placeholderAccentColor, borderLeftWidth: "3px" },
8556
- children: /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(SorceryProgressBar, { isLoading: true, statusText: "CONJURING MIDI...", heightClass: "h-10" })
8782
+ children: /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(SorceryProgressBar, { isLoading: true, statusText: "CONJURING MIDI...", heightClass: "h-10" })
8557
8783
  },
8558
8784
  ph.id
8559
8785
  );
@@ -8565,13 +8791,13 @@ function GeneratorPanelShell({ core, slots }) {
8565
8791
  supportsMeters,
8566
8792
  levels: supportsMeters ? trackLevels : void 0,
8567
8793
  handlers,
8568
- renderDefaultTrackRow: (track, overrides, drag) => /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(TrackRow, { ...{ ...buildRowProps(track, drag), ...overrides ?? {} } }, track.handle.id),
8794
+ renderDefaultTrackRow: (track, overrides, drag) => /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(TrackRow, { ...{ ...buildRowProps(track, drag), ...overrides ?? {} } }, track.handle.id),
8569
8795
  setGroupMute,
8570
8796
  setGroupSolo,
8571
8797
  deleteGroup
8572
8798
  };
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)(
8799
+ return /* @__PURE__ */ (0, import_jsx_runtime29.jsxs)("div", { "data-testid": `${identity.familyKey}-section`, className: "p-2 space-y-2", children: [
8800
+ features.importTracks && host.listImportableTracks && /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8575
8801
  ImportTrackModal,
8576
8802
  {
8577
8803
  host,
@@ -8584,7 +8810,7 @@ function GeneratorPanelShell({ core, slots }) {
8584
8810
  testIdPrefix: `${identity.familyKey}-import`
8585
8811
  }
8586
8812
  ),
8587
- features.importTracks && host.listImportableTracks && host.getTrackSound && /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
8813
+ features.importTracks && host.listImportableTracks && host.getTrackSound && /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8588
8814
  ImportTrackModal,
8589
8815
  {
8590
8816
  host,
@@ -8599,7 +8825,7 @@ function GeneratorPanelShell({ core, slots }) {
8599
8825
  }
8600
8826
  ),
8601
8827
  slots?.modals,
8602
- canCrossfade && xfFromId && xfToId && /* @__PURE__ */ (0, import_jsx_runtime28.jsx)("div", { className: designerView ? "contents" : "hidden", children: /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
8828
+ canCrossfade && xfFromId && xfToId && /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("div", { className: designerView ? "contents" : "hidden", children: /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8603
8829
  TransitionDesigner,
8604
8830
  {
8605
8831
  host,
@@ -8618,8 +8844,8 @@ function GeneratorPanelShell({ core, slots }) {
8618
8844
  testIdPrefix: `${identity.familyKey}-transition-designer`
8619
8845
  }
8620
8846
  ) }),
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)(
8847
+ !(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: [
8848
+ panelBus.supported && panelBus.bus && /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8623
8849
  PanelMasterStrip,
8624
8850
  {
8625
8851
  bus: panelBus.bus,
@@ -8640,7 +8866,7 @@ function GeneratorPanelShell({ core, slots }) {
8640
8866
  }
8641
8867
  ),
8642
8868
  slots?.beforeRows,
8643
- resolvedCrossfadePairs.map((pair) => /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
8869
+ resolvedCrossfadePairs.map((pair) => /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8644
8870
  CrossfadeTrackRow,
8645
8871
  {
8646
8872
  accentColor: identity.transitionAccentColor,
@@ -8677,7 +8903,7 @@ function GeneratorPanelShell({ core, slots }) {
8677
8903
  },
8678
8904
  pair.groupId
8679
8905
  )),
8680
- resolvedSingleFades.map((fade) => /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
8906
+ resolvedSingleFades.map((fade) => /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8681
8907
  FadeTrackRow,
8682
8908
  {
8683
8909
  accentColor: identity.transitionAccentColor,
@@ -8702,7 +8928,7 @@ function GeneratorPanelShell({ core, slots }) {
8702
8928
  },
8703
8929
  fade.dbId
8704
8930
  )),
8705
- resolvedGroupFades.map((group) => /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
8931
+ resolvedGroupFades.map((group) => /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8706
8932
  GroupFadeTrackRow,
8707
8933
  {
8708
8934
  accentColor: identity.transitionAccentColor,
@@ -8738,20 +8964,28 @@ function GeneratorPanelShell({ core, slots }) {
8738
8964
  group.groupId
8739
8965
  )),
8740
8966
  (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}`))
8967
+ (ext) => (resolvedGenericGroups[ext.metaKey]?.resolved ?? []).filter((group) => !group.members.every((m) => fadeMemberDbIds.has(m.dbId))).map((group) => /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8968
+ CollapsibleGroup,
8969
+ {
8970
+ ext,
8971
+ group,
8972
+ groupCtx
8973
+ },
8974
+ `${ext.metaKey}:${group.groupId}`
8975
+ ))
8742
8976
  ),
8743
8977
  tracks.map((track, index) => {
8744
8978
  if (crossfadeMemberDbIds.has(track.handle.dbId) || fadeMemberDbIds.has(track.handle.dbId) || genericGroupMemberDbIds.has(track.handle.dbId)) {
8745
8979
  return null;
8746
8980
  }
8747
- return /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(TrackRow, { ...buildRowProps(track, reorder.dragPropsFor(index)) }, track.handle.id);
8981
+ return /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(TrackRow, { ...buildRowProps(track, reorder.dragPropsFor(index)) }, track.handle.id);
8748
8982
  }),
8749
8983
  slots?.afterRows
8750
8984
  ] })),
8751
8985
  features.exportMidi && !designerView && !isLoadingTracks && tracks.length > 0 && (() => {
8752
8986
  const hasAnyMidi = tracks.some((t) => t.hasMidi);
8753
8987
  const exportDisabled = isExportingMidi || !hasAnyMidi;
8754
- return /* @__PURE__ */ (0, import_jsx_runtime28.jsx)("div", { className: "pt-2", children: /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(
8988
+ return /* @__PURE__ */ (0, import_jsx_runtime29.jsx)("div", { className: "pt-2", children: /* @__PURE__ */ (0, import_jsx_runtime29.jsx)(
8755
8989
  "button",
8756
8990
  {
8757
8991
  "data-testid": "export-midi-tracks-button",
@@ -8806,6 +9040,15 @@ function createSurgeSoundAdapter(host, overrides = {}) {
8806
9040
  });
8807
9041
  return snap.label;
8808
9042
  },
9043
+ persistDescriptor: async (trackId, descriptor, label) => {
9044
+ const { state, stateType } = descriptor;
9045
+ await host.persistTrackPresetState?.(trackId, {
9046
+ state,
9047
+ stateType: stateType ?? "valuetree",
9048
+ name: label
9049
+ }).catch(() => {
9050
+ });
9051
+ },
8809
9052
  descriptorFromSnapshot: (snap) => {
8810
9053
  const preset = snap;
8811
9054
  return { state: preset.state, stateType: preset.stateType };
@@ -8890,18 +9133,183 @@ var SUB = {
8890
9133
  rootOnly: true,
8891
9134
  monoPreference: "low"
8892
9135
  };
9136
+ var HORN_MAX_NOTES_PER_BAR = 12;
9137
+ var HORN_FOLLOW_PALETTE = "EXACTLY the lead voice's rhythm \u2014 every attack lands together with voice 1";
9138
+ var HORN_LEAD = {
9139
+ label: "lead trumpet",
9140
+ role: "brass",
9141
+ registerLow: 60,
9142
+ // C4
9143
+ registerHigh: 84,
9144
+ // C6
9145
+ maxNotesPerBar: HORN_MAX_NOTES_PER_BAR,
9146
+ rhythmPalette: "tight funk syncopation \u2014 8ths and 16ths, staccato punches, off-beat anticipations",
9147
+ harmonicDiscipline: "the section's melodic top \u2014 chord tones on every hit; quick chromatic approach tones only on pickups",
9148
+ monoPreference: "high"
9149
+ };
9150
+ var HORN_TRUMPET_2 = {
9151
+ label: "second trumpet",
9152
+ role: "brass",
9153
+ registerLow: 55,
9154
+ // G3
9155
+ registerHigh: 79,
9156
+ // G5
9157
+ maxNotesPerBar: HORN_MAX_NOTES_PER_BAR,
9158
+ rhythmPalette: HORN_FOLLOW_PALETTE,
9159
+ harmonicDiscipline: "nearest chord tone directly below voice 1 \u2014 tight close voicing",
9160
+ monoPreference: "high"
9161
+ };
9162
+ var HORN_ALTO = {
9163
+ label: "alto sax",
9164
+ role: "brass",
9165
+ registerLow: 53,
9166
+ // F3
9167
+ registerHigh: 77,
9168
+ // F5
9169
+ maxNotesPerBar: HORN_MAX_NOTES_PER_BAR,
9170
+ rhythmPalette: HORN_FOLLOW_PALETTE,
9171
+ harmonicDiscipline: "chord tone below the trumpets \u2014 keep the upper stack inside one octave",
9172
+ monoPreference: "high"
9173
+ };
9174
+ var HORN_TENOR = {
9175
+ label: "tenor sax",
9176
+ role: "brass",
9177
+ registerLow: 46,
9178
+ // Bb2
9179
+ registerHigh: 72,
9180
+ // C5
9181
+ maxNotesPerBar: HORN_MAX_NOTES_PER_BAR,
9182
+ rhythmPalette: HORN_FOLLOW_PALETTE,
9183
+ harmonicDiscipline: "chord tone under the upper horns \u2014 complete the chord",
9184
+ monoPreference: "low"
9185
+ };
9186
+ var HORN_TROMBONE = {
9187
+ label: "trombone",
9188
+ role: "brass",
9189
+ registerLow: 43,
9190
+ // G2
9191
+ registerHigh: 67,
9192
+ // G4
9193
+ maxNotesPerBar: HORN_MAX_NOTES_PER_BAR,
9194
+ rhythmPalette: HORN_FOLLOW_PALETTE,
9195
+ harmonicDiscipline: "chord tone or root below the saxes \u2014 weight in the middle-low stack",
9196
+ monoPreference: "low"
9197
+ };
9198
+ var HORN_BARI = {
9199
+ label: "baritone sax",
9200
+ role: "brass",
9201
+ registerLow: 36,
9202
+ // C2
9203
+ registerHigh: 60,
9204
+ // C4
9205
+ maxNotesPerBar: HORN_MAX_NOTES_PER_BAR,
9206
+ rhythmPalette: HORN_FOLLOW_PALETTE,
9207
+ harmonicDiscipline: "chord roots and lower chord tones \u2014 the section's bottom anchor",
9208
+ monoPreference: "low"
9209
+ };
9210
+ var WIND_FLUTE = {
9211
+ label: "flute",
9212
+ role: "winds",
9213
+ registerLow: 60,
9214
+ // C4
9215
+ registerHigh: 93,
9216
+ // A6
9217
+ maxNotesPerBar: 8,
9218
+ rhythmPalette: "8ths and 16ths; runs, turns and trills welcome",
9219
+ harmonicDiscipline: "freest voice \u2014 non-chord tones as passing/neighbor tones on weak beats, resolving by step",
9220
+ monoPreference: "high"
9221
+ };
9222
+ var WIND_FLUTE_2 = {
9223
+ label: "second flute",
9224
+ role: "winds",
9225
+ registerLow: 60,
9226
+ // C4
9227
+ registerHigh: 86,
9228
+ // D6
9229
+ maxNotesPerBar: 5,
9230
+ rhythmPalette: "quarters with occasional 8th-note motion",
9231
+ harmonicDiscipline: "chord tones; fill gaps the other inner voices leave",
9232
+ monoPreference: "high"
9233
+ };
9234
+ var WIND_OBOE = {
9235
+ label: "oboe",
9236
+ role: "winds",
9237
+ registerLow: 58,
9238
+ // Bb3
9239
+ registerHigh: 84,
9240
+ // C6
9241
+ maxNotesPerBar: 6,
9242
+ rhythmPalette: "8ths and quarters; move when the flute rests",
9243
+ harmonicDiscipline: "mostly chord tones; may imitate the flute's motifs a bar later",
9244
+ monoPreference: "high"
9245
+ };
9246
+ var WIND_CLARINET = {
9247
+ label: "clarinet",
9248
+ role: "winds",
9249
+ registerLow: 50,
9250
+ // D3
9251
+ registerHigh: 81,
9252
+ // A5
9253
+ maxNotesPerBar: 4,
9254
+ rhythmPalette: "quarters and halves",
9255
+ harmonicDiscipline: "chord tones with smooth stepwise motion between them",
9256
+ monoPreference: "high"
9257
+ };
9258
+ var WIND_FRENCH_HORN = {
9259
+ label: "french horn",
9260
+ role: "winds",
9261
+ registerLow: 41,
9262
+ // F2
9263
+ registerHigh: 72,
9264
+ // C5
9265
+ maxNotesPerBar: 4,
9266
+ rhythmPalette: "quarters and halves; long held tones welcome",
9267
+ harmonicDiscipline: "chord tones \u2014 the warm glue in the middle of the ensemble",
9268
+ monoPreference: "low"
9269
+ };
9270
+ var WIND_BASSOON = {
9271
+ label: "bassoon",
9272
+ role: "winds",
9273
+ registerLow: 34,
9274
+ // Bb1
9275
+ registerHigh: 62,
9276
+ // D4
9277
+ maxNotesPerBar: 3,
9278
+ rhythmPalette: "quarters and halves; brief walking figures at cadences",
9279
+ harmonicDiscipline: "roots and fifths emphasized; passing tones only between chord tones",
9280
+ monoPreference: "low"
9281
+ };
8893
9282
  var ENSEMBLE_MIN_VOICES = 2;
8894
9283
  var ENSEMBLE_MAX_VOICES = 6;
8895
- var SPEC_TABLES = {
9284
+ var STRINGS_SPEC_TABLES = {
8896
9285
  2: [TOP, BASS],
8897
9286
  3: [TOP, INNER, BASS],
8898
9287
  4: [TOP, COUNTER, TENOR, BASS],
8899
9288
  5: [TOP, COUNTER, INNER, TENOR, SUB],
8900
9289
  6: [TOP, COUNTER, INNER_2, INNER, TENOR, SUB]
8901
9290
  };
8902
- function defaultVoiceSpecs(voiceCount) {
9291
+ var HORNS_SPEC_TABLES = {
9292
+ 2: [HORN_LEAD, HORN_TENOR],
9293
+ 3: [HORN_LEAD, HORN_TENOR, HORN_BARI],
9294
+ 4: [HORN_LEAD, HORN_TRUMPET_2, HORN_TENOR, HORN_BARI],
9295
+ 5: [HORN_LEAD, HORN_TRUMPET_2, HORN_TENOR, HORN_TROMBONE, HORN_BARI],
9296
+ 6: [HORN_LEAD, HORN_TRUMPET_2, HORN_ALTO, HORN_TENOR, HORN_TROMBONE, HORN_BARI]
9297
+ };
9298
+ var WINDS_SPEC_TABLES = {
9299
+ 2: [WIND_FLUTE, WIND_BASSOON],
9300
+ 3: [WIND_FLUTE, WIND_CLARINET, WIND_BASSOON],
9301
+ 4: [WIND_FLUTE, WIND_OBOE, WIND_FRENCH_HORN, WIND_BASSOON],
9302
+ 5: [WIND_FLUTE, WIND_OBOE, WIND_CLARINET, WIND_FRENCH_HORN, WIND_BASSOON],
9303
+ 6: [WIND_FLUTE, WIND_FLUTE_2, WIND_OBOE, WIND_CLARINET, WIND_FRENCH_HORN, WIND_BASSOON]
9304
+ };
9305
+ var SPEC_TABLES_BY_INSTRUMENTATION = {
9306
+ strings: STRINGS_SPEC_TABLES,
9307
+ horns: HORNS_SPEC_TABLES,
9308
+ winds: WINDS_SPEC_TABLES
9309
+ };
9310
+ function defaultVoiceSpecs(voiceCount, instrumentation = "strings") {
8903
9311
  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 }));
9312
+ return SPEC_TABLES_BY_INSTRUMENTATION[instrumentation][n].map((spec, voiceIndex) => ({ ...spec, voiceIndex }));
8905
9313
  }
8906
9314
 
8907
9315
  // src/ensemble-core/enforce-voice.ts
@@ -8941,10 +9349,14 @@ function enforceVoice(rawNotes, spec, opts) {
8941
9349
  repairs.push(`voice ${spec.voiceIndex}: dropped note outside the ${opts.bars}-bar clip (start ${n.startBeat})`);
8942
9350
  continue;
8943
9351
  }
8944
- const durationBeats = Math.max(
9352
+ let durationBeats = Math.max(
8945
9353
  MIN_NOTE_DURATION_BEATS,
8946
9354
  Math.min(n.durationBeats, clipEnd - n.startBeat)
8947
9355
  );
9356
+ if (opts.maxNoteDurationBeats !== void 0 && durationBeats > opts.maxNoteDurationBeats) {
9357
+ durationBeats = Math.max(MIN_NOTE_DURATION_BEATS, opts.maxNoteDurationBeats);
9358
+ repairs.push(`voice ${spec.voiceIndex}: trimmed note at beat ${n.startBeat} to the style's ${opts.maxNoteDurationBeats}-beat stab ceiling`);
9359
+ }
8948
9360
  notes.push({ ...n, durationBeats });
8949
9361
  }
8950
9362
  notes = notes.map((n) => {
@@ -9105,12 +9517,19 @@ function describeViolations(analysis, rules) {
9105
9517
  if (pair.onsetIndependence < rules.minOnsetIndependence) {
9106
9518
  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
9519
  }
9520
+ if (rules.maxOnsetIndependence !== void 0 && pair.onsetIndependence > rules.maxOnsetIndependence) {
9521
+ 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.`);
9522
+ }
9108
9523
  }
9109
9524
  return out;
9110
9525
  }
9111
9526
 
9112
9527
  // src/ensemble-core/styles.ts
9113
9528
  var ENSEMBLE_STYLES = ["counterpoint", "chorale", "interlock"];
9529
+ var STAB_MAX_ONSET_INDEPENDENCE = 0.25;
9530
+ var RIFF_MAX_ONSET_INDEPENDENCE = 0.3;
9531
+ var UNISON_MAX_ONSET_INDEPENDENCE = 0.15;
9532
+ var STAB_MAX_NOTE_DURATION_BEATS = 1;
9114
9533
  var STYLE_RULES = {
9115
9534
  counterpoint: {
9116
9535
  forbidParallelPerfects: true,
@@ -9129,9 +9548,55 @@ var STYLE_RULES = {
9129
9548
  forbidVoiceCrossing: false,
9130
9549
  minOnsetIndependence: 0.6,
9131
9550
  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."
9551
+ },
9552
+ stabs: {
9553
+ forbidParallelPerfects: false,
9554
+ forbidVoiceCrossing: true,
9555
+ minOnsetIndependence: 0,
9556
+ maxOnsetIndependence: STAB_MAX_ONSET_INDEPENDENCE,
9557
+ maxNoteDurationBeats: STAB_MAX_NOTE_DURATION_BEATS,
9558
+ 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.`
9559
+ },
9560
+ riffs: {
9561
+ forbidParallelPerfects: false,
9562
+ forbidVoiceCrossing: true,
9563
+ minOnsetIndependence: 0,
9564
+ maxOnsetIndependence: RIFF_MAX_ONSET_INDEPENDENCE,
9565
+ 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.'
9566
+ },
9567
+ unison: {
9568
+ forbidParallelPerfects: false,
9569
+ forbidVoiceCrossing: false,
9570
+ minOnsetIndependence: 0,
9571
+ maxOnsetIndependence: UNISON_MAX_ONSET_INDEPENDENCE,
9572
+ 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
9573
  }
9133
9574
  };
9134
9575
 
9576
+ // src/ensemble-core/instrumentation.ts
9577
+ var ENSEMBLE_INSTRUMENTATIONS = [
9578
+ "strings",
9579
+ "horns",
9580
+ "winds"
9581
+ ];
9582
+ var STYLES_FOR_INSTRUMENTATION = {
9583
+ strings: ["counterpoint", "chorale", "interlock"],
9584
+ horns: ["stabs", "riffs", "unison"],
9585
+ winds: ["counterpoint", "chorale", "interlock"]
9586
+ };
9587
+ var DEFAULT_STYLE_FOR_INSTRUMENTATION = {
9588
+ strings: "counterpoint",
9589
+ horns: "stabs",
9590
+ winds: "chorale"
9591
+ };
9592
+ function normalizeInstrumentation(raw) {
9593
+ return ENSEMBLE_INSTRUMENTATIONS.includes(raw) ? raw : "strings";
9594
+ }
9595
+ function styleForInstrumentation(instrumentation, rawStyle) {
9596
+ const allowed = STYLES_FOR_INSTRUMENTATION[instrumentation];
9597
+ return allowed.includes(rawStyle ?? "") ? rawStyle : DEFAULT_STYLE_FOR_INSTRUMENTATION[instrumentation];
9598
+ }
9599
+
9135
9600
  // src/ensemble-core/ensemble-schema.ts
9136
9601
  var SUBMIT_ENSEMBLE_TOOL_NAME = "submit_ensemble";
9137
9602
  function buildSubmitEnsembleParameters(voiceCount) {
@@ -9213,9 +9678,13 @@ function voiceContractLine(spec) {
9213
9678
  const root = spec.rootOnly ? " ROOT PITCH CLASS ONLY (each bar's chord root)." : "";
9214
9679
  return `- Voice ${spec.voiceIndex + 1} (${spec.label}): MIDI ${spec.registerLow}-${spec.registerHigh}, max ${spec.maxNotesPerBar} notes/bar, rhythm: ${spec.rhythmPalette}. ${spec.harmonicDiscipline}.${root}`;
9215
9680
  }
9216
- function buildEnsembleSystemPrompt(specs, style) {
9681
+ var WOVEN_PERSONA = {
9682
+ strings: "You are an ensemble composer.",
9683
+ winds: "You are a wind-ensemble composer writing for chamber winds (flutes, oboe, clarinet, french horn, bassoon)."
9684
+ };
9685
+ function buildWovenPrompt(specs, style, instrumentation) {
9217
9686
  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.
9687
+ return `${WOVEN_PERSONA[instrumentation]} Compose ${specs.length} voices as ONE piece of music \u2014 not ${specs.length} independent parts.
9219
9688
 
9220
9689
  Submit your composition by calling the ${SUBMIT_ENSEMBLE_TOOL_NAME} tool with all ${specs.length} voices.
9221
9690
 
@@ -9232,6 +9701,28 @@ ${styleParagraph}
9232
9701
  PER-VOICE CONTRACTS:
9233
9702
  ${specs.map(voiceContractLine).join("\n")}`;
9234
9703
  }
9704
+ function buildSectionPrompt(specs, style) {
9705
+ const styleParagraph = STYLE_RULES[style].promptParagraph;
9706
+ 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.
9707
+
9708
+ Submit your composition by calling the ${SUBMIT_ENSEMBLE_TOOL_NAME} tool with all ${specs.length} voices.
9709
+
9710
+ THE SECTION RULES (most important):
9711
+ 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.
9712
+ 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.
9713
+ 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.
9714
+ 4. Follow the chord progression in the musical context exactly \u2014 every hit is voiced from the chord sounding at that beat.
9715
+ 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.
9716
+ 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).
9717
+
9718
+ ${styleParagraph}
9719
+
9720
+ PER-VOICE CONTRACTS:
9721
+ ${specs.map(voiceContractLine).join("\n")}`;
9722
+ }
9723
+ function buildEnsembleSystemPrompt(specs, style, instrumentation = "strings") {
9724
+ return instrumentation === "horns" ? buildSectionPrompt(specs, style) : buildWovenPrompt(specs, style, instrumentation);
9725
+ }
9235
9726
  function buildViolationRetrySuffix(violations) {
9236
9727
  if (violations.length === 0) return "";
9237
9728
  return `
@@ -9407,10 +9898,12 @@ function pickTopKWeighted(scored, options = {}) {
9407
9898
  DB_MIN,
9408
9899
  DEFAULT_FX_CATEGORY_DETAIL,
9409
9900
  DEFAULT_FX_DRY_WET,
9901
+ DEFAULT_STYLE_FOR_INSTRUMENTATION,
9410
9902
  DRAG_DEAD_ZONE,
9411
9903
  DownloadPackButton,
9412
9904
  EMPTY_FX_DETAIL_STATE,
9413
9905
  EMPTY_FX_STATE,
9906
+ ENSEMBLE_INSTRUMENTATIONS,
9414
9907
  ENSEMBLE_MAX_VOICES,
9415
9908
  ENSEMBLE_MIN_VOICES,
9416
9909
  ENSEMBLE_STYLES,
@@ -9425,7 +9918,9 @@ function pickTopKWeighted(scored, options = {}) {
9425
9918
  FxToggleBar,
9426
9919
  GUTTER_W,
9427
9920
  GeneratorPanelShell,
9921
+ GroupCollapseChevron,
9428
9922
  GroupFadeTrackRow,
9923
+ HORN_MAX_NOTES_PER_BAR,
9429
9924
  ImportTrackModal,
9430
9925
  InstrumentDrawer,
9431
9926
  LevelMeter,
@@ -9439,8 +9934,12 @@ function pickTopKWeighted(scored, options = {}) {
9439
9934
  PianoRollEditor,
9440
9935
  PluginError,
9441
9936
  RESIZE_HANDLE_PX,
9937
+ RIFF_MAX_ONSET_INDEPENDENCE,
9442
9938
  ROW_HEIGHT,
9443
9939
  SLIDER_UNITY,
9940
+ STAB_MAX_NOTE_DURATION_BEATS,
9941
+ STAB_MAX_ONSET_INDEPENDENCE,
9942
+ STYLES_FOR_INSTRUMENTATION,
9444
9943
  STYLE_RULES,
9445
9944
  SUBMIT_ENSEMBLE_TOOL_NAME,
9446
9945
  SamplePackCTACard,
@@ -9454,6 +9953,7 @@ function pickTopKWeighted(scored, options = {}) {
9454
9953
  TrackMeterStrip,
9455
9954
  TrackRow,
9456
9955
  TransitionDesigner,
9956
+ UNISON_MAX_ONSET_INDEPENDENCE,
9457
9957
  VolumeSlider,
9458
9958
  WaveformView,
9459
9959
  analyzeEnsemble,
@@ -9488,6 +9988,7 @@ function pickTopKWeighted(scored, options = {}) {
9488
9988
  moveItem,
9489
9989
  nearestPitchWithPc,
9490
9990
  newTrackState,
9991
+ normalizeInstrumentation,
9491
9992
  normalizeSlots,
9492
9993
  padPair,
9493
9994
  padSlots,
@@ -9511,6 +10012,7 @@ function pickTopKWeighted(scored, options = {}) {
9511
10012
  slotsEqual,
9512
10013
  soundIdentity,
9513
10014
  splitFadeEntries,
10015
+ styleForInstrumentation,
9514
10016
  synthesizeCuePoints,
9515
10017
  tokenizePrompt,
9516
10018
  trackDataKey,