@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.mjs CHANGED
@@ -1204,6 +1204,7 @@ function TrackDrawer({
1204
1204
  onToggleFavorite,
1205
1205
  onImportSound,
1206
1206
  importSoundLabel,
1207
+ linkedSoundHint,
1207
1208
  editNotes,
1208
1209
  onNotesChange,
1209
1210
  editBars,
@@ -1270,7 +1271,7 @@ function TrackDrawer({
1270
1271
  }
1271
1272
  ) : null;
1272
1273
  const currentSound = soundHistoryCursor >= 0 && soundHistoryCursor < history.length ? history[soundHistoryCursor].label : null;
1273
- const header = strip || currentSound ? /* @__PURE__ */ jsxs5("div", { className: "flex flex-col gap-1", "data-testid": "sdk-drawer-header", children: [
1274
+ const header = strip || currentSound || linkedSoundHint ? /* @__PURE__ */ jsxs5("div", { className: "flex flex-col gap-1", "data-testid": "sdk-drawer-header", children: [
1274
1275
  strip,
1275
1276
  currentSound && /* @__PURE__ */ jsx5(
1276
1277
  "span",
@@ -1279,6 +1280,14 @@ function TrackDrawer({
1279
1280
  title: currentSound,
1280
1281
  children: currentSound
1281
1282
  }
1283
+ ),
1284
+ linkedSoundHint && /* @__PURE__ */ jsx5(
1285
+ "span",
1286
+ {
1287
+ "data-testid": "sdk-drawer-linked-hint",
1288
+ className: "text-[10px] text-sas-accent/80 px-0.5",
1289
+ children: linkedSoundHint
1290
+ }
1282
1291
  )
1283
1292
  ] }) : null;
1284
1293
  if (effectiveTab === "freeze") {
@@ -2514,6 +2523,7 @@ function TrackRow({
2514
2523
  onToggleFavorite,
2515
2524
  onImportSound,
2516
2525
  importSoundLabel,
2526
+ linkedSoundHint,
2517
2527
  editNotes,
2518
2528
  onNotesChange,
2519
2529
  editBars,
@@ -2679,15 +2689,26 @@ function TrackRow({
2679
2689
  )
2680
2690
  ] }),
2681
2691
  /* @__PURE__ */ jsxs9("div", { className: "flex gap-1 items-center", children: [
2682
- onShuffle && /* @__PURE__ */ jsx13(
2692
+ onShuffle && /* @__PURE__ */ jsxs9(
2683
2693
  "button",
2684
2694
  {
2685
2695
  "data-testid": "sdk-shuffle-button",
2686
2696
  onClick: onShuffle,
2687
2697
  disabled: !hasMidi || isGenerating || !!currentInstrumentPluginId,
2688
- 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"}`,
2689
- title: currentInstrumentPluginId ? "Shuffle only works with default Surge XT" : hasMidi ? "Re-roll sound (keep MIDI)" : "Generate MIDI first",
2690
- children: "Shuffle"
2698
+ 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"}`,
2699
+ 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",
2700
+ children: [
2701
+ "Shuffle",
2702
+ linkedSoundHint && /* @__PURE__ */ jsx13(
2703
+ "span",
2704
+ {
2705
+ "aria-hidden": true,
2706
+ "data-testid": "sdk-shuffle-linked-marker",
2707
+ className: "absolute -top-1 -right-1 text-[8px] leading-none",
2708
+ children: "\u{1F517}"
2709
+ }
2710
+ )
2711
+ ]
2691
2712
  }
2692
2713
  ),
2693
2714
  freezeBadge && /* @__PURE__ */ jsx13(
@@ -2771,6 +2792,7 @@ function TrackRow({
2771
2792
  onToggleFavorite,
2772
2793
  onImportSound,
2773
2794
  importSoundLabel,
2795
+ linkedSoundHint,
2774
2796
  editNotes,
2775
2797
  onNotesChange,
2776
2798
  editBars,
@@ -4998,9 +5020,38 @@ function TransitionDesigner({
4998
5020
  ] });
4999
5021
  }
5000
5022
 
5023
+ // src/components/GroupCollapseChevron.tsx
5024
+ import { ChevronDown as ChevronDown2 } from "lucide-react";
5025
+ import { jsx as jsx21 } from "react/jsx-runtime";
5026
+ function GroupCollapseChevron({
5027
+ collapsed = false,
5028
+ onToggle,
5029
+ what = "group"
5030
+ }) {
5031
+ if (!onToggle) return null;
5032
+ return /* @__PURE__ */ jsx21(
5033
+ "button",
5034
+ {
5035
+ type: "button",
5036
+ "data-testid": "sdk-group-collapse",
5037
+ onClick: onToggle,
5038
+ "aria-expanded": !collapsed,
5039
+ title: collapsed ? `Expand ${what}` : `Collapse ${what}`,
5040
+ className: "shrink-0 px-0.5 py-0.5 rounded-sm text-sas-muted hover:text-sas-accent transition-colors",
5041
+ children: /* @__PURE__ */ jsx21(
5042
+ ChevronDown2,
5043
+ {
5044
+ className: `w-3 h-3 transition-transform ${collapsed ? "-rotate-90" : ""}`,
5045
+ strokeWidth: 2.5
5046
+ }
5047
+ )
5048
+ }
5049
+ );
5050
+ }
5051
+
5001
5052
  // src/components/PanelMasterStrip.tsx
5002
5053
  import { useMemo as useMemo7, useState as useState15 } from "react";
5003
- import { jsx as jsx21, jsxs as jsxs17 } from "react/jsx-runtime";
5054
+ import { jsx as jsx22, jsxs as jsxs17 } from "react/jsx-runtime";
5004
5055
  function PanelMasterStrip({
5005
5056
  bus,
5006
5057
  levels = null,
@@ -5034,7 +5085,7 @@ function PanelMasterStrip({
5034
5085
  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" : ""}`,
5035
5086
  children: [
5036
5087
  /* @__PURE__ */ jsxs17("div", { className: "flex items-center gap-2", children: [
5037
- /* @__PURE__ */ jsx21(
5088
+ /* @__PURE__ */ jsx22(
5038
5089
  "span",
5039
5090
  {
5040
5091
  className: "text-[9px] font-bold tracking-widest text-sas-muted/70 select-none",
@@ -5043,7 +5094,7 @@ function PanelMasterStrip({
5043
5094
  }
5044
5095
  ),
5045
5096
  /* @__PURE__ */ jsxs17("div", { className: "flex-1 min-w-[8rem] flex flex-col gap-0.5", children: [
5046
- /* @__PURE__ */ jsx21(
5097
+ /* @__PURE__ */ jsx22(
5047
5098
  VolumeSlider,
5048
5099
  {
5049
5100
  value: dbToSlider(bus.volume),
@@ -5052,7 +5103,7 @@ function PanelMasterStrip({
5052
5103
  }
5053
5104
  ),
5054
5105
  /* @__PURE__ */ jsxs17("div", { className: "flex flex-col gap-px", "data-testid": "bus-meter", children: [
5055
- /* @__PURE__ */ jsx21(
5106
+ /* @__PURE__ */ jsx22(
5056
5107
  LevelMeter,
5057
5108
  {
5058
5109
  peakDb: levels?.leftDb ?? -120,
@@ -5062,7 +5113,7 @@ function PanelMasterStrip({
5062
5113
  "data-testid": "bus-meter-left"
5063
5114
  }
5064
5115
  ),
5065
- /* @__PURE__ */ jsx21(
5116
+ /* @__PURE__ */ jsx22(
5066
5117
  LevelMeter,
5067
5118
  {
5068
5119
  peakDb: levels?.rightDb ?? -120,
@@ -5073,7 +5124,7 @@ function PanelMasterStrip({
5073
5124
  )
5074
5125
  ] })
5075
5126
  ] }),
5076
- /* @__PURE__ */ jsx21(
5127
+ /* @__PURE__ */ jsx22(
5077
5128
  "button",
5078
5129
  {
5079
5130
  "data-testid": "bus-mute-button",
@@ -5084,7 +5135,7 @@ function PanelMasterStrip({
5084
5135
  children: "M"
5085
5136
  }
5086
5137
  ),
5087
- /* @__PURE__ */ jsx21(
5138
+ /* @__PURE__ */ jsx22(
5088
5139
  "button",
5089
5140
  {
5090
5141
  "data-testid": "bus-solo-button",
@@ -5095,14 +5146,14 @@ function PanelMasterStrip({
5095
5146
  children: "S"
5096
5147
  }
5097
5148
  ),
5098
- /* @__PURE__ */ jsx21("div", { className: "flex items-center gap-1 max-w-[45%] min-w-0 overflow-x-auto", children: bus.fx.map((fx) => /* @__PURE__ */ jsxs17(
5149
+ /* @__PURE__ */ jsx22("div", { className: "flex items-center gap-1 max-w-[45%] min-w-0 overflow-x-auto", children: bus.fx.map((fx) => /* @__PURE__ */ jsxs17(
5099
5150
  "span",
5100
5151
  {
5101
5152
  "data-testid": `bus-fx-chip-${fx.index}`,
5102
5153
  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"}`,
5103
5154
  title: `${fx.name}${fx.enabled ? "" : " (bypassed)"}`,
5104
5155
  children: [
5105
- /* @__PURE__ */ jsx21(
5156
+ /* @__PURE__ */ jsx22(
5106
5157
  "button",
5107
5158
  {
5108
5159
  "data-testid": `bus-fx-toggle-${fx.index}`,
@@ -5113,7 +5164,7 @@ function PanelMasterStrip({
5113
5164
  children: fx.enabled ? "\u25CF" : "\u25CB"
5114
5165
  }
5115
5166
  ),
5116
- onShowFxEditor ? /* @__PURE__ */ jsx21(
5167
+ onShowFxEditor ? /* @__PURE__ */ jsx22(
5117
5168
  "button",
5118
5169
  {
5119
5170
  "data-testid": `bus-fx-edit-${fx.index}`,
@@ -5123,8 +5174,8 @@ function PanelMasterStrip({
5123
5174
  title: `Open ${fx.name} editor`,
5124
5175
  children: fx.name
5125
5176
  }
5126
- ) : /* @__PURE__ */ jsx21("span", { className: "max-w-[80px] truncate", children: fx.name }),
5127
- /* @__PURE__ */ jsx21(
5177
+ ) : /* @__PURE__ */ jsx22("span", { className: "max-w-[80px] truncate", children: fx.name }),
5178
+ /* @__PURE__ */ jsx22(
5128
5179
  "button",
5129
5180
  {
5130
5181
  "data-testid": `bus-fx-remove-${fx.index}`,
@@ -5139,7 +5190,7 @@ function PanelMasterStrip({
5139
5190
  },
5140
5191
  `${fx.index}:${fx.pluginId}`
5141
5192
  )) }),
5142
- /* @__PURE__ */ jsx21(
5193
+ /* @__PURE__ */ jsx22(
5143
5194
  "button",
5144
5195
  {
5145
5196
  "data-testid": "bus-fx-add-button",
@@ -5153,7 +5204,7 @@ function PanelMasterStrip({
5153
5204
  ] }),
5154
5205
  fxPickerOpen && /* @__PURE__ */ jsxs17("div", { "data-testid": "bus-fx-picker", className: "flex flex-col gap-2 pt-1", children: [
5155
5206
  /* @__PURE__ */ jsxs17("div", { className: "flex items-center gap-2", children: [
5156
- /* @__PURE__ */ jsx21(
5207
+ /* @__PURE__ */ jsx22(
5157
5208
  "input",
5158
5209
  {
5159
5210
  type: "text",
@@ -5163,7 +5214,7 @@ function PanelMasterStrip({
5163
5214
  className: "sas-input flex-1 px-2 py-1 text-xs"
5164
5215
  }
5165
5216
  ),
5166
- onRefreshFx && /* @__PURE__ */ jsx21(
5217
+ onRefreshFx && /* @__PURE__ */ jsx22(
5167
5218
  "button",
5168
5219
  {
5169
5220
  onClick: () => onRefreshFx(),
@@ -5174,7 +5225,7 @@ function PanelMasterStrip({
5174
5225
  }
5175
5226
  )
5176
5227
  ] }),
5177
- fxLoading && availableFx.length === 0 ? /* @__PURE__ */ jsx21("div", { className: "text-xs text-sas-muted/60 text-center py-3", children: "Scanning plugins..." }) : /* @__PURE__ */ jsxs17("div", { className: "grid grid-cols-3 gap-1 max-h-[140px] overflow-y-auto", children: [
5228
+ fxLoading && availableFx.length === 0 ? /* @__PURE__ */ jsx22("div", { className: "text-xs text-sas-muted/60 text-center py-3", children: "Scanning plugins..." }) : /* @__PURE__ */ jsxs17("div", { className: "grid grid-cols-3 gap-1 max-h-[140px] overflow-y-auto", children: [
5178
5229
  filtered.map((fx) => /* @__PURE__ */ jsxs17(
5179
5230
  "button",
5180
5231
  {
@@ -5183,13 +5234,13 @@ function PanelMasterStrip({
5183
5234
  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",
5184
5235
  title: `${fx.name} by ${fx.manufacturer} (${fx.type.toUpperCase()})`,
5185
5236
  children: [
5186
- /* @__PURE__ */ jsx21("span", { className: "text-xs font-medium truncate w-full", children: fx.name }),
5187
- /* @__PURE__ */ jsx21("span", { className: "text-[9px] text-sas-muted/50 truncate w-full", children: fx.manufacturer || fx.type.toUpperCase() })
5237
+ /* @__PURE__ */ jsx22("span", { className: "text-xs font-medium truncate w-full", children: fx.name }),
5238
+ /* @__PURE__ */ jsx22("span", { className: "text-[9px] text-sas-muted/50 truncate w-full", children: fx.manufacturer || fx.type.toUpperCase() })
5188
5239
  ]
5189
5240
  },
5190
5241
  fx.pluginId
5191
5242
  )),
5192
- filtered.length === 0 && /* @__PURE__ */ jsx21("div", { className: "col-span-3 text-xs text-sas-muted/60 text-center py-2", children: search.trim() ? "No matches" : "No FX plugins found" })
5243
+ filtered.length === 0 && /* @__PURE__ */ jsx22("div", { className: "col-span-3 text-xs text-sas-muted/60 text-center py-2", children: search.trim() ? "No matches" : "No FX plugins found" })
5193
5244
  ] })
5194
5245
  ] })
5195
5246
  ]
@@ -5316,7 +5367,7 @@ function usePanelBus(host, activeSceneId) {
5316
5367
 
5317
5368
  // src/components/DownloadPackButton.tsx
5318
5369
  import { useCallback as useCallback12, useEffect as useEffect13, useState as useState17 } from "react";
5319
- import { jsx as jsx22, jsxs as jsxs18 } from "react/jsx-runtime";
5370
+ import { jsx as jsx23, jsxs as jsxs18 } from "react/jsx-runtime";
5320
5371
  function formatSize(bytes) {
5321
5372
  if (!bytes || bytes <= 0) return "";
5322
5373
  const gb = bytes / 1024 ** 3;
@@ -5405,7 +5456,7 @@ var DownloadPackButton = ({
5405
5456
  className = `${baseClasses} text-sas-muted hover:text-sas-accent border-sas-border hover:border-sas-accent`;
5406
5457
  }
5407
5458
  return /* @__PURE__ */ jsxs18("div", { children: [
5408
- /* @__PURE__ */ jsx22(
5459
+ /* @__PURE__ */ jsx23(
5409
5460
  "button",
5410
5461
  {
5411
5462
  "data-testid": `download-pack-button-${packId}`,
@@ -5416,12 +5467,12 @@ var DownloadPackButton = ({
5416
5467
  children: buttonLabel
5417
5468
  }
5418
5469
  ),
5419
- variant === "large" && status === "error" && errorMessage && /* @__PURE__ */ jsx22("div", { className: "text-xs text-sas-danger mt-2", "data-testid": `download-pack-error-${packId}`, children: errorMessage })
5470
+ variant === "large" && status === "error" && errorMessage && /* @__PURE__ */ jsx23("div", { className: "text-xs text-sas-danger mt-2", "data-testid": `download-pack-error-${packId}`, children: errorMessage })
5420
5471
  ] });
5421
5472
  };
5422
5473
 
5423
5474
  // src/components/SamplePackCTACard.tsx
5424
- import { jsx as jsx23, jsxs as jsxs19 } from "react/jsx-runtime";
5475
+ import { jsx as jsx24, jsxs as jsxs19 } from "react/jsx-runtime";
5425
5476
  var SamplePackCTACard = ({
5426
5477
  host,
5427
5478
  pack,
@@ -5429,7 +5480,7 @@ var SamplePackCTACard = ({
5429
5480
  onDownloadComplete
5430
5481
  }) => {
5431
5482
  if (status === "checking") {
5432
- return /* @__PURE__ */ jsx23(
5483
+ return /* @__PURE__ */ jsx24(
5433
5484
  "div",
5434
5485
  {
5435
5486
  "data-testid": `sample-pack-cta-checking-${pack.packId}`,
@@ -5446,10 +5497,10 @@ var SamplePackCTACard = ({
5446
5497
  "data-testid": `sample-pack-cta-${pack.packId}`,
5447
5498
  className: "flex flex-col items-center justify-center py-12 px-6 text-center",
5448
5499
  children: [
5449
- /* @__PURE__ */ jsx23("div", { className: "text-sm uppercase tracking-wide text-sas-muted mb-2", children: status === "stale" ? "Update available" : "Sample library not installed" }),
5450
- /* @__PURE__ */ jsx23("div", { className: "text-base text-sas-text mb-1", children: headline }),
5451
- /* @__PURE__ */ jsx23("div", { className: "text-xs text-sas-muted mb-6 max-w-md", children: sublabel }),
5452
- /* @__PURE__ */ jsx23(
5500
+ /* @__PURE__ */ jsx24("div", { className: "text-sm uppercase tracking-wide text-sas-muted mb-2", children: status === "stale" ? "Update available" : "Sample library not installed" }),
5501
+ /* @__PURE__ */ jsx24("div", { className: "text-base text-sas-text mb-1", children: headline }),
5502
+ /* @__PURE__ */ jsx24("div", { className: "text-xs text-sas-muted mb-6 max-w-md", children: sublabel }),
5503
+ /* @__PURE__ */ jsx24(
5453
5504
  DownloadPackButton,
5454
5505
  {
5455
5506
  host,
@@ -5529,7 +5580,7 @@ function drawWaveform(canvas, peaks, options = {}) {
5529
5580
  }
5530
5581
 
5531
5582
  // src/components/WaveformView.tsx
5532
- import { jsx as jsx24 } from "react/jsx-runtime";
5583
+ import { jsx as jsx25 } from "react/jsx-runtime";
5533
5584
  var WaveformView = ({
5534
5585
  host,
5535
5586
  filePath,
@@ -5577,7 +5628,7 @@ var WaveformView = ({
5577
5628
  observer.observe(canvas);
5578
5629
  return () => observer.disconnect();
5579
5630
  }, [peaks, fillStyle]);
5580
- return /* @__PURE__ */ jsx24(
5631
+ return /* @__PURE__ */ jsx25(
5581
5632
  "canvas",
5582
5633
  {
5583
5634
  ref: canvasRef,
@@ -5589,7 +5640,7 @@ var WaveformView = ({
5589
5640
 
5590
5641
  // src/components/ScrollingWaveform.tsx
5591
5642
  import { useEffect as useEffect15, useRef as useRef14 } from "react";
5592
- import { jsx as jsx25 } from "react/jsx-runtime";
5643
+ import { jsx as jsx26 } from "react/jsx-runtime";
5593
5644
  var ScrollingWaveform = ({
5594
5645
  getPeakDb,
5595
5646
  active,
@@ -5665,7 +5716,7 @@ var ScrollingWaveform = ({
5665
5716
  }
5666
5717
  };
5667
5718
  }, [active, getPeakDb, fillStyle]);
5668
- return /* @__PURE__ */ jsx25(
5719
+ return /* @__PURE__ */ jsx26(
5669
5720
  "canvas",
5670
5721
  {
5671
5722
  ref: canvasRef,
@@ -5677,7 +5728,7 @@ var ScrollingWaveform = ({
5677
5728
 
5678
5729
  // src/components/OffsetScrubber.tsx
5679
5730
  import { useCallback as useCallback13, useEffect as useEffect16, useMemo as useMemo8, useRef as useRef15, useState as useState19 } from "react";
5680
- import { jsx as jsx26, jsxs as jsxs20 } from "react/jsx-runtime";
5731
+ import { jsx as jsx27, jsxs as jsxs20 } from "react/jsx-runtime";
5681
5732
  var SLIDER_HEIGHT_PX = 28;
5682
5733
  var TICK_HEIGHT_PX = 14;
5683
5734
  var DOWNBEAT_TICK_HEIGHT_PX = 22;
@@ -5793,7 +5844,7 @@ function OffsetScrubber({
5793
5844
  }, [cuePoints, sampleToFraction]);
5794
5845
  const isDisabled = disabled || !cuePoints || cuePoints.beats.length === 0;
5795
5846
  return /* @__PURE__ */ jsxs20("div", { "data-testid": "offset-scrubber", className: "flex items-center gap-2 w-full", children: [
5796
- /* @__PURE__ */ jsx26("span", { className: "text-[9px] text-sas-muted/60 uppercase tracking-wide flex-shrink-0", children: "Align" }),
5847
+ /* @__PURE__ */ jsx27("span", { className: "text-[9px] text-sas-muted/60 uppercase tracking-wide flex-shrink-0", children: "Align" }),
5797
5848
  /* @__PURE__ */ jsxs20(
5798
5849
  "div",
5799
5850
  {
@@ -5810,7 +5861,7 @@ function OffsetScrubber({
5810
5861
  "aria-valuenow": draftOffset,
5811
5862
  "aria-disabled": isDisabled,
5812
5863
  children: [
5813
- /* @__PURE__ */ jsx26(
5864
+ /* @__PURE__ */ jsx27(
5814
5865
  "div",
5815
5866
  {
5816
5867
  "aria-hidden": "true",
@@ -5818,7 +5869,7 @@ function OffsetScrubber({
5818
5869
  style: { left: "50%" }
5819
5870
  }
5820
5871
  ),
5821
- ticks.map((t) => /* @__PURE__ */ jsx26(
5872
+ ticks.map((t) => /* @__PURE__ */ jsx27(
5822
5873
  "div",
5823
5874
  {
5824
5875
  "data-testid": t.isDownbeat ? "offset-tick-downbeat" : "offset-tick",
@@ -5833,7 +5884,7 @@ function OffsetScrubber({
5833
5884
  },
5834
5885
  t.i
5835
5886
  )),
5836
- /* @__PURE__ */ jsx26(
5887
+ /* @__PURE__ */ jsx27(
5837
5888
  "div",
5838
5889
  {
5839
5890
  "data-testid": "offset-scrubber-thumb",
@@ -5850,7 +5901,7 @@ function OffsetScrubber({
5850
5901
  ]
5851
5902
  }
5852
5903
  ),
5853
- /* @__PURE__ */ jsx26(
5904
+ /* @__PURE__ */ jsx27(
5854
5905
  "span",
5855
5906
  {
5856
5907
  "data-testid": "offset-scrubber-readout",
@@ -5858,7 +5909,7 @@ function OffsetScrubber({
5858
5909
  children: formatOffset(draftOffset, sampleRate)
5859
5910
  }
5860
5911
  ),
5861
- /* @__PURE__ */ jsx26(
5912
+ /* @__PURE__ */ jsx27(
5862
5913
  "button",
5863
5914
  {
5864
5915
  type: "button",
@@ -5870,7 +5921,7 @@ function OffsetScrubber({
5870
5921
  children: "\u2316"
5871
5922
  }
5872
5923
  ),
5873
- bpmMismatch && /* @__PURE__ */ jsx26(
5924
+ bpmMismatch && /* @__PURE__ */ jsx27(
5874
5925
  "span",
5875
5926
  {
5876
5927
  "data-testid": "offset-bpm-mismatch",
@@ -6883,7 +6934,7 @@ function useTransitionOps({
6883
6934
  }
6884
6935
 
6885
6936
  // src/panel-core/useGeneratorPanelCore.tsx
6886
- import { jsx as jsx27, jsxs as jsxs21 } from "react/jsx-runtime";
6937
+ import { jsx as jsx28, jsxs as jsxs21 } from "react/jsx-runtime";
6887
6938
  var EMPTY_PLACEHOLDERS = [];
6888
6939
  function useGeneratorPanelCore({
6889
6940
  ui,
@@ -7294,37 +7345,6 @@ function useGeneratorPanelCore({
7294
7345
  },
7295
7346
  [host, adapter, identity, activeSceneId, isConnected, tracks.length, loadTracks]
7296
7347
  );
7297
- const handleSoundImportPick = useCallback17(
7298
- async (sel) => {
7299
- const target = soundImportTarget;
7300
- if (!target || !host.getTrackSound) {
7301
- setSoundImportTarget(null);
7302
- return;
7303
- }
7304
- const noun = adapter.sound.importNoun;
7305
- const nounTitle = noun.charAt(0).toUpperCase() + noun.slice(1);
7306
- try {
7307
- const snap = await host.getTrackSound(sel.sourceTrackDbId);
7308
- if (!snap || snap.kind !== adapter.sound.acceptedSnapshotKind) {
7309
- host.showToast(
7310
- "error",
7311
- `No ${noun} to import`,
7312
- `${sel.trackName} has no ${identity.familyKey} ${noun}.`
7313
- );
7314
- return;
7315
- }
7316
- const descriptor = adapter.sound.descriptorFromSnapshot(snap);
7317
- await adapter.sound.applySound(target.handle.id, descriptor);
7318
- soundHistory.record(target.handle.id, descriptor, snap.label);
7319
- host.showToast("success", `${nounTitle} imported`, `${snap.label} \u2192 ${target.handle.name}`);
7320
- } catch (err) {
7321
- host.showToast("error", "Import failed", err instanceof Error ? err.message : String(err));
7322
- } finally {
7323
- setSoundImportTarget(null);
7324
- }
7325
- },
7326
- [soundImportTarget, host, adapter, identity.familyKey, soundHistory]
7327
- );
7328
7348
  const [isExportingMidi, setIsExportingMidi] = useState24(false);
7329
7349
  const handleExportMidi = useCallback17(async () => {
7330
7350
  if (isExportingMidi) return;
@@ -7381,7 +7401,7 @@ function useGeneratorPanelCore({
7381
7401
  const addDisabled = needsContract || !isConnected || !activeSceneId || tracks.length >= identity.maxTracks || isAddingTrack;
7382
7402
  onHeaderContent(
7383
7403
  /* @__PURE__ */ jsxs21("div", { className: "flex gap-1 items-center", children: [
7384
- features.importTracks && (!canCrossfade || !designerView) && host.listImportableTracks && /* @__PURE__ */ jsx27(
7404
+ features.importTracks && (!canCrossfade || !designerView) && host.listImportableTracks && /* @__PURE__ */ jsx28(
7385
7405
  "button",
7386
7406
  {
7387
7407
  "data-testid": `import-from-scene-${identity.familyKey}-button`,
@@ -7395,7 +7415,7 @@ function useGeneratorPanelCore({
7395
7415
  children: identity.importTrackLabel ?? "Import Track"
7396
7416
  }
7397
7417
  ),
7398
- (!canCrossfade || !designerView) && /* @__PURE__ */ jsx27(
7418
+ (!canCrossfade || !designerView) && /* @__PURE__ */ jsx28(
7399
7419
  "button",
7400
7420
  {
7401
7421
  "data-testid": `add-${identity.familyKey}-track-button`,
@@ -7430,7 +7450,7 @@ function useGeneratorPanelCore({
7430
7450
  title: designerView ? "Back to the track list" : "Open the transition designer",
7431
7451
  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",
7432
7452
  children: [
7433
- transitionSourceTotal > 0 && /* @__PURE__ */ jsx27(
7453
+ transitionSourceTotal > 0 && /* @__PURE__ */ jsx28(
7434
7454
  "span",
7435
7455
  {
7436
7456
  className: "absolute inset-y-0 left-0 bg-sas-accent/25",
@@ -7571,9 +7591,163 @@ function useGeneratorPanelCore({
7571
7591
  name: `${identity.trackNamePrefix}-${Date.now()}${nameSuffix}`,
7572
7592
  ...adapter.createTrackOptions()
7573
7593
  }),
7574
- resolvedGroups: (metaKey) => resolvedGenericGroupsRef.current[metaKey]?.resolved ?? []
7594
+ resolvedGroups: (metaKey) => resolvedGenericGroupsRef.current[metaKey]?.resolved ?? [],
7595
+ sound: adapter.sound
7575
7596
  };
7576
7597
  }, [host, activeSceneId, updateTrack, loadTracks, soundHistory, engineToDbId, markEditLoaded, identity, adapter]);
7598
+ const broadcastSoundFromTrack = useCallback17(
7599
+ async (sourceTrackId, descriptor, label) => {
7600
+ const targetsOf = adapter.sound.broadcastTargets;
7601
+ if (!targetsOf) return;
7602
+ const source = tracksRef.current.find((t) => t.handle.id === sourceTrackId);
7603
+ if (!source) return;
7604
+ let targets;
7605
+ try {
7606
+ targets = await targetsOf(source, makeServices());
7607
+ } catch (err) {
7608
+ console.warn(`[${logTag}] broadcastTargets failed:`, err);
7609
+ return;
7610
+ }
7611
+ const sourceInstrument = source.instrumentPluginId ?? null;
7612
+ const others = (targets ?? []).filter((t) => {
7613
+ if (t.engineId === sourceTrackId) return false;
7614
+ const live = tracksRef.current.find((x) => x.handle.id === t.engineId);
7615
+ return (live?.instrumentPluginId ?? null) === sourceInstrument;
7616
+ });
7617
+ if (others.length === 0) return;
7618
+ try {
7619
+ await adapter.sound.persistDescriptor?.(sourceTrackId, descriptor, label);
7620
+ } catch {
7621
+ }
7622
+ const appliedIds = /* @__PURE__ */ new Set();
7623
+ const failed = [];
7624
+ for (const target of others) {
7625
+ try {
7626
+ if (soundHistory.list(target.engineId).entries.length === 0) {
7627
+ try {
7628
+ const cap = await adapter.sound.captureSoundDescriptor(target.engineId);
7629
+ if (cap) {
7630
+ soundHistory.record(target.engineId, cap.descriptor, adapter.sound.previousSoundLabel);
7631
+ }
7632
+ } catch {
7633
+ }
7634
+ }
7635
+ await adapter.sound.applySound(target.engineId, descriptor);
7636
+ try {
7637
+ await adapter.sound.persistDescriptor?.(target.engineId, descriptor, label);
7638
+ } catch {
7639
+ }
7640
+ soundHistory.record(target.engineId, descriptor, label);
7641
+ appliedIds.add(target.engineId);
7642
+ } catch (err) {
7643
+ failed.push(target.label ?? target.engineId);
7644
+ console.warn(`[${logTag}] Linked sound apply failed for ${target.engineId}:`, err);
7645
+ }
7646
+ }
7647
+ if (appliedIds.size > 0) {
7648
+ setTracks(
7649
+ (prev) => prev.map(
7650
+ (t) => appliedIds.has(t.handle.id) ? { ...t, shuffleHistory: /* @__PURE__ */ new Set([...t.shuffleHistory, label]) } : t
7651
+ )
7652
+ );
7653
+ }
7654
+ if (failed.length > 0) {
7655
+ host.showToast(
7656
+ "warning",
7657
+ "Linked sound applied to some parts only",
7658
+ `Skipped: ${failed.join(", ")}`
7659
+ );
7660
+ }
7661
+ },
7662
+ [adapter, makeServices, soundHistory, host, logTag]
7663
+ );
7664
+ const broadcastInstrumentFromTrack = useCallback17(
7665
+ async (sourceTrackId, pluginId) => {
7666
+ const targetsOf = adapter.sound.broadcastTargets;
7667
+ if (!targetsOf) return;
7668
+ const source = tracksRef.current.find((t) => t.handle.id === sourceTrackId);
7669
+ if (!source) return;
7670
+ let targets;
7671
+ try {
7672
+ targets = await targetsOf(source, makeServices());
7673
+ } catch (err) {
7674
+ console.warn(`[${logTag}] broadcastTargets failed:`, err);
7675
+ return;
7676
+ }
7677
+ const others = (targets ?? []).filter((t) => t.engineId !== sourceTrackId);
7678
+ if (others.length === 0) return;
7679
+ const failed = [];
7680
+ for (const target of others) {
7681
+ try {
7682
+ await host.setTrackInstrument(target.engineId, pluginId);
7683
+ const descriptor = await host.getTrackInstrument(target.engineId);
7684
+ setTracks(
7685
+ (prev) => prev.map(
7686
+ (t) => t.handle.id === target.engineId ? {
7687
+ ...t,
7688
+ instrumentPluginId: descriptor?.pluginId ?? null,
7689
+ instrumentName: descriptor?.name ?? null,
7690
+ instrumentMissing: descriptor?.missing ?? false
7691
+ } : t
7692
+ )
7693
+ );
7694
+ } catch (err) {
7695
+ failed.push(target.label ?? target.engineId);
7696
+ console.warn(`[${logTag}] Linked instrument apply failed for ${target.engineId}:`, err);
7697
+ }
7698
+ }
7699
+ if (failed.length > 0) {
7700
+ host.showToast(
7701
+ "warning",
7702
+ "Instrument applied to some parts only",
7703
+ `Skipped: ${failed.join(", ")}`
7704
+ );
7705
+ }
7706
+ },
7707
+ [adapter, makeServices, host, logTag]
7708
+ );
7709
+ const handleRestoreSound = useCallback17(
7710
+ async (trackId, index) => {
7711
+ const entry = soundHistory.list(trackId).entries[index];
7712
+ const moved = await soundHistory.restoreTo(trackId, index);
7713
+ if (moved && entry) {
7714
+ await broadcastSoundFromTrack(trackId, entry.descriptor, entry.label);
7715
+ }
7716
+ },
7717
+ [soundHistory, broadcastSoundFromTrack]
7718
+ );
7719
+ const handleSoundImportPick = useCallback17(
7720
+ async (sel) => {
7721
+ const target = soundImportTarget;
7722
+ if (!target || !host.getTrackSound) {
7723
+ setSoundImportTarget(null);
7724
+ return;
7725
+ }
7726
+ const noun = adapter.sound.importNoun;
7727
+ const nounTitle = noun.charAt(0).toUpperCase() + noun.slice(1);
7728
+ try {
7729
+ const snap = await host.getTrackSound(sel.sourceTrackDbId);
7730
+ if (!snap || snap.kind !== adapter.sound.acceptedSnapshotKind) {
7731
+ host.showToast(
7732
+ "error",
7733
+ `No ${noun} to import`,
7734
+ `${sel.trackName} has no ${identity.familyKey} ${noun}.`
7735
+ );
7736
+ return;
7737
+ }
7738
+ const descriptor = adapter.sound.descriptorFromSnapshot(snap);
7739
+ await adapter.sound.applySound(target.handle.id, descriptor);
7740
+ soundHistory.record(target.handle.id, descriptor, snap.label);
7741
+ host.showToast("success", `${nounTitle} imported`, `${snap.label} \u2192 ${target.handle.name}`);
7742
+ await broadcastSoundFromTrack(target.handle.id, descriptor, snap.label);
7743
+ } catch (err) {
7744
+ host.showToast("error", "Import failed", err instanceof Error ? err.message : String(err));
7745
+ } finally {
7746
+ setSoundImportTarget(null);
7747
+ }
7748
+ },
7749
+ [soundImportTarget, host, adapter, identity.familyKey, soundHistory, broadcastSoundFromTrack]
7750
+ );
7577
7751
  const handleGenerate = useCallback17(
7578
7752
  async (trackId) => {
7579
7753
  const track = tracks.find((t) => t.handle.id === trackId);
@@ -7692,7 +7866,10 @@ function useGeneratorPanelCore({
7692
7866
  );
7693
7867
  try {
7694
7868
  const cap = await adapter.sound.captureSoundDescriptor(trackId);
7695
- if (cap) soundHistory.record(trackId, cap.descriptor, result.appliedName);
7869
+ if (cap) {
7870
+ soundHistory.record(trackId, cap.descriptor, result.appliedName);
7871
+ await broadcastSoundFromTrack(trackId, cap.descriptor, result.appliedName);
7872
+ }
7696
7873
  } catch {
7697
7874
  }
7698
7875
  console.log(`[${logTag}] Sound shuffled: ${result.appliedName} (history ${nextHistory.size})`);
@@ -7701,7 +7878,7 @@ function useGeneratorPanelCore({
7701
7878
  host.showToast("error", "Shuffle failed", msg);
7702
7879
  }
7703
7880
  },
7704
- [host, adapter, tracks, soundHistory, logTag]
7881
+ [host, adapter, tracks, soundHistory, logTag, broadcastSoundFromTrack]
7705
7882
  );
7706
7883
  const handleCopy = useCallback17(
7707
7884
  async (trackId) => {
@@ -7904,6 +8081,7 @@ function useGeneratorPanelCore({
7904
8081
  } : t
7905
8082
  )
7906
8083
  );
8084
+ await broadcastInstrumentFromTrack(trackId, pluginId);
7907
8085
  } catch (err) {
7908
8086
  const msg = err instanceof Error ? err.message : "Failed to load instrument";
7909
8087
  host.showToast("error", "Instrument load failed", msg);
@@ -7926,6 +8104,7 @@ function useGeneratorPanelCore({
7926
8104
  } : t
7927
8105
  )
7928
8106
  );
8107
+ await broadcastInstrumentFromTrack(trackId, pluginId);
7929
8108
  } catch (err) {
7930
8109
  const msg = err instanceof Error ? err.message : "Failed to load instrument";
7931
8110
  console.error(`[${logTag}] Failed to set instrument:`, err);
@@ -7935,7 +8114,7 @@ function useGeneratorPanelCore({
7935
8114
  );
7936
8115
  }
7937
8116
  },
7938
- [host, identity.defaultInstrumentPluginId, logTag]
8117
+ [host, identity.defaultInstrumentPluginId, logTag, broadcastInstrumentFromTrack]
7939
8118
  );
7940
8119
  const handleShowEditor = useCallback17(
7941
8120
  async (trackId) => {
@@ -8145,6 +8324,7 @@ function useGeneratorPanelCore({
8145
8324
  handlers,
8146
8325
  handleGenerate,
8147
8326
  handleShuffle,
8327
+ handleRestoreSound,
8148
8328
  handleAddTrack,
8149
8329
  handleDeleteTrack,
8150
8330
  handleExportMidi,
@@ -8175,8 +8355,41 @@ function useGeneratorPanelCore({
8175
8355
  }
8176
8356
 
8177
8357
  // src/panel-core/GeneratorPanelShell.tsx
8178
- import React23, { useCallback as useCallback18 } from "react";
8179
- import { Fragment as Fragment7, jsx as jsx28, jsxs as jsxs22 } from "react/jsx-runtime";
8358
+ import { useCallback as useCallback18, useEffect as useEffect20, useState as useState25 } from "react";
8359
+ import { Fragment as Fragment7, jsx as jsx29, jsxs as jsxs22 } from "react/jsx-runtime";
8360
+ var GROUP_UI_KEY = "groupUi";
8361
+ function CollapsibleGroup({
8362
+ ext,
8363
+ group,
8364
+ groupCtx
8365
+ }) {
8366
+ const { host, activeSceneId, trackDataKey: trackDataKey2 } = groupCtx.services;
8367
+ const uiKey = trackDataKey2(group.groupId, GROUP_UI_KEY);
8368
+ const [collapsed, setCollapsed] = useState25(false);
8369
+ useEffect20(() => {
8370
+ let cancelled = false;
8371
+ if (!activeSceneId) return void 0;
8372
+ void host.getSceneData(activeSceneId, uiKey).then((raw) => {
8373
+ if (cancelled || !raw || typeof raw !== "object") return;
8374
+ setCollapsed(raw.collapsed === true);
8375
+ }).catch(() => {
8376
+ });
8377
+ return () => {
8378
+ cancelled = true;
8379
+ };
8380
+ }, [host, activeSceneId, uiKey]);
8381
+ const onToggleCollapse = useCallback18(() => {
8382
+ setCollapsed((prev) => {
8383
+ const next = !prev;
8384
+ if (activeSceneId) {
8385
+ void host.setSceneData(activeSceneId, uiKey, { collapsed: next }).catch(() => {
8386
+ });
8387
+ }
8388
+ return next;
8389
+ });
8390
+ }, [host, activeSceneId, uiKey]);
8391
+ return /* @__PURE__ */ jsx29(Fragment7, { children: ext.renderGroup(group, { ...groupCtx, collapsed, onToggleCollapse }) });
8392
+ }
8180
8393
  function GeneratorPanelShell({ core, slots }) {
8181
8394
  const {
8182
8395
  ui,
@@ -8199,6 +8412,7 @@ function GeneratorPanelShell({ core, slots }) {
8199
8412
  soundImportTarget,
8200
8413
  setSoundImportTarget,
8201
8414
  handleSoundImportPick,
8415
+ handleRestoreSound,
8202
8416
  handlePortTrack,
8203
8417
  transition,
8204
8418
  crossfadePairsMeta,
@@ -8294,7 +8508,7 @@ function GeneratorPanelShell({ core, slots }) {
8294
8508
  soundHistory: soundHistory.list(id).entries,
8295
8509
  soundHistoryCursor: soundHistory.list(id).cursor,
8296
8510
  onRestoreSound: (i) => {
8297
- void soundHistory.restoreTo(id, i);
8511
+ void handleRestoreSound(id, i);
8298
8512
  },
8299
8513
  onToggleFavorite: (i) => soundHistory.toggleFavorite(id, i),
8300
8514
  ...importSoundProps,
@@ -8325,6 +8539,7 @@ function GeneratorPanelShell({ core, slots }) {
8325
8539
  handleBackToInstruments,
8326
8540
  setSoundImportTarget,
8327
8541
  soundHistory,
8542
+ handleRestoreSound,
8328
8543
  handleFxToggle,
8329
8544
  handleFxPresetChange,
8330
8545
  handleFxDryWetChange,
@@ -8332,12 +8547,12 @@ function GeneratorPanelShell({ core, slots }) {
8332
8547
  ]
8333
8548
  );
8334
8549
  if (!activeSceneId) {
8335
- return /* @__PURE__ */ jsx28(
8550
+ return /* @__PURE__ */ jsx29(
8336
8551
  "div",
8337
8552
  {
8338
8553
  "data-testid": `no-scene-placeholder-${identity.familyKey}`,
8339
8554
  className: "flex items-center justify-center py-8",
8340
- children: /* @__PURE__ */ jsx28(
8555
+ children: /* @__PURE__ */ jsx29(
8341
8556
  "button",
8342
8557
  {
8343
8558
  onClick: () => onSelectScene?.(),
@@ -8349,12 +8564,12 @@ function GeneratorPanelShell({ core, slots }) {
8349
8564
  );
8350
8565
  }
8351
8566
  if (!sceneContext?.hasContract) {
8352
- return /* @__PURE__ */ jsx28(
8567
+ return /* @__PURE__ */ jsx29(
8353
8568
  "div",
8354
8569
  {
8355
8570
  "data-testid": `no-contract-placeholder-${identity.familyKey}`,
8356
8571
  className: "flex items-center justify-center py-8",
8357
- children: /* @__PURE__ */ jsx28(
8572
+ children: /* @__PURE__ */ jsx29(
8358
8573
  "button",
8359
8574
  {
8360
8575
  onClick: () => onOpenContract?.(),
@@ -8366,7 +8581,7 @@ function GeneratorPanelShell({ core, slots }) {
8366
8581
  );
8367
8582
  }
8368
8583
  if (features.bulkComposePlaceholders && isComposing) {
8369
- return /* @__PURE__ */ jsx28("div", { "data-testid": `${identity.familyKey}-section`, className: "p-2", children: /* @__PURE__ */ jsx28(SorceryProgressBar, { isLoading: true, statusText: "COMPOSING...", heightClass: "h-10" }) });
8584
+ return /* @__PURE__ */ jsx29("div", { "data-testid": `${identity.familyKey}-section`, className: "p-2", children: /* @__PURE__ */ jsx29(SorceryProgressBar, { isLoading: true, statusText: "COMPOSING...", heightClass: "h-10" }) });
8370
8585
  }
8371
8586
  const activePlaceholders = features.bulkComposePlaceholders ? placeholders : [];
8372
8587
  if (activePlaceholders.length > 0) {
@@ -8377,18 +8592,18 @@ function GeneratorPanelShell({ core, slots }) {
8377
8592
  tracksByDbId.set(t.handle.id, t);
8378
8593
  }
8379
8594
  }
8380
- return /* @__PURE__ */ jsx28("div", { "data-testid": `${identity.familyKey}-section`, className: "p-2 space-y-2", children: activePlaceholders.map((ph) => {
8595
+ return /* @__PURE__ */ jsx29("div", { "data-testid": `${identity.familyKey}-section`, className: "p-2 space-y-2", children: activePlaceholders.map((ph) => {
8381
8596
  const loadedTrack = ph.status === "completed" ? tracksByDbId.get(ph.id) : void 0;
8382
8597
  if (loadedTrack) {
8383
- return /* @__PURE__ */ jsx28(TrackRow, { ...buildRowProps(loadedTrack) }, ph.id);
8598
+ return /* @__PURE__ */ jsx29(TrackRow, { ...buildRowProps(loadedTrack) }, ph.id);
8384
8599
  }
8385
- return /* @__PURE__ */ jsx28(
8600
+ return /* @__PURE__ */ jsx29(
8386
8601
  "div",
8387
8602
  {
8388
8603
  "data-testid": "bulk-placeholder-track",
8389
8604
  className: "relative rounded-sm border w-full overflow-hidden border-sas-border bg-sas-panel-alt",
8390
8605
  style: { borderLeftColor: identity.placeholderAccentColor, borderLeftWidth: "3px" },
8391
- children: /* @__PURE__ */ jsx28(SorceryProgressBar, { isLoading: true, statusText: "CONJURING MIDI...", heightClass: "h-10" })
8606
+ children: /* @__PURE__ */ jsx29(SorceryProgressBar, { isLoading: true, statusText: "CONJURING MIDI...", heightClass: "h-10" })
8392
8607
  },
8393
8608
  ph.id
8394
8609
  );
@@ -8400,13 +8615,13 @@ function GeneratorPanelShell({ core, slots }) {
8400
8615
  supportsMeters,
8401
8616
  levels: supportsMeters ? trackLevels : void 0,
8402
8617
  handlers,
8403
- renderDefaultTrackRow: (track, overrides, drag) => /* @__PURE__ */ jsx28(TrackRow, { ...{ ...buildRowProps(track, drag), ...overrides ?? {} } }, track.handle.id),
8618
+ renderDefaultTrackRow: (track, overrides, drag) => /* @__PURE__ */ jsx29(TrackRow, { ...{ ...buildRowProps(track, drag), ...overrides ?? {} } }, track.handle.id),
8404
8619
  setGroupMute,
8405
8620
  setGroupSolo,
8406
8621
  deleteGroup
8407
8622
  };
8408
8623
  return /* @__PURE__ */ jsxs22("div", { "data-testid": `${identity.familyKey}-section`, className: "p-2 space-y-2", children: [
8409
- features.importTracks && host.listImportableTracks && /* @__PURE__ */ jsx28(
8624
+ features.importTracks && host.listImportableTracks && /* @__PURE__ */ jsx29(
8410
8625
  ImportTrackModal,
8411
8626
  {
8412
8627
  host,
@@ -8419,7 +8634,7 @@ function GeneratorPanelShell({ core, slots }) {
8419
8634
  testIdPrefix: `${identity.familyKey}-import`
8420
8635
  }
8421
8636
  ),
8422
- features.importTracks && host.listImportableTracks && host.getTrackSound && /* @__PURE__ */ jsx28(
8637
+ features.importTracks && host.listImportableTracks && host.getTrackSound && /* @__PURE__ */ jsx29(
8423
8638
  ImportTrackModal,
8424
8639
  {
8425
8640
  host,
@@ -8434,7 +8649,7 @@ function GeneratorPanelShell({ core, slots }) {
8434
8649
  }
8435
8650
  ),
8436
8651
  slots?.modals,
8437
- canCrossfade && xfFromId && xfToId && /* @__PURE__ */ jsx28("div", { className: designerView ? "contents" : "hidden", children: /* @__PURE__ */ jsx28(
8652
+ canCrossfade && xfFromId && xfToId && /* @__PURE__ */ jsx29("div", { className: designerView ? "contents" : "hidden", children: /* @__PURE__ */ jsx29(
8438
8653
  TransitionDesigner,
8439
8654
  {
8440
8655
  host,
@@ -8453,8 +8668,8 @@ function GeneratorPanelShell({ core, slots }) {
8453
8668
  testIdPrefix: `${identity.familyKey}-transition-designer`
8454
8669
  }
8455
8670
  ) }),
8456
- !(designerView && canCrossfade) && (isLoadingTracks ? /* @__PURE__ */ jsx28("div", { className: "text-sas-muted text-xs text-center py-4", children: "Loading tracks..." }) : /* @__PURE__ */ jsxs22(Fragment7, { children: [
8457
- panelBus.supported && panelBus.bus && /* @__PURE__ */ jsx28(
8671
+ !(designerView && canCrossfade) && (isLoadingTracks ? /* @__PURE__ */ jsx29("div", { className: "text-sas-muted text-xs text-center py-4", children: "Loading tracks..." }) : /* @__PURE__ */ jsxs22(Fragment7, { children: [
8672
+ panelBus.supported && panelBus.bus && /* @__PURE__ */ jsx29(
8458
8673
  PanelMasterStrip,
8459
8674
  {
8460
8675
  bus: panelBus.bus,
@@ -8475,7 +8690,7 @@ function GeneratorPanelShell({ core, slots }) {
8475
8690
  }
8476
8691
  ),
8477
8692
  slots?.beforeRows,
8478
- resolvedCrossfadePairs.map((pair) => /* @__PURE__ */ jsx28(
8693
+ resolvedCrossfadePairs.map((pair) => /* @__PURE__ */ jsx29(
8479
8694
  CrossfadeTrackRow,
8480
8695
  {
8481
8696
  accentColor: identity.transitionAccentColor,
@@ -8512,7 +8727,7 @@ function GeneratorPanelShell({ core, slots }) {
8512
8727
  },
8513
8728
  pair.groupId
8514
8729
  )),
8515
- resolvedSingleFades.map((fade) => /* @__PURE__ */ jsx28(
8730
+ resolvedSingleFades.map((fade) => /* @__PURE__ */ jsx29(
8516
8731
  FadeTrackRow,
8517
8732
  {
8518
8733
  accentColor: identity.transitionAccentColor,
@@ -8537,7 +8752,7 @@ function GeneratorPanelShell({ core, slots }) {
8537
8752
  },
8538
8753
  fade.dbId
8539
8754
  )),
8540
- resolvedGroupFades.map((group) => /* @__PURE__ */ jsx28(
8755
+ resolvedGroupFades.map((group) => /* @__PURE__ */ jsx29(
8541
8756
  GroupFadeTrackRow,
8542
8757
  {
8543
8758
  accentColor: identity.transitionAccentColor,
@@ -8573,20 +8788,28 @@ function GeneratorPanelShell({ core, slots }) {
8573
8788
  group.groupId
8574
8789
  )),
8575
8790
  (adapter.groupExtensions ?? []).flatMap(
8576
- (ext) => (resolvedGenericGroups[ext.metaKey]?.resolved ?? []).filter((group) => !group.members.every((m) => fadeMemberDbIds.has(m.dbId))).map((group) => /* @__PURE__ */ jsx28(React23.Fragment, { children: ext.renderGroup(group, groupCtx) }, `${ext.metaKey}:${group.groupId}`))
8791
+ (ext) => (resolvedGenericGroups[ext.metaKey]?.resolved ?? []).filter((group) => !group.members.every((m) => fadeMemberDbIds.has(m.dbId))).map((group) => /* @__PURE__ */ jsx29(
8792
+ CollapsibleGroup,
8793
+ {
8794
+ ext,
8795
+ group,
8796
+ groupCtx
8797
+ },
8798
+ `${ext.metaKey}:${group.groupId}`
8799
+ ))
8577
8800
  ),
8578
8801
  tracks.map((track, index) => {
8579
8802
  if (crossfadeMemberDbIds.has(track.handle.dbId) || fadeMemberDbIds.has(track.handle.dbId) || genericGroupMemberDbIds.has(track.handle.dbId)) {
8580
8803
  return null;
8581
8804
  }
8582
- return /* @__PURE__ */ jsx28(TrackRow, { ...buildRowProps(track, reorder.dragPropsFor(index)) }, track.handle.id);
8805
+ return /* @__PURE__ */ jsx29(TrackRow, { ...buildRowProps(track, reorder.dragPropsFor(index)) }, track.handle.id);
8583
8806
  }),
8584
8807
  slots?.afterRows
8585
8808
  ] })),
8586
8809
  features.exportMidi && !designerView && !isLoadingTracks && tracks.length > 0 && (() => {
8587
8810
  const hasAnyMidi = tracks.some((t) => t.hasMidi);
8588
8811
  const exportDisabled = isExportingMidi || !hasAnyMidi;
8589
- return /* @__PURE__ */ jsx28("div", { className: "pt-2", children: /* @__PURE__ */ jsx28(
8812
+ return /* @__PURE__ */ jsx29("div", { className: "pt-2", children: /* @__PURE__ */ jsx29(
8590
8813
  "button",
8591
8814
  {
8592
8815
  "data-testid": "export-midi-tracks-button",
@@ -8641,6 +8864,15 @@ function createSurgeSoundAdapter(host, overrides = {}) {
8641
8864
  });
8642
8865
  return snap.label;
8643
8866
  },
8867
+ persistDescriptor: async (trackId, descriptor, label) => {
8868
+ const { state, stateType } = descriptor;
8869
+ await host.persistTrackPresetState?.(trackId, {
8870
+ state,
8871
+ stateType: stateType ?? "valuetree",
8872
+ name: label
8873
+ }).catch(() => {
8874
+ });
8875
+ },
8644
8876
  descriptorFromSnapshot: (snap) => {
8645
8877
  const preset = snap;
8646
8878
  return { state: preset.state, stateType: preset.stateType };
@@ -8725,18 +8957,183 @@ var SUB = {
8725
8957
  rootOnly: true,
8726
8958
  monoPreference: "low"
8727
8959
  };
8960
+ var HORN_MAX_NOTES_PER_BAR = 12;
8961
+ var HORN_FOLLOW_PALETTE = "EXACTLY the lead voice's rhythm \u2014 every attack lands together with voice 1";
8962
+ var HORN_LEAD = {
8963
+ label: "lead trumpet",
8964
+ role: "brass",
8965
+ registerLow: 60,
8966
+ // C4
8967
+ registerHigh: 84,
8968
+ // C6
8969
+ maxNotesPerBar: HORN_MAX_NOTES_PER_BAR,
8970
+ rhythmPalette: "tight funk syncopation \u2014 8ths and 16ths, staccato punches, off-beat anticipations",
8971
+ harmonicDiscipline: "the section's melodic top \u2014 chord tones on every hit; quick chromatic approach tones only on pickups",
8972
+ monoPreference: "high"
8973
+ };
8974
+ var HORN_TRUMPET_2 = {
8975
+ label: "second trumpet",
8976
+ role: "brass",
8977
+ registerLow: 55,
8978
+ // G3
8979
+ registerHigh: 79,
8980
+ // G5
8981
+ maxNotesPerBar: HORN_MAX_NOTES_PER_BAR,
8982
+ rhythmPalette: HORN_FOLLOW_PALETTE,
8983
+ harmonicDiscipline: "nearest chord tone directly below voice 1 \u2014 tight close voicing",
8984
+ monoPreference: "high"
8985
+ };
8986
+ var HORN_ALTO = {
8987
+ label: "alto sax",
8988
+ role: "brass",
8989
+ registerLow: 53,
8990
+ // F3
8991
+ registerHigh: 77,
8992
+ // F5
8993
+ maxNotesPerBar: HORN_MAX_NOTES_PER_BAR,
8994
+ rhythmPalette: HORN_FOLLOW_PALETTE,
8995
+ harmonicDiscipline: "chord tone below the trumpets \u2014 keep the upper stack inside one octave",
8996
+ monoPreference: "high"
8997
+ };
8998
+ var HORN_TENOR = {
8999
+ label: "tenor sax",
9000
+ role: "brass",
9001
+ registerLow: 46,
9002
+ // Bb2
9003
+ registerHigh: 72,
9004
+ // C5
9005
+ maxNotesPerBar: HORN_MAX_NOTES_PER_BAR,
9006
+ rhythmPalette: HORN_FOLLOW_PALETTE,
9007
+ harmonicDiscipline: "chord tone under the upper horns \u2014 complete the chord",
9008
+ monoPreference: "low"
9009
+ };
9010
+ var HORN_TROMBONE = {
9011
+ label: "trombone",
9012
+ role: "brass",
9013
+ registerLow: 43,
9014
+ // G2
9015
+ registerHigh: 67,
9016
+ // G4
9017
+ maxNotesPerBar: HORN_MAX_NOTES_PER_BAR,
9018
+ rhythmPalette: HORN_FOLLOW_PALETTE,
9019
+ harmonicDiscipline: "chord tone or root below the saxes \u2014 weight in the middle-low stack",
9020
+ monoPreference: "low"
9021
+ };
9022
+ var HORN_BARI = {
9023
+ label: "baritone sax",
9024
+ role: "brass",
9025
+ registerLow: 36,
9026
+ // C2
9027
+ registerHigh: 60,
9028
+ // C4
9029
+ maxNotesPerBar: HORN_MAX_NOTES_PER_BAR,
9030
+ rhythmPalette: HORN_FOLLOW_PALETTE,
9031
+ harmonicDiscipline: "chord roots and lower chord tones \u2014 the section's bottom anchor",
9032
+ monoPreference: "low"
9033
+ };
9034
+ var WIND_FLUTE = {
9035
+ label: "flute",
9036
+ role: "winds",
9037
+ registerLow: 60,
9038
+ // C4
9039
+ registerHigh: 93,
9040
+ // A6
9041
+ maxNotesPerBar: 8,
9042
+ rhythmPalette: "8ths and 16ths; runs, turns and trills welcome",
9043
+ harmonicDiscipline: "freest voice \u2014 non-chord tones as passing/neighbor tones on weak beats, resolving by step",
9044
+ monoPreference: "high"
9045
+ };
9046
+ var WIND_FLUTE_2 = {
9047
+ label: "second flute",
9048
+ role: "winds",
9049
+ registerLow: 60,
9050
+ // C4
9051
+ registerHigh: 86,
9052
+ // D6
9053
+ maxNotesPerBar: 5,
9054
+ rhythmPalette: "quarters with occasional 8th-note motion",
9055
+ harmonicDiscipline: "chord tones; fill gaps the other inner voices leave",
9056
+ monoPreference: "high"
9057
+ };
9058
+ var WIND_OBOE = {
9059
+ label: "oboe",
9060
+ role: "winds",
9061
+ registerLow: 58,
9062
+ // Bb3
9063
+ registerHigh: 84,
9064
+ // C6
9065
+ maxNotesPerBar: 6,
9066
+ rhythmPalette: "8ths and quarters; move when the flute rests",
9067
+ harmonicDiscipline: "mostly chord tones; may imitate the flute's motifs a bar later",
9068
+ monoPreference: "high"
9069
+ };
9070
+ var WIND_CLARINET = {
9071
+ label: "clarinet",
9072
+ role: "winds",
9073
+ registerLow: 50,
9074
+ // D3
9075
+ registerHigh: 81,
9076
+ // A5
9077
+ maxNotesPerBar: 4,
9078
+ rhythmPalette: "quarters and halves",
9079
+ harmonicDiscipline: "chord tones with smooth stepwise motion between them",
9080
+ monoPreference: "high"
9081
+ };
9082
+ var WIND_FRENCH_HORN = {
9083
+ label: "french horn",
9084
+ role: "winds",
9085
+ registerLow: 41,
9086
+ // F2
9087
+ registerHigh: 72,
9088
+ // C5
9089
+ maxNotesPerBar: 4,
9090
+ rhythmPalette: "quarters and halves; long held tones welcome",
9091
+ harmonicDiscipline: "chord tones \u2014 the warm glue in the middle of the ensemble",
9092
+ monoPreference: "low"
9093
+ };
9094
+ var WIND_BASSOON = {
9095
+ label: "bassoon",
9096
+ role: "winds",
9097
+ registerLow: 34,
9098
+ // Bb1
9099
+ registerHigh: 62,
9100
+ // D4
9101
+ maxNotesPerBar: 3,
9102
+ rhythmPalette: "quarters and halves; brief walking figures at cadences",
9103
+ harmonicDiscipline: "roots and fifths emphasized; passing tones only between chord tones",
9104
+ monoPreference: "low"
9105
+ };
8728
9106
  var ENSEMBLE_MIN_VOICES = 2;
8729
9107
  var ENSEMBLE_MAX_VOICES = 6;
8730
- var SPEC_TABLES = {
9108
+ var STRINGS_SPEC_TABLES = {
8731
9109
  2: [TOP, BASS],
8732
9110
  3: [TOP, INNER, BASS],
8733
9111
  4: [TOP, COUNTER, TENOR, BASS],
8734
9112
  5: [TOP, COUNTER, INNER, TENOR, SUB],
8735
9113
  6: [TOP, COUNTER, INNER_2, INNER, TENOR, SUB]
8736
9114
  };
8737
- function defaultVoiceSpecs(voiceCount) {
9115
+ var HORNS_SPEC_TABLES = {
9116
+ 2: [HORN_LEAD, HORN_TENOR],
9117
+ 3: [HORN_LEAD, HORN_TENOR, HORN_BARI],
9118
+ 4: [HORN_LEAD, HORN_TRUMPET_2, HORN_TENOR, HORN_BARI],
9119
+ 5: [HORN_LEAD, HORN_TRUMPET_2, HORN_TENOR, HORN_TROMBONE, HORN_BARI],
9120
+ 6: [HORN_LEAD, HORN_TRUMPET_2, HORN_ALTO, HORN_TENOR, HORN_TROMBONE, HORN_BARI]
9121
+ };
9122
+ var WINDS_SPEC_TABLES = {
9123
+ 2: [WIND_FLUTE, WIND_BASSOON],
9124
+ 3: [WIND_FLUTE, WIND_CLARINET, WIND_BASSOON],
9125
+ 4: [WIND_FLUTE, WIND_OBOE, WIND_FRENCH_HORN, WIND_BASSOON],
9126
+ 5: [WIND_FLUTE, WIND_OBOE, WIND_CLARINET, WIND_FRENCH_HORN, WIND_BASSOON],
9127
+ 6: [WIND_FLUTE, WIND_FLUTE_2, WIND_OBOE, WIND_CLARINET, WIND_FRENCH_HORN, WIND_BASSOON]
9128
+ };
9129
+ var SPEC_TABLES_BY_INSTRUMENTATION = {
9130
+ strings: STRINGS_SPEC_TABLES,
9131
+ horns: HORNS_SPEC_TABLES,
9132
+ winds: WINDS_SPEC_TABLES
9133
+ };
9134
+ function defaultVoiceSpecs(voiceCount, instrumentation = "strings") {
8738
9135
  const n = Math.max(ENSEMBLE_MIN_VOICES, Math.min(ENSEMBLE_MAX_VOICES, Math.round(voiceCount)));
8739
- return SPEC_TABLES[n].map((spec, voiceIndex) => ({ ...spec, voiceIndex }));
9136
+ return SPEC_TABLES_BY_INSTRUMENTATION[instrumentation][n].map((spec, voiceIndex) => ({ ...spec, voiceIndex }));
8740
9137
  }
8741
9138
 
8742
9139
  // src/ensemble-core/enforce-voice.ts
@@ -8776,10 +9173,14 @@ function enforceVoice(rawNotes, spec, opts) {
8776
9173
  repairs.push(`voice ${spec.voiceIndex}: dropped note outside the ${opts.bars}-bar clip (start ${n.startBeat})`);
8777
9174
  continue;
8778
9175
  }
8779
- const durationBeats = Math.max(
9176
+ let durationBeats = Math.max(
8780
9177
  MIN_NOTE_DURATION_BEATS,
8781
9178
  Math.min(n.durationBeats, clipEnd - n.startBeat)
8782
9179
  );
9180
+ if (opts.maxNoteDurationBeats !== void 0 && durationBeats > opts.maxNoteDurationBeats) {
9181
+ durationBeats = Math.max(MIN_NOTE_DURATION_BEATS, opts.maxNoteDurationBeats);
9182
+ repairs.push(`voice ${spec.voiceIndex}: trimmed note at beat ${n.startBeat} to the style's ${opts.maxNoteDurationBeats}-beat stab ceiling`);
9183
+ }
8783
9184
  notes.push({ ...n, durationBeats });
8784
9185
  }
8785
9186
  notes = notes.map((n) => {
@@ -8940,12 +9341,19 @@ function describeViolations(analysis, rules) {
8940
9341
  if (pair.onsetIndependence < rules.minOnsetIndependence) {
8941
9342
  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.`);
8942
9343
  }
9344
+ if (rules.maxOnsetIndependence !== void 0 && pair.onsetIndependence > rules.maxOnsetIndependence) {
9345
+ 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.`);
9346
+ }
8943
9347
  }
8944
9348
  return out;
8945
9349
  }
8946
9350
 
8947
9351
  // src/ensemble-core/styles.ts
8948
9352
  var ENSEMBLE_STYLES = ["counterpoint", "chorale", "interlock"];
9353
+ var STAB_MAX_ONSET_INDEPENDENCE = 0.25;
9354
+ var RIFF_MAX_ONSET_INDEPENDENCE = 0.3;
9355
+ var UNISON_MAX_ONSET_INDEPENDENCE = 0.15;
9356
+ var STAB_MAX_NOTE_DURATION_BEATS = 1;
8949
9357
  var STYLE_RULES = {
8950
9358
  counterpoint: {
8951
9359
  forbidParallelPerfects: true,
@@ -8964,9 +9372,55 @@ var STYLE_RULES = {
8964
9372
  forbidVoiceCrossing: false,
8965
9373
  minOnsetIndependence: 0.6,
8966
9374
  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."
9375
+ },
9376
+ stabs: {
9377
+ forbidParallelPerfects: false,
9378
+ forbidVoiceCrossing: true,
9379
+ minOnsetIndependence: 0,
9380
+ maxOnsetIndependence: STAB_MAX_ONSET_INDEPENDENCE,
9381
+ maxNoteDurationBeats: STAB_MAX_NOTE_DURATION_BEATS,
9382
+ 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.`
9383
+ },
9384
+ riffs: {
9385
+ forbidParallelPerfects: false,
9386
+ forbidVoiceCrossing: true,
9387
+ minOnsetIndependence: 0,
9388
+ maxOnsetIndependence: RIFF_MAX_ONSET_INDEPENDENCE,
9389
+ 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.'
9390
+ },
9391
+ unison: {
9392
+ forbidParallelPerfects: false,
9393
+ forbidVoiceCrossing: false,
9394
+ minOnsetIndependence: 0,
9395
+ maxOnsetIndependence: UNISON_MAX_ONSET_INDEPENDENCE,
9396
+ 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."
8967
9397
  }
8968
9398
  };
8969
9399
 
9400
+ // src/ensemble-core/instrumentation.ts
9401
+ var ENSEMBLE_INSTRUMENTATIONS = [
9402
+ "strings",
9403
+ "horns",
9404
+ "winds"
9405
+ ];
9406
+ var STYLES_FOR_INSTRUMENTATION = {
9407
+ strings: ["counterpoint", "chorale", "interlock"],
9408
+ horns: ["stabs", "riffs", "unison"],
9409
+ winds: ["counterpoint", "chorale", "interlock"]
9410
+ };
9411
+ var DEFAULT_STYLE_FOR_INSTRUMENTATION = {
9412
+ strings: "counterpoint",
9413
+ horns: "stabs",
9414
+ winds: "chorale"
9415
+ };
9416
+ function normalizeInstrumentation(raw) {
9417
+ return ENSEMBLE_INSTRUMENTATIONS.includes(raw) ? raw : "strings";
9418
+ }
9419
+ function styleForInstrumentation(instrumentation, rawStyle) {
9420
+ const allowed = STYLES_FOR_INSTRUMENTATION[instrumentation];
9421
+ return allowed.includes(rawStyle ?? "") ? rawStyle : DEFAULT_STYLE_FOR_INSTRUMENTATION[instrumentation];
9422
+ }
9423
+
8970
9424
  // src/ensemble-core/ensemble-schema.ts
8971
9425
  var SUBMIT_ENSEMBLE_TOOL_NAME = "submit_ensemble";
8972
9426
  function buildSubmitEnsembleParameters(voiceCount) {
@@ -9048,9 +9502,13 @@ function voiceContractLine(spec) {
9048
9502
  const root = spec.rootOnly ? " ROOT PITCH CLASS ONLY (each bar's chord root)." : "";
9049
9503
  return `- Voice ${spec.voiceIndex + 1} (${spec.label}): MIDI ${spec.registerLow}-${spec.registerHigh}, max ${spec.maxNotesPerBar} notes/bar, rhythm: ${spec.rhythmPalette}. ${spec.harmonicDiscipline}.${root}`;
9050
9504
  }
9051
- function buildEnsembleSystemPrompt(specs, style) {
9505
+ var WOVEN_PERSONA = {
9506
+ strings: "You are an ensemble composer.",
9507
+ winds: "You are a wind-ensemble composer writing for chamber winds (flutes, oboe, clarinet, french horn, bassoon)."
9508
+ };
9509
+ function buildWovenPrompt(specs, style, instrumentation) {
9052
9510
  const styleParagraph = STYLE_RULES[style].promptParagraph;
9053
- return `You are an ensemble composer. Compose ${specs.length} voices as ONE piece of music \u2014 not ${specs.length} independent parts.
9511
+ return `${WOVEN_PERSONA[instrumentation]} Compose ${specs.length} voices as ONE piece of music \u2014 not ${specs.length} independent parts.
9054
9512
 
9055
9513
  Submit your composition by calling the ${SUBMIT_ENSEMBLE_TOOL_NAME} tool with all ${specs.length} voices.
9056
9514
 
@@ -9067,6 +9525,28 @@ ${styleParagraph}
9067
9525
  PER-VOICE CONTRACTS:
9068
9526
  ${specs.map(voiceContractLine).join("\n")}`;
9069
9527
  }
9528
+ function buildSectionPrompt(specs, style) {
9529
+ const styleParagraph = STYLE_RULES[style].promptParagraph;
9530
+ 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.
9531
+
9532
+ Submit your composition by calling the ${SUBMIT_ENSEMBLE_TOOL_NAME} tool with all ${specs.length} voices.
9533
+
9534
+ THE SECTION RULES (most important):
9535
+ 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.
9536
+ 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.
9537
+ 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.
9538
+ 4. Follow the chord progression in the musical context exactly \u2014 every hit is voiced from the chord sounding at that beat.
9539
+ 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.
9540
+ 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).
9541
+
9542
+ ${styleParagraph}
9543
+
9544
+ PER-VOICE CONTRACTS:
9545
+ ${specs.map(voiceContractLine).join("\n")}`;
9546
+ }
9547
+ function buildEnsembleSystemPrompt(specs, style, instrumentation = "strings") {
9548
+ return instrumentation === "horns" ? buildSectionPrompt(specs, style) : buildWovenPrompt(specs, style, instrumentation);
9549
+ }
9070
9550
  function buildViolationRetrySuffix(violations) {
9071
9551
  if (violations.length === 0) return "";
9072
9552
  return `
@@ -9241,10 +9721,12 @@ export {
9241
9721
  DB_MIN,
9242
9722
  DEFAULT_FX_CATEGORY_DETAIL,
9243
9723
  DEFAULT_FX_DRY_WET,
9724
+ DEFAULT_STYLE_FOR_INSTRUMENTATION,
9244
9725
  DRAG_DEAD_ZONE,
9245
9726
  DownloadPackButton,
9246
9727
  EMPTY_FX_DETAIL_STATE,
9247
9728
  EMPTY_FX_STATE,
9729
+ ENSEMBLE_INSTRUMENTATIONS,
9248
9730
  ENSEMBLE_MAX_VOICES,
9249
9731
  ENSEMBLE_MIN_VOICES,
9250
9732
  ENSEMBLE_STYLES,
@@ -9259,7 +9741,9 @@ export {
9259
9741
  FxToggleBar,
9260
9742
  GUTTER_W,
9261
9743
  GeneratorPanelShell,
9744
+ GroupCollapseChevron,
9262
9745
  GroupFadeTrackRow,
9746
+ HORN_MAX_NOTES_PER_BAR,
9263
9747
  ImportTrackModal,
9264
9748
  TrackDrawer as InstrumentDrawer,
9265
9749
  LevelMeter,
@@ -9273,8 +9757,12 @@ export {
9273
9757
  PianoRollEditor,
9274
9758
  PluginError,
9275
9759
  RESIZE_HANDLE_PX,
9760
+ RIFF_MAX_ONSET_INDEPENDENCE,
9276
9761
  ROW_HEIGHT,
9277
9762
  SLIDER_UNITY,
9763
+ STAB_MAX_NOTE_DURATION_BEATS,
9764
+ STAB_MAX_ONSET_INDEPENDENCE,
9765
+ STYLES_FOR_INSTRUMENTATION,
9278
9766
  STYLE_RULES,
9279
9767
  SUBMIT_ENSEMBLE_TOOL_NAME,
9280
9768
  SamplePackCTACard,
@@ -9288,6 +9776,7 @@ export {
9288
9776
  TrackMeterStrip,
9289
9777
  TrackRow,
9290
9778
  TransitionDesigner,
9779
+ UNISON_MAX_ONSET_INDEPENDENCE,
9291
9780
  VolumeSlider,
9292
9781
  WaveformView,
9293
9782
  analyzeEnsemble,
@@ -9322,6 +9811,7 @@ export {
9322
9811
  moveItem,
9323
9812
  nearestPitchWithPc,
9324
9813
  newTrackState,
9814
+ normalizeInstrumentation,
9325
9815
  normalizeSlots,
9326
9816
  padPair,
9327
9817
  padSlots,
@@ -9345,6 +9835,7 @@ export {
9345
9835
  slotsEqual,
9346
9836
  soundIdentity,
9347
9837
  splitFadeEntries,
9838
+ styleForInstrumentation,
9348
9839
  synthesizeCuePoints,
9349
9840
  tokenizePrompt,
9350
9841
  trackDataKey,