@signalsandsorcery/plugin-sdk 2.38.2 → 2.40.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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,8 @@ function TrackRow({
2514
2523
  onToggleFavorite,
2515
2524
  onImportSound,
2516
2525
  importSoundLabel,
2526
+ linkedSoundHint,
2527
+ onSyncSoundToGroup,
2517
2528
  editNotes,
2518
2529
  onNotesChange,
2519
2530
  editBars,
@@ -2679,15 +2690,37 @@ function TrackRow({
2679
2690
  )
2680
2691
  ] }),
2681
2692
  /* @__PURE__ */ jsxs9("div", { className: "flex gap-1 items-center", children: [
2682
- onShuffle && /* @__PURE__ */ jsx13(
2693
+ onShuffle && /* @__PURE__ */ jsxs9(
2683
2694
  "button",
2684
2695
  {
2685
2696
  "data-testid": "sdk-shuffle-button",
2686
2697
  onClick: onShuffle,
2687
2698
  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"
2699
+ 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"}`,
2700
+ 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",
2701
+ children: [
2702
+ "Shuffle",
2703
+ linkedSoundHint && /* @__PURE__ */ jsx13(
2704
+ "span",
2705
+ {
2706
+ "aria-hidden": true,
2707
+ "data-testid": "sdk-shuffle-linked-marker",
2708
+ className: "absolute -top-1 -right-1 text-[8px] leading-none",
2709
+ children: "\u{1F517}"
2710
+ }
2711
+ )
2712
+ ]
2713
+ }
2714
+ ),
2715
+ onSyncSoundToGroup && /* @__PURE__ */ jsx13(
2716
+ "button",
2717
+ {
2718
+ "data-testid": "sdk-sync-sound-button",
2719
+ onClick: onSyncSoundToGroup,
2720
+ disabled: isGenerating,
2721
+ className: `px-1.5 py-0.5 rounded-sm text-xs font-medium transition-colors border whitespace-nowrap ${isGenerating ? "bg-sas-panel border-sas-border text-sas-muted/30 cursor-not-allowed" : "bg-sas-panel-alt border-sas-border text-sas-muted hover:border-sas-accent hover:text-sas-accent"}`,
2722
+ title: "Apply THIS part's current sound to all linked parts \u2014 use after picking a patch inside the plugin's own editor",
2723
+ children: "\u2192 All"
2691
2724
  }
2692
2725
  ),
2693
2726
  freezeBadge && /* @__PURE__ */ jsx13(
@@ -2771,6 +2804,7 @@ function TrackRow({
2771
2804
  onToggleFavorite,
2772
2805
  onImportSound,
2773
2806
  importSoundLabel,
2807
+ linkedSoundHint,
2774
2808
  editNotes,
2775
2809
  onNotesChange,
2776
2810
  editBars,
@@ -4998,9 +5032,38 @@ function TransitionDesigner({
4998
5032
  ] });
4999
5033
  }
5000
5034
 
5035
+ // src/components/GroupCollapseChevron.tsx
5036
+ import { ChevronDown as ChevronDown2 } from "lucide-react";
5037
+ import { jsx as jsx21 } from "react/jsx-runtime";
5038
+ function GroupCollapseChevron({
5039
+ collapsed = false,
5040
+ onToggle,
5041
+ what = "group"
5042
+ }) {
5043
+ if (!onToggle) return null;
5044
+ return /* @__PURE__ */ jsx21(
5045
+ "button",
5046
+ {
5047
+ type: "button",
5048
+ "data-testid": "sdk-group-collapse",
5049
+ onClick: onToggle,
5050
+ "aria-expanded": !collapsed,
5051
+ title: collapsed ? `Expand ${what}` : `Collapse ${what}`,
5052
+ className: "shrink-0 px-0.5 py-0.5 rounded-sm text-sas-muted hover:text-sas-accent transition-colors",
5053
+ children: /* @__PURE__ */ jsx21(
5054
+ ChevronDown2,
5055
+ {
5056
+ className: `w-3 h-3 transition-transform ${collapsed ? "-rotate-90" : ""}`,
5057
+ strokeWidth: 2.5
5058
+ }
5059
+ )
5060
+ }
5061
+ );
5062
+ }
5063
+
5001
5064
  // src/components/PanelMasterStrip.tsx
5002
5065
  import { useMemo as useMemo7, useState as useState15 } from "react";
5003
- import { jsx as jsx21, jsxs as jsxs17 } from "react/jsx-runtime";
5066
+ import { jsx as jsx22, jsxs as jsxs17 } from "react/jsx-runtime";
5004
5067
  function PanelMasterStrip({
5005
5068
  bus,
5006
5069
  levels = null,
@@ -5034,7 +5097,7 @@ function PanelMasterStrip({
5034
5097
  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
5098
  children: [
5036
5099
  /* @__PURE__ */ jsxs17("div", { className: "flex items-center gap-2", children: [
5037
- /* @__PURE__ */ jsx21(
5100
+ /* @__PURE__ */ jsx22(
5038
5101
  "span",
5039
5102
  {
5040
5103
  className: "text-[9px] font-bold tracking-widest text-sas-muted/70 select-none",
@@ -5043,7 +5106,7 @@ function PanelMasterStrip({
5043
5106
  }
5044
5107
  ),
5045
5108
  /* @__PURE__ */ jsxs17("div", { className: "flex-1 min-w-[8rem] flex flex-col gap-0.5", children: [
5046
- /* @__PURE__ */ jsx21(
5109
+ /* @__PURE__ */ jsx22(
5047
5110
  VolumeSlider,
5048
5111
  {
5049
5112
  value: dbToSlider(bus.volume),
@@ -5052,7 +5115,7 @@ function PanelMasterStrip({
5052
5115
  }
5053
5116
  ),
5054
5117
  /* @__PURE__ */ jsxs17("div", { className: "flex flex-col gap-px", "data-testid": "bus-meter", children: [
5055
- /* @__PURE__ */ jsx21(
5118
+ /* @__PURE__ */ jsx22(
5056
5119
  LevelMeter,
5057
5120
  {
5058
5121
  peakDb: levels?.leftDb ?? -120,
@@ -5062,7 +5125,7 @@ function PanelMasterStrip({
5062
5125
  "data-testid": "bus-meter-left"
5063
5126
  }
5064
5127
  ),
5065
- /* @__PURE__ */ jsx21(
5128
+ /* @__PURE__ */ jsx22(
5066
5129
  LevelMeter,
5067
5130
  {
5068
5131
  peakDb: levels?.rightDb ?? -120,
@@ -5073,7 +5136,7 @@ function PanelMasterStrip({
5073
5136
  )
5074
5137
  ] })
5075
5138
  ] }),
5076
- /* @__PURE__ */ jsx21(
5139
+ /* @__PURE__ */ jsx22(
5077
5140
  "button",
5078
5141
  {
5079
5142
  "data-testid": "bus-mute-button",
@@ -5084,7 +5147,7 @@ function PanelMasterStrip({
5084
5147
  children: "M"
5085
5148
  }
5086
5149
  ),
5087
- /* @__PURE__ */ jsx21(
5150
+ /* @__PURE__ */ jsx22(
5088
5151
  "button",
5089
5152
  {
5090
5153
  "data-testid": "bus-solo-button",
@@ -5095,14 +5158,14 @@ function PanelMasterStrip({
5095
5158
  children: "S"
5096
5159
  }
5097
5160
  ),
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(
5161
+ /* @__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
5162
  "span",
5100
5163
  {
5101
5164
  "data-testid": `bus-fx-chip-${fx.index}`,
5102
5165
  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
5166
  title: `${fx.name}${fx.enabled ? "" : " (bypassed)"}`,
5104
5167
  children: [
5105
- /* @__PURE__ */ jsx21(
5168
+ /* @__PURE__ */ jsx22(
5106
5169
  "button",
5107
5170
  {
5108
5171
  "data-testid": `bus-fx-toggle-${fx.index}`,
@@ -5113,7 +5176,7 @@ function PanelMasterStrip({
5113
5176
  children: fx.enabled ? "\u25CF" : "\u25CB"
5114
5177
  }
5115
5178
  ),
5116
- onShowFxEditor ? /* @__PURE__ */ jsx21(
5179
+ onShowFxEditor ? /* @__PURE__ */ jsx22(
5117
5180
  "button",
5118
5181
  {
5119
5182
  "data-testid": `bus-fx-edit-${fx.index}`,
@@ -5123,8 +5186,8 @@ function PanelMasterStrip({
5123
5186
  title: `Open ${fx.name} editor`,
5124
5187
  children: fx.name
5125
5188
  }
5126
- ) : /* @__PURE__ */ jsx21("span", { className: "max-w-[80px] truncate", children: fx.name }),
5127
- /* @__PURE__ */ jsx21(
5189
+ ) : /* @__PURE__ */ jsx22("span", { className: "max-w-[80px] truncate", children: fx.name }),
5190
+ /* @__PURE__ */ jsx22(
5128
5191
  "button",
5129
5192
  {
5130
5193
  "data-testid": `bus-fx-remove-${fx.index}`,
@@ -5139,7 +5202,7 @@ function PanelMasterStrip({
5139
5202
  },
5140
5203
  `${fx.index}:${fx.pluginId}`
5141
5204
  )) }),
5142
- /* @__PURE__ */ jsx21(
5205
+ /* @__PURE__ */ jsx22(
5143
5206
  "button",
5144
5207
  {
5145
5208
  "data-testid": "bus-fx-add-button",
@@ -5153,7 +5216,7 @@ function PanelMasterStrip({
5153
5216
  ] }),
5154
5217
  fxPickerOpen && /* @__PURE__ */ jsxs17("div", { "data-testid": "bus-fx-picker", className: "flex flex-col gap-2 pt-1", children: [
5155
5218
  /* @__PURE__ */ jsxs17("div", { className: "flex items-center gap-2", children: [
5156
- /* @__PURE__ */ jsx21(
5219
+ /* @__PURE__ */ jsx22(
5157
5220
  "input",
5158
5221
  {
5159
5222
  type: "text",
@@ -5163,7 +5226,7 @@ function PanelMasterStrip({
5163
5226
  className: "sas-input flex-1 px-2 py-1 text-xs"
5164
5227
  }
5165
5228
  ),
5166
- onRefreshFx && /* @__PURE__ */ jsx21(
5229
+ onRefreshFx && /* @__PURE__ */ jsx22(
5167
5230
  "button",
5168
5231
  {
5169
5232
  onClick: () => onRefreshFx(),
@@ -5174,7 +5237,7 @@ function PanelMasterStrip({
5174
5237
  }
5175
5238
  )
5176
5239
  ] }),
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: [
5240
+ 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
5241
  filtered.map((fx) => /* @__PURE__ */ jsxs17(
5179
5242
  "button",
5180
5243
  {
@@ -5183,13 +5246,13 @@ function PanelMasterStrip({
5183
5246
  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
5247
  title: `${fx.name} by ${fx.manufacturer} (${fx.type.toUpperCase()})`,
5185
5248
  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() })
5249
+ /* @__PURE__ */ jsx22("span", { className: "text-xs font-medium truncate w-full", children: fx.name }),
5250
+ /* @__PURE__ */ jsx22("span", { className: "text-[9px] text-sas-muted/50 truncate w-full", children: fx.manufacturer || fx.type.toUpperCase() })
5188
5251
  ]
5189
5252
  },
5190
5253
  fx.pluginId
5191
5254
  )),
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" })
5255
+ 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
5256
  ] })
5194
5257
  ] })
5195
5258
  ]
@@ -5316,7 +5379,7 @@ function usePanelBus(host, activeSceneId) {
5316
5379
 
5317
5380
  // src/components/DownloadPackButton.tsx
5318
5381
  import { useCallback as useCallback12, useEffect as useEffect13, useState as useState17 } from "react";
5319
- import { jsx as jsx22, jsxs as jsxs18 } from "react/jsx-runtime";
5382
+ import { jsx as jsx23, jsxs as jsxs18 } from "react/jsx-runtime";
5320
5383
  function formatSize(bytes) {
5321
5384
  if (!bytes || bytes <= 0) return "";
5322
5385
  const gb = bytes / 1024 ** 3;
@@ -5405,7 +5468,7 @@ var DownloadPackButton = ({
5405
5468
  className = `${baseClasses} text-sas-muted hover:text-sas-accent border-sas-border hover:border-sas-accent`;
5406
5469
  }
5407
5470
  return /* @__PURE__ */ jsxs18("div", { children: [
5408
- /* @__PURE__ */ jsx22(
5471
+ /* @__PURE__ */ jsx23(
5409
5472
  "button",
5410
5473
  {
5411
5474
  "data-testid": `download-pack-button-${packId}`,
@@ -5416,12 +5479,12 @@ var DownloadPackButton = ({
5416
5479
  children: buttonLabel
5417
5480
  }
5418
5481
  ),
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 })
5482
+ 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
5483
  ] });
5421
5484
  };
5422
5485
 
5423
5486
  // src/components/SamplePackCTACard.tsx
5424
- import { jsx as jsx23, jsxs as jsxs19 } from "react/jsx-runtime";
5487
+ import { jsx as jsx24, jsxs as jsxs19 } from "react/jsx-runtime";
5425
5488
  var SamplePackCTACard = ({
5426
5489
  host,
5427
5490
  pack,
@@ -5429,7 +5492,7 @@ var SamplePackCTACard = ({
5429
5492
  onDownloadComplete
5430
5493
  }) => {
5431
5494
  if (status === "checking") {
5432
- return /* @__PURE__ */ jsx23(
5495
+ return /* @__PURE__ */ jsx24(
5433
5496
  "div",
5434
5497
  {
5435
5498
  "data-testid": `sample-pack-cta-checking-${pack.packId}`,
@@ -5446,10 +5509,10 @@ var SamplePackCTACard = ({
5446
5509
  "data-testid": `sample-pack-cta-${pack.packId}`,
5447
5510
  className: "flex flex-col items-center justify-center py-12 px-6 text-center",
5448
5511
  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(
5512
+ /* @__PURE__ */ jsx24("div", { className: "text-sm uppercase tracking-wide text-sas-muted mb-2", children: status === "stale" ? "Update available" : "Sample library not installed" }),
5513
+ /* @__PURE__ */ jsx24("div", { className: "text-base text-sas-text mb-1", children: headline }),
5514
+ /* @__PURE__ */ jsx24("div", { className: "text-xs text-sas-muted mb-6 max-w-md", children: sublabel }),
5515
+ /* @__PURE__ */ jsx24(
5453
5516
  DownloadPackButton,
5454
5517
  {
5455
5518
  host,
@@ -5529,7 +5592,7 @@ function drawWaveform(canvas, peaks, options = {}) {
5529
5592
  }
5530
5593
 
5531
5594
  // src/components/WaveformView.tsx
5532
- import { jsx as jsx24 } from "react/jsx-runtime";
5595
+ import { jsx as jsx25 } from "react/jsx-runtime";
5533
5596
  var WaveformView = ({
5534
5597
  host,
5535
5598
  filePath,
@@ -5577,7 +5640,7 @@ var WaveformView = ({
5577
5640
  observer.observe(canvas);
5578
5641
  return () => observer.disconnect();
5579
5642
  }, [peaks, fillStyle]);
5580
- return /* @__PURE__ */ jsx24(
5643
+ return /* @__PURE__ */ jsx25(
5581
5644
  "canvas",
5582
5645
  {
5583
5646
  ref: canvasRef,
@@ -5589,7 +5652,7 @@ var WaveformView = ({
5589
5652
 
5590
5653
  // src/components/ScrollingWaveform.tsx
5591
5654
  import { useEffect as useEffect15, useRef as useRef14 } from "react";
5592
- import { jsx as jsx25 } from "react/jsx-runtime";
5655
+ import { jsx as jsx26 } from "react/jsx-runtime";
5593
5656
  var ScrollingWaveform = ({
5594
5657
  getPeakDb,
5595
5658
  active,
@@ -5665,7 +5728,7 @@ var ScrollingWaveform = ({
5665
5728
  }
5666
5729
  };
5667
5730
  }, [active, getPeakDb, fillStyle]);
5668
- return /* @__PURE__ */ jsx25(
5731
+ return /* @__PURE__ */ jsx26(
5669
5732
  "canvas",
5670
5733
  {
5671
5734
  ref: canvasRef,
@@ -5677,7 +5740,7 @@ var ScrollingWaveform = ({
5677
5740
 
5678
5741
  // src/components/OffsetScrubber.tsx
5679
5742
  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";
5743
+ import { jsx as jsx27, jsxs as jsxs20 } from "react/jsx-runtime";
5681
5744
  var SLIDER_HEIGHT_PX = 28;
5682
5745
  var TICK_HEIGHT_PX = 14;
5683
5746
  var DOWNBEAT_TICK_HEIGHT_PX = 22;
@@ -5793,7 +5856,7 @@ function OffsetScrubber({
5793
5856
  }, [cuePoints, sampleToFraction]);
5794
5857
  const isDisabled = disabled || !cuePoints || cuePoints.beats.length === 0;
5795
5858
  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" }),
5859
+ /* @__PURE__ */ jsx27("span", { className: "text-[9px] text-sas-muted/60 uppercase tracking-wide flex-shrink-0", children: "Align" }),
5797
5860
  /* @__PURE__ */ jsxs20(
5798
5861
  "div",
5799
5862
  {
@@ -5810,7 +5873,7 @@ function OffsetScrubber({
5810
5873
  "aria-valuenow": draftOffset,
5811
5874
  "aria-disabled": isDisabled,
5812
5875
  children: [
5813
- /* @__PURE__ */ jsx26(
5876
+ /* @__PURE__ */ jsx27(
5814
5877
  "div",
5815
5878
  {
5816
5879
  "aria-hidden": "true",
@@ -5818,7 +5881,7 @@ function OffsetScrubber({
5818
5881
  style: { left: "50%" }
5819
5882
  }
5820
5883
  ),
5821
- ticks.map((t) => /* @__PURE__ */ jsx26(
5884
+ ticks.map((t) => /* @__PURE__ */ jsx27(
5822
5885
  "div",
5823
5886
  {
5824
5887
  "data-testid": t.isDownbeat ? "offset-tick-downbeat" : "offset-tick",
@@ -5833,7 +5896,7 @@ function OffsetScrubber({
5833
5896
  },
5834
5897
  t.i
5835
5898
  )),
5836
- /* @__PURE__ */ jsx26(
5899
+ /* @__PURE__ */ jsx27(
5837
5900
  "div",
5838
5901
  {
5839
5902
  "data-testid": "offset-scrubber-thumb",
@@ -5850,7 +5913,7 @@ function OffsetScrubber({
5850
5913
  ]
5851
5914
  }
5852
5915
  ),
5853
- /* @__PURE__ */ jsx26(
5916
+ /* @__PURE__ */ jsx27(
5854
5917
  "span",
5855
5918
  {
5856
5919
  "data-testid": "offset-scrubber-readout",
@@ -5858,7 +5921,7 @@ function OffsetScrubber({
5858
5921
  children: formatOffset(draftOffset, sampleRate)
5859
5922
  }
5860
5923
  ),
5861
- /* @__PURE__ */ jsx26(
5924
+ /* @__PURE__ */ jsx27(
5862
5925
  "button",
5863
5926
  {
5864
5927
  type: "button",
@@ -5870,7 +5933,7 @@ function OffsetScrubber({
5870
5933
  children: "\u2316"
5871
5934
  }
5872
5935
  ),
5873
- bpmMismatch && /* @__PURE__ */ jsx26(
5936
+ bpmMismatch && /* @__PURE__ */ jsx27(
5874
5937
  "span",
5875
5938
  {
5876
5939
  "data-testid": "offset-bpm-mismatch",
@@ -6883,7 +6946,7 @@ function useTransitionOps({
6883
6946
  }
6884
6947
 
6885
6948
  // src/panel-core/useGeneratorPanelCore.tsx
6886
- import { jsx as jsx27, jsxs as jsxs21 } from "react/jsx-runtime";
6949
+ import { jsx as jsx28, jsxs as jsxs21 } from "react/jsx-runtime";
6887
6950
  var EMPTY_PLACEHOLDERS = [];
6888
6951
  function useGeneratorPanelCore({
6889
6952
  ui,
@@ -7294,37 +7357,6 @@ function useGeneratorPanelCore({
7294
7357
  },
7295
7358
  [host, adapter, identity, activeSceneId, isConnected, tracks.length, loadTracks]
7296
7359
  );
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
7360
  const [isExportingMidi, setIsExportingMidi] = useState24(false);
7329
7361
  const handleExportMidi = useCallback17(async () => {
7330
7362
  if (isExportingMidi) return;
@@ -7381,7 +7413,7 @@ function useGeneratorPanelCore({
7381
7413
  const addDisabled = needsContract || !isConnected || !activeSceneId || tracks.length >= identity.maxTracks || isAddingTrack;
7382
7414
  onHeaderContent(
7383
7415
  /* @__PURE__ */ jsxs21("div", { className: "flex gap-1 items-center", children: [
7384
- features.importTracks && (!canCrossfade || !designerView) && host.listImportableTracks && /* @__PURE__ */ jsx27(
7416
+ features.importTracks && (!canCrossfade || !designerView) && host.listImportableTracks && /* @__PURE__ */ jsx28(
7385
7417
  "button",
7386
7418
  {
7387
7419
  "data-testid": `import-from-scene-${identity.familyKey}-button`,
@@ -7395,7 +7427,7 @@ function useGeneratorPanelCore({
7395
7427
  children: identity.importTrackLabel ?? "Import Track"
7396
7428
  }
7397
7429
  ),
7398
- (!canCrossfade || !designerView) && /* @__PURE__ */ jsx27(
7430
+ (!canCrossfade || !designerView) && /* @__PURE__ */ jsx28(
7399
7431
  "button",
7400
7432
  {
7401
7433
  "data-testid": `add-${identity.familyKey}-track-button`,
@@ -7430,7 +7462,7 @@ function useGeneratorPanelCore({
7430
7462
  title: designerView ? "Back to the track list" : "Open the transition designer",
7431
7463
  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
7464
  children: [
7433
- transitionSourceTotal > 0 && /* @__PURE__ */ jsx27(
7465
+ transitionSourceTotal > 0 && /* @__PURE__ */ jsx28(
7434
7466
  "span",
7435
7467
  {
7436
7468
  className: "absolute inset-y-0 left-0 bg-sas-accent/25",
@@ -7571,9 +7603,183 @@ function useGeneratorPanelCore({
7571
7603
  name: `${identity.trackNamePrefix}-${Date.now()}${nameSuffix}`,
7572
7604
  ...adapter.createTrackOptions()
7573
7605
  }),
7574
- resolvedGroups: (metaKey) => resolvedGenericGroupsRef.current[metaKey]?.resolved ?? []
7606
+ resolvedGroups: (metaKey) => resolvedGenericGroupsRef.current[metaKey]?.resolved ?? [],
7607
+ sound: adapter.sound
7575
7608
  };
7576
7609
  }, [host, activeSceneId, updateTrack, loadTracks, soundHistory, engineToDbId, markEditLoaded, identity, adapter]);
7610
+ const broadcastSoundFromTrack = useCallback17(
7611
+ async (sourceTrackId, descriptor, label) => {
7612
+ const targetsOf = adapter.sound.broadcastTargets;
7613
+ if (!targetsOf) return;
7614
+ const source = tracksRef.current.find((t) => t.handle.id === sourceTrackId);
7615
+ if (!source) return;
7616
+ let targets;
7617
+ try {
7618
+ targets = await targetsOf(source, makeServices());
7619
+ } catch (err) {
7620
+ console.warn(`[${logTag}] broadcastTargets failed:`, err);
7621
+ return;
7622
+ }
7623
+ const sourceInstrument = source.instrumentPluginId ?? null;
7624
+ const others = (targets ?? []).filter((t) => {
7625
+ if (t.engineId === sourceTrackId) return false;
7626
+ const live = tracksRef.current.find((x) => x.handle.id === t.engineId);
7627
+ return (live?.instrumentPluginId ?? null) === sourceInstrument;
7628
+ });
7629
+ if (others.length === 0) return;
7630
+ try {
7631
+ await adapter.sound.persistDescriptor?.(sourceTrackId, descriptor, label);
7632
+ } catch {
7633
+ }
7634
+ const appliedIds = /* @__PURE__ */ new Set();
7635
+ const failed = [];
7636
+ for (const target of others) {
7637
+ try {
7638
+ if (soundHistory.list(target.engineId).entries.length === 0) {
7639
+ try {
7640
+ const cap = await adapter.sound.captureSoundDescriptor(target.engineId);
7641
+ if (cap) {
7642
+ soundHistory.record(target.engineId, cap.descriptor, adapter.sound.previousSoundLabel);
7643
+ }
7644
+ } catch {
7645
+ }
7646
+ }
7647
+ await adapter.sound.applySound(target.engineId, descriptor);
7648
+ try {
7649
+ await adapter.sound.persistDescriptor?.(target.engineId, descriptor, label);
7650
+ } catch {
7651
+ }
7652
+ soundHistory.record(target.engineId, descriptor, label);
7653
+ appliedIds.add(target.engineId);
7654
+ } catch (err) {
7655
+ failed.push(target.label ?? target.engineId);
7656
+ console.warn(`[${logTag}] Linked sound apply failed for ${target.engineId}:`, err);
7657
+ }
7658
+ }
7659
+ if (appliedIds.size > 0) {
7660
+ setTracks(
7661
+ (prev) => prev.map(
7662
+ (t) => appliedIds.has(t.handle.id) ? { ...t, shuffleHistory: /* @__PURE__ */ new Set([...t.shuffleHistory, label]) } : t
7663
+ )
7664
+ );
7665
+ }
7666
+ if (failed.length > 0) {
7667
+ host.showToast(
7668
+ "warning",
7669
+ "Linked sound applied to some parts only",
7670
+ `Skipped: ${failed.join(", ")}`
7671
+ );
7672
+ }
7673
+ },
7674
+ [adapter, makeServices, soundHistory, host, logTag]
7675
+ );
7676
+ const broadcastInstrumentFromTrack = useCallback17(
7677
+ async (sourceTrackId, pluginId) => {
7678
+ const targetsOf = adapter.sound.broadcastTargets;
7679
+ if (!targetsOf) return;
7680
+ const source = tracksRef.current.find((t) => t.handle.id === sourceTrackId);
7681
+ if (!source) return;
7682
+ let targets;
7683
+ try {
7684
+ targets = await targetsOf(source, makeServices());
7685
+ } catch (err) {
7686
+ console.warn(`[${logTag}] broadcastTargets failed:`, err);
7687
+ return;
7688
+ }
7689
+ const others = (targets ?? []).filter((t) => t.engineId !== sourceTrackId);
7690
+ if (others.length === 0) return;
7691
+ const failed = [];
7692
+ for (const target of others) {
7693
+ try {
7694
+ await host.setTrackInstrument(target.engineId, pluginId);
7695
+ const descriptor = await host.getTrackInstrument(target.engineId);
7696
+ setTracks(
7697
+ (prev) => prev.map(
7698
+ (t) => t.handle.id === target.engineId ? {
7699
+ ...t,
7700
+ instrumentPluginId: descriptor?.pluginId ?? null,
7701
+ instrumentName: descriptor?.name ?? null,
7702
+ instrumentMissing: descriptor?.missing ?? false
7703
+ } : t
7704
+ )
7705
+ );
7706
+ } catch (err) {
7707
+ failed.push(target.label ?? target.engineId);
7708
+ console.warn(`[${logTag}] Linked instrument apply failed for ${target.engineId}:`, err);
7709
+ }
7710
+ }
7711
+ if (failed.length > 0) {
7712
+ host.showToast(
7713
+ "warning",
7714
+ "Instrument applied to some parts only",
7715
+ `Skipped: ${failed.join(", ")}`
7716
+ );
7717
+ }
7718
+ },
7719
+ [adapter, makeServices, host, logTag]
7720
+ );
7721
+ const handleSyncSoundToGroup = useCallback17(
7722
+ async (trackId) => {
7723
+ const track = tracksRef.current.find((t) => t.handle.id === trackId);
7724
+ if (!track) return;
7725
+ let cap = null;
7726
+ try {
7727
+ cap = await adapter.sound.captureSoundDescriptor(trackId);
7728
+ } catch {
7729
+ cap = null;
7730
+ }
7731
+ if (!cap) {
7732
+ host.showToast("warning", "Nothing to sync", "This track has no sound to copy yet.");
7733
+ return;
7734
+ }
7735
+ const hist = soundHistory.list(trackId);
7736
+ const label = (hist.cursor >= 0 ? hist.entries[hist.cursor]?.label : void 0) ?? track.instrumentName ?? "Current sound";
7737
+ await broadcastSoundFromTrack(trackId, cap.descriptor, label);
7738
+ },
7739
+ [adapter, soundHistory, host, broadcastSoundFromTrack]
7740
+ );
7741
+ const handleRestoreSound = useCallback17(
7742
+ async (trackId, index) => {
7743
+ const entry = soundHistory.list(trackId).entries[index];
7744
+ const moved = await soundHistory.restoreTo(trackId, index);
7745
+ if (moved && entry) {
7746
+ await broadcastSoundFromTrack(trackId, entry.descriptor, entry.label);
7747
+ }
7748
+ },
7749
+ [soundHistory, broadcastSoundFromTrack]
7750
+ );
7751
+ const handleSoundImportPick = useCallback17(
7752
+ async (sel) => {
7753
+ const target = soundImportTarget;
7754
+ if (!target || !host.getTrackSound) {
7755
+ setSoundImportTarget(null);
7756
+ return;
7757
+ }
7758
+ const noun = adapter.sound.importNoun;
7759
+ const nounTitle = noun.charAt(0).toUpperCase() + noun.slice(1);
7760
+ try {
7761
+ const snap = await host.getTrackSound(sel.sourceTrackDbId);
7762
+ if (!snap || snap.kind !== adapter.sound.acceptedSnapshotKind) {
7763
+ host.showToast(
7764
+ "error",
7765
+ `No ${noun} to import`,
7766
+ `${sel.trackName} has no ${identity.familyKey} ${noun}.`
7767
+ );
7768
+ return;
7769
+ }
7770
+ const descriptor = adapter.sound.descriptorFromSnapshot(snap);
7771
+ await adapter.sound.applySound(target.handle.id, descriptor);
7772
+ soundHistory.record(target.handle.id, descriptor, snap.label);
7773
+ host.showToast("success", `${nounTitle} imported`, `${snap.label} \u2192 ${target.handle.name}`);
7774
+ await broadcastSoundFromTrack(target.handle.id, descriptor, snap.label);
7775
+ } catch (err) {
7776
+ host.showToast("error", "Import failed", err instanceof Error ? err.message : String(err));
7777
+ } finally {
7778
+ setSoundImportTarget(null);
7779
+ }
7780
+ },
7781
+ [soundImportTarget, host, adapter, identity.familyKey, soundHistory, broadcastSoundFromTrack]
7782
+ );
7577
7783
  const handleGenerate = useCallback17(
7578
7784
  async (trackId) => {
7579
7785
  const track = tracks.find((t) => t.handle.id === trackId);
@@ -7692,7 +7898,10 @@ function useGeneratorPanelCore({
7692
7898
  );
7693
7899
  try {
7694
7900
  const cap = await adapter.sound.captureSoundDescriptor(trackId);
7695
- if (cap) soundHistory.record(trackId, cap.descriptor, result.appliedName);
7901
+ if (cap) {
7902
+ soundHistory.record(trackId, cap.descriptor, result.appliedName);
7903
+ await broadcastSoundFromTrack(trackId, cap.descriptor, result.appliedName);
7904
+ }
7696
7905
  } catch {
7697
7906
  }
7698
7907
  console.log(`[${logTag}] Sound shuffled: ${result.appliedName} (history ${nextHistory.size})`);
@@ -7701,7 +7910,7 @@ function useGeneratorPanelCore({
7701
7910
  host.showToast("error", "Shuffle failed", msg);
7702
7911
  }
7703
7912
  },
7704
- [host, adapter, tracks, soundHistory, logTag]
7913
+ [host, adapter, tracks, soundHistory, logTag, broadcastSoundFromTrack]
7705
7914
  );
7706
7915
  const handleCopy = useCallback17(
7707
7916
  async (trackId) => {
@@ -7904,6 +8113,7 @@ function useGeneratorPanelCore({
7904
8113
  } : t
7905
8114
  )
7906
8115
  );
8116
+ await broadcastInstrumentFromTrack(trackId, pluginId);
7907
8117
  } catch (err) {
7908
8118
  const msg = err instanceof Error ? err.message : "Failed to load instrument";
7909
8119
  host.showToast("error", "Instrument load failed", msg);
@@ -7926,6 +8136,7 @@ function useGeneratorPanelCore({
7926
8136
  } : t
7927
8137
  )
7928
8138
  );
8139
+ await broadcastInstrumentFromTrack(trackId, pluginId);
7929
8140
  } catch (err) {
7930
8141
  const msg = err instanceof Error ? err.message : "Failed to load instrument";
7931
8142
  console.error(`[${logTag}] Failed to set instrument:`, err);
@@ -7935,7 +8146,7 @@ function useGeneratorPanelCore({
7935
8146
  );
7936
8147
  }
7937
8148
  },
7938
- [host, identity.defaultInstrumentPluginId, logTag]
8149
+ [host, identity.defaultInstrumentPluginId, logTag, broadcastInstrumentFromTrack]
7939
8150
  );
7940
8151
  const handleShowEditor = useCallback17(
7941
8152
  async (trackId) => {
@@ -8068,6 +8279,9 @@ function useGeneratorPanelCore({
8068
8279
  shuffle: (trackId) => {
8069
8280
  void handleShuffle(trackId);
8070
8281
  },
8282
+ syncSoundToGroup: (trackId) => {
8283
+ void handleSyncSoundToGroup(trackId);
8284
+ },
8071
8285
  copy: (trackId) => {
8072
8286
  void handleCopy(trackId);
8073
8287
  },
@@ -8088,6 +8302,7 @@ function useGeneratorPanelCore({
8088
8302
  handlePromptChange,
8089
8303
  handleGenerate,
8090
8304
  handleShuffle,
8305
+ handleSyncSoundToGroup,
8091
8306
  handleCopy,
8092
8307
  handleDeleteTrack,
8093
8308
  handleMuteToggle,
@@ -8145,6 +8360,8 @@ function useGeneratorPanelCore({
8145
8360
  handlers,
8146
8361
  handleGenerate,
8147
8362
  handleShuffle,
8363
+ handleRestoreSound,
8364
+ handleSyncSoundToGroup,
8148
8365
  handleAddTrack,
8149
8366
  handleDeleteTrack,
8150
8367
  handleExportMidi,
@@ -8175,8 +8392,41 @@ function useGeneratorPanelCore({
8175
8392
  }
8176
8393
 
8177
8394
  // 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";
8395
+ import { useCallback as useCallback18, useEffect as useEffect20, useState as useState25 } from "react";
8396
+ import { Fragment as Fragment7, jsx as jsx29, jsxs as jsxs22 } from "react/jsx-runtime";
8397
+ var GROUP_UI_KEY = "groupUi";
8398
+ function CollapsibleGroup({
8399
+ ext,
8400
+ group,
8401
+ groupCtx
8402
+ }) {
8403
+ const { host, activeSceneId, trackDataKey: trackDataKey2 } = groupCtx.services;
8404
+ const uiKey = trackDataKey2(group.groupId, GROUP_UI_KEY);
8405
+ const [collapsed, setCollapsed] = useState25(false);
8406
+ useEffect20(() => {
8407
+ let cancelled = false;
8408
+ if (!activeSceneId) return void 0;
8409
+ void host.getSceneData(activeSceneId, uiKey).then((raw) => {
8410
+ if (cancelled || !raw || typeof raw !== "object") return;
8411
+ setCollapsed(raw.collapsed === true);
8412
+ }).catch(() => {
8413
+ });
8414
+ return () => {
8415
+ cancelled = true;
8416
+ };
8417
+ }, [host, activeSceneId, uiKey]);
8418
+ const onToggleCollapse = useCallback18(() => {
8419
+ setCollapsed((prev) => {
8420
+ const next = !prev;
8421
+ if (activeSceneId) {
8422
+ void host.setSceneData(activeSceneId, uiKey, { collapsed: next }).catch(() => {
8423
+ });
8424
+ }
8425
+ return next;
8426
+ });
8427
+ }, [host, activeSceneId, uiKey]);
8428
+ return /* @__PURE__ */ jsx29(Fragment7, { children: ext.renderGroup(group, { ...groupCtx, collapsed, onToggleCollapse }) });
8429
+ }
8180
8430
  function GeneratorPanelShell({ core, slots }) {
8181
8431
  const {
8182
8432
  ui,
@@ -8199,6 +8449,7 @@ function GeneratorPanelShell({ core, slots }) {
8199
8449
  soundImportTarget,
8200
8450
  setSoundImportTarget,
8201
8451
  handleSoundImportPick,
8452
+ handleRestoreSound,
8202
8453
  handlePortTrack,
8203
8454
  transition,
8204
8455
  crossfadePairsMeta,
@@ -8294,7 +8545,7 @@ function GeneratorPanelShell({ core, slots }) {
8294
8545
  soundHistory: soundHistory.list(id).entries,
8295
8546
  soundHistoryCursor: soundHistory.list(id).cursor,
8296
8547
  onRestoreSound: (i) => {
8297
- void soundHistory.restoreTo(id, i);
8548
+ void handleRestoreSound(id, i);
8298
8549
  },
8299
8550
  onToggleFavorite: (i) => soundHistory.toggleFavorite(id, i),
8300
8551
  ...importSoundProps,
@@ -8325,6 +8576,7 @@ function GeneratorPanelShell({ core, slots }) {
8325
8576
  handleBackToInstruments,
8326
8577
  setSoundImportTarget,
8327
8578
  soundHistory,
8579
+ handleRestoreSound,
8328
8580
  handleFxToggle,
8329
8581
  handleFxPresetChange,
8330
8582
  handleFxDryWetChange,
@@ -8332,12 +8584,12 @@ function GeneratorPanelShell({ core, slots }) {
8332
8584
  ]
8333
8585
  );
8334
8586
  if (!activeSceneId) {
8335
- return /* @__PURE__ */ jsx28(
8587
+ return /* @__PURE__ */ jsx29(
8336
8588
  "div",
8337
8589
  {
8338
8590
  "data-testid": `no-scene-placeholder-${identity.familyKey}`,
8339
8591
  className: "flex items-center justify-center py-8",
8340
- children: /* @__PURE__ */ jsx28(
8592
+ children: /* @__PURE__ */ jsx29(
8341
8593
  "button",
8342
8594
  {
8343
8595
  onClick: () => onSelectScene?.(),
@@ -8349,12 +8601,12 @@ function GeneratorPanelShell({ core, slots }) {
8349
8601
  );
8350
8602
  }
8351
8603
  if (!sceneContext?.hasContract) {
8352
- return /* @__PURE__ */ jsx28(
8604
+ return /* @__PURE__ */ jsx29(
8353
8605
  "div",
8354
8606
  {
8355
8607
  "data-testid": `no-contract-placeholder-${identity.familyKey}`,
8356
8608
  className: "flex items-center justify-center py-8",
8357
- children: /* @__PURE__ */ jsx28(
8609
+ children: /* @__PURE__ */ jsx29(
8358
8610
  "button",
8359
8611
  {
8360
8612
  onClick: () => onOpenContract?.(),
@@ -8366,7 +8618,7 @@ function GeneratorPanelShell({ core, slots }) {
8366
8618
  );
8367
8619
  }
8368
8620
  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" }) });
8621
+ return /* @__PURE__ */ jsx29("div", { "data-testid": `${identity.familyKey}-section`, className: "p-2", children: /* @__PURE__ */ jsx29(SorceryProgressBar, { isLoading: true, statusText: "COMPOSING...", heightClass: "h-10" }) });
8370
8622
  }
8371
8623
  const activePlaceholders = features.bulkComposePlaceholders ? placeholders : [];
8372
8624
  if (activePlaceholders.length > 0) {
@@ -8377,18 +8629,18 @@ function GeneratorPanelShell({ core, slots }) {
8377
8629
  tracksByDbId.set(t.handle.id, t);
8378
8630
  }
8379
8631
  }
8380
- return /* @__PURE__ */ jsx28("div", { "data-testid": `${identity.familyKey}-section`, className: "p-2 space-y-2", children: activePlaceholders.map((ph) => {
8632
+ return /* @__PURE__ */ jsx29("div", { "data-testid": `${identity.familyKey}-section`, className: "p-2 space-y-2", children: activePlaceholders.map((ph) => {
8381
8633
  const loadedTrack = ph.status === "completed" ? tracksByDbId.get(ph.id) : void 0;
8382
8634
  if (loadedTrack) {
8383
- return /* @__PURE__ */ jsx28(TrackRow, { ...buildRowProps(loadedTrack) }, ph.id);
8635
+ return /* @__PURE__ */ jsx29(TrackRow, { ...buildRowProps(loadedTrack) }, ph.id);
8384
8636
  }
8385
- return /* @__PURE__ */ jsx28(
8637
+ return /* @__PURE__ */ jsx29(
8386
8638
  "div",
8387
8639
  {
8388
8640
  "data-testid": "bulk-placeholder-track",
8389
8641
  className: "relative rounded-sm border w-full overflow-hidden border-sas-border bg-sas-panel-alt",
8390
8642
  style: { borderLeftColor: identity.placeholderAccentColor, borderLeftWidth: "3px" },
8391
- children: /* @__PURE__ */ jsx28(SorceryProgressBar, { isLoading: true, statusText: "CONJURING MIDI...", heightClass: "h-10" })
8643
+ children: /* @__PURE__ */ jsx29(SorceryProgressBar, { isLoading: true, statusText: "CONJURING MIDI...", heightClass: "h-10" })
8392
8644
  },
8393
8645
  ph.id
8394
8646
  );
@@ -8400,13 +8652,13 @@ function GeneratorPanelShell({ core, slots }) {
8400
8652
  supportsMeters,
8401
8653
  levels: supportsMeters ? trackLevels : void 0,
8402
8654
  handlers,
8403
- renderDefaultTrackRow: (track, overrides, drag) => /* @__PURE__ */ jsx28(TrackRow, { ...{ ...buildRowProps(track, drag), ...overrides ?? {} } }, track.handle.id),
8655
+ renderDefaultTrackRow: (track, overrides, drag) => /* @__PURE__ */ jsx29(TrackRow, { ...{ ...buildRowProps(track, drag), ...overrides ?? {} } }, track.handle.id),
8404
8656
  setGroupMute,
8405
8657
  setGroupSolo,
8406
8658
  deleteGroup
8407
8659
  };
8408
8660
  return /* @__PURE__ */ jsxs22("div", { "data-testid": `${identity.familyKey}-section`, className: "p-2 space-y-2", children: [
8409
- features.importTracks && host.listImportableTracks && /* @__PURE__ */ jsx28(
8661
+ features.importTracks && host.listImportableTracks && /* @__PURE__ */ jsx29(
8410
8662
  ImportTrackModal,
8411
8663
  {
8412
8664
  host,
@@ -8419,7 +8671,7 @@ function GeneratorPanelShell({ core, slots }) {
8419
8671
  testIdPrefix: `${identity.familyKey}-import`
8420
8672
  }
8421
8673
  ),
8422
- features.importTracks && host.listImportableTracks && host.getTrackSound && /* @__PURE__ */ jsx28(
8674
+ features.importTracks && host.listImportableTracks && host.getTrackSound && /* @__PURE__ */ jsx29(
8423
8675
  ImportTrackModal,
8424
8676
  {
8425
8677
  host,
@@ -8434,7 +8686,7 @@ function GeneratorPanelShell({ core, slots }) {
8434
8686
  }
8435
8687
  ),
8436
8688
  slots?.modals,
8437
- canCrossfade && xfFromId && xfToId && /* @__PURE__ */ jsx28("div", { className: designerView ? "contents" : "hidden", children: /* @__PURE__ */ jsx28(
8689
+ canCrossfade && xfFromId && xfToId && /* @__PURE__ */ jsx29("div", { className: designerView ? "contents" : "hidden", children: /* @__PURE__ */ jsx29(
8438
8690
  TransitionDesigner,
8439
8691
  {
8440
8692
  host,
@@ -8453,8 +8705,8 @@ function GeneratorPanelShell({ core, slots }) {
8453
8705
  testIdPrefix: `${identity.familyKey}-transition-designer`
8454
8706
  }
8455
8707
  ) }),
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(
8708
+ !(designerView && canCrossfade) && (isLoadingTracks ? /* @__PURE__ */ jsx29("div", { className: "text-sas-muted text-xs text-center py-4", children: "Loading tracks..." }) : /* @__PURE__ */ jsxs22(Fragment7, { children: [
8709
+ panelBus.supported && panelBus.bus && /* @__PURE__ */ jsx29(
8458
8710
  PanelMasterStrip,
8459
8711
  {
8460
8712
  bus: panelBus.bus,
@@ -8475,7 +8727,7 @@ function GeneratorPanelShell({ core, slots }) {
8475
8727
  }
8476
8728
  ),
8477
8729
  slots?.beforeRows,
8478
- resolvedCrossfadePairs.map((pair) => /* @__PURE__ */ jsx28(
8730
+ resolvedCrossfadePairs.map((pair) => /* @__PURE__ */ jsx29(
8479
8731
  CrossfadeTrackRow,
8480
8732
  {
8481
8733
  accentColor: identity.transitionAccentColor,
@@ -8512,7 +8764,7 @@ function GeneratorPanelShell({ core, slots }) {
8512
8764
  },
8513
8765
  pair.groupId
8514
8766
  )),
8515
- resolvedSingleFades.map((fade) => /* @__PURE__ */ jsx28(
8767
+ resolvedSingleFades.map((fade) => /* @__PURE__ */ jsx29(
8516
8768
  FadeTrackRow,
8517
8769
  {
8518
8770
  accentColor: identity.transitionAccentColor,
@@ -8537,7 +8789,7 @@ function GeneratorPanelShell({ core, slots }) {
8537
8789
  },
8538
8790
  fade.dbId
8539
8791
  )),
8540
- resolvedGroupFades.map((group) => /* @__PURE__ */ jsx28(
8792
+ resolvedGroupFades.map((group) => /* @__PURE__ */ jsx29(
8541
8793
  GroupFadeTrackRow,
8542
8794
  {
8543
8795
  accentColor: identity.transitionAccentColor,
@@ -8573,20 +8825,28 @@ function GeneratorPanelShell({ core, slots }) {
8573
8825
  group.groupId
8574
8826
  )),
8575
8827
  (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}`))
8828
+ (ext) => (resolvedGenericGroups[ext.metaKey]?.resolved ?? []).filter((group) => !group.members.every((m) => fadeMemberDbIds.has(m.dbId))).map((group) => /* @__PURE__ */ jsx29(
8829
+ CollapsibleGroup,
8830
+ {
8831
+ ext,
8832
+ group,
8833
+ groupCtx
8834
+ },
8835
+ `${ext.metaKey}:${group.groupId}`
8836
+ ))
8577
8837
  ),
8578
8838
  tracks.map((track, index) => {
8579
8839
  if (crossfadeMemberDbIds.has(track.handle.dbId) || fadeMemberDbIds.has(track.handle.dbId) || genericGroupMemberDbIds.has(track.handle.dbId)) {
8580
8840
  return null;
8581
8841
  }
8582
- return /* @__PURE__ */ jsx28(TrackRow, { ...buildRowProps(track, reorder.dragPropsFor(index)) }, track.handle.id);
8842
+ return /* @__PURE__ */ jsx29(TrackRow, { ...buildRowProps(track, reorder.dragPropsFor(index)) }, track.handle.id);
8583
8843
  }),
8584
8844
  slots?.afterRows
8585
8845
  ] })),
8586
8846
  features.exportMidi && !designerView && !isLoadingTracks && tracks.length > 0 && (() => {
8587
8847
  const hasAnyMidi = tracks.some((t) => t.hasMidi);
8588
8848
  const exportDisabled = isExportingMidi || !hasAnyMidi;
8589
- return /* @__PURE__ */ jsx28("div", { className: "pt-2", children: /* @__PURE__ */ jsx28(
8849
+ return /* @__PURE__ */ jsx29("div", { className: "pt-2", children: /* @__PURE__ */ jsx29(
8590
8850
  "button",
8591
8851
  {
8592
8852
  "data-testid": "export-midi-tracks-button",
@@ -8641,6 +8901,15 @@ function createSurgeSoundAdapter(host, overrides = {}) {
8641
8901
  });
8642
8902
  return snap.label;
8643
8903
  },
8904
+ persistDescriptor: async (trackId, descriptor, label) => {
8905
+ const { state, stateType } = descriptor;
8906
+ await host.persistTrackPresetState?.(trackId, {
8907
+ state,
8908
+ stateType: stateType ?? "valuetree",
8909
+ name: label
8910
+ }).catch(() => {
8911
+ });
8912
+ },
8644
8913
  descriptorFromSnapshot: (snap) => {
8645
8914
  const preset = snap;
8646
8915
  return { state: preset.state, stateType: preset.stateType };
@@ -8725,18 +8994,183 @@ var SUB = {
8725
8994
  rootOnly: true,
8726
8995
  monoPreference: "low"
8727
8996
  };
8997
+ var HORN_MAX_NOTES_PER_BAR = 12;
8998
+ var HORN_FOLLOW_PALETTE = "EXACTLY the lead voice's rhythm \u2014 every attack lands together with voice 1";
8999
+ var HORN_LEAD = {
9000
+ label: "lead trumpet",
9001
+ role: "brass",
9002
+ registerLow: 60,
9003
+ // C4
9004
+ registerHigh: 84,
9005
+ // C6
9006
+ maxNotesPerBar: HORN_MAX_NOTES_PER_BAR,
9007
+ rhythmPalette: "tight funk syncopation \u2014 8ths and 16ths, staccato punches, off-beat anticipations",
9008
+ harmonicDiscipline: "the section's melodic top \u2014 chord tones on every hit; quick chromatic approach tones only on pickups",
9009
+ monoPreference: "high"
9010
+ };
9011
+ var HORN_TRUMPET_2 = {
9012
+ label: "second trumpet",
9013
+ role: "brass",
9014
+ registerLow: 55,
9015
+ // G3
9016
+ registerHigh: 79,
9017
+ // G5
9018
+ maxNotesPerBar: HORN_MAX_NOTES_PER_BAR,
9019
+ rhythmPalette: HORN_FOLLOW_PALETTE,
9020
+ harmonicDiscipline: "nearest chord tone directly below voice 1 \u2014 tight close voicing",
9021
+ monoPreference: "high"
9022
+ };
9023
+ var HORN_ALTO = {
9024
+ label: "alto sax",
9025
+ role: "brass",
9026
+ registerLow: 53,
9027
+ // F3
9028
+ registerHigh: 77,
9029
+ // F5
9030
+ maxNotesPerBar: HORN_MAX_NOTES_PER_BAR,
9031
+ rhythmPalette: HORN_FOLLOW_PALETTE,
9032
+ harmonicDiscipline: "chord tone below the trumpets \u2014 keep the upper stack inside one octave",
9033
+ monoPreference: "high"
9034
+ };
9035
+ var HORN_TENOR = {
9036
+ label: "tenor sax",
9037
+ role: "brass",
9038
+ registerLow: 46,
9039
+ // Bb2
9040
+ registerHigh: 72,
9041
+ // C5
9042
+ maxNotesPerBar: HORN_MAX_NOTES_PER_BAR,
9043
+ rhythmPalette: HORN_FOLLOW_PALETTE,
9044
+ harmonicDiscipline: "chord tone under the upper horns \u2014 complete the chord",
9045
+ monoPreference: "low"
9046
+ };
9047
+ var HORN_TROMBONE = {
9048
+ label: "trombone",
9049
+ role: "brass",
9050
+ registerLow: 43,
9051
+ // G2
9052
+ registerHigh: 67,
9053
+ // G4
9054
+ maxNotesPerBar: HORN_MAX_NOTES_PER_BAR,
9055
+ rhythmPalette: HORN_FOLLOW_PALETTE,
9056
+ harmonicDiscipline: "chord tone or root below the saxes \u2014 weight in the middle-low stack",
9057
+ monoPreference: "low"
9058
+ };
9059
+ var HORN_BARI = {
9060
+ label: "baritone sax",
9061
+ role: "brass",
9062
+ registerLow: 36,
9063
+ // C2
9064
+ registerHigh: 60,
9065
+ // C4
9066
+ maxNotesPerBar: HORN_MAX_NOTES_PER_BAR,
9067
+ rhythmPalette: HORN_FOLLOW_PALETTE,
9068
+ harmonicDiscipline: "chord roots and lower chord tones \u2014 the section's bottom anchor",
9069
+ monoPreference: "low"
9070
+ };
9071
+ var WIND_FLUTE = {
9072
+ label: "flute",
9073
+ role: "winds",
9074
+ registerLow: 60,
9075
+ // C4
9076
+ registerHigh: 93,
9077
+ // A6
9078
+ maxNotesPerBar: 8,
9079
+ rhythmPalette: "8ths and 16ths; runs, turns and trills welcome",
9080
+ harmonicDiscipline: "freest voice \u2014 non-chord tones as passing/neighbor tones on weak beats, resolving by step",
9081
+ monoPreference: "high"
9082
+ };
9083
+ var WIND_FLUTE_2 = {
9084
+ label: "second flute",
9085
+ role: "winds",
9086
+ registerLow: 60,
9087
+ // C4
9088
+ registerHigh: 86,
9089
+ // D6
9090
+ maxNotesPerBar: 5,
9091
+ rhythmPalette: "quarters with occasional 8th-note motion",
9092
+ harmonicDiscipline: "chord tones; fill gaps the other inner voices leave",
9093
+ monoPreference: "high"
9094
+ };
9095
+ var WIND_OBOE = {
9096
+ label: "oboe",
9097
+ role: "winds",
9098
+ registerLow: 58,
9099
+ // Bb3
9100
+ registerHigh: 84,
9101
+ // C6
9102
+ maxNotesPerBar: 6,
9103
+ rhythmPalette: "8ths and quarters; move when the flute rests",
9104
+ harmonicDiscipline: "mostly chord tones; may imitate the flute's motifs a bar later",
9105
+ monoPreference: "high"
9106
+ };
9107
+ var WIND_CLARINET = {
9108
+ label: "clarinet",
9109
+ role: "winds",
9110
+ registerLow: 50,
9111
+ // D3
9112
+ registerHigh: 81,
9113
+ // A5
9114
+ maxNotesPerBar: 4,
9115
+ rhythmPalette: "quarters and halves",
9116
+ harmonicDiscipline: "chord tones with smooth stepwise motion between them",
9117
+ monoPreference: "high"
9118
+ };
9119
+ var WIND_FRENCH_HORN = {
9120
+ label: "french horn",
9121
+ role: "winds",
9122
+ registerLow: 41,
9123
+ // F2
9124
+ registerHigh: 72,
9125
+ // C5
9126
+ maxNotesPerBar: 4,
9127
+ rhythmPalette: "quarters and halves; long held tones welcome",
9128
+ harmonicDiscipline: "chord tones \u2014 the warm glue in the middle of the ensemble",
9129
+ monoPreference: "low"
9130
+ };
9131
+ var WIND_BASSOON = {
9132
+ label: "bassoon",
9133
+ role: "winds",
9134
+ registerLow: 34,
9135
+ // Bb1
9136
+ registerHigh: 62,
9137
+ // D4
9138
+ maxNotesPerBar: 3,
9139
+ rhythmPalette: "quarters and halves; brief walking figures at cadences",
9140
+ harmonicDiscipline: "roots and fifths emphasized; passing tones only between chord tones",
9141
+ monoPreference: "low"
9142
+ };
8728
9143
  var ENSEMBLE_MIN_VOICES = 2;
8729
9144
  var ENSEMBLE_MAX_VOICES = 6;
8730
- var SPEC_TABLES = {
9145
+ var STRINGS_SPEC_TABLES = {
8731
9146
  2: [TOP, BASS],
8732
9147
  3: [TOP, INNER, BASS],
8733
9148
  4: [TOP, COUNTER, TENOR, BASS],
8734
9149
  5: [TOP, COUNTER, INNER, TENOR, SUB],
8735
9150
  6: [TOP, COUNTER, INNER_2, INNER, TENOR, SUB]
8736
9151
  };
8737
- function defaultVoiceSpecs(voiceCount) {
9152
+ var HORNS_SPEC_TABLES = {
9153
+ 2: [HORN_LEAD, HORN_TENOR],
9154
+ 3: [HORN_LEAD, HORN_TENOR, HORN_BARI],
9155
+ 4: [HORN_LEAD, HORN_TRUMPET_2, HORN_TENOR, HORN_BARI],
9156
+ 5: [HORN_LEAD, HORN_TRUMPET_2, HORN_TENOR, HORN_TROMBONE, HORN_BARI],
9157
+ 6: [HORN_LEAD, HORN_TRUMPET_2, HORN_ALTO, HORN_TENOR, HORN_TROMBONE, HORN_BARI]
9158
+ };
9159
+ var WINDS_SPEC_TABLES = {
9160
+ 2: [WIND_FLUTE, WIND_BASSOON],
9161
+ 3: [WIND_FLUTE, WIND_CLARINET, WIND_BASSOON],
9162
+ 4: [WIND_FLUTE, WIND_OBOE, WIND_FRENCH_HORN, WIND_BASSOON],
9163
+ 5: [WIND_FLUTE, WIND_OBOE, WIND_CLARINET, WIND_FRENCH_HORN, WIND_BASSOON],
9164
+ 6: [WIND_FLUTE, WIND_FLUTE_2, WIND_OBOE, WIND_CLARINET, WIND_FRENCH_HORN, WIND_BASSOON]
9165
+ };
9166
+ var SPEC_TABLES_BY_INSTRUMENTATION = {
9167
+ strings: STRINGS_SPEC_TABLES,
9168
+ horns: HORNS_SPEC_TABLES,
9169
+ winds: WINDS_SPEC_TABLES
9170
+ };
9171
+ function defaultVoiceSpecs(voiceCount, instrumentation = "strings") {
8738
9172
  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 }));
9173
+ return SPEC_TABLES_BY_INSTRUMENTATION[instrumentation][n].map((spec, voiceIndex) => ({ ...spec, voiceIndex }));
8740
9174
  }
8741
9175
 
8742
9176
  // src/ensemble-core/enforce-voice.ts
@@ -8776,10 +9210,14 @@ function enforceVoice(rawNotes, spec, opts) {
8776
9210
  repairs.push(`voice ${spec.voiceIndex}: dropped note outside the ${opts.bars}-bar clip (start ${n.startBeat})`);
8777
9211
  continue;
8778
9212
  }
8779
- const durationBeats = Math.max(
9213
+ let durationBeats = Math.max(
8780
9214
  MIN_NOTE_DURATION_BEATS,
8781
9215
  Math.min(n.durationBeats, clipEnd - n.startBeat)
8782
9216
  );
9217
+ if (opts.maxNoteDurationBeats !== void 0 && durationBeats > opts.maxNoteDurationBeats) {
9218
+ durationBeats = Math.max(MIN_NOTE_DURATION_BEATS, opts.maxNoteDurationBeats);
9219
+ repairs.push(`voice ${spec.voiceIndex}: trimmed note at beat ${n.startBeat} to the style's ${opts.maxNoteDurationBeats}-beat stab ceiling`);
9220
+ }
8783
9221
  notes.push({ ...n, durationBeats });
8784
9222
  }
8785
9223
  notes = notes.map((n) => {
@@ -8940,12 +9378,19 @@ function describeViolations(analysis, rules) {
8940
9378
  if (pair.onsetIndependence < rules.minOnsetIndependence) {
8941
9379
  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
9380
  }
9381
+ if (rules.maxOnsetIndependence !== void 0 && pair.onsetIndependence > rules.maxOnsetIndependence) {
9382
+ 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.`);
9383
+ }
8943
9384
  }
8944
9385
  return out;
8945
9386
  }
8946
9387
 
8947
9388
  // src/ensemble-core/styles.ts
8948
9389
  var ENSEMBLE_STYLES = ["counterpoint", "chorale", "interlock"];
9390
+ var STAB_MAX_ONSET_INDEPENDENCE = 0.25;
9391
+ var RIFF_MAX_ONSET_INDEPENDENCE = 0.3;
9392
+ var UNISON_MAX_ONSET_INDEPENDENCE = 0.15;
9393
+ var STAB_MAX_NOTE_DURATION_BEATS = 1;
8949
9394
  var STYLE_RULES = {
8950
9395
  counterpoint: {
8951
9396
  forbidParallelPerfects: true,
@@ -8964,9 +9409,55 @@ var STYLE_RULES = {
8964
9409
  forbidVoiceCrossing: false,
8965
9410
  minOnsetIndependence: 0.6,
8966
9411
  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."
9412
+ },
9413
+ stabs: {
9414
+ forbidParallelPerfects: false,
9415
+ forbidVoiceCrossing: true,
9416
+ minOnsetIndependence: 0,
9417
+ maxOnsetIndependence: STAB_MAX_ONSET_INDEPENDENCE,
9418
+ maxNoteDurationBeats: STAB_MAX_NOTE_DURATION_BEATS,
9419
+ 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.`
9420
+ },
9421
+ riffs: {
9422
+ forbidParallelPerfects: false,
9423
+ forbidVoiceCrossing: true,
9424
+ minOnsetIndependence: 0,
9425
+ maxOnsetIndependence: RIFF_MAX_ONSET_INDEPENDENCE,
9426
+ 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.'
9427
+ },
9428
+ unison: {
9429
+ forbidParallelPerfects: false,
9430
+ forbidVoiceCrossing: false,
9431
+ minOnsetIndependence: 0,
9432
+ maxOnsetIndependence: UNISON_MAX_ONSET_INDEPENDENCE,
9433
+ 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
9434
  }
8968
9435
  };
8969
9436
 
9437
+ // src/ensemble-core/instrumentation.ts
9438
+ var ENSEMBLE_INSTRUMENTATIONS = [
9439
+ "strings",
9440
+ "horns",
9441
+ "winds"
9442
+ ];
9443
+ var STYLES_FOR_INSTRUMENTATION = {
9444
+ strings: ["counterpoint", "chorale", "interlock"],
9445
+ horns: ["stabs", "riffs", "unison"],
9446
+ winds: ["counterpoint", "chorale", "interlock"]
9447
+ };
9448
+ var DEFAULT_STYLE_FOR_INSTRUMENTATION = {
9449
+ strings: "counterpoint",
9450
+ horns: "stabs",
9451
+ winds: "chorale"
9452
+ };
9453
+ function normalizeInstrumentation(raw) {
9454
+ return ENSEMBLE_INSTRUMENTATIONS.includes(raw) ? raw : "strings";
9455
+ }
9456
+ function styleForInstrumentation(instrumentation, rawStyle) {
9457
+ const allowed = STYLES_FOR_INSTRUMENTATION[instrumentation];
9458
+ return allowed.includes(rawStyle ?? "") ? rawStyle : DEFAULT_STYLE_FOR_INSTRUMENTATION[instrumentation];
9459
+ }
9460
+
8970
9461
  // src/ensemble-core/ensemble-schema.ts
8971
9462
  var SUBMIT_ENSEMBLE_TOOL_NAME = "submit_ensemble";
8972
9463
  function buildSubmitEnsembleParameters(voiceCount) {
@@ -9048,9 +9539,13 @@ function voiceContractLine(spec) {
9048
9539
  const root = spec.rootOnly ? " ROOT PITCH CLASS ONLY (each bar's chord root)." : "";
9049
9540
  return `- Voice ${spec.voiceIndex + 1} (${spec.label}): MIDI ${spec.registerLow}-${spec.registerHigh}, max ${spec.maxNotesPerBar} notes/bar, rhythm: ${spec.rhythmPalette}. ${spec.harmonicDiscipline}.${root}`;
9050
9541
  }
9051
- function buildEnsembleSystemPrompt(specs, style) {
9542
+ var WOVEN_PERSONA = {
9543
+ strings: "You are an ensemble composer.",
9544
+ winds: "You are a wind-ensemble composer writing for chamber winds (flutes, oboe, clarinet, french horn, bassoon)."
9545
+ };
9546
+ function buildWovenPrompt(specs, style, instrumentation) {
9052
9547
  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.
9548
+ return `${WOVEN_PERSONA[instrumentation]} Compose ${specs.length} voices as ONE piece of music \u2014 not ${specs.length} independent parts.
9054
9549
 
9055
9550
  Submit your composition by calling the ${SUBMIT_ENSEMBLE_TOOL_NAME} tool with all ${specs.length} voices.
9056
9551
 
@@ -9067,6 +9562,28 @@ ${styleParagraph}
9067
9562
  PER-VOICE CONTRACTS:
9068
9563
  ${specs.map(voiceContractLine).join("\n")}`;
9069
9564
  }
9565
+ function buildSectionPrompt(specs, style) {
9566
+ const styleParagraph = STYLE_RULES[style].promptParagraph;
9567
+ 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.
9568
+
9569
+ Submit your composition by calling the ${SUBMIT_ENSEMBLE_TOOL_NAME} tool with all ${specs.length} voices.
9570
+
9571
+ THE SECTION RULES (most important):
9572
+ 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.
9573
+ 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.
9574
+ 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.
9575
+ 4. Follow the chord progression in the musical context exactly \u2014 every hit is voiced from the chord sounding at that beat.
9576
+ 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.
9577
+ 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).
9578
+
9579
+ ${styleParagraph}
9580
+
9581
+ PER-VOICE CONTRACTS:
9582
+ ${specs.map(voiceContractLine).join("\n")}`;
9583
+ }
9584
+ function buildEnsembleSystemPrompt(specs, style, instrumentation = "strings") {
9585
+ return instrumentation === "horns" ? buildSectionPrompt(specs, style) : buildWovenPrompt(specs, style, instrumentation);
9586
+ }
9070
9587
  function buildViolationRetrySuffix(violations) {
9071
9588
  if (violations.length === 0) return "";
9072
9589
  return `
@@ -9241,10 +9758,12 @@ export {
9241
9758
  DB_MIN,
9242
9759
  DEFAULT_FX_CATEGORY_DETAIL,
9243
9760
  DEFAULT_FX_DRY_WET,
9761
+ DEFAULT_STYLE_FOR_INSTRUMENTATION,
9244
9762
  DRAG_DEAD_ZONE,
9245
9763
  DownloadPackButton,
9246
9764
  EMPTY_FX_DETAIL_STATE,
9247
9765
  EMPTY_FX_STATE,
9766
+ ENSEMBLE_INSTRUMENTATIONS,
9248
9767
  ENSEMBLE_MAX_VOICES,
9249
9768
  ENSEMBLE_MIN_VOICES,
9250
9769
  ENSEMBLE_STYLES,
@@ -9259,7 +9778,9 @@ export {
9259
9778
  FxToggleBar,
9260
9779
  GUTTER_W,
9261
9780
  GeneratorPanelShell,
9781
+ GroupCollapseChevron,
9262
9782
  GroupFadeTrackRow,
9783
+ HORN_MAX_NOTES_PER_BAR,
9263
9784
  ImportTrackModal,
9264
9785
  TrackDrawer as InstrumentDrawer,
9265
9786
  LevelMeter,
@@ -9273,8 +9794,12 @@ export {
9273
9794
  PianoRollEditor,
9274
9795
  PluginError,
9275
9796
  RESIZE_HANDLE_PX,
9797
+ RIFF_MAX_ONSET_INDEPENDENCE,
9276
9798
  ROW_HEIGHT,
9277
9799
  SLIDER_UNITY,
9800
+ STAB_MAX_NOTE_DURATION_BEATS,
9801
+ STAB_MAX_ONSET_INDEPENDENCE,
9802
+ STYLES_FOR_INSTRUMENTATION,
9278
9803
  STYLE_RULES,
9279
9804
  SUBMIT_ENSEMBLE_TOOL_NAME,
9280
9805
  SamplePackCTACard,
@@ -9288,6 +9813,7 @@ export {
9288
9813
  TrackMeterStrip,
9289
9814
  TrackRow,
9290
9815
  TransitionDesigner,
9816
+ UNISON_MAX_ONSET_INDEPENDENCE,
9291
9817
  VolumeSlider,
9292
9818
  WaveformView,
9293
9819
  analyzeEnsemble,
@@ -9322,6 +9848,7 @@ export {
9322
9848
  moveItem,
9323
9849
  nearestPitchWithPc,
9324
9850
  newTrackState,
9851
+ normalizeInstrumentation,
9325
9852
  normalizeSlots,
9326
9853
  padPair,
9327
9854
  padSlots,
@@ -9345,6 +9872,7 @@ export {
9345
9872
  slotsEqual,
9346
9873
  soundIdentity,
9347
9874
  splitFadeEntries,
9875
+ styleForInstrumentation,
9348
9876
  synthesizeCuePoints,
9349
9877
  tokenizePrompt,
9350
9878
  trackDataKey,