@map-audio/pam-mcp-server 1.18.1 → 1.22.0

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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +5 -3
  3. package/dist/audioAnalysis.d.ts +35 -0
  4. package/dist/audioAnalysis.js +277 -0
  5. package/dist/audioAnalysis.js.map +1 -0
  6. package/dist/bridge.d.ts +2 -0
  7. package/dist/bridge.js +113 -24
  8. package/dist/bridge.js.map +1 -1
  9. package/dist/corpusFingerprints.d.ts +17 -0
  10. package/dist/corpusFingerprints.js +10 -0
  11. package/dist/corpusFingerprints.js.map +1 -0
  12. package/dist/corpusRanges.d.ts +28 -0
  13. package/dist/corpusRanges.js +1029 -0
  14. package/dist/corpusRanges.js.map +1 -0
  15. package/dist/drumSynth.d.ts +96 -0
  16. package/dist/drumSynth.js +193 -0
  17. package/dist/drumSynth.js.map +1 -0
  18. package/dist/drumSynthEngines.json +1736 -0
  19. package/dist/fingerprint.d.ts +70 -0
  20. package/dist/fingerprint.js +240 -0
  21. package/dist/fingerprint.js.map +1 -0
  22. package/dist/generated/variationSnapshot.d.ts +31 -0
  23. package/dist/generated/variationSnapshot.js +62 -0
  24. package/dist/generated/variationSnapshot.js.map +1 -0
  25. package/dist/manifest-cell1.json +5414 -396
  26. package/dist/manifest-mfx.json +12013 -1714
  27. package/dist/manifest.d.ts +24 -0
  28. package/dist/manifest.js +60 -5
  29. package/dist/manifest.js.map +1 -1
  30. package/dist/manifest.json +24881 -547
  31. package/dist/mfxCombos.d.ts +45 -0
  32. package/dist/mfxCombos.js +499 -0
  33. package/dist/mfxCombos.js.map +1 -0
  34. package/dist/mixAssist.d.ts +6 -0
  35. package/dist/mixAssist.js +101 -11
  36. package/dist/mixAssist.js.map +1 -1
  37. package/dist/productProfile.d.ts +9 -0
  38. package/dist/productProfile.js +20 -0
  39. package/dist/productProfile.js.map +1 -0
  40. package/dist/server.js +1979 -342
  41. package/dist/server.js.map +1 -1
  42. package/dist/variationSessionKeys.json +110 -0
  43. package/package.json +10 -3
package/dist/server.js CHANGED
@@ -3,26 +3,37 @@ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mc
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { completable } from "@modelcontextprotocol/sdk/server/completable.js";
5
5
  import { z } from "zod";
6
+ import { buildVariationSnapshotBase } from "./generated/variationSnapshot.js";
7
+ import { CORPUS_META, CORPUS_RANGES } from "./corpusRanges.js";
8
+ import { CORPUS_FINGERPRINTS, CORPUS_NOVELTY_BASELINE, CORPUS_VARIATION_BASELINE } from "./corpusFingerprints.js";
9
+ import { buildFingerprint, scoreNovelty, similarity as fingerprintSimilarity } from "./fingerprint.js";
10
+ import { analyzeMotion, decodeWav } from "./audioAnalysis.js";
6
11
  import { readFile } from "node:fs/promises";
7
12
  import { existsSync, readFileSync } from "node:fs";
8
13
  import { inflateSync } from "node:zlib";
9
- import { basename, dirname, resolve } from "node:path";
14
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
10
15
  import { fileURLToPath } from "node:url";
11
- import { loadManifest, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, setActiveProductHint, walkPresets, } from "./manifest.js";
16
+ import { createDrumSynthCatalog, } from "./drumSynth.js";
17
+ import { loadManifest, cellEffectNames, cellEffectOrder, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, setActiveProductHint, walkPresets, } from "./manifest.js";
12
18
  import { PluginBridge } from "./bridge.js";
13
19
  import { buildMixMeasurementFromState, currentFlatFromState, parseMixAnalysisPayload, suggestMix, } from "./mixAssist.js";
14
20
  import { normalizePresetLookupPath, selectSaveVariationSourcePath, } from "./presetSave.js";
15
21
  import { STOP_SEQUENCES, buildGenerationPrompt, parseFunctionCalls, renderContextLine, renderFunctionCall, renderFunctionResponse, truncateAtFabricatedResponse, withContext, } from "./fgemma-format.js";
22
+ import { mcpProductProfile } from "./productProfile.js";
23
+ import { MFX_COMBO_COUNT, MFX_COMBO_MANUAL_STRENGTH, buildMfxComboTarget, isMfxComboTargetParameter, mfxComboTargetValueFromCurrent, parseMfxComboSlots, reanchorMfxComboTarget, resolveMfxCombos, setMfxComboTargetStop, } from "./mfxCombos.js";
16
24
  // --- State ---
17
25
  // Variation slot count. Must stay in sync with PRESET_VARIATION_SLOTS in
18
26
  // interface/src/stores/Preset.ts. Defined locally because the MCP package
19
27
  // can't import from the interface workspace without infra changes.
20
28
  const PRESET_VARIATION_SLOTS = 8;
29
+ const SERVER_PRODUCT = mcpProductProfile();
30
+ const IS_MFX_SERVER = SERVER_PRODUCT.id === "mfx";
21
31
  const bridge = new PluginBridge();
22
32
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
23
33
  const cellModeChangeSeq = {};
24
34
  let lastSavedPresetName;
25
35
  let lastSavedPresetPath;
36
+ let activeLiveResample;
26
37
  // --- Helpers ---
27
38
  function text(s) {
28
39
  return { content: [{ type: "text", text: s }] };
@@ -100,6 +111,9 @@ function validateCellIdForManifest(cellId, manifest) {
100
111
  return true;
101
112
  }
102
113
  function readPackageVersion() {
114
+ const override = IS_MFX_SERVER ? process.env.MFX_MCP_SERVER_VERSION?.trim() : undefined;
115
+ if (override)
116
+ return override;
103
117
  try {
104
118
  const raw = readFileSync(resolve(PACKAGE_ROOT, "package.json"), "utf-8");
105
119
  const pkg = JSON.parse(raw);
@@ -120,9 +134,9 @@ async function handleCliArgs() {
120
134
  }
121
135
  if (args.has("--help") || args.has("-h")) {
122
136
  const version = SERVER_VERSION;
123
- console.log(`pam-mcp ${version}\n\n` +
124
- "MCP stdio server for PAM. Configure your MCP client to launch this command; " +
125
- "it reads ~/.pam-mcp-bridge.json to find a running PAM instance.");
137
+ console.log(`${SERVER_PRODUCT.command} ${version}\n\n` +
138
+ `MCP stdio server for ${SERVER_PRODUCT.name}. Configure your MCP client to launch this command; ` +
139
+ `it reads the local bridge registry to find a running ${SERVER_PRODUCT.name} instance.`);
126
140
  return true;
127
141
  }
128
142
  return false;
@@ -132,21 +146,56 @@ async function handleCliArgs() {
132
146
  function syncProductHint() {
133
147
  setActiveProductHint(bridge.getConnectedInstance()?.product);
134
148
  }
149
+ function resolveServerMfxCombos(state, manifest) {
150
+ const instance = bridge.getConnectedInstance();
151
+ const cacheKey = instance?.instanceId ?? (instance ? `${instance.product}:${instance.port}` : manifest.bundleId);
152
+ return resolveMfxCombos(state, manifest, cacheKey);
153
+ }
135
154
  async function requireBridge() {
136
155
  if (bridge.isConnected())
137
156
  return true;
138
157
  const ok = await bridge.connect();
139
158
  if (!ok) {
140
159
  const pin = process.env.PAM_BRIDGE_PRODUCT;
141
- return error("Not connected to PAM plugin. Start PAM (standalone or in a DAW) with MCP bridge enabled. " +
142
- "The bridge writes its port to ~/.pam-mcp-bridge.json on startup." +
160
+ return error(`Not connected to ${SERVER_PRODUCT.name}. Start ${SERVER_PRODUCT.name} (standalone or in a DAW) with MCP bridge enabled. ` +
161
+ (IS_MFX_SERVER
162
+ ? "The launcher finds MFX through the local bridge registry; the plugin records its actual port on startup."
163
+ : "The bridge writes its port to ~/.pam-mcp-bridge.json on startup.") +
143
164
  (pin
144
- ? ` Note: PAM_BRIDGE_PRODUCT pins this server to '${pin}' — no matching instance was found (connection_status lists what is running).`
165
+ ? IS_MFX_SERVER
166
+ ? " The MFX launcher is product-pinned, but no matching instance was found."
167
+ : ` Note: PAM_BRIDGE_PRODUCT pins this server to '${pin}' — no matching instance was found (connection_status lists what is running).`
145
168
  : ""));
146
169
  }
147
170
  syncProductHint();
148
171
  return true;
149
172
  }
173
+ /**
174
+ * requireBridge() plus product-aware cell bounds, for tools that ADDRESS a cell.
175
+ *
176
+ * The zod schemas cap cellId at 8 because they are built before we know which
177
+ * product is on the other end. Cell1 has 1 cell and MFX has 4, so without this
178
+ * an agent aims at cell 7 and gets a bridge-level failure or a silent no-op
179
+ * instead of being told the cell does not exist. Read filters (get_state,
180
+ * get_design_overview) deliberately stay unguarded — returning only the cells
181
+ * that exist is the right answer there. Manifests are cached, so this is
182
+ * effectively free after the first call.
183
+ */
184
+ async function requireBridgeForCells(...cellIds) {
185
+ const check = await requireBridge();
186
+ if (check !== true)
187
+ return check;
188
+ const ids = cellIds.flat().filter((c) => typeof c === "number");
189
+ if (ids.length === 0)
190
+ return true;
191
+ const manifest = await loadManifestForProductGuard();
192
+ for (const id of new Set(ids)) {
193
+ const cellCheck = validateCellIdForManifest(id, manifest);
194
+ if (cellCheck !== true)
195
+ return cellCheck;
196
+ }
197
+ return true;
198
+ }
150
199
  /**
151
200
  * Parse the PAM binary preset format: <metadataSize as text int><metadata JSON><data JSON>
152
201
  */
@@ -207,42 +256,99 @@ async function readExistingVariations(presetPath) {
207
256
  return Array(PRESET_VARIATION_SLOTS).fill(null);
208
257
  }
209
258
  }
210
- // Mirrors interop.ts VARIATION_EXCLUDED_PARAMS (activePresetPath is excluded separately below, matching interop.ts)
259
+ const VARIATION_CONTRACT = (() => {
260
+ const here = dirname(fileURLToPath(import.meta.url));
261
+ const candidates = [
262
+ resolve(here, "..", "..", "..", "shared", "variationSessionKeys.json"), // repo checkout
263
+ resolve(here, "variationSessionKeys.json"), // published dist/
264
+ ];
265
+ for (const file of candidates) {
266
+ if (!existsSync(file))
267
+ continue;
268
+ const contract = JSON.parse(readFileSync(file, "utf8"));
269
+ if (Array.isArray(contract.all) && contract.all.length > 0)
270
+ return contract;
271
+ }
272
+ throw new Error("pam-mcp: variationSessionKeys.json not found. Run `node scripts/generate-variation-session-keys.mjs` " +
273
+ "(repo) or rebuild the package — saving a variation without it would bake session state into the slot.");
274
+ })();
275
+ // `transport` is live host position, reported in state but never variation content.
211
276
  const VARIATION_EXCLUDED_PARAMS = [
212
- "variations",
213
- "interfaceCache",
214
- "songMode",
215
- "volume",
216
- "hqMode",
217
- "previewSamplePath",
218
- "previewCellId",
219
- "transport",
220
- "snapshotRateHz",
221
- "looperCrossfade",
222
- "looperLength",
223
- "looperVolume",
224
- "looperFilter",
225
- "loopers",
226
- "looperPlaybackBars",
227
- "looperTrack1Level",
228
- "looperTrack2Level",
229
- "looperTrack3Level",
230
- "looperTrack4Level",
231
- "looperTrack5Level",
232
- "looperTrack6Level",
233
- "looperTrack7Level",
234
- "looperTrack8Level",
277
+ ...new Set([...(VARIATION_CONTRACT.all ?? []), "transport"]),
235
278
  ];
236
279
  /**
237
- * Build a variation snapshot string from plugin state (matching interop.ts logic).
280
+ * Build a variation snapshot string from plugin state.
281
+ *
282
+ * The exclusion set comes from the generated contract, so this matches what a
283
+ * UI save strips. It does NOT match a UI save in one respect: interop.ts awaits
284
+ * `refreshLoadedPluginStateBlobs()` first and stamps each hosted plugin's
285
+ * identity and bypass from `pluginHostStore` — data that lives in the WebView,
286
+ * not in the headless bridge's state. `hostedPluginCells` reports the cells
287
+ * where that difference bites so callers are told rather than silently handed a
288
+ * lossy slot.
289
+ *
290
+ * This path is the FALLBACK. A PAM build carrying the `getSaveSnapshot` action
291
+ * hands back the native canonical snapshot (hosted-plugin state included) and
292
+ * never reaches here.
238
293
  */
239
294
  function buildVariationString(state) {
240
- const stateForVariation = { ...state };
241
- for (const param of VARIATION_EXCLUDED_PARAMS) {
242
- delete stateForVariation[param];
295
+ return JSON.stringify(buildVariationSnapshotBase(state, {
296
+ sessionKeys: VARIATION_EXCLUDED_PARAMS,
297
+ visualParamIds: VARIATION_CONTRACT.visualParamIds ?? [],
298
+ lfoGeneratorCount: VARIATION_CONTRACT.lfoGeneratorCount ?? 6,
299
+ }));
300
+ }
301
+ /**
302
+ * The canonical save-time state — engine state PLUS every live hosted plugin's
303
+ * identity, bypass and state blob (PluginProcessor::buildCanonicalSaveSnapshot).
304
+ *
305
+ * `getState` returns the live map, which carries no hosted-plugin blob, so a
306
+ * variation built from it silently dropped the hosted patch (A1). Falls back to
307
+ * `getState` on a PAM build that predates the action, and reports the fallback
308
+ * so the save result can say the snapshot is lossy.
309
+ */
310
+ async function fetchSaveSnapshot() {
311
+ let state;
312
+ let canonical = false;
313
+ try {
314
+ const snap = await bridge.send("getSaveSnapshot");
315
+ if (snap && typeof snap === "object") {
316
+ state = snap;
317
+ canonical = true;
318
+ }
319
+ else {
320
+ state = (await bridge.send("getState"));
321
+ }
322
+ }
323
+ catch {
324
+ // Older PAM: action not registered. Fall through.
325
+ state = (await bridge.send("getState"));
243
326
  }
244
- delete stateForVariation.activePresetPath;
245
- return JSON.stringify(stateForVariation);
327
+ return { state, canonical };
328
+ }
329
+ /** Cells whose hosted plugin this snapshot cannot fully capture. */
330
+ function hostedPluginCells(state) {
331
+ const cells = new Set();
332
+ for (const key of Object.keys(state)) {
333
+ const m = /^cell(\d+)_plugin(?:Fx)?Path$/.exec(key);
334
+ if (m && state[key])
335
+ cells.add(Number(m[1]));
336
+ }
337
+ return [...cells].sort((a, b) => a - b);
338
+ }
339
+ /** Appended to a save result when the snapshot is known to be lossy. */
340
+ function hostedPluginSaveWarning(state, canonical = false) {
341
+ // The canonical snapshot stamps hosted-plugin state on the message thread, so
342
+ // there is nothing to warn about.
343
+ if (canonical)
344
+ return "";
345
+ const cells = hostedPluginCells(state);
346
+ if (!cells.length)
347
+ return "";
348
+ return (`\n\n⚠️ Cell${cells.length > 1 ? "s" : ""} ${cells.join(", ")} host${cells.length > 1 ? "" : "s"} a plugin. ` +
349
+ "This save captured the plugin's last-known state, not its live one — the bridge cannot refresh a " +
350
+ "hosted plugin's state blob (that runs in the plugin UI). Re-save from the PAM window to capture " +
351
+ "current hosted-plugin settings.");
246
352
  }
247
353
  function formatParam(p) {
248
354
  let desc = `${p.paramId}: ${p.displayName}`;
@@ -263,7 +369,9 @@ function formatParam(p) {
263
369
  return desc;
264
370
  }
265
371
  const FULL_STATE_RESPONSE_LIMIT_CHARS = 24_000;
266
- const CELL_EFFECTS = ["filter", "delay", "crusher", "flanger", "eq", "fdn", "gate", "compressor"];
372
+ // Preset listings grow with every installed pack; cap the unfiltered dump so it
373
+ // can't crowd out the agent's context (`search` narrows it).
374
+ const LIST_PRESETS_MAX = 400;
267
375
  const PATTERN_LANES = {
268
376
  note: "midiSequence",
269
377
  pitch: "pitchSequence",
@@ -279,6 +387,10 @@ const LFO_DIVISIONS = [
279
387
  "1/32D", "1/32", "1/32T",
280
388
  "1/64D", "1/64", "1/64T",
281
389
  ];
390
+ // Index/enum params. derive-corpus-ranges.mjs skips them (a percentile over an
391
+ // index means nothing), so design_context must not read their missing corpus
392
+ // row as "no preset ever used this". Keep in sync with ENUMISH in that script.
393
+ const CORPUS_ENUM_PARAMS = /^(mode|filterType|arpegMode|gateProgram|synthEngine|outputMode|outputIndex|crossfadeAssign|grainWindow|grainArp|fmSource|fmPolarity|gainClipMode|patternDirection|quantRoot|quantScale|.*SyncEnabled|.*Enabled)$/;
282
394
  function levenshtein(a, b) {
283
395
  const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
284
396
  const curr = Array(b.length + 1).fill(0);
@@ -466,54 +578,22 @@ function stemName(path) {
466
578
  }
467
579
  // --- Server ---
468
580
  const server = new McpServer({
469
- name: "pam",
470
- title: "PAM Audio Sampler",
581
+ name: SERVER_PRODUCT.id,
582
+ title: SERVER_PRODUCT.serverTitle,
471
583
  version: SERVER_VERSION,
472
584
  }, {
473
- instructions: "PAM is an audio sampler plugin. Start design/read work with design_context (curated param " +
474
- "cheat sheet) and get_design_overview (compact live snapshot) instead of full get_state or " +
475
- "broad get_parameter_info; when you need detail, call get_state with section/keys/cellIds filters. " +
476
- "When browsing samples, ALWAYS call get_default_locations " +
477
- "first to discover the user's actual library paths — never guess paths like ~/Music/PAM. " +
478
- "The sample library lives under ~/Library/Application Support/, NOT ~/Music/. " +
479
- "Use the returned paths with list_samples. When loading samples, use load_sample with " +
480
- "paths from list_samples. Transport must be playing for sequencer output. " +
481
- "To enable experimental features (plugin host, AI engines) call update_settings with both " +
482
- "experimentalMode:true AND the specific sub-flag (e.g. experimentalPluginHost:true). " +
483
- "The drum synth is a standard feature (PAM ≥ 1.5) — no settings flag needed. " +
484
- "Tools added in server version 1.7.0 — update_settings, configure_transport, " +
485
- "get_audio_device_setup, set_midi_io, set_midi_mapping, delete_preset, " +
486
- "set_preset_factory_status, get_sample_usage, repair_preset_samples, preview_sample, " +
487
- "set_plugin_params, skip_current_plugin_scan, remove_plugin_from_blocklist — require PAM ≥ 1.7.0. " +
488
- "VX video tools (get_video_state, video_command, add_video_layer, update_video_layer, " +
489
- "remove_video_layer, apply_video_layout, add_video_fx, set_video_fx, remove_video_fx, " +
490
- "bind_video_param, set_video_settings, set_video_layer_source) require PAM ≥ 1.8.0 AND PAM's window to be OPEN " +
491
- "(the video engine renders in the WebView; there is no headless renderer). " +
492
- "capture_ui, set_visual, set_drum_synth_wave, and randomize with target 'synth' are " +
493
- "WebView-routed too and need the window open for the same reason. For " +
494
- "audio-reactive control, note the visual audio-react params (visualWaveDisplace, etc.) " +
495
- "are regular plugin params — drive them with set_parameters; layers/FX/bindings go through " +
496
- "the video tools. When building a scene, call get_video_state first to learn layer/fx ids. " +
497
- "AI model lifecycle tools (download_model, get_model_status, load_model, unload_model, " +
498
- "delete_model, list_models; engines aigen|magenta|notochord) require PAM ≥ 1.9.0 plus the " +
499
- "matching experimental flags; load/unload/delete can block for seconds — poll " +
500
- "get_model_status rather than tight-looping. " +
501
- "Earlier tool sets (looper, song mode, modulation edits, MIDI recording, sample analysis, " +
502
- "live resample, variation scheduling, AI generation) require PAM ≥ 1.5.0. " +
503
- "SONG WORKFLOW: author sections as variation slots (save_variation), arrange them with " +
504
- "song_mode (op setArrangement authors real Song Mode grid nodes and can pin a VX variation " +
505
- "slot per section via vxVariationIndex; op setSequence is the older headless raw-step path), " +
506
- "manage visual variation slots with vx_variation (save/load/clear/fill — the VX siblings of " +
507
- "save_variation), then bounce with export_song (includeVideo:true films the VX scene with " +
508
- "variation follow live). song_mode setArrangement/get/assignVx, vx_variation, and " +
509
- "export_song includeVideo are WebView-routed (window open, PAM ≥ 1.5.7). " +
510
- "If a tool fails with \"PAM build does not support 'X'\", the connected plugin is older than " +
511
- "this MCP server; the user must update PAM (updating the npm package alone is not enough). " +
512
- "Tool annotations are accurate: readOnlyHint tools never mutate the plugin, destructiveHint " +
513
- "tools discard user state (confirm intent before calling them). When several product " +
514
- "instances run at once (PAM + MFX), select_instance chooses which one the live tools drive " +
515
- "(PAM_BRIDGE_PRODUCT pins it at launch). Guided workflow prompts are available: build-a-beat, " +
516
- "make-a-song, design-a-preset, mix-and-master, troubleshoot-bridge.",
585
+ instructions: IS_MFX_SERVER
586
+ ? "MFX is a four-cell modular FX router. Start with design_context and get_design_overview; " +
587
+ "use set_mfx_routing for cables, set_effect_order for each chain, and set_parameters or " +
588
+ "update_effect_config to shape effects. Use cells 1-4 and save finished work with save_preset. " +
589
+ "Treat tool annotations as authoritative and use select_instance when several products are running."
590
+ : "PAM is an eight-cell sampler. Start read and design work with design_context and " +
591
+ "get_design_overview; filter get_state with section, keys, or cellIds. For samples, call " +
592
+ "get_default_locations before list_samples and load only returned paths; never guess library paths. " +
593
+ "Sequencer output requires a running transport. UI, video, and visual tools require the plugin " +
594
+ "window open; experimental plugin-host and AI tools require experimentalMode plus the matching " +
595
+ "feature flag. Treat tool annotations as authoritative and use select_instance when several " +
596
+ "products are running. A 'build does not support' error means update the plugin, not only this server.",
517
597
  });
518
598
  // --- tool annotation policy ---------------------------------------------------
519
599
  // Single audit surface for MCP tool annotations. Every tool MUST have an entry;
@@ -537,6 +617,9 @@ const TOOL_ANNOTATIONS = {
537
617
  read_preset: RO,
538
618
  get_manifest_summary: RO,
539
619
  design_context: RO,
620
+ preset_novelty: RO,
621
+ variation_distinctness: RO,
622
+ analyze_motion: W,
540
623
  get_state: RO,
541
624
  get_design_overview: RO,
542
625
  get_transport: RO,
@@ -550,6 +633,7 @@ const TOOL_ANNOTATIONS = {
550
633
  capture_ui: RO, // writes a screenshot file, mutates no plugin state
551
634
  get_sample_peaks: RO,
552
635
  get_audio_device_setup: RO,
636
+ get_memory_report: RO,
553
637
  get_sample_usage: RO,
554
638
  get_model_status: RO,
555
639
  list_models: RO,
@@ -637,13 +721,18 @@ const TOOL_ANNOTATIONS = {
637
721
  looper_settings: WI,
638
722
  looper_variation: W,
639
723
  looper_variation_sync: WI,
640
- live_resample: W,
724
+ live_resample: D, // finalize replaces the destination cell; finalize:false discards the take
641
725
  // Song mode / export
642
726
  song_mode: W,
643
727
  export_song: W,
644
728
  export_audio: W,
645
729
  // Settings / devices
646
730
  update_settings: WI,
731
+ set_audio_device: WI,
732
+ set_mfx_routing: WI,
733
+ set_mfx_edge_gain: WI,
734
+ configure_mfx_combo: WI,
735
+ set_effect_order: WI,
647
736
  set_midi_io: WI,
648
737
  set_midi_mapping: WI,
649
738
  // Plugin host
@@ -692,18 +781,116 @@ const TOOL_ANNOTATIONS = {
692
781
  ask_assistant: D, // executes arbitrary model-chosen tool calls
693
782
  };
694
783
  const internalTools = new Map();
784
+ // The shared implementation contains sampler, song, video, AI, hosted-
785
+ // instrument, and MFX operations. A dedicated MFX launcher must not advertise
786
+ // sampler workflows merely because their handlers live in this file: tool
787
+ // discovery is part of the product contract, not an implementation inventory.
788
+ const MFX_TOOL_NAMES = new Set([
789
+ "get_parameter_info",
790
+ "list_presets",
791
+ "read_preset",
792
+ "get_manifest_summary",
793
+ "design_context",
794
+ "variation_distinctness",
795
+ "analyze_motion",
796
+ "get_state",
797
+ "get_design_overview",
798
+ "get_transport",
799
+ "connection_status",
800
+ "get_default_locations",
801
+ "capture_ui",
802
+ "get_memory_report",
803
+ "set_parameters",
804
+ "add_modulation",
805
+ "update_modulation",
806
+ "clear_modulations",
807
+ "update_effect_config",
808
+ "update_cell_config",
809
+ "set_cell_mute",
810
+ "set_master_effects",
811
+ "add_param_seq",
812
+ "update_param_seq",
813
+ "remove_param_seq",
814
+ "stop_cell",
815
+ "reset_cell",
816
+ "copy_cell",
817
+ "start_transport",
818
+ "stop_transport",
819
+ "full_stop_transport",
820
+ "set_bpm",
821
+ "set_metronome",
822
+ "configure_transport",
823
+ "load_preset",
824
+ "save_preset",
825
+ "new_preset",
826
+ "import_preset",
827
+ "export_preset",
828
+ "update_preset_metadata",
829
+ "delete_preset",
830
+ "set_preset_factory_status",
831
+ "load_variation",
832
+ "save_variation",
833
+ "queue_variation",
834
+ "cancel_queued_variation",
835
+ "update_variations",
836
+ "variation_hold",
837
+ "export_audio",
838
+ "update_settings",
839
+ "set_audio_device",
840
+ "set_mfx_routing",
841
+ "set_mfx_edge_gain",
842
+ "configure_mfx_combo",
843
+ "set_effect_order",
844
+ "set_midi_io",
845
+ "set_midi_mapping",
846
+ "select_instance",
847
+ ]);
848
+ function mfxSurfaceText(value) {
849
+ return value
850
+ .replaceAll("PAM/Cell1-only", "sampler-product-only")
851
+ .replaceAll("PAM/Cell1", "sampler products")
852
+ .replaceAll("PAM-only", "sampler-product-only")
853
+ .replaceAll("PAM's", "MFX's")
854
+ .replaceAll("PAM", "MFX");
855
+ }
856
+ function mfxToolConfig(name, config) {
857
+ const source = config;
858
+ const inputEntries = Object.entries(source.inputSchema ?? {}).filter(([key]) => (name !== "update_cell_config" || key === "cellId" || key === "sidechainFrom") &&
859
+ (name !== "select_instance" || key !== "product"));
860
+ const inputSchema = source.inputSchema
861
+ ? Object.fromEntries(inputEntries.map(([key, schema]) => [
862
+ key,
863
+ schema.description ? schema.describe(mfxSurfaceText(schema.description)) : schema,
864
+ ]))
865
+ : undefined;
866
+ const description = name === "select_instance"
867
+ ? "Choose which running MFX instance the live tools drive. Match by instanceId or port from connection_status. The launcher refuses non-MFX instances."
868
+ : name === "update_cell_config"
869
+ ? "Set one MFX cell's sidechain source. A non-null source also enables that cell's compressor."
870
+ : source.description ? mfxSurfaceText(source.description) : undefined;
871
+ return {
872
+ ...source,
873
+ ...(description ? { description } : {}),
874
+ ...(inputSchema ? { inputSchema } : {}),
875
+ };
876
+ }
695
877
  {
696
878
  const origRegisterTool = server.registerTool.bind(server);
697
879
  server.registerTool = ((name, config, handler) => {
698
- internalTools.set(name, { shape: config.inputSchema, handler });
880
+ // Registration handles are never retained by this module; omitted product
881
+ // tools therefore have no runtime handle to return.
882
+ if (IS_MFX_SERVER && !MFX_TOOL_NAMES.has(name))
883
+ return undefined;
884
+ const productConfig = (IS_MFX_SERVER ? mfxToolConfig(name, config) : config);
885
+ internalTools.set(name, { shape: productConfig.inputSchema, handler });
699
886
  // Merge the central annotation policy. Inline annotations at the call site
700
887
  // win per key; openWorldHint is false server-wide (local plugin only).
701
- const cfg = config;
888
+ const cfg = productConfig;
702
889
  if (!TOOL_ANNOTATIONS[name]) {
703
- console.error(`[pam-mcp] tool '${name}' has no TOOL_ANNOTATIONS entry — classify it (check-tool-surface fails on this)`);
890
+ console.error(`[${SERVER_PRODUCT.command}] tool '${name}' has no TOOL_ANNOTATIONS entry — classify it (check-tool-surface fails on this)`);
704
891
  }
705
892
  cfg.annotations = { openWorldHint: false, ...TOOL_ANNOTATIONS[name], ...cfg.annotations };
706
- return origRegisterTool(name, config, handler);
893
+ return origRegisterTool(name, productConfig, handler);
707
894
  });
708
895
  }
709
896
  async function callInternalTool(name, args) {
@@ -829,7 +1016,20 @@ server.registerTool("list_presets", {
829
1016
  }
830
1017
  if (allPresets.length === 0)
831
1018
  return text("No presets found.");
832
- return json(allPresets.map((p) => ({ name: p.name, path: p.relativePath })));
1019
+ // A pack-heavy library runs to thousands of entries and would swamp the
1020
+ // agent's context. `search` is the narrowing control — it is already in the
1021
+ // FunctionGemma toolpack schema, so this stays an output-side cap and the
1022
+ // inputSchema does not drift. The plain array shape is preserved unless the
1023
+ // cap actually bites, so normal libraries see exactly what they saw before.
1024
+ const entries = allPresets.map((p) => ({ name: p.name, path: p.relativePath }));
1025
+ if (entries.length <= LIST_PRESETS_MAX)
1026
+ return json(entries);
1027
+ return json({
1028
+ presets: entries.slice(0, LIST_PRESETS_MAX),
1029
+ total: entries.length,
1030
+ truncated: entries.length - LIST_PRESETS_MAX,
1031
+ hint: "Pass `search` to narrow the list — only the first entries are shown.",
1032
+ });
833
1033
  });
834
1034
  server.registerTool("read_preset", {
835
1035
  title: "Read Preset",
@@ -911,8 +1111,11 @@ server.registerTool("get_manifest_summary", {
911
1111
  });
912
1112
  server.registerTool("design_context", {
913
1113
  title: "Design Context",
914
- description: "Compact parameter-design cheat sheet for agents: macro targets, per-effect key params, musical ranges, " +
915
- "unit notes, LFO/delay division hints, MIDI cell note map, and variation slot semantics.",
1114
+ description: "Compact parameter-design cheat sheet for agents: the product's global controls and full per-cell effect vocabulary, " +
1115
+ "value bands measured from the " +
1116
+ "shipped factory presets, an underexplored-parameter list, macro targets, unit traps, LFO/delay division " +
1117
+ "tables, FX chain order, MIDI cell note map, and variation slot semantics. The bands describe where " +
1118
+ "shipped presets sit — they are not limits, and the distinctive end of a range is usually outside them.",
916
1119
  inputSchema: {
917
1120
  cellId: z
918
1121
  .number()
@@ -921,13 +1124,74 @@ server.registerTool("design_context", {
921
1124
  .max(8)
922
1125
  .default(1)
923
1126
  .describe("Cell number to use for per-cell example param IDs. Defaults to cell 1."),
1127
+ groups: z
1128
+ .array(z.string())
1129
+ .optional()
1130
+ .describe("Narrow keyPerCellParams to these groups (e.g. ['delay','vocoder']) on a follow-up call. " +
1131
+ "Omit on the FIRST call — the full vocabulary is the point, and designing from a narrowed " +
1132
+ "subset is what makes presets resemble each other."),
924
1133
  },
925
1134
  annotations: { readOnlyHint: true },
926
- }, async ({ cellId }) => {
1135
+ }, async ({ cellId, groups: groupFilter }) => {
927
1136
  const manifest = await loadManifest();
1137
+ const cellCheck = validateCellIdForManifest(cellId, manifest);
1138
+ if (cellCheck !== true)
1139
+ return cellCheck;
928
1140
  const params = manifest.parameters;
1141
+ const defaultEffectOrder = cellEffectOrder(manifest);
929
1142
  const byId = new Map(params.map((p) => [p.paramId, p]));
930
1143
  const example = (suffix) => byId.get(`cell${cellId}_${suffix}`)?.paramId ?? `cell${cellId}_${suffix}`;
1144
+ if (IS_MFX_SERVER) {
1145
+ const requestedGroups = groupFilter?.length ? new Set(groupFilter) : null;
1146
+ const effectGroups = Object.fromEntries(cellEffectNames(manifest).filter((effect) => !requestedGroups || requestedGroups.has(effect)).map((effect) => [
1147
+ effect,
1148
+ params
1149
+ .filter((parameter) => parameter.cell === `cell${cellId}` && parameter.effect === effect)
1150
+ .map((parameter) => parameter.paramId),
1151
+ ]));
1152
+ return json({
1153
+ product: manifest.name,
1154
+ architecture: "Four effect cells connected by a replaceable cable graph.",
1155
+ cells: [1, 2, 3, 4],
1156
+ routing: {
1157
+ tool: "set_mfx_routing",
1158
+ gainTool: "set_mfx_edge_gain",
1159
+ ports: {
1160
+ sources: ["input", "sidechain", "cell1..cell4", "split_lo", "split_mid", "split_hi"],
1161
+ destinations: ["cell1..cell4", "split", "output"],
1162
+ },
1163
+ serialExample: [
1164
+ { from: "input", to: "cell1" },
1165
+ { from: "cell1", to: "cell2" },
1166
+ { from: "cell2", to: "cell3" },
1167
+ { from: "cell3", to: "cell4" },
1168
+ { from: "cell4", to: "output" },
1169
+ ],
1170
+ splitParameters: ["mfx_split_lo_hz", "mfx_split_hi_hz"],
1171
+ },
1172
+ combos: {
1173
+ tool: "configure_mfx_combo",
1174
+ parameters: ["mfx_combo1", "mfx_combo2", "mfx_combo3"],
1175
+ roles: ["Morph", "Color", "Motion"],
1176
+ note: "The tool resolves automatic assignments, edits targets and labels, locks assignments, and fans combo values out while the MFX window is closed.",
1177
+ },
1178
+ globalParameters: params
1179
+ .filter((parameter) => !parameter.cell && parameter.paramId.startsWith("mfx_"))
1180
+ .map((parameter) => parameter.paramId),
1181
+ effectOrder: {
1182
+ tool: "set_effect_order",
1183
+ paramId: `cell${cellId}_effectOrder`,
1184
+ defaultOrder: defaultEffectOrder,
1185
+ },
1186
+ keyPerCellParams: effectGroups,
1187
+ parameterRanges: "Call get_parameter_info with a paramId above for its exact range, default, unit, and enum labels.",
1188
+ modulation: {
1189
+ tools: ["add_modulation", "update_modulation", "clear_modulations", "add_param_seq"],
1190
+ note: "Target the cell effect parameters above or the three combo parameters.",
1191
+ },
1192
+ variationSlots: "API indices are 0..7.",
1193
+ });
1194
+ }
931
1195
  const macroTargetsByEffect = {};
932
1196
  for (const p of params.filter((param) => param.paramId.startsWith("macro_") && param.isModulatable)) {
933
1197
  const key = p.effect ?? "global";
@@ -951,39 +1215,131 @@ server.registerTool("design_context", {
951
1215
  ["1/32", 18, 0.818],
952
1216
  ["1/64", 21, 0.955],
953
1217
  ].map(([division, index, value]) => ({ division, index, lfoRate: value }));
1218
+ // Terse corpus band for one param suffix. Reports where the shipped
1219
+ // presets actually sit AND how much of the legal span they leave unused —
1220
+ // the second number is the one that matters when the brief is "make it
1221
+ // sound like nothing we've shipped".
1222
+ const band = (suffix) => {
1223
+ const r = CORPUS_RANGES[suffix];
1224
+ if (!r)
1225
+ return undefined;
1226
+ const headroom = r.spanUsedPct < 60 ? ` — corpus only visits ${r.spanUsedPct}% of the legal span` : "";
1227
+ return `typical ${r.p10}..${r.p90} (median ${r.p50}); corpus ${r.lo}..${r.hi}; legal ${r.legal[0]}..${r.legal[1]}; default ${r.def}${headroom}`;
1228
+ };
1229
+ // Attach bands to a group's param IDs so one lookup answers both "what is
1230
+ // the ID" and "what value is musical".
1231
+ const group = (...suffixes) => Object.fromEntries(suffixes.map((s) => [example(s), band(s) ?? "no corpus data — use get_parameter_info for the legal range"]));
1232
+ const wanted = groupFilter?.length ? new Set(groupFilter) : null;
1233
+ const pick = (all) => wanted ? Object.fromEntries(Object.entries(all).filter(([k]) => wanted.has(k))) : all;
1234
+ // Params the corpus barely explores. Presented as opportunity, not as a
1235
+ // warning: an agent that only ever writes values inside the dense part of
1236
+ // the distribution reproduces the average shipped preset by construction.
1237
+ const underexplored = Object.entries(CORPUS_RANGES)
1238
+ .filter(([, r]) => r.spanUsedPct < 55 || r.distinct <= 8)
1239
+ .sort((a, b) => a[1].spanUsedPct - b[1].spanUsedPct)
1240
+ .map(([k, r]) => `${k}: corpus uses ${r.lo}..${r.hi} of ${r.legal[0]}..${r.legal[1]} (${r.distinct} distinct values)`);
1241
+ // Params with no corpus row at all — no shipped preset has ever moved them.
1242
+ // Enable flags, sync toggles and the per-band EQ8 grid are excluded because
1243
+ // they are switches and a 49-row template, not sound-design dials.
1244
+ const neverUsed = params
1245
+ .filter((p) => p.paramId.startsWith(`cell${cellId}_`))
1246
+ .map((p) => p.paramId.slice(`cell${cellId}_`.length))
1247
+ .filter((s) => !CORPUS_RANGES[s] &&
1248
+ // Enum/index params carry no corpus row because a percentile over an
1249
+ // index is meaningless — their absence is not evidence of disuse.
1250
+ !CORPUS_ENUM_PARAMS.test(s) &&
1251
+ !s.startsWith("eq8_") &&
1252
+ !s.startsWith("magenta") &&
1253
+ !/^synth_p[4-8]$/.test(s))
1254
+ .sort();
954
1255
  return json({
955
1256
  product: manifest.name,
956
1257
  exampleCell: cellId,
957
1258
  macroTargetsByEffect,
958
- keyPerCellParams: {
959
- mixer: [example("volume"), example("pan"), example("gainGain")],
960
- filter: [example("filterCutoff"), example("filterQ"), example("filterType")],
961
- delay: [example("delayDelayTime"), example("delayFeedback"), example("delayTilt"), example("delayMix")],
962
- crusher: [example("crusherBitReduction"), example("crusherRateReduction"), example("crusherDrive"), example("crusherMix")],
963
- fdn: [example("fdnSize"), example("fdnFeedback"), example("fdnModDepth"), example("fdnMix")],
964
- gate: [example("gateTime"), example("gateProgram"), example("gateMix")],
965
- compressor: [example("compressorThreshold"), example("compressorRatio"), example("compressorAttack"), example("compressorRelease")],
966
- drumSynth: [example("synthEngine"), example("synthVolume"), example("synth_p1"), example("synth_p2"), example("synth_p3")],
967
- },
1259
+ // Ordered roughly by how much each group changes the character of a
1260
+ // sound. The sampler group is first because startPos/endPos/pitchShift
1261
+ // are the three most actively-designed params in the whole factory
1262
+ // corpus — chopping and re-tuning the source moves a preset further than
1263
+ // any amount of downstream FX.
1264
+ keyPerCellParams: pick({
1265
+ sampler: group("startPos", "endPos", "cutFrom", "fadeIn", "fadeOut", "mode"),
1266
+ pitch: group("pitchShift", "stretch", "arpeg", "arpegMode"),
1267
+ envelope: group("envAttack", "envGain", "envRelease"),
1268
+ retrigger: group("retrigger", "retriggerDelay"),
1269
+ groove: group("patternSwing"),
1270
+ mixer: group("volume", "pan", "gainGain"),
1271
+ filter: group("filterCutoff", "filterQ", "filterType"),
1272
+ eq: group("eqLowGain", "eqLowMidFreq", "eqMidGain", "eqMidHighFreq", "eqHighGain"),
1273
+ delay: group("delayDelayTime", "delayFeedback", "delayTilt", "delayMix"),
1274
+ flanger: group("flangerMix", "flangerTime", "flangerFeedback", "flangerFreq", "flangerSyncEnabled"),
1275
+ crusher: group("crusherBitReduction", "crusherRateReduction", "crusherDrive", "crusherMix"),
1276
+ fdn: group("fdnSize", "fdnFeedback", "fdnModDepth", "fdnMix"),
1277
+ gate: group("gateTime", "gateProgram", "gateMix"),
1278
+ compressor: group("compressorThreshold", "compressorRatio", "compressorAttack", "compressorRelease"),
1279
+ panner: group("pannerBalance", "pannerWidth"),
1280
+ clip: group("gainClip", "gainClipMode"),
1281
+ drumSynth: group("synthEngine", "synthVolume", "synthPitch", "synthAttack", "synth_p1", "synth_p2", "synth_p3"),
1282
+ // Everything below is a real per-cell engine that no shipped factory
1283
+ // preset has ever touched. They are the least-explored territory in the
1284
+ // instrument, which makes them the first place to look when the brief
1285
+ // is "something we haven't heard".
1286
+ granular: group("stretchGrainMs", "stretchCrossfadeFrac", "stretchSyncEnabled", "grainDensity", "grainWindow", "grainArp", "grainScan", "grainFeedback", "grainTone"),
1287
+ vocoder: group("vocoderMix", "vocoderWave", "vocoderNote", "vocoderFormant", "vocoderSpeed", "vocoderWidth"),
1288
+ resonator: group("envResonatorMix", "envResonatorResonance", "envResonatorDampHz", "envResonatorDetune", "envResonatorGlideMs"),
1289
+ crossModulation: group("fmSource", "fmAmount", "fmPolarity", "spread", "chaos", "formantShift", "vcaDepth"),
1290
+ patternShape: group("patternDirection", "patternLength", "patternSpeed"),
1291
+ pitchQuantize: group("quantRoot", "quantScale", "quantStrength"),
1292
+ retriggerShape: group("retriggerSyncEnabled", "retriggerDecayDb", "retriggerRamp"),
1293
+ slicing: group("slice"),
1294
+ }),
968
1295
  unitNotes: {
969
- pan: "cellN_pan is -1..1, so -0.15 is about 15% left; use cellN_pannerBalance for -100..100%.",
1296
+ sampleWindow: "cellN_startPos / cellN_endPos are 0..100 percent of the sample (defaults 0 and 100). Trimming endPos is how the corpus builds chops — a quarter of shipped sounding cells end below 69%. cellN_cutFrom (0..8) picks the slice source.",
1297
+ pitchShift: "cellN_pitchShift is -24..24 SEMITONES, not a ratio. Pair with update_cell_config quantRoot/quantScale to keep a pitched cell in key.",
1298
+ stretch: "cellN_stretch is 0..1 with 0.25 = unstretched. Below 0.25 smears toward granular freeze, above plays faster. This is PAM's signature control — a value away from 0.25 is audible immediately.",
1299
+ envelope: "cellN_envAttack 1..100 and cellN_envRelease 1..5000 are ms; cellN_envGain is -24..24 dB. A long envRelease on a short one-shot is how a tail is built without reverb.",
1300
+ retrigger: "cellN_retrigger is a repeat COUNT (0..10); cellN_retriggerDelay is a normalized 0..1 division, not seconds.",
1301
+ arpeg: "cellN_arpeg is a -24..24 semitone step and cellN_arpegMode selects the pattern (0=Up 1=Down 2=Up/Down 3=Random 4=Triad 5=Major 6=Minor 7=Penta Maj ...). Non-zero arpeg turns a one-shot cell into a melodic line.",
1302
+ pan: "cellN_pan is -1..1, so -0.15 is about 15% left; use cellN_pannerBalance for -100..100% (needs pannerEnabled).",
970
1303
  percentages: "Most mix/feedback/depth controls are 0..100 percent.",
971
1304
  filterCutoff: "Use real Hz values; move at least 1.5 octaves for obviously audible modulation.",
972
1305
  delayDelayTime: "With delaySyncEnabled=1, normalized 0..1 maps to musical divisions; do not use cellN_delayTime.",
1306
+ flanger: "cellN_flangerTime and cellN_flangerFreq are normalized 0..1, not ms/Hz. flangerFeedback is where the character lives — the corpus median is 70 and the top quartile is at 95+.",
1307
+ eqGains: "cellN_eq{Low,Mid,High}Gain are -60..12 dB. The -60 floor makes the EQ a band KILL, not just a tilt.",
973
1308
  lfoRate: "LFO IDs are 1-based: lfo1_... through lfo6_.... Sync ON uses index/22.",
1309
+ lfoFeel: "lfoN_jitter and lfoN_offset (both 0..100, both default 0) are what stop an LFO sounding like a metronome — jitter randomizes the period, offset moves the starting phase so two LFOs at the same rate stop moving in lockstep. Only 8% and 6% of shipped LFOs set them.",
1310
+ granular: "The granular engine runs under cellN_stretch. cellN_stretchGrainMs (1..2000) is grain size — small values buzz, 200+ smears into a pad; cellN_grainDensity (1..32) is grains in flight; cellN_grainScan (-100..100) moves the read head independently of playback, so a negative scan plays a sample backwards through itself; cellN_grainFeedback (0..100) recirculates grains; cellN_grainTone (-100..100) tilts them; cellN_grainWindow (0..4) is the grain envelope shape.",
1311
+ vocoder: "Per-cell vocoder: cellN_vocoderMix (0..100) blends it in, cellN_vocoderNote (24..96) is the carrier MIDI note, cellN_vocoderWave (0..100) morphs the carrier, cellN_vocoderFormant (-12..12) shifts formants against pitch, cellN_vocoderSpeed (0.25..4) is band tracking rate. Any cell can vocode any source — it does not need a voice sample.",
1312
+ resonator: "cellN_envResonator* is the tuned comb resonator: mix (0..100), resonance (0..100), dampHz (200..20000), detune (-24..24 semitones), glideMs (0..500). High resonance turns a percussive hit into a pitched tone that follows the cell — see rules/tunedcomb.md for PRE/POST routing.",
1313
+ crossModulation: "cellN_fmSource (0..8) picks ANOTHER cell as an FM modulator and cellN_fmAmount (0..100) sets depth, so cells can modulate each other rather than each sitting in its own lane. cellN_chaos (0..100) and cellN_spread (0..100) add instability and stereo scatter; cellN_formantShift (-12..12) moves formants without pitch; cellN_vcaDepth (0..100) sets how hard the amp envelope bites.",
1314
+ patternShape: "cellN_patternDirection (0..4), cellN_patternLength (1..32) and cellN_patternSpeed (0..19) reshape a lane without rewriting a single note. Odd lengths against a 16-step grid (7, 11, 13) produce phasing polymeter, and the pitch/slice/stretch lanes have their own patternLength*/patternSpeed* variants so lanes can run at different lengths against each other.",
1315
+ pitchQuantize: "cellN_quantRoot (0..11), cellN_quantScale (0..9) and cellN_quantStrength (0..100) force a pitched cell into key. Set these before writing a pitch lane so transposition stays musical.",
1316
+ clip: "cellN_gainClip (0..12 dB) with cellN_gainClipMode (0..3) is a per-cell clipper — a different distortion character from the crusher's drive.",
1317
+ slice: "cellN_slice (0..64) selects the active slice; drive it from a slice lane for stutter edits without touching the sample.",
1318
+ },
1319
+ // Derived from the shipped factory presets, not hand-written. Regenerate
1320
+ // with `node scripts/derive-corpus-ranges.mjs` after changing the corpus.
1321
+ corpusBands: {
1322
+ source: `${CORPUS_META.presets} factory presets, ${CORPUS_META.soundingCells} sounding cells (${CORPUS_META.source})`,
1323
+ reading: "'typical' is p10..p90 — where the shipped presets sit. It is a description of the house average, NOT a limit. The interesting end of a range is usually outside it, and 'corpus' shows how far the shipped presets actually went.",
1324
+ underexplored,
1325
+ neverUsed: {
1326
+ note: "No shipped factory preset has ever moved these. That makes them the cheapest route to a sound the library does not already contain — reach here before adding another filter sweep.",
1327
+ params: neverUsed,
1328
+ },
974
1329
  },
975
- musicalRanges: {
976
- filterCutoff: "dark 300-900 Hz, mid 900-3500 Hz, open 3500-12000 Hz",
977
- delayFeedback: "10-35% controlled, 35-60% dubby, >70% runaway risk",
978
- wetMix: "10-25% subtle, 25-45% audible, >60% wash/destroy",
979
- crusherBitReduction: "7-16 bits musical grit, 1-4 bits brutal",
980
- volumeDb: "Layer cells with -12..0 dB staging before master processing",
1330
+ eq8: {
1331
+ note: "Every cell also has a full 8-band parametric EQ, separate from the 3-band eq group above. It is a per-band grid rather than a fixed param list.",
1332
+ template: `cell${cellId}_eq8_b{1..8}_{freq,gain,q,type,slope,enabled}`,
1333
+ bandRanges: "freq 10..30000 Hz, gain -30..30 dB, q 0.025..40, type 0..5, slope 0..2, enabled 0/1",
1334
+ outputGain: example("eq8_outputGain"),
1335
+ idea: "A q of 40 at a chosen freq is a resonant whistle, not an EQ move — eight of them tuned to a chord turn any noise source into a pitched pad.",
981
1336
  },
982
1337
  lfoSyncTable: {
983
1338
  formula: "set lfoN_lfoSyncEnabled=1 and lfoN_lfoRate=index/22",
984
1339
  quick: lfoQuick,
985
1340
  fullDivisions: LFO_DIVISIONS,
986
1341
  shapes: ["0=Sine", "1=Saw", "2=Tri", "3=Square", "4=Random", "5=Wander", "6=Custom"],
1342
+ shapeNote: "Shapes 4 (Random) and 5 (Wander) are stepped/drifting rather than periodic — they read as performance rather than automation. Half of shipped LFOs are shape 0 (Sine).",
987
1343
  },
988
1344
  delaySyncDivisions: [
989
1345
  "1/32T", "1/32", "1/32D", "1/16T", "1/16", "1/16D",
@@ -993,12 +1349,293 @@ server.registerTool("design_context", {
993
1349
  filterTypes: filterType?.strRepr?.map((label, index) => `${index}=${label}`) ?? [
994
1350
  "0=Lowpass", "1=Highpass", "2=Bandpass", "3=Peak", "4=Notch",
995
1351
  ],
1352
+ effectOrder: {
1353
+ paramId: `cell${cellId}_effectOrder`,
1354
+ tool: "set_effect_order",
1355
+ note: "Per-cell FX chain order is a JSON string array, NOT a numeric param — set_parameters cannot reach it. Order is audible: filter-into-delay smears the resonance, delay-into-filter darkens the repeats.",
1356
+ defaultOrder: defaultEffectOrder,
1357
+ },
996
1358
  midiNoteMap: Object.fromEntries(Array.from({ length: 8 }, (_, i) => {
997
1359
  const id = i + 1;
998
1360
  return [`cell${id}`, 47 + id];
999
1361
  })),
1000
1362
  variationSlots: "API is 0-based (0..7); PAM UI labels slots 1..8. Save/load variation index 0 = UI slot 1.",
1001
- modulationGuidance: "Primary movement should target per-cell params for distinct audible changes. Use macro_* for performance-level fan-out across cells.",
1363
+ modulationGuidance: "Primary movement should target per-cell params for distinct audible changes. Use macro_* for performance-level fan-out across cells. " +
1364
+ "Shipped presets that modulate at all carry a mean of 8.2 sources; the top third carry 8-46. Four sources is the corpus MEDIAN, i.e. the unremarkable middle — not a target.",
1365
+ });
1366
+ });
1367
+ server.registerTool("preset_novelty", {
1368
+ title: "Score Preset Novelty",
1369
+ description: "Score a preset against the shipped factory corpus and name what it duplicates. Answers the question " +
1370
+ "nothing else in the pipeline could: 'does this already exist?'\n\n" +
1371
+ "Compares structure, not audio — which params are off their defaults, the FX set and chain orders, the " +
1372
+ "modulation topology, per-cell onset patterns, transpositions, and sample sources. No render, no audio, " +
1373
+ "and (for source 'file') no plugin connection needed.\n\n" +
1374
+ "novelty is 1 - (closest match). Read it against percentileVsCorpus, NOT as an absolute: every PAM preset " +
1375
+ "shares an instrument, so a perfectly good one still scores around " +
1376
+ `${CORPUS_NOVELTY_BASELINE.p50}. The closest pair that actually shipped scores ${CORPUS_NOVELTY_BASELINE.floor} — ` +
1377
+ "below that, a preset is more derivative than anything in the library.\n\n" +
1378
+ "duplicatedDimensions is the actionable part: it names which dimensions to change.",
1379
+ inputSchema: {
1380
+ source: z
1381
+ .enum(["live", "file"])
1382
+ .default("live")
1383
+ .describe("'live' fingerprints the running plugin's current state; 'file' reads a .preset from disk."),
1384
+ path: z.string().optional().describe("Preset path for source 'file' (absolute or relative to the preset dirs)."),
1385
+ topN: z.number().int().min(1).max(20).default(5).describe("How many nearest neighbours to return."),
1386
+ },
1387
+ annotations: { readOnlyHint: true },
1388
+ }, async ({ source, path: presetPath, topN }) => {
1389
+ const manifest = await loadManifest();
1390
+ const defaults = {};
1391
+ for (const p of manifest.parameters) {
1392
+ const m = /^cell1_(.+)$/.exec(p.paramId);
1393
+ if (m)
1394
+ defaults[m[1]] = p.defaultValue;
1395
+ }
1396
+ const effectNames = cellEffectNames(manifest);
1397
+ let state;
1398
+ let label = "live state";
1399
+ if (source === "file") {
1400
+ if (!presetPath)
1401
+ return error("source 'file' requires a `path`.");
1402
+ try {
1403
+ const full = await resolvePresetPath(presetPath);
1404
+ const { metadata, data } = parsePresetFile(await readFile(full, "utf-8"));
1405
+ state = data;
1406
+ label = metadata.name || basename(full, ".preset");
1407
+ }
1408
+ catch (e) {
1409
+ return error(`Failed to read preset: ${e.message}`);
1410
+ }
1411
+ }
1412
+ else {
1413
+ const check = await requireBridge();
1414
+ if (check !== true)
1415
+ return check;
1416
+ state = (await bridge.send("getState"));
1417
+ }
1418
+ const candidate = buildFingerprint(state, defaults, effectNames, label);
1419
+ if (candidate.cells === 0) {
1420
+ return error("No cell carries any notes, so there is nothing to fingerprint. Write note patterns first (update_pattern), then re-score.");
1421
+ }
1422
+ // Re-scoring a preset that already shipped would otherwise match itself at
1423
+ // 1.0 and report "more derivative than anything in the library", which is
1424
+ // true but useless. Drop the identity match and say so.
1425
+ const selfIndex = CORPUS_FINGERPRINTS.findIndex((f) => f.name === candidate.name && fingerprintSimilarity(candidate, f).overall > 0.999);
1426
+ const corpus = selfIndex >= 0 ? CORPUS_FINGERPRINTS.filter((_, i) => i !== selfIndex) : CORPUS_FINGERPRINTS;
1427
+ const report = scoreNovelty(candidate, corpus, topN, CORPUS_NOVELTY_BASELINE);
1428
+ return json({
1429
+ subject: label,
1430
+ alreadyInCorpus: selfIndex >= 0 || undefined,
1431
+ ...report,
1432
+ corpusBaseline: {
1433
+ ...CORPUS_NOVELTY_BASELINE,
1434
+ note: `Leave-one-out novelty of the ${CORPUS_FINGERPRINTS.length} shipped presets against each other. p50 is a typical preset; below floor is more derivative than any shipped pair.`,
1435
+ },
1436
+ candidateProfile: {
1437
+ soundingCells: candidate.cells,
1438
+ paramsMoved: candidate.params.length,
1439
+ fx: candidate.fx,
1440
+ distinctChainOrders: candidate.chains.length,
1441
+ modulationSources: candidate.mod.lfo + candidate.mod.env + candidate.mod.vary + candidate.mod.seq,
1442
+ modulationTargets: candidate.mod.targets.length,
1443
+ transpositions: candidate.pitch.length,
1444
+ sampleSources: candidate.sources.length,
1445
+ },
1446
+ });
1447
+ });
1448
+ server.registerTool("analyze_motion", {
1449
+ title: "Analyze Preset Motion",
1450
+ description: "Measure whether a preset actually MOVES over time, rather than whether it is mixed. Fills the gap left by " +
1451
+ "get_master_analysis (loudness) and get_mix_analysis (aggregate spectrum + onset density), neither of which " +
1452
+ "can tell a modulated preset from a static one.\n\n" +
1453
+ "Reports spectral flux, brightness wander, per-bar level and brightness variation, and level movement WITHIN " +
1454
+ "a bar — the last one is what catches a bar-synced gate, tremolo or sidechain pump, whose bars are all " +
1455
+ "identical to each other.\n\n" +
1456
+ "Pass wavPath to analyze an existing render or live_resample capture. Otherwise the tool runs an offline " +
1457
+ "export first. Digital silence is reported as a RENDER FAILURE with the workaround, not as 'no motion' — " +
1458
+ "offline export is known to produce silence in the standalone build.",
1459
+ inputSchema: {
1460
+ wavPath: z
1461
+ .string()
1462
+ .optional()
1463
+ .describe("Absolute path to an existing WAV. Skips rendering — use this for a live_resample capture."),
1464
+ bars: z.number().int().min(1).max(32).default(4).describe("Bars to render when no wavPath is given."),
1465
+ bpm: z
1466
+ .number()
1467
+ .optional()
1468
+ .describe("Tempo for the per-bar windows. Read from the transport when omitted."),
1469
+ },
1470
+ annotations: { readOnlyHint: false, destructiveHint: false },
1471
+ }, async ({ wavPath, bars, bpm }) => {
1472
+ let path = wavPath;
1473
+ let effectiveBpm = bpm;
1474
+ if (!path) {
1475
+ const check = await requireBridge();
1476
+ if (check !== true)
1477
+ return check;
1478
+ const transport = (await bridge.send("getTransport"));
1479
+ effectiveBpm ??= typeof transport?.bpm === "number" ? transport.bpm : 120;
1480
+ const filename = `pam_motion_${Date.now()}`;
1481
+ await bridge.send("exportAudio", {
1482
+ filename,
1483
+ mode: "main",
1484
+ durationInSeconds: (bars * 4 * 60) / (effectiveBpm || 120),
1485
+ bitDepth: 24,
1486
+ });
1487
+ const outcome = await pollExportStatus();
1488
+ if (outcome !== "success")
1489
+ return error(`Render failed before analysis could run (export ${outcome}).`);
1490
+ const locations = (await bridge.send("getDefaultLocations"));
1491
+ const exportDir = [locations?.exports, locations?.exportsLocation, locations?.Exports].find((v) => typeof v === "string" && v.length > 0);
1492
+ if (!exportDir) {
1493
+ return error(`Rendered ${filename}.wav but could not resolve PAM's Exports folder from get_default_locations. ` +
1494
+ "Re-run with an explicit wavPath.");
1495
+ }
1496
+ path = resolve(exportDir, `${filename}.wav`);
1497
+ }
1498
+ else if (effectiveBpm === undefined) {
1499
+ const check = await requireBridge();
1500
+ if (check === true) {
1501
+ const transport = (await bridge.send("getTransport"));
1502
+ if (typeof transport?.bpm === "number")
1503
+ effectiveBpm = transport.bpm;
1504
+ }
1505
+ }
1506
+ let metrics;
1507
+ try {
1508
+ metrics = analyzeMotion(decodeWav(await readFile(path)), effectiveBpm);
1509
+ }
1510
+ catch (e) {
1511
+ return error(`Could not analyze ${path}: ${e.message}`);
1512
+ }
1513
+ return json({
1514
+ file: path,
1515
+ bpm: effectiveBpm ?? null,
1516
+ ...metrics,
1517
+ reading: "static:true means nothing measurably changes — brightness, per-bar level and within-bar level are all flat. " +
1518
+ "centroidVariability and perBarCentroidVariability catch slow filter/timbre motion; intraBarRmsVariability " +
1519
+ "catches per-hit and per-beat movers (gate, tremolo, sidechain duck). A preset can pass the modulation-count " +
1520
+ "check and still fail here when depths are too small to hear.",
1521
+ });
1522
+ });
1523
+ // What to change when a pair of variations is identical on a given dimension.
1524
+ // The dimension name alone is not actionable; this maps it to a move.
1525
+ const VARIATION_FIX_HINTS = {
1526
+ groove: "rewrite the note patterns — different onsets, not just different density",
1527
+ params: "change the sound, not the mix: filter type, chop window (startPos/endPos), stretch, or a different FX",
1528
+ mod: "change what MOVES — retarget the front modulator or swap the mover type",
1529
+ pitch: "transpose or re-voice the melodic parts",
1530
+ fx: "enable or bypass a different effect on at least one cell",
1531
+ chains: "reorder one cell's FX chain (set_effect_order)",
1532
+ sources: "swap a sample on at least one cell",
1533
+ };
1534
+ server.registerTool("variation_distinctness", {
1535
+ title: "Check Variation Distinctness",
1536
+ description: "Measure whether a preset's variation slots are actually different, and name what is identical about the " +
1537
+ "pairs that are not. Structural comparison of the saved slot snapshots — no render, no transport, no " +
1538
+ "audition loop, and no risk of the load_variation race.\n\n" +
1539
+ `Read scores against the shipped corpus, where sibling variation pairs sit at a median of ` +
1540
+ `${CORPUS_VARIATION_BASELINE.p50} similarity (p75 ${CORPUS_VARIATION_BASELINE.p75}, p90 ${CORPUS_VARIATION_BASELINE.p90}). ` +
1541
+ `Siblings are far more alike than two different presets by construction, so the preset_novelty scale does ` +
1542
+ `not apply. ${CORPUS_VARIATION_BASELINE.nearIdenticalShare}% of shipped pairs are already near-identical — ` +
1543
+ "this gate exists to stop adding to that number.\n\n" +
1544
+ "Each failing pair reports the dimensions it duplicates plus the move that would fix it.",
1545
+ inputSchema: {
1546
+ source: z
1547
+ .enum(["live", "file"])
1548
+ .default("live")
1549
+ .describe("'live' reads the running plugin's variation slots; 'file' reads them from a .preset."),
1550
+ path: z.string().optional().describe("Preset path for source 'file'."),
1551
+ threshold: z
1552
+ .number()
1553
+ .min(0.5)
1554
+ .max(1)
1555
+ .default(0.9)
1556
+ .describe(`Similarity above which a pair fails. Default 0.9 — worse than 75% of shipped sibling pairs (p75 ${CORPUS_VARIATION_BASELINE.p75}).`),
1557
+ },
1558
+ annotations: { readOnlyHint: true },
1559
+ }, async ({ source, path: presetPath, threshold }) => {
1560
+ const manifest = await loadManifest();
1561
+ const defaults = {};
1562
+ for (const p of manifest.parameters) {
1563
+ const m = /^cell1_(.+)$/.exec(p.paramId);
1564
+ if (m)
1565
+ defaults[m[1]] = p.defaultValue;
1566
+ }
1567
+ const effectNames = cellEffectNames(manifest);
1568
+ let slots;
1569
+ let label;
1570
+ if (source === "file") {
1571
+ if (!presetPath)
1572
+ return error("source 'file' requires a `path`.");
1573
+ try {
1574
+ const full = await resolvePresetPath(presetPath);
1575
+ const parsed = parsePresetFile(await readFile(full, "utf-8"));
1576
+ slots = variationsFromParsedPreset(parsed);
1577
+ label = parsed.metadata.name || basename(full, ".preset");
1578
+ }
1579
+ catch (e) {
1580
+ return error(`Failed to read preset: ${e.message}`);
1581
+ }
1582
+ }
1583
+ else {
1584
+ const check = await requireBridge();
1585
+ if (check !== true)
1586
+ return check;
1587
+ const state = (await bridge.send("getState"));
1588
+ const raw = Array.isArray(state.variations) ? state.variations : [];
1589
+ slots = Array.from({ length: PRESET_VARIATION_SLOTS }, (_, i) => typeof raw[i] === "string" ? decompressVariationEntry(raw[i]) : null);
1590
+ label = "live state";
1591
+ }
1592
+ const filled = [];
1593
+ for (const [i, slot] of slots.entries()) {
1594
+ if (typeof slot !== "string" || slot.length < 10)
1595
+ continue;
1596
+ try {
1597
+ filled.push({ slot: i, fp: buildFingerprint(JSON.parse(slot), defaults, effectNames, `slot ${i}`) });
1598
+ }
1599
+ catch {
1600
+ /* unreadable slot — reported via the occupancy count below */
1601
+ }
1602
+ }
1603
+ if (filled.length < 2) {
1604
+ return error(`Only ${filled.length} readable variation slot(s) — need at least 2 to compare. ` +
1605
+ "Save more slots with save_variation (API indices are 0-based; UI labels them 1-8).");
1606
+ }
1607
+ const pairs = [];
1608
+ for (let i = 0; i < filled.length; i += 1) {
1609
+ for (let j = i + 1; j < filled.length; j += 1) {
1610
+ const sim = fingerprintSimilarity(filled[i].fp, filled[j].fp);
1611
+ const identical = Object.entries(sim.byDimension)
1612
+ .filter(([, v]) => v >= 0.95)
1613
+ .sort((a, b) => b[1] - a[1])
1614
+ .map(([k]) => k);
1615
+ pairs.push({
1616
+ pair: `slot ${filled[i].slot} vs slot ${filled[j].slot}`,
1617
+ similarity: Math.round(sim.overall * 1000) / 1000,
1618
+ pass: sim.overall < threshold,
1619
+ identicalDimensions: identical,
1620
+ fix: sim.overall < threshold ? undefined : identical.map((d) => VARIATION_FIX_HINTS[d] ?? d),
1621
+ });
1622
+ }
1623
+ }
1624
+ const failing = pairs.filter((p) => !p.pass).sort((a, b) => b.similarity - a.similarity);
1625
+ return json({
1626
+ subject: label,
1627
+ filledSlots: filled.map((f) => f.slot),
1628
+ threshold,
1629
+ pass: failing.length === 0,
1630
+ verdict: failing.length === 0
1631
+ ? `All ${pairs.length} slot pairs differ by more than the ${threshold} threshold.`
1632
+ : `${failing.length} of ${pairs.length} slot pairs are too similar. Give one slot in each a real change — density alone is not a variation.`,
1633
+ failingPairs: failing,
1634
+ allPairs: pairs,
1635
+ corpusBaseline: CORPUS_VARIATION_BASELINE,
1636
+ // Shipped variations move these dimensions and leave those alone — useful
1637
+ // when deciding WHICH dimension to change.
1638
+ corpusHabit: "Shipped variations mostly change modulation and groove; they almost never change samples or FX chain order. Those two are therefore the least-used lever available to you.",
1002
1639
  });
1003
1640
  });
1004
1641
  // Standard full update — graph dispatch + KV sync + parameter updates.
@@ -1132,6 +1769,8 @@ server.registerTool("get_design_overview", {
1132
1769
  ]);
1133
1770
  const state = isRecord(stateRaw) ? stateRaw : {};
1134
1771
  const transport = isRecord(transportRaw) ? transportRaw : {};
1772
+ // The manifest is authoritative; every current product carries both character effects.
1773
+ const effectNames = cellEffectNames(manifest);
1135
1774
  const manifestCellIds = [...new Set(manifest.parameters
1136
1775
  .map((p) => p.cell)
1137
1776
  .filter((cell) => typeof cell === "string" && /^cell\d+$/.test(cell))
@@ -1144,6 +1783,49 @@ server.registerTool("get_design_overview", {
1144
1783
  vary: Array.isArray(state.vary) ? state.vary : [],
1145
1784
  paramSeq: Array.isArray(state.paramSeqs) ? state.paramSeqs : [],
1146
1785
  };
1786
+ if (IS_MFX_SERVER) {
1787
+ const cells = ids.map((id) => {
1788
+ const prefix = `cell${id}_`;
1789
+ const targetPrefix = new Set([prefix]);
1790
+ const storedOrder = state[`${prefix}effectOrder`];
1791
+ const rawCell = cellsObj[String(id)];
1792
+ const cellConfig = isRecord(rawCell) ? rawCell : {};
1793
+ return {
1794
+ cellId: id,
1795
+ muted: state[`${prefix}mute`] === 1 || state[`${prefix}mute`] === true,
1796
+ sidechainFrom: cellConfig.sidechainFrom ?? state[`${prefix}sidechainFrom`],
1797
+ effectOrder: Array.isArray(storedOrder) ? storedOrder : cellEffectOrder(manifest),
1798
+ enabledEffects: effectNames.filter((effectName) => state[`${prefix}${effectName}Enabled`] === 1 || state[`${prefix}${effectName}Enabled`] === true),
1799
+ modulation: Object.fromEntries(Object.entries(modArrays).map(([kind, arr]) => [
1800
+ kind,
1801
+ arr.filter((entry) => paramTargetsCell(entry, targetPrefix)).length,
1802
+ ])),
1803
+ mixer: {
1804
+ volume: state[`${prefix}volume`],
1805
+ pan: state[`${prefix}pan`],
1806
+ gain: state[`${prefix}gainGain`],
1807
+ },
1808
+ };
1809
+ });
1810
+ return json({
1811
+ product: manifest.name,
1812
+ bpm: state.bpm ?? transport.bpm,
1813
+ playing: transport.playing,
1814
+ mix: state.mfx_mix,
1815
+ routing: state.mfx_routing,
1816
+ edgeGains: state.mfx_edge_gains,
1817
+ split: { lowHz: state.mfx_split_lo_hz, highHz: state.mfx_split_hi_hz },
1818
+ combos: resolveServerMfxCombos(state, manifest).map((combo) => ({
1819
+ ...combo,
1820
+ value: state[`mfx_combo${combo.index}`],
1821
+ })),
1822
+ totals: {
1823
+ enabledEffects: cells.reduce((sum, cell) => sum + cell.enabledEffects.length, 0),
1824
+ modulationLinks: cells.reduce((sum, cell) => sum + Object.values(cell.modulation).reduce((subtotal, count) => subtotal + count, 0), 0),
1825
+ },
1826
+ cells,
1827
+ });
1828
+ }
1147
1829
  const cells = ids.map((id) => {
1148
1830
  const prefix = `cell${id}_`;
1149
1831
  const rawCellState = cellsObj[String(id)];
@@ -1162,8 +1844,9 @@ server.registerTool("get_design_overview", {
1162
1844
  name: stemName(samplePath),
1163
1845
  path: typeof samplePath === "string" ? samplePath : undefined,
1164
1846
  },
1165
- synthEngine: state[`${prefix}synthEngine`] ?? 0,
1166
- enabledEffects: CELL_EFFECTS.filter((effectName) => state[`${prefix}${effectName}Enabled`] === 1 || state[`${prefix}${effectName}Enabled`] === true),
1847
+ // Both halves: the enum an agent writes back, and the name it reasons about.
1848
+ synthEngine: describeSynthEngine(state[`${prefix}synthEngine`]),
1849
+ enabledEffects: effectNames.filter((effectName) => state[`${prefix}${effectName}Enabled`] === 1 || state[`${prefix}${effectName}Enabled`] === true),
1167
1850
  patternEvents: Object.fromEntries(Object.entries(PATTERN_LANES).map(([lane, suffix]) => [
1168
1851
  lane,
1169
1852
  countEvents(state[`${prefix}${suffix}`]),
@@ -1241,6 +1924,36 @@ server.registerTool("set_parameters", {
1241
1924
  }
1242
1925
  delta.macros = macros;
1243
1926
  }
1927
+ let comboTargetCount = 0;
1928
+ if (IS_MFX_SERVER) {
1929
+ const comboValues = new Map();
1930
+ for (let comboIndex = 1; comboIndex <= MFX_COMBO_COUNT; comboIndex++) {
1931
+ const value = parameters[`mfx_combo${comboIndex}`];
1932
+ if (typeof value === "number")
1933
+ comboValues.set(comboIndex, value);
1934
+ }
1935
+ if (comboValues.size > 0) {
1936
+ const stateRaw = await bridge.send("getState");
1937
+ const state = isRecord(stateRaw) ? stateRaw : {};
1938
+ const manifest = await loadManifest();
1939
+ for (const combo of resolveServerMfxCombos(state, manifest)) {
1940
+ const value = comboValues.get(combo.index);
1941
+ if (value === undefined)
1942
+ continue;
1943
+ if (combo.targets.some((target) => Object.hasOwn(parameters, target.id)))
1944
+ continue;
1945
+ for (const target of combo.targets) {
1946
+ const info = index.get(target.id);
1947
+ if (!info)
1948
+ continue;
1949
+ const previousComboValue = state[`mfx_combo${combo.index}`];
1950
+ const currentTargetValue = state[target.id];
1951
+ delta[target.id] = mfxComboTargetValueFromCurrent(target, typeof previousComboValue === "number" ? previousComboValue : 0, value, info, typeof currentTargetValue === "number" ? currentTargetValue : info.defaultValue);
1952
+ comboTargetCount++;
1953
+ }
1954
+ }
1955
+ }
1956
+ }
1244
1957
  await bridge.send("applyUnifiedStateKvUpdate", {
1245
1958
  delta,
1246
1959
  options: {
@@ -1252,7 +1965,8 @@ server.registerTool("set_parameters", {
1252
1965
  broadcastFollowers: true,
1253
1966
  },
1254
1967
  });
1255
- return text(`Set ${Object.keys(parameters).length} parameter(s): ${Object.entries(parameters).map(([k, v]) => `${k}=${v}`).join(", ")}`);
1968
+ return text(`Set ${Object.keys(parameters).length} parameter(s): ${Object.entries(parameters).map(([k, v]) => `${k}=${v}`).join(", ")}` +
1969
+ (comboTargetCount > 0 ? `; fanned out ${comboTargetCount} MFX combo target write(s)` : ""));
1256
1970
  });
1257
1971
  server.registerTool("load_sample", {
1258
1972
  title: "Load Sample",
@@ -1262,7 +1976,7 @@ server.registerTool("load_sample", {
1262
1976
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
1263
1977
  },
1264
1978
  }, async ({ filePath, cellId }) => {
1265
- const check = await requireBridge();
1979
+ const check = await requireBridgeForCells(cellId);
1266
1980
  if (check !== true)
1267
1981
  return check;
1268
1982
  await bridge.send("loadSampleToCell", filePath, cellId);
@@ -1274,7 +1988,7 @@ server.registerTool("load_sample", {
1274
1988
  // + experimental<Engine>) — the bridge returns a clear error otherwise.
1275
1989
  const MODEL_ENGINE = z
1276
1990
  .enum(["notochord", "aigen", "magenta"])
1277
- .describe("Which AI engine. notochord = MIDI co-player (~18 MB blob); aigen = Stable Audio 3 sample generation (~3.2 GB ONNX); magenta = MagentaRT realtime audio (Apple Silicon, MLX).");
1991
+ .describe("Which AI engine. notochord = MIDI co-player (~18 MB blob); aigen = Stable Audio 3 sample generation (2.71 GB); magenta = MagentaRT realtime audio (Apple Silicon, MLX).");
1278
1992
  const MODEL_VARIANT = z
1279
1993
  .enum(["base", "small"])
1280
1994
  .optional()
@@ -1351,7 +2065,7 @@ server.registerTool("list_models", {
1351
2065
  });
1352
2066
  server.registerTool("generate_sample", {
1353
2067
  title: "Generate Sample (AI)",
1354
- description: "Generate a new audio sample via the on-device AI engine (Stable Audio 3 Small SFX, ~3.2 GB ONNX). The plugin must have the model downloaded first (Settings → Local models). Returns the on-disk path to the generated WAV and optionally loads it into a cell.",
2068
+ description: "Generate a new audio sample via the on-device AI engine (Stable Audio 3 Small SFX, 2.71 GB). The plugin must have the model downloaded first (Settings → Local models). Returns the on-disk path to the generated WAV and optionally loads it into a cell.",
1355
2069
  inputSchema: {
1356
2070
  category: z
1357
2071
  .enum(["kick", "snare", "hat", "perc", "bass", "synth", "pad", "fx", "loop"])
@@ -1525,7 +2239,7 @@ server.registerTool("workbench_load_take", {
1525
2239
  cellId: z.number().int().min(1).max(8).describe("Target cell."),
1526
2240
  },
1527
2241
  }, async ({ takeId, cellId }) => {
1528
- const check = await requireBridge();
2242
+ const check = await requireBridgeForCells(cellId);
1529
2243
  if (check !== true)
1530
2244
  return check;
1531
2245
  try {
@@ -1582,7 +2296,7 @@ server.registerTool("trigger_midi", {
1582
2296
  channel: z.number().min(1).max(16).default(1).describe("MIDI channel (1-16)."),
1583
2297
  },
1584
2298
  }, async ({ cellId, note, noteOn, channel }) => {
1585
- const check = await requireBridge();
2299
+ const check = await requireBridgeForCells(cellId);
1586
2300
  if (check !== true)
1587
2301
  return check;
1588
2302
  if (cellId) {
@@ -1600,7 +2314,7 @@ server.registerTool("stop_cell", {
1600
2314
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
1601
2315
  },
1602
2316
  }, async ({ cellId }) => {
1603
- const check = await requireBridge();
2317
+ const check = await requireBridgeForCells(cellId);
1604
2318
  if (check !== true)
1605
2319
  return check;
1606
2320
  await bridge.send("stopCell", cellId);
@@ -1653,11 +2367,11 @@ server.registerTool("add_modulation", {
1653
2367
  .boolean()
1654
2368
  .optional()
1655
2369
  .describe("Envelope only: route from the cell's pitch-tracked source instead of amp. " +
1656
- "false (default) = AMP; true & pitchDuck=false = BOOST; true & pitchDuck=true = DUCK."),
2370
+ "false (default) = AMP; true & pitchDuck=false = PITCH (comb); true & pitchDuck=true = DUCK."),
1657
2371
  pitchDuck: z
1658
2372
  .boolean()
1659
2373
  .optional()
1660
- .describe("Envelope only: when pitch=true, switch from BOOST to DUCK semantics (notch). Ignored if pitch=false."),
2374
+ .describe("Envelope only: when pitch=true, switch from PITCH (comb) to DUCK semantics (notch). Ignored if pitch=false."),
1661
2375
  },
1662
2376
  }, async ({ type, paramId, cellId, lfoIndex, strength, bipolar, pitch, pitchDuck }) => {
1663
2377
  const index = await getParameterIndex();
@@ -1666,7 +2380,7 @@ server.registerTool("add_modulation", {
1666
2380
  return error(formatUnknownParamError(paramId, index, { modulatableOnly: true }));
1667
2381
  if (!param.isModulatable)
1668
2382
  return error(formatNotModulatableError(paramId, index));
1669
- const check = await requireBridge();
2383
+ const check = await requireBridgeForCells(cellId);
1670
2384
  if (check !== true)
1671
2385
  return check;
1672
2386
  // Compute effect-enabled params to auto-enable disabled effects (mirrors UI behavior)
@@ -1705,7 +2419,7 @@ server.registerTool("update_pattern", {
1705
2419
  "For 'pitch'/'slice'/'stretch': [{time, value, duration}]."),
1706
2420
  },
1707
2421
  }, async ({ cellId, type, sequence }) => {
1708
- const check = await requireBridge();
2422
+ const check = await requireBridgeForCells(cellId);
1709
2423
  if (check !== true)
1710
2424
  return check;
1711
2425
  // Map pattern type to state key suffix
@@ -1790,9 +2504,21 @@ server.registerTool("randomize", {
1790
2504
  .max(1)
1791
2505
  .optional()
1792
2506
  .describe("Mutation amount for pattern randomization (0-1). Lower = subtler changes."),
2507
+ density: z
2508
+ .number()
2509
+ .min(-1)
2510
+ .max(1)
2511
+ .optional()
2512
+ .describe("Pattern density for 'sequences'/'full' (-1 = sparse, 0 = neutral, 1 = dense). Omit for the genre and role default."),
2513
+ syncopation: z
2514
+ .number()
2515
+ .min(-1)
2516
+ .max(1)
2517
+ .optional()
2518
+ .describe("Rhythmic placement for 'sequences'/'full' (-1 = strong beats, 0 = neutral, 1 = weaker offbeats). Omit for the genre and role default."),
1793
2519
  },
1794
- }, async ({ target, cellIds, genreHint, bpmHint, mutationAmount }) => {
1795
- const check = await requireBridge();
2520
+ }, async ({ target, cellIds, genreHint, bpmHint, mutationAmount, density, syncopation }) => {
2521
+ const check = await requireBridgeForCells(cellIds);
1796
2522
  if (check !== true)
1797
2523
  return check;
1798
2524
  if (target === "synth") {
@@ -1810,7 +2536,7 @@ server.registerTool("randomize", {
1810
2536
  return text(`Randomized ${target === "lfos" ? "LFO modulation links" : "all modulation (LFOs, envelopes, vary)"}. ` +
1811
2537
  `(Runs in the WebView — if nothing changed, ensure PAM's window is open.)`);
1812
2538
  }
1813
- await bridge.send("randomize", target, cellIds ?? [], genreHint ?? "", bpmHint ?? -1, mutationAmount ?? -1);
2539
+ await bridge.send("randomize", target, cellIds ?? [], genreHint ?? "", bpmHint ?? -1, mutationAmount ?? -1, density ?? null, syncopation ?? null);
1814
2540
  return text(`Randomized ${target}${cellIds ? ` for cells ${cellIds.join(", ")}` : ""}.`);
1815
2541
  });
1816
2542
  server.registerTool("randomize_samples", {
@@ -1824,7 +2550,7 @@ server.registerTool("randomize_samples", {
1824
2550
  genreHint: z
1825
2551
  .string()
1826
2552
  .optional()
1827
- .describe("Genre hint to guide sample selection. Supported: boom_bap (aliases hip-hop, lofi), trap, drill, house, techno, dnb (aliases jungle, breakbeat, breaks). Other values fall back to neutral role-based selection; omit to auto-detect from the library."),
2553
+ .describe("Genre hint to guide sample selection. Supported: boom_bap (aliases hip-hop, lofi), trap, drill, house, techno, dnb (aliases jungle, breakbeat, breaks), idm (aliases braindance, electronica, breakcore). Other values fall back to neutral role-based selection; omit to auto-detect from the library."),
1828
2554
  bpmHint: z
1829
2555
  .number()
1830
2556
  .min(20)
@@ -1839,13 +2565,51 @@ server.registerTool("randomize_samples", {
1839
2565
  .array(z.string())
1840
2566
  .optional()
1841
2567
  .describe("If provided, only pick samples from these paths. Overrides default library scan."),
2568
+ diversify: z
2569
+ .number()
2570
+ .min(0)
2571
+ .max(1)
2572
+ .optional()
2573
+ .describe("Anti-centroid mode (0 = default behaviour, 1 = maximum spread). The native engine scores FOLDER " +
2574
+ "COHERENCE, which is correct for a kit-from-one-pack and is also why repeated randomizes keep landing " +
2575
+ "on the same few well-stocked folders. Above 0 this builds a cross-folder pool first and restricts the " +
2576
+ "engine to it, keeping role assignment while removing its ability to converge. Use it for the DIVERGE " +
2577
+ "step; ignored when allowedSamplePaths is given."),
2578
+ seed: z
2579
+ .number()
2580
+ .int()
2581
+ .min(0)
2582
+ .optional()
2583
+ .describe("Seed for diversify, so a diverged kit can be reproduced. Defaults to 0."),
1842
2584
  },
1843
- }, async ({ cellIds, genreHint, bpmHint, excludedFolders, allowedSamplePaths }) => {
1844
- const check = await requireBridge();
1845
- if (check !== true)
1846
- return check;
1847
- await bridge.send("randomizeSamples", cellIds ?? [], genreHint ?? "", bpmHint ?? -1, excludedFolders ?? [], allowedSamplePaths ?? []);
1848
- return text(`Randomized samples${cellIds ? ` for cells ${cellIds.join(", ")}` : " for all cells"}${genreHint ? ` (genre: ${genreHint})` : ""}.`);
2585
+ }, async ({ cellIds, genreHint, bpmHint, excludedFolders, allowedSamplePaths, diversify, seed }) => {
2586
+ const check = await requireBridgeForCells(cellIds);
2587
+ if (check !== true)
2588
+ return check;
2589
+ let pool = allowedSamplePaths;
2590
+ let diversifyNote = "";
2591
+ if (!pool && diversify && diversify > 0) {
2592
+ const locations = (await bridge.send("getDefaultLocations"));
2593
+ const roots = Object.entries(locations ?? {})
2594
+ .filter(([k, v]) => typeof v === "string" && v.length > 0 && /sample/i.test(k))
2595
+ .map(([, v]) => v);
2596
+ if (roots.length) {
2597
+ const byFolder = await collectLibraryByFolder(roots);
2598
+ const spread = spreadAcrossFolders(byFolder, diversify, seed ?? 0);
2599
+ if (spread.length >= 4) {
2600
+ pool = spread;
2601
+ diversifyNote = ` Diversified: ${spread.length} samples drawn across ${byFolder.size} folders (diversify ${diversify}, seed ${seed ?? 0}).`;
2602
+ }
2603
+ else {
2604
+ diversifyNote = " Diversify found too few samples to build a pool; fell back to the normal library scan.";
2605
+ }
2606
+ }
2607
+ else {
2608
+ diversifyNote = " Diversify could not resolve a sample library root; fell back to the normal library scan.";
2609
+ }
2610
+ }
2611
+ await bridge.send("randomizeSamples", cellIds ?? [], genreHint ?? "", bpmHint ?? -1, excludedFolders ?? [], pool ?? []);
2612
+ return text(`Randomized samples${cellIds ? ` for cells ${cellIds.join(", ")}` : " for all cells"}${genreHint ? ` (genre: ${genreHint})` : ""}.${diversifyNote}`);
1849
2613
  });
1850
2614
  server.registerTool("randomize_patterns", {
1851
2615
  title: "Randomize Patterns",
@@ -1870,13 +2634,28 @@ server.registerTool("randomize_patterns", {
1870
2634
  .min(0)
1871
2635
  .max(1)
1872
2636
  .optional()
1873
- .describe("How much to vary from typical patterns (0 = conventional, 1 = experimental). Default ~0.5."),
2637
+ .describe("How much to vary from typical patterns (0 = conventional, 1 = experimental). Default ~0.5. " +
2638
+ "This is the pattern-side divergence lever: the generator otherwise produces the genre's most typical " +
2639
+ "rhythm, which is exactly what makes a batch of presets feel the same. Push it to 0.7-1.0 during the " +
2640
+ "DIVERGE step, and keep it low only when the brief genuinely wants a stock groove."),
2641
+ density: z
2642
+ .number()
2643
+ .min(-1)
2644
+ .max(1)
2645
+ .optional()
2646
+ .describe("Pattern density (-1 = sparse, 0 = neutral, 1 = dense). Omit for the genre and role default."),
2647
+ syncopation: z
2648
+ .number()
2649
+ .min(-1)
2650
+ .max(1)
2651
+ .optional()
2652
+ .describe("Rhythmic placement (-1 = strong beats, 0 = neutral, 1 = weaker offbeats). Omit for the genre and role default."),
1874
2653
  },
1875
- }, async ({ cellIds, genreHint, bpmHint, mutationAmount }) => {
1876
- const check = await requireBridge();
2654
+ }, async ({ cellIds, genreHint, bpmHint, mutationAmount, density, syncopation }) => {
2655
+ const check = await requireBridgeForCells(cellIds);
1877
2656
  if (check !== true)
1878
2657
  return check;
1879
- await bridge.send("randomize", "sequences", cellIds ?? [], genreHint ?? "", bpmHint ?? -1, mutationAmount ?? -1);
2658
+ await bridge.send("randomize", "sequences", cellIds ?? [], genreHint ?? "", bpmHint ?? -1, mutationAmount ?? -1, density ?? null, syncopation ?? null);
1880
2659
  return text(`Randomized patterns${cellIds ? ` for cells ${cellIds.join(", ")}` : " for all cells"}${genreHint ? ` (genre: ${genreHint})` : ""}.`);
1881
2660
  });
1882
2661
  server.registerTool("load_variation", {
@@ -1967,14 +2746,32 @@ server.registerTool("get_mix_analysis", {
1967
2746
  while (Date.now() - t0 < 120_000) {
1968
2747
  await new Promise((r) => setTimeout(r, 500));
1969
2748
  const status = (await bridge.send("getMixAnalysis"));
1970
- if (status.status !== "running")
1971
- return json(status);
2749
+ if (status.status !== "running") {
2750
+ const silent = silentMixAnalysisMessage(status);
2751
+ return silent ? error(silent) : json(status);
2752
+ }
1972
2753
  }
1973
2754
  return error("Mix analysis timed out after 120 s.");
1974
2755
  }
1975
2756
  const result = await bridge.send("getMixAnalysis");
1976
- return json(result);
2757
+ const silent = silentMixAnalysisMessage(result);
2758
+ return silent ? error(silent) : json(result);
1977
2759
  });
2760
+ function silentMixAnalysisMessage(raw) {
2761
+ if (!isRecord(raw))
2762
+ return null;
2763
+ const analysis = parseMixAnalysisPayload(raw.analysis);
2764
+ if (!analysis || analysis.cells.length === 0)
2765
+ return null;
2766
+ const stems = [...analysis.cells, analysis.master];
2767
+ const silent = stems.every((stem) => stem.barkDb.length > 0 &&
2768
+ stem.onsets === 0 &&
2769
+ (stem.lufsI === undefined || stem.lufsI <= -119) &&
2770
+ stem.barkDb.every((band) => band <= -119));
2771
+ return silent
2772
+ ? "Mix analysis rendered digital silence. No balance or EQ inference is safe from this result; use live playback measurement or retry in a plugin host."
2773
+ : null;
2774
+ }
1978
2775
  // Epoch of the last OFFLINE analysis suggest_mix applied moves from — the
1979
2776
  // stacking guard's memory (live payloads refresh their epoch, so they never
1980
2777
  // stay consumed). Session-scoped by design: a server restart just re-allows a
@@ -2004,6 +2801,9 @@ server.registerTool("suggest_mix", {
2004
2801
  const analysis = parseMixAnalysisPayload(r.analysis);
2005
2802
  if (!analysis)
2006
2803
  return error("Cached mix analysis is malformed — re-run get_mix_analysis with refresh:true.");
2804
+ const silent = silentMixAnalysisMessage(r);
2805
+ if (silent)
2806
+ return error(silent);
2007
2807
  // Stacking guard (mirrors the panel's consumed-epoch gate): the engine's
2008
2808
  // deltas come from the analysis but land on the LIVE params, so suggesting
2009
2809
  // again from an analysis that was already applied doubles the correction.
@@ -2103,7 +2903,7 @@ server.registerTool("save_preset", {
2103
2903
  const check = await requireBridge();
2104
2904
  if (check !== true)
2105
2905
  return check;
2106
- const state = (await bridge.send("getState"));
2906
+ const { state, canonical } = await fetchSaveSnapshot();
2107
2907
  // Bare save (no name): the UI Save button's semantics — overwrite the
2108
2908
  // ACTIVE user preset in place, preserving its on-disk metadata. Mirrors
2109
2909
  // the in-plugin assistant executor (error strings included — the model's
@@ -2168,7 +2968,8 @@ server.registerTool("save_preset", {
2168
2968
  const result = (await bridge.send("savePreset", metadata));
2169
2969
  lastSavedPresetName = name;
2170
2970
  lastSavedPresetPath = result?.path || readPath;
2171
- return text(`Saved preset: ${name}\nPath: ${result?.path || "(unknown path)"}`);
2971
+ return text(`Saved preset: ${name}\nPath: ${result?.path || "(unknown path)"}` +
2972
+ hostedPluginSaveWarning(state, canonical));
2172
2973
  });
2173
2974
  server.registerTool("save_variation", {
2174
2975
  title: "Save Variation",
@@ -2201,8 +3002,8 @@ server.registerTool("save_variation", {
2201
3002
  const check = await requireBridge();
2202
3003
  if (check !== true)
2203
3004
  return check;
2204
- // 1. Get current full state and build variation snapshot
2205
- const state = (await bridge.send("getState"));
3005
+ // 1. Canonical save snapshot (engine + hosted-plugin state), then shape it
3006
+ const { state, canonical } = await fetchSaveSnapshot();
2206
3007
  const variationString = buildVariationString(state);
2207
3008
  // 2. Determine preset name and disk path — needed to read existing variations and metadata
2208
3009
  let presetName = name;
@@ -2267,7 +3068,8 @@ server.registerTool("save_variation", {
2267
3068
  return text(`Saved variation ${index} for "${presetName}"\n` +
2268
3069
  `Path: ${result?.path || "(unknown path)"}\n` +
2269
3070
  `Filled slots: ${variations.filter((v) => v !== null).length}/8` +
2270
- resultSuffix);
3071
+ resultSuffix +
3072
+ hostedPluginSaveWarning(state, canonical));
2271
3073
  });
2272
3074
  server.registerTool("update_preset_metadata", {
2273
3075
  title: "Update Preset Metadata",
@@ -2362,7 +3164,7 @@ server.registerTool("trim_sample", {
2362
3164
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
2363
3165
  },
2364
3166
  }, async ({ cellId }) => {
2365
- const check = await requireBridge();
3167
+ const check = await requireBridgeForCells(cellId);
2366
3168
  if (check !== true)
2367
3169
  return check;
2368
3170
  await bridge.send("trimSampleForCell", cellId);
@@ -2375,7 +3177,7 @@ server.registerTool("reverse_sample", {
2375
3177
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
2376
3178
  },
2377
3179
  }, async ({ cellId }) => {
2378
- const check = await requireBridge();
3180
+ const check = await requireBridgeForCells(cellId);
2379
3181
  if (check !== true)
2380
3182
  return check;
2381
3183
  await bridge.send("reverseSampleForCell", cellId);
@@ -2400,13 +3202,16 @@ server.registerTool("connection_status", {
2400
3202
  },
2401
3203
  annotations: { readOnlyHint: true },
2402
3204
  }, async () => {
2403
- const instances = await bridge.discover();
3205
+ const discoveredInstances = await bridge.discover();
3206
+ const instances = IS_MFX_SERVER
3207
+ ? discoveredInstances.filter((instance) => instance.product?.trim().toLowerCase() === "mfx")
3208
+ : discoveredInstances;
2404
3209
  if (instances.length === 0) {
2405
3210
  return structured({
2406
3211
  instances: [],
2407
3212
  connected: false,
2408
- note: "No running PAM instances found. To use live tools, PAM needs the MCP bridge enabled: " +
2409
- "start PAM (standalone or in a DAW) and the bridge will write its port to ~/.pam-mcp-bridge.json. " +
3213
+ note: `No running ${SERVER_PRODUCT.name} instances found. To use live tools, ${SERVER_PRODUCT.name} needs the MCP bridge enabled: ` +
3214
+ `start ${SERVER_PRODUCT.name} (standalone or in a DAW) and enable it in Settings → Experimental. ` +
2410
3215
  "Offline tools (get_parameter_info, list_presets, read_preset) work without a connection.",
2411
3216
  });
2412
3217
  }
@@ -2419,8 +3224,8 @@ server.registerTool("connection_status", {
2419
3224
  connectedTo: bridge.getConnectedInstance(),
2420
3225
  note: connected
2421
3226
  ? instances.length > 1
2422
- ? "Connected. Multiple instances are running — use select_instance to drive a specific product."
2423
- : "Connected to PAM. All tools available."
3227
+ ? `Connected. Multiple ${SERVER_PRODUCT.name} instances are running; the launcher uses the first available instance.`
3228
+ : `Connected to ${SERVER_PRODUCT.name}. All advertised tools are available.`
2424
3229
  : "Found instances but could not connect. The plugin may have shut down.",
2425
3230
  });
2426
3231
  });
@@ -2445,9 +3250,16 @@ server.registerTool("select_instance", {
2445
3250
  if (!product && !instanceId && port === undefined) {
2446
3251
  return error("Pass at least one of product, instanceId, or port. Call connection_status to list running instances.");
2447
3252
  }
2448
- const ok = await bridge.connect({ product, instanceId, port });
3253
+ if (IS_MFX_SERVER && product && product.trim().toLowerCase() !== "mfx") {
3254
+ return error("The mfx-mcp launcher can select MFX instances only.");
3255
+ }
3256
+ const targetProduct = IS_MFX_SERVER ? "MFX" : product;
3257
+ const ok = await bridge.connect({ product: targetProduct, instanceId, port });
2449
3258
  if (!ok) {
2450
- const instances = await bridge.discover();
3259
+ const discovered = await bridge.discover();
3260
+ const instances = IS_MFX_SERVER
3261
+ ? discovered.filter((instance) => instance.product?.trim().toLowerCase() === "mfx")
3262
+ : discovered;
2451
3263
  const listing = instances.length
2452
3264
  ? ` Running instances: ${instances
2453
3265
  .map((i) => `${i.product} (port ${i.port}, id ${i.instanceId})`)
@@ -2504,6 +3316,69 @@ async function searchSampleLibrary(rootPath, term, audioOnly) {
2504
3316
  }
2505
3317
  return matches;
2506
3318
  }
3319
+ // Walk the library and return audio files grouped by their parent folder.
3320
+ // Bounded like searchSampleLibrary — this runs inside a tool call, not a job.
3321
+ async function collectLibraryByFolder(roots, maxDirReads = 120) {
3322
+ const byFolder = new Map();
3323
+ const queue = roots.map((path) => ({ path, depth: 0 }));
3324
+ const options = JSON.stringify({ audioOnly: true, sort: "name" });
3325
+ let dirReads = 0;
3326
+ while (queue.length && dirReads < maxDirReads) {
3327
+ const { path, depth } = queue.shift();
3328
+ dirReads += 1;
3329
+ let entries = [];
3330
+ try {
3331
+ const res = await bridge.send("listDirectory", path, options);
3332
+ if (Array.isArray(res))
3333
+ entries = res;
3334
+ }
3335
+ catch {
3336
+ continue;
3337
+ }
3338
+ for (const e of entries) {
3339
+ if (typeof e.path !== "string")
3340
+ continue;
3341
+ if (e.isDir) {
3342
+ if (depth < 3)
3343
+ queue.push({ path: e.path, depth: depth + 1 });
3344
+ }
3345
+ else {
3346
+ const list = byFolder.get(path) ?? [];
3347
+ list.push(e.path);
3348
+ byFolder.set(path, list);
3349
+ }
3350
+ }
3351
+ }
3352
+ return byFolder;
3353
+ }
3354
+ // Build a sample pool that deliberately SPANS folders instead of collapsing
3355
+ // into one pack.
3356
+ //
3357
+ // WHY: randomize_samples' native engine scores folder coherence, which is the
3358
+ // right default (a kit from one pack sounds like a kit) and the wrong one when
3359
+ // the brief is "something we haven't heard" — coherence pulls every result
3360
+ // toward the same few well-stocked folders. Restricting the engine to a
3361
+ // cross-folder pool keeps its role assignment while removing its ability to
3362
+ // converge. Deterministic given the same seed, so a diverged kit can be
3363
+ // reproduced.
3364
+ function spreadAcrossFolders(byFolder, diversify, seed) {
3365
+ const folders = [...byFolder.keys()].sort();
3366
+ if (!folders.length)
3367
+ return [];
3368
+ // At diversify 1 every folder contributes a single file (maximum spread); at
3369
+ // low values a few folders contribute many (close to normal behaviour).
3370
+ const perFolder = Math.max(1, Math.round(12 - 11 * diversify));
3371
+ const folderCount = Math.max(1, Math.min(folders.length, Math.round(2 + (folders.length - 2) * diversify)));
3372
+ const start = folders.length ? seed % folders.length : 0;
3373
+ const pool = [];
3374
+ for (let i = 0; i < folderCount; i += 1) {
3375
+ const files = byFolder.get(folders[(start + i) % folders.length]) ?? [];
3376
+ for (let j = 0; j < Math.min(perFolder, files.length); j += 1) {
3377
+ pool.push(files[(seed + j * 7) % files.length]);
3378
+ }
3379
+ }
3380
+ return [...new Set(pool)];
3381
+ }
2507
3382
  // Search-result payloads feed the assistant's 256-token continuation round —
2508
3383
  // trim to that budget instead of letting the listing collapse to an opaque
2509
3384
  // truncation string (executor parity: assistantExecutor.ts fitListing).
@@ -2896,6 +3771,10 @@ server.registerTool("video_command", {
2896
3771
  "moveLayer{layerId,toIndex}, duplicateLayer{layerId}, renameLayer{layerId,name}, " +
2897
3772
  "setLayerEnabled{layerId,enabled?}, toggleSolo{layerId}, selectLayer{layerId}, " +
2898
3773
  "resetLayer{layerId}, applyLayout{preset}, randomizeLayer{layerId}; " +
3774
+ "setShaderSource{layerId,source} (GLSL fragment for a 'shader' layer; auto-parses " +
3775
+ "'// @param uP_x 0..1 = 0.5 \"Label\"' rows into sliders/bind targets; read " +
3776
+ "layers[].shaderCompile in get_video_state for the compile ok/infoLog and repair), " +
3777
+ "setShaderParam{layerId,paramKey,value}; " +
2899
3778
  "addLayerFx{layerId,fxType,params?,beforeFxId?}, removeLayerFx{layerId,fxId}, " +
2900
3779
  "removeAllLayerFx{layerId}, setLayerFxParam{layerId,fxId,param,value|params}, " +
2901
3780
  "setLayerFxEnabled{layerId,fxId,enabled?}, reorderLayerFx{layerId,fxIds}, " +
@@ -2938,8 +3817,8 @@ server.registerTool("add_video_layer", {
2938
3817
  "update_video_layer and stack FX with add_video_fx.",
2939
3818
  inputSchema: {
2940
3819
  kind: z
2941
- .enum(["video", "image", "camera", "text", "drawing", "model3d", "adjustment"])
2942
- .describe("Layer kind."),
3820
+ .enum(["video", "image", "camera", "text", "drawing", "model3d", "shader", "adjustment"])
3821
+ .describe("Layer kind. 'shader' is a user-GLSL fragment layer — write its source with video_command {op:'setShaderSource'}."),
2943
3822
  name: z.string().optional().describe("Display name (auto-generated if omitted)."),
2944
3823
  insertAt: z
2945
3824
  .number()
@@ -3578,7 +4457,8 @@ server.registerTool("reset_patterns", {
3578
4457
  });
3579
4458
  server.registerTool("update_effect_config", {
3580
4459
  title: "Update Effect Config",
3581
- description: "Toggle cell effects on/off (filter, delay, crusher, flanger, eq, fdn, gate, compressor). " +
4460
+ description: "Toggle cell effects on/off (filter, delay, crusher, flanger, chorus, phaser, eq, eq8, tablefilter, fdn, gate, compressor, panner, gain, vocoder, ringmod, vinylbrake, transient, repeat, pitchshift, limiter, qverb, shimmer, wavefold, resonator). " +
4461
+ "Wavefold and Resonator ship in every product. Exact availability comes from the selected product manifest. " +
3582
4462
  "Uses the lighter KV path that does not rebuild the audio graph. Pass 1 to enable, 0 to disable.",
3583
4463
  inputSchema: {
3584
4464
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
@@ -3590,6 +4470,29 @@ server.registerTool("update_effect_config", {
3590
4470
  fdnEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3591
4471
  gateEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3592
4472
  compressorEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4473
+ // Shared extended effects. These ship on PAM/Cell1 too, so leaving them out of the
4474
+ // schema made them unreachable over the bridge even though the handler is
4475
+ // generic (it just builds `cell{N}_{key}`) and the state vars exist in
4476
+ // build.js alongside the eight above.
4477
+ eq8Enabled: z.union([z.literal(0), z.literal(1)]).optional(),
4478
+ pannerEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4479
+ gainEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4480
+ vocoderEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4481
+ ringmodEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4482
+ vinylbrakeEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4483
+ transientEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4484
+ repeatEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4485
+ pitchshiftEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4486
+ limiterEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4487
+ qverbEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4488
+ chorusEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4489
+ phaserEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4490
+ shimmerEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4491
+ tablefilterEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4492
+ // Product-specific extended effects. The handler rejects flags absent from
4493
+ // the connected product instead of relying on the bridge to ignore them.
4494
+ wavefoldEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4495
+ resonatorEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3593
4496
  },
3594
4497
  }, async ({ cellId, ...config }) => {
3595
4498
  const check = await requireBridge();
@@ -3599,10 +4502,20 @@ server.registerTool("update_effect_config", {
3599
4502
  const cellCheck = validateCellIdForManifest(cellId, manifest);
3600
4503
  if (cellCheck !== true)
3601
4504
  return cellCheck;
4505
+ if (!manifest) {
4506
+ return error("The selected product manifest is unavailable; effect availability cannot be validated safely.");
4507
+ }
4508
+ const availableEffects = new Set(cellEffectNames(manifest));
3602
4509
  const delta = {};
3603
4510
  for (const [key, value] of Object.entries(config)) {
3604
- if (value !== undefined)
3605
- delta[`cell${cellId}_${key}`] = value;
4511
+ if (value === undefined)
4512
+ continue;
4513
+ const effect = key.endsWith("Enabled") ? key.slice(0, -"Enabled".length) : key;
4514
+ if (!availableEffects.has(effect)) {
4515
+ return error(`${effect} is not available in ${manifest.name ?? "the selected product"}. ` +
4516
+ `Available effects: ${[...availableEffects].join(", ")}.`);
4517
+ }
4518
+ delta[`cell${cellId}_${key}`] = value;
3606
4519
  }
3607
4520
  if (Object.keys(delta).length === 0) {
3608
4521
  return error("At least one effect flag must be provided.");
@@ -3613,6 +4526,356 @@ server.registerTool("update_effect_config", {
3613
4526
  .join(", ");
3614
4527
  return text(`Updated effect config for cell ${cellId}: ${summary}`);
3615
4528
  });
4529
+ // MFX cable patchbay. `mfx_routing` is a non-numeric state variable, so it was
4530
+ // unreachable through set_parameters (numbers only) — an agent could shape an MFX
4531
+ // cell but never patch one to another, which is the whole product.
4532
+ //
4533
+ // Validation mirrors parseRoutingEdges (interface/src/dsp/mfxRouting.ts): the
4534
+ // plugin re-validates on receipt, so this pass exists to fail loudly here rather
4535
+ // than have a typo silently drop a cable.
4536
+ const MFX_ROUTING_SOURCE_HINT = "'input', 'sidechain', 'split_lo' / 'split_mid' / 'split_hi', or 'cellN'. " +
4537
+ "'output' and 'split' are destinations only.";
4538
+ const MFX_ROUTING_SINK_HINT = "'output', 'split', or 'cellN'. 'input', 'sidechain' and the three split bands are sources only.";
4539
+ // SPLIT — the three-band crossover patch node. One sink, three band sources.
4540
+ const MFX_SPLIT_SINK = "split";
4541
+ const MFX_SPLIT_BANDS = ["split_lo", "split_mid", "split_hi"];
4542
+ const isMfxSplitBand = (port) => MFX_SPLIT_BANDS.includes(port);
4543
+ function mfxPortCell(port) {
4544
+ const m = /^cell([1-9]\d*)$/.exec(port); // canonical form only — 'cell01' is not a port
4545
+ return m ? Number(m[1]) : null;
4546
+ }
4547
+ function validateMfxEdges(edges, maxCells) {
4548
+ const seen = new Set();
4549
+ const out = [];
4550
+ const inRange = (port) => {
4551
+ const c = mfxPortCell(port);
4552
+ return c !== null && c >= 1 && c <= maxCells;
4553
+ };
4554
+ for (const { from, to } of edges) {
4555
+ if (from !== "input" && from !== "sidechain" && !isMfxSplitBand(from) && !inRange(from)) {
4556
+ return { ok: false, reason: `invalid source port '${from}' — expected ${MFX_ROUTING_SOURCE_HINT}` };
4557
+ }
4558
+ if (to !== "output" && to !== MFX_SPLIT_SINK && !inRange(to)) {
4559
+ return { ok: false, reason: `invalid destination port '${to}' — expected ${MFX_ROUTING_SINK_HINT}` };
4560
+ }
4561
+ // Only a source ENDPOINT may feed the crossover. Mirrors parseRoutingEdges: SPLIT sits
4562
+ // outside the cell evaluation order, so a cell→split cable would put it inside a cycle
4563
+ // the plugin's resolver cannot see. The plugin drops such a cable silently; failing here
4564
+ // is what tells the agent why its patch came back short.
4565
+ if (to === MFX_SPLIT_SINK && from !== "input" && from !== "sidechain") {
4566
+ return {
4567
+ ok: false,
4568
+ reason: `'${from}' cannot feed '${MFX_SPLIT_SINK}' — only 'input' and 'sidechain' can. ` +
4569
+ "Split what arrives, then patch the bands.",
4570
+ };
4571
+ }
4572
+ const key = `${from}->${to}`;
4573
+ if (seen.has(key))
4574
+ continue; // dedupe, matching the plugin
4575
+ seen.add(key);
4576
+ out.push({ from, to });
4577
+ }
4578
+ return { ok: true, edges: out };
4579
+ }
4580
+ server.registerTool("set_mfx_routing", {
4581
+ title: "Set MFX Cable Routing",
4582
+ description: "Read or replace the MFX cable patch — the graph that decides which cell feeds which. MFX-only; on PAM/Cell1 " +
4583
+ "this returns a product error.\n\n" +
4584
+ "Ports: 'input' (the host's audio in) and 'sidechain' are sources; 'output' is the destination; 'cellN' is both. " +
4585
+ "A self-edge cellN→cellN is a feedback loop, and input→output is a direct dry path. Duplicate cables are dropped.\n\n" +
4586
+ "SPLIT is a three-band crossover node: cable 'input' (or 'sidechain') to 'split', then patch 'split_lo', " +
4587
+ "'split_mid' and 'split_hi' to different cells. Each band then has its own full FX rack, its own modulation " +
4588
+ "and its own sidechain key — which is how you build a multiband gate or a multiband distortion here. Only " +
4589
+ "'input' and 'sidechain' may feed 'split'; the bands are sources only. Its two corner frequencies are the " +
4590
+ "ordinary parameters mfx_split_lo_hz and mfx_split_hi_hz (set_parameters).\n\n" +
4591
+ "A plain serial chain on 4 cells is input→cell1, cell1→cell2, cell2→cell3, cell3→cell4, cell4→output. " +
4592
+ "Parallel is input→each cell plus each cell→output.\n\n" +
4593
+ "'set' REPLACES the whole patch, so send the full cable list. Per-cable levels live in mfx_edge_gains and are " +
4594
+ "left alone; a new cable takes the graph's own default (unity forward, 0.5 on a back or self edge). " +
4595
+ "Re-patching rebuilds the audio graph, like reordering FX.",
4596
+ inputSchema: {
4597
+ op: z.enum(["get", "set"]).describe("'get' reads the current cables; 'set' replaces them."),
4598
+ edges: z
4599
+ .array(z.object({
4600
+ from: z.string().describe(`Source port: ${MFX_ROUTING_SOURCE_HINT}`),
4601
+ to: z.string().describe(`Destination port: ${MFX_ROUTING_SINK_HINT}`),
4602
+ }))
4603
+ .optional()
4604
+ .describe("Full cable list for 'set'. An empty array clears the patch (silence)."),
4605
+ },
4606
+ annotations: { destructiveHint: false, idempotentHint: true },
4607
+ }, async ({ op, edges }) => {
4608
+ const check = await requireBridge();
4609
+ if (check !== true)
4610
+ return check;
4611
+ const manifest = await loadManifestForProductGuard();
4612
+ if (manifest && !isMfxManifest(manifest)) {
4613
+ return error(`set_mfx_routing is MFX-only. ${manifest.name ?? "This product"} has no cable patchbay — ` +
4614
+ "its cells run in a fixed per-cell chain.");
4615
+ }
4616
+ if (op === "get") {
4617
+ const raw = await bridge.send("getState", "mfx_routing");
4618
+ return json(isRecord(raw) ? raw.mfx_routing ?? raw : raw);
4619
+ }
4620
+ if (!edges)
4621
+ return error("op 'set' requires an `edges` array (pass [] to clear the patch).");
4622
+ const maxCells = manifestMaxCellId(manifest) ?? 4;
4623
+ const validated = validateMfxEdges(edges, maxCells);
4624
+ if (!validated.ok) {
4625
+ return error(`${validated.reason}. This product exposes cells 1..${maxCells}.`);
4626
+ }
4627
+ // mfx_routing is a GRAPH_RENDER state var — a re-patch must rebuild the graph.
4628
+ await applyKv({ mfx_routing: validated.edges }, KV_OPTS_STANDARD);
4629
+ const summary = validated.edges.map((e) => `${e.from}→${e.to}`).join(", ");
4630
+ return text(`MFX routing set: ${validated.edges.length} cable(s)${summary ? ` — ${summary}` : " (patch cleared)"}.`);
4631
+ });
4632
+ server.registerTool("set_mfx_edge_gain", {
4633
+ title: "Set MFX Cable Gain",
4634
+ description: "Read or set the gain for one cable in the current MFX patch. Gain is linear: 0 is silent, 1 is unity, " +
4635
+ "and 1.5 is the maximum. The cable must already exist. Other cable gains are preserved.",
4636
+ inputSchema: {
4637
+ op: z.enum(["get", "set"]),
4638
+ from: z.string().optional().describe(`Cable source for 'set': ${MFX_ROUTING_SOURCE_HINT}`),
4639
+ to: z.string().optional().describe(`Cable destination for 'set': ${MFX_ROUTING_SINK_HINT}`),
4640
+ gain: z.number().min(0).max(1.5).optional().describe("Linear gain for 'set'."),
4641
+ },
4642
+ }, async ({ op, from, to, gain }) => {
4643
+ const check = await requireBridge();
4644
+ if (check !== true)
4645
+ return check;
4646
+ const manifest = await loadManifestForProductGuard();
4647
+ if (!manifest || !isMfxManifest(manifest))
4648
+ return error("set_mfx_edge_gain is MFX-only.");
4649
+ const stateRaw = await bridge.send("getState");
4650
+ const state = isRecord(stateRaw) ? stateRaw : {};
4651
+ const gains = isRecord(state.mfx_edge_gains) ? { ...state.mfx_edge_gains } : {};
4652
+ if (op === "get")
4653
+ return json(gains);
4654
+ if (!from || !to || typeof gain !== "number")
4655
+ return error("op 'set' requires from, to, and gain.");
4656
+ const validated = validateMfxEdges([{ from, to }], manifestMaxCellId(manifest) ?? 4);
4657
+ if (!validated.ok)
4658
+ return error(validated.reason);
4659
+ const edges = Array.isArray(state.mfx_routing) ? state.mfx_routing : [];
4660
+ const exists = edges.some((edge) => isRecord(edge) && edge.from === from && edge.to === to);
4661
+ if (!exists)
4662
+ return error(`Cable ${from}→${to} is not in the current patch. Add it with set_mfx_routing first.`);
4663
+ const key = `${from}__${to}`;
4664
+ gains[key] = gain;
4665
+ await applyKv({ mfx_edge_gains: gains }, KV_OPTS_EFFECT_CONFIG);
4666
+ return text(`MFX cable gain set: ${from}→${to} = ${gain}.`);
4667
+ });
4668
+ const MFX_COMBO_TARGET_INPUT = z.object({
4669
+ paramId: z.string(),
4670
+ from: z.number().optional(),
4671
+ mid: z.number().optional(),
4672
+ to: z.number().optional(),
4673
+ });
4674
+ server.registerTool("configure_mfx_combo", {
4675
+ title: "Configure MFX Combo",
4676
+ description: "Read, edit, or move one of MFX's three performance combos. Automatic assignments follow the audible patch. " +
4677
+ "Manual target edits materialize and lock the assignment. setValue writes the combo parameter and its real targets " +
4678
+ "in one native state update, so it works with the MFX window closed.",
4679
+ inputSchema: {
4680
+ op: z.enum([
4681
+ "get", "setValue", "rename", "lock", "unlock", "rescan", "setTargets", "addTarget",
4682
+ "removeTarget", "clearTargets", "invertTarget", "setTargetStop", "anchor",
4683
+ ]),
4684
+ index: z.number().int().min(1).max(3).default(1),
4685
+ value: z.number().min(-100).max(100).optional().describe("Combo value for setValue, or target value for setTargetStop."),
4686
+ name: z.string().max(24).optional().describe("New label for rename; blank restores the role label."),
4687
+ paramId: z.string().optional().describe("Target parameter for add/remove/invert/setTargetStop."),
4688
+ stop: z.enum(["from", "mid", "to"]).optional().describe("Target stop edited by setTargetStop."),
4689
+ targets: z.array(MFX_COMBO_TARGET_INPUT).max(6).optional().describe("Complete target list for setTargets."),
4690
+ },
4691
+ }, async ({ op, index, value, name, paramId, stop, targets }) => {
4692
+ const check = await requireBridge();
4693
+ if (check !== true)
4694
+ return check;
4695
+ const manifest = await loadManifest();
4696
+ if (!isMfxManifest(manifest))
4697
+ return error("configure_mfx_combo is MFX-only.");
4698
+ const stateRaw = await bridge.send("getState");
4699
+ const state = isRecord(stateRaw) ? stateRaw : {};
4700
+ const resolved = resolveServerMfxCombos(state, manifest);
4701
+ const combo = resolved[index - 1];
4702
+ const slots = parseMfxComboSlots(state.mfx_combos);
4703
+ const stored = slots[String(index)] ?? {};
4704
+ const parameterIndex = new Map(manifest.parameters.map((parameter) => [parameter.paramId, parameter]));
4705
+ if (op === "get")
4706
+ return json({ combos: resolved.map((entry) => ({ ...entry, value: state[`mfx_combo${entry.index}`] })) });
4707
+ if (op === "setValue") {
4708
+ if (typeof value !== "number")
4709
+ return error("setValue requires value.");
4710
+ const delta = { [`mfx_combo${index}`]: value };
4711
+ for (const target of combo.targets) {
4712
+ const info = parameterIndex.get(target.id);
4713
+ if (!info)
4714
+ continue;
4715
+ const previousComboValue = state[`mfx_combo${index}`];
4716
+ const currentTargetValue = state[target.id];
4717
+ delta[target.id] = mfxComboTargetValueFromCurrent(target, typeof previousComboValue === "number" ? previousComboValue : 0, value, info, typeof currentTargetValue === "number" ? currentTargetValue : info.defaultValue);
4718
+ }
4719
+ await applyKv(delta, KV_OPTS_STANDARD);
4720
+ return json({ combo: index, value, targetsWritten: combo.targets.map((target) => target.id) });
4721
+ }
4722
+ const materialized = () => ({ ...stored, targets: [...combo.targets] });
4723
+ let next = stored;
4724
+ if (op === "rename") {
4725
+ if (name === undefined)
4726
+ return error("rename requires name.");
4727
+ next = { ...stored };
4728
+ const trimmed = name.trim().slice(0, 24);
4729
+ if (trimmed)
4730
+ next.name = trimmed;
4731
+ else
4732
+ delete next.name;
4733
+ }
4734
+ else if (op === "lock") {
4735
+ next = { ...materialized(), locked: 1 };
4736
+ }
4737
+ else if (op === "unlock" || op === "rescan") {
4738
+ next = stored.name ? { name: stored.name } : null;
4739
+ }
4740
+ else {
4741
+ let edited = materialized();
4742
+ if (op === "setTargets") {
4743
+ if (!targets)
4744
+ return error("setTargets requires targets.");
4745
+ const built = [];
4746
+ for (const target of targets) {
4747
+ const info = parameterIndex.get(target.paramId);
4748
+ if (!info || !isMfxComboTargetParameter(info)) {
4749
+ return error(`'${target.paramId}' is not an available MFX combo target.`);
4750
+ }
4751
+ if (built.some((candidate) => candidate.id === target.paramId)) {
4752
+ return error(`'${target.paramId}' appears more than once in targets.`);
4753
+ }
4754
+ const current = state[target.paramId];
4755
+ let builtTarget = buildMfxComboTarget(target.paramId, info, MFX_COMBO_MANUAL_STRENGTH, 1, typeof current === "number" ? current : info.defaultValue);
4756
+ for (const targetStop of ["from", "mid", "to"]) {
4757
+ const targetValue = target[targetStop];
4758
+ if (typeof targetValue === "number")
4759
+ builtTarget = setMfxComboTargetStop(builtTarget, info, targetStop, targetValue);
4760
+ }
4761
+ built.push(builtTarget);
4762
+ }
4763
+ edited.targets = built;
4764
+ }
4765
+ else if (op === "clearTargets") {
4766
+ edited.targets = [];
4767
+ }
4768
+ else if (op === "anchor") {
4769
+ edited.targets = edited.targets.map((target) => {
4770
+ const info = parameterIndex.get(target.id);
4771
+ const current = state[target.id];
4772
+ return info && typeof current === "number" ? reanchorMfxComboTarget(target, info, current) : target;
4773
+ });
4774
+ }
4775
+ else {
4776
+ if (!paramId)
4777
+ return error(`${op} requires paramId.`);
4778
+ const info = parameterIndex.get(paramId);
4779
+ if (!info || !isMfxComboTargetParameter(info)) {
4780
+ return error(`'${paramId}' is not an available MFX combo target.`);
4781
+ }
4782
+ if (op === "addTarget") {
4783
+ if (!edited.targets.some((target) => target.id === paramId)) {
4784
+ if (edited.targets.length >= 6)
4785
+ return error("A combo can drive at most 6 targets.");
4786
+ const current = state[paramId];
4787
+ edited.targets.push(buildMfxComboTarget(paramId, info, MFX_COMBO_MANUAL_STRENGTH, 1, typeof current === "number" ? current : info.defaultValue));
4788
+ }
4789
+ }
4790
+ else if (op === "removeTarget") {
4791
+ edited.targets = edited.targets.filter((target) => target.id !== paramId);
4792
+ }
4793
+ else if (op === "invertTarget") {
4794
+ edited.targets = edited.targets.map((target) => target.id === paramId
4795
+ ? { id: target.id, from: target.to, mid: target.mid, to: target.from }
4796
+ : target);
4797
+ }
4798
+ else if (op === "setTargetStop") {
4799
+ if (!stop || typeof value !== "number")
4800
+ return error("setTargetStop requires paramId, stop, and value.");
4801
+ if (!edited.targets.some((target) => target.id === paramId)) {
4802
+ return error(`'${paramId}' is not assigned to combo ${index}.`);
4803
+ }
4804
+ edited.targets = edited.targets.map((target) => target.id === paramId
4805
+ ? setMfxComboTargetStop(target, info, stop, value)
4806
+ : target);
4807
+ }
4808
+ }
4809
+ next = { ...edited, locked: 1 };
4810
+ }
4811
+ const update = {
4812
+ name: next?.name ?? null,
4813
+ locked: next?.locked === 1 ? 1 : 0,
4814
+ targets: Array.isArray(next?.targets) ? next.targets : null,
4815
+ };
4816
+ await applyKv({ mfx_combos: { ...slots, [String(index)]: update } }, KV_OPTS_EFFECT_CONFIG);
4817
+ return json({ combo: index, slot: update });
4818
+ });
4819
+ server.registerTool("set_effect_order", {
4820
+ title: "Set Cell FX Chain Order",
4821
+ description: "Read or replace one cell's FX chain order. cellN_effectOrder is a JSON string array, not a numeric " +
4822
+ "parameter, so set_parameters cannot reach it — this tool owns it (the set_mfx_routing precedent).\n\n" +
4823
+ "The available stages and their fresh-cell order are product-specific and come from the selected manifest; " +
4824
+ "design_context reports that default order. 'set' REPLACES the chain, so send every listed stage " +
4825
+ "in the order you want it to process; the array must be a permutation, not a subset (use the per-effect " +
4826
+ "*Enabled flags via update_effect_config to switch stages off).\n\n" +
4827
+ "Order is audible and is a cheap way to make two otherwise identical patches sound different: filter before " +
4828
+ "delay smears the resonance into the repeats, delay before filter darkens the repeats instead. Crusher " +
4829
+ "before filter grinds then tames; filter before crusher grinds only what survived. Reordering rebuilds the " +
4830
+ "cell's graph.",
4831
+ inputSchema: {
4832
+ cellId: z.number().int().min(1).max(8).describe("Cell to read or reorder."),
4833
+ op: z.enum(["get", "set"]).default("set").describe("'get' reads the current chain; 'set' replaces it."),
4834
+ order: z
4835
+ .array(z.string().min(1))
4836
+ .optional()
4837
+ .describe("Full product-specific chain order for 'set' — a permutation of every stage reported by design_context."),
4838
+ },
4839
+ annotations: { destructiveHint: false, idempotentHint: true },
4840
+ }, async ({ cellId, op, order }) => {
4841
+ const check = await requireBridgeForCells(cellId);
4842
+ if (check !== true)
4843
+ return check;
4844
+ const key = `cell${cellId}_effectOrder`;
4845
+ if (op === "get") {
4846
+ const raw = await bridge.send("getState", key);
4847
+ return json(isRecord(raw) ? raw[key] ?? raw : raw);
4848
+ }
4849
+ if (!order)
4850
+ return error("op 'set' requires an `order` array.");
4851
+ const manifest = await loadManifestForProductGuard();
4852
+ if (!manifest) {
4853
+ return error("The selected product manifest is unavailable; its complete FX chain cannot be validated safely.");
4854
+ }
4855
+ const expectedOrder = cellEffectOrder(manifest);
4856
+ if (expectedOrder.length === 0) {
4857
+ return error(`${manifest.name ?? "The selected product"} manifest has no valid cell1_effectOrder default.`);
4858
+ }
4859
+ const seen = new Set(order);
4860
+ if (seen.size !== order.length) {
4861
+ const dupes = order.filter((s, i) => order.indexOf(s) !== i);
4862
+ return error(`Duplicate stage(s) in order: ${[...new Set(dupes)].join(", ")}.`);
4863
+ }
4864
+ // A subset would silently drop stages from the chain rather than bypass
4865
+ // them, so it is rejected here instead of at the plugin.
4866
+ const expected = new Set(expectedOrder);
4867
+ const unknown = order.filter((stage) => !expected.has(stage));
4868
+ const missing = expectedOrder.filter((stage) => !seen.has(stage));
4869
+ if (unknown.length || missing.length) {
4870
+ return error(`order must be a full ${manifest.name ?? "product"} permutation` +
4871
+ `${unknown.length ? ` — unknown: ${[...new Set(unknown)].join(", ")}` : ""}` +
4872
+ `${missing.length ? ` — missing: ${missing.join(", ")}` : ""}. ` +
4873
+ "To bypass a stage set its *Enabled flag via update_effect_config; leaving it out of the chain is not the same thing.");
4874
+ }
4875
+ // effectOrder rebuilds the cell's FX graph, like an MFX re-patch.
4876
+ await applyKv({ [key]: order }, KV_OPTS_STANDARD);
4877
+ return text(`Cell ${cellId} FX chain: ${order.join(" → ")}.`);
4878
+ });
3616
4879
  server.registerTool("update_cell_config", {
3617
4880
  title: "Update Cell Config",
3618
4881
  description: "Update cell-level configuration properties like polyphony, MIDI settings, varispeed, and modulation latch. " +
@@ -3640,7 +4903,24 @@ server.registerTool("update_cell_config", {
3640
4903
  .max(2)
3641
4904
  .optional()
3642
4905
  .describe("Slice playback length when sliceKeymapMode=1: 0=Boundary (stop at the next slice edge), 1=Sample End (play to end of sample), 2=Note Off (sustain until key release; requires the cell to be in Gate mode)."),
3643
- scaleName: z.string().optional().describe("Scale name for pitch sequencer quantization (e.g. 'chromatic', 'major', 'minor')."),
4906
+ scaleName: z
4907
+ .enum([
4908
+ "chromatic",
4909
+ "major",
4910
+ "minor",
4911
+ "pentatonicMajor",
4912
+ "pentatonicMinor",
4913
+ "dorian",
4914
+ "mixolydian",
4915
+ "blues",
4916
+ "harmonicMinor",
4917
+ "phrygian",
4918
+ "lydian",
4919
+ "locrian",
4920
+ ])
4921
+ .optional()
4922
+ .describe("Scale name for pitch sequencer / note quantization ('chromatic' = no filter). " +
4923
+ "An out-of-vocabulary string would silently play as chromatic while junk persists into the preset, so the name is validated."),
3644
4924
  scaleRoot: z.number().min(0).max(11).optional().describe("Root note of the scale (0=C, 1=C#, ..., 11=B)."),
3645
4925
  pitchMode: z.number().int().min(0).max(3).optional().describe("Pitch engine: 0=Varispeed, 1=Formant, 2=Pitch Shift Quantize, 3=Varispeed Quantize."),
3646
4926
  stretchMode: z.number().int().min(0).max(3).optional().describe("Stretch engine: 0=OLA, 1=Cloud, 2=Phase Vocoder, 3=GrainRate."),
@@ -3664,22 +4944,35 @@ server.registerTool("update_cell_config", {
3664
4944
  const cellCheck = validateCellIdForManifest(cellId, manifest);
3665
4945
  if (cellCheck !== true)
3666
4946
  return cellCheck;
3667
- const cellConfig = {};
3668
- const flatDelta = {};
3669
- for (const [key, value] of Object.entries(config)) {
3670
- if (value !== undefined)
3671
- cellConfig[key] = value;
3672
- }
3673
- if (Object.keys(cellConfig).length === 0) {
4947
+ // These four are DAW params (cell{N}_quantScale, …), not cell-config keys —
4948
+ // nothing consumes them on the cells{} config channel (the sampler reads
4949
+ // the param signal inputs), so a cells{} write was a silent no-op that
4950
+ // also persisted junk keys into the preset. Route them to flat param keys,
4951
+ // which applyKv maps onto the JUCE params (updateParameters: true).
4952
+ const PARAM_ROUTED_KEYS = new Set(["quantScale", "quantRoot", "spread", "chaos"]);
4953
+ const providedKeys = Object.entries(config)
4954
+ .filter(([, value]) => value !== undefined)
4955
+ .map(([key]) => key);
4956
+ if (providedKeys.length === 0) {
3674
4957
  return error("At least one config property must be provided.");
3675
4958
  }
3676
4959
  if (manifest && isMfxManifest(manifest)) {
3677
- const unsupported = Object.keys(cellConfig).filter((key) => !MFX_CELL_CONFIG_KEYS.has(key));
4960
+ const unsupported = providedKeys.filter((key) => !MFX_CELL_CONFIG_KEYS.has(key));
3678
4961
  if (unsupported.length > 0) {
3679
4962
  return error("update_cell_config in MFX only accepts sidechainFrom. " +
3680
4963
  `Sampler/MIDI/instrument config keys are PAM/Cell1-only: ${unsupported.join(", ")}.`);
3681
4964
  }
3682
4965
  }
4966
+ const cellConfig = {};
4967
+ const flatDelta = {};
4968
+ for (const [key, value] of Object.entries(config)) {
4969
+ if (value === undefined)
4970
+ continue;
4971
+ if (PARAM_ROUTED_KEYS.has(key))
4972
+ flatDelta[`cell${cellId}_${key}`] = value;
4973
+ else
4974
+ cellConfig[key] = value;
4975
+ }
3683
4976
  // Auto-enable compressor when setting sidechain source
3684
4977
  if (cellConfig.sidechainFrom !== undefined && cellConfig.sidechainFrom !== null) {
3685
4978
  flatDelta[`cell${cellId}_compressorEnabled`] = 1;
@@ -3694,11 +4987,14 @@ server.registerTool("update_cell_config", {
3694
4987
  cellModeChangeSeq[cellId] = next;
3695
4988
  cellConfig.modeChangeSeq = next;
3696
4989
  }
3697
- const delta = { cells: { [cellId]: cellConfig }, ...flatDelta };
4990
+ const delta = { ...flatDelta };
4991
+ if (Object.keys(cellConfig).length > 0)
4992
+ delta.cells = { [cellId]: cellConfig };
3698
4993
  await applyKv(delta, KV_OPTS_CELL_CONFIG);
3699
- const summary = Object.entries(cellConfig)
3700
- .map(([k, v]) => `${k}=${v}`)
3701
- .join(", ");
4994
+ const summary = [
4995
+ ...Object.entries(cellConfig).map(([k, v]) => `${k}=${v}`),
4996
+ ...Object.entries(flatDelta).map(([k, v]) => `${k}=${v}`),
4997
+ ].join(", ");
3702
4998
  return text(`Updated cell ${cellId} config: ${summary}`);
3703
4999
  });
3704
5000
  server.registerTool("set_cell_mute", {
@@ -3752,7 +5048,8 @@ server.registerTool("set_master_effects", {
3752
5048
  "medium release (80-200ms), ratio 4-8, autoMakeup true. " +
3753
5049
  "For lo-fi/Memphis/dusty glue: tapeEnabled=1, tapeMode=1 (Lo-Fi), masterTapeDrive 0.55-0.7, " +
3754
5050
  "masterTapeTone -0.4..-0.1 (darker), masterTapeWear 0.5-0.7 (wow/flutter). " +
3755
- "NOTE: the Tape requires a PAM build that ships it (flat state key masterTapeEnabled — master-chain keys are not manifest params). On older builds these keys are ignored.",
5051
+ "Squeeze (masterSqueeze*) is the 3-band up/down density stage between Punch and Tape — for dense modern-electronic masters set masterSqueezeEnabled=1 with masterSqueezeDepth 0.2-0.4; it lifts quiet detail and limits loud bands per band (a character stage, not glue — Punch stays the glue). " +
5052
+ "NOTE: the Tape and Squeeze stages require a PAM build that ships them (flat state keys, not manifest params). On older builds these keys are ignored.",
3756
5053
  inputSchema: {
3757
5054
  compressorEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable Punch compressor (0=off, 1=on)."),
3758
5055
  limiterEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable master Limiter (0=off, 1=on)."),
@@ -3792,6 +5089,15 @@ server.registerTool("set_master_effects", {
3792
5089
  masterIronDrive: z.number().min(0).max(12).optional().describe("Iron drive dB (default 0). Level-compensated — drive adds colour, not loudness."),
3793
5090
  masterIronAmount: z.number().min(0).max(1).optional().describe("Transformer (magnetizing-branch) depth 0..1 — LF-concentrated saturation, the 'iron' signature (default 0.5)."),
3794
5091
  masterIronMode: z.union([z.literal(0), z.literal(1)]).optional().describe("0=Console (amp-forward, subtle), 1=Transformer (heavier LF iron)."),
5092
+ masterSqueezeEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable Squeeze — a 3-band upward+downward compressor (post-Punch, pre-Tape; zero latency). The character (88/2500 Hz splits, thresholds, ratios) is baked; Depth is the main lever."),
5093
+ masterSqueezeDepth: z.number().min(0).max(1).optional().describe("Squeeze depth 0..1 (default 0.3): scales the applied per-band gain toward the full character — a density control, NOT a dry/wet. 0.15-0.3 densifies a master; 0.6+ is the aggressive pumped sound."),
5094
+ masterSqueezeTime: z.number().min(0.1).max(10).optional().describe("Squeeze time multiplier on all band attack+release (0.1 fast .. 10 slow, default 1)."),
5095
+ masterSqueezeTone: z.number().min(-1).max(1).optional().describe("Squeeze tone: bipolar low<->high band tilt, ±6 dB at the extremes (default 0)."),
5096
+ masterSqueezeInGain: z.number().min(-12).max(12).optional().describe("Squeeze input gain dB (default 0) — drives program into the fixed thresholds (more In = more of everything)."),
5097
+ masterSqueezeOutTrim: z.number().min(-12).max(12).optional().describe("Squeeze output trim dB (default 0)."),
5098
+ masterSqueezeUpAmount: z.number().min(0).max(1.5).optional().describe("Squeeze upward-branch trim 0..1.5 (default 1). 0 disables the upward lift (downward-only multiband)."),
5099
+ masterSqueezeDownAmount: z.number().min(0).max(1.5).optional().describe("Squeeze downward-branch trim 0..1.5 (default 1). 0 disables the downward limiting (pure upward lift)."),
5100
+ masterSqueezeUpCapDb: z.number().min(0).max(24).optional().describe("Squeeze upward max-boost clamp dB (default 12) — bounds how far quiet material (tails, noise floor) is lifted."),
3795
5101
  masterLoudnessAutoReset: z.union([z.literal(0), z.literal(1)]).optional().describe("Loudness meter auto-reset on host transport start (default 1). 0 keeps measurements running across transport stops."),
3796
5102
  masterEqBells: z
3797
5103
  .array(z.object({
@@ -3857,38 +5163,50 @@ server.registerTool("set_master_effects", {
3857
5163
  return text(`Updated master effects: ${summary}`);
3858
5164
  });
3859
5165
  // Built-in per-cell drum synth ("machines"). Engine enum index = drumsynth::Engine
3860
- // (Off = 0). Per-engine param labels + musical defaults mirror DRUM_SYNTH_ENGINES
3861
- // in interface/src/dsp/modules/drumSynth.ts — keep in lockstep (append-only).
3862
- const DRUM_SYNTH_ENGINES = [
3863
- { name: "Kick", params: [["Tune", 0.25], ["Pitch", 0.5], ["P.Decay", 0.35], ["Decay", 0.5], ["Click", 0.3], ["Drive", 0.35], ["Tone", 0.6]] },
3864
- { name: "Snare", params: [["Tune", 0.4], ["Snap", 0.45], ["Body", 0.35], ["N.Decay", 0.45], ["N.Tone", 0.5], ["Detune", 0.3], ["Drive", 0.25]] },
3865
- { name: "HiHat", params: [["Tune", 0.5], ["Decay", 0.3], ["Tone", 0.5], ["Metal", 0.6], ["Noise", 0.2]] },
3866
- { name: "Clap", params: [["Tune", 0.45], ["Spread", 0.4], ["Decay", 0.4], ["Tone", 0.5], ["Drive", 0.2]] },
3867
- { name: "Tom", params: [["Tune", 0.4], ["Pitch", 0.4], ["Decay", 0.45], ["Tone", 0.55], ["Noise", 0.2], ["Drive", 0.2]] },
3868
- { name: "FM", params: [["Tune", 0.4], ["Ratio", 0.45], ["FM Amt", 0.4], ["Decay", 0.4], ["FM Dcy", 0.4], ["F.Back", 0.1], ["Drive", 0.2]] },
3869
- { name: "Cymbal", params: [["Tune", 0.5], ["Decay", 0.5], ["Tone", 0.6], ["Metal", 0.7], ["Sizzle", 0.4]] },
3870
- { name: "Cowbell", params: [["Tune", 0.5], ["Decay", 0.4], ["Ratio", 0.45], ["Tone", 0.5], ["Drive", 0.2]] },
3871
- { name: "808", params: [["Tune", 0.35], ["Glide", 0.25], ["P.Decay", 0.4], ["Decay", 0.55], ["Saturate", 0.2], ["Click", 0.25], ["Tone", 0.5]] },
3872
- { name: "Shaker", params: [["Tune", 0.5], ["Color", 0.3], ["Decay", 0.3], ["Attack", 0.4], ["Rattle", 0.0], ["Tone", 0.4]] },
3873
- { name: "Rim", params: [["Tune", 0.4], ["Ring", 0.5], ["Decay", 0.3], ["Mode", 0.4], ["Snap", 0.6], ["Drive", 0.2]] },
3874
- { name: "Conga", params: [["Tune", 0.45], ["Slap", 0.4], ["Decay", 0.4], ["Mode", 0.3], ["Tone", 0.55], ["Skin", 0.25], ["Drive", 0.2]] },
3875
- { name: "Bell", params: [["Tune", 0.5], ["Ratio", 0.4], ["FM Amt", 0.4], ["Decay", 0.5], ["Inharm", 0.3], ["Strike", 0.3], ["Tone", 0.3]] },
3876
- { name: "Pluck", params: [["Tune", 0.4], ["Sustain", 0.6], ["Damp", 0.5], ["Excite", 0.4], ["Tone", 0.6], ["Drive", 0.15], ["Decay", 0.5]] },
3877
- { name: "Zap", params: [["Tune", 0.3], ["Sweep", 0.7], ["S.Time", 0.35], ["Decay", 0.4], ["Timbre", 0.3], ["Noise", 0.1], ["Drive", 0.25], ["Tone", 0.6]] },
3878
- { name: "Wave", params: [["Tune", 0.35], ["Position", 0.2], ["P.Env", 0.4], ["Decay", 0.4], ["Bright", 0.6], ["Morph", 0.0], ["Drive", 0.2]] },
3879
- { name: "Chaos", params: [["Tune", 0.4], ["Chaos", 0.6], ["Decay", 0.4], ["Tone", 0.5], ["Drive", 0.3], ["Noise", 0.1]] },
3880
- ];
3881
- const DRUM_SYNTH_ENGINE_NAMES = ["Off", ...DRUM_SYNTH_ENGINES.map((e) => e.name)];
5166
+ // (Off = 0). The roster, the curated defaults and the engine-switch rule all come
5167
+ // from shared/drumSynthEngines.json — the registry build.js codegens the C++ cook
5168
+ // and the manifest from, and interface/src/dsp/modules/drumSynth.ts derives its
5169
+ // own tables from. The hand-copied table this replaced had drifted on Kick,
5170
+ // Cowbell and 808 (issue #999); the switch rule it shipped with carried knob
5171
+ // positions where the plugin carries engine units.
5172
+ const DRUM_SYNTH_REGISTRY = (() => {
5173
+ const here = dirname(fileURLToPath(import.meta.url));
5174
+ const candidates = [
5175
+ resolve(here, "..", "..", "..", "shared", "drumSynthEngines.json"), // repo checkout
5176
+ resolve(here, "drumSynthEngines.json"), // published dist/
5177
+ ];
5178
+ for (const file of candidates) {
5179
+ if (!existsSync(file))
5180
+ continue;
5181
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
5182
+ if (parsed.engines && Object.keys(parsed.engines).length > 0)
5183
+ return parsed;
5184
+ }
5185
+ throw new Error("pam-mcp: drumSynthEngines.json not found. Rebuild the package (scripts/build-postprocess.mjs " +
5186
+ "vendors it into dist/) — seeding a drum engine without it would write defaults that disagree " +
5187
+ "with the plugin.");
5188
+ })();
5189
+ const drumSynth = createDrumSynthCatalog(DRUM_SYNTH_REGISTRY);
5190
+ const DRUM_SYNTH_ENGINE_NAMES = drumSynth.engineNames;
5191
+ /** State carries the bare enum; agents reason about the name. Report both. */
5192
+ function describeSynthEngine(raw) {
5193
+ const id = Math.round(Number(raw ?? 0));
5194
+ return { id, name: DRUM_SYNTH_ENGINE_NAMES[id] ?? "Off" };
5195
+ }
3882
5196
  server.registerTool("set_drum_synth", {
3883
5197
  title: "Set Drum Synth (built-in machine)",
3884
5198
  description: "Select and shape a cell's built-in drum-synth 'machine' — a real-time synthesized voice that sums into the cell " +
3885
- "alongside (or instead of) its sample. 18 engines: Off, Kick, Snare, HiHat, Clap, Tom, FM, Cymbal, Cowbell, 808, " +
3886
- "Shaker, Rim, Conga, Bell, Pluck, Zap, Wave, Chaos. The 808 engine is a tuned long-glide sub that plays as a chromatic " +
5199
+ `alongside (or instead of) its sample. ${DRUM_SYNTH_ENGINE_NAMES.length} engines: ${DRUM_SYNTH_ENGINE_NAMES.join(", ")}. ` +
5200
+ "The 808 engine is a tuned long-glide sub that plays as a chromatic " +
3887
5201
  "bassline (drive it with a pitch lane + pitch-quantize) — ideal for 808 sub-bass, trap, Memphis. Use a cell with NO " +
3888
5202
  "sample for a pure synth voice, or layer over a sample. Each engine reinterprets the 8 generic params (p1..p8); pass " +
3889
- "only the ones you want to nuance — unspecified params seed to the engine's musical default. Per-engine p-labels: " +
3890
- DRUM_SYNTH_ENGINES.map((e) => `${e.name}=[${e.params.map((p) => p[0]).join(",")}]`).join("; ") + ". " +
3891
- "NOTE: requires a PAM build with drum synth (manifest.drumSynth.enabled) — a standard PAM feature, no settings flag. " +
5203
+ "only the ones you want to nuance. First activation seeds every slot from the engine's musical defaults. On a switch " +
5204
+ "between two active engines, only slots you had moved off their default travel, and they carry the engine VALUE " +
5205
+ "(re-mapped through the destination's own range), so Kick Decay 0.41 becomes Stab Decay 0.336 — the same 198 ms. " +
5206
+ "Untouched slots, and slots whose label or unit has no counterpart, take the destination's default. Per-engine p-labels: " +
5207
+ drumSynth.engines.map((e) => `${e.name}=[${e.params.map((p) => p.label).join(",")}]`).join("; ") + ". " +
5208
+ "NOTE: requires a PAM build with drum synth (manifest.drumSynth.enabled). This headless tool is product-gated, " +
5209
+ "not gated by the UI-only drumSynthEnabled preference. " +
3892
5210
  "On products without it, this tool returns a product error instead of writing inert synth keys.",
3893
5211
  inputSchema: {
3894
5212
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
@@ -3917,14 +5235,11 @@ server.registerTool("set_drum_synth", {
3917
5235
  const engineId = DRUM_SYNTH_ENGINE_NAMES.indexOf(engine);
3918
5236
  if (engineId < 0)
3919
5237
  return error(`Unknown drum synth engine: ${engine}`);
3920
- const delta = { [`cell${cellId}_synthEngine`]: engineId };
3921
- if (engineId > 0) {
3922
- // Seed the engine's musical defaults so the voice sounds good immediately,
3923
- // then apply explicit p-overrides on top (nuance only what you mean to).
3924
- const desc = DRUM_SYNTH_ENGINES[engineId - 1];
3925
- desc.params.forEach(([, def], i) => {
3926
- delta[`cell${cellId}_synth_p${i + 1}`] = def;
3927
- });
5238
+ const stateRaw = await bridge.send("getState");
5239
+ const state = isRecord(stateRaw) ? stateRaw : {};
5240
+ const delta = drumSynth.buildEngineSwitchDelta(cellId, engineId, state);
5241
+ const desc = drumSynth.getEngine(engineId);
5242
+ if (desc) {
3928
5243
  for (const [key, value] of Object.entries(overrides)) {
3929
5244
  if (value === undefined)
3930
5245
  continue;
@@ -3937,7 +5252,7 @@ server.registerTool("set_drum_synth", {
3937
5252
  delta[`cell${cellId}_synthVolume`] = volume;
3938
5253
  // synthEngine is a graph-render key → needs a graph dispatch (KV_OPTS_STANDARD).
3939
5254
  await applyKv(delta, KV_OPTS_STANDARD);
3940
- const labels = engineId > 0 ? DRUM_SYNTH_ENGINES[engineId - 1].params.map((p) => p[0]).join(",") : "—";
5255
+ const labels = desc ? desc.params.map((p) => p.label).join(",") : "—";
3941
5256
  return text(`Cell ${cellId} drum synth → ${engine}${engineId > 0 ? ` (p1..pN = ${labels})` : ""}.`);
3942
5257
  });
3943
5258
  server.registerTool("set_drum_synth_wave", {
@@ -3996,7 +5311,7 @@ server.registerTool("add_param_seq", {
3996
5311
  speed: z.number().min(0.0625).max(16).default(1).describe("Pattern speed multiplier."),
3997
5312
  },
3998
5313
  }, async ({ paramId, cellId, sequence, strength, bipolar, length, speed }) => {
3999
- const check = await requireBridge();
5314
+ const check = await requireBridgeForCells(cellId);
4000
5315
  if (check !== true)
4001
5316
  return check;
4002
5317
  const index = await getParameterIndex();
@@ -4082,7 +5397,7 @@ server.registerTool("normalize_sample", {
4082
5397
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
4083
5398
  },
4084
5399
  }, async ({ cellId }) => {
4085
- const check = await requireBridge();
5400
+ const check = await requireBridgeForCells(cellId);
4086
5401
  if (check !== true)
4087
5402
  return check;
4088
5403
  await bridge.send("normalizeSampleForCell", cellId);
@@ -4101,7 +5416,7 @@ server.registerTool("gain_sample", {
4101
5416
  .describe("Gain in decibels (e.g. 3 = +3 dB louder, -6 = 6 dB quieter)."),
4102
5417
  },
4103
5418
  }, async ({ cellId, gainDb }) => {
4104
- const check = await requireBridge();
5419
+ const check = await requireBridgeForCells(cellId);
4105
5420
  if (check !== true)
4106
5421
  return check;
4107
5422
  await bridge.send("gainSampleForCell", { cellId, gainDb });
@@ -4170,7 +5485,7 @@ server.registerTool("export_audio", {
4170
5485
  .describe("Bit depth (16 or 24)."),
4171
5486
  },
4172
5487
  }, async ({ bars, cellId, mode, selectedCellIds, bitDepth }) => {
4173
- const check = await requireBridge();
5488
+ const check = await requireBridgeForCells(cellId, selectedCellIds);
4174
5489
  if (check !== true)
4175
5490
  return check;
4176
5491
  // Resolve the effective mode. A bare cellId means "individual".
@@ -4208,7 +5523,7 @@ server.registerTool("export_audio", {
4208
5523
  const bpm = typeof transport.bpm === "number" && transport.bpm > 0 ? transport.bpm : 120;
4209
5524
  // 4/4 assumption for the placeholder duration; 'song-mode' recomputes its own length from the timeline.
4210
5525
  const durationInSeconds = (bars * 4 * 60) / bpm;
4211
- const filename = `pam_export_${effectiveMode.replace(/[^a-z0-9]/gi, "_")}_${Date.now()}`;
5526
+ const filename = `${SERVER_PRODUCT.id}_export_${effectiveMode.replace(/[^a-z0-9]/gi, "_")}_${Date.now()}`;
4212
5527
  const options = {
4213
5528
  filename,
4214
5529
  mode: effectiveMode,
@@ -4223,12 +5538,8 @@ server.registerTool("export_audio", {
4223
5538
  const outcome = await pollExportStatus();
4224
5539
  if (outcome === "success")
4225
5540
  return text(`Export complete (mode '${effectiveMode}').\n` +
4226
- `File(s) written to PAM's Exports folder, base name: ${filename}.wav` +
4227
- loudnessReportLine() +
4228
- (effectiveMode === "song-mode"
4229
- ? "\n\nReminder: the offline song-mode bounce captures only the first section's state. " +
4230
- "Use export_song for a full track with section changes."
4231
- : ""));
5541
+ `File(s) written to ${SERVER_PRODUCT.name}'s Exports folder, base name: ${filename}.wav` +
5542
+ loudnessReportLine());
4232
5543
  if (outcome === "failed")
4233
5544
  return error(`Export failed (mode '${effectiveMode}').`);
4234
5545
  return text(`Export timed out (mode '${effectiveMode}'). The render may still be running.`);
@@ -4236,8 +5547,8 @@ server.registerTool("export_audio", {
4236
5547
  // ============================================================
4237
5548
  // RESOURCES
4238
5549
  // ============================================================
4239
- server.registerResource("parameter-manifest", "pam://parameters", {
4240
- title: "PAM Parameter Manifest",
5550
+ server.registerResource("parameter-manifest", `${SERVER_PRODUCT.resourceScheme}://parameters`, {
5551
+ title: `${SERVER_PRODUCT.name} Parameter Manifest`,
4241
5552
  description: "Complete parameter definitions with ranges, defaults, and metadata.",
4242
5553
  mimeType: "application/json",
4243
5554
  }, async (uri) => {
@@ -4251,8 +5562,8 @@ server.registerResource("parameter-manifest", "pam://parameters", {
4251
5562
  ],
4252
5563
  };
4253
5564
  });
4254
- server.registerResource("parameter-summary", "pam://parameters/summary", {
4255
- title: "PAM Parameter Summary",
5565
+ server.registerResource("parameter-summary", `${SERVER_PRODUCT.resourceScheme}://parameters/summary`, {
5566
+ title: `${SERVER_PRODUCT.name} Parameter Summary`,
4256
5567
  description: "Human-readable parameter list with ranges and descriptions.",
4257
5568
  mimeType: "text/plain",
4258
5569
  }, async (uri) => {
@@ -4262,10 +5573,10 @@ server.registerResource("parameter-summary", "pam://parameters/summary", {
4262
5573
  contents: [{ uri: uri.href, text: lines.join("\n") }],
4263
5574
  };
4264
5575
  });
4265
- server.registerResource("cell-parameters", new ResourceTemplate("pam://cell/{cellId}/parameters", {
5576
+ server.registerResource("cell-parameters", new ResourceTemplate(`${SERVER_PRODUCT.resourceScheme}://cell/{cellId}/parameters`, {
4266
5577
  list: async () => ({
4267
- resources: Array.from({ length: 8 }, (_, i) => ({
4268
- uri: `pam://cell/cell${i + 1}/parameters`,
5578
+ resources: Array.from({ length: SERVER_PRODUCT.cellCount }, (_, i) => ({
5579
+ uri: `${SERVER_PRODUCT.resourceScheme}://cell/cell${i + 1}/parameters`,
4269
5580
  name: `Cell ${i + 1} Parameters`,
4270
5581
  })),
4271
5582
  }),
@@ -4280,7 +5591,7 @@ server.registerResource("cell-parameters", new ResourceTemplate("pam://cell/{cel
4280
5591
  contents: [{ uri: uri.href, text: JSON.stringify(params, null, 2) }],
4281
5592
  };
4282
5593
  });
4283
- server.registerResource("global-parameters", "pam://parameters/global", {
5594
+ server.registerResource("global-parameters", `${SERVER_PRODUCT.resourceScheme}://parameters/global`, {
4284
5595
  title: "Global Parameters",
4285
5596
  description: "Non-cell-specific parameters (volume, crossfade, looper, etc.).",
4286
5597
  mimeType: "application/json",
@@ -4290,16 +5601,38 @@ server.registerResource("global-parameters", "pam://parameters/global", {
4290
5601
  contents: [{ uri: uri.href, text: JSON.stringify(params, null, 2) }],
4291
5602
  };
4292
5603
  });
4293
- server.registerResource("beat-making-guide", "pam://guide/beat-making", {
4294
- title: "PAM Beat-Making Guide",
4295
- description: "Comprehensive guide for creating beats with PAM. Covers cell roles, slicer for loops, " +
4296
- "sidechain pumping, poly/arp patterns, macros, variations, gain staging, and master effects.",
4297
- mimeType: "text/markdown",
4298
- }, async () => ({
4299
- contents: [
4300
- {
4301
- uri: "pam://guide/beat-making",
4302
- text: `# PAM Beat-Making Guide
5604
+ if (IS_MFX_SERVER) {
5605
+ server.registerResource("mfx-routing-guide", "mfx://guide/routing", {
5606
+ title: "MFX Routing Guide",
5607
+ description: "A concise workflow for cabling, ordering, shaping, and modulating an MFX patch.",
5608
+ mimeType: "text/markdown",
5609
+ }, async () => ({
5610
+ contents: [{
5611
+ uri: "mfx://guide/routing",
5612
+ text: `# MFX Routing Guide
5613
+
5614
+ MFX has four effects cells. Audio reaches a cell only when the cable patch routes a source into it.
5615
+
5616
+ 1. Call \`set_mfx_routing\` with \`op: "get"\` before changing the patch.
5617
+ 2. Use \`design_context\` to inspect the available effects, parameters, and default effect order.
5618
+ 3. Replace the complete cable list with \`set_mfx_routing\` and \`op: "set"\`. A serial patch is input → cell1 → cell2 → cell3 → cell4 → output.
5619
+ 4. Use \`set_effect_order\`, \`update_effect_config\`, and \`set_parameters\` to shape each cell.
5620
+ 5. Add movement with the modulation tools, audition the result, and save it with \`save_preset\`.
5621
+ `,
5622
+ }],
5623
+ }));
5624
+ }
5625
+ else {
5626
+ server.registerResource("beat-making-guide", "pam://guide/beat-making", {
5627
+ title: "PAM Beat-Making Guide",
5628
+ description: "Comprehensive guide for creating beats with PAM. Covers cell roles, slicer for loops, " +
5629
+ "sidechain pumping, poly/arp patterns, macros, variations, gain staging, and master effects.",
5630
+ mimeType: "text/markdown",
5631
+ }, async () => ({
5632
+ contents: [
5633
+ {
5634
+ uri: "pam://guide/beat-making",
5635
+ text: `# PAM Beat-Making Guide
4303
5636
 
4304
5637
  ## Cell Architecture — 8 Cells, Each a Full Instrument
4305
5638
 
@@ -4482,7 +5815,7 @@ Variations exclude: global volume, looper state.
4482
5815
 
4483
5816
  ## Master Effects — The Final Stage
4484
5817
 
4485
- Signal chain: Cell outputs → Bus Mix → **Punch Compressor** → **Limiter** → Volume Fader → Output
5818
+ Signal chain: Cell outputs → Bus Mix → **EQ** → **Punch Compressor** → **Squeeze** (3-band up/down) → **Tape** → **Iron** → **Clipper** → **Limiter** → Volume Fader → Output
4486
5819
 
4487
5820
  ### Punch Compressor (Musical Bus Comp)
4488
5821
  \`\`\`
@@ -4603,7 +5936,8 @@ update_cell_config: cellId=5, scaleName="pentatonicMinor", scaleRoot=0
4603
5936
  \`\`\`
4604
5937
  \`scaleRoot\`: 0=C, 1=C#, 2=D, 3=D#, 4=E, 5=F, 6=F#, 7=G, 8=G#, 9=A, 10=A#, 11=B
4605
5938
 
4606
- Available scales: \`chromatic\` (default/none), \`major\`, \`minor\`, \`pentatonicMajor\`, \`pentatonicMinor\`, \`dorian\`, \`mixolydian\`, \`blues\`, \`harmonicMinor\`, \`phrygian\`
5939
+ Available scales: \`chromatic\` (default/none), \`major\`, \`minor\`, \`pentatonicMajor\`, \`pentatonicMinor\`, \`dorian\`, \`mixolydian\`, \`blues\`, \`harmonicMinor\`, \`phrygian\`, \`lydian\`, \`locrian\`
5940
+ (The sampler pitch-quantize lane \`cell{N}_quantScale\` is a separate, deliberately frozen 10-scale DAW param — no lydian/locrian there.)
4607
5941
 
4608
5942
  ### Playback Modes
4609
5943
  \`cell{N}_mode\`: 0=Trigger (one-shot), 1=Gate (plays while held, default), 2=Loop (continuous)
@@ -4638,15 +5972,19 @@ Index → multiplier: 0=1/32, 1=1/16, 2=1/12, 3=1/8, 4=1/6, 5=1/5, 6=1/4, 7=1/3,
4638
5972
  ### Pattern Direction
4639
5973
  \`cell{N}_patternDirection\`: 0=Forward, 1=Backward, 2=Ping-Pong, 3=Random Cycle (shuffle each loop), 4=Random Step (each step independent)
4640
5974
  `,
4641
- },
4642
- ],
4643
- }));
5975
+ },
5976
+ ],
5977
+ }));
5978
+ }
4644
5979
  // ============================================================
4645
5980
  // PROMPTS — guided workflows (client-invokable, e.g. slash
4646
5981
  // commands in Claude Code). Each stages the same flows the
4647
5982
  // README and tool descriptions teach, with exact tool names.
4648
5983
  // ============================================================
4649
- const GENRE_SUGGESTIONS = ["boom_bap", "trap", "drill", "house", "techno", "dnb"];
5984
+ // Mirror of RANDOMIZER_GENRES (shared/randomizer/types.ts) — the published
5985
+ // package can't import repo-relative shared modules, so the copy is pinned by
5986
+ // interface/test/randomizerGenre.test.ts (source-text parity check).
5987
+ const GENRE_SUGGESTIONS = ["boom_bap", "trap", "drill", "house", "techno", "dnb", "idm"];
4650
5988
  const completeGenre = (value) => GENRE_SUGGESTIONS.filter((g) => g.startsWith((value ?? "").toLowerCase()));
4651
5989
  // completable() tags the schema OBJECT with a symbol, so ordering is delicate:
4652
5990
  // .describe()/.optional() clone schemas and drop the tag, SDK 1.29 unwraps
@@ -4654,7 +5992,7 @@ const completeGenre = (value) => GENRE_SUGGESTIONS.filter((g) => g.startsWith((v
4654
5992
  // completer at request time — the tag must sit on both the inner type and the
4655
5993
  // outer optional. Covered by the completion assertion in check-tool-surface's
4656
5994
  // sibling smoke; revisit when the SDK unifies the two lookups.
4657
- const genreArg = completable(completable(z.string().describe("Genre hint, e.g. techno, house, dnb, trap, boom_bap, drill."), completeGenre).optional(), completeGenre);
5995
+ const genreArg = completable(completable(z.string().describe("Genre hint, e.g. techno, house, dnb, trap, boom_bap, drill, idm."), completeGenre).optional(), completeGenre);
4658
5996
  function promptText(text) {
4659
5997
  return {
4660
5998
  messages: [
@@ -4662,65 +6000,93 @@ function promptText(text) {
4662
6000
  ],
4663
6001
  };
4664
6002
  }
4665
- server.registerPrompt("build-a-beat", {
4666
- title: "Build a Beat",
4667
- description: "Guided kit-building + pattern workflow: coherent samples, genre patterns, transport, refinement, save.",
4668
- argsSchema: {
4669
- genre: genreArg,
4670
- bpm: z.string().optional().describe("Target tempo in BPM."),
4671
- },
4672
- }, ({ genre, bpm }) => promptText(`Build a beat in PAM${genre ? ` in the style of ${genre}` : ""}${bpm ? ` at ${bpm} BPM` : ""}. Work in stages, listening between them:\n\n` +
4673
- `1. connection_status to confirm the bridge, then get_design_overview for the current state.\n` +
4674
- `2. Load a kit: randomize_samples${genre ? ` with genreHint '${genre}'` : " (pass a genreHint)"} — the native engine picks role-coherent samples; prefer it over manual browsing. For specific sounds: get_default_locations → list_samples → load_sample.\n` +
4675
- `3. ${bpm ? `set_bpm to ${bpm}` : "set_bpm to the genre's tempo"}, then randomize_patterns to generate genre-appropriate patterns from the loaded sample roles.\n` +
4676
- `4. start_transport — sequencer output needs the transport rolling.\n` +
4677
- `5. Refine: update_pattern for individual lanes, set_parameters for levels/filters (ranges via design_context), update_effect_config to switch FX on, add_modulation for movement.\n` +
4678
- `6. save_preset once it works, and update_preset_metadata with genre tags and BPM.`));
4679
- server.registerPrompt("make-a-song", {
4680
- title: "Make a Song",
4681
- description: "Arrange the current preset into sections with variations + Song Mode, then bounce a full track.",
4682
- argsSchema: {
4683
- bars: z.string().optional().describe("Approximate song length in bars."),
4684
- },
4685
- }, ({ bars }) => promptText(`Turn the current PAM preset into an arranged song${bars ? ` of roughly ${bars} bars` : ""}:\n\n` +
4686
- `1. Design 2-4 contrasting sections (intro / groove / peak / outro): shape the state for each, then save_variation into slots 0-7.\n` +
4687
- `2. Arrange with song_mode op 'setArrangement' — an ordered sections list ({variationIndex, bars, repeat}, minimum 2 sections). Read it back with op 'get'.\n` +
4688
- `3. Audition: start_transport with Song Mode driving the timeline; queue_variation switches sections live.\n` +
4689
- `4. Bounce with export_song — strategy 'live' captures the ground-truth master mix (required for Magenta/Notochord/automation), strategy 'offline' is the deterministic multi-section bounce with no bar cap. includeVideo:true films the VX scene (window must be open).\n` +
4690
- `5. The rendered file lands in PAM's Exports/Resamples locations — get_default_locations resolves them.`));
4691
- server.registerPrompt("design-a-preset", {
4692
- title: "Design a Preset",
4693
- description: "Sound-design workflow from a creative brief to a saved preset.",
4694
- argsSchema: {
4695
- brief: z.string().describe("Creative brief, e.g. 'dusty lo-fi kit with a wobbly sub'."),
4696
- },
4697
- }, ({ brief }) => promptText(`Design a PAM preset from this brief: "${brief}"\n\n` +
4698
- `1. Read design_context (curated parameter cheat sheet) and get_design_overview first — avoid full get_state dumps.\n` +
4699
- `2. Source sounds: randomize_samples with a genre hint, or get_default_locations → list_samples → load_sample. generate_sample creates bespoke hits (AI engines need update_settings experimental flags + a downloaded model).\n` +
4700
- `3. Shape each cell: set_parameters (design_context teaches ranges and units), update_effect_config for FX chains, set_drum_synth for synthesized drum layers.\n` +
4701
- `4. Add movement: add_modulation (LFO / envelope / vary), add_param_seq for stepped motion.\n` +
4702
- `5. Write patterns with update_pattern, then start_transport to audition.\n` +
4703
- `6. save_preset with a fitting name and update_preset_metadata (category, tags, BPM).`));
4704
- server.registerPrompt("mix-and-master", {
4705
- title: "Mix and Master",
4706
- description: "Balance the cells and shape the master bus using the analysis + suggestion tools.",
4707
- argsSchema: {},
4708
- }, () => promptText(`Balance and master the current PAM project:\n\n` +
4709
- `1. get_mix_analysis with refresh:true (offline per-cell stem measurements — takes a few seconds) and get_master_analysis (live loudness snapshot).\n` +
4710
- `2. suggest_mix for rule-based balance / pan / EQ / dynamics moves; apply the returned parameter moves with set_parameters and enable keys with update_effect_config.\n` +
4711
- `3. Shape the master bus with set_master_effects (compressor, tape, limiter — design_context lists the keys).\n` +
4712
- `4. Re-check get_master_analysis and iterate toward the loudness target.\n` +
4713
- `5. Render with export_audio (mode 'main'; 'all-cells' for stems).`));
4714
- server.registerPrompt("troubleshoot-bridge", {
4715
- title: "Troubleshoot the PAM Bridge",
4716
- description: "Diagnose why live tools can't reach the plugin.",
4717
- argsSchema: {},
4718
- }, () => promptText(`Diagnose the PAM MCP connection:\n\n` +
4719
- `1. connection_status. No instances → PAM isn't running or the bridge is off (PAM Settings → Experimental Mode → MCP Bridge, default port 9847). Multiple instances → select_instance picks which product the live tools drive.\n` +
4720
- `2. Instances found but not connected → the plugin may have shut down, or its message thread is busy (heavy render); retry once.\n` +
4721
- `3. Timeouts on WebView-routed tools (video tools, capture_ui, set_drum_synth_wave, randomize target 'synth') → the plugin window must be OPEN.\n` +
4722
- `4. "PAM build does not support 'X'" errors → the plugin is older than this server; the user must update PAM (updating the npm package alone is not enough).\n` +
4723
- `5. Offline tools always work without a plugin: get_parameter_info, design_context, list_presets, read_preset, get_manifest_summary.`));
6003
+ if (IS_MFX_SERVER) {
6004
+ server.registerPrompt("design-a-preset", {
6005
+ title: "Design an MFX Preset",
6006
+ description: "Turn a creative effects brief into a routed, modulated, saved MFX preset.",
6007
+ argsSchema: {
6008
+ brief: z.string().describe("Creative brief, e.g. 'wide rhythmic distortion with a filtered parallel tail'."),
6009
+ },
6010
+ }, ({ brief }) => promptText(`Design an MFX preset from this brief: "${brief}"\n\n` +
6011
+ `1. Call connection_status, design_context, and get_design_overview.\n` +
6012
+ `2. Read the current cable patch with set_mfx_routing op 'get', then replace the full patch only if the routing needs to change.\n` +
6013
+ `3. Shape cells 1-4 with set_effect_order, update_effect_config, and set_parameters.\n` +
6014
+ `4. Add movement with add_modulation or add_param_seq, then audition and refine.\n` +
6015
+ `5. Save the result with save_preset and useful metadata.`));
6016
+ server.registerPrompt("troubleshoot-bridge", {
6017
+ title: "Troubleshoot the MFX Bridge",
6018
+ description: "Diagnose why live tools cannot reach MFX.",
6019
+ argsSchema: {},
6020
+ }, () => promptText(`Diagnose the MFX MCP connection:\n\n` +
6021
+ `1. Run connection_status. If no instance appears, start MFX and enable Settings → Experimental → MCP Bridge.\n` +
6022
+ `2. MFX starts at the configured port and automatically takes the next available port when needed; do not assign a second fixed product port.\n` +
6023
+ `3. If an instance appears but cannot connect, it may have closed or be busy with a render; retry once.\n` +
6024
+ `4. If a live tool is unavailable, update the MFX plugin as well as the mfx-mcp package.`));
6025
+ }
6026
+ if (!IS_MFX_SERVER)
6027
+ server.registerPrompt("build-a-beat", {
6028
+ title: "Build a Beat",
6029
+ description: "Guided kit-building + pattern workflow: coherent samples, genre patterns, transport, refinement, save.",
6030
+ argsSchema: {
6031
+ genre: genreArg,
6032
+ bpm: z.string().optional().describe("Target tempo in BPM."),
6033
+ },
6034
+ }, ({ genre, bpm }) => promptText(`Build a beat in PAM${genre ? ` in the style of ${genre}` : ""}${bpm ? ` at ${bpm} BPM` : ""}. Work in stages, listening between them:\n\n` +
6035
+ `1. connection_status to confirm the bridge, then get_design_overview for the current state.\n` +
6036
+ `2. Load a kit: randomize_samples${genre ? ` with genreHint '${genre}'` : " (pass a genreHint)"} — the native engine picks role-coherent samples; prefer it over manual browsing. For specific sounds: get_default_locations → list_samples → load_sample.\n` +
6037
+ `3. ${bpm ? `set_bpm to ${bpm}` : "set_bpm to the genre's tempo"}, then randomize_patterns to generate genre-appropriate patterns from the loaded sample roles.\n` +
6038
+ `4. start_transport — sequencer output needs the transport rolling.\n` +
6039
+ `5. Refine: update_pattern for individual lanes, set_parameters for levels/filters (ranges via design_context), update_effect_config to switch FX on, add_modulation for movement.\n` +
6040
+ `6. save_preset once it works, and update_preset_metadata with genre tags and BPM.`));
6041
+ if (!IS_MFX_SERVER)
6042
+ server.registerPrompt("make-a-song", {
6043
+ title: "Make a Song",
6044
+ description: "Arrange the current preset into sections with variations + Song Mode, then bounce a full track.",
6045
+ argsSchema: {
6046
+ bars: z.string().optional().describe("Approximate song length in bars."),
6047
+ },
6048
+ }, ({ bars }) => promptText(`Turn the current PAM preset into an arranged song${bars ? ` of roughly ${bars} bars` : ""}:\n\n` +
6049
+ `1. Design 2-4 contrasting sections (intro / groove / peak / outro): shape the state for each, then save_variation into slots 0-7.\n` +
6050
+ `2. Arrange with song_mode op 'setArrangement' — an ordered sections list ({variationIndex, bars, repeat}, minimum 2 sections). Read it back with op 'get'.\n` +
6051
+ `3. Audition: start_transport with Song Mode driving the timeline; queue_variation switches sections live.\n` +
6052
+ `4. Bounce with export_song — strategy 'live' captures the ground-truth master mix (required for Magenta/Notochord/automation), strategy 'offline' is the deterministic multi-section bounce with no bar cap. includeVideo:true films the VX scene (window must be open).\n` +
6053
+ `5. The rendered file lands in PAM's Exports/Resamples locations — get_default_locations resolves them.`));
6054
+ if (!IS_MFX_SERVER)
6055
+ server.registerPrompt("design-a-preset", {
6056
+ title: "Design a Preset",
6057
+ description: "Sound-design workflow from a creative brief to a saved preset.",
6058
+ argsSchema: {
6059
+ brief: z.string().describe("Creative brief, e.g. 'dusty lo-fi kit with a wobbly sub'."),
6060
+ },
6061
+ }, ({ brief }) => promptText(`Design a PAM preset from this brief: "${brief}"\n\n` +
6062
+ `1. Read design_context (curated parameter cheat sheet) and get_design_overview first — avoid full get_state dumps.\n` +
6063
+ `2. Source sounds: randomize_samples with a genre hint, or get_default_locations → list_samples → load_sample. generate_sample creates bespoke hits (AI engines need update_settings experimental flags + a downloaded model).\n` +
6064
+ `3. Shape each cell: set_parameters (design_context teaches ranges and units), update_effect_config for FX chains, set_drum_synth for synthesized drum layers.\n` +
6065
+ `4. Add movement: add_modulation (LFO / envelope / vary), add_param_seq for stepped motion.\n` +
6066
+ `5. Write patterns with update_pattern, then start_transport to audition.\n` +
6067
+ `6. save_preset with a fitting name and update_preset_metadata (category, tags, BPM).`));
6068
+ if (!IS_MFX_SERVER)
6069
+ server.registerPrompt("mix-and-master", {
6070
+ title: "Mix and Master",
6071
+ description: "Balance the cells and shape the master bus using the analysis + suggestion tools.",
6072
+ argsSchema: {},
6073
+ }, () => promptText(`Balance and master the current PAM project:\n\n` +
6074
+ `1. get_mix_analysis with refresh:true (offline per-cell stem measurements — takes a few seconds) and get_master_analysis (live loudness snapshot).\n` +
6075
+ `2. suggest_mix for rule-based balance / pan / EQ / dynamics moves; apply the returned parameter moves with set_parameters and enable keys with update_effect_config.\n` +
6076
+ `3. Shape the master bus with set_master_effects (compressor, tape, limiter — design_context lists the keys).\n` +
6077
+ `4. Re-check get_master_analysis and iterate toward the loudness target.\n` +
6078
+ `5. Render with export_audio (mode 'main'; 'all-cells' for stems).`));
6079
+ if (!IS_MFX_SERVER)
6080
+ server.registerPrompt("troubleshoot-bridge", {
6081
+ title: "Troubleshoot the PAM Bridge",
6082
+ description: "Diagnose why live tools can't reach the plugin.",
6083
+ argsSchema: {},
6084
+ }, () => promptText(`Diagnose the PAM MCP connection:\n\n` +
6085
+ `1. connection_status. No instances → PAM isn't running or the bridge is off (PAM Settings → Experimental Mode → MCP Bridge, default port 9847). Multiple instances → select_instance picks which product the live tools drive.\n` +
6086
+ `2. Instances found but not connected → the plugin may have shut down, or its message thread is busy (heavy render); retry once.\n` +
6087
+ `3. Timeouts on WebView-routed tools (video tools, capture_ui, set_drum_synth_wave, randomize target 'synth') → the plugin window must be OPEN.\n` +
6088
+ `4. "PAM build does not support 'X'" errors → the plugin is older than this server; the user must update PAM (updating the npm package alone is not enough).\n` +
6089
+ `5. Offline tools always work without a plugin: get_parameter_info, design_context, list_presets, read_preset, get_manifest_summary.`));
4724
6090
  // ============================================================
4725
6091
  // TOOLS — Looper (state deltas)
4726
6092
  // ============================================================
@@ -4749,7 +6115,7 @@ server.registerTool("arm_looper_track", {
4749
6115
  armed: z.boolean().describe("true to arm, false to disarm."),
4750
6116
  },
4751
6117
  }, async ({ cellId, armed }) => {
4752
- const check = await requireBridge();
6118
+ const check = await requireBridgeForCells(cellId);
4753
6119
  if (check !== true)
4754
6120
  return check;
4755
6121
  await applyLoopersDelta({ [cellId]: { armed: armed ? 1 : 0 } });
@@ -4833,7 +6199,7 @@ server.registerTool("set_looper_track", {
4833
6199
  if (typeof rest.sourceCell === "number")
4834
6200
  trackDelta.sourceCell = rest.sourceCell;
4835
6201
  if (typeof rest.autoSelect === "boolean")
4836
- trackDelta.autoSelect = rest.autoSelect ? 1 : 0;
6202
+ trackDelta.autoSelect = rest.autoSelect;
4837
6203
  if (typeof rest.recordedBpm === "number")
4838
6204
  trackDelta.recordedBpm = rest.recordedBpm;
4839
6205
  if (Object.keys(trackDelta).length === 0)
@@ -4849,7 +6215,7 @@ server.registerTool("clear_looper_track", {
4849
6215
  cellId: z.number().min(1).max(8).describe("Looper track / cell (1-8)."),
4850
6216
  },
4851
6217
  }, async ({ cellId }) => {
4852
- const check = await requireBridge();
6218
+ const check = await requireBridgeForCells(cellId);
4853
6219
  if (check !== true)
4854
6220
  return check;
4855
6221
  await applyLoopersDelta({
@@ -4877,7 +6243,7 @@ server.registerTool("set_looper_track_selection", {
4877
6243
  }, async ({ cellId, start, end }) => {
4878
6244
  if (end <= start)
4879
6245
  return error("end must be > start");
4880
- const check = await requireBridge();
6246
+ const check = await requireBridgeForCells(cellId);
4881
6247
  if (check !== true)
4882
6248
  return check;
4883
6249
  await applyLoopersDelta({
@@ -4911,9 +6277,13 @@ server.registerTool("start_looper_recording", {
4911
6277
  .describe("Per-track metadata. Defaults: replace mode, cell-output source. Pass existingClipPaths to enable overdub on a prior layer."),
4912
6278
  },
4913
6279
  }, async ({ loopBars, armedCellIds, trackMetadata }) => {
4914
- const check = await requireBridge();
6280
+ const check = await requireBridgeForCells(armedCellIds);
4915
6281
  if (check !== true)
4916
6282
  return check;
6283
+ // Arm in UI state too — the plugin's live recording overlay and progress
6284
+ // mount off loopers[id].armed, so a bridge-only record left the timeline
6285
+ // blank while recording ran fine underneath.
6286
+ await applyLoopersDelta(Object.fromEntries(armedCellIds.map((cellId) => [cellId, { armed: 1 }])));
4917
6287
  const meta = trackMetadata ??
4918
6288
  armedCellIds.map((cellId) => ({
4919
6289
  cellId,
@@ -4981,7 +6351,7 @@ server.registerTool("request_looper_warp", {
4981
6351
  cellId: z.number().min(1).max(8).describe("Looper track (1-8)."),
4982
6352
  },
4983
6353
  }, async ({ cellId }) => {
4984
- const check = await requireBridge();
6354
+ const check = await requireBridgeForCells(cellId);
4985
6355
  if (check !== true)
4986
6356
  return check;
4987
6357
  await bridge.send("requestLooperWarp", cellId);
@@ -5129,7 +6499,9 @@ server.registerTool("looper_variation_sync", {
5129
6499
  server.registerTool("queue_variation", {
5130
6500
  title: "Queue Variation Switch",
5131
6501
  description: "Schedule a global variation switch on the next bar boundary. " +
5132
- "For an immediate switch, use load_variation instead.",
6502
+ "For an immediate switch, use load_variation instead. Optionally morph " +
6503
+ "into the new section over the bars before the boundary (song mode / an " +
6504
+ "already-engaged queued-flip window only; a from-idle flip hard-switches).",
5133
6505
  inputSchema: {
5134
6506
  index: z.number().min(0).max(7).describe("Variation slot 0-7."),
5135
6507
  intervalBars: z.number().min(0.125).max(64).describe("Bars before switching."),
@@ -5138,13 +6510,48 @@ server.registerTool("queue_variation", {
5138
6510
  .boolean()
5139
6511
  .default(true)
5140
6512
  .describe("Reset pattern phase on switch."),
6513
+ morphBars: z
6514
+ .number()
6515
+ .min(0.25)
6516
+ .max(8)
6517
+ .optional()
6518
+ .describe("Pre-anchor morph window in bars for this flip (PAM >= 1.19.3). Omit = hard switch."),
6519
+ morphMode: z
6520
+ .enum(["auto", "param", "mix", "seq", "all"])
6521
+ .optional()
6522
+ .describe("Morph style: 'param' glides continuous params (default); 'mix' equal-power blends the two sections; 'seq' adds a staged pattern dissolve to the glide (old notes lose trigger probability as new notes gain it); 'all' runs glide + dissolve + blend together; 'auto' lets the engine pick — mix when the sections differ in graph topology (samples, synth engines, routing) or when nothing can glide, param otherwise (PAM >= 1.19.4; seq/all on older builds run param)."),
6523
+ morphCurve: z
6524
+ .enum(["linear", "ease", "exp", "log"])
6525
+ .optional()
6526
+ .describe("Morph easing; default ease."),
6527
+ transition: z
6528
+ .object({
6529
+ patternPolicy: z.enum(["dissolve", "wipe", "cells"]).optional(),
6530
+ mixLaw: z.enum(["equalPower", "equalGain", "correlation"]).optional(),
6531
+ tail: z.enum(["cut", "fade", "ring"]).optional(),
6532
+ tailMaxBars: z.number().min(0.25).max(8).optional(),
6533
+ })
6534
+ .optional()
6535
+ .describe("Advanced transition settings for this flip (PAM >= 1.20.0): pattern policy for a " +
6536
+ "seq/all morph, blend gain law for a mix/all morph, and the outgoing section's " +
6537
+ "tail. Omitted fields keep the defaults (dissolve, equalPower, fade)."),
5141
6538
  },
5142
- }, async ({ index, intervalBars, fadeMs, resetPhase }) => {
6539
+ }, async ({ index, intervalBars, fadeMs, resetPhase, morphBars, morphMode, morphCurve, transition }) => {
5143
6540
  const check = await requireBridge();
5144
6541
  if (check !== true)
5145
6542
  return check;
5146
- await bridge.send("queueVariationOnBars", { index, intervalBars, fadeMs, resetPhase });
5147
- return text(`Queued variation ${index} on next ${intervalBars}-bar boundary.`);
6543
+ await bridge.send("queueVariationOnBars", {
6544
+ index,
6545
+ intervalBars,
6546
+ fadeMs,
6547
+ resetPhase,
6548
+ ...(morphBars !== undefined && { morphBars }),
6549
+ ...(morphMode !== undefined && { morphMode }),
6550
+ ...(morphCurve !== undefined && { morphCurve }),
6551
+ ...(transition !== undefined && { transition }),
6552
+ });
6553
+ const morphNote = morphBars !== undefined ? ` (morph ${morphBars} bars, ${morphMode ?? "param"})` : "";
6554
+ return text(`Queued variation ${index} on next ${intervalBars}-bar boundary${morphNote}.`);
5148
6555
  });
5149
6556
  server.registerTool("cancel_queued_variation", {
5150
6557
  title: "Cancel Queued Variation",
@@ -5298,16 +6705,17 @@ server.registerTool("midi_recording", {
5298
6705
  // ============================================================
5299
6706
  server.registerTool("update_modulation", {
5300
6707
  title: "Update Modulation",
5301
- description: "Apply a delta to a modulation source: LFO, Envelope, Vary, Macro, ParamSeq config, or ParamSeq sequence data. " +
6708
+ description: "Apply a delta to a modulation source: LFO, Envelope, Vary, Macro, ParamSeq config, ParamSeq sequence data, " +
6709
+ "or the Free Sequencer master pattern. " +
5302
6710
  "The payload is type-specific; see modulation update flows in interop.ts (applyLfoUpdate / updateEnvelope / etc.). " +
5303
6711
  "Use add_modulation to first attach a source to a target — then this tool to edit it.",
5304
6712
  inputSchema: {
5305
6713
  type: z
5306
- .enum(["lfo", "envelope", "vary", "macro", "paramSeq", "paramSeqSequence"])
6714
+ .enum(["lfo", "envelope", "vary", "macro", "paramSeq", "paramSeqSequence", "freeSeqSequence"])
5307
6715
  .describe("Which modulation system to update."),
5308
6716
  payload: z
5309
6717
  .record(z.string(), z.unknown())
5310
- .describe("Update payload object. Shape depends on type: lfo expects { index, rate?, depth?, shape?, customCurve?, target? }, envelope expects matrix entry edits, paramSeqSequence expects { id, steps[] }, etc."),
6718
+ .describe("Update payload object. Shape depends on type: lfo expects { index, rate?, depth?, shape?, customCurve?, target? }, envelope expects matrix entry edits, paramSeqSequence expects { id, steps[] }, freeSeqSequence expects { sequence: number[] } (the master step contour every free-mode ParamSeq follows), etc."),
5311
6719
  },
5312
6720
  }, async ({ type, payload }) => {
5313
6721
  const check = await requireBridge();
@@ -5324,13 +6732,55 @@ server.registerTool("update_modulation", {
5324
6732
  ? "applyMacroUpdate"
5325
6733
  : type === "paramSeq"
5326
6734
  ? "applyParamSeqUpdate"
5327
- : "applyParamSeqSequenceUpdate";
6735
+ : type === "paramSeqSequence"
6736
+ ? "applyParamSeqSequenceUpdate"
6737
+ : "applyFreeSeqSequenceUpdate";
5328
6738
  await bridge.send(action, data);
5329
6739
  return text(`Applied ${type} update.`);
5330
6740
  });
5331
6741
  // ============================================================
5332
6742
  // TOOLS — Song mode
5333
6743
  // ============================================================
6744
+ // Advanced transition spec (ADR-001, PAM >= 1.19.6; engine families live from
6745
+ // PAM >= 1.20.0). Honest contract: it persists, round-trips, and reads back
6746
+ // RESOLVED. Pattern policy (dissolve/wipe/cells), mix law (equal power / equal
6747
+ // gain / correlation), tail (cut/fade/ring) and phase run on song VARIATION
6748
+ // sections and queued bar-sync flips; preset destinations coerce to a param
6749
+ // glide, and offline bounces coerce ring→fade and blend→hard switch (each
6750
+ // reported, never silent). Wire enums are append-only.
6751
+ const morphCurveEnum = z.enum(["linear", "ease", "exp", "log"]);
6752
+ const transitionSpecSchema = z.object({
6753
+ mode: z.enum(["auto", "param", "mix", "seq", "all"]).optional(),
6754
+ window: z
6755
+ .object({
6756
+ bars: z.number().min(0).max(8).optional(),
6757
+ paramCurve: morphCurveEnum.optional(),
6758
+ patternCurve: morphCurveEnum.optional(),
6759
+ mixCurve: morphCurveEnum.optional(),
6760
+ })
6761
+ .optional(),
6762
+ pattern: z.object({ policy: z.enum(["dissolve", "wipe", "cells"]).optional() }).optional(),
6763
+ audio: z
6764
+ .object({
6765
+ mixLaw: z.enum(["equalPower", "equalGain", "correlation"]).optional(),
6766
+ tail: z.enum(["cut", "fade", "ring"]).optional(),
6767
+ tailMaxBars: z.number().min(0.25).max(8).optional(),
6768
+ })
6769
+ .optional(),
6770
+ phase: z.enum(["inherit", "reset", "continue"]).optional(),
6771
+ });
6772
+ const TRANSITION_SPEC_HONESTY = "Advanced transition spec (PAM >= 1.19.6; live engine from PAM >= 1.20.0): pattern.policy " +
6773
+ "('dissolve' prob-morphs events, 'wipe' replaces them at a moving frontier, 'cells' hands " +
6774
+ "whole cells over in ascending order), audio.mixLaw ('equalPower' default, 'equalGain' for " +
6775
+ "correlated sections, 'correlation' auto-adapts), audio.tail ('fade' default, 'cut', 'ring' " +
6776
+ "lets voices/FX decay up to tailMaxBars), phase ('reset'/'continue' overrides the song's " +
6777
+ "Reset Phase per cable), window.patternCurve/mixCurve (per-domain easing; patternCurve " +
6778
+ "defaults linear). Runs on variation sections + queued flips, and on preset destinations " +
6779
+ "when the lanePresetSections setting is on (PAM >= 1.20.1 — the dual-lane preset section " +
6780
+ "pipeline; with it off, preset destinations coerce to a param glide). Offline bounces " +
6781
+ "coerce ring→fade, blend→hard switch. 'get' reads back each cable's resolvedTransition " +
6782
+ "(effective values after inheritance and capability coercion) and lastMorphResolution " +
6783
+ "(the engine's latest AUTO decision + reason).";
5334
6784
  server.registerTool("song_mode", {
5335
6785
  title: "Song Mode",
5336
6786
  description: "Control PAM's song mode (timeline of variation/preset sections). Ops: 'setArrangement', " +
@@ -5342,9 +6792,13 @@ server.registerTool("song_mode", {
5342
6792
  "VX (visual) variation slot via `vxVariationIndex` (omit = visuals follow the section's " +
5343
6793
  "audio slot 1:1; see the vx_variation tool), optionally inside a specific VX scene via " +
5344
6794
  "`vxScenePath` (the .vxpreset loads before the section plays, so sections can pull visuals " +
5345
- "from different scenes). 'get' reads the arrangement back (nodes, compiled steps with the " +
5346
- "VX scene+slot each recalls, total bars); 'assignVx' re-pins one section's VX slot (and " +
5347
- "optional scene) after authoring.\n\n" +
6795
+ "from different scenes). A section can also carry `morphBars`/`morphCurve`/`morphMode` to glide the " +
6796
+ "incoming variation's continuous params over the bars before its boundary (PAM >= 1.19.3), and an " +
6797
+ "advanced `transition` spec (PAM >= 1.19.6 — authored metadata; see the field description). " +
6798
+ "'get' reads the arrangement back (nodes, compiled steps with the " +
6799
+ "VX scene+slot each recalls, total bars, and each cable's `resolvedTransition` — the effective " +
6800
+ "transition after inheritance and capability coercion, e.g. preset destinations report param); " +
6801
+ "'assignVx' re-pins one section's VX slot (and optional scene) after authoring.\n\n" +
5348
6802
  "'setSequence' is the older HEADLESS raw-step path (works with the window closed): steps " +
5349
6803
  "land in the C++ player but carry no grid nodes, so the UI can't edit them and VX can only " +
5350
6804
  "follow 1:1 by audio slot.",
@@ -5365,13 +6819,28 @@ server.registerTool("song_mode", {
5365
6819
  .describe("Operation."),
5366
6820
  sections: z
5367
6821
  .array(z.object({
5368
- variationIndex: z.number().min(0).max(7).describe("Audio variation slot 0-7 this section plays."),
6822
+ variationIndex: z.number().min(0).max(7).describe("Audio variation slot 0-7 this section plays. With presetPath set, this selects the variation INSIDE that preset's own bank — the two compose, they are not mutually exclusive."),
5369
6823
  bars: z.number().positive().optional().describe("Section length in bars (default: auto from longest pattern)."),
5370
6824
  repeat: z.number().int().min(1).optional().describe("Times to play before advancing (default 1)."),
5371
6825
  muted: z.boolean().optional().describe("Pass through without an audio trigger."),
5372
6826
  probability: z.number().min(0).max(1).optional().describe("Trigger probability (0-1)."),
5373
- presetPath: z.string().optional().describe("Load this preset instead of switching variation."),
6827
+ presetPath: z.string().optional().describe("Cross-preset section: at this section's boundary the song switches to THIS preset file, playing its variations[variationIndex] slot. With lanePresetSections on (PAM >= 1.20.1) it transitions exactly like a variation section (prepared off the audible path, full morph family); otherwise the legacy in-place load applies."),
5374
6828
  label: z.string().optional().describe("Section label shown on the grid node (e.g. 'Intro', 'Hook')."),
6829
+ morphBars: z
6830
+ .number()
6831
+ .min(0)
6832
+ .max(8)
6833
+ .optional()
6834
+ .describe("Pre-anchor morph window in bars for the transition INTO this section — continuous params glide to the new values, landing at the boundary (PAM >= 1.19.3). 0 = explicit hard switch (never morph, ignores the song's default); omit to inherit the default. Values in (0, 0.25) clamp up to 0.25."),
6835
+ morphCurve: z
6836
+ .enum(["linear", "ease", "exp", "log"])
6837
+ .optional()
6838
+ .describe("Morph glide easing; default ease. Ignored without morphBars."),
6839
+ morphMode: z
6840
+ .enum(["auto", "param", "mix", "seq", "all"])
6841
+ .optional()
6842
+ .describe("Morph realization; default auto — the engine picks mix when the sections differ in graph topology (samples, synth engines, routing) or when nothing can glide, param otherwise (PAM >= 1.19.4; older builds run param). 'param' glides continuous params; 'mix' DJ-blends stems; 'seq' adds a staged pattern dissolve to the glide; 'all' runs glide + dissolve + blend (seq/all on older builds run param). Ignored without morphBars."),
6843
+ transition: transitionSpecSchema.optional().describe(TRANSITION_SPEC_HONESTY),
5375
6844
  vxVariationIndex: z
5376
6845
  .number()
5377
6846
  .int()
@@ -5399,9 +6868,28 @@ server.registerTool("song_mode", {
5399
6868
  .max(1)
5400
6869
  .optional()
5401
6870
  .describe("Branch probability (0-1). Sibling routes normalize to 100%."),
6871
+ morphBars: z
6872
+ .number()
6873
+ .min(0)
6874
+ .max(8)
6875
+ .optional()
6876
+ .describe("Per-cable morph window in bars — overrides the destination section's morphBars for THIS cable only (branching arrangements can give each incoming cable its own window). 0 = hard switch; omit to use the section value / song default."),
6877
+ morphCurve: z
6878
+ .enum(["linear", "ease", "exp", "log"])
6879
+ .optional()
6880
+ .describe("Per-cable morph easing; overrides the destination section's morphCurve."),
6881
+ morphMode: z
6882
+ .enum(["auto", "param", "mix", "seq", "all"])
6883
+ .optional()
6884
+ .describe("Per-cable morph realization; overrides the destination section's morphMode. Same modes as the section field, incl. 'seq' (pattern dissolve) and 'all' (glide + dissolve + blend)."),
6885
+ transition: transitionSpecSchema.optional().describe("Per-cable " + TRANSITION_SPEC_HONESTY),
5402
6886
  }))
5403
6887
  .optional()
5404
- .describe("For 'setArrangement': optional cable graph. Multiple cables may leave or enter a section."),
6888
+ .describe("For 'setArrangement': optional cable graph. Multiple cables may leave or enter a section. Each cable may carry its own morphBars/morphCurve/morphMode/transition."),
6889
+ defaultTransition: transitionSpecSchema
6890
+ .nullable()
6891
+ .optional()
6892
+ .describe("For 'setArrangement': song-level advanced transition defaults inherited by cables without their own `transition`; null clears. " + TRANSITION_SPEC_HONESTY),
5405
6893
  loop: z.boolean().optional().describe("For 'setArrangement': loop the whole arrangement."),
5406
6894
  nodeId: z.string().optional().describe("For 'assignVx': grid node id (from op 'get')."),
5407
6895
  stepIndex: z.number().int().min(0).optional().describe("For 'assignVx': compiled step index (alternative to nodeId)."),
@@ -5464,6 +6952,7 @@ server.registerTool("song_mode", {
5464
6952
  op: "setSongArrangement",
5465
6953
  sections: args.sections,
5466
6954
  ...(args.connections !== undefined && { connections: args.connections }),
6955
+ ...(args.defaultTransition !== undefined && { defaultTransition: args.defaultTransition }),
5467
6956
  ...(args.loop !== undefined && { loop: args.loop }),
5468
6957
  ...(args.enabled !== undefined && { enabled: args.enabled }),
5469
6958
  ...(args.crossfadeMs !== undefined && { crossfadeMs: args.crossfadeMs }),
@@ -5602,7 +7091,7 @@ server.registerTool("export_song", {
5602
7091
  .describe("For legacy cell-capture 'live': extra bars captured after the arrangement end for tails."),
5603
7092
  },
5604
7093
  }, async ({ strategy, bars, cellId, includeVideo, name, normalize, encoderId, bitDepth, tailBars }) => {
5605
- const check = await requireBridge();
7094
+ const check = await requireBridgeForCells(cellId);
5606
7095
  if (check !== true)
5607
7096
  return check;
5608
7097
  // -------- Offline audio-only: headless multi-section bounce --------
@@ -5811,7 +7300,7 @@ server.registerTool("preview_slice", {
5811
7300
  sliceNumber: z.number().min(0).describe("Slice index."),
5812
7301
  },
5813
7302
  }, async ({ cellId, sliceNumber }) => {
5814
- const check = await requireBridge();
7303
+ const check = await requireBridgeForCells(cellId);
5815
7304
  if (check !== true)
5816
7305
  return check;
5817
7306
  await bridge.send("previewSlice", cellId, sliceNumber);
@@ -5850,7 +7339,7 @@ server.registerTool("restore_original_sample", {
5850
7339
  cellId: z.number().min(1).max(8),
5851
7340
  },
5852
7341
  }, async ({ cellId }) => {
5853
- const check = await requireBridge();
7342
+ const check = await requireBridgeForCells(cellId);
5854
7343
  if (check !== true)
5855
7344
  return check;
5856
7345
  await bridge.send("restoreOriginalSample", { cellId });
@@ -5864,7 +7353,7 @@ server.registerTool("build_sample_chain", {
5864
7353
  cellId: z.number().min(1).max(8).describe("Destination cell."),
5865
7354
  },
5866
7355
  }, async ({ filePaths, cellId }) => {
5867
- const check = await requireBridge();
7356
+ const check = await requireBridgeForCells(cellId);
5868
7357
  if (check !== true)
5869
7358
  return check;
5870
7359
  const result = await bridge.send("buildSampleChain", filePaths, cellId);
@@ -5878,7 +7367,7 @@ server.registerTool("export_slice", {
5878
7367
  sliceIndex: z.number().min(0),
5879
7368
  },
5880
7369
  }, async ({ cellId, sliceIndex }) => {
5881
- const check = await requireBridge();
7370
+ const check = await requireBridgeForCells(cellId);
5882
7371
  if (check !== true)
5883
7372
  return check;
5884
7373
  await bridge.send("exportSliceForCell", { cellId, sliceIndex });
@@ -5923,14 +7412,21 @@ server.registerTool("live_resample", {
5923
7412
  .describe("For 'stop': finalize the captured clip (false discards)."),
5924
7413
  },
5925
7414
  }, async (args) => {
5926
- const check = await requireBridge();
7415
+ const check = await requireBridgeForCells(args.op === "start" ? args.cellId : undefined);
5927
7416
  if (check !== true)
5928
7417
  return check;
5929
7418
  if (args.op === "start") {
7419
+ if (activeLiveResample) {
7420
+ return error(`A live resample started by this server is already targeting cell ${activeLiveResample.cellId}. Stop it first.`);
7421
+ }
5930
7422
  if (typeof args.cellId !== "number" || typeof args.bars !== "number")
5931
7423
  return error("'start' requires cellId + bars");
5932
7424
  const source = args.source ?? "cell";
5933
7425
  const captureSource = source === "fullmix" ? 2 : source === "input" ? 1 : 0;
7426
+ const beforeRaw = await bridge.send("getState");
7427
+ const beforeCells = isRecord(beforeRaw) && isRecord(beforeRaw.cells) ? beforeRaw.cells : {};
7428
+ const beforeCell = beforeCells[String(args.cellId)];
7429
+ const before = isRecord(beforeCell) ? beforeCell : {};
5934
7430
  await bridge.send("resampleGraphToCell", JSON.stringify({
5935
7431
  cellId: args.cellId,
5936
7432
  bars: args.bars,
@@ -5941,10 +7437,48 @@ server.registerTool("live_resample", {
5941
7437
  cellOnly: source === "cell",
5942
7438
  normalize: args.normalize ?? false,
5943
7439
  }));
7440
+ activeLiveResample = {
7441
+ cellId: args.cellId,
7442
+ previousPath: typeof before.filePath === "string" ? before.filePath : undefined,
7443
+ };
5944
7444
  return text(`Started live resample → cell ${args.cellId} (${args.bars} bars, source '${source}').`);
5945
7445
  }
5946
- await bridge.send("stopLiveResample", args.finalize ?? true);
5947
- return text(`Stopped live resample (finalize=${args.finalize ?? true}).`);
7446
+ const finalize = args.finalize ?? true;
7447
+ await bridge.send("stopLiveResample", finalize);
7448
+ const capture = activeLiveResample;
7449
+ activeLiveResample = undefined;
7450
+ if (!finalize)
7451
+ return text("Stopped live resample and discarded the capture.");
7452
+ if (!capture)
7453
+ return error("Stopped live resample, but this server did not start it and cannot identify the destination cell.");
7454
+ let capturedPath;
7455
+ for (let attempt = 0; attempt < 20; attempt++) {
7456
+ const stateRaw = await bridge.send("getState");
7457
+ const state = isRecord(stateRaw) ? stateRaw : {};
7458
+ const stateCells = isRecord(state.cells) ? state.cells : {};
7459
+ const rawCell = stateCells[String(capture.cellId)];
7460
+ const cell = isRecord(rawCell) ? rawCell : {};
7461
+ if (typeof cell.filePath === "string" && cell.filePath && cell.filePath !== capture.previousPath) {
7462
+ capturedPath = cell.filePath;
7463
+ break;
7464
+ }
7465
+ await new Promise((resolveWait) => setTimeout(resolveWait, 250));
7466
+ }
7467
+ if (!capturedPath) {
7468
+ return error(`Capture finalized into cell ${capture.cellId}, but its new file path was not available after 5 seconds.`);
7469
+ }
7470
+ if (!isAbsolute(capturedPath)) {
7471
+ const locations = await bridge.send("getDefaultLocations");
7472
+ const userSamples = Array.isArray(locations)
7473
+ ? locations.find((location) => isRecord(location) && location.name === "User Samples")
7474
+ : undefined;
7475
+ if (isRecord(userSamples) && typeof userSamples.path === "string") {
7476
+ const normalized = capturedPath.replaceAll("\\", "/");
7477
+ const userRelative = normalized.replace(/^Samples\/User\//, "");
7478
+ capturedPath = join(userSamples.path, userRelative);
7479
+ }
7480
+ }
7481
+ return text(`Stopped live resample and finalized cell ${capture.cellId}.\nFile: ${capturedPath}`);
5948
7482
  });
5949
7483
  // ============================================================
5950
7484
  // TOOLS — Plugin host (scan/load/unload/param/bypass on hosted VST3/AU/CLAP)
@@ -6239,7 +7773,7 @@ server.registerTool("update_settings", {
6239
7773
  "experimental-feature gates from an agent. Common boolean keys: experimentalMode, " +
6240
7774
  "experimentalPluginHost, experimentalMcpBridge. To enable the plugin host, set BOTH " +
6241
7775
  "experimentalMode:true AND the specific sub-flag (e.g. experimentalPluginHost:true) in one call. " +
6242
- "The drum synth needs no flag — it is a standard feature on PAM. Other keys " +
7776
+ "Set drumSynthEnabled:false to hide its UI without muting loaded synths. Other keys " +
6243
7777
  "include UI/library preferences — call get_state or inspect the returned object for the current set.",
6244
7778
  inputSchema: {
6245
7779
  settings: z
@@ -6316,6 +7850,40 @@ server.registerTool("get_audio_device_setup", {
6316
7850
  const setup = await bridge.send("getAudioDeviceSetup");
6317
7851
  return json(setup);
6318
7852
  });
7853
+ server.registerTool("get_memory_report", {
7854
+ title: "Get Memory Report",
7855
+ description: "Report the instance's audio memory: vfsBytes (decoded samples resident in the engine's virtual file system), " +
7856
+ "vfsRetiredBytes (superseded buffers not yet reclaimed), vfsEntryCount, looperRingBytes (allocated looper " +
7857
+ "capture rings), and residentBytes (whole-process RSS; residentBytesAvailable is false when the platform " +
7858
+ "could not answer, in which case residentBytes is 0 rather than empty). Use it to check whether loading, " +
7859
+ "replacing or auditioning samples leaves memory flat or growing.",
7860
+ inputSchema: {},
7861
+ annotations: { readOnlyHint: true },
7862
+ }, async () => {
7863
+ const check = await requireBridge();
7864
+ if (check !== true)
7865
+ return check;
7866
+ const report = await bridge.send("getMemoryReport");
7867
+ return json(report);
7868
+ });
7869
+ server.registerTool("set_audio_device", {
7870
+ title: "Set Audio Output Device",
7871
+ description: "Switch the active audio OUTPUT device by name (standalone only — plugin-hosted builds let the DAW own the " +
7872
+ "device, and the call errors there). Use the exact name from get_audio_device_setup's device list. " +
7873
+ "The usual reason to call this headlessly is routing PAM into a loopback device (BlackHole, Soundflower) " +
7874
+ "so a capture run records audio. MIDI devices go through set_midi_io.",
7875
+ inputSchema: {
7876
+ deviceName: z
7877
+ .string()
7878
+ .describe("Output device name exactly as listed by get_audio_device_setup."),
7879
+ },
7880
+ }, async ({ deviceName }) => {
7881
+ const check = await requireBridge();
7882
+ if (check !== true)
7883
+ return check;
7884
+ await bridge.send("setAudioDevice", [deviceName]);
7885
+ return text(`Audio output device set to '${deviceName}'.`);
7886
+ });
6319
7887
  server.registerTool("set_midi_io", {
6320
7888
  title: "Set MIDI I/O Devices",
6321
7889
  description: "Select MIDI input/output devices (standalone). Use get_audio_device_setup to discover device IDs first. " +
@@ -6560,6 +8128,9 @@ const SET_VIEW_MAIN_VIEW = {
6560
8128
  export: "Export",
6561
8129
  presets: "PresetBrowser",
6562
8130
  browser: "PresetBrowser",
8131
+ // MFX-only surfaces. The handler rejects them for other products.
8132
+ patch: "MfxRouting",
8133
+ patchbay: "MfxRouting",
6563
8134
  };
6564
8135
  // Dev-tree twin of the in-plugin assistant's set_view (toolpack-gated like
6565
8136
  // ask_assistant — the published server deliberately excludes window/view
@@ -6579,12 +8150,13 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
6579
8150
  view: z
6580
8151
  .union([z.string(), z.number()])
6581
8152
  .optional()
6582
- .describe("Main view: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets."),
8153
+ .describe("Main view: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets. " +
8154
+ "MFX also takes patch (the cable patchbay)."),
6583
8155
  cellId: z.number().int().optional().describe("Cell to select/focus (1-8)."),
6584
8156
  gridSize: z.number().optional().describe("How many pads the grid shows: 1, 4, or 8."),
6585
8157
  },
6586
8158
  }, async ({ view: viewArg, cellId, gridSize }) => {
6587
- const check = await requireBridge();
8159
+ const check = await requireBridgeForCells(cellId);
6588
8160
  if (check !== true)
6589
8161
  return check;
6590
8162
  let view = viewArg === undefined ? undefined : String(viewArg);
@@ -6600,7 +8172,12 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
6600
8172
  if (typeof view === "string" && view.trim()) {
6601
8173
  token = view.trim().toLowerCase();
6602
8174
  if (!(token in SET_VIEW_MAIN_VIEW))
6603
- return error(`Unknown view: ${view}. Views: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets.`);
8175
+ return error(`Unknown view: ${view}. Views: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets, patch.`);
8176
+ if (token === "patch" || token === "patchbay") {
8177
+ const manifest = await loadManifestForProductGuard();
8178
+ if (manifest && !isMfxManifest(manifest))
8179
+ return error(`${token} is available in MFX only.`);
8180
+ }
6604
8181
  applied.view = token;
6605
8182
  }
6606
8183
  if (gridSize !== undefined) {
@@ -6818,13 +8395,55 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
6818
8395
  // ============================================================
6819
8396
  // Start
6820
8397
  // ============================================================
8398
+ // A stdio server that dies takes its whole tool surface with it, and the client
8399
+ // only ever reports "Connection closed". Nothing short of an explicit exit is
8400
+ // worth that, so stray async faults are logged to stderr and the server lives.
8401
+ // The rule must stay BOUNDED: a fault that repeats every tick turns "the server
8402
+ // lives" into a permanent 100% CPU spin, and each report re-formats a tsx
8403
+ // source-mapped stack. Five orphaned servers burned a core each for over a week.
8404
+ const FAULT_WINDOW_MS = 10_000;
8405
+ const FAULT_LIMIT = 100;
8406
+ let faultTimes = [];
8407
+ function surviveFault(label, value) {
8408
+ const now = Date.now();
8409
+ faultTimes = faultTimes.filter((t) => now - t < FAULT_WINDOW_MS);
8410
+ faultTimes.push(now);
8411
+ if (faultTimes.length > FAULT_LIMIT)
8412
+ process.exit(1);
8413
+ try {
8414
+ console.error(label, value);
8415
+ }
8416
+ catch {
8417
+ process.exit(1);
8418
+ }
8419
+ }
8420
+ // EPIPE on our own stdio is the dead-client signal, not a stray fault: the SDK
8421
+ // writes JSON-RPC to stdout unguarded, and routing that through the handler
8422
+ // below made the logger re-raise the fault it was reporting. Exit at the source.
8423
+ for (const s of [process.stdout, process.stderr]) {
8424
+ s.on("error", (e) => {
8425
+ if (e?.code === "EPIPE")
8426
+ process.exit(0);
8427
+ });
8428
+ }
8429
+ process.on("unhandledRejection", (reason) => {
8430
+ surviveFault("[pam-mcp] unhandled rejection (server continuing):", reason);
8431
+ });
8432
+ process.on("uncaughtException", (err) => {
8433
+ surviveFault("[pam-mcp] uncaught exception (server continuing):", err);
8434
+ });
6821
8435
  async function main() {
6822
8436
  if (await handleCliArgs())
6823
8437
  return;
6824
8438
  // Try to connect to plugin on startup (non-blocking)
6825
- void bridge.connect().then((ok) => {
8439
+ void bridge
8440
+ .connect()
8441
+ .then((ok) => {
6826
8442
  if (ok)
6827
8443
  syncProductHint();
8444
+ })
8445
+ .catch((e) => {
8446
+ console.error("[pam-mcp] startup bridge connect failed:", e);
6828
8447
  });
6829
8448
  const transport = new StdioServerTransport();
6830
8449
  await server.connect(transport);
@@ -6835,9 +8454,27 @@ main().catch((e) => {
6835
8454
  console.error("Fatal:", e);
6836
8455
  process.exit(1);
6837
8456
  });
6838
- process.on("SIGINT", async () => {
8457
+ // MCP clients terminate stdio servers with SIGTERM as often as SIGINT; without
8458
+ // both, the bridge socket is left for the OS to reap.
8459
+ let shuttingDown = false;
8460
+ const shutdown = async () => {
8461
+ if (shuttingDown)
8462
+ return;
8463
+ shuttingDown = true;
6839
8464
  bridge.disconnect();
6840
- await server.close();
8465
+ try {
8466
+ await server.close();
8467
+ }
8468
+ catch {
8469
+ // Already torn down — exiting anyway.
8470
+ }
6841
8471
  process.exit(0);
6842
- });
8472
+ };
8473
+ process.on("SIGINT", shutdown);
8474
+ process.on("SIGTERM", shutdown);
8475
+ // A client that dies without signalling leaves no signal at all — the process is
8476
+ // reparented to launchd and only stdin EOF says the session is gone. Without this
8477
+ // the server outlives every crashed client; ten-day-old orphans were measured.
8478
+ process.stdin.on("end", () => void shutdown());
8479
+ process.stdin.on("close", () => void shutdown());
6843
8480
  //# sourceMappingURL=server.js.map