@map-audio/pam-mcp-server 1.21.0 → 1.25.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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +33 -7
  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 +6 -0
  7. package/dist/bridge.js +151 -168
  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/drumSynthEngines.json +376 -0
  16. package/dist/fingerprint.d.ts +70 -0
  17. package/dist/fingerprint.js +240 -0
  18. package/dist/fingerprint.js.map +1 -0
  19. package/dist/generated/variationSnapshot.d.ts +8 -4
  20. package/dist/generated/variationSnapshot.js +6 -4
  21. package/dist/generated/variationSnapshot.js.map +1 -1
  22. package/dist/generativePlayerPresets.json +70 -0
  23. package/dist/generativePlayers.json +134 -0
  24. package/dist/manifest-cell1.json +4972 -249
  25. package/dist/manifest-mfx.json +11571 -1800
  26. package/dist/manifest.d.ts +15 -3
  27. package/dist/manifest.js +24 -15
  28. package/dist/manifest.js.map +1 -1
  29. package/dist/manifest.json +25034 -499
  30. package/dist/mfxCombos.d.ts +45 -0
  31. package/dist/mfxCombos.js +499 -0
  32. package/dist/mfxCombos.js.map +1 -0
  33. package/dist/midiFile.d.ts +21 -0
  34. package/dist/midiFile.js +108 -0
  35. package/dist/midiFile.js.map +1 -0
  36. package/dist/mixAssist.d.ts +6 -0
  37. package/dist/mixAssist.js +96 -11
  38. package/dist/mixAssist.js.map +1 -1
  39. package/dist/players.d.ts +89 -0
  40. package/dist/players.js +235 -0
  41. package/dist/players.js.map +1 -0
  42. package/dist/presetSave.d.ts +5 -0
  43. package/dist/presetSave.js +22 -0
  44. package/dist/presetSave.js.map +1 -1
  45. package/dist/productProfile.d.ts +9 -0
  46. package/dist/productProfile.js +20 -0
  47. package/dist/productProfile.js.map +1 -0
  48. package/dist/sampleSlicing.d.ts +12 -0
  49. package/dist/sampleSlicing.js +78 -0
  50. package/dist/sampleSlicing.js.map +1 -0
  51. package/dist/server.js +2536 -483
  52. package/dist/server.js.map +1 -1
  53. package/dist/variationSessionKeys.json +34 -1
  54. package/package.json +11 -5
package/dist/server.js CHANGED
@@ -4,27 +4,40 @@ 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
6
  import { buildVariationSnapshotBase } from "./generated/variationSnapshot.js";
7
- import { readFile } from "node:fs/promises";
8
- import { existsSync, readFileSync } from "node:fs";
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";
11
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
12
+ import { existsSync, readFileSync, statSync } from "node:fs";
13
+ import { execFileSync } from "node:child_process";
9
14
  import { inflateSync } from "node:zlib";
10
- import { basename, dirname, resolve } from "node:path";
15
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
11
16
  import { fileURLToPath } from "node:url";
12
17
  import { createDrumSynthCatalog, } from "./drumSynth.js";
13
- import { loadManifest, cellEffectNames, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, setActiveProductHint, walkPresets, } from "./manifest.js";
18
+ import { loadManifest, cellEffectNames, cellEffectOrder, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, setActiveProductHint, walkPresets, } from "./manifest.js";
14
19
  import { PluginBridge } from "./bridge.js";
15
20
  import { buildMixMeasurementFromState, currentFlatFromState, parseMixAnalysisPayload, suggestMix, } from "./mixAssist.js";
16
- import { normalizePresetLookupPath, selectSaveVariationSourcePath, } from "./presetSave.js";
21
+ import { normalizePresetLookupPath, parsePresetFile, selectSaveVariationSourcePath, } from "./presetSave.js";
17
22
  import { STOP_SEQUENCES, buildGenerationPrompt, parseFunctionCalls, renderContextLine, renderFunctionCall, renderFunctionResponse, truncateAtFabricatedResponse, withContext, } from "./fgemma-format.js";
23
+ import { mcpProductProfile } from "./productProfile.js";
24
+ import { MFX_COMBO_COUNT, MFX_COMBO_MANUAL_STRENGTH, buildMfxComboTarget, isMfxComboTargetParameter, mfxComboTargetValueFromCurrent, parseMfxComboSlots, reanchorMfxComboTarget, resolveMfxCombos, setMfxComboTargetStop, } from "./mfxCombos.js";
25
+ import { GENERATIVE_PLAYERS, LISTEN_MODES, MODULATABLE_PLAYER_FIELDS, NO_POLY_ARM, PATTERN_MODES, PLAYER_GENRES, PLAYER_NAMES, PLAYER_SLOT_FIELDS, cellPlayerType, describeCellPlayer, describePlayerSpec, getPlayerSpec, isMelodicPlayer, playerDefaultsFor, playerDensityDisplay, playerGenreConfig, playerMutateDisplay, playerPresetsFor, playerSlotDisplay, presetConfig, randomPlayerSeed, resolvePlayerPreset, resolvePlayerSpec, resolveSlotValue, rolePlayerConfigFor, roleSpreadSummary, snapPlayerValue, } from "./players.js";
26
+ import { buildMidiFile, patternSpeedMultiplier, tilePatternEvents } from "./midiFile.js";
27
+ import { MAX_SLICES, detectOnsetBoundaries, equalBoundaries, normalizeBoundaries } from "./sampleSlicing.js";
18
28
  // --- State ---
19
29
  // Variation slot count. Must stay in sync with PRESET_VARIATION_SLOTS in
20
30
  // interface/src/stores/Preset.ts. Defined locally because the MCP package
21
31
  // can't import from the interface workspace without infra changes.
22
32
  const PRESET_VARIATION_SLOTS = 8;
33
+ const SERVER_PRODUCT = mcpProductProfile();
34
+ const IS_MFX_SERVER = SERVER_PRODUCT.id === "mfx";
23
35
  const bridge = new PluginBridge();
24
36
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
25
37
  const cellModeChangeSeq = {};
26
38
  let lastSavedPresetName;
27
39
  let lastSavedPresetPath;
40
+ let activeLiveResample;
28
41
  // --- Helpers ---
29
42
  function text(s) {
30
43
  return { content: [{ type: "text", text: s }] };
@@ -102,6 +115,9 @@ function validateCellIdForManifest(cellId, manifest) {
102
115
  return true;
103
116
  }
104
117
  function readPackageVersion() {
118
+ const override = IS_MFX_SERVER ? process.env.MFX_MCP_SERVER_VERSION?.trim() : undefined;
119
+ if (override)
120
+ return override;
105
121
  try {
106
122
  const raw = readFileSync(resolve(PACKAGE_ROOT, "package.json"), "utf-8");
107
123
  const pkg = JSON.parse(raw);
@@ -122,9 +138,9 @@ async function handleCliArgs() {
122
138
  }
123
139
  if (args.has("--help") || args.has("-h")) {
124
140
  const version = SERVER_VERSION;
125
- console.log(`pam-mcp ${version}\n\n` +
126
- "MCP stdio server for PAM. Configure your MCP client to launch this command; " +
127
- "it reads ~/.pam-mcp-bridge.json to find a running PAM instance.");
141
+ console.log(`${SERVER_PRODUCT.command} ${version}\n\n` +
142
+ `MCP stdio server for ${SERVER_PRODUCT.name}. Configure your MCP client to launch this command; ` +
143
+ `it reads the local bridge registry to find a running ${SERVER_PRODUCT.name} instance.`);
128
144
  return true;
129
145
  }
130
146
  return false;
@@ -134,36 +150,55 @@ async function handleCliArgs() {
134
150
  function syncProductHint() {
135
151
  setActiveProductHint(bridge.getConnectedInstance()?.product);
136
152
  }
153
+ function resolveServerMfxCombos(state, manifest) {
154
+ const instance = bridge.getConnectedInstance();
155
+ const cacheKey = instance?.instanceId ?? (instance ? `${instance.product}:${instance.port}` : manifest.bundleId);
156
+ return resolveMfxCombos(state, manifest, cacheKey);
157
+ }
137
158
  async function requireBridge() {
138
159
  if (bridge.isConnected())
139
160
  return true;
140
161
  const ok = await bridge.connect();
141
162
  if (!ok) {
142
163
  const pin = process.env.PAM_BRIDGE_PRODUCT;
143
- return error("Not connected to PAM plugin. Start PAM (standalone or in a DAW) with MCP bridge enabled. " +
144
- "The bridge writes its port to ~/.pam-mcp-bridge.json on startup." +
164
+ return error(`Not connected to ${SERVER_PRODUCT.name}. Start ${SERVER_PRODUCT.name} (standalone or in a DAW) with MCP bridge enabled. ` +
165
+ (IS_MFX_SERVER
166
+ ? "The launcher finds MFX through the local bridge registry; the plugin records its actual port on startup."
167
+ : "The bridge writes its port to ~/.pam-mcp-bridge.json on startup.") +
145
168
  (pin
146
- ? ` Note: PAM_BRIDGE_PRODUCT pins this server to '${pin}' — no matching instance was found (connection_status lists what is running).`
169
+ ? IS_MFX_SERVER
170
+ ? " The MFX launcher is product-pinned, but no matching instance was found."
171
+ : ` Note: PAM_BRIDGE_PRODUCT pins this server to '${pin}' — no matching instance was found (connection_status lists what is running).`
147
172
  : ""));
148
173
  }
149
174
  syncProductHint();
150
175
  return true;
151
176
  }
152
177
  /**
153
- * Parse the PAM binary preset format: <metadataSize as text int><metadata JSON><data JSON>
178
+ * requireBridge() plus product-aware cell bounds, for tools that ADDRESS a cell.
179
+ *
180
+ * The zod schemas cap cellId at 8 because they are built before we know which
181
+ * product is on the other end. Cell1 has 1 cell and MFX has 4, so without this
182
+ * an agent aims at cell 7 and gets a bridge-level failure or a silent no-op
183
+ * instead of being told the cell does not exist. Read filters (get_state,
184
+ * get_design_overview) deliberately stay unguarded — returning only the cells
185
+ * that exist is the right answer there. Manifests are cached, so this is
186
+ * effectively free after the first call.
154
187
  */
155
- function parsePresetFile(raw) {
156
- const sizeMatch = raw.match(/^(\d+)/);
157
- if (!sizeMatch)
158
- throw new Error("invalid format (no metadata size prefix)");
159
- const prefixLen = sizeMatch[1].length;
160
- const metadataSize = parseInt(sizeMatch[1], 10);
161
- if (metadataSize <= 0 || prefixLen + metadataSize > raw.length) {
162
- throw new Error(`invalid format (metadata size ${metadataSize} out of bounds for file length ${raw.length})`);
163
- }
164
- const metadataStr = raw.slice(prefixLen, prefixLen + metadataSize);
165
- const dataStr = raw.slice(prefixLen + metadataSize);
166
- return { metadata: JSON.parse(metadataStr), data: JSON.parse(dataStr) };
188
+ async function requireBridgeForCells(...cellIds) {
189
+ const check = await requireBridge();
190
+ if (check !== true)
191
+ return check;
192
+ const ids = cellIds.flat().filter((c) => typeof c === "number");
193
+ if (ids.length === 0)
194
+ return true;
195
+ const manifest = await loadManifestForProductGuard();
196
+ for (const id of new Set(ids)) {
197
+ const cellCheck = validateCellIdForManifest(id, manifest);
198
+ if (cellCheck !== true)
199
+ return cellCheck;
200
+ }
201
+ return true;
167
202
  }
168
203
  // PAM ≥ 1.4.61 stores variations ONLY in the data block, zlib+base64-compressed with a
169
204
  // "PAMZ1:" marker (see helpers/VariationCodec.h). Decompress one entry to plain JSON;
@@ -194,20 +229,17 @@ function variationsFromParsedPreset(parsed) {
194
229
  return Array(PRESET_VARIATION_SLOTS).fill(null);
195
230
  return src.map(decompressVariationEntry);
196
231
  }
197
- /**
198
- * Read existing variations array from a preset file on disk.
199
- * Returns Array(8) of plain-JSON strings or nulls.
200
- */
201
- async function readExistingVariations(presetPath) {
232
+ async function readPresetForSave(presetPath, allowMissing = false) {
233
+ let fullPath;
202
234
  try {
203
- const fullPath = await resolvePresetPath(presetPath);
204
- const raw = await readFile(fullPath, "utf-8");
205
- return variationsFromParsedPreset(parsePresetFile(raw));
235
+ fullPath = await resolvePresetPath(presetPath);
206
236
  }
207
- catch {
208
- // Preset doesn't exist yet or can't be read — start fresh
209
- return Array(PRESET_VARIATION_SLOTS).fill(null);
237
+ catch (error) {
238
+ if (allowMissing && error.code === "ENOENT")
239
+ return null;
240
+ throw error;
210
241
  }
242
+ return parsePresetFile(await readFile(fullPath));
211
243
  }
212
244
  const VARIATION_CONTRACT = (() => {
213
245
  const here = dirname(fileURLToPath(import.meta.url));
@@ -247,6 +279,7 @@ const VARIATION_EXCLUDED_PARAMS = [
247
279
  function buildVariationString(state) {
248
280
  return JSON.stringify(buildVariationSnapshotBase(state, {
249
281
  sessionKeys: VARIATION_EXCLUDED_PARAMS,
282
+ transientKeys: VARIATION_CONTRACT.transientDispatchKeys ?? [],
250
283
  visualParamIds: VARIATION_CONTRACT.visualParamIds ?? [],
251
284
  lfoGeneratorCount: VARIATION_CONTRACT.lfoGeneratorCount ?? 6,
252
285
  }));
@@ -261,17 +294,23 @@ function buildVariationString(state) {
261
294
  * so the save result can say the snapshot is lossy.
262
295
  */
263
296
  async function fetchSaveSnapshot() {
297
+ let state;
298
+ let canonical = false;
264
299
  try {
265
300
  const snap = await bridge.send("getSaveSnapshot");
266
301
  if (snap && typeof snap === "object") {
267
- return { state: snap, canonical: true };
302
+ state = snap;
303
+ canonical = true;
304
+ }
305
+ else {
306
+ state = (await bridge.send("getState"));
268
307
  }
269
308
  }
270
309
  catch {
271
310
  // Older PAM: action not registered. Fall through.
311
+ state = (await bridge.send("getState"));
272
312
  }
273
- const state = (await bridge.send("getState"));
274
- return { state, canonical: false };
313
+ return { state, canonical };
275
314
  }
276
315
  /** Cells whose hosted plugin this snapshot cannot fully capture. */
277
316
  function hostedPluginCells(state) {
@@ -316,14 +355,9 @@ function formatParam(p) {
316
355
  return desc;
317
356
  }
318
357
  const FULL_STATE_RESPONSE_LIMIT_CHARS = 24_000;
319
- // Cell effects are derived per product from the manifest (`cellEffectNames`) —
320
- // this constant is only the fallback for a manifest that failed to load. The
321
- // hardcoded eight it used to be is why get_design_overview under-reported:
322
- // limiter, qverb, eq8, panner, gain, vocoder (and MFX's wavefold/resonator)
323
- // were enabled in state but absent from `enabledEffects`.
324
- const CELL_EFFECTS_FALLBACK = [
325
- "filter", "delay", "crusher", "flanger", "eq", "fdn", "gate", "compressor",
326
- ];
358
+ // Preset listings grow with every installed pack; cap the unfiltered dump so it
359
+ // can't crowd out the agent's context (`search` narrows it).
360
+ const LIST_PRESETS_MAX = 400;
327
361
  const PATTERN_LANES = {
328
362
  note: "midiSequence",
329
363
  pitch: "pitchSequence",
@@ -339,6 +373,10 @@ const LFO_DIVISIONS = [
339
373
  "1/32D", "1/32", "1/32T",
340
374
  "1/64D", "1/64", "1/64T",
341
375
  ];
376
+ // Index/enum params. derive-corpus-ranges.mjs skips them (a percentile over an
377
+ // index means nothing), so design_context must not read their missing corpus
378
+ // row as "no preset ever used this". Keep in sync with ENUMISH in that script.
379
+ const CORPUS_ENUM_PARAMS = /^(mode|filterType|arpegMode|gateProgram|synthEngine|outputMode|outputIndex|crossfadeAssign|grainWindow|grainArp|fmSource|fmPolarity|gainClipMode|patternDirection|quantRoot|quantScale|.*SyncEnabled|.*Enabled)$/;
342
380
  function levenshtein(a, b) {
343
381
  const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
344
382
  const curr = Array(b.length + 1).fill(0);
@@ -526,55 +564,18 @@ function stemName(path) {
526
564
  }
527
565
  // --- Server ---
528
566
  const server = new McpServer({
529
- name: "pam",
530
- title: "PAM Audio Sampler",
567
+ name: SERVER_PRODUCT.id,
568
+ title: SERVER_PRODUCT.serverTitle,
531
569
  version: SERVER_VERSION,
532
570
  }, {
533
- instructions: "PAM is an audio sampler plugin. Start design/read work with design_context (curated param " +
534
- "cheat sheet) and get_design_overview (compact live snapshot) instead of full get_state or " +
535
- "broad get_parameter_info; when you need detail, call get_state with section/keys/cellIds filters. " +
536
- "When browsing samples, ALWAYS call get_default_locations " +
537
- "first to discover the user's actual library paths — never guess paths like ~/Music/PAM. " +
538
- "The sample library lives under ~/Library/Application Support/, NOT ~/Music/. " +
539
- "Use the returned paths with list_samples. When loading samples, use load_sample with " +
540
- "paths from list_samples. Transport must be playing for sequencer output. " +
541
- "To enable experimental features (plugin host, AI engines) call update_settings with both " +
542
- "experimentalMode:true AND the specific sub-flag (e.g. experimentalPluginHost:true). " +
543
- "The standard Drum Synth UI follows drumSynthEnabled (default true); this preference does not mute synth audio. " +
544
- "set_mfx_routing is the MFX cable patchbay and errors on PAM/Cell1; set_audio_device is standalone-only. " +
545
- "Tools added in server version 1.7.0 — update_settings, configure_transport, " +
546
- "get_audio_device_setup, set_midi_io, set_midi_mapping, delete_preset, " +
547
- "set_preset_factory_status, get_sample_usage, repair_preset_samples, preview_sample, " +
548
- "set_plugin_params, skip_current_plugin_scan, remove_plugin_from_blocklist — require PAM ≥ 1.7.0. " +
549
- "VX video tools (get_video_state, video_command, add_video_layer, update_video_layer, " +
550
- "remove_video_layer, apply_video_layout, add_video_fx, set_video_fx, remove_video_fx, " +
551
- "bind_video_param, set_video_settings, set_video_layer_source) require PAM ≥ 1.8.0 AND PAM's window to be OPEN " +
552
- "(the video engine renders in the WebView; there is no headless renderer). " +
553
- "capture_ui, set_visual, set_drum_synth_wave, and randomize with target 'synth' are " +
554
- "WebView-routed too and need the window open for the same reason. For " +
555
- "audio-reactive control, note the visual audio-react params (visualWaveDisplace, etc.) " +
556
- "are regular plugin params — drive them with set_parameters; layers/FX/bindings go through " +
557
- "the video tools. When building a scene, call get_video_state first to learn layer/fx ids. " +
558
- "AI model lifecycle tools (download_model, get_model_status, load_model, unload_model, " +
559
- "delete_model, list_models; engines aigen|magenta|notochord) require PAM ≥ 1.9.0 plus the " +
560
- "matching experimental flags; load/unload/delete can block for seconds — poll " +
561
- "get_model_status rather than tight-looping. " +
562
- "Earlier tool sets (looper, song mode, modulation edits, MIDI recording, sample analysis, " +
563
- "live resample, variation scheduling, AI generation) require PAM ≥ 1.5.0. " +
564
- "SONG WORKFLOW: author sections as variation slots (save_variation), arrange them with " +
565
- "song_mode (op setArrangement authors real Song Mode grid nodes and can pin a VX variation " +
566
- "slot per section via vxVariationIndex; op setSequence is the older headless raw-step path), " +
567
- "manage visual variation slots with vx_variation (save/load/clear/fill — the VX siblings of " +
568
- "save_variation), then bounce with export_song (includeVideo:true films the VX scene with " +
569
- "variation follow live). song_mode setArrangement/get/assignVx, vx_variation, and " +
570
- "export_song includeVideo are WebView-routed (window open, PAM ≥ 1.5.7). " +
571
- "If a tool fails with \"PAM build does not support 'X'\", the connected plugin is older than " +
572
- "this MCP server; the user must update PAM (updating the npm package alone is not enough). " +
573
- "Tool annotations are accurate: readOnlyHint tools never mutate the plugin, destructiveHint " +
574
- "tools discard user state (confirm intent before calling them). When several product " +
575
- "instances run at once (PAM + MFX), select_instance chooses which one the live tools drive " +
576
- "(PAM_BRIDGE_PRODUCT pins it at launch). Guided workflow prompts are available: build-a-beat, " +
577
- "make-a-song, design-a-preset, mix-and-master, troubleshoot-bridge.",
571
+ instructions: IS_MFX_SERVER
572
+ ? "MFX is a four-cell modular FX router: cells are patched with cables and each cell runs its own effect chain. " +
573
+ "Use cells 1-4. Treat tool annotations as authoritative and use select_instance when several products are running."
574
+ : "PAM is an eight-cell sampler. " +
575
+ "Sequencer output requires a running transport. UI, video, and visual tools require the plugin " +
576
+ "window open; experimental plugin-host and AI tools require experimentalMode plus the matching " +
577
+ "feature flag. Treat tool annotations as authoritative and use select_instance when several " +
578
+ "products are running. A 'build does not support' error means update the plugin, not only this server.",
578
579
  });
579
580
  // --- tool annotation policy ---------------------------------------------------
580
581
  // Single audit surface for MCP tool annotations. Every tool MUST have an entry;
@@ -598,19 +599,24 @@ const TOOL_ANNOTATIONS = {
598
599
  read_preset: RO,
599
600
  get_manifest_summary: RO,
600
601
  design_context: RO,
602
+ preset_novelty: RO,
603
+ variation_distinctness: RO,
604
+ analyze_motion: W,
601
605
  get_state: RO,
602
606
  get_design_overview: RO,
603
607
  get_transport: RO,
604
608
  get_master_analysis: RO,
605
- suggest_mix: RO, // pure function over cached analysis + current state
609
+ suggest_mix: W,
606
610
  connection_status: RO,
611
+ situation: RO,
607
612
  list_samples: RO,
608
613
  get_default_locations: RO,
609
614
  get_audio_info: RO,
610
615
  get_video_state: RO,
611
- capture_ui: RO, // writes a screenshot file, mutates no plugin state
616
+ capture_ui: WI,
612
617
  get_sample_peaks: RO,
613
618
  get_audio_device_setup: RO,
619
+ get_memory_report: RO,
614
620
  get_sample_usage: RO,
615
621
  get_model_status: RO,
616
622
  list_models: RO,
@@ -640,6 +646,11 @@ const TOOL_ANNOTATIONS = {
640
646
  remove_param_seq: DI,
641
647
  set_drum_synth: WI,
642
648
  set_drum_synth_wave: WI,
649
+ list_players: RO,
650
+ set_player: WI,
651
+ set_slices: WI,
652
+ set_free_sequencer: WI,
653
+ export_midi: W,
643
654
  stop_cell: WI,
644
655
  reset_cell: DI,
645
656
  copy_cell: DI, // overwrites the destination cell
@@ -659,6 +670,7 @@ const TOOL_ANNOTATIONS = {
659
670
  midi_recording: W,
660
671
  // Presets / variations
661
672
  load_preset: DI, // replaces the working state (unsaved edits are lost)
673
+ get_preset_load_status: RO,
662
674
  save_preset: WI,
663
675
  new_preset: DI,
664
676
  import_preset: WI,
@@ -698,7 +710,7 @@ const TOOL_ANNOTATIONS = {
698
710
  looper_settings: WI,
699
711
  looper_variation: W,
700
712
  looper_variation_sync: WI,
701
- live_resample: W,
713
+ live_resample: D, // finalize replaces the destination cell; finalize:false discards the take
702
714
  // Song mode / export
703
715
  song_mode: W,
704
716
  export_song: W,
@@ -707,6 +719,9 @@ const TOOL_ANNOTATIONS = {
707
719
  update_settings: WI,
708
720
  set_audio_device: WI,
709
721
  set_mfx_routing: WI,
722
+ set_mfx_edge_gain: WI,
723
+ configure_mfx_combo: WI,
724
+ set_effect_order: WI,
710
725
  set_midi_io: WI,
711
726
  set_midi_mapping: WI,
712
727
  // Plugin host
@@ -746,7 +761,7 @@ const TOOL_ANNOTATIONS = {
746
761
  set_video_fx: WI,
747
762
  remove_video_fx: DI,
748
763
  bind_video_param: WI,
749
- set_video_settings: WI,
764
+ set_video_settings: D, // resetWorkspace:true clears the scene
750
765
  vx_variation: W,
751
766
  // Connection management
752
767
  select_instance: WI,
@@ -755,18 +770,231 @@ const TOOL_ANNOTATIONS = {
755
770
  ask_assistant: D, // executes arbitrary model-chosen tool calls
756
771
  };
757
772
  const internalTools = new Map();
773
+ // The shared implementation contains sampler, song, video, AI, hosted-
774
+ // instrument, and MFX operations. A dedicated MFX launcher must not advertise
775
+ // sampler workflows merely because their handlers live in this file: tool
776
+ // discovery is part of the product contract, not an implementation inventory.
777
+ const MFX_TOOL_NAMES = new Set([
778
+ "get_parameter_info",
779
+ "list_presets",
780
+ "read_preset",
781
+ "get_manifest_summary",
782
+ "design_context",
783
+ "variation_distinctness",
784
+ "analyze_motion",
785
+ "get_state",
786
+ "get_design_overview",
787
+ "get_transport",
788
+ "connection_status",
789
+ "situation",
790
+ "get_default_locations",
791
+ "capture_ui",
792
+ "get_memory_report",
793
+ "set_parameters",
794
+ "add_modulation",
795
+ "update_modulation",
796
+ "clear_modulations",
797
+ "update_effect_config",
798
+ "update_cell_config",
799
+ "set_cell_mute",
800
+ "set_master_effects",
801
+ "add_param_seq",
802
+ "update_param_seq",
803
+ "remove_param_seq",
804
+ "set_free_sequencer",
805
+ "stop_cell",
806
+ "reset_cell",
807
+ "copy_cell",
808
+ "start_transport",
809
+ "stop_transport",
810
+ "full_stop_transport",
811
+ "set_bpm",
812
+ "set_metronome",
813
+ "configure_transport",
814
+ "load_preset",
815
+ "get_preset_load_status",
816
+ "save_preset",
817
+ "new_preset",
818
+ "import_preset",
819
+ "export_preset",
820
+ "update_preset_metadata",
821
+ "delete_preset",
822
+ "set_preset_factory_status",
823
+ "load_variation",
824
+ "save_variation",
825
+ "queue_variation",
826
+ "cancel_queued_variation",
827
+ "update_variations",
828
+ "variation_hold",
829
+ "export_audio",
830
+ "update_settings",
831
+ "get_audio_device_setup",
832
+ "set_audio_device",
833
+ "set_mfx_routing",
834
+ "set_mfx_edge_gain",
835
+ "configure_mfx_combo",
836
+ "set_effect_order",
837
+ "set_midi_io",
838
+ "set_midi_mapping",
839
+ "select_instance",
840
+ ]);
841
+ function mfxSurfaceText(value) {
842
+ return value
843
+ // MFX has its own version line, so a PAM version gate names a build MFX never reaches.
844
+ .replace(/PAM ?(?:≥|>=) ?\d+(?:\.\d+)*/g, "a current MFX build")
845
+ .replaceAll("a PAM build", "an MFX build")
846
+ .replaceAll(" saved via set_visual", "")
847
+ .replaceAll(" Check corr in get_master_analysis after widening.", "")
848
+ .replaceAll(" (same constraint as the video tools)", "")
849
+ .replaceAll("so the preset_novelty scale does", "so a whole-preset novelty scale does")
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
+ const MFX_MAX_CELL = 4;
857
+ // Zod wrappers are immutable, so narrowing a leaf means rebuilding the
858
+ // optional/default/array/union chain around it and re-attaching the description.
859
+ function rebuildSchema(schema, narrowLeaf) {
860
+ const def = schema._zod?.def;
861
+ const keepText = (next) => (schema.description ? next.describe(schema.description) : next);
862
+ const inner = () => rebuildSchema(def.innerType, narrowLeaf);
863
+ switch (def?.type) {
864
+ case "optional":
865
+ return keepText(inner().optional());
866
+ case "nullable":
867
+ return keepText(inner().nullable());
868
+ case "default":
869
+ return keepText(inner().default(def.defaultValue));
870
+ case "array":
871
+ return keepText(z.array(rebuildSchema(def.element, narrowLeaf)));
872
+ case "union":
873
+ return keepText(z.union(def.options.map((option) => rebuildSchema(option, narrowLeaf))));
874
+ default:
875
+ return keepText(narrowLeaf(schema));
876
+ }
877
+ }
878
+ function schemaKind(schema) {
879
+ return schema._zod?.def?.type;
880
+ }
881
+ const capCellId = (leaf) => schemaKind(leaf) === "number" ? leaf.max(MFX_MAX_CELL) : leaf;
882
+ const dropEnumMembers = (drop) => (leaf) => {
883
+ if (schemaKind(leaf) !== "enum")
884
+ return leaf;
885
+ const entries = leaf._zod.def.entries;
886
+ const kept = Object.values(entries).filter((value) => !drop.includes(value));
887
+ return z.enum(kept);
888
+ };
889
+ // MFX ships four cells and the shared schemas advertise PAM's eight. The runtime
890
+ // guard is manifest-driven; this is the advertised half of the same contract.
891
+ const MFX_CELL_FIELDS = new Set([
892
+ "cellId",
893
+ "sourceCellId",
894
+ "targetCellId",
895
+ "cellIds",
896
+ "selectedCellIds",
897
+ "sidechainFrom",
898
+ ]);
899
+ // Enum members MFX has no surface for: Vary is removed from its modulation stack,
900
+ // and neither song_mode nor a looper tool is registered on this launcher.
901
+ const MFX_ENUM_DROPS = {
902
+ add_modulation: { type: ["vary"] },
903
+ get_state: { section: ["vary", "loopers", "songMode"] },
904
+ export_audio: { mode: ["song-mode"] },
905
+ };
906
+ const MFX_KEPT_FIELDS = {
907
+ update_cell_config: ["cellId", "sidechainFrom"],
908
+ };
909
+ const MFX_DROPPED_FIELDS = {
910
+ select_instance: ["product"],
911
+ // MFX cells are effect slots — there is no synth/sampler tab to switch.
912
+ capture_ui: ["instrumentView"],
913
+ };
914
+ const MFX_TOOL_DESCRIPTIONS = {
915
+ select_instance: "Choose which running MFX instance the live tools drive. Match by instanceId or port from connection_status. The launcher refuses non-MFX instances.",
916
+ update_cell_config: "Set one MFX cell's sidechain source. A non-null source also enables that cell's compressor.",
917
+ capture_ui: "Render MFX's plugin UI (#app_root) to a PNG via html2canvas and return the saved file path (read it to " +
918
+ "view the image). Optionally compose the view first: gridSize (1 or 4), mainView (CellGrid|MfxRouting|" +
919
+ "CtrlAll|Envelope|LFO|ParamSeq|Mixer|PresetBrowser; omit/null = cell grid). REQUIRES MFX's window OPEN — " +
920
+ "html2canvas needs the live DOM, there is no headless renderer. Beats screen-capture: deterministic, no " +
921
+ "cursor/screen control, exact UI bitmap. View arguments switch the live editor and the previous view is " +
922
+ "not restored.",
923
+ };
924
+ const MFX_FIELD_DESCRIPTIONS = {
925
+ capture_ui: { gridSize: "Cell grid size to set before capture: 1 or 4." },
926
+ export_audio: { bars: "Bars to render." },
927
+ add_modulation: {
928
+ cellId: "Required for envelope: source cell driving the follower (1-4). This is not the target cell; target is inferred from paramId.",
929
+ },
930
+ };
931
+ // Text whose subject is a tool this launcher does not register.
932
+ function mfxDescriptionEdits(name, value) {
933
+ if (name === "export_audio")
934
+ return value
935
+ .replace("Set `mode` to bounce stems or the whole Song Mode arrangement.", "Set `mode` to bounce per-cell or per-bus stems.")
936
+ .replace(" · 'song-mode' (the Song Mode timeline)", "")
937
+ .replace(/\n\nNOTE on 'song-mode':[\s\S]*$/, "");
938
+ if (name === "analyze_motion")
939
+ return value.replace("Fills the gap left by get_master_analysis (loudness) and get_mix_analysis (aggregate spectrum + onset " +
940
+ "density), neither of which can tell a modulated preset from a static one.", "No other tool on this surface can tell a modulated preset from a static one.");
941
+ if (name === "set_master_effects")
942
+ return value.replace("Check results with get_master_analysis (keep tp <= -1 dBTP, PSR >= 8 in the loudest section). ", "Aim for tp <= -1 dBTP and PSR >= 8 in the loudest section; no loudness analysis tool is registered on this surface. ");
943
+ if (name === "set_parameters")
944
+ return (value +
945
+ " Writing mfx_combo1, mfx_combo2 or mfx_combo3 also fans the value out to that combo's resolved target " +
946
+ "parameters, unless one of those targets is written in the same call.");
947
+ return value;
948
+ }
949
+ function mfxToolConfig(name, config) {
950
+ const source = config;
951
+ const kept = MFX_KEPT_FIELDS[name];
952
+ const droppedFields = MFX_DROPPED_FIELDS[name] ?? [];
953
+ const enumDrops = MFX_ENUM_DROPS[name] ?? {};
954
+ const fieldText = MFX_FIELD_DESCRIPTIONS[name] ?? {};
955
+ const inputSchema = source.inputSchema
956
+ ? Object.fromEntries(Object.entries(source.inputSchema)
957
+ .filter(([key]) => (!kept || kept.includes(key)) && !droppedFields.includes(key))
958
+ .map(([key, schema]) => {
959
+ let narrowed = schema;
960
+ if (enumDrops[key])
961
+ narrowed = rebuildSchema(narrowed, dropEnumMembers(enumDrops[key]));
962
+ if (MFX_CELL_FIELDS.has(key))
963
+ narrowed = rebuildSchema(narrowed, capCellId);
964
+ const described = fieldText[key] ??
965
+ (narrowed.description
966
+ ? mfxSurfaceText(MFX_CELL_FIELDS.has(key)
967
+ ? narrowed.description.replaceAll("1-8", "1-4").replaceAll("1..8", "1..4")
968
+ : narrowed.description)
969
+ : undefined);
970
+ return [key, described ? narrowed.describe(described) : narrowed];
971
+ }))
972
+ : undefined;
973
+ const description = MFX_TOOL_DESCRIPTIONS[name] ??
974
+ (source.description ? mfxSurfaceText(mfxDescriptionEdits(name, source.description)) : undefined);
975
+ return {
976
+ ...source,
977
+ ...(description ? { description } : {}),
978
+ ...(inputSchema ? { inputSchema } : {}),
979
+ };
980
+ }
758
981
  {
759
982
  const origRegisterTool = server.registerTool.bind(server);
760
983
  server.registerTool = ((name, config, handler) => {
761
- internalTools.set(name, { shape: config.inputSchema, handler });
984
+ // Registration handles are never retained by this module; omitted product
985
+ // tools therefore have no runtime handle to return.
986
+ if (IS_MFX_SERVER && !MFX_TOOL_NAMES.has(name))
987
+ return undefined;
988
+ const productConfig = (IS_MFX_SERVER ? mfxToolConfig(name, config) : config);
989
+ internalTools.set(name, { shape: productConfig.inputSchema, handler });
762
990
  // Merge the central annotation policy. Inline annotations at the call site
763
991
  // win per key; openWorldHint is false server-wide (local plugin only).
764
- const cfg = config;
992
+ const cfg = productConfig;
765
993
  if (!TOOL_ANNOTATIONS[name]) {
766
- console.error(`[pam-mcp] tool '${name}' has no TOOL_ANNOTATIONS entry — classify it (check-tool-surface fails on this)`);
994
+ console.error(`[${SERVER_PRODUCT.command}] tool '${name}' has no TOOL_ANNOTATIONS entry — classify it (check-tool-surface fails on this)`);
767
995
  }
768
996
  cfg.annotations = { openWorldHint: false, ...TOOL_ANNOTATIONS[name], ...cfg.annotations };
769
- return origRegisterTool(name, config, handler);
997
+ return origRegisterTool(name, productConfig, handler);
770
998
  });
771
999
  }
772
1000
  async function callInternalTool(name, args) {
@@ -872,7 +1100,7 @@ server.registerTool("get_parameter_info", {
872
1100
  });
873
1101
  server.registerTool("list_presets", {
874
1102
  title: "List Presets",
875
- description: "List available PAM presets (factory and user).",
1103
+ description: "List available PAM presets (factory and user) as an array of {name, path}. Above 400 matches it returns {presets, total, truncated, hint} instead — narrow with `search` rather than paging.",
876
1104
  inputSchema: {
877
1105
  search: z.string().optional().describe("Filter presets by name."),
878
1106
  },
@@ -892,7 +1120,20 @@ server.registerTool("list_presets", {
892
1120
  }
893
1121
  if (allPresets.length === 0)
894
1122
  return text("No presets found.");
895
- return json(allPresets.map((p) => ({ name: p.name, path: p.relativePath })));
1123
+ // A pack-heavy library runs to thousands of entries and would swamp the
1124
+ // agent's context. `search` is the narrowing control — it is already in the
1125
+ // FunctionGemma toolpack schema, so this stays an output-side cap and the
1126
+ // inputSchema does not drift. The plain array shape is preserved unless the
1127
+ // cap actually bites, so normal libraries see exactly what they saw before.
1128
+ const entries = allPresets.map((p) => ({ name: p.name, path: p.relativePath }));
1129
+ if (entries.length <= LIST_PRESETS_MAX)
1130
+ return json(entries);
1131
+ return json({
1132
+ presets: entries.slice(0, LIST_PRESETS_MAX),
1133
+ total: entries.length,
1134
+ truncated: entries.length - LIST_PRESETS_MAX,
1135
+ hint: "Pass `search` to narrow the list — only the first entries are shown.",
1136
+ });
896
1137
  });
897
1138
  server.registerTool("read_preset", {
898
1139
  title: "Read Preset",
@@ -974,8 +1215,11 @@ server.registerTool("get_manifest_summary", {
974
1215
  });
975
1216
  server.registerTool("design_context", {
976
1217
  title: "Design Context",
977
- description: "Compact parameter-design cheat sheet for agents: macro targets, per-effect key params, musical ranges, " +
978
- "unit notes, LFO/delay division hints, MIDI cell note map, and variation slot semantics.",
1218
+ description: "Compact parameter-design cheat sheet for agents: the product's global controls and full per-cell effect vocabulary, " +
1219
+ "value bands measured from the " +
1220
+ "shipped factory presets, an underexplored-parameter list, macro targets, unit traps, LFO/delay division " +
1221
+ "tables, FX chain order, MIDI cell note map, and variation slot semantics. The bands describe where " +
1222
+ "shipped presets sit — they are not limits, and the distinctive end of a range is usually outside them.",
979
1223
  inputSchema: {
980
1224
  cellId: z
981
1225
  .number()
@@ -984,13 +1228,73 @@ server.registerTool("design_context", {
984
1228
  .max(8)
985
1229
  .default(1)
986
1230
  .describe("Cell number to use for per-cell example param IDs. Defaults to cell 1."),
1231
+ groups: z
1232
+ .array(z.string())
1233
+ .optional()
1234
+ .describe("Narrow keyPerCellParams to these groups (e.g. ['delay','vocoder']). " +
1235
+ "Omit it to get the full per-cell vocabulary."),
987
1236
  },
988
1237
  annotations: { readOnlyHint: true },
989
- }, async ({ cellId }) => {
1238
+ }, async ({ cellId, groups: groupFilter }) => {
990
1239
  const manifest = await loadManifest();
1240
+ const cellCheck = validateCellIdForManifest(cellId, manifest);
1241
+ if (cellCheck !== true)
1242
+ return cellCheck;
991
1243
  const params = manifest.parameters;
1244
+ const defaultEffectOrder = cellEffectOrder(manifest);
992
1245
  const byId = new Map(params.map((p) => [p.paramId, p]));
993
1246
  const example = (suffix) => byId.get(`cell${cellId}_${suffix}`)?.paramId ?? `cell${cellId}_${suffix}`;
1247
+ if (IS_MFX_SERVER) {
1248
+ const requestedGroups = groupFilter?.length ? new Set(groupFilter) : null;
1249
+ const effectGroups = Object.fromEntries(cellEffectNames(manifest).filter((effect) => !requestedGroups || requestedGroups.has(effect)).map((effect) => [
1250
+ effect,
1251
+ params
1252
+ .filter((parameter) => parameter.cell === `cell${cellId}` && parameter.effect === effect)
1253
+ .map((parameter) => parameter.paramId),
1254
+ ]));
1255
+ return json({
1256
+ product: manifest.name,
1257
+ architecture: "Four effect cells connected by a replaceable cable graph.",
1258
+ cells: [1, 2, 3, 4],
1259
+ routing: {
1260
+ tool: "set_mfx_routing",
1261
+ gainTool: "set_mfx_edge_gain",
1262
+ ports: {
1263
+ sources: ["input", "sidechain", "cell1..cell4", "split_lo", "split_mid", "split_hi"],
1264
+ destinations: ["cell1..cell4", "split", "output"],
1265
+ },
1266
+ serialExample: [
1267
+ { from: "input", to: "cell1" },
1268
+ { from: "cell1", to: "cell2" },
1269
+ { from: "cell2", to: "cell3" },
1270
+ { from: "cell3", to: "cell4" },
1271
+ { from: "cell4", to: "output" },
1272
+ ],
1273
+ splitParameters: ["mfx_split_lo_hz", "mfx_split_hi_hz"],
1274
+ },
1275
+ combos: {
1276
+ tool: "configure_mfx_combo",
1277
+ parameters: ["mfx_combo1", "mfx_combo2", "mfx_combo3"],
1278
+ roles: ["Morph", "Color", "Motion"],
1279
+ note: "The tool resolves automatic assignments, edits targets and labels, locks assignments, and fans combo values out while the MFX window is closed.",
1280
+ },
1281
+ globalParameters: params
1282
+ .filter((parameter) => !parameter.cell && parameter.paramId.startsWith("mfx_"))
1283
+ .map((parameter) => parameter.paramId),
1284
+ effectOrder: {
1285
+ tool: "set_effect_order",
1286
+ paramId: `cell${cellId}_effectOrder`,
1287
+ defaultOrder: defaultEffectOrder,
1288
+ },
1289
+ keyPerCellParams: effectGroups,
1290
+ parameterRanges: "Call get_parameter_info with a paramId above for its exact range, default, unit, and enum labels.",
1291
+ modulation: {
1292
+ tools: ["add_modulation", "update_modulation", "clear_modulations", "add_param_seq"],
1293
+ note: "Target the cell effect parameters above or the three combo parameters.",
1294
+ },
1295
+ variationSlots: "API indices are 0..7.",
1296
+ });
1297
+ }
994
1298
  const macroTargetsByEffect = {};
995
1299
  for (const p of params.filter((param) => param.paramId.startsWith("macro_") && param.isModulatable)) {
996
1300
  const key = p.effect ?? "global";
@@ -1014,39 +1318,131 @@ server.registerTool("design_context", {
1014
1318
  ["1/32", 18, 0.818],
1015
1319
  ["1/64", 21, 0.955],
1016
1320
  ].map(([division, index, value]) => ({ division, index, lfoRate: value }));
1321
+ // Terse corpus band for one param suffix. Reports where the shipped
1322
+ // presets actually sit AND how much of the legal span they leave unused —
1323
+ // the second number is the one that matters when the brief is "make it
1324
+ // sound like nothing we've shipped".
1325
+ const band = (suffix) => {
1326
+ const r = CORPUS_RANGES[suffix];
1327
+ if (!r)
1328
+ return undefined;
1329
+ const headroom = r.spanUsedPct < 60 ? ` — corpus only visits ${r.spanUsedPct}% of the legal span` : "";
1330
+ 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}`;
1331
+ };
1332
+ // Attach bands to a group's param IDs so one lookup answers both "what is
1333
+ // the ID" and "what value is musical".
1334
+ const group = (...suffixes) => Object.fromEntries(suffixes.map((s) => [example(s), band(s) ?? "no corpus data — use get_parameter_info for the legal range"]));
1335
+ const wanted = groupFilter?.length ? new Set(groupFilter) : null;
1336
+ const pick = (all) => wanted ? Object.fromEntries(Object.entries(all).filter(([k]) => wanted.has(k))) : all;
1337
+ // Params the corpus barely explores. Presented as opportunity, not as a
1338
+ // warning: an agent that only ever writes values inside the dense part of
1339
+ // the distribution reproduces the average shipped preset by construction.
1340
+ const underexplored = Object.entries(CORPUS_RANGES)
1341
+ .filter(([, r]) => r.spanUsedPct < 55 || r.distinct <= 8)
1342
+ .sort((a, b) => a[1].spanUsedPct - b[1].spanUsedPct)
1343
+ .map(([k, r]) => `${k}: corpus uses ${r.lo}..${r.hi} of ${r.legal[0]}..${r.legal[1]} (${r.distinct} distinct values)`);
1344
+ // Params with no corpus row at all — no shipped preset has ever moved them.
1345
+ // Enable flags, sync toggles and the per-band EQ8 grid are excluded because
1346
+ // they are switches and a 49-row template, not sound-design dials.
1347
+ const neverUsed = params
1348
+ .filter((p) => p.paramId.startsWith(`cell${cellId}_`))
1349
+ .map((p) => p.paramId.slice(`cell${cellId}_`.length))
1350
+ .filter((s) => !CORPUS_RANGES[s] &&
1351
+ // Enum/index params carry no corpus row because a percentile over an
1352
+ // index is meaningless — their absence is not evidence of disuse.
1353
+ !CORPUS_ENUM_PARAMS.test(s) &&
1354
+ !s.startsWith("eq8_") &&
1355
+ !s.startsWith("magenta") &&
1356
+ !/^synth_p[4-8]$/.test(s))
1357
+ .sort();
1017
1358
  return json({
1018
1359
  product: manifest.name,
1019
1360
  exampleCell: cellId,
1020
1361
  macroTargetsByEffect,
1021
- keyPerCellParams: {
1022
- mixer: [example("volume"), example("pan"), example("gainGain")],
1023
- filter: [example("filterCutoff"), example("filterQ"), example("filterType")],
1024
- delay: [example("delayDelayTime"), example("delayFeedback"), example("delayTilt"), example("delayMix")],
1025
- crusher: [example("crusherBitReduction"), example("crusherRateReduction"), example("crusherDrive"), example("crusherMix")],
1026
- fdn: [example("fdnSize"), example("fdnFeedback"), example("fdnModDepth"), example("fdnMix")],
1027
- gate: [example("gateTime"), example("gateProgram"), example("gateMix")],
1028
- compressor: [example("compressorThreshold"), example("compressorRatio"), example("compressorAttack"), example("compressorRelease")],
1029
- drumSynth: [example("synthEngine"), example("synthVolume"), example("synth_p1"), example("synth_p2"), example("synth_p3")],
1030
- },
1362
+ // Ordered roughly by how much each group changes the character of a
1363
+ // sound. The sampler group is first because startPos/endPos/pitchShift
1364
+ // are the three most actively-designed params in the whole factory
1365
+ // corpus — chopping and re-tuning the source moves a preset further than
1366
+ // any amount of downstream FX.
1367
+ keyPerCellParams: pick({
1368
+ sampler: group("startPos", "endPos", "cutFrom", "fadeIn", "fadeOut", "mode"),
1369
+ pitch: group("pitchShift", "stretch", "arpeg", "arpegMode"),
1370
+ envelope: group("envAttack", "envGain", "envRelease"),
1371
+ retrigger: group("retrigger", "retriggerDelay"),
1372
+ groove: group("patternSwing"),
1373
+ mixer: group("volume", "pan", "gainGain"),
1374
+ filter: group("filterCutoff", "filterQ", "filterType"),
1375
+ eq: group("eqLowGain", "eqLowMidFreq", "eqMidGain", "eqMidHighFreq", "eqHighGain"),
1376
+ delay: group("delayDelayTime", "delayFeedback", "delayTilt", "delayMix"),
1377
+ flanger: group("flangerMix", "flangerTime", "flangerFeedback", "flangerFreq", "flangerSyncEnabled"),
1378
+ crusher: group("crusherBitReduction", "crusherRateReduction", "crusherDrive", "crusherMix"),
1379
+ fdn: group("fdnSize", "fdnFeedback", "fdnModDepth", "fdnMix"),
1380
+ gate: group("gateTime", "gateProgram", "gateMix"),
1381
+ compressor: group("compressorThreshold", "compressorRatio", "compressorAttack", "compressorRelease"),
1382
+ panner: group("pannerBalance", "pannerWidth"),
1383
+ clip: group("gainClip", "gainClipMode"),
1384
+ drumSynth: group("synthEngine", "synthVolume", "synthPitch", "synthAttack", "synthWaveFm", "synth_p1", "synth_p2", "synth_p3"),
1385
+ // Everything below is a real per-cell engine that no shipped factory
1386
+ // preset has ever touched. They are the least-explored territory in the
1387
+ // instrument, which makes them the first place to look when the brief
1388
+ // is "something we haven't heard".
1389
+ granular: group("stretchGrainMs", "stretchCrossfadeFrac", "stretchSyncEnabled", "grainDensity", "grainWindow", "grainArp", "grainScan", "grainFeedback", "grainTone"),
1390
+ vocoder: group("vocoderMix", "vocoderWave", "vocoderNote", "vocoderFormant", "vocoderSpeed", "vocoderWidth"),
1391
+ resonator: group("envResonatorMix", "envResonatorResonance", "envResonatorDampHz", "envResonatorDetune", "envResonatorGlideMs"),
1392
+ crossModulation: group("fmSource", "fmAmount", "fmPolarity", "spread", "chaos", "formantShift", "vcaDepth"),
1393
+ patternShape: group("patternDirection", "patternLength", "patternSpeed"),
1394
+ pitchQuantize: group("quantRoot", "quantScale", "quantStrength"),
1395
+ retriggerShape: group("retriggerSyncEnabled", "retriggerDecayDb", "retriggerRamp"),
1396
+ slicing: group("slice"),
1397
+ }),
1031
1398
  unitNotes: {
1032
- pan: "cellN_pan is -1..1, so -0.15 is about 15% left; use cellN_pannerBalance for -100..100%.",
1399
+ 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.",
1400
+ pitchShift: "cellN_pitchShift is -24..24 SEMITONES, not a ratio. Pair with update_cell_config quantRoot/quantScale to keep a pitched cell in key.",
1401
+ 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.",
1402
+ 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.",
1403
+ retrigger: "cellN_retrigger is a repeat COUNT (0..10); cellN_retriggerDelay is a normalized 0..1 division, not seconds.",
1404
+ 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.",
1405
+ pan: "cellN_pan is -1..1, so -0.15 is about 15% left; use cellN_pannerBalance for -100..100% (needs pannerEnabled).",
1033
1406
  percentages: "Most mix/feedback/depth controls are 0..100 percent.",
1034
1407
  filterCutoff: "Use real Hz values; move at least 1.5 octaves for obviously audible modulation.",
1035
1408
  delayDelayTime: "With delaySyncEnabled=1, normalized 0..1 maps to musical divisions; do not use cellN_delayTime.",
1409
+ 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+.",
1410
+ eqGains: "cellN_eq{Low,Mid,High}Gain are -60..12 dB. The -60 floor makes the EQ a band KILL, not just a tilt.",
1036
1411
  lfoRate: "LFO IDs are 1-based: lfo1_... through lfo6_.... Sync ON uses index/22.",
1412
+ 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.",
1413
+ 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.",
1414
+ 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.",
1415
+ 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.",
1416
+ 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.",
1417
+ 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.",
1418
+ 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.",
1419
+ 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.",
1420
+ slice: "cellN_slice (0..64) selects the active slice; drive it from a slice lane for stutter edits without touching the sample.",
1421
+ },
1422
+ // Derived from the shipped factory presets, not hand-written. Regenerate
1423
+ // with `node scripts/derive-corpus-ranges.mjs` after changing the corpus.
1424
+ corpusBands: {
1425
+ source: `${CORPUS_META.presets} factory presets, ${CORPUS_META.soundingCells} sounding cells (${CORPUS_META.source})`,
1426
+ 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.",
1427
+ underexplored,
1428
+ neverUsed: {
1429
+ 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.",
1430
+ params: neverUsed,
1431
+ },
1037
1432
  },
1038
- musicalRanges: {
1039
- filterCutoff: "dark 300-900 Hz, mid 900-3500 Hz, open 3500-12000 Hz",
1040
- delayFeedback: "10-35% controlled, 35-60% dubby, >70% runaway risk",
1041
- wetMix: "10-25% subtle, 25-45% audible, >60% wash/destroy",
1042
- crusherBitReduction: "7-16 bits musical grit, 1-4 bits brutal",
1043
- volumeDb: "Layer cells with -12..0 dB staging before master processing",
1433
+ eq8: {
1434
+ 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.",
1435
+ template: `cell${cellId}_eq8_b{1..8}_{freq,gain,q,type,slope,enabled}`,
1436
+ bandRanges: "freq 10..30000 Hz, gain -30..30 dB, q 0.025..40, type 0..5, slope 0..2, enabled 0/1",
1437
+ outputGain: example("eq8_outputGain"),
1438
+ 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.",
1044
1439
  },
1045
1440
  lfoSyncTable: {
1046
1441
  formula: "set lfoN_lfoSyncEnabled=1 and lfoN_lfoRate=index/22",
1047
1442
  quick: lfoQuick,
1048
1443
  fullDivisions: LFO_DIVISIONS,
1049
1444
  shapes: ["0=Sine", "1=Saw", "2=Tri", "3=Square", "4=Random", "5=Wander", "6=Custom"],
1445
+ 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).",
1050
1446
  },
1051
1447
  delaySyncDivisions: [
1052
1448
  "1/32T", "1/32", "1/32D", "1/16T", "1/16", "1/16D",
@@ -1056,12 +1452,297 @@ server.registerTool("design_context", {
1056
1452
  filterTypes: filterType?.strRepr?.map((label, index) => `${index}=${label}`) ?? [
1057
1453
  "0=Lowpass", "1=Highpass", "2=Bandpass", "3=Peak", "4=Notch",
1058
1454
  ],
1455
+ effectOrder: {
1456
+ paramId: `cell${cellId}_effectOrder`,
1457
+ tool: "set_effect_order",
1458
+ 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.",
1459
+ defaultOrder: defaultEffectOrder,
1460
+ },
1059
1461
  midiNoteMap: Object.fromEntries(Array.from({ length: 8 }, (_, i) => {
1060
1462
  const id = i + 1;
1061
1463
  return [`cell${id}`, 47 + id];
1062
1464
  })),
1063
1465
  variationSlots: "API is 0-based (0..7); PAM UI labels slots 1..8. Save/load variation index 0 = UI slot 1.",
1064
- modulationGuidance: "Primary movement should target per-cell params for distinct audible changes. Use macro_* for performance-level fan-out across cells.",
1466
+ generativePlayers: "set_player turns a cell into a live generator (list_players for the roster: EUCLID, KIT, TURING, WALK, MARKOV, " +
1467
+ "RATCHET, PHASE, RIFF, MASK). cell{N}_playerDensity/Mutate/P1..P5 are modulatable params with macro_player* twins; " +
1468
+ "write them through set_player, never set_parameters alone, so the cell config stays in step.",
1469
+ modulationGuidance: "Primary movement should target per-cell params for distinct audible changes. Use macro_* for performance-level fan-out across cells. " +
1470
+ "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.",
1471
+ });
1472
+ });
1473
+ server.registerTool("preset_novelty", {
1474
+ title: "Score Preset Novelty",
1475
+ description: "Score a preset against the shipped factory corpus and name what it duplicates. Answers the question " +
1476
+ "nothing else in the pipeline could: 'does this already exist?'\n\n" +
1477
+ "Compares structure, not audio — which params are off their defaults, the FX set and chain orders, the " +
1478
+ "modulation topology, per-cell onset patterns, transpositions, and sample sources. No render, no audio, " +
1479
+ "and (for source 'file') no plugin connection needed.\n\n" +
1480
+ "novelty is 1 - (closest match). Read it against percentileVsCorpus, NOT as an absolute: every PAM preset " +
1481
+ "shares an instrument, so a perfectly good one still scores around " +
1482
+ `${CORPUS_NOVELTY_BASELINE.p50}. The closest pair that actually shipped scores ${CORPUS_NOVELTY_BASELINE.floor} — ` +
1483
+ "below that, a preset is more derivative than anything in the library.\n\n" +
1484
+ "duplicatedDimensions is the actionable part: it names which dimensions to change.",
1485
+ inputSchema: {
1486
+ source: z
1487
+ .enum(["live", "file"])
1488
+ .default("live")
1489
+ .describe("'live' fingerprints the running plugin's current state; 'file' reads a .preset from disk."),
1490
+ path: z.string().optional().describe("Preset path for source 'file' (absolute or relative to the preset dirs)."),
1491
+ topN: z.number().int().min(1).max(20).default(5).describe("How many nearest neighbours to return."),
1492
+ },
1493
+ annotations: { readOnlyHint: true },
1494
+ }, async ({ source, path: presetPath, topN }) => {
1495
+ const manifest = await loadManifest();
1496
+ const defaults = {};
1497
+ for (const p of manifest.parameters) {
1498
+ const m = /^cell1_(.+)$/.exec(p.paramId);
1499
+ if (m)
1500
+ defaults[m[1]] = p.defaultValue;
1501
+ }
1502
+ const effectNames = cellEffectNames(manifest);
1503
+ let state;
1504
+ let label = "live state";
1505
+ if (source === "file") {
1506
+ if (!presetPath)
1507
+ return error("source 'file' requires a `path`.");
1508
+ try {
1509
+ const full = await resolvePresetPath(presetPath);
1510
+ const { metadata, data } = parsePresetFile(await readFile(full, "utf-8"));
1511
+ state = data;
1512
+ label = metadata.name || basename(full, ".preset");
1513
+ }
1514
+ catch (e) {
1515
+ return error(`Failed to read preset: ${e.message}`);
1516
+ }
1517
+ }
1518
+ else {
1519
+ const check = await requireBridge();
1520
+ if (check !== true)
1521
+ return check;
1522
+ state = (await bridge.send("getState"));
1523
+ }
1524
+ const candidate = buildFingerprint(state, defaults, effectNames, label);
1525
+ if (candidate.cells === 0) {
1526
+ return error("No cell carries any notes, so there is nothing to fingerprint. Write note patterns first (update_pattern), then re-score.");
1527
+ }
1528
+ // Re-scoring a preset that already shipped would otherwise match itself at
1529
+ // 1.0 and report "more derivative than anything in the library", which is
1530
+ // true but useless. Drop the identity match and say so.
1531
+ const selfIndex = CORPUS_FINGERPRINTS.findIndex((f) => f.name === candidate.name && fingerprintSimilarity(candidate, f).overall > 0.999);
1532
+ const corpus = selfIndex >= 0 ? CORPUS_FINGERPRINTS.filter((_, i) => i !== selfIndex) : CORPUS_FINGERPRINTS;
1533
+ const report = scoreNovelty(candidate, corpus, topN, CORPUS_NOVELTY_BASELINE);
1534
+ return json({
1535
+ subject: label,
1536
+ alreadyInCorpus: selfIndex >= 0 || undefined,
1537
+ ...report,
1538
+ corpusBaseline: {
1539
+ ...CORPUS_NOVELTY_BASELINE,
1540
+ 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.`,
1541
+ },
1542
+ candidateProfile: {
1543
+ soundingCells: candidate.cells,
1544
+ paramsMoved: candidate.params.length,
1545
+ fx: candidate.fx,
1546
+ distinctChainOrders: candidate.chains.length,
1547
+ modulationSources: candidate.mod.lfo + candidate.mod.env + candidate.mod.vary + candidate.mod.seq,
1548
+ modulationTargets: candidate.mod.targets.length,
1549
+ transpositions: candidate.pitch.length,
1550
+ sampleSources: candidate.sources.length,
1551
+ },
1552
+ });
1553
+ });
1554
+ server.registerTool("analyze_motion", {
1555
+ title: "Analyze Preset Motion",
1556
+ description: "Measure whether a preset actually MOVES over time, rather than whether it is mixed. Fills the gap left by " +
1557
+ "get_master_analysis (loudness) and get_mix_analysis (aggregate spectrum + onset density), neither of which " +
1558
+ "can tell a modulated preset from a static one.\n\n" +
1559
+ "Reports spectral flux, brightness wander, per-bar level and brightness variation, and level movement WITHIN " +
1560
+ "a bar — the last one is what catches a bar-synced gate, tremolo or sidechain pump, whose bars are all " +
1561
+ "identical to each other.\n\n" +
1562
+ "Pass wavPath to analyze an existing render or live_resample capture. Otherwise the tool runs an offline " +
1563
+ "export first and leaves that render in PAM's Exports folder as pam_motion_<epoch>.wav. Digital silence is " +
1564
+ "reported as a RENDER FAILURE with the workaround, not as 'no motion' — offline export is known to produce " +
1565
+ "silence in the standalone build.",
1566
+ inputSchema: {
1567
+ wavPath: z
1568
+ .string()
1569
+ .optional()
1570
+ .describe("Absolute path to an existing WAV. Skips rendering — use this for a live_resample capture."),
1571
+ bars: z.number().int().min(1).max(32).default(4).describe("Bars to render when no wavPath is given."),
1572
+ bpm: z
1573
+ .number()
1574
+ .optional()
1575
+ .describe("Tempo for the per-bar windows. Read from the transport when omitted."),
1576
+ },
1577
+ annotations: { readOnlyHint: false, destructiveHint: false },
1578
+ }, async ({ wavPath, bars, bpm }) => {
1579
+ let path = wavPath;
1580
+ let effectiveBpm = bpm;
1581
+ if (!path) {
1582
+ const check = await requireBridge();
1583
+ if (check !== true)
1584
+ return check;
1585
+ const transport = (await bridge.send("getTransport"));
1586
+ effectiveBpm ??= typeof transport?.bpm === "number" ? transport.bpm : 120;
1587
+ const filename = `pam_motion_${Date.now()}`;
1588
+ await bridge.send("exportAudio", {
1589
+ filename,
1590
+ mode: "main",
1591
+ durationInSeconds: (bars * 4 * 60) / (effectiveBpm || 120),
1592
+ bitDepth: 24,
1593
+ });
1594
+ const outcome = await pollExportStatus();
1595
+ if (outcome !== "success")
1596
+ return error(`Render failed before analysis could run (export ${outcome}).`);
1597
+ const locations = (await bridge.send("getDefaultLocations"));
1598
+ const exportDir = [locations?.exports, locations?.exportsLocation, locations?.Exports].find((v) => typeof v === "string" && v.length > 0);
1599
+ if (!exportDir) {
1600
+ return error(`Rendered ${filename}.wav but could not resolve PAM's Exports folder from get_default_locations. ` +
1601
+ "Re-run with an explicit wavPath.");
1602
+ }
1603
+ path = resolve(exportDir, `${filename}.wav`);
1604
+ }
1605
+ else if (effectiveBpm === undefined) {
1606
+ const check = await requireBridge();
1607
+ if (check === true) {
1608
+ const transport = (await bridge.send("getTransport"));
1609
+ if (typeof transport?.bpm === "number")
1610
+ effectiveBpm = transport.bpm;
1611
+ }
1612
+ }
1613
+ let metrics;
1614
+ try {
1615
+ metrics = analyzeMotion(decodeWav(await readFile(path)), effectiveBpm);
1616
+ }
1617
+ catch (e) {
1618
+ return error(`Could not analyze ${path}: ${e.message}`);
1619
+ }
1620
+ return json({
1621
+ file: path,
1622
+ bpm: effectiveBpm ?? null,
1623
+ ...metrics,
1624
+ reading: "static:true means nothing measurably changes — brightness, per-bar level and within-bar level are all flat. " +
1625
+ "centroidVariability and perBarCentroidVariability catch slow filter/timbre motion; intraBarRmsVariability " +
1626
+ "catches per-hit and per-beat movers (gate, tremolo, sidechain duck). A preset can pass the modulation-count " +
1627
+ "check and still fail here when depths are too small to hear.",
1628
+ });
1629
+ });
1630
+ // What to change when a pair of variations is identical on a given dimension.
1631
+ // The dimension name alone is not actionable; this maps it to a move.
1632
+ const VARIATION_FIX_HINTS = {
1633
+ groove: "rewrite the note patterns — different onsets, not just different density",
1634
+ params: "change the sound, not the mix: filter type, chop window (startPos/endPos), stretch, or a different FX",
1635
+ mod: "change what MOVES — retarget the front modulator or swap the mover type",
1636
+ pitch: "transpose or re-voice the melodic parts",
1637
+ fx: "enable or bypass a different effect on at least one cell",
1638
+ chains: "reorder one cell's FX chain (set_effect_order)",
1639
+ sources: "swap a sample on at least one cell",
1640
+ };
1641
+ server.registerTool("variation_distinctness", {
1642
+ title: "Check Variation Distinctness",
1643
+ description: "Measure whether a preset's variation slots are actually different, and name what is identical about the " +
1644
+ "pairs that are not. Structural comparison of the saved slot snapshots — no render, no transport, no " +
1645
+ "audition loop, and no risk of the load_variation race.\n\n" +
1646
+ `Read scores against the shipped corpus, where sibling variation pairs sit at a median of ` +
1647
+ `${CORPUS_VARIATION_BASELINE.p50} similarity (p75 ${CORPUS_VARIATION_BASELINE.p75}, p90 ${CORPUS_VARIATION_BASELINE.p90}). ` +
1648
+ `Siblings are far more alike than two different presets by construction, so the preset_novelty scale does ` +
1649
+ `not apply. ${CORPUS_VARIATION_BASELINE.nearIdenticalShare}% of shipped pairs are already near-identical — ` +
1650
+ "this gate exists to stop adding to that number.\n\n" +
1651
+ "Each failing pair reports the dimensions it duplicates plus the move that would fix it.",
1652
+ inputSchema: {
1653
+ source: z
1654
+ .enum(["live", "file"])
1655
+ .default("live")
1656
+ .describe("'live' reads the running plugin's variation slots; 'file' reads them from a .preset."),
1657
+ path: z.string().optional().describe("Preset path for source 'file'."),
1658
+ threshold: z
1659
+ .number()
1660
+ .min(0.5)
1661
+ .max(1)
1662
+ .default(0.9)
1663
+ .describe(`Similarity above which a pair fails. Default 0.9 — worse than 75% of shipped sibling pairs (p75 ${CORPUS_VARIATION_BASELINE.p75}).`),
1664
+ },
1665
+ annotations: { readOnlyHint: true },
1666
+ }, async ({ source, path: presetPath, threshold }) => {
1667
+ const manifest = await loadManifest();
1668
+ const defaults = {};
1669
+ for (const p of manifest.parameters) {
1670
+ const m = /^cell1_(.+)$/.exec(p.paramId);
1671
+ if (m)
1672
+ defaults[m[1]] = p.defaultValue;
1673
+ }
1674
+ const effectNames = cellEffectNames(manifest);
1675
+ let slots;
1676
+ let label;
1677
+ if (source === "file") {
1678
+ if (!presetPath)
1679
+ return error("source 'file' requires a `path`.");
1680
+ try {
1681
+ const full = await resolvePresetPath(presetPath);
1682
+ const parsed = parsePresetFile(await readFile(full, "utf-8"));
1683
+ slots = variationsFromParsedPreset(parsed);
1684
+ label = parsed.metadata.name || basename(full, ".preset");
1685
+ }
1686
+ catch (e) {
1687
+ return error(`Failed to read preset: ${e.message}`);
1688
+ }
1689
+ }
1690
+ else {
1691
+ const check = await requireBridge();
1692
+ if (check !== true)
1693
+ return check;
1694
+ const state = (await bridge.send("getState"));
1695
+ const raw = Array.isArray(state.variations) ? state.variations : [];
1696
+ slots = Array.from({ length: PRESET_VARIATION_SLOTS }, (_, i) => typeof raw[i] === "string" ? decompressVariationEntry(raw[i]) : null);
1697
+ label = "live state";
1698
+ }
1699
+ const filled = [];
1700
+ for (const [i, slot] of slots.entries()) {
1701
+ if (typeof slot !== "string" || slot.length < 10)
1702
+ continue;
1703
+ try {
1704
+ filled.push({ slot: i, fp: buildFingerprint(JSON.parse(slot), defaults, effectNames, `slot ${i}`) });
1705
+ }
1706
+ catch {
1707
+ /* unreadable slot — reported via the occupancy count below */
1708
+ }
1709
+ }
1710
+ if (filled.length < 2) {
1711
+ return error(`Only ${filled.length} readable variation slot(s) — need at least 2 to compare. ` +
1712
+ "Save more slots with save_variation (API indices are 0-based; UI labels them 1-8).");
1713
+ }
1714
+ const pairs = [];
1715
+ for (let i = 0; i < filled.length; i += 1) {
1716
+ for (let j = i + 1; j < filled.length; j += 1) {
1717
+ const sim = fingerprintSimilarity(filled[i].fp, filled[j].fp);
1718
+ const identical = Object.entries(sim.byDimension)
1719
+ .filter(([, v]) => v >= 0.95)
1720
+ .sort((a, b) => b[1] - a[1])
1721
+ .map(([k]) => k);
1722
+ pairs.push({
1723
+ pair: `slot ${filled[i].slot} vs slot ${filled[j].slot}`,
1724
+ similarity: Math.round(sim.overall * 1000) / 1000,
1725
+ pass: sim.overall < threshold,
1726
+ identicalDimensions: identical,
1727
+ fix: sim.overall < threshold ? undefined : identical.map((d) => VARIATION_FIX_HINTS[d] ?? d),
1728
+ });
1729
+ }
1730
+ }
1731
+ const failing = pairs.filter((p) => !p.pass).sort((a, b) => b.similarity - a.similarity);
1732
+ return json({
1733
+ subject: label,
1734
+ filledSlots: filled.map((f) => f.slot),
1735
+ threshold,
1736
+ pass: failing.length === 0,
1737
+ verdict: failing.length === 0
1738
+ ? `All ${pairs.length} slot pairs differ by more than the ${threshold} threshold.`
1739
+ : `${failing.length} of ${pairs.length} slot pairs are too similar. Give one slot in each a real change — density alone is not a variation.`,
1740
+ failingPairs: failing,
1741
+ allPairs: pairs,
1742
+ corpusBaseline: CORPUS_VARIATION_BASELINE,
1743
+ // Shipped variations move these dimensions and leave those alone — useful
1744
+ // when deciding WHICH dimension to change.
1745
+ 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.",
1065
1746
  });
1066
1747
  });
1067
1748
  // Standard full update — graph dispatch + KV sync + parameter updates.
@@ -1195,9 +1876,8 @@ server.registerTool("get_design_overview", {
1195
1876
  ]);
1196
1877
  const state = isRecord(stateRaw) ? stateRaw : {};
1197
1878
  const transport = isRecord(transportRaw) ? transportRaw : {};
1198
- // Product-aware: MFX carries wavefold/resonator, PAM/Cell1 do not.
1199
- const derivedEffects = cellEffectNames(manifest);
1200
- const effectNames = derivedEffects.length ? derivedEffects : CELL_EFFECTS_FALLBACK;
1879
+ // The manifest is authoritative; every current product carries both character effects.
1880
+ const effectNames = cellEffectNames(manifest);
1201
1881
  const manifestCellIds = [...new Set(manifest.parameters
1202
1882
  .map((p) => p.cell)
1203
1883
  .filter((cell) => typeof cell === "string" && /^cell\d+$/.test(cell))
@@ -1210,6 +1890,49 @@ server.registerTool("get_design_overview", {
1210
1890
  vary: Array.isArray(state.vary) ? state.vary : [],
1211
1891
  paramSeq: Array.isArray(state.paramSeqs) ? state.paramSeqs : [],
1212
1892
  };
1893
+ if (IS_MFX_SERVER) {
1894
+ const cells = ids.map((id) => {
1895
+ const prefix = `cell${id}_`;
1896
+ const targetPrefix = new Set([prefix]);
1897
+ const storedOrder = state[`${prefix}effectOrder`];
1898
+ const rawCell = cellsObj[String(id)];
1899
+ const cellConfig = isRecord(rawCell) ? rawCell : {};
1900
+ return {
1901
+ cellId: id,
1902
+ muted: state[`${prefix}mute`] === 1 || state[`${prefix}mute`] === true,
1903
+ sidechainFrom: cellConfig.sidechainFrom ?? state[`${prefix}sidechainFrom`],
1904
+ effectOrder: Array.isArray(storedOrder) ? storedOrder : cellEffectOrder(manifest),
1905
+ enabledEffects: effectNames.filter((effectName) => state[`${prefix}${effectName}Enabled`] === 1 || state[`${prefix}${effectName}Enabled`] === true),
1906
+ modulation: Object.fromEntries(Object.entries(modArrays).map(([kind, arr]) => [
1907
+ kind,
1908
+ arr.filter((entry) => paramTargetsCell(entry, targetPrefix)).length,
1909
+ ])),
1910
+ mixer: {
1911
+ volume: state[`${prefix}volume`],
1912
+ pan: state[`${prefix}pan`],
1913
+ gain: state[`${prefix}gainGain`],
1914
+ },
1915
+ };
1916
+ });
1917
+ return json({
1918
+ product: manifest.name,
1919
+ bpm: state.bpm ?? transport.bpm,
1920
+ playing: transport.playing,
1921
+ mix: state.mfx_mix,
1922
+ routing: state.mfx_routing,
1923
+ edgeGains: state.mfx_edge_gains,
1924
+ split: { lowHz: state.mfx_split_lo_hz, highHz: state.mfx_split_hi_hz },
1925
+ combos: resolveServerMfxCombos(state, manifest).map((combo) => ({
1926
+ ...combo,
1927
+ value: state[`mfx_combo${combo.index}`],
1928
+ })),
1929
+ totals: {
1930
+ enabledEffects: cells.reduce((sum, cell) => sum + cell.enabledEffects.length, 0),
1931
+ modulationLinks: cells.reduce((sum, cell) => sum + Object.values(cell.modulation).reduce((subtotal, count) => subtotal + count, 0), 0),
1932
+ },
1933
+ cells,
1934
+ });
1935
+ }
1213
1936
  const cells = ids.map((id) => {
1214
1937
  const prefix = `cell${id}_`;
1215
1938
  const rawCellState = cellsObj[String(id)];
@@ -1224,12 +1947,14 @@ server.registerTool("get_design_overview", {
1224
1947
  ]));
1225
1948
  return {
1226
1949
  cellId: id,
1950
+ muted: state[`${prefix}mute`] === 1 || state[`${prefix}mute`] === true,
1227
1951
  sample: {
1228
1952
  name: stemName(samplePath),
1229
1953
  path: typeof samplePath === "string" ? samplePath : undefined,
1230
1954
  },
1231
1955
  // Both halves: the enum an agent writes back, and the name it reasons about.
1232
1956
  synthEngine: describeSynthEngine(state[`${prefix}synthEngine`]),
1957
+ player: describeCellPlayer(id, cellState, state),
1233
1958
  enabledEffects: effectNames.filter((effectName) => state[`${prefix}${effectName}Enabled`] === 1 || state[`${prefix}${effectName}Enabled`] === true),
1234
1959
  patternEvents: Object.fromEntries(Object.entries(PATTERN_LANES).map(([lane, suffix]) => [
1235
1960
  lane,
@@ -1254,6 +1979,7 @@ server.registerTool("get_design_overview", {
1254
1979
  },
1255
1980
  totals: {
1256
1981
  cellsWithSamples: cells.filter((c) => c.sample.path).length,
1982
+ playersRunning: cells.filter((c) => c.player).length,
1257
1983
  activePatternEvents: cells.reduce((sum, c) => sum + Object.values(c.patternEvents).reduce((a, b) => a + b, 0), 0),
1258
1984
  modulationLinks: cells.reduce((sum, c) => sum + Object.values(c.modulation).reduce((a, b) => a + b, 0), 0),
1259
1985
  },
@@ -1264,7 +1990,8 @@ server.registerTool("set_parameters", {
1264
1990
  title: "Set Parameters",
1265
1991
  description: "Set one or more PAM parameters. Values are validated against the manifest. " +
1266
1992
  "Effect enable flags such as cell1_filterEnabled are settable here too, so params + enables can land in one call " +
1267
- "when a graph dispatch is acceptable. Use get_parameter_info or design_context to discover valid IDs and ranges.",
1993
+ "when a graph dispatch is acceptable. Use get_parameter_info or design_context to discover valid IDs and ranges. " +
1994
+ "Writing a macro_* id also enables that macro, matching the UI.",
1268
1995
  inputSchema: {
1269
1996
  parameters: z
1270
1997
  .record(z.string(), z.number())
@@ -1308,6 +2035,36 @@ server.registerTool("set_parameters", {
1308
2035
  }
1309
2036
  delta.macros = macros;
1310
2037
  }
2038
+ let comboTargetCount = 0;
2039
+ if (isMfxManifest(await loadManifest())) {
2040
+ const comboValues = new Map();
2041
+ for (let comboIndex = 1; comboIndex <= MFX_COMBO_COUNT; comboIndex++) {
2042
+ const value = parameters[`mfx_combo${comboIndex}`];
2043
+ if (typeof value === "number")
2044
+ comboValues.set(comboIndex, value);
2045
+ }
2046
+ if (comboValues.size > 0) {
2047
+ const stateRaw = await bridge.send("getState");
2048
+ const state = isRecord(stateRaw) ? stateRaw : {};
2049
+ const manifest = await loadManifest();
2050
+ for (const combo of resolveServerMfxCombos(state, manifest)) {
2051
+ const value = comboValues.get(combo.index);
2052
+ if (value === undefined)
2053
+ continue;
2054
+ if (combo.targets.some((target) => Object.hasOwn(parameters, target.id)))
2055
+ continue;
2056
+ for (const target of combo.targets) {
2057
+ const info = index.get(target.id);
2058
+ if (!info)
2059
+ continue;
2060
+ const previousComboValue = state[`mfx_combo${combo.index}`];
2061
+ const currentTargetValue = state[target.id];
2062
+ delta[target.id] = mfxComboTargetValueFromCurrent(target, typeof previousComboValue === "number" ? previousComboValue : 0, value, info, typeof currentTargetValue === "number" ? currentTargetValue : info.defaultValue);
2063
+ comboTargetCount++;
2064
+ }
2065
+ }
2066
+ }
2067
+ }
1311
2068
  await bridge.send("applyUnifiedStateKvUpdate", {
1312
2069
  delta,
1313
2070
  options: {
@@ -1319,17 +2076,18 @@ server.registerTool("set_parameters", {
1319
2076
  broadcastFollowers: true,
1320
2077
  },
1321
2078
  });
1322
- return text(`Set ${Object.keys(parameters).length} parameter(s): ${Object.entries(parameters).map(([k, v]) => `${k}=${v}`).join(", ")}`);
2079
+ return text(`Set ${Object.keys(parameters).length} parameter(s): ${Object.entries(parameters).map(([k, v]) => `${k}=${v}`).join(", ")}` +
2080
+ (comboTargetCount > 0 ? `; fanned out ${comboTargetCount} MFX combo target write(s)` : ""));
1323
2081
  });
1324
2082
  server.registerTool("load_sample", {
1325
2083
  title: "Load Sample",
1326
- description: "Load an audio file (wav, mp3, aif, flac) into a PAM cell.",
2084
+ description: "Load an audio file (wav, mp3, aif, flac) into a PAM cell, replacing whatever the cell currently holds. Take filePath from list_samples or a get_default_locations root.",
1327
2085
  inputSchema: {
1328
2086
  filePath: z.string().describe("Path to the audio file (absolute, or relative as returned by list_samples)."),
1329
2087
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
1330
2088
  },
1331
2089
  }, async ({ filePath, cellId }) => {
1332
- const check = await requireBridge();
2090
+ const check = await requireBridgeForCells(cellId);
1333
2091
  if (check !== true)
1334
2092
  return check;
1335
2093
  await bridge.send("loadSampleToCell", filePath, cellId);
@@ -1341,7 +2099,7 @@ server.registerTool("load_sample", {
1341
2099
  // + experimental<Engine>) — the bridge returns a clear error otherwise.
1342
2100
  const MODEL_ENGINE = z
1343
2101
  .enum(["notochord", "aigen", "magenta"])
1344
- .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).");
2102
+ .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).");
1345
2103
  const MODEL_VARIANT = z
1346
2104
  .enum(["base", "small"])
1347
2105
  .optional()
@@ -1418,7 +2176,7 @@ server.registerTool("list_models", {
1418
2176
  });
1419
2177
  server.registerTool("generate_sample", {
1420
2178
  title: "Generate Sample (AI)",
1421
- 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.",
2179
+ 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.",
1422
2180
  inputSchema: {
1423
2181
  category: z
1424
2182
  .enum(["kick", "snare", "hat", "perc", "bass", "synth", "pad", "fx", "loop"])
@@ -1502,6 +2260,7 @@ server.registerTool("generate_kit", {
1502
2260
  // curve / prompt / seed / sigma), sidecar renders, and the take shelf. Requires
1503
2261
  // experimentalMode + experimentalAIGen (update_settings) AND the SA3 Python
1504
2262
  // sidecar running on 127.0.0.1:8093 (packages/sa3-workbench-sidecar/README.md).
2263
+ const WORKBENCH_PREREQ = "Requires experimentalMode + experimentalAIGen (update_settings) and the SA3 sidecar on 127.0.0.1:8093; check workbench_sidecar_status first. ";
1505
2264
  const WORKBENCH_MASK_REGION = z.object({
1506
2265
  startSec: z.number().min(0).describe("Region start in seconds (source time)."),
1507
2266
  endSec: z.number().min(0).describe("Region end in seconds (must be > startSec)."),
@@ -1537,7 +2296,8 @@ const WORKBENCH_STATE_FIELDS = {
1537
2296
  };
1538
2297
  server.registerTool("workbench_get_state", {
1539
2298
  title: "Workbench: Get State",
1540
- description: "Read the AI Audio Workbench canvas state AND the take shelf: { state: { sourceCellId, prompt, seed, seedLocked, sigma, steps, maskRegions, strengthCurve, activeSourcePath, sketchPath, sketchGain }, takes: [...] }.",
2299
+ description: WORKBENCH_PREREQ +
2300
+ "Read the AI Audio Workbench canvas state AND the take shelf: { state: { sourceCellId, prompt, seed, seedLocked, sigma, steps, maskRegions, strengthCurve, activeSourcePath, sketchPath, sketchGain }, takes: [...] }.",
1541
2301
  inputSchema: {},
1542
2302
  }, async () => {
1543
2303
  const check = await requireBridge();
@@ -1547,7 +2307,8 @@ server.registerTool("workbench_get_state", {
1547
2307
  });
1548
2308
  server.registerTool("workbench_set_state", {
1549
2309
  title: "Workbench: Set State",
1550
- description: "Partial-merge the Workbench canvas state (only the keys you pass change). Also pushes the merged state into an open Workbench UI. Returns the full merged state.",
2310
+ description: WORKBENCH_PREREQ +
2311
+ "Partial-merge the Workbench canvas state (only the keys you pass change). Also pushes the merged state into an open Workbench UI. Returns the full merged state.",
1551
2312
  inputSchema: WORKBENCH_STATE_FIELDS,
1552
2313
  }, async (args) => {
1553
2314
  const check = await requireBridge();
@@ -1557,7 +2318,8 @@ server.registerTool("workbench_set_state", {
1557
2318
  });
1558
2319
  server.registerTool("workbench_render", {
1559
2320
  title: "Workbench: Render",
1560
- description: "Render the Workbench transform via the SA3 sidecar and append the result to the take shelf. Renders the CURRENT canvas state merged with any overrides passed here (overrides do not persist). Blocks until done (up to ~3 min: pipeline rebuilds + multi-region inpaints render sequentially). Returns the take { id, wavPath, prompt, seed, sigma, maskRegions, createdAt, sourceCellId, durationSeconds, error? }. Source resolution: sourcePath override > activeSourcePath (chain head) > state.sourceCellId's sample file. A successful render ADVANCES the chain (activeSourcePath = new take's wavPath) — step back with workbench_undo or workbench_reset_chain.",
2321
+ description: WORKBENCH_PREREQ +
2322
+ "Render the Workbench transform via the SA3 sidecar and append the result to the take shelf. Renders the CURRENT canvas state merged with any overrides passed here (overrides do not persist). Blocks until done (up to ~3 min: pipeline rebuilds + multi-region inpaints render sequentially). Returns the take { id, wavPath, prompt, seed, sigma, maskRegions, createdAt, sourceCellId, durationSeconds, error? }. Source resolution: sourcePath override > activeSourcePath (chain head) > state.sourceCellId's sample file. A successful render ADVANCES the chain (activeSourcePath = new take's wavPath) — step back with workbench_undo or workbench_reset_chain.",
1561
2323
  inputSchema: {
1562
2324
  ...WORKBENCH_STATE_FIELDS,
1563
2325
  sourcePath: z.string().optional()
@@ -1576,7 +2338,7 @@ server.registerTool("workbench_render", {
1576
2338
  });
1577
2339
  server.registerTool("workbench_list_takes", {
1578
2340
  title: "Workbench: List Takes",
1579
- description: "List all Workbench takes (renders) with full provenance, oldest first.",
2341
+ description: WORKBENCH_PREREQ + "List all Workbench takes (renders) with full provenance, oldest first.",
1580
2342
  inputSchema: {},
1581
2343
  }, async () => {
1582
2344
  const check = await requireBridge();
@@ -1586,13 +2348,13 @@ server.registerTool("workbench_list_takes", {
1586
2348
  });
1587
2349
  server.registerTool("workbench_load_take", {
1588
2350
  title: "Workbench: Load Take",
1589
- description: "Load a rendered take's WAV into a cell (same native path as load_sample).",
2351
+ description: WORKBENCH_PREREQ + "Load a rendered take's WAV into a cell (same native path as load_sample).",
1590
2352
  inputSchema: {
1591
2353
  takeId: z.string().describe("Take id from workbench_render / workbench_list_takes."),
1592
2354
  cellId: z.number().int().min(1).max(8).describe("Target cell."),
1593
2355
  },
1594
2356
  }, async ({ takeId, cellId }) => {
1595
- const check = await requireBridge();
2357
+ const check = await requireBridgeForCells(cellId);
1596
2358
  if (check !== true)
1597
2359
  return check;
1598
2360
  try {
@@ -1604,7 +2366,8 @@ server.registerTool("workbench_load_take", {
1604
2366
  });
1605
2367
  server.registerTool("workbench_undo", {
1606
2368
  title: "Workbench: Undo (pop chain)",
1607
- description: "Pop the Workbench render chain one step: restores the previous chain head (activeSourcePath) so the next render re-transforms the prior source. No-op at the chain root (cell sample). Takes are kept. Returns the full { state, takes }.",
2369
+ description: WORKBENCH_PREREQ +
2370
+ "Pop the Workbench render chain one step: restores the previous chain head (activeSourcePath) so the next render re-transforms the prior source. No-op at the chain root (cell sample). Takes are kept. Returns the full { state, takes }.",
1608
2371
  inputSchema: {},
1609
2372
  }, async () => {
1610
2373
  const check = await requireBridge();
@@ -1614,7 +2377,8 @@ server.registerTool("workbench_undo", {
1614
2377
  });
1615
2378
  server.registerTool("workbench_reset_chain", {
1616
2379
  title: "Workbench: Reset Chain",
1617
- description: "Clear the Workbench render chain: empties the undo stack and resets the source back to the cell sample (activeSourcePath = ''). Takes are kept. Returns the full { state, takes }.",
2380
+ description: WORKBENCH_PREREQ +
2381
+ "Clear the Workbench render chain: empties the undo stack and resets the source back to the cell sample (activeSourcePath = ''). Takes are kept. Returns the full { state, takes }.",
1618
2382
  inputSchema: {},
1619
2383
  }, async () => {
1620
2384
  const check = await requireBridge();
@@ -1649,7 +2413,7 @@ server.registerTool("trigger_midi", {
1649
2413
  channel: z.number().min(1).max(16).default(1).describe("MIDI channel (1-16)."),
1650
2414
  },
1651
2415
  }, async ({ cellId, note, noteOn, channel }) => {
1652
- const check = await requireBridge();
2416
+ const check = await requireBridgeForCells(cellId);
1653
2417
  if (check !== true)
1654
2418
  return check;
1655
2419
  if (cellId) {
@@ -1667,7 +2431,7 @@ server.registerTool("stop_cell", {
1667
2431
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
1668
2432
  },
1669
2433
  }, async ({ cellId }) => {
1670
- const check = await requireBridge();
2434
+ const check = await requireBridgeForCells(cellId);
1671
2435
  if (check !== true)
1672
2436
  return check;
1673
2437
  await bridge.send("stopCell", cellId);
@@ -1675,17 +2439,31 @@ server.registerTool("stop_cell", {
1675
2439
  });
1676
2440
  server.registerTool("load_preset", {
1677
2441
  title: "Load Preset",
1678
- description: "Load a preset into the running PAM plugin.",
2442
+ description: "Queue a preset load through PAM's native load/morph lifecycle. Returns requestId and status:'queued'. " +
2443
+ "Poll get_preset_load_status until status:'succeeded' before editing, randomizing, or saving; failure, cancellation, and supersession are explicit. " +
2444
+ "Replaces working state and the variation bank; save_preset first if unsaved edits matter. Requires a plugin build with tracked preset loading.",
1679
2445
  inputSchema: {
1680
2446
  path: z.string().describe("Preset file path (absolute or relative to preset dirs)."),
2447
+ immediate: z.boolean().optional().describe("true bypasses the preset morph; false/omitted follows the native preset morph settings."),
1681
2448
  },
1682
- }, async ({ path: presetPath }) => {
2449
+ }, async ({ path: presetPath, immediate }) => {
1683
2450
  const check = await requireBridge();
1684
2451
  if (check !== true)
1685
2452
  return check;
1686
2453
  const fullPath = await resolvePresetPath(presetPath);
1687
- await bridge.send("loadPreset", fullPath);
1688
- return text(`Loaded preset: ${basename(fullPath, ".preset")}`);
2454
+ return json(await bridge.send("loadPresetTracked", fullPath, immediate === true));
2455
+ });
2456
+ server.registerTool("get_preset_load_status", {
2457
+ title: "Get Preset Load Status",
2458
+ description: "Read the outcome of a load_preset request without requiring the plugin window. " +
2459
+ "Statuses: queued, morphing, succeeded, failed, cancelled, superseded. Only succeeded confirms the world committed, including same-path reloads. " +
2460
+ "PAM retains the latest 64 requests per instance; unknown/expired IDs return an error. Keep the same selected instance while polling.",
2461
+ inputSchema: { requestId: z.string().min(1).describe("requestId returned by load_preset.") },
2462
+ }, async ({ requestId }) => {
2463
+ const check = await requireBridge();
2464
+ if (check !== true)
2465
+ return check;
2466
+ return json(await bridge.send("getPresetLoadStatus", requestId));
1689
2467
  });
1690
2468
  server.registerTool("add_modulation", {
1691
2469
  title: "Add Modulation",
@@ -1733,7 +2511,7 @@ server.registerTool("add_modulation", {
1733
2511
  return error(formatUnknownParamError(paramId, index, { modulatableOnly: true }));
1734
2512
  if (!param.isModulatable)
1735
2513
  return error(formatNotModulatableError(paramId, index));
1736
- const check = await requireBridge();
2514
+ const check = await requireBridgeForCells(cellId);
1737
2515
  if (check !== true)
1738
2516
  return check;
1739
2517
  // Compute effect-enabled params to auto-enable disabled effects (mirrors UI behavior)
@@ -1772,7 +2550,7 @@ server.registerTool("update_pattern", {
1772
2550
  "For 'pitch'/'slice'/'stretch': [{time, value, duration}]."),
1773
2551
  },
1774
2552
  }, async ({ cellId, type, sequence }) => {
1775
- const check = await requireBridge();
2553
+ const check = await requireBridgeForCells(cellId);
1776
2554
  if (check !== true)
1777
2555
  return check;
1778
2556
  // Map pattern type to state key suffix
@@ -1829,7 +2607,10 @@ server.registerTool("randomize", {
1829
2607
  description: "Trigger PAM's randomization engine to generate random sounds and patterns. " +
1830
2608
  "target:'synth' runs the musical, engine-aware drum-synth randomizer (picks an engine + " +
1831
2609
  "shapes its params per cell) — it is routed through the WebView, so it requires PAM's window " +
1832
- "to be OPEN (samples/sequences/full run natively and do not). target:'synth' needs PAM ≥ 1.13.0.",
2610
+ "to be OPEN (samples/sequences/full run natively and do not). 'lfos'/'mods' are global and require an open window. " +
2611
+ "Returns queued, not completed: poll get_state for the requested changes before another randomize, edit, or save. " +
2612
+ "Concurrent requests can coalesce; a preset/variation load can discard pending work. " +
2613
+ "Use target:'full' to chain sample selection and pattern generation in the native engine.",
1833
2614
  inputSchema: {
1834
2615
  // 'lfos'/'mods' added for the assistant surface (1.15.0) — WebView-routed
1835
2616
  // like 'synth'. The describe() text is pinned (exported toolpack bytes).
@@ -1840,7 +2621,7 @@ server.registerTool("randomize", {
1840
2621
  cellIds: z
1841
2622
  .array(z.number().min(1).max(8))
1842
2623
  .optional()
1843
- .describe("Specific cells to randomize. Omit for all."),
2624
+ .describe("Specific cells to randomize. Omit for all. Not supported for global 'lfos'/'mods'."),
1844
2625
  genreHint: z
1845
2626
  .string()
1846
2627
  .optional()
@@ -1857,32 +2638,44 @@ server.registerTool("randomize", {
1857
2638
  .max(1)
1858
2639
  .optional()
1859
2640
  .describe("Mutation amount for pattern randomization (0-1). Lower = subtler changes."),
2641
+ density: z
2642
+ .number()
2643
+ .min(-1)
2644
+ .max(1)
2645
+ .optional()
2646
+ .describe("Pattern density for 'sequences'/'full' (-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 for 'sequences'/'full' (-1 = strong beats, 0 = neutral, 1 = weaker offbeats). Omit for the genre and role default."),
1860
2653
  },
1861
- }, async ({ target, cellIds, genreHint, bpmHint, mutationAmount }) => {
1862
- const check = await requireBridge();
2654
+ }, async ({ target, cellIds, genreHint, bpmHint, mutationAmount, density, syncopation }) => {
2655
+ if ((target === "lfos" || target === "mods") && cellIds !== undefined)
2656
+ return error(`target:'${target}' randomizes global modulation; omit cellIds or edit individual links with update_modulation.`);
2657
+ const check = await requireBridgeForCells(cellIds);
1863
2658
  if (check !== true)
1864
2659
  return check;
1865
2660
  if (target === "synth") {
1866
2661
  // Routed to the canonical WebView randomizer (single source of truth) — needs the
1867
2662
  // window open. Empty array → all non-excluded cells.
1868
2663
  await bridge.send("randomizeDrumSynth", cellIds ?? []);
1869
- return text(`Randomized drum synths${cellIds ? ` for cells ${cellIds.join(", ")}` : " (all cells)"}. ` +
1870
- `(Drum-synth randomize runs in the WebView — if nothing changed, ensure PAM's window is open.)`);
2664
+ return json({ status: "queued", target, cellIds, note: "Open PAM's window and poll get_design_overview for synth changes before editing or saving." });
1871
2665
  }
1872
2666
  if (target === "lfos" || target === "mods") {
1873
- // The data-driven modulation randomizer lives in WebView JS
1874
- // (interop.ts randomizeLfos/randomizeEnvelopes/randomizeVary) — routed
1875
- // like 'synth'. Needs PAM ≥ 1.15.0 and the window open.
1876
2667
  await bridge.send("randomizeModulations", target, mutationAmount ?? -1);
1877
- return text(`Randomized ${target === "lfos" ? "LFO modulation links" : "all modulation (LFOs, envelopes, vary)"}. ` +
1878
- `(Runs in the WebView — if nothing changed, ensure PAM's window is open.)`);
2668
+ return json({ status: "queued", target, note: "Open PAM's window and poll get_state for modulation changes before editing or saving." });
1879
2669
  }
1880
- await bridge.send("randomize", target, cellIds ?? [], genreHint ?? "", bpmHint ?? -1, mutationAmount ?? -1);
1881
- return text(`Randomized ${target}${cellIds ? ` for cells ${cellIds.join(", ")}` : ""}.`);
2670
+ await bridge.send("randomize", target, cellIds ?? [], genreHint ?? "", bpmHint ?? -1, mutationAmount ?? -1, density ?? null, syncopation ?? null);
2671
+ return json({ status: "queued", target, cellIds, note: "Poll get_state for changed sample paths and/or sequences before another randomize, edit, or save. An acknowledgment does not prove the request applied." });
1882
2672
  });
1883
2673
  server.registerTool("randomize_samples", {
1884
2674
  title: "Randomize Samples",
1885
- description: "Use PAM's native sample randomization engine to pick coherent samples based on genre, BPM, and kit role. More musically coherent than manually browsing and loading samples. Supports folder filtering to constrain selection to specific sample packs.",
2675
+ description: "Use PAM's native sample randomization engine to pick coherent samples based on genre, BPM, and kit role. Supports folder filtering. " +
2676
+ "Returns queued: poll get_state section:'cells' for sample changes before generating patterns, editing, or saving. " +
2677
+ "Serialize requests because concurrent requests can coalesce; a preset/variation load can discard pending work. " +
2678
+ "For samples plus patterns, prefer randomize target:'full'.",
1886
2679
  inputSchema: {
1887
2680
  cellIds: z
1888
2681
  .array(z.number().min(1).max(8))
@@ -1891,7 +2684,7 @@ server.registerTool("randomize_samples", {
1891
2684
  genreHint: z
1892
2685
  .string()
1893
2686
  .optional()
1894
- .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."),
2687
+ .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."),
1895
2688
  bpmHint: z
1896
2689
  .number()
1897
2690
  .min(20)
@@ -1906,17 +2699,58 @@ server.registerTool("randomize_samples", {
1906
2699
  .array(z.string())
1907
2700
  .optional()
1908
2701
  .describe("If provided, only pick samples from these paths. Overrides default library scan."),
2702
+ diversify: z
2703
+ .number()
2704
+ .min(0)
2705
+ .max(1)
2706
+ .optional()
2707
+ .describe("Anti-centroid mode (0 = default behaviour, 1 = maximum spread). The native engine scores FOLDER " +
2708
+ "COHERENCE, which is correct for a kit-from-one-pack and is also why repeated randomizes keep landing " +
2709
+ "on the same few well-stocked folders. Above 0 this builds a cross-folder pool first and restricts the " +
2710
+ "engine to it, keeping role assignment while removing its ability to converge. Ignored when " +
2711
+ "allowedSamplePaths is given."),
2712
+ seed: z
2713
+ .number()
2714
+ .int()
2715
+ .min(0)
2716
+ .optional()
2717
+ .describe("Seed for diversify, so a diverged kit can be reproduced. Defaults to 0."),
1909
2718
  },
1910
- }, async ({ cellIds, genreHint, bpmHint, excludedFolders, allowedSamplePaths }) => {
1911
- const check = await requireBridge();
1912
- if (check !== true)
1913
- return check;
1914
- await bridge.send("randomizeSamples", cellIds ?? [], genreHint ?? "", bpmHint ?? -1, excludedFolders ?? [], allowedSamplePaths ?? []);
1915
- return text(`Randomized samples${cellIds ? ` for cells ${cellIds.join(", ")}` : " for all cells"}${genreHint ? ` (genre: ${genreHint})` : ""}.`);
2719
+ }, async ({ cellIds, genreHint, bpmHint, excludedFolders, allowedSamplePaths, diversify, seed }) => {
2720
+ const check = await requireBridgeForCells(cellIds);
2721
+ if (check !== true)
2722
+ return check;
2723
+ let pool = allowedSamplePaths;
2724
+ let diversifyNote = "";
2725
+ if (!pool && diversify && diversify > 0) {
2726
+ const locations = (await bridge.send("getDefaultLocations"));
2727
+ const roots = Object.entries(locations ?? {})
2728
+ .filter(([k, v]) => typeof v === "string" && v.length > 0 && /sample/i.test(k))
2729
+ .map(([, v]) => v);
2730
+ if (roots.length) {
2731
+ const byFolder = await collectLibraryByFolder(roots);
2732
+ const spread = spreadAcrossFolders(byFolder, diversify, seed ?? 0);
2733
+ if (spread.length >= 4) {
2734
+ pool = spread;
2735
+ diversifyNote = ` Diversified: ${spread.length} samples drawn across ${byFolder.size} folders (diversify ${diversify}, seed ${seed ?? 0}).`;
2736
+ }
2737
+ else {
2738
+ diversifyNote = " Diversify found too few samples to build a pool; fell back to the normal library scan.";
2739
+ }
2740
+ }
2741
+ else {
2742
+ diversifyNote = " Diversify could not resolve a sample library root; fell back to the normal library scan.";
2743
+ }
2744
+ }
2745
+ await bridge.send("randomizeSamples", cellIds ?? [], genreHint ?? "", bpmHint ?? -1, excludedFolders ?? [], pool ?? []);
2746
+ return json({ status: "queued", target: "samples", cellIds, genreHint,
2747
+ note: "Poll get_state section:'cells' for changed sample paths before another randomize, patterns, edit, or save." + diversifyNote });
1916
2748
  });
1917
2749
  server.registerTool("randomize_patterns", {
1918
2750
  title: "Randomize Patterns",
1919
- description: "Use PAM's native pattern generation engine to create musically coherent note patterns based on genre and BPM. Analyzes loaded samples to determine roles (kick, snare, hat, etc.) and generates appropriate rhythms.",
2751
+ description: "Use PAM's native pattern generation engine to create note patterns based on loaded sample roles, genre, and BPM. " +
2752
+ "Identical to randomize target:'sequences'. Returns queued: poll get_state for changed cellN_midiSequence values " +
2753
+ "before another randomize, edit, or save. Wait for sample loading/randomization first; concurrent requests can coalesce.",
1920
2754
  inputSchema: {
1921
2755
  cellIds: z
1922
2756
  .array(z.number().min(1).max(8))
@@ -1937,18 +2771,34 @@ server.registerTool("randomize_patterns", {
1937
2771
  .min(0)
1938
2772
  .max(1)
1939
2773
  .optional()
1940
- .describe("How much to vary from typical patterns (0 = conventional, 1 = experimental). Default ~0.5."),
2774
+ .describe("How much to vary from typical patterns (0 = conventional, 1 = experimental). Default ~0.5. " +
2775
+ "This is the pattern-side divergence lever: the generator otherwise produces the genre's most typical " +
2776
+ "rhythm, which is exactly what makes a batch of presets feel the same; 0.7-1.0 buys rhythmic " +
2777
+ "distinctness, low values keep a stock groove."),
2778
+ density: z
2779
+ .number()
2780
+ .min(-1)
2781
+ .max(1)
2782
+ .optional()
2783
+ .describe("Pattern density (-1 = sparse, 0 = neutral, 1 = dense). Omit for the genre and role default."),
2784
+ syncopation: z
2785
+ .number()
2786
+ .min(-1)
2787
+ .max(1)
2788
+ .optional()
2789
+ .describe("Rhythmic placement (-1 = strong beats, 0 = neutral, 1 = weaker offbeats). Omit for the genre and role default."),
1941
2790
  },
1942
- }, async ({ cellIds, genreHint, bpmHint, mutationAmount }) => {
1943
- const check = await requireBridge();
2791
+ }, async ({ cellIds, genreHint, bpmHint, mutationAmount, density, syncopation }) => {
2792
+ const check = await requireBridgeForCells(cellIds);
1944
2793
  if (check !== true)
1945
2794
  return check;
1946
- await bridge.send("randomize", "sequences", cellIds ?? [], genreHint ?? "", bpmHint ?? -1, mutationAmount ?? -1);
1947
- return text(`Randomized patterns${cellIds ? ` for cells ${cellIds.join(", ")}` : " for all cells"}${genreHint ? ` (genre: ${genreHint})` : ""}.`);
2795
+ await bridge.send("randomize", "sequences", cellIds ?? [], genreHint ?? "", bpmHint ?? -1, mutationAmount ?? -1, density ?? null, syncopation ?? null);
2796
+ return json({ status: "queued", target: "sequences", cellIds, genreHint,
2797
+ note: "Poll get_state for changed cellN_midiSequence values before another randomize, edit, or save." });
1948
2798
  });
1949
2799
  server.registerTool("load_variation", {
1950
2800
  title: "Load Variation",
1951
- description: "Switch to a saved variation slot (0-7). Loads the stored state for that variation including parameters, effects, patterns, and modulation. The preset must have variations saved via save_variation first.",
2801
+ description: "Request a switch to a saved variation slot (0-7), including parameters, effects, patterns, and modulation. Empty slots are rejected. Verify currentVariation and a slot-specific value with get_state before editing or saving.",
1952
2802
  inputSchema: {
1953
2803
  index: z
1954
2804
  .number()
@@ -1962,7 +2812,7 @@ server.registerTool("load_variation", {
1962
2812
  if (check !== true)
1963
2813
  return check;
1964
2814
  await bridge.send("loadVariation", index);
1965
- return text(`Loaded variation ${index} (UI slot ${index + 1}).`);
2815
+ return text(`Requested variation ${index} (UI slot ${index + 1}); verify currentVariation and the intended state with get_state before editing or saving.`);
1966
2816
  });
1967
2817
  server.registerTool("full_stop_transport", {
1968
2818
  title: "Full Stop Transport",
@@ -2016,7 +2866,7 @@ server.registerTool("get_mix_analysis", {
2016
2866
  "true/sample peak (tp/sp), PSR/PLR crest, stereo correlation, onset density, and a 25-band Bark " +
2017
2867
  "spectrum per cell, plus the full-mix master block. Pass refresh:true to run a new analysis " +
2018
2868
  "(renders each sounding cell soloed from bar 0 — takes a few seconds; audio does NOT need to be " +
2019
- "playing). Cached result otherwise. Requires PAM ≥ 1.17.0. PAM only.",
2869
+ "playing). Cached result otherwise. Requires a PAM build with Mix Assist support.",
2020
2870
  inputSchema: {
2021
2871
  refresh: z.boolean().optional().describe("Run a fresh analysis instead of returning the cached one."),
2022
2872
  durationSeconds: z.number().min(5).max(30).optional().describe("Analysis render length (default 8 bars at the current BPM, clamped 5-30 s)."),
@@ -2034,14 +2884,32 @@ server.registerTool("get_mix_analysis", {
2034
2884
  while (Date.now() - t0 < 120_000) {
2035
2885
  await new Promise((r) => setTimeout(r, 500));
2036
2886
  const status = (await bridge.send("getMixAnalysis"));
2037
- if (status.status !== "running")
2038
- return json(status);
2887
+ if (status.status !== "running") {
2888
+ const silent = silentMixAnalysisMessage(status);
2889
+ return silent ? error(silent) : json(status);
2890
+ }
2039
2891
  }
2040
2892
  return error("Mix analysis timed out after 120 s.");
2041
2893
  }
2042
2894
  const result = await bridge.send("getMixAnalysis");
2043
- return json(result);
2895
+ const silent = silentMixAnalysisMessage(result);
2896
+ return silent ? error(silent) : json(result);
2044
2897
  });
2898
+ function silentMixAnalysisMessage(raw) {
2899
+ if (!isRecord(raw))
2900
+ return null;
2901
+ const analysis = parseMixAnalysisPayload(raw.analysis);
2902
+ if (!analysis || analysis.cells.length === 0)
2903
+ return null;
2904
+ const stems = [...analysis.cells, analysis.master];
2905
+ const silent = stems.every((stem) => stem.barkDb.length > 0 &&
2906
+ stem.onsets === 0 &&
2907
+ (stem.lufsI === undefined || stem.lufsI <= -119) &&
2908
+ stem.barkDb.every((band) => band <= -119));
2909
+ return silent
2910
+ ? "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."
2911
+ : null;
2912
+ }
2045
2913
  // Epoch of the last OFFLINE analysis suggest_mix applied moves from — the
2046
2914
  // stacking guard's memory (live payloads refresh their epoch, so they never
2047
2915
  // stay consumed). Session-scoped by design: a server restart just re-allows a
@@ -2053,7 +2921,7 @@ server.registerTool("suggest_mix", {
2053
2921
  "partial-loudness fader balance, pan spread, bounded masking EQ cuts, and optional compressor " +
2054
2922
  "settings. Returns absolute moves with per-move rationale and guard notes; apply:true lands them " +
2055
2923
  "as one state update. Run get_mix_analysis with refresh:true first if the mix changed. " +
2056
- "Requires PAM ≥ 1.17.0. PAM only.",
2924
+ "Requires a PAM build with Mix Assist support.",
2057
2925
  inputSchema: {
2058
2926
  scopes: z
2059
2927
  .array(z.enum(["balance", "pan", "eq", "dynamics"]))
@@ -2071,6 +2939,9 @@ server.registerTool("suggest_mix", {
2071
2939
  const analysis = parseMixAnalysisPayload(r.analysis);
2072
2940
  if (!analysis)
2073
2941
  return error("Cached mix analysis is malformed — re-run get_mix_analysis with refresh:true.");
2942
+ const silent = silentMixAnalysisMessage(r);
2943
+ if (silent)
2944
+ return error(silent);
2074
2945
  // Stacking guard (mirrors the panel's consumed-epoch gate): the engine's
2075
2946
  // deltas come from the analysis but land on the LIVE params, so suggesting
2076
2947
  // again from an analysis that was already applied doubles the correction.
@@ -2137,7 +3008,8 @@ server.registerTool("new_preset", {
2137
3008
  });
2138
3009
  server.registerTool("save_preset", {
2139
3010
  title: "Save Preset",
2140
- description: "Save the current PAM state as a new preset file with metadata. " +
3011
+ description: "Save the current PAM state as a preset file with metadata. Omit `name` to re-save the active user preset in place " +
3012
+ "(factory presets are refused). Every save also snapshots the current state into variation slot 0 if the bank is empty, otherwise into the active slot. " +
2141
3013
  "Writes to the user preset folder by default; pass `path` for a specific location. " +
2142
3014
  "Use `update_preset_metadata` to edit an existing preset without re-saving plugin state.",
2143
3015
  inputSchema: {
@@ -2188,19 +3060,12 @@ server.registerTool("save_preset", {
2188
3060
  return error(`Could not derive a name from the active preset path (${activePath}) — pass name explicitly.`);
2189
3061
  if (path === undefined)
2190
3062
  path = activePath;
2191
- try {
2192
- const parsed = parsePresetFile(await readFile(await resolvePresetPath(readPath), "utf-8"));
2193
- for (const [k, v] of Object.entries(parsed.metadata)) {
2194
- // Structural keys are rebuilt below; everything else (author/tags/
2195
- // category/…) carries over unless the caller overrode it.
2196
- if (["name", "factory", "path", "variations", "currentVariation"].includes(k))
2197
- continue;
2198
- if (extras[k] === undefined)
2199
- extras[k] = v;
2200
- }
2201
- }
2202
- catch {
2203
- // unreadable/new — save fresh with whatever the caller passed
3063
+ const parsed = await readPresetForSave(readPath);
3064
+ for (const [k, v] of Object.entries(parsed.metadata)) {
3065
+ if (["name", "factory", "path", "variations", "currentVariation"].includes(k))
3066
+ continue;
3067
+ if (extras[k] === undefined)
3068
+ extras[k] = v;
2204
3069
  }
2205
3070
  }
2206
3071
  const metadata = { name, factory: false };
@@ -2219,7 +3084,11 @@ server.registerTool("save_preset", {
2219
3084
  // the new destination starts from eight empty slots because that file does
2220
3085
  // not exist yet, which used to collapse every named save to one slot.
2221
3086
  const variationSourcePath = selectSaveVariationSourcePath(state.activePresetPath, presetRelPath);
2222
- const existingVariations = await readExistingVariations(variationSourcePath);
3087
+ const hasActivePreset = typeof state.activePresetPath === "string"
3088
+ && state.activePresetPath.length > 0 && state.activePresetPath !== "Init";
3089
+ const source = await readPresetForSave(variationSourcePath, !hasActivePreset);
3090
+ const existingVariations = source
3091
+ ? variationsFromParsedPreset(source) : Array(PRESET_VARIATION_SLOTS).fill(null);
2223
3092
  const hasVariations = existingVariations.some((v) => v !== null);
2224
3093
  if (!hasVariations) {
2225
3094
  existingVariations[0] = variationString;
@@ -2269,49 +3138,25 @@ server.registerTool("save_variation", {
2269
3138
  const check = await requireBridge();
2270
3139
  if (check !== true)
2271
3140
  return check;
2272
- // 1. Canonical save snapshot (engine + hosted-plugin state), then shape it
2273
3141
  const { state, canonical } = await fetchSaveSnapshot();
2274
3142
  const variationString = buildVariationString(state);
2275
- // 2. Determine preset name and disk path — needed to read existing variations and metadata
2276
- let presetName = name;
2277
- let diskPath;
2278
- const activePath = state.activePresetPath;
2279
- if (!presetName && activePath) {
2280
- const filename = activePath.split("/").pop() || "";
2281
- presetName = filename.replace(/\.preset$/, "");
2282
- // Use active path for disk lookup (preserves subfolder structure).
2283
- // Strip "Presets/" prefix if present — resolvePresetPath already prepends
2284
- // the presets base directory, so "Presets/User/X" would double up to
2285
- // ".../Presets/Presets/User/X" and silently fail to read back variations.
2286
- diskPath = normalizePresetLookupPath(activePath);
2287
- }
2288
- if (!presetName && lastSavedPresetName) {
2289
- presetName = lastSavedPresetName;
2290
- diskPath = lastSavedPresetPath;
3143
+ if (!name && state.activePresetPath === "Init") {
3144
+ return error("No active preset. Call save_preset with a name first, or pass a name to save_variation.");
2291
3145
  }
3146
+ const activePath = typeof state.activePresetPath === "string" && state.activePresetPath !== "Init"
3147
+ ? normalizePresetLookupPath(state.activePresetPath) : "";
3148
+ const presetName = name || (activePath.split("/").pop() || "").replace(/\.preset$/, "") || lastSavedPresetName;
2292
3149
  if (!presetName) {
2293
- return error("No preset name provided and no active preset loaded.\n" +
2294
- "Next call: save_preset({ name: 'Your Preset Name', ...metadata }) first, or call save_variation with name: 'Your Preset Name'.");
2295
- }
2296
- if (!diskPath) {
2297
- diskPath = `User/${presetName}.preset`;
2298
- }
2299
- // 3. Read existing preset metadata and variations from disk
2300
- let existingMeta = {};
2301
- let variations = Array(PRESET_VARIATION_SLOTS).fill(null);
2302
- try {
2303
- const fullPath = await resolvePresetPath(diskPath);
2304
- const raw = await readFile(fullPath, "utf-8");
2305
- const parsed = parsePresetFile(raw);
2306
- existingMeta = parsed.metadata;
2307
- variations = variationsFromParsedPreset(parsed);
2308
- }
2309
- catch {
2310
- // New preset or unreadable — start fresh
2311
- }
2312
- // 4. Update the target slot
3150
+ return error("No active preset. Call save_preset with a name first, or pass a name to save_variation.");
3151
+ }
3152
+ const diskPath = name ? `User/${presetName}.preset`
3153
+ : activePath || lastSavedPresetPath || `User/${presetName}.preset`;
3154
+ const sourcePath = selectSaveVariationSourcePath(activePath, diskPath);
3155
+ const parsed = await readPresetForSave(sourcePath, !!name && !activePath);
3156
+ const existingMeta = parsed?.metadata ?? {};
3157
+ const variations = parsed
3158
+ ? variationsFromParsedPreset(parsed) : Array(PRESET_VARIATION_SLOTS).fill(null);
2313
3159
  variations[index] = variationString;
2314
- // 5. Build metadata: merge existing (preserves author/tags/etc.) then override with caller args
2315
3160
  const metadata = {
2316
3161
  ...existingMeta,
2317
3162
  name: presetName,
@@ -2320,12 +3165,13 @@ server.registerTool("save_variation", {
2320
3165
  currentVariation: index,
2321
3166
  ...Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined)),
2322
3167
  };
2323
- // Write to the correct subfolder path
3168
+ delete metadata.path;
2324
3169
  let resultSuffix = "";
2325
- if (activePath && !activePath.startsWith("Factory/")) {
2326
- metadata.path = activePath;
3170
+ const destinationPath = normalizePresetLookupPath(diskPath);
3171
+ if (!name && !destinationPath.startsWith("Factory/")) {
3172
+ metadata.path = destinationPath;
2327
3173
  }
2328
- else if (activePath?.startsWith("Factory/")) {
3174
+ else if (sourcePath.startsWith("Factory/")) {
2329
3175
  // Cannot write back to factory location; savePreset will create User/{name}.preset
2330
3176
  resultSuffix = "\n(Note: active preset is a factory preset — saved as new user preset)";
2331
3177
  }
@@ -2426,12 +3272,12 @@ server.registerTool("set_bpm", {
2426
3272
  });
2427
3273
  server.registerTool("trim_sample", {
2428
3274
  title: "Trim Sample",
2429
- description: "Trim a cell's sample to its start/end markers (non-destructive crop).",
3275
+ description: "Trim a cell's sample to its start/end markers: writes a new WAV into Samples/Resamples/Trimmed/ and points the cell at it. The original file is untouched, but a preset saved afterwards references the trimmed copy, and calling it again crops the trimmed file further.",
2430
3276
  inputSchema: {
2431
3277
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
2432
3278
  },
2433
3279
  }, async ({ cellId }) => {
2434
- const check = await requireBridge();
3280
+ const check = await requireBridgeForCells(cellId);
2435
3281
  if (check !== true)
2436
3282
  return check;
2437
3283
  await bridge.send("trimSampleForCell", cellId);
@@ -2439,12 +3285,12 @@ server.registerTool("trim_sample", {
2439
3285
  });
2440
3286
  server.registerTool("reverse_sample", {
2441
3287
  title: "Reverse Sample",
2442
- description: "Reverse a cell's loaded sample.",
3288
+ description: "Reverse a cell's loaded sample. Writes <name>_reversed.<ext> next to the source file and points the cell at it; calling it again on an already-reversed sample restores the original instead of reversing twice.",
2443
3289
  inputSchema: {
2444
3290
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
2445
3291
  },
2446
3292
  }, async ({ cellId }) => {
2447
- const check = await requireBridge();
3293
+ const check = await requireBridgeForCells(cellId);
2448
3294
  if (check !== true)
2449
3295
  return check;
2450
3296
  await bridge.send("reverseSampleForCell", cellId);
@@ -2469,13 +3315,16 @@ server.registerTool("connection_status", {
2469
3315
  },
2470
3316
  annotations: { readOnlyHint: true },
2471
3317
  }, async () => {
2472
- const instances = await bridge.discover();
3318
+ const discoveredInstances = await bridge.discover();
3319
+ const instances = IS_MFX_SERVER
3320
+ ? discoveredInstances.filter((instance) => instance.product?.trim().toLowerCase() === "mfx")
3321
+ : discoveredInstances;
2473
3322
  if (instances.length === 0) {
2474
3323
  return structured({
2475
3324
  instances: [],
2476
3325
  connected: false,
2477
- note: "No running PAM instances found. To use live tools, PAM needs the MCP bridge enabled: " +
2478
- "start PAM (standalone or in a DAW) and the bridge will write its port to ~/.pam-mcp-bridge.json. " +
3326
+ note: `No running ${SERVER_PRODUCT.name} instances found. To use live tools, ${SERVER_PRODUCT.name} needs the MCP bridge enabled: ` +
3327
+ `start ${SERVER_PRODUCT.name} (standalone or in a DAW) and enable it in Settings → Experimental. ` +
2479
3328
  "Offline tools (get_parameter_info, list_presets, read_preset) work without a connection.",
2480
3329
  });
2481
3330
  }
@@ -2488,11 +3337,117 @@ server.registerTool("connection_status", {
2488
3337
  connectedTo: bridge.getConnectedInstance(),
2489
3338
  note: connected
2490
3339
  ? instances.length > 1
2491
- ? "Connected. Multiple instances are running — use select_instance to drive a specific product."
2492
- : "Connected to PAM. All tools available."
3340
+ ? `Connected. Multiple ${SERVER_PRODUCT.name} instances are running; use select_instance to choose; reconnects stay pinned to that instance.`
3341
+ : `Connected to ${SERVER_PRODUCT.name}. Tool availability also depends on this plugin build, open windows, and feature settings.`
2493
3342
  : "Found instances but could not connect. The plugin may have shut down.",
2494
3343
  });
2495
3344
  });
3345
+ server.registerTool("situation", {
3346
+ title: "Situation",
3347
+ description: "One-call orientation snapshot: discovered instances, the connected instance's product/pid/executable " +
3348
+ "(plus build mtime and a dev-build heuristic when resolvable), server and manifest identity, active preset, " +
3349
+ "transport, and per-cell occupancy counts. Start a session here — it collapses the connection/product/build " +
3350
+ "preflight and the first design reads into one round trip. Deeper reads: get_design_overview, design_context.",
3351
+ inputSchema: {},
3352
+ annotations: { readOnlyHint: true },
3353
+ }, async () => {
3354
+ const instances = await bridge.discover().catch(() => []);
3355
+ const connected = bridge.isConnected() || (await bridge.connect().catch(() => false)) === true;
3356
+ if (connected)
3357
+ syncProductHint();
3358
+ const instance = connected ? bridge.getConnectedInstance() : null;
3359
+ let build;
3360
+ if (instance?.pid) {
3361
+ try {
3362
+ const executable = execFileSync("ps", ["-o", "comm=", "-p", String(instance.pid)], {
3363
+ encoding: "utf8",
3364
+ }).trim();
3365
+ if (executable) {
3366
+ build = {
3367
+ executable,
3368
+ looksLikeDevBuild: executable.includes("/build/") && !executable.startsWith("/Applications/"),
3369
+ };
3370
+ try {
3371
+ build.executableMtime = statSync(executable).mtime.toISOString();
3372
+ }
3373
+ catch {
3374
+ /* executable path unreadable from this process */
3375
+ }
3376
+ const projectRoot = process.env.PAM_PROJECT_ROOT;
3377
+ if (projectRoot && typeof build.executableMtime === "string") {
3378
+ try {
3379
+ const headIso = execFileSync("git", ["-C", resolve(projectRoot), "log", "-1", "--format=%cI"], { encoding: "utf8" }).trim();
3380
+ build.repoHeadCommitTime = headIso;
3381
+ build.binaryOlderThanHeadCommit =
3382
+ new Date(build.executableMtime).getTime() < new Date(headIso).getTime();
3383
+ }
3384
+ catch {
3385
+ /* PAM_PROJECT_ROOT is not a git checkout */
3386
+ }
3387
+ }
3388
+ }
3389
+ }
3390
+ catch {
3391
+ /* ps unavailable (Windows) or pid already gone */
3392
+ }
3393
+ }
3394
+ let live;
3395
+ let manifestName;
3396
+ if (connected) {
3397
+ const [stateRaw, transportRaw, manifest] = await Promise.all([
3398
+ bridge.send("getState").catch(() => undefined),
3399
+ bridge.send("getTransport").catch(() => undefined),
3400
+ loadManifest().catch(() => null),
3401
+ ]);
3402
+ manifestName = manifest?.name;
3403
+ const state = isRecord(stateRaw) ? stateRaw : {};
3404
+ const transport = isRecord(transportRaw) ? transportRaw : {};
3405
+ if (manifest) {
3406
+ const effectNames = cellEffectNames(manifest);
3407
+ const ids = [...new Set(manifest.parameters
3408
+ .map((p) => p.cell)
3409
+ .filter((cell) => typeof cell === "string" && /^cell\d+$/.test(cell))
3410
+ .map((cell) => Number(cell.replace("cell", ""))))].sort((a, b) => a - b);
3411
+ const cellsObj = isRecord(state.cells) ? state.cells : {};
3412
+ const cells = ids.map((id) => {
3413
+ const prefix = `cell${id}_`;
3414
+ const rawCellState = cellsObj[String(id)];
3415
+ const cellState = isRecord(rawCellState) ? rawCellState : {};
3416
+ const samplePath = cellState.filePath ??
3417
+ cellState.samplePath ??
3418
+ state[`${prefix}filePath`] ??
3419
+ state[`${prefix}samplePath`];
3420
+ const synth = IS_MFX_SERVER ? undefined : describeSynthEngine(state[`${prefix}synthEngine`]);
3421
+ return {
3422
+ cellId: id,
3423
+ sample: stemName(samplePath),
3424
+ synthEngine: synth && synth.id > 0 ? synth.name : undefined,
3425
+ muted: state[`${prefix}mute`] === 1 || state[`${prefix}mute`] === true,
3426
+ enabledEffects: effectNames.filter((n) => state[`${prefix}${n}Enabled`] === 1 || state[`${prefix}${n}Enabled`] === true).length,
3427
+ };
3428
+ });
3429
+ live = {
3430
+ activePreset: state.activePresetPath,
3431
+ bpm: state.bpm ?? transport.bpm,
3432
+ playing: transport.playing,
3433
+ cells,
3434
+ ...(IS_MFX_SERVER ? { mix: state.mfx_mix, routing: state.mfx_routing } : {}),
3435
+ };
3436
+ }
3437
+ }
3438
+ return json({
3439
+ server: { name: SERVER_PRODUCT.name, version: SERVER_VERSION },
3440
+ instances,
3441
+ connected,
3442
+ instance,
3443
+ manifest: manifestName,
3444
+ build,
3445
+ live,
3446
+ note: connected
3447
+ ? undefined
3448
+ : "Not connected. connection_status has enable instructions; offline tools still work.",
3449
+ });
3450
+ });
2496
3451
  server.registerTool("select_instance", {
2497
3452
  title: "Select Plugin Instance",
2498
3453
  description: "Choose which running plugin instance (PAM / CELL1 / MFX) the live tools drive when more than " +
@@ -2514,9 +3469,16 @@ server.registerTool("select_instance", {
2514
3469
  if (!product && !instanceId && port === undefined) {
2515
3470
  return error("Pass at least one of product, instanceId, or port. Call connection_status to list running instances.");
2516
3471
  }
2517
- const ok = await bridge.connect({ product, instanceId, port });
3472
+ if (IS_MFX_SERVER && product && product.trim().toLowerCase() !== "mfx") {
3473
+ return error("The mfx-mcp launcher can select MFX instances only.");
3474
+ }
3475
+ const targetProduct = IS_MFX_SERVER ? "MFX" : product;
3476
+ const ok = await bridge.connect({ product: targetProduct, instanceId, port });
2518
3477
  if (!ok) {
2519
- const instances = await bridge.discover();
3478
+ const discovered = await bridge.discover();
3479
+ const instances = IS_MFX_SERVER
3480
+ ? discovered.filter((instance) => instance.product?.trim().toLowerCase() === "mfx")
3481
+ : discovered;
2520
3482
  const listing = instances.length
2521
3483
  ? ` Running instances: ${instances
2522
3484
  .map((i) => `${i.product} (port ${i.port}, id ${i.instanceId})`)
@@ -2573,6 +3535,69 @@ async function searchSampleLibrary(rootPath, term, audioOnly) {
2573
3535
  }
2574
3536
  return matches;
2575
3537
  }
3538
+ // Walk the library and return audio files grouped by their parent folder.
3539
+ // Bounded like searchSampleLibrary — this runs inside a tool call, not a job.
3540
+ async function collectLibraryByFolder(roots, maxDirReads = 120) {
3541
+ const byFolder = new Map();
3542
+ const queue = roots.map((path) => ({ path, depth: 0 }));
3543
+ const options = JSON.stringify({ audioOnly: true, sort: "name" });
3544
+ let dirReads = 0;
3545
+ while (queue.length && dirReads < maxDirReads) {
3546
+ const { path, depth } = queue.shift();
3547
+ dirReads += 1;
3548
+ let entries = [];
3549
+ try {
3550
+ const res = await bridge.send("listDirectory", path, options);
3551
+ if (Array.isArray(res))
3552
+ entries = res;
3553
+ }
3554
+ catch {
3555
+ continue;
3556
+ }
3557
+ for (const e of entries) {
3558
+ if (typeof e.path !== "string")
3559
+ continue;
3560
+ if (e.isDir) {
3561
+ if (depth < 3)
3562
+ queue.push({ path: e.path, depth: depth + 1 });
3563
+ }
3564
+ else {
3565
+ const list = byFolder.get(path) ?? [];
3566
+ list.push(e.path);
3567
+ byFolder.set(path, list);
3568
+ }
3569
+ }
3570
+ }
3571
+ return byFolder;
3572
+ }
3573
+ // Build a sample pool that deliberately SPANS folders instead of collapsing
3574
+ // into one pack.
3575
+ //
3576
+ // WHY: randomize_samples' native engine scores folder coherence, which is the
3577
+ // right default (a kit from one pack sounds like a kit) and the wrong one when
3578
+ // the brief is "something we haven't heard" — coherence pulls every result
3579
+ // toward the same few well-stocked folders. Restricting the engine to a
3580
+ // cross-folder pool keeps its role assignment while removing its ability to
3581
+ // converge. Deterministic given the same seed, so a diverged kit can be
3582
+ // reproduced.
3583
+ function spreadAcrossFolders(byFolder, diversify, seed) {
3584
+ const folders = [...byFolder.keys()].sort();
3585
+ if (!folders.length)
3586
+ return [];
3587
+ // At diversify 1 every folder contributes a single file (maximum spread); at
3588
+ // low values a few folders contribute many (close to normal behaviour).
3589
+ const perFolder = Math.max(1, Math.round(12 - 11 * diversify));
3590
+ const folderCount = Math.max(1, Math.min(folders.length, Math.round(2 + (folders.length - 2) * diversify)));
3591
+ const start = folders.length ? seed % folders.length : 0;
3592
+ const pool = [];
3593
+ for (let i = 0; i < folderCount; i += 1) {
3594
+ const files = byFolder.get(folders[(start + i) % folders.length]) ?? [];
3595
+ for (let j = 0; j < Math.min(perFolder, files.length); j += 1) {
3596
+ pool.push(files[(seed + j * 7) % files.length]);
3597
+ }
3598
+ }
3599
+ return [...new Set(pool)];
3600
+ }
2576
3601
  // Search-result payloads feed the assistant's 256-token continuation round —
2577
3602
  // trim to that budget instead of letting the listing collapse to an opaque
2578
3603
  // truncation string (executor parity: assistantExecutor.ts fitListing).
@@ -2587,14 +3612,12 @@ function fitListing(base, itemsKey, items, moreKey, total) {
2587
3612
  }
2588
3613
  server.registerTool("list_samples", {
2589
3614
  title: "List Samples",
2590
- description: "Browse sample library directories. IMPORTANT: Do NOT guess paths — always call " +
2591
- "get_default_locations first to discover the actual library paths, then use those " +
2592
- "paths here. Paths vary per user and platform.",
3615
+ description: "Browse sample library directories. Library paths vary per user and platform, so take them from " +
3616
+ "get_default_locations rather than guessing.",
2593
3617
  inputSchema: {
2594
3618
  path: z
2595
3619
  .string()
2596
- .describe("Directory path to list. MUST be a path returned by get_default_locations " +
2597
- "or a subdirectory of one. Do not guess paths like ~/Music/PAM."),
3620
+ .describe("Directory path to list: a path from get_default_locations, or a subdirectory of one."),
2598
3621
  audioOnly: z
2599
3622
  .boolean()
2600
3623
  .default(true)
@@ -2683,8 +3706,9 @@ server.registerTool("get_audio_info", {
2683
3706
  });
2684
3707
  server.registerTool("export_preset", {
2685
3708
  title: "Export Preset",
2686
- description: "Export a preset as a zip file bundled with all referenced samples. " +
2687
- "Creates a shareable preset package.",
3709
+ description: "Export a preset as a zip file bundled with all referenced samples, written to PAM's Exports folder as " +
3710
+ "<outputFolderName>.zip (get_default_locations resolves that folder). An existing folder or zip of that " +
3711
+ "name is replaced, and presets belonging to a preset pack are refused.",
2688
3712
  inputSchema: {
2689
3713
  presetPath: z
2690
3714
  .string()
@@ -2702,7 +3726,10 @@ server.registerTool("export_preset", {
2702
3726
  });
2703
3727
  server.registerTool("import_preset", {
2704
3728
  title: "Import Preset",
2705
- description: "Import a preset from a zip file or folder into PAM's preset library.",
3729
+ description: "Import a preset from a zip file or folder into PAM's preset library. Returns the relative path of the " +
3730
+ "first imported preset, or an empty string on failure. Referenced samples are copied into the user sample " +
3731
+ "folder and the preset's paths are rewritten. A name already in the library is never overwritten: the " +
3732
+ "import lands as <name>_1, <name>_2 and so on.",
2706
3733
  inputSchema: {
2707
3734
  path: z.string().describe("Absolute path to the preset zip file or folder to import."),
2708
3735
  },
@@ -2912,7 +3939,7 @@ server.registerTool("capture_ui", {
2912
3939
  "PresetBrowser; omit/null = cell grid), instrumentView ('synth'|'sampler', applied to " +
2913
3940
  "all cells). REQUIRES PAM's window OPEN — html2canvas needs the live DOM, there is no " +
2914
3941
  "headless renderer (same constraint as the video tools). Beats screen-capture: " +
2915
- "deterministic, no cursor/screen control, exact UI bitmap.",
3942
+ "deterministic, no cursor/screen control, exact UI bitmap. View arguments switch the live editor and the previous view is not restored.",
2916
3943
  inputSchema: {
2917
3944
  gridSize: z.number().optional().describe("Cell grid size to set before capture: 1, 4, or 8."),
2918
3945
  mainView: z
@@ -2926,7 +3953,7 @@ server.registerTool("capture_ui", {
2926
3953
  settleMs: z.number().optional().describe("Delay (ms) after view changes before rasterizing. Default 350."),
2927
3954
  timeoutMs: z.number().optional().default(8000).describe("Max ms to wait for the WebView to return the image."),
2928
3955
  },
2929
- annotations: { readOnlyHint: true },
3956
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
2930
3957
  }, async ({ gridSize, mainView, instrumentView, settleMs, timeoutMs }) => {
2931
3958
  const check = await requireBridge();
2932
3959
  if (check !== true)
@@ -3306,13 +4333,23 @@ server.registerTool("set_video_settings", {
3306
4333
  "loads on the next bar boundary while playing). resetWorkspace clears the " +
3307
4334
  "scene (preserves the master layer).",
3308
4335
  inputSchema: {
3309
- syncMode: z.enum(["off", "beat-sync", "beat-match"]).optional(),
3310
- loopBars: z.number().optional(),
3311
- aspectRatio: z.enum(["auto", "16:9", "4:3", "1:1", "stretch"]).optional(),
3312
- renderQuality: z.enum(["low", "medium", "high"]).optional(),
3313
- designAspect: z.number().nullable().optional(),
3314
- letterboxColor: z.string().optional(),
3315
- canvasColor: z.string().optional(),
4336
+ syncMode: z
4337
+ .enum(["off", "beat-sync", "beat-match"])
4338
+ .optional()
4339
+ .describe("Clock source: 'off' free-runs, 'beat-sync' locks playback to the bar grid, 'beat-match' retimes clips to the transport."),
4340
+ loopBars: z.number().min(0).optional().describe("Loop length in bars for the beat-sync and beat-match modes."),
4341
+ aspectRatio: z
4342
+ .enum(["auto", "16:9", "4:3", "1:1", "stretch"])
4343
+ .optional()
4344
+ .describe("How a layer is fitted to the canvas. 'auto' keeps the source ratio, 'stretch' fills it."),
4345
+ renderQuality: z.enum(["low", "medium", "high"]).optional().describe("VX render resolution/effort tier."),
4346
+ designAspect: z
4347
+ .number()
4348
+ .nullable()
4349
+ .optional()
4350
+ .describe("Canvas design ratio as width/height (e.g. 1.7777 for 16:9). null clears it and follows the window."),
4351
+ letterboxColor: z.string().describe("#hex fill for the bars outside the design aspect.").optional(),
4352
+ canvasColor: z.string().describe("#hex background behind every VX layer.").optional(),
3316
4353
  modEnabled: z.boolean().optional().describe("Mod-matrix master enable."),
3317
4354
  vxVariationFollowAudio: z
3318
4355
  .boolean()
@@ -3434,8 +4471,9 @@ const PATTERN_SUFFIXES = [
3434
4471
  ];
3435
4472
  server.registerTool("reset_cell", {
3436
4473
  title: "Reset Cell",
3437
- description: "Reset all parameters of a single cell to their default values and remove " +
3438
- "any envelopes targeting this cell. Does not touch other cells.",
4474
+ description: "Reset a single cell's manifest parameters to their defaults and remove the envelopes whose SOURCE is this cell. " +
4475
+ "Untouched: envelopes targeting this cell from another source (clear_modulations), pattern lanes (reset_patterns), " +
4476
+ "the cell config block (update_cell_config) and the loaded sample. Does not touch other cells.",
3439
4477
  inputSchema: {
3440
4478
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
3441
4479
  },
@@ -3468,8 +4506,9 @@ server.registerTool("reset_cell", {
3468
4506
  });
3469
4507
  server.registerTool("copy_cell", {
3470
4508
  title: "Copy Cell",
3471
- description: "Copy all parameters and modulation from one cell to another. " +
3472
- "Overwrites the target cell's settings.",
4509
+ description: "Copy a cell's manifest parameters and remap its LFO, envelope and vary links onto another cell, overwriting the target's. " +
4510
+ "Not copied: the loaded sample, parameter sequencers, and the cell config block (polyphony, MIDI note/channel, scale, " +
4511
+ "sidechain source, pitch/stretch engine, slice keymap) — re-apply those with load_sample and update_cell_config.",
3473
4512
  inputSchema: {
3474
4513
  sourceCellId: z.number().min(1).max(8).describe("Source cell (1-8)."),
3475
4514
  targetCellId: z.number().min(1).max(8).describe("Target cell (1-8)."),
@@ -3579,7 +4618,7 @@ server.registerTool("copy_cell", {
3579
4618
  server.registerTool("clear_modulations", {
3580
4619
  title: "Clear Modulations",
3581
4620
  description: "Remove all LFO, envelope, and vary modulation links targeting a cell's parameters " +
3582
- "(or originating from that cell, for envelopes/vary). Useful for starting fresh on a cell.",
4621
+ "(or originating from that cell, for envelopes/vary). Parameter sequencers survive — use remove_param_seq for those.",
3583
4622
  inputSchema: {
3584
4623
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
3585
4624
  },
@@ -3651,8 +4690,8 @@ server.registerTool("reset_patterns", {
3651
4690
  });
3652
4691
  server.registerTool("update_effect_config", {
3653
4692
  title: "Update Effect Config",
3654
- description: "Toggle cell effects on/off (filter, delay, crusher, flanger, eq, eq8, fdn, gate, compressor, panner, gain, vocoder, limiter, qverb, wavefold, resonator). " +
3655
- "wavefold and resonator exist on MFX only. " +
4693
+ 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). " +
4694
+ "Wavefold and Resonator ship in every product. Exact availability comes from the selected product manifest. " +
3656
4695
  "Uses the lighter KV path that does not rebuild the audio graph. Pass 1 to enable, 0 to disable.",
3657
4696
  inputSchema: {
3658
4697
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
@@ -3664,7 +4703,7 @@ server.registerTool("update_effect_config", {
3664
4703
  fdnEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3665
4704
  gateEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3666
4705
  compressorEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3667
- // The page-2 effects. These ship on PAM/Cell1 too, so leaving them out of the
4706
+ // Shared extended effects. These ship on PAM/Cell1 too, so leaving them out of the
3668
4707
  // schema made them unreachable over the bridge even though the handler is
3669
4708
  // generic (it just builds `cell{N}_{key}`) and the state vars exist in
3670
4709
  // build.js alongside the eight above.
@@ -3672,10 +4711,19 @@ server.registerTool("update_effect_config", {
3672
4711
  pannerEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3673
4712
  gainEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3674
4713
  vocoderEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4714
+ ringmodEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4715
+ vinylbrakeEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4716
+ transientEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4717
+ repeatEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4718
+ pitchshiftEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3675
4719
  limiterEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3676
4720
  qverbEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3677
- // MFX-only page-2 signature effects; harmless no-ops on PAM/Cell1, whose
3678
- // manifests carry no cellN_wavefoldEnabled / cellN_resonatorEnabled key.
4721
+ chorusEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4722
+ phaserEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4723
+ shimmerEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4724
+ tablefilterEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4725
+ // Product-specific extended effects. The handler rejects flags absent from
4726
+ // the connected product instead of relying on the bridge to ignore them.
3679
4727
  wavefoldEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3680
4728
  resonatorEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3681
4729
  },
@@ -3687,10 +4735,20 @@ server.registerTool("update_effect_config", {
3687
4735
  const cellCheck = validateCellIdForManifest(cellId, manifest);
3688
4736
  if (cellCheck !== true)
3689
4737
  return cellCheck;
4738
+ if (!manifest) {
4739
+ return error("The selected product manifest is unavailable; effect availability cannot be validated safely.");
4740
+ }
4741
+ const availableEffects = new Set(cellEffectNames(manifest));
3690
4742
  const delta = {};
3691
4743
  for (const [key, value] of Object.entries(config)) {
3692
- if (value !== undefined)
3693
- delta[`cell${cellId}_${key}`] = value;
4744
+ if (value === undefined)
4745
+ continue;
4746
+ const effect = key.endsWith("Enabled") ? key.slice(0, -"Enabled".length) : key;
4747
+ if (!availableEffects.has(effect)) {
4748
+ return error(`${effect} is not available in ${manifest.name ?? "the selected product"}. ` +
4749
+ `Available effects: ${[...availableEffects].join(", ")}.`);
4750
+ }
4751
+ delta[`cell${cellId}_${key}`] = value;
3694
4752
  }
3695
4753
  if (Object.keys(delta).length === 0) {
3696
4754
  return error("At least one effect flag must be provided.");
@@ -3705,11 +4763,16 @@ server.registerTool("update_effect_config", {
3705
4763
  // unreachable through set_parameters (numbers only) — an agent could shape an MFX
3706
4764
  // cell but never patch one to another, which is the whole product.
3707
4765
  //
3708
- // Validation mirrors parseRoutingEdges (interface/src/dsp/mfxRouting.ts): the
4766
+ // Validation mirrors parseRoutingEdges (interface/src/dsp/products/mfx/mfxRouting.ts): the
3709
4767
  // plugin re-validates on receipt, so this pass exists to fail loudly here rather
3710
4768
  // than have a typo silently drop a cable.
3711
- const MFX_ROUTING_SOURCE_HINT = "'input', 'sidechain', or 'cellN'. 'output' is a destination only.";
3712
- const MFX_ROUTING_SINK_HINT = "'output' or 'cellN'. 'input'/'sidechain' are sources only.";
4769
+ const MFX_ROUTING_SOURCE_HINT = "'input', 'sidechain', 'split_lo' / 'split_mid' / 'split_hi', or 'cellN'. " +
4770
+ "'output' and 'split' are destinations only.";
4771
+ const MFX_ROUTING_SINK_HINT = "'output', 'split', or 'cellN'. 'input', 'sidechain' and the three split bands are sources only.";
4772
+ // SPLIT — the three-band crossover patch node. One sink, three band sources.
4773
+ const MFX_SPLIT_SINK = "split";
4774
+ const MFX_SPLIT_BANDS = ["split_lo", "split_mid", "split_hi"];
4775
+ const isMfxSplitBand = (port) => MFX_SPLIT_BANDS.includes(port);
3713
4776
  function mfxPortCell(port) {
3714
4777
  const m = /^cell([1-9]\d*)$/.exec(port); // canonical form only — 'cell01' is not a port
3715
4778
  return m ? Number(m[1]) : null;
@@ -3722,12 +4785,23 @@ function validateMfxEdges(edges, maxCells) {
3722
4785
  return c !== null && c >= 1 && c <= maxCells;
3723
4786
  };
3724
4787
  for (const { from, to } of edges) {
3725
- if (from !== "input" && from !== "sidechain" && !inRange(from)) {
4788
+ if (from !== "input" && from !== "sidechain" && !isMfxSplitBand(from) && !inRange(from)) {
3726
4789
  return { ok: false, reason: `invalid source port '${from}' — expected ${MFX_ROUTING_SOURCE_HINT}` };
3727
4790
  }
3728
- if (to !== "output" && !inRange(to)) {
4791
+ if (to !== "output" && to !== MFX_SPLIT_SINK && !inRange(to)) {
3729
4792
  return { ok: false, reason: `invalid destination port '${to}' — expected ${MFX_ROUTING_SINK_HINT}` };
3730
4793
  }
4794
+ // Only a source ENDPOINT may feed the crossover. Mirrors parseRoutingEdges: SPLIT sits
4795
+ // outside the cell evaluation order, so a cell→split cable would put it inside a cycle
4796
+ // the plugin's resolver cannot see. The plugin drops such a cable silently; failing here
4797
+ // is what tells the agent why its patch came back short.
4798
+ if (to === MFX_SPLIT_SINK && from !== "input" && from !== "sidechain") {
4799
+ return {
4800
+ ok: false,
4801
+ reason: `'${from}' cannot feed '${MFX_SPLIT_SINK}' — only 'input' and 'sidechain' can. ` +
4802
+ "Split what arrives, then patch the bands.",
4803
+ };
4804
+ }
3731
4805
  const key = `${from}->${to}`;
3732
4806
  if (seen.has(key))
3733
4807
  continue; // dedupe, matching the plugin
@@ -3742,6 +4816,11 @@ server.registerTool("set_mfx_routing", {
3742
4816
  "this returns a product error.\n\n" +
3743
4817
  "Ports: 'input' (the host's audio in) and 'sidechain' are sources; 'output' is the destination; 'cellN' is both. " +
3744
4818
  "A self-edge cellN→cellN is a feedback loop, and input→output is a direct dry path. Duplicate cables are dropped.\n\n" +
4819
+ "SPLIT is a three-band crossover node: cable 'input' (or 'sidechain') to 'split', then patch 'split_lo', " +
4820
+ "'split_mid' and 'split_hi' to different cells. Each band then has its own full FX rack, its own modulation " +
4821
+ "and its own sidechain key — which is how you build a multiband gate or a multiband distortion here. Only " +
4822
+ "'input' and 'sidechain' may feed 'split'; the bands are sources only. Its two corner frequencies are the " +
4823
+ "ordinary parameters mfx_split_lo_hz and mfx_split_hi_hz (set_parameters).\n\n" +
3745
4824
  "A plain serial chain on 4 cells is input→cell1, cell1→cell2, cell2→cell3, cell3→cell4, cell4→output. " +
3746
4825
  "Parallel is input→each cell plus each cell→output.\n\n" +
3747
4826
  "'set' REPLACES the whole patch, so send the full cable list. Per-cable levels live in mfx_edge_gains and are " +
@@ -3783,6 +4862,258 @@ server.registerTool("set_mfx_routing", {
3783
4862
  const summary = validated.edges.map((e) => `${e.from}→${e.to}`).join(", ");
3784
4863
  return text(`MFX routing set: ${validated.edges.length} cable(s)${summary ? ` — ${summary}` : " (patch cleared)"}.`);
3785
4864
  });
4865
+ server.registerTool("set_mfx_edge_gain", {
4866
+ title: "Set MFX Cable Gain",
4867
+ description: "Read or set the gain for one cable in the current MFX patch. Gain is linear: 0 is silent, 1 is unity, " +
4868
+ "and 1.5 is the maximum. The cable must already exist. Other cable gains are preserved.",
4869
+ inputSchema: {
4870
+ op: z.enum(["get", "set"]).describe("'get' returns every cable gain; 'set' writes one."),
4871
+ from: z.string().optional().describe(`Cable source for 'set': ${MFX_ROUTING_SOURCE_HINT}`),
4872
+ to: z.string().optional().describe(`Cable destination for 'set': ${MFX_ROUTING_SINK_HINT}`),
4873
+ gain: z.number().min(0).max(1.5).optional().describe("Linear gain for 'set'."),
4874
+ },
4875
+ }, async ({ op, from, to, gain }) => {
4876
+ const check = await requireBridge();
4877
+ if (check !== true)
4878
+ return check;
4879
+ const manifest = await loadManifestForProductGuard();
4880
+ if (!manifest || !isMfxManifest(manifest))
4881
+ return error("set_mfx_edge_gain is MFX-only.");
4882
+ const stateRaw = await bridge.send("getState");
4883
+ const state = isRecord(stateRaw) ? stateRaw : {};
4884
+ const gains = isRecord(state.mfx_edge_gains) ? { ...state.mfx_edge_gains } : {};
4885
+ if (op === "get")
4886
+ return json(gains);
4887
+ if (!from || !to || typeof gain !== "number")
4888
+ return error("op 'set' requires from, to, and gain.");
4889
+ const validated = validateMfxEdges([{ from, to }], manifestMaxCellId(manifest) ?? 4);
4890
+ if (!validated.ok)
4891
+ return error(validated.reason);
4892
+ const edges = Array.isArray(state.mfx_routing) ? state.mfx_routing : [];
4893
+ const exists = edges.some((edge) => isRecord(edge) && edge.from === from && edge.to === to);
4894
+ if (!exists)
4895
+ return error(`Cable ${from}→${to} is not in the current patch. Add it with set_mfx_routing first.`);
4896
+ const key = `${from}__${to}`;
4897
+ gains[key] = gain;
4898
+ await applyKv({ mfx_edge_gains: gains }, KV_OPTS_EFFECT_CONFIG);
4899
+ return text(`MFX cable gain set: ${from}→${to} = ${gain}.`);
4900
+ });
4901
+ const MFX_COMBO_TARGET_INPUT = z.object({
4902
+ paramId: z.string(),
4903
+ from: z.number().optional(),
4904
+ mid: z.number().optional(),
4905
+ to: z.number().optional(),
4906
+ });
4907
+ server.registerTool("configure_mfx_combo", {
4908
+ title: "Configure MFX Combo",
4909
+ description: "Read, edit, or move one of MFX's three performance combos. Automatic assignments follow the audible patch. " +
4910
+ "Manual target edits materialize and lock the assignment. setValue writes the combo parameter and its real targets " +
4911
+ "in one native state update, so it works with the MFX window closed.",
4912
+ inputSchema: {
4913
+ op: z.enum([
4914
+ "get", "setValue", "rename", "lock", "unlock", "rescan", "setTargets", "addTarget",
4915
+ "removeTarget", "clearTargets", "invertTarget", "setTargetStop", "anchor",
4916
+ ]).describe("get reads the combo; setValue writes its value and every resolved target; rename relabels it (blank restores the role label); " +
4917
+ "lock freezes the current automatic assignment as manual; unlock and rescan return it to automatic; " +
4918
+ "setTargets/addTarget/removeTarget/clearTargets edit the manual target list; invertTarget swaps a target's from/to stops; " +
4919
+ "setTargetStop sets one target's stop value; anchor re-derives every target's stops from the current parameter values."),
4920
+ index: z.number().int().min(1).max(3).default(1),
4921
+ value: z.number().optional().describe("Combo value (−100 to 100) for setValue, or a value in the target parameter's units for setTargetStop."),
4922
+ name: z.string().max(24).optional().describe("New label for rename; blank restores the role label."),
4923
+ paramId: z.string().optional().describe("Target parameter for add/remove/invert/setTargetStop."),
4924
+ stop: z.enum(["from", "mid", "to"]).optional().describe("Target stop edited by setTargetStop."),
4925
+ targets: z.array(MFX_COMBO_TARGET_INPUT).max(6).optional().describe("Complete target list for setTargets."),
4926
+ },
4927
+ }, async ({ op, index, value, name, paramId, stop, targets }) => {
4928
+ const check = await requireBridge();
4929
+ if (check !== true)
4930
+ return check;
4931
+ const manifest = await loadManifest();
4932
+ if (!isMfxManifest(manifest))
4933
+ return error("configure_mfx_combo is MFX-only.");
4934
+ const stateRaw = await bridge.send("getState");
4935
+ const state = isRecord(stateRaw) ? stateRaw : {};
4936
+ const resolved = resolveServerMfxCombos(state, manifest);
4937
+ const combo = resolved[index - 1];
4938
+ const slots = parseMfxComboSlots(state.mfx_combos);
4939
+ const stored = slots[String(index)] ?? {};
4940
+ const parameterIndex = new Map(manifest.parameters.map((parameter) => [parameter.paramId, parameter]));
4941
+ if (op === "get")
4942
+ return json({ combos: resolved.map((entry) => ({ ...entry, value: state[`mfx_combo${entry.index}`] })) });
4943
+ if (op === "setValue") {
4944
+ if (typeof value !== "number")
4945
+ return error("setValue requires value.");
4946
+ if (value < -100 || value > 100)
4947
+ return error("Combo value must be between -100 and 100.");
4948
+ const delta = { [`mfx_combo${index}`]: value };
4949
+ for (const target of combo.targets) {
4950
+ const info = parameterIndex.get(target.id);
4951
+ if (!info)
4952
+ continue;
4953
+ const previousComboValue = state[`mfx_combo${index}`];
4954
+ const currentTargetValue = state[target.id];
4955
+ delta[target.id] = mfxComboTargetValueFromCurrent(target, typeof previousComboValue === "number" ? previousComboValue : 0, value, info, typeof currentTargetValue === "number" ? currentTargetValue : info.defaultValue);
4956
+ }
4957
+ await applyKv(delta, KV_OPTS_STANDARD);
4958
+ return json({ combo: index, value, targetsWritten: combo.targets.map((target) => target.id) });
4959
+ }
4960
+ const materialized = () => ({ ...stored, targets: [...combo.targets] });
4961
+ let next = stored;
4962
+ if (op === "rename") {
4963
+ if (name === undefined)
4964
+ return error("rename requires name.");
4965
+ next = { ...stored };
4966
+ const trimmed = name.trim().slice(0, 24);
4967
+ if (trimmed)
4968
+ next.name = trimmed;
4969
+ else
4970
+ delete next.name;
4971
+ }
4972
+ else if (op === "lock") {
4973
+ next = { ...materialized(), locked: 1 };
4974
+ }
4975
+ else if (op === "unlock" || op === "rescan") {
4976
+ next = stored.name ? { name: stored.name } : null;
4977
+ }
4978
+ else {
4979
+ let edited = materialized();
4980
+ if (op === "setTargets") {
4981
+ if (!targets)
4982
+ return error("setTargets requires targets.");
4983
+ const built = [];
4984
+ for (const target of targets) {
4985
+ const info = parameterIndex.get(target.paramId);
4986
+ if (!info || !isMfxComboTargetParameter(info)) {
4987
+ return error(`'${target.paramId}' is not an available MFX combo target.`);
4988
+ }
4989
+ if (built.some((candidate) => candidate.id === target.paramId)) {
4990
+ return error(`'${target.paramId}' appears more than once in targets.`);
4991
+ }
4992
+ const current = state[target.paramId];
4993
+ let builtTarget = buildMfxComboTarget(target.paramId, info, MFX_COMBO_MANUAL_STRENGTH, 1, typeof current === "number" ? current : info.defaultValue);
4994
+ for (const targetStop of ["from", "mid", "to"]) {
4995
+ const targetValue = target[targetStop];
4996
+ if (typeof targetValue === "number")
4997
+ builtTarget = setMfxComboTargetStop(builtTarget, info, targetStop, targetValue);
4998
+ }
4999
+ built.push(builtTarget);
5000
+ }
5001
+ edited.targets = built;
5002
+ }
5003
+ else if (op === "clearTargets") {
5004
+ edited.targets = [];
5005
+ }
5006
+ else if (op === "anchor") {
5007
+ edited.targets = edited.targets.map((target) => {
5008
+ const info = parameterIndex.get(target.id);
5009
+ const current = state[target.id];
5010
+ return info && typeof current === "number" ? reanchorMfxComboTarget(target, info, current) : target;
5011
+ });
5012
+ }
5013
+ else {
5014
+ if (!paramId)
5015
+ return error(`${op} requires paramId.`);
5016
+ const info = parameterIndex.get(paramId);
5017
+ if (!info || !isMfxComboTargetParameter(info)) {
5018
+ return error(`'${paramId}' is not an available MFX combo target.`);
5019
+ }
5020
+ if (op === "addTarget") {
5021
+ if (!edited.targets.some((target) => target.id === paramId)) {
5022
+ if (edited.targets.length >= 6)
5023
+ return error("A combo can drive at most 6 targets.");
5024
+ const current = state[paramId];
5025
+ edited.targets.push(buildMfxComboTarget(paramId, info, MFX_COMBO_MANUAL_STRENGTH, 1, typeof current === "number" ? current : info.defaultValue));
5026
+ }
5027
+ }
5028
+ else if (op === "removeTarget") {
5029
+ edited.targets = edited.targets.filter((target) => target.id !== paramId);
5030
+ }
5031
+ else if (op === "invertTarget") {
5032
+ edited.targets = edited.targets.map((target) => target.id === paramId
5033
+ ? { id: target.id, from: target.to, mid: target.mid, to: target.from }
5034
+ : target);
5035
+ }
5036
+ else if (op === "setTargetStop") {
5037
+ if (!stop || typeof value !== "number")
5038
+ return error("setTargetStop requires paramId, stop, and value.");
5039
+ if (!edited.targets.some((target) => target.id === paramId)) {
5040
+ return error(`'${paramId}' is not assigned to combo ${index}.`);
5041
+ }
5042
+ edited.targets = edited.targets.map((target) => target.id === paramId
5043
+ ? setMfxComboTargetStop(target, info, stop, value)
5044
+ : target);
5045
+ }
5046
+ }
5047
+ next = { ...edited, locked: 1 };
5048
+ }
5049
+ const update = {
5050
+ name: next?.name ?? null,
5051
+ locked: next?.locked === 1 ? 1 : 0,
5052
+ targets: Array.isArray(next?.targets) ? next.targets : null,
5053
+ };
5054
+ await applyKv({ mfx_combos: { ...slots, [String(index)]: update } }, KV_OPTS_EFFECT_CONFIG);
5055
+ return json({ combo: index, slot: update });
5056
+ });
5057
+ server.registerTool("set_effect_order", {
5058
+ title: "Set Cell FX Chain Order",
5059
+ description: "Read or replace one cell's FX chain order. cellN_effectOrder is a JSON string array, not a numeric " +
5060
+ "parameter, so set_parameters cannot reach it — this tool owns it (the set_mfx_routing precedent).\n\n" +
5061
+ "The available stages and their fresh-cell order are product-specific and come from the selected manifest; " +
5062
+ "design_context reports that default order. 'set' REPLACES the chain, so send every listed stage " +
5063
+ "in the order you want it to process; the array must be a permutation, not a subset (use the per-effect " +
5064
+ "*Enabled flags via update_effect_config to switch stages off).\n\n" +
5065
+ "Order is audible and is a cheap way to make two otherwise identical patches sound different: filter before " +
5066
+ "delay smears the resonance into the repeats, delay before filter darkens the repeats instead. Crusher " +
5067
+ "before filter grinds then tames; filter before crusher grinds only what survived. Reordering rebuilds the " +
5068
+ "cell's graph.",
5069
+ inputSchema: {
5070
+ cellId: z.number().int().min(1).max(8).describe("Cell to read or reorder."),
5071
+ op: z.enum(["get", "set"]).default("set").describe("'get' reads the current chain; 'set' replaces it."),
5072
+ order: z
5073
+ .array(z.string().min(1))
5074
+ .optional()
5075
+ .describe("Full product-specific chain order for 'set' — a permutation of every stage reported by design_context."),
5076
+ },
5077
+ annotations: { destructiveHint: false, idempotentHint: true },
5078
+ }, async ({ cellId, op, order }) => {
5079
+ const check = await requireBridgeForCells(cellId);
5080
+ if (check !== true)
5081
+ return check;
5082
+ const key = `cell${cellId}_effectOrder`;
5083
+ if (op === "get") {
5084
+ const raw = await bridge.send("getState", key);
5085
+ return json(isRecord(raw) ? raw[key] ?? raw : raw);
5086
+ }
5087
+ if (!order)
5088
+ return error("op 'set' requires an `order` array.");
5089
+ const manifest = await loadManifestForProductGuard();
5090
+ if (!manifest) {
5091
+ return error("The selected product manifest is unavailable; its complete FX chain cannot be validated safely.");
5092
+ }
5093
+ const expectedOrder = cellEffectOrder(manifest);
5094
+ if (expectedOrder.length === 0) {
5095
+ return error(`${manifest.name ?? "The selected product"} manifest has no valid cell1_effectOrder default.`);
5096
+ }
5097
+ const seen = new Set(order);
5098
+ if (seen.size !== order.length) {
5099
+ const dupes = order.filter((s, i) => order.indexOf(s) !== i);
5100
+ return error(`Duplicate stage(s) in order: ${[...new Set(dupes)].join(", ")}.`);
5101
+ }
5102
+ // A subset would silently drop stages from the chain rather than bypass
5103
+ // them, so it is rejected here instead of at the plugin.
5104
+ const expected = new Set(expectedOrder);
5105
+ const unknown = order.filter((stage) => !expected.has(stage));
5106
+ const missing = expectedOrder.filter((stage) => !seen.has(stage));
5107
+ if (unknown.length || missing.length) {
5108
+ return error(`order must be a full ${manifest.name ?? "product"} permutation` +
5109
+ `${unknown.length ? ` — unknown: ${[...new Set(unknown)].join(", ")}` : ""}` +
5110
+ `${missing.length ? ` — missing: ${missing.join(", ")}` : ""}. ` +
5111
+ "To bypass a stage set its *Enabled flag via update_effect_config; leaving it out of the chain is not the same thing.");
5112
+ }
5113
+ // effectOrder rebuilds the cell's FX graph, like an MFX re-patch.
5114
+ await applyKv({ [key]: order }, KV_OPTS_STANDARD);
5115
+ return text(`Cell ${cellId} FX chain: ${order.join(" → ")}.`);
5116
+ });
3786
5117
  server.registerTool("update_cell_config", {
3787
5118
  title: "Update Cell Config",
3788
5119
  description: "Update cell-level configuration properties like polyphony, MIDI settings, varispeed, and modulation latch. " +
@@ -3830,8 +5161,8 @@ server.registerTool("update_cell_config", {
3830
5161
  "An out-of-vocabulary string would silently play as chromatic while junk persists into the preset, so the name is validated."),
3831
5162
  scaleRoot: z.number().min(0).max(11).optional().describe("Root note of the scale (0=C, 1=C#, ..., 11=B)."),
3832
5163
  pitchMode: z.number().int().min(0).max(3).optional().describe("Pitch engine: 0=Varispeed, 1=Formant, 2=Pitch Shift Quantize, 3=Varispeed Quantize."),
3833
- stretchMode: z.number().int().min(0).max(3).optional().describe("Stretch engine: 0=OLA, 1=Cloud, 2=Phase Vocoder, 3=GrainRate."),
3834
- quantScale: z.number().int().min(0).max(9).optional().describe("Pitch quantize scale when pitchMode=2 (Pitch Shift Quantize) or 3 (Varispeed Quantize): 0=Chromatic, 1=Major, 2=Minor, 3=Penta Maj, 4=Penta Min, 5=Dorian, 6=Mixolydian, 7=Blues, 8=Harm Min, 9=Phrygian. Order locked to QuantScale in modules/helpers/Scale.h."),
5164
+ stretchMode: z.number().int().min(0).max(3).optional().describe("Stretch engine: 0=Default (OLA), 1=Cloud, 2=Spectral, 3=Beats."),
5165
+ quantScale: z.number().int().min(0).max(9).optional().describe("Pitch quantize scale when pitchMode=2 (Pitch Shift Quantize) or 3 (Varispeed Quantize): 0=Chromatic, 1=Major, 2=Minor, 3=Penta Maj, 4=Penta Min, 5=Dorian, 6=Mixolydian, 7=Blues, 8=Harm Min, 9=Phrygian. Order locked to QuantScale in modules/dsp-core/Scale.h."),
3835
5166
  quantRoot: z.number().int().min(0).max(11).optional().describe("Pitch quantize root when pitchMode=2 (Pitch Shift Quantize) or 3 (Varispeed Quantize): 0=C, 1=C#, ..., 11=B."),
3836
5167
  grainDensity: z.number().int().min(1).max(24).optional().describe("Cloud stretch grain density when stretchMode=1."),
3837
5168
  grainWindow: z.number().int().min(0).max(4).optional().describe("Cloud stretch grain envelope when stretchMode=1: 0=Hann, 1=Tukey, 2=Welch, 3=Punch, 4=Swell."),
@@ -3909,7 +5240,7 @@ server.registerTool("set_cell_mute", {
3909
5240
  description: "Mute, unmute, solo, or unsolo a cell in the mixer — the real channel mute/solo, cutting the whole channel " +
3910
5241
  "(source + all effect tails) rather than pulling the volume fader down. Soloing a cell silences every other " +
3911
5242
  "non-soloed cell until unsoloed. cell{N}_mute / cell{N}_solo are live-polled state variables (no graph rebuild). " +
3912
- "Prefer this over set_parameter volume -60 when the user says mute, unmute, solo, unsolo, isolate, or 'just this cell'.",
5243
+ "Prefer this over pulling cell{N}_volume down: a fader move leaves effect tails audible, a channel mute cuts them.",
3913
5244
  inputSchema: {
3914
5245
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
3915
5246
  mute: z.boolean().optional().describe("Mute (true) or unmute (false) the cell's channel."),
@@ -3950,11 +5281,7 @@ server.registerTool("set_master_effects", {
3950
5281
  "Check results with get_master_analysis (keep tp <= -1 dBTP, PSR >= 8 in the loudest section). " +
3951
5282
  "The Tape is a vintage-tape coloration stage (ADAA tanh/asymmetric saturation + HF compression + wow/flutter), " +
3952
5283
  "the single biggest lever for lo-fi / dusty / 'pushed' master character (hip-hop, lo-fi, Memphis, dub). " +
3953
- "Pass enabled flags to toggle each on/off. Pass parameters to shape the sound. " +
3954
- "For pumpy beats: enable punch, set threshold around -18 to -12 dB, fast attack (0.1-0.5ms), " +
3955
- "medium release (80-200ms), ratio 4-8, autoMakeup true. " +
3956
- "For lo-fi/Memphis/dusty glue: tapeEnabled=1, tapeMode=1 (Lo-Fi), masterTapeDrive 0.55-0.7, " +
3957
- "masterTapeTone -0.4..-0.1 (darker), masterTapeWear 0.5-0.7 (wow/flutter). " +
5284
+ "Pass enabled flags to toggle each on/off. Pass parameters to shape the sound; genre recipes live in the beat-making-guide resource. " +
3958
5285
  "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). " +
3959
5286
  "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.",
3960
5287
  inputSchema: {
@@ -3988,7 +5315,7 @@ server.registerTool("set_master_effects", {
3988
5315
  masterEqHsGain: z.number().min(-12).max(12).optional().describe("High-shelf gain dB (default 0)."),
3989
5316
  masterWidth: z.number().min(0).max(2).optional().describe("Stereo width (0=mono, 1=unity, 2=double side; default 1). Check corr in get_master_analysis after widening."),
3990
5317
  masterBassMonoFreq: z.number().min(0).max(300).optional().describe("Elliptical bass-mono corner Hz; < 20 = off. Engage (~100-120) when corrLow < +0.7."),
3991
- masterClipEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the oversampled soft clipper (post-Tape, pre-Limiter). Adds 23 samples of reported latency while on."),
5318
+ masterClipEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the oversampled soft clipper (post-Tape, pre-Limiter). Its 23 samples of latency are reported whether it is on or off, so toggling it never moves the host's compensation."),
3992
5319
  masterClipDrive: z.number().min(0).max(12).optional().describe("Clipper drive dB (default 0). Staging guidance: the clipper shaves the sharpest 2-3 dB so the limiter works 1-2 dB."),
3993
5320
  masterClipMix: z.number().min(0).max(1).optional().describe("Clipper parallel blend (1 = fully clipped)."),
3994
5321
  masterClipMode: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]).optional().describe("Clipper transfer: 0=Smooth (knee-tanh), 1=Silicon diode (hard knee), 2=Germanium diode (earlier, softer knee), 3=LED (higher knee). Diode modes are exact antiparallel-pair solves, ADAA at the 2x rate. Default 0."),
@@ -4169,7 +5496,7 @@ server.registerTool("set_drum_synth_wave", {
4169
5496
  "centre, tension −1..+1 with 0 = linear). PAM bakes them into a DC-removed, peak-normalized single cycle " +
4170
5497
  "that the Wave engine's Draw param (p8) crossfades against the factory bank. Only the Wave engine reads it — " +
4171
5498
  "select 'Wave' via set_drum_synth and raise Draw (p8) to hear it. Runs in the WebView (the curve transform " +
4172
- "is single-sourced there), so PAM's window must be OPEN. Needs PAM ≥ 1.13.0.",
5499
+ "is single-sourced there), so PAM's window must be OPEN with drum-synth wave editing support.",
4173
5500
  inputSchema: {
4174
5501
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
4175
5502
  points: z
@@ -4200,6 +5527,394 @@ server.registerTool("set_drum_synth_wave", {
4200
5527
  return text(`Set cell ${cellId} Wave-engine custom waveform (${points.length} breakpoints). ` +
4201
5528
  `Select the Wave engine and raise Draw (p8) to hear it. (Runs in the WebView — ensure PAM's window is open.)`);
4202
5529
  });
5530
+ // Generative Players (per-cell PLAYER mode). Arming semantics mirror
5531
+ // interface/src/interop.ts setCellPlayer; the registry is shared/generativePlayers.json.
5532
+ const PLAYER_REF_HELP = `one of ${PLAYER_NAMES.join(", ")} (or its id 1..${GENERATIVE_PLAYERS.length}), ` +
5533
+ "'role' for the cell's role default, or 'off'";
5534
+ function playerProductError(manifest) {
5535
+ if (manifest && manifest.generativePatterns?.enabled !== true) {
5536
+ return error(`Generative players are PAM-only. ${manifest.name ?? "This product"}'s manifest exposes none.`);
5537
+ }
5538
+ return null;
5539
+ }
5540
+ const playerSlotArg = (n) => z
5541
+ .union([z.number().min(0).max(1), z.string()])
5542
+ .optional()
5543
+ .describe(`P${n} slot: 0..1 (snapped to the slot's detents) or a position label such as 'Techno' or 'Triad'. ` +
5544
+ "Its meaning is per player — list_players names it.");
5545
+ server.registerTool("list_players", {
5546
+ title: "List Generative Players",
5547
+ description: "Catalogue of PAM's generative Players — per-cell generators that replace a cell's drawn pattern with a " +
5548
+ "seeded, transport-synced cycle (EUCLID, KIT, TURING, WALK, MARKOV, RATCHET, PHASE, RIFF, MASK). Returns each " +
5549
+ "player's blurb, defaults, P-slot vocabulary (detented positions or 0..1 knobs) and curated preset names, plus " +
5550
+ "the role spread (which player each cell arms by default). Offline — no bridge needed. Arm one with set_player.",
5551
+ inputSchema: {
5552
+ player: z.string().optional().describe("Return one player's full detail (name or id). Omit for the whole roster."),
5553
+ presets: z
5554
+ .boolean()
5555
+ .optional()
5556
+ .describe("Include each curated preset's values, not only its name. Default false."),
5557
+ },
5558
+ annotations: { readOnlyHint: true },
5559
+ }, async ({ player, presets }) => {
5560
+ const manifest = await loadManifestForProductGuard();
5561
+ const available = manifest ? manifest.generativePatterns?.enabled === true : true;
5562
+ const spec = player ? resolvePlayerSpec(player) : undefined;
5563
+ if (player && !spec)
5564
+ return error(`Unknown player '${player}' — ${PLAYER_REF_HELP}.`);
5565
+ const specs = spec ? [spec] : GENERATIVE_PLAYERS;
5566
+ return json({
5567
+ available,
5568
+ ...(available ? {} : { note: `${manifest?.name ?? "This product"} exposes no players; the catalogue is informational.` }),
5569
+ players: specs.map((entry) => ({
5570
+ ...describePlayerSpec(entry, true),
5571
+ ...(presets ? { presetValues: playerPresetsFor(entry.id) } : {}),
5572
+ })),
5573
+ roleSpread: roleSpreadSummary(),
5574
+ notes: [
5575
+ "A player replaces what the cell PLAYS, never the drawn pattern: set_player 'off' restores the grid exactly.",
5576
+ "Sequencer output needs the transport running (start_transport).",
5577
+ "Melodic players arm the cell poly with the root on its trigger note; 'off' restores both.",
5578
+ "KIT follows its Genre slot — pass genre to set_player so it matches the track.",
5579
+ "patternMode 'keep' layers the player over the drawn pattern instead of masking it.",
5580
+ ],
5581
+ });
5582
+ });
5583
+ server.registerTool("set_player", {
5584
+ title: "Set Generative Player",
5585
+ description: "Arm, edit or switch off a cell's generative Player (PAM-only). Passing `player` arms that generator with the " +
5586
+ "roster's own voicing (or a curated `preset`), the track genre on genre-aware players, and a reversible poly " +
5587
+ "arm on melodic ones; omit `player` to edit the running player's values in place. Values land in both the cell " +
5588
+ "config and the cell{N}_player* automation params, like the plugin UI. 'off' restores the drawn pattern and the " +
5589
+ "cell's pre-arm polyphony. The player sounds only while the transport runs. The UI shows a running player " +
5590
+ "unprompted; to arm from the UI as well, update_settings { experimentalMode: true, experimentalGenerativePatterns: true }.",
5591
+ inputSchema: {
5592
+ cellIds: z
5593
+ .array(z.number().int().min(1).max(8))
5594
+ .min(1)
5595
+ .describe("Cells to write (1-8). Several cells take the same write, each snapped to its own player."),
5596
+ player: z
5597
+ .union([z.string(), z.number().int().min(0)])
5598
+ .optional()
5599
+ .describe(`Player to arm: ${PLAYER_REF_HELP}. Omit to edit the running player.`),
5600
+ preset: z.string().optional().describe("Curated preset name for the armed or running player — list_players lists them."),
5601
+ genre: z
5602
+ .enum(PLAYER_GENRES)
5603
+ .optional()
5604
+ .describe("Genre for genre-aware players (KIT's Genre slot). Defaults to the roster default when arming."),
5605
+ density: z.number().min(0).max(1).optional().describe("How much plays, 0..1. Audible from the next step."),
5606
+ mutate: z.number().min(0).max(1).optional().describe("Cycle-to-cycle evolution, 0..1 (below 0.02 locks the loop)."),
5607
+ p1: playerSlotArg(1),
5608
+ p2: playerSlotArg(2),
5609
+ p3: playerSlotArg(3),
5610
+ p4: playerSlotArg(4),
5611
+ p5: playerSlotArg(5),
5612
+ seed: z
5613
+ .union([z.number().int().min(0), z.literal("random")])
5614
+ .optional()
5615
+ .describe("Player seed (uint31) or 'random' to reroll — the DICE gesture."),
5616
+ patternMode: z
5617
+ .enum(PATTERN_MODES)
5618
+ .optional()
5619
+ .describe("'mask' (default): the player replaces the drawn pattern. 'keep': the drawn pattern keeps sounding and the player layers over it."),
5620
+ listenMode: z
5621
+ .enum(LISTEN_MODES)
5622
+ .optional()
5623
+ .describe("Ensemble listening: react to another cell's last completed cycle — complement its gaps, answer it, or shade velocity harmonically."),
5624
+ listenSource: z.number().int().min(0).max(8).optional().describe("Listening source: 0 = all other cells, 1-8 = one cell (never itself)."),
5625
+ listenAmount: z.number().min(0).max(1).optional().describe("Listening strength 0..1."),
5626
+ poly: z
5627
+ .boolean()
5628
+ .optional()
5629
+ .describe("Override the automatic poly arm: true forces poly, false keeps the cell mono even for a melodic player."),
5630
+ },
5631
+ }, async ({ cellIds, player, preset, genre, density, mutate, p1, p2, p3, p4, p5, seed, patternMode, listenMode, listenSource, listenAmount, poly }) => {
5632
+ const check = await requireBridgeForCells(cellIds);
5633
+ if (check !== true)
5634
+ return check;
5635
+ const manifest = await loadManifestForProductGuard();
5636
+ const gate = playerProductError(manifest);
5637
+ if (gate)
5638
+ return gate;
5639
+ const playerRef = typeof player === "string" ? player.trim().toLowerCase() : player;
5640
+ const wantsOff = playerRef === "off" || playerRef === 0;
5641
+ const wantsRole = playerRef === "role";
5642
+ const explicitSpec = player !== undefined && !wantsOff && !wantsRole ? resolvePlayerSpec(player) : undefined;
5643
+ if (player !== undefined && !wantsOff && !wantsRole && !explicitSpec) {
5644
+ return error(`Unknown player '${player}' — ${PLAYER_REF_HELP}.`);
5645
+ }
5646
+ const stateRaw = await bridge.send("getState");
5647
+ const state = isRecord(stateRaw) ? stateRaw : {};
5648
+ const cellsObj = isRecord(state.cells) ? state.cells : {};
5649
+ const slotInputs = [p1, p2, p3, p4, p5];
5650
+ const flatDelta = {};
5651
+ const cellsDelta = {};
5652
+ const lines = [];
5653
+ for (const cellId of new Set(cellIds)) {
5654
+ const rawCell = cellsObj[String(cellId)];
5655
+ const cell = isRecord(rawCell) ? rawCell : {};
5656
+ const currentType = cellPlayerType(cell);
5657
+ const recorded = (key) => {
5658
+ const value = Number(cell[key]);
5659
+ return Number.isFinite(value) && value !== NO_POLY_ARM ? Math.round(value) : undefined;
5660
+ };
5661
+ if (wantsOff) {
5662
+ if (currentType <= 0) {
5663
+ lines.push(`Cell ${cellId}: no player running.`);
5664
+ continue;
5665
+ }
5666
+ const config = { playerType: 0, playerLastType: currentType };
5667
+ const prePoly = recorded("playerPrePoly");
5668
+ const preRoot = recorded("playerPreRoot");
5669
+ if (prePoly !== undefined)
5670
+ config.isPoly = prePoly;
5671
+ if (preRoot !== undefined)
5672
+ config.polyRootNote = preRoot;
5673
+ config.playerPrePoly = NO_POLY_ARM;
5674
+ config.playerPreRoot = NO_POLY_ARM;
5675
+ cellsDelta[String(cellId)] = config;
5676
+ lines.push(`Cell ${cellId}: ${getPlayerSpec(currentType)?.name ?? "player"} off — drawn pattern restored` +
5677
+ `${prePoly !== undefined || preRoot !== undefined ? ", pre-arm polyphony restored" : ""}.`);
5678
+ continue;
5679
+ }
5680
+ const roleConfig = wantsRole ? rolePlayerConfigFor(cellId) : undefined;
5681
+ const spec = explicitSpec ?? (roleConfig ? getPlayerSpec(roleConfig.playerType) : getPlayerSpec(currentType));
5682
+ if (!spec)
5683
+ return error(`Cell ${cellId} runs no player. Pass player to arm one — ${PLAYER_REF_HELP}.`);
5684
+ const arming = explicitSpec !== undefined || roleConfig !== undefined;
5685
+ const config = {};
5686
+ if (arming) {
5687
+ Object.assign(config, playerDefaultsFor(spec.id), playerGenreConfig(spec, genre));
5688
+ if (roleConfig?.playerP1 !== undefined)
5689
+ config.playerP1 = roleConfig.playerP1;
5690
+ if (roleConfig?.playerP5 !== undefined)
5691
+ config.playerP5 = roleConfig.playerP5;
5692
+ config.playerLastType = spec.id;
5693
+ }
5694
+ else if (genre) {
5695
+ Object.assign(config, playerGenreConfig(spec, genre));
5696
+ }
5697
+ if (preset) {
5698
+ const found = resolvePlayerPreset(spec, preset);
5699
+ if (!found) {
5700
+ const names = playerPresetsFor(spec.id).map((entry) => entry.name);
5701
+ return error(`${spec.name} has no preset '${preset}' — one of: ${names.join(", ")}.`);
5702
+ }
5703
+ Object.assign(config, presetConfig(found));
5704
+ }
5705
+ if (density !== undefined)
5706
+ config.playerDensity = density;
5707
+ if (mutate !== undefined)
5708
+ config.playerMutate = mutate;
5709
+ for (let i = 0; i < slotInputs.length; i++) {
5710
+ const input = slotInputs[i];
5711
+ if (input === undefined)
5712
+ continue;
5713
+ const slot = spec.slots[i];
5714
+ if (!slot)
5715
+ return error(`${spec.name} has no P${i + 1} slot (it has ${spec.slots.length}).`);
5716
+ const resolved = resolveSlotValue(slot, input);
5717
+ if (typeof resolved === "string")
5718
+ return error(`Cell ${cellId} ${spec.name} P${i + 1}: ${resolved}`);
5719
+ config[PLAYER_SLOT_FIELDS[i]] = resolved;
5720
+ }
5721
+ PLAYER_SLOT_FIELDS.forEach((field, i) => {
5722
+ if (config[field] !== undefined)
5723
+ config[field] = snapPlayerValue(spec.slots[i]?.steps, config[field]);
5724
+ });
5725
+ if (seed !== undefined)
5726
+ config.playerSeed = seed === "random" ? randomPlayerSeed() : Math.round(seed);
5727
+ if (patternMode)
5728
+ config.playerPatternMode = PATTERN_MODES.indexOf(patternMode);
5729
+ if (listenMode)
5730
+ config.playerListenMode = LISTEN_MODES.indexOf(listenMode);
5731
+ if (listenSource !== undefined)
5732
+ config.playerListenSource = listenSource;
5733
+ if (listenAmount !== undefined)
5734
+ config.playerListenAmount = listenAmount;
5735
+ // Reversible poly arm: record the pre-arm values once, never the armed state.
5736
+ const autoPoly = arming && poly !== false && (isMelodicPlayer(spec) || roleConfig?.isPoly === 1);
5737
+ let polyNote = "";
5738
+ if (autoPoly) {
5739
+ if (recorded("playerPrePoly") === undefined && recorded("playerPreRoot") === undefined) {
5740
+ config.playerPrePoly = Math.round(Number(cell.isPoly ?? 0)) || 0;
5741
+ config.playerPreRoot = Math.round(Number(cell.polyRootNote ?? 48)) || 48;
5742
+ }
5743
+ config.isPoly = 1;
5744
+ config.polyRootNote = Math.round(Number(cell.midiNote ?? 47 + cellId)) || 47 + cellId;
5745
+ polyNote = `; poly armed (root ${config.polyRootNote})`;
5746
+ }
5747
+ else if (poly !== undefined) {
5748
+ config.isPoly = poly ? 1 : 0;
5749
+ polyNote = `; poly ${poly ? "on" : "off"}`;
5750
+ }
5751
+ if (arming)
5752
+ config.playerType = spec.id;
5753
+ if (Object.keys(config).length === 0)
5754
+ return error("Nothing to write — pass player, preset, or at least one value.");
5755
+ cellsDelta[String(cellId)] = config;
5756
+ for (const field of MODULATABLE_PLAYER_FIELDS) {
5757
+ if (typeof config[field] === "number")
5758
+ flatDelta[`cell${cellId}_${field}`] = config[field];
5759
+ }
5760
+ const parts = [];
5761
+ if (config.playerDensity !== undefined)
5762
+ parts.push(`density ${playerDensityDisplay(config.playerDensity)}`);
5763
+ if (config.playerMutate !== undefined)
5764
+ parts.push(`mutate ${playerMutateDisplay(config.playerMutate)}`);
5765
+ spec.slots.forEach((slot, i) => {
5766
+ const value = config[PLAYER_SLOT_FIELDS[i]];
5767
+ if (value !== undefined)
5768
+ parts.push(`${slot.label}=${playerSlotDisplay(slot, value)}`);
5769
+ });
5770
+ if (config.playerSeed !== undefined)
5771
+ parts.push(`seed ${config.playerSeed}`);
5772
+ if (patternMode)
5773
+ parts.push(`pattern ${patternMode}`);
5774
+ if (listenMode)
5775
+ parts.push(`listen ${listenMode}`);
5776
+ lines.push(`Cell ${cellId}: ${spec.name} ${arming ? "armed" : "updated"} — ${parts.join(", ")}${polyNote}.`);
5777
+ }
5778
+ if (Object.keys(cellsDelta).length === 0)
5779
+ return text(lines.join("\n"));
5780
+ await applyKv({ ...flatDelta, cells: cellsDelta }, KV_OPTS_CELL_CONFIG);
5781
+ return text(`${lines.join("\n")}\n` +
5782
+ "Players sound only while the transport runs; get_design_overview reports each cell's player.");
5783
+ });
5784
+ /** A cell's sample as a readable file: absolute as given, else relative to a library root. */
5785
+ async function resolveCellSampleFile(filePath) {
5786
+ const expanded = expandHome(filePath);
5787
+ if (isAbsolute(expanded))
5788
+ return existsSync(expanded) ? expanded : undefined;
5789
+ const locations = await bridge.send("getDefaultLocations").catch(() => undefined);
5790
+ const roots = Array.isArray(locations)
5791
+ ? locations.filter((entry) => isRecord(entry) && typeof entry.path === "string")
5792
+ : [];
5793
+ const normalized = filePath.replaceAll("\\", "/");
5794
+ const candidates = [];
5795
+ for (const root of roots) {
5796
+ const stripped = normalized.replace(/^Samples\/(User|Factory)\//, "");
5797
+ if (/user/i.test(root.name) && /^Samples\/User\//.test(normalized))
5798
+ candidates.push(join(root.path, stripped));
5799
+ if (/factory/i.test(root.name) && /^Samples\/Factory\//.test(normalized))
5800
+ candidates.push(join(root.path, stripped));
5801
+ candidates.push(join(root.path, normalized), join(root.path, stripped));
5802
+ }
5803
+ return candidates.find((candidate) => existsSync(candidate));
5804
+ }
5805
+ server.registerTool("set_slices", {
5806
+ title: "Set Sample Slices",
5807
+ description: "Slice the sample loaded in a cell: explicit `boundaries` (0..1 of the file), `count` equal slices, or " +
5808
+ "`transients` detected from the audio (WAV files, offline). Writes cell{N}_sliceBoundaries / _sliceCount — " +
5809
+ "what the slice lane, Slice Keymap, preview_slice and export_slice all read — and switches the Slice knob on. " +
5810
+ "`sequence:true` also chops the loop into the slice + note lanes so it plays back in order at native rate " +
5811
+ "(the Slice → Sequence gesture: modLatch on, both lanes at 1× over the loop's length in beats).",
5812
+ inputSchema: {
5813
+ cellId: z.number().int().min(1).max(8).describe("Cell number (1-8); it must have a sample loaded."),
5814
+ boundaries: z
5815
+ .array(z.number().min(0).max(1))
5816
+ .min(2)
5817
+ .max(MAX_SLICES + 1)
5818
+ .optional()
5819
+ .describe("Slice boundaries as 0..1 positions in the file, ascending; 0 and 1 are added when missing."),
5820
+ count: z.number().int().min(1).max(MAX_SLICES).optional().describe(`Equal slices, 1..${MAX_SLICES}.`),
5821
+ transients: z.boolean().optional().describe("Detect onsets in the sample and slice there (WAV only)."),
5822
+ sensitivity: z.number().min(0).max(1).optional().describe("Transient sensitivity 0..1 (default 0.5; higher finds more onsets)."),
5823
+ maxSlices: z.number().int().min(2).max(MAX_SLICES).optional().describe("Cap on transient slices (default 32)."),
5824
+ sequence: z.boolean().optional().describe("Also write the slice + note lanes so the slices play in order (loops)."),
5825
+ },
5826
+ }, async ({ cellId, boundaries, count, transients, sensitivity, maxSlices, sequence }) => {
5827
+ const check = await requireBridgeForCells(cellId);
5828
+ if (check !== true)
5829
+ return check;
5830
+ const manifest = await loadManifestForProductGuard();
5831
+ if (manifest && isMfxManifest(manifest))
5832
+ return error("set_slices needs a sampler cell; MFX cells carry no sample.");
5833
+ const modes = [boundaries !== undefined, count !== undefined, transients === true].filter(Boolean).length;
5834
+ if (modes !== 1)
5835
+ return error("Pass exactly one of boundaries, count, or transients:true.");
5836
+ const stateRaw = await bridge.send("getState");
5837
+ const state = isRecord(stateRaw) ? stateRaw : {};
5838
+ const cellsObj = isRecord(state.cells) ? state.cells : {};
5839
+ const cell = isRecord(cellsObj[String(cellId)]) ? cellsObj[String(cellId)] : {};
5840
+ const filePath = [cell.filePath, cell.samplePath, state[`cell${cellId}_filePath`]].find((v) => typeof v === "string" && v.length > 0);
5841
+ if (!filePath)
5842
+ return error(`Cell ${cellId} has no sample loaded — load_sample first.`);
5843
+ let resolved;
5844
+ let wavDuration;
5845
+ if (boundaries)
5846
+ resolved = normalizeBoundaries(boundaries);
5847
+ else if (count !== undefined)
5848
+ resolved = equalBoundaries(count);
5849
+ else {
5850
+ const file = await resolveCellSampleFile(filePath);
5851
+ if (!file)
5852
+ return error(`Could not find '${filePath}' on disk to analyse — pass count or boundaries instead.`);
5853
+ let wav;
5854
+ try {
5855
+ wav = decodeWav(await readFile(file));
5856
+ }
5857
+ catch (e) {
5858
+ return error(`Transient slicing reads WAV files only (${e.message}) — pass count or boundaries for ${basename(file)}.`);
5859
+ }
5860
+ wavDuration = wav.durationSeconds;
5861
+ resolved = detectOnsetBoundaries(wav, sensitivity ?? 0.5, maxSlices ?? 32);
5862
+ if (resolved.length < 3)
5863
+ return error("No transients found above the threshold — raise sensitivity or pass count.");
5864
+ }
5865
+ const sliceCount = resolved.length - 1;
5866
+ const currentKnob = Number(state[`cell${cellId}_slice`]) || 0;
5867
+ const delta = {
5868
+ [`cell${cellId}_sliceBoundaries`]: resolved,
5869
+ [`cell${cellId}_sliceCount`]: sliceCount,
5870
+ };
5871
+ if (currentKnob < 1 || currentKnob > sliceCount)
5872
+ delta[`cell${cellId}_slice`] = 1;
5873
+ await applyKv(delta, KV_OPTS_CELL_CONFIG);
5874
+ let sequenceNote = "";
5875
+ if (sequence) {
5876
+ const transportRaw = await bridge.send("getTransport").catch(() => undefined);
5877
+ const bpm = Number(isRecord(transportRaw) ? transportRaw.bpm : undefined) || Number(state.bpm) || 120;
5878
+ let duration = wavDuration;
5879
+ if (duration === undefined) {
5880
+ const info = await bridge.send("getAudioFileInfo", filePath).catch(() => undefined);
5881
+ const parsed = typeof info === "string" ? JSON.parse(info) : info;
5882
+ duration = [parsed?.duration, parsed?.durationSeconds, parsed?.lengthSeconds, parsed?.length]
5883
+ .map(Number)
5884
+ .find((v) => Number.isFinite(v) && v > 0);
5885
+ }
5886
+ const loopBeats = duration ? (duration * bpm) / 60 : Number(state[`cell${cellId}_patternLengthSlice`]) || 16;
5887
+ const patternLength = Math.max(1, Math.min(32, duration ? Math.ceil(loopBeats) : loopBeats));
5888
+ const note = typeof cell.midiNote === "number" ? cell.midiNote : 48 + cellId - 1;
5889
+ const channel = typeof cell.midiChannel === "number" ? cell.midiChannel : 1;
5890
+ const sliceEvents = [];
5891
+ const noteEvents = [];
5892
+ for (let i = 0; i < sliceCount; i++) {
5893
+ const time = resolved[i] * loopBeats;
5894
+ const dur = (resolved[i + 1] - resolved[i]) * loopBeats;
5895
+ if (dur <= 0)
5896
+ continue;
5897
+ sliceEvents.push({ note, channel, time, duration: dur, velocity: 100, value: i + 1 });
5898
+ noteEvents.push({ note, channel, time, duration: dur, velocity: 100 });
5899
+ }
5900
+ await applyKv({
5901
+ [`cell${cellId}_sliceSequence`]: sliceEvents,
5902
+ [`cell${cellId}_midiSequence`]: noteEvents,
5903
+ [`cell${cellId}_patternLengthSlice`]: patternLength,
5904
+ [`cell${cellId}_patternLength`]: patternLength,
5905
+ [`cell${cellId}_patternSpeedSlice`]: 9,
5906
+ [`cell${cellId}_patternSpeed`]: 9,
5907
+ [`cell${cellId}_patternDirection`]: 0,
5908
+ [`cell${cellId}_slice`]: 1,
5909
+ cells: { [cellId]: { modLatch: 1 } },
5910
+ __forceRender__: true,
5911
+ }, KV_OPTS_PATTERN);
5912
+ sequenceNote = ` Chopped into the slice + note lanes over ${patternLength} beats at ${bpm} BPM; start_transport to hear it.`;
5913
+ }
5914
+ const positions = resolved.slice(1, -1).map((b) => `${Math.round(b * 1000) / 10}%`);
5915
+ return text(`Cell ${cellId}: ${sliceCount} slice${sliceCount === 1 ? "" : "s"}` +
5916
+ `${positions.length ? ` (cuts at ${positions.join(", ")})` : ""}.${sequenceNote}`);
5917
+ });
4203
5918
  server.registerTool("add_param_seq", {
4204
5919
  title: "Add Parameter Sequencer",
4205
5920
  description: "Add a parameter sequencer (step modulation) targeting any modulatable parameter. " +
@@ -4216,9 +5931,17 @@ server.registerTool("add_param_seq", {
4216
5931
  bipolar: z.union([z.literal(0), z.literal(1)]).default(0).describe("0 = unipolar, 1 = bipolar."),
4217
5932
  length: z.number().min(0.0625).max(16).default(1).describe("Pattern length in bars."),
4218
5933
  speed: z.number().min(0.0625).max(16).default(1).describe("Pattern speed multiplier."),
5934
+ freeMode: z
5935
+ .union([z.literal(0), z.literal(1)])
5936
+ .default(0)
5937
+ .describe("0 = locked to the source cell's clock and its own steps; 1 = mapped to the Free Sequencer (its clock AND its master steps, set_free_sequencer)."),
5938
+ stepMode: z
5939
+ .union([z.literal(0), z.literal(1)])
5940
+ .default(0)
5941
+ .describe("0 = Clock; 1 = Gate: advance one step per trigger of the source cell (cell-locked only; MFX has no Gate)."),
4219
5942
  },
4220
- }, async ({ paramId, cellId, sequence, strength, bipolar, length, speed }) => {
4221
- const check = await requireBridge();
5943
+ }, async ({ paramId, cellId, sequence, strength, bipolar, length, speed, freeMode, stepMode }) => {
5944
+ const check = await requireBridgeForCells(cellId);
4222
5945
  if (check !== true)
4223
5946
  return check;
4224
5947
  const index = await getParameterIndex();
@@ -4240,7 +5963,8 @@ server.registerTool("add_param_seq", {
4240
5963
  bipolar,
4241
5964
  boost: 0,
4242
5965
  interpolate: 0,
4243
- freeMode: 0,
5966
+ freeMode,
5967
+ stepMode,
4244
5968
  };
4245
5969
  paramSeqs.push(newSeq);
4246
5970
  await applyKv({ paramSeqs }, KV_OPTS_MOD);
@@ -4253,11 +5977,19 @@ server.registerTool("update_param_seq", {
4253
5977
  inputSchema: {
4254
5978
  paramId: z.string().describe("Target parameter ID."),
4255
5979
  sequence: z.array(z.number().min(0).max(1)).optional().describe("New step values (0-1)."),
4256
- strength: z.number().min(0).max(1).optional(),
4257
- length: z.number().min(0.0625).max(16).optional(),
4258
- speed: z.number().min(0.0625).max(16).optional(),
4259
- bipolar: z.union([z.literal(0), z.literal(1)]).optional(),
4260
- enabled: z.union([z.literal(0), z.literal(1)]).optional(),
5980
+ strength: z.number().min(0).max(1).optional().describe("Modulation depth (0-1)."),
5981
+ length: z.number().min(0.0625).max(16).optional().describe("Pattern length in bars."),
5982
+ speed: z.number().min(0.0625).max(16).optional().describe("Pattern speed multiplier."),
5983
+ bipolar: z.union([z.literal(0), z.literal(1)]).optional().describe("0 = unipolar, 1 = bipolar."),
5984
+ enabled: z.union([z.literal(0), z.literal(1)]).optional().describe("0 = bypass the sequencer, 1 = run it."),
5985
+ freeMode: z
5986
+ .union([z.literal(0), z.literal(1)])
5987
+ .optional()
5988
+ .describe("0 = cell-locked with its own steps; 1 = mapped to the Free Sequencer master contour (set_free_sequencer)."),
5989
+ stepMode: z
5990
+ .union([z.literal(0), z.literal(1)])
5991
+ .optional()
5992
+ .describe("0 = Clock; 1 = Gate (one step per source-cell trigger; falls back to Clock when free or on MFX)."),
4261
5993
  },
4262
5994
  }, async ({ paramId, ...updates }) => {
4263
5995
  const check = await requireBridge();
@@ -4281,6 +6013,105 @@ server.registerTool("update_param_seq", {
4281
6013
  await applyKv({ paramSeqs }, KV_OPTS_MOD);
4282
6014
  return text(`Updated paramSeq ${paramId}: ${Object.keys(cleanUpdates).join(", ")}`);
4283
6015
  });
6016
+ server.registerTool("set_free_sequencer", {
6017
+ title: "Set Free Sequencer",
6018
+ description: "Write the Free Sequencer's master step contour (top-level `freeSeqSequence`, 2..64 steps of 0..1). Every " +
6019
+ "parameter sequencer mapped to it (freeMode:1 via add_param_seq / update_param_seq) follows these steps AND its " +
6020
+ "clock live, each with its own strength, polarity and smoothing; cell-locked and Gate-mode sequencers keep " +
6021
+ "their own steps. An empty contour restores the legacy per-sequencer behaviour.",
6022
+ inputSchema: {
6023
+ sequence: z.array(z.number().min(0).max(1)).min(2).max(64).describe("Master step values, 0..1 each."),
6024
+ },
6025
+ }, async ({ sequence }) => {
6026
+ const check = await requireBridge();
6027
+ if (check !== true)
6028
+ return check;
6029
+ await bridge.send("applyFreeSeqSequenceUpdate", { sequence });
6030
+ return text(`Free Sequencer: ${sequence.length} steps written. Map a paramSeq with freeMode:1 to follow it.`);
6031
+ });
6032
+ server.registerTool("export_midi", {
6033
+ title: "Export MIDI",
6034
+ description: "Write the cells' drawn note patterns to a Standard MIDI File (type 1, 480 PPQ, tempo from the transport): " +
6035
+ "one track per cell on MIDI channel = cell number, each pattern tiled across `bars`. Pure file write — no " +
6036
+ "render, nothing changes in PAM. Generated Player cycles are not drawn patterns; FREEZE them first (in the UI) " +
6037
+ "to export what a player is playing. Default location is PAM's Exports folder.",
6038
+ inputSchema: {
6039
+ filename: z.string().optional().describe("Base name without extension (default pam_midi_<epoch>)."),
6040
+ cellIds: z.array(z.number().int().min(1).max(8)).optional().describe("Cells to include (default: every cell with notes)."),
6041
+ bars: z.number().min(1).max(64).optional().describe("Length in 4/4 bars (default: the longest included pattern cycle, at least 1)."),
6042
+ mode: z.enum(["combined", "stems"]).optional().describe("'combined' (default): one multi-track file. 'stems': one file per cell."),
6043
+ outputDir: z.string().optional().describe("Directory to write into (default: the Exports location from get_default_locations)."),
6044
+ },
6045
+ }, async ({ filename, cellIds, bars, mode, outputDir }) => {
6046
+ const check = await requireBridgeForCells(cellIds);
6047
+ if (check !== true)
6048
+ return check;
6049
+ const manifest = await loadManifestForProductGuard();
6050
+ if (manifest && isMfxManifest(manifest))
6051
+ return error("export_midi needs sampler cells with note patterns; MFX has none.");
6052
+ const [stateRaw, transportRaw, locations] = await Promise.all([
6053
+ bridge.send("getState"),
6054
+ bridge.send("getTransport").catch(() => undefined),
6055
+ bridge.send("getDefaultLocations").catch(() => undefined),
6056
+ ]);
6057
+ const state = isRecord(stateRaw) ? stateRaw : {};
6058
+ const bpm = Number(isRecord(transportRaw) ? transportRaw.bpm : undefined) || Number(state.bpm) || 120;
6059
+ let dir = outputDir ? expandHome(outputDir) : undefined;
6060
+ if (!dir) {
6061
+ if (Array.isArray(locations)) {
6062
+ const exportsEntry = locations.find((entry) => isRecord(entry) && /export/i.test(String(entry.name)));
6063
+ if (isRecord(exportsEntry) && typeof exportsEntry.path === "string")
6064
+ dir = exportsEntry.path;
6065
+ }
6066
+ else if (isRecord(locations)) {
6067
+ dir = [locations.exports, locations.exportsLocation, locations.Exports].find((v) => typeof v === "string" && v.length > 0);
6068
+ }
6069
+ }
6070
+ if (!dir)
6071
+ return error("Could not resolve PAM's Exports folder from get_default_locations — pass outputDir.");
6072
+ const maxCell = manifestMaxCellId(manifest) ?? 8;
6073
+ const ids = (cellIds?.length ? cellIds : Array.from({ length: maxCell }, (_, i) => i + 1)).filter((id) => id <= maxCell);
6074
+ const cells = ids
6075
+ .map((id) => ({
6076
+ id,
6077
+ sequence: state[`cell${id}_midiSequence`],
6078
+ patternLength: state[`cell${id}_patternLength`],
6079
+ speed: state[`cell${id}_patternSpeed`],
6080
+ }))
6081
+ .filter((cell) => Array.isArray(cell.sequence) && cell.sequence.length > 0);
6082
+ if (cells.length === 0)
6083
+ return error("No note patterns to export — the selected cells carry no notes.");
6084
+ const cycleBars = cells.map((cell) => {
6085
+ const length = Number(cell.patternLength);
6086
+ const speedIndex = Math.round(Number(cell.speed));
6087
+ const mul = Number.isFinite(speedIndex) ? patternSpeedMultiplier(speedIndex) : 1;
6088
+ return (Number.isFinite(length) && length > 0 ? length : 4) / mul / 4;
6089
+ });
6090
+ const totalBars = bars ?? Math.max(1, Math.ceil(Math.max(...cycleBars)));
6091
+ const totalBeats = totalBars * 4;
6092
+ const tracks = cells.map((cell) => ({
6093
+ name: `Cell ${cell.id}`,
6094
+ events: tilePatternEvents(cell.sequence, cell.patternLength, cell.speed, totalBeats, Math.min(16, cell.id)),
6095
+ }));
6096
+ const base = (filename?.trim() || `pam_midi_${Date.now()}`).replace(/\.mid$/i, "").replace(/[\\/:]/g, "_");
6097
+ await mkdir(dir, { recursive: true });
6098
+ const written = [];
6099
+ if (mode === "stems") {
6100
+ for (const track of tracks) {
6101
+ const path = join(dir, `${base}_cell${track.name.slice(5)}.mid`);
6102
+ await writeFile(path, buildMidiFile(bpm, [track], track.name));
6103
+ written.push(path);
6104
+ }
6105
+ }
6106
+ else {
6107
+ const path = join(dir, `${base}.mid`);
6108
+ await writeFile(path, buildMidiFile(bpm, tracks, base));
6109
+ written.push(path);
6110
+ }
6111
+ const noteCount = tracks.reduce((sum, track) => sum + track.events.length, 0);
6112
+ return text(`Exported ${noteCount} notes from ${tracks.length} cell${tracks.length === 1 ? "" : "s"} over ${totalBars} bar${totalBars === 1 ? "" : "s"} at ${bpm} BPM.\n` +
6113
+ written.map((path) => `File: ${path}`).join("\n"));
6114
+ });
4284
6115
  server.registerTool("remove_param_seq", {
4285
6116
  title: "Remove Parameter Sequencer",
4286
6117
  description: "Remove a parameter sequencer for the given paramId.",
@@ -4304,7 +6135,7 @@ server.registerTool("normalize_sample", {
4304
6135
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
4305
6136
  },
4306
6137
  }, async ({ cellId }) => {
4307
- const check = await requireBridge();
6138
+ const check = await requireBridgeForCells(cellId);
4308
6139
  if (check !== true)
4309
6140
  return check;
4310
6141
  await bridge.send("normalizeSampleForCell", cellId);
@@ -4323,7 +6154,7 @@ server.registerTool("gain_sample", {
4323
6154
  .describe("Gain in decibels (e.g. 3 = +3 dB louder, -6 = 6 dB quieter)."),
4324
6155
  },
4325
6156
  }, async ({ cellId, gainDb }) => {
4326
- const check = await requireBridge();
6157
+ const check = await requireBridgeForCells(cellId);
4327
6158
  if (check !== true)
4328
6159
  return check;
4329
6160
  await bridge.send("gainSampleForCell", { cellId, gainDb });
@@ -4369,7 +6200,9 @@ server.registerTool("export_audio", {
4369
6200
  "'per-bus' (per output bus) · 'song-mode' (the Song Mode timeline).\n\n" +
4370
6201
  "NOTE on 'song-mode': the offline bounce applies EVERY section on its anchor (PAM ≥ 1.5.7 with " +
4371
6202
  "offline song export; older builds render only the first section). Wall-clock sources " +
4372
- "(Magenta/Notochord) render silent offline — use the `export_song` tool's live strategy for those.",
6203
+ "(Magenta/Notochord) render silent offline — use the `export_song` tool's live strategy for those. " +
6204
+ "It issues the same render as `export_song` with strategy 'offline'; prefer `export_song` for arrangements, " +
6205
+ "which is the only route to the live and audio+video strategies.",
4373
6206
  inputSchema: {
4374
6207
  bars: z.number().min(1).max(128).default(4).describe("Bars to render (ignored for 'song-mode', which derives length from the timeline)."),
4375
6208
  cellId: z
@@ -4387,12 +6220,12 @@ server.registerTool("export_audio", {
4387
6220
  .optional()
4388
6221
  .describe("For mode 'selected-cells': cells to bounce as stems."),
4389
6222
  bitDepth: z
4390
- .number()
6223
+ .union([z.literal(16), z.literal(24)])
4391
6224
  .default(24)
4392
- .describe("Bit depth (16 or 24)."),
6225
+ .describe("Bit depth: 16 or 24."),
4393
6226
  },
4394
6227
  }, async ({ bars, cellId, mode, selectedCellIds, bitDepth }) => {
4395
- const check = await requireBridge();
6228
+ const check = await requireBridgeForCells(cellId, selectedCellIds);
4396
6229
  if (check !== true)
4397
6230
  return check;
4398
6231
  // Resolve the effective mode. A bare cellId means "individual".
@@ -4430,7 +6263,7 @@ server.registerTool("export_audio", {
4430
6263
  const bpm = typeof transport.bpm === "number" && transport.bpm > 0 ? transport.bpm : 120;
4431
6264
  // 4/4 assumption for the placeholder duration; 'song-mode' recomputes its own length from the timeline.
4432
6265
  const durationInSeconds = (bars * 4 * 60) / bpm;
4433
- const filename = `pam_export_${effectiveMode.replace(/[^a-z0-9]/gi, "_")}_${Date.now()}`;
6266
+ const filename = `${SERVER_PRODUCT.id}_export_${effectiveMode.replace(/[^a-z0-9]/gi, "_")}_${Date.now()}`;
4434
6267
  const options = {
4435
6268
  filename,
4436
6269
  mode: effectiveMode,
@@ -4445,12 +6278,8 @@ server.registerTool("export_audio", {
4445
6278
  const outcome = await pollExportStatus();
4446
6279
  if (outcome === "success")
4447
6280
  return text(`Export complete (mode '${effectiveMode}').\n` +
4448
- `File(s) written to PAM's Exports folder, base name: ${filename}.wav` +
4449
- loudnessReportLine() +
4450
- (effectiveMode === "song-mode"
4451
- ? "\n\nReminder: the offline song-mode bounce captures only the first section's state. " +
4452
- "Use export_song for a full track with section changes."
4453
- : ""));
6281
+ `File(s) written to ${SERVER_PRODUCT.name}'s Exports folder, base name: ${filename}.wav` +
6282
+ loudnessReportLine());
4454
6283
  if (outcome === "failed")
4455
6284
  return error(`Export failed (mode '${effectiveMode}').`);
4456
6285
  return text(`Export timed out (mode '${effectiveMode}'). The render may still be running.`);
@@ -4458,8 +6287,8 @@ server.registerTool("export_audio", {
4458
6287
  // ============================================================
4459
6288
  // RESOURCES
4460
6289
  // ============================================================
4461
- server.registerResource("parameter-manifest", "pam://parameters", {
4462
- title: "PAM Parameter Manifest",
6290
+ server.registerResource("parameter-manifest", `${SERVER_PRODUCT.resourceScheme}://parameters`, {
6291
+ title: `${SERVER_PRODUCT.name} Parameter Manifest`,
4463
6292
  description: "Complete parameter definitions with ranges, defaults, and metadata.",
4464
6293
  mimeType: "application/json",
4465
6294
  }, async (uri) => {
@@ -4473,8 +6302,8 @@ server.registerResource("parameter-manifest", "pam://parameters", {
4473
6302
  ],
4474
6303
  };
4475
6304
  });
4476
- server.registerResource("parameter-summary", "pam://parameters/summary", {
4477
- title: "PAM Parameter Summary",
6305
+ server.registerResource("parameter-summary", `${SERVER_PRODUCT.resourceScheme}://parameters/summary`, {
6306
+ title: `${SERVER_PRODUCT.name} Parameter Summary`,
4478
6307
  description: "Human-readable parameter list with ranges and descriptions.",
4479
6308
  mimeType: "text/plain",
4480
6309
  }, async (uri) => {
@@ -4484,10 +6313,10 @@ server.registerResource("parameter-summary", "pam://parameters/summary", {
4484
6313
  contents: [{ uri: uri.href, text: lines.join("\n") }],
4485
6314
  };
4486
6315
  });
4487
- server.registerResource("cell-parameters", new ResourceTemplate("pam://cell/{cellId}/parameters", {
6316
+ server.registerResource("cell-parameters", new ResourceTemplate(`${SERVER_PRODUCT.resourceScheme}://cell/{cellId}/parameters`, {
4488
6317
  list: async () => ({
4489
- resources: Array.from({ length: 8 }, (_, i) => ({
4490
- uri: `pam://cell/cell${i + 1}/parameters`,
6318
+ resources: Array.from({ length: SERVER_PRODUCT.cellCount }, (_, i) => ({
6319
+ uri: `${SERVER_PRODUCT.resourceScheme}://cell/cell${i + 1}/parameters`,
4491
6320
  name: `Cell ${i + 1} Parameters`,
4492
6321
  })),
4493
6322
  }),
@@ -4502,7 +6331,7 @@ server.registerResource("cell-parameters", new ResourceTemplate("pam://cell/{cel
4502
6331
  contents: [{ uri: uri.href, text: JSON.stringify(params, null, 2) }],
4503
6332
  };
4504
6333
  });
4505
- server.registerResource("global-parameters", "pam://parameters/global", {
6334
+ server.registerResource("global-parameters", `${SERVER_PRODUCT.resourceScheme}://parameters/global`, {
4506
6335
  title: "Global Parameters",
4507
6336
  description: "Non-cell-specific parameters (volume, crossfade, looper, etc.).",
4508
6337
  mimeType: "application/json",
@@ -4512,16 +6341,38 @@ server.registerResource("global-parameters", "pam://parameters/global", {
4512
6341
  contents: [{ uri: uri.href, text: JSON.stringify(params, null, 2) }],
4513
6342
  };
4514
6343
  });
4515
- server.registerResource("beat-making-guide", "pam://guide/beat-making", {
4516
- title: "PAM Beat-Making Guide",
4517
- description: "Comprehensive guide for creating beats with PAM. Covers cell roles, slicer for loops, " +
4518
- "sidechain pumping, poly/arp patterns, macros, variations, gain staging, and master effects.",
4519
- mimeType: "text/markdown",
4520
- }, async () => ({
4521
- contents: [
4522
- {
4523
- uri: "pam://guide/beat-making",
4524
- text: `# PAM Beat-Making Guide
6344
+ if (IS_MFX_SERVER) {
6345
+ server.registerResource("mfx-routing-guide", "mfx://guide/routing", {
6346
+ title: "MFX Routing Guide",
6347
+ description: "A concise workflow for cabling, ordering, shaping, and modulating an MFX patch.",
6348
+ mimeType: "text/markdown",
6349
+ }, async () => ({
6350
+ contents: [{
6351
+ uri: "mfx://guide/routing",
6352
+ text: `# MFX Routing Guide
6353
+
6354
+ MFX has four effects cells. Audio reaches a cell only when the cable patch routes a source into it.
6355
+
6356
+ 1. Call \`set_mfx_routing\` with \`op: "get"\` before changing the patch.
6357
+ 2. Use \`design_context\` to inspect the available effects, parameters, and default effect order.
6358
+ 3. Replace the complete cable list with \`set_mfx_routing\` and \`op: "set"\`. A serial patch is input → cell1 → cell2 → cell3 → cell4 → output.
6359
+ 4. Use \`set_effect_order\`, \`update_effect_config\`, and \`set_parameters\` to shape each cell.
6360
+ 5. Add movement with the modulation tools, audition the result, and save it with \`save_preset\`.
6361
+ `,
6362
+ }],
6363
+ }));
6364
+ }
6365
+ else {
6366
+ server.registerResource("beat-making-guide", "pam://guide/beat-making", {
6367
+ title: "PAM Beat-Making Guide",
6368
+ description: "Comprehensive guide for creating beats with PAM. Covers cell roles, slicer for loops, " +
6369
+ "sidechain pumping, poly/arp patterns, macros, variations, gain staging, and master effects.",
6370
+ mimeType: "text/markdown",
6371
+ }, async () => ({
6372
+ contents: [
6373
+ {
6374
+ uri: "pam://guide/beat-making",
6375
+ text: `# PAM Beat-Making Guide
4525
6376
 
4526
6377
  ## Cell Architecture — 8 Cells, Each a Full Instrument
4527
6378
 
@@ -4861,9 +6712,10 @@ 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,
4861
6712
  ### Pattern Direction
4862
6713
  \`cell{N}_patternDirection\`: 0=Forward, 1=Backward, 2=Ping-Pong, 3=Random Cycle (shuffle each loop), 4=Random Step (each step independent)
4863
6714
  `,
4864
- },
4865
- ],
4866
- }));
6715
+ },
6716
+ ],
6717
+ }));
6718
+ }
4867
6719
  // ============================================================
4868
6720
  // PROMPTS — guided workflows (client-invokable, e.g. slash
4869
6721
  // commands in Claude Code). Each stages the same flows the
@@ -4872,7 +6724,7 @@ 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,
4872
6724
  // Mirror of RANDOMIZER_GENRES (shared/randomizer/types.ts) — the published
4873
6725
  // package can't import repo-relative shared modules, so the copy is pinned by
4874
6726
  // interface/test/randomizerGenre.test.ts (source-text parity check).
4875
- const GENRE_SUGGESTIONS = ["boom_bap", "trap", "drill", "house", "techno", "dnb"];
6727
+ const GENRE_SUGGESTIONS = ["boom_bap", "trap", "drill", "house", "techno", "dnb", "idm"];
4876
6728
  const completeGenre = (value) => GENRE_SUGGESTIONS.filter((g) => g.startsWith((value ?? "").toLowerCase()));
4877
6729
  // completable() tags the schema OBJECT with a symbol, so ordering is delicate:
4878
6730
  // .describe()/.optional() clone schemas and drop the tag, SDK 1.29 unwraps
@@ -4880,7 +6732,7 @@ const completeGenre = (value) => GENRE_SUGGESTIONS.filter((g) => g.startsWith((v
4880
6732
  // completer at request time — the tag must sit on both the inner type and the
4881
6733
  // outer optional. Covered by the completion assertion in check-tool-surface's
4882
6734
  // sibling smoke; revisit when the SDK unifies the two lookups.
4883
- const genreArg = completable(completable(z.string().describe("Genre hint, e.g. techno, house, dnb, trap, boom_bap, drill."), completeGenre).optional(), completeGenre);
6735
+ const genreArg = completable(completable(z.string().describe("Genre hint, e.g. techno, house, dnb, trap, boom_bap, drill, idm."), completeGenre).optional(), completeGenre);
4884
6736
  function promptText(text) {
4885
6737
  return {
4886
6738
  messages: [
@@ -4888,65 +6740,93 @@ function promptText(text) {
4888
6740
  ],
4889
6741
  };
4890
6742
  }
4891
- server.registerPrompt("build-a-beat", {
4892
- title: "Build a Beat",
4893
- description: "Guided kit-building + pattern workflow: coherent samples, genre patterns, transport, refinement, save.",
4894
- argsSchema: {
4895
- genre: genreArg,
4896
- bpm: z.string().optional().describe("Target tempo in BPM."),
4897
- },
4898
- }, ({ 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` +
4899
- `1. connection_status to confirm the bridge, then get_design_overview for the current state.\n` +
4900
- `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` +
4901
- `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` +
4902
- `4. start_transport — sequencer output needs the transport rolling.\n` +
4903
- `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` +
4904
- `6. save_preset once it works, and update_preset_metadata with genre tags and BPM.`));
4905
- server.registerPrompt("make-a-song", {
4906
- title: "Make a Song",
4907
- description: "Arrange the current preset into sections with variations + Song Mode, then bounce a full track.",
4908
- argsSchema: {
4909
- bars: z.string().optional().describe("Approximate song length in bars."),
4910
- },
4911
- }, ({ bars }) => promptText(`Turn the current PAM preset into an arranged song${bars ? ` of roughly ${bars} bars` : ""}:\n\n` +
4912
- `1. Design 2-4 contrasting sections (intro / groove / peak / outro): shape the state for each, then save_variation into slots 0-7.\n` +
4913
- `2. Arrange with song_mode op 'setArrangement' — an ordered sections list ({variationIndex, bars, repeat}, minimum 2 sections). Read it back with op 'get'.\n` +
4914
- `3. Audition: start_transport with Song Mode driving the timeline; queue_variation switches sections live.\n` +
4915
- `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` +
4916
- `5. The rendered file lands in PAM's Exports/Resamples locations — get_default_locations resolves them.`));
4917
- server.registerPrompt("design-a-preset", {
4918
- title: "Design a Preset",
4919
- description: "Sound-design workflow from a creative brief to a saved preset.",
4920
- argsSchema: {
4921
- brief: z.string().describe("Creative brief, e.g. 'dusty lo-fi kit with a wobbly sub'."),
4922
- },
4923
- }, ({ brief }) => promptText(`Design a PAM preset from this brief: "${brief}"\n\n` +
4924
- `1. Read design_context (curated parameter cheat sheet) and get_design_overview first — avoid full get_state dumps.\n` +
4925
- `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` +
4926
- `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` +
4927
- `4. Add movement: add_modulation (LFO / envelope / vary), add_param_seq for stepped motion.\n` +
4928
- `5. Write patterns with update_pattern, then start_transport to audition.\n` +
4929
- `6. save_preset with a fitting name and update_preset_metadata (category, tags, BPM).`));
4930
- server.registerPrompt("mix-and-master", {
4931
- title: "Mix and Master",
4932
- description: "Balance the cells and shape the master bus using the analysis + suggestion tools.",
4933
- argsSchema: {},
4934
- }, () => promptText(`Balance and master the current PAM project:\n\n` +
4935
- `1. get_mix_analysis with refresh:true (offline per-cell stem measurements — takes a few seconds) and get_master_analysis (live loudness snapshot).\n` +
4936
- `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` +
4937
- `3. Shape the master bus with set_master_effects (compressor, tape, limiter — design_context lists the keys).\n` +
4938
- `4. Re-check get_master_analysis and iterate toward the loudness target.\n` +
4939
- `5. Render with export_audio (mode 'main'; 'all-cells' for stems).`));
4940
- server.registerPrompt("troubleshoot-bridge", {
4941
- title: "Troubleshoot the PAM Bridge",
4942
- description: "Diagnose why live tools can't reach the plugin.",
4943
- argsSchema: {},
4944
- }, () => promptText(`Diagnose the PAM MCP connection:\n\n` +
4945
- `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` +
4946
- `2. Instances found but not connected → the plugin may have shut down, or its message thread is busy (heavy render); retry once.\n` +
4947
- `3. Timeouts on WebView-routed tools (video tools, capture_ui, set_drum_synth_wave, randomize target 'synth') → the plugin window must be OPEN.\n` +
4948
- `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` +
4949
- `5. Offline tools always work without a plugin: get_parameter_info, design_context, list_presets, read_preset, get_manifest_summary.`));
6743
+ if (IS_MFX_SERVER) {
6744
+ server.registerPrompt("design-a-preset", {
6745
+ title: "Design an MFX Preset",
6746
+ description: "Turn a creative effects brief into a routed, modulated, saved MFX preset.",
6747
+ argsSchema: {
6748
+ brief: z.string().describe("Creative brief, e.g. 'wide rhythmic distortion with a filtered parallel tail'."),
6749
+ },
6750
+ }, ({ brief }) => promptText(`Design an MFX preset from this brief: "${brief}"\n\n` +
6751
+ `1. Call connection_status, design_context, and get_design_overview.\n` +
6752
+ `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` +
6753
+ `3. Shape cells 1-4 with set_effect_order, update_effect_config, and set_parameters.\n` +
6754
+ `4. Add movement with add_modulation or add_param_seq, then audition and refine.\n` +
6755
+ `5. Save the result with save_preset and useful metadata.`));
6756
+ server.registerPrompt("troubleshoot-bridge", {
6757
+ title: "Troubleshoot the MFX Bridge",
6758
+ description: "Diagnose why live tools cannot reach MFX.",
6759
+ argsSchema: {},
6760
+ }, () => promptText(`Diagnose the MFX MCP connection:\n\n` +
6761
+ `1. Run connection_status. If no instance appears, start MFX and enable Settings → Experimental → MCP Bridge.\n` +
6762
+ `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` +
6763
+ `3. If an instance appears but cannot connect, it may have closed or be busy with a render; retry once.\n` +
6764
+ `4. If a live tool is unavailable, update the MFX plugin as well as the mfx-mcp package.`));
6765
+ }
6766
+ if (!IS_MFX_SERVER)
6767
+ server.registerPrompt("build-a-beat", {
6768
+ title: "Build a Beat",
6769
+ description: "Guided kit-building + pattern workflow: coherent samples, genre patterns, transport, refinement, save.",
6770
+ argsSchema: {
6771
+ genre: genreArg,
6772
+ bpm: z.string().optional().describe("Target tempo in BPM."),
6773
+ },
6774
+ }, ({ 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` +
6775
+ `1. connection_status to confirm the bridge, then get_design_overview for the current state.\n` +
6776
+ `2. ${bpm ? `set_bpm to ${bpm}` : "set_bpm to the genre's tempo"}.\n` +
6777
+ `3. Build the kit and groove with randomize target:'full'${genre ? ` and genreHint '${genre}'` : " (pass a genreHint)"}; native chains samples before patterns. The reply is queued: poll get_state for changed cells and cellN_midiSequence before editing/saving. For specific sounds: get_default_locations → list_samples → load_sample, verify paths, then randomize_patterns and verify sequences.\n` +
6778
+ `4. start_transport — sequencer output needs the transport rolling.\n` +
6779
+ `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. For a groove that evolves, set_player (player 'role' across the kit, or KIT / EUCLID / RIFF per cell — list_players first${genre ? `, genre '${genre}'` : ""}).\n` +
6780
+ `6. save_preset once it works, and update_preset_metadata with genre tags and BPM.`));
6781
+ if (!IS_MFX_SERVER)
6782
+ server.registerPrompt("make-a-song", {
6783
+ title: "Make a Song",
6784
+ description: "Arrange the current preset into sections with variations + Song Mode, then bounce a full track.",
6785
+ argsSchema: {
6786
+ bars: z.string().optional().describe("Approximate song length in bars."),
6787
+ },
6788
+ }, ({ bars }) => promptText(`Turn the current PAM preset into an arranged song${bars ? ` of roughly ${bars} bars` : ""}:\n\n` +
6789
+ `1. Design 2-4 contrasting sections (intro / groove / peak / outro): shape the state for each, then save_variation into slots 0-7.\n` +
6790
+ `2. Arrange with song_mode op 'setArrangement' — an ordered sections list ({variationIndex, bars, repeat}, minimum 2 sections). Read it back with op 'get'.\n` +
6791
+ `3. Audition: start_transport with Song Mode driving the timeline; queue_variation switches sections live.\n` +
6792
+ `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` +
6793
+ `5. The rendered file lands in PAM's Exports/Resamples locations — get_default_locations resolves them.`));
6794
+ if (!IS_MFX_SERVER)
6795
+ server.registerPrompt("design-a-preset", {
6796
+ title: "Design a Preset",
6797
+ description: "Sound-design workflow from a creative brief to a saved preset.",
6798
+ argsSchema: {
6799
+ brief: z.string().describe("Creative brief, e.g. 'dusty lo-fi kit with a wobbly sub'."),
6800
+ },
6801
+ }, ({ brief }) => promptText(`Design a PAM preset from this brief: "${brief}"\n\n` +
6802
+ `1. Read design_context (curated parameter cheat sheet) and get_design_overview first — avoid full get_state dumps.\n` +
6803
+ `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` +
6804
+ `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` +
6805
+ `4. Add movement: add_modulation (LFO / envelope / vary), add_param_seq for stepped motion.\n` +
6806
+ `5. Write patterns with update_pattern, then start_transport to audition.\n` +
6807
+ `6. save_preset with a fitting name and update_preset_metadata (category, tags, BPM).`));
6808
+ if (!IS_MFX_SERVER)
6809
+ server.registerPrompt("mix-and-master", {
6810
+ title: "Mix and Master",
6811
+ description: "Balance the cells and shape the master bus using the analysis + suggestion tools.",
6812
+ argsSchema: {},
6813
+ }, () => promptText(`Balance and master the current PAM project:\n\n` +
6814
+ `1. get_mix_analysis with refresh:true (offline per-cell stem measurements — takes a few seconds) and get_master_analysis (live loudness snapshot).\n` +
6815
+ `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` +
6816
+ `3. Shape the master bus with set_master_effects (compressor, tape, limiter — design_context lists the keys).\n` +
6817
+ `4. Re-check get_master_analysis and iterate toward the loudness target.\n` +
6818
+ `5. Render with export_audio (mode 'main'; 'all-cells' for stems).`));
6819
+ if (!IS_MFX_SERVER)
6820
+ server.registerPrompt("troubleshoot-bridge", {
6821
+ title: "Troubleshoot the PAM Bridge",
6822
+ description: "Diagnose why live tools can't reach the plugin.",
6823
+ argsSchema: {},
6824
+ }, () => promptText(`Diagnose the PAM MCP connection:\n\n` +
6825
+ `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` +
6826
+ `2. Instances found but not connected → the plugin may have shut down, or its message thread is busy (heavy render); retry once.\n` +
6827
+ `3. Timeouts on WebView-routed tools (video tools, capture_ui, set_drum_synth_wave, randomize target 'synth') → the plugin window must be OPEN.\n` +
6828
+ `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` +
6829
+ `5. Offline tools always work without a plugin: get_parameter_info, design_context, list_presets, read_preset, get_manifest_summary.`));
4950
6830
  // ============================================================
4951
6831
  // TOOLS — Looper (state deltas)
4952
6832
  // ============================================================
@@ -4969,13 +6849,13 @@ async function applyLoopersDelta(loopers, extra) {
4969
6849
  server.registerTool("arm_looper_track", {
4970
6850
  title: "Arm / Disarm Looper Track",
4971
6851
  description: "Arm or disarm a looper track for recording. Arming alone does not start " +
4972
- "recording — call start_looper_recording afterwards (or use arm_and_record).",
6852
+ "recording — call start_looper_recording afterwards, or pass armedCellIds to start_looper_recording to arm and record in one call.",
4973
6853
  inputSchema: {
4974
6854
  cellId: z.number().min(1).max(8).describe("Looper track / cell (1-8)."),
4975
6855
  armed: z.boolean().describe("true to arm, false to disarm."),
4976
6856
  },
4977
6857
  }, async ({ cellId, armed }) => {
4978
- const check = await requireBridge();
6858
+ const check = await requireBridgeForCells(cellId);
4979
6859
  if (check !== true)
4980
6860
  return check;
4981
6861
  await applyLoopersDelta({ [cellId]: { armed: armed ? 1 : 0 } });
@@ -4983,8 +6863,8 @@ server.registerTool("arm_looper_track", {
4983
6863
  });
4984
6864
  server.registerTool("set_looper_track", {
4985
6865
  title: "Set Looper Track State",
4986
- description: "Update one or more per-track looper fields (mute, overdub mode, reverse, " +
4987
- "pitch, warp, capture source, level). Pass only the fields you want to change.",
6866
+ description: "Update one or more per-track looper fields (mute, solo, overdub mode, reverse, " +
6867
+ "pitch, warp, capture source, level, auto-select and recorded BPM). Pass only the fields you want to change.",
4988
6868
  inputSchema: {
4989
6869
  cellId: z.number().min(1).max(8).describe("Looper track / cell (1-8)."),
4990
6870
  mute: z.boolean().optional().describe("Mute / unmute."),
@@ -5075,7 +6955,7 @@ server.registerTool("clear_looper_track", {
5075
6955
  cellId: z.number().min(1).max(8).describe("Looper track / cell (1-8)."),
5076
6956
  },
5077
6957
  }, async ({ cellId }) => {
5078
- const check = await requireBridge();
6958
+ const check = await requireBridgeForCells(cellId);
5079
6959
  if (check !== true)
5080
6960
  return check;
5081
6961
  await applyLoopersDelta({
@@ -5103,7 +6983,7 @@ server.registerTool("set_looper_track_selection", {
5103
6983
  }, async ({ cellId, start, end }) => {
5104
6984
  if (end <= start)
5105
6985
  return error("end must be > start");
5106
- const check = await requireBridge();
6986
+ const check = await requireBridgeForCells(cellId);
5107
6987
  if (check !== true)
5108
6988
  return check;
5109
6989
  await applyLoopersDelta({
@@ -5137,7 +7017,7 @@ server.registerTool("start_looper_recording", {
5137
7017
  .describe("Per-track metadata. Defaults: replace mode, cell-output source. Pass existingClipPaths to enable overdub on a prior layer."),
5138
7018
  },
5139
7019
  }, async ({ loopBars, armedCellIds, trackMetadata }) => {
5140
- const check = await requireBridge();
7020
+ const check = await requireBridgeForCells(armedCellIds);
5141
7021
  if (check !== true)
5142
7022
  return check;
5143
7023
  // Arm in UI state too — the plugin's live recording overlay and progress
@@ -5211,7 +7091,7 @@ server.registerTool("request_looper_warp", {
5211
7091
  cellId: z.number().min(1).max(8).describe("Looper track (1-8)."),
5212
7092
  },
5213
7093
  }, async ({ cellId }) => {
5214
- const check = await requireBridge();
7094
+ const check = await requireBridgeForCells(cellId);
5215
7095
  if (check !== true)
5216
7096
  return check;
5217
7097
  await bridge.send("requestLooperWarp", cellId);
@@ -5287,7 +7167,7 @@ server.registerTool("looper_variation", {
5287
7167
  data: z
5288
7168
  .record(z.string(), z.unknown())
5289
7169
  .optional()
5290
- .describe("For 'save': the looper variation snapshot (object). Pass null to clear instead."),
7170
+ .describe("For 'save': the looper variation snapshot (object). Omit this field to clear the slot instead."),
5291
7171
  },
5292
7172
  }, async (args) => {
5293
7173
  const check = await requireBridge();
@@ -5365,21 +7245,21 @@ server.registerTool("queue_variation", {
5365
7245
  inputSchema: {
5366
7246
  index: z.number().min(0).max(7).describe("Variation slot 0-7."),
5367
7247
  intervalBars: z.number().min(0.125).max(64).describe("Bars before switching."),
5368
- fadeMs: z.number().min(0).max(2000).default(20).describe("Crossfade duration in ms."),
7248
+ fadeMs: z.number().min(0).max(2000).optional().describe("Crossfade duration in ms. Omit all transition options to inherit the native Bar Sync/Song Mode settings."),
5369
7249
  resetPhase: z
5370
7250
  .boolean()
5371
- .default(true)
5372
- .describe("Reset pattern phase on switch."),
7251
+ .optional()
7252
+ .describe("Reset pattern phase on switch. Omit to inherit native settings."),
5373
7253
  morphBars: z
5374
7254
  .number()
5375
7255
  .min(0.25)
5376
7256
  .max(8)
5377
7257
  .optional()
5378
- .describe("Pre-anchor morph window in bars for this flip (PAM >= 1.19.3). Omit = hard switch."),
7258
+ .describe("Pre-anchor morph window in bars for this flip. Omit all transition options to inherit native settings."),
5379
7259
  morphMode: z
5380
7260
  .enum(["auto", "param", "mix", "seq", "all"])
5381
7261
  .optional()
5382
- .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)."),
7262
+ .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."),
5383
7263
  morphCurve: z
5384
7264
  .enum(["linear", "ease", "exp", "log"])
5385
7265
  .optional()
@@ -5392,7 +7272,7 @@ server.registerTool("queue_variation", {
5392
7272
  tailMaxBars: z.number().min(0.25).max(8).optional(),
5393
7273
  })
5394
7274
  .optional()
5395
- .describe("Advanced transition settings for this flip (PAM >= 1.20.0): pattern policy for a " +
7275
+ .describe("Advanced transition settings for this flip: pattern policy for a " +
5396
7276
  "seq/all morph, blend gain law for a mix/all morph, and the outgoing section's " +
5397
7277
  "tail. Omitted fields keep the defaults (dissolve, equalPower, fade)."),
5398
7278
  },
@@ -5403,8 +7283,8 @@ server.registerTool("queue_variation", {
5403
7283
  await bridge.send("queueVariationOnBars", {
5404
7284
  index,
5405
7285
  intervalBars,
5406
- fadeMs,
5407
- resetPhase,
7286
+ ...(fadeMs !== undefined && { fadeMs }),
7287
+ ...(resetPhase !== undefined && { resetPhase }),
5408
7288
  ...(morphBars !== undefined && { morphBars }),
5409
7289
  ...(morphMode !== undefined && { morphMode }),
5410
7290
  ...(morphCurve !== undefined && { morphCurve }),
@@ -5445,7 +7325,7 @@ server.registerTool("update_variations", {
5445
7325
  server.registerTool("variation_hold", {
5446
7326
  title: "Variation Hold",
5447
7327
  description: "'Hold while note pressed' mode for variations. Use this to map MIDI notes to momentary " +
5448
- "variation switches that revert when the note releases.",
7328
+ "variation switches. Hold mode reverts when the note releases; latch mode stays.",
5449
7329
  inputSchema: {
5450
7330
  enabled: z.boolean().optional().describe("Toggle hold mode on/off."),
5451
7331
  allNotesOff: z
@@ -5567,7 +7447,6 @@ server.registerTool("update_modulation", {
5567
7447
  title: "Update Modulation",
5568
7448
  description: "Apply a delta to a modulation source: LFO, Envelope, Vary, Macro, ParamSeq config, ParamSeq sequence data, " +
5569
7449
  "or the Free Sequencer master pattern. " +
5570
- "The payload is type-specific; see modulation update flows in interop.ts (applyLfoUpdate / updateEnvelope / etc.). " +
5571
7450
  "Use add_modulation to first attach a source to a target — then this tool to edit it.",
5572
7451
  inputSchema: {
5573
7452
  type: z
@@ -5575,7 +7454,11 @@ server.registerTool("update_modulation", {
5575
7454
  .describe("Which modulation system to update."),
5576
7455
  payload: z
5577
7456
  .record(z.string(), z.unknown())
5578
- .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."),
7457
+ .describe("Update payload object, one property per call. lfo, envelope, vary, macro and paramSeq all take " +
7458
+ "{ index, property, value }, where index is the 0-based position in that state array (get_state " +
7459
+ "section 'lfos' / 'envelopes' / 'vary' / 'macros' / 'paramSeqs') and property is the field name to " +
7460
+ "overwrite. paramSeqSequence takes { index, sequence: number[] }. freeSeqSequence takes " +
7461
+ "{ sequence: number[] } — the master step contour every free-mode ParamSeq follows."),
5579
7462
  },
5580
7463
  }, async ({ type, payload }) => {
5581
7464
  const check = await requireBridge();
@@ -5601,13 +7484,6 @@ server.registerTool("update_modulation", {
5601
7484
  // ============================================================
5602
7485
  // TOOLS — Song mode
5603
7486
  // ============================================================
5604
- // Advanced transition spec (ADR-001, PAM >= 1.19.6; engine families live from
5605
- // PAM >= 1.20.0). Honest contract: it persists, round-trips, and reads back
5606
- // RESOLVED. Pattern policy (dissolve/wipe/cells), mix law (equal power / equal
5607
- // gain / correlation), tail (cut/fade/ring) and phase run on song VARIATION
5608
- // sections and queued bar-sync flips; preset destinations coerce to a param
5609
- // glide, and offline bounces coerce ring→fade and blend→hard switch (each
5610
- // reported, never silent). Wire enums are append-only.
5611
7487
  const morphCurveEnum = z.enum(["linear", "ease", "exp", "log"]);
5612
7488
  const transitionSpecSchema = z.object({
5613
7489
  mode: z.enum(["auto", "param", "mix", "seq", "all"]).optional(),
@@ -5617,6 +7493,10 @@ const transitionSpecSchema = z.object({
5617
7493
  paramCurve: morphCurveEnum.optional(),
5618
7494
  patternCurve: morphCurveEnum.optional(),
5619
7495
  mixCurve: morphCurveEnum.optional(),
7496
+ staggerBy: z.enum(["off", "cell", "category"]).optional(),
7497
+ staggerSpread: z.number().min(0).max(0.9).optional(),
7498
+ staggerOrder: z.enum(["forward", "reverse"]).optional(),
7499
+ relations: z.enum(["off", "stepped"]).optional(),
5620
7500
  })
5621
7501
  .optional(),
5622
7502
  pattern: z.object({ policy: z.enum(["dissolve", "wipe", "cells"]).optional() }).optional(),
@@ -5629,23 +7509,25 @@ const transitionSpecSchema = z.object({
5629
7509
  .optional(),
5630
7510
  phase: z.enum(["inherit", "reset", "continue"]).optional(),
5631
7511
  });
5632
- const TRANSITION_SPEC_HONESTY = "Advanced transition spec (PAM >= 1.19.6; live engine from PAM >= 1.20.0): pattern.policy " +
7512
+ const TRANSITION_SPEC_HONESTY = "Advanced transition spec: pattern.policy " +
5633
7513
  "('dissolve' prob-morphs events, 'wipe' replaces them at a moving frontier, 'cells' hands " +
5634
7514
  "whole cells over in ascending order), audio.mixLaw ('equalPower' default, 'equalGain' for " +
5635
7515
  "correlated sections, 'correlation' auto-adapts), audio.tail ('fade' default, 'cut', 'ring' " +
5636
7516
  "lets voices/FX decay up to tailMaxBars), phase ('reset'/'continue' overrides the song's " +
5637
7517
  "Reset Phase per cable), window.patternCurve/mixCurve (per-domain easing; patternCurve " +
5638
- "defaults linear). Runs on variation sections + queued flips, and on preset destinations " +
5639
- "when the lanePresetSections setting is on (PAM >= 1.20.1 — the dual-lane preset section " +
7518
+ "defaults linear). window.staggerBy (off/cell/category), staggerSpread (0..0.9), staggerOrder (forward/reverse), and relations (off/stepped) shape the parameter glide. Runs on variation sections + queued flips, and on preset destinations " +
7519
+ "when the lanePresetSections setting is on (the dual-lane preset section " +
5640
7520
  "pipeline; with it off, preset destinations coerce to a param glide). Offline bounces " +
5641
7521
  "coerce ring→fade, blend→hard switch. 'get' reads back each cable's resolvedTransition " +
5642
7522
  "(effective values after inheritance and capability coercion) and lastMorphResolution " +
5643
- "(the engine's latest AUTO decision + reason).";
7523
+ "(the engine's latest AUTO decision + reason; published only for an AUTO request, and " +
7524
+ "one per song rather than one per cable).";
5644
7525
  server.registerTool("song_mode", {
5645
7526
  title: "Song Mode",
5646
7527
  description: "Control PAM's song mode (timeline of variation/preset sections). Ops: 'setArrangement', " +
5647
7528
  "'get', 'assignVx', 'setSequence', 'clear', 'toggle', 'setLoopPoints', 'toggleLoop', " +
5648
- "'setCrossfadeMs', 'setPlayhead'.\n\n" +
7529
+ "'setCrossfadeMs', 'setPlayhead'. WebView-routed (window must be OPEN): setArrangement, get, assignVx. " +
7530
+ "Headless: setSequence, clear, toggle, setLoopPoints, toggleLoop, setCrossfadeMs, setPlayhead.\n\n" +
5649
7531
  "PREFER 'setArrangement' (PAM ≥ 1.5.7, window open): it authors real Song Mode grid nodes " +
5650
7532
  "from a `sections` list, with an optional weighted `connections` graph. The result is " +
5651
7533
  "visible/editable in the UI, and each section can pin a " +
@@ -5653,8 +7535,11 @@ server.registerTool("song_mode", {
5653
7535
  "audio slot 1:1; see the vx_variation tool), optionally inside a specific VX scene via " +
5654
7536
  "`vxScenePath` (the .vxpreset loads before the section plays, so sections can pull visuals " +
5655
7537
  "from different scenes). A section can also carry `morphBars`/`morphCurve`/`morphMode` to glide the " +
5656
- "incoming variation's continuous params over the bars before its boundary (PAM >= 1.19.3), and an " +
5657
- "advanced `transition` spec (PAM >= 1.19.6 — authored metadata; see the field description). " +
7538
+ "incoming variation's continuous params over the bars before its boundary, and an " +
7539
+ "advanced `transition` spec (see the field description). " +
7540
+ "A `transitionPreset` id (or `defaultTransitionPreset` at the top level) stamps a whole named " +
7541
+ "transition in one field; op 'get' lists the available ids in `transitionPresets` and reports " +
7542
+ "each cable's derived `transitionPreset`. " +
5658
7543
  "'get' reads the arrangement back (nodes, compiled steps with the " +
5659
7544
  "VX scene+slot each recalls, total bars, and each cable's `resolvedTransition` — the effective " +
5660
7545
  "transition after inheritance and capability coercion, e.g. preset destinations report param); " +
@@ -5684,14 +7569,14 @@ server.registerTool("song_mode", {
5684
7569
  repeat: z.number().int().min(1).optional().describe("Times to play before advancing (default 1)."),
5685
7570
  muted: z.boolean().optional().describe("Pass through without an audio trigger."),
5686
7571
  probability: z.number().min(0).max(1).optional().describe("Trigger probability (0-1)."),
5687
- 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."),
7572
+ 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 it transitions exactly like a variation section (prepared off the audible path, full morph family); otherwise the legacy in-place load applies."),
5688
7573
  label: z.string().optional().describe("Section label shown on the grid node (e.g. 'Intro', 'Hook')."),
5689
7574
  morphBars: z
5690
7575
  .number()
5691
7576
  .min(0)
5692
7577
  .max(8)
5693
7578
  .optional()
5694
- .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."),
7579
+ .describe("Pre-anchor morph window in bars for the transition INTO this section — continuous params glide to the new values, landing at the boundary. 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."),
5695
7580
  morphCurve: z
5696
7581
  .enum(["linear", "ease", "exp", "log"])
5697
7582
  .optional()
@@ -5699,8 +7584,12 @@ server.registerTool("song_mode", {
5699
7584
  morphMode: z
5700
7585
  .enum(["auto", "param", "mix", "seq", "all"])
5701
7586
  .optional()
5702
- .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."),
7587
+ .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. 'param' glides continuous params; 'mix' DJ-blends stems; 'seq' adds a staged pattern dissolve to the glide; 'all' runs glide + dissolve + blend. Ignored without morphBars."),
5703
7588
  transition: transitionSpecSchema.optional().describe(TRANSITION_SPEC_HONESTY),
7589
+ transitionPreset: z
7590
+ .string()
7591
+ .optional()
7592
+ .describe("Named transition preset id — read the list from op 'get' (`transitionPresets`: the factory set plus the user's saved ones). Stamps every transition field onto the incoming cable; morphBars/morphCurve/morphMode/transition on the same object override the stamp field by field. Unknown ids fail with the known list."),
5704
7593
  vxVariationIndex: z
5705
7594
  .number()
5706
7595
  .int()
@@ -5743,6 +7632,10 @@ server.registerTool("song_mode", {
5743
7632
  .optional()
5744
7633
  .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)."),
5745
7634
  transition: transitionSpecSchema.optional().describe("Per-cable " + TRANSITION_SPEC_HONESTY),
7635
+ transitionPreset: z
7636
+ .string()
7637
+ .optional()
7638
+ .describe("Named transition preset id — read the list from op 'get' (`transitionPresets`: the factory set plus the user's saved ones). Stamps every transition field onto the incoming cable; morphBars/morphCurve/morphMode/transition on the same object override the stamp field by field. Unknown ids fail with the known list."),
5746
7639
  }))
5747
7640
  .optional()
5748
7641
  .describe("For 'setArrangement': optional cable graph. Multiple cables may leave or enter a section. Each cable may carry its own morphBars/morphCurve/morphMode/transition."),
@@ -5750,6 +7643,11 @@ server.registerTool("song_mode", {
5750
7643
  .nullable()
5751
7644
  .optional()
5752
7645
  .describe("For 'setArrangement': song-level advanced transition defaults inherited by cables without their own `transition`; null clears. " + TRANSITION_SPEC_HONESTY),
7646
+ defaultTransitionPreset: z
7647
+ .string()
7648
+ .nullable()
7649
+ .optional()
7650
+ .describe("For 'setArrangement': stamp a named preset as the song-level default transition (see transitionPreset)."),
5753
7651
  loop: z.boolean().optional().describe("For 'setArrangement': loop the whole arrangement."),
5754
7652
  nodeId: z.string().optional().describe("For 'assignVx': grid node id (from op 'get')."),
5755
7653
  stepIndex: z.number().int().min(0).optional().describe("For 'assignVx': compiled step index (alternative to nodeId)."),
@@ -5808,29 +7706,37 @@ server.registerTool("song_mode", {
5808
7706
  case "setArrangement": {
5809
7707
  if (!args.sections || args.sections.length === 0)
5810
7708
  return error("'setArrangement' requires sections");
7709
+ const requestId = mintVideoRequestId();
5811
7710
  await sendVideoCommand({
5812
7711
  op: "setSongArrangement",
7712
+ requestId,
5813
7713
  sections: args.sections,
5814
7714
  ...(args.connections !== undefined && { connections: args.connections }),
5815
7715
  ...(args.defaultTransition !== undefined && { defaultTransition: args.defaultTransition }),
7716
+ ...(args.defaultTransitionPreset !== undefined && { defaultTransitionPreset: args.defaultTransitionPreset }),
5816
7717
  ...(args.loop !== undefined && { loop: args.loop }),
5817
7718
  ...(args.enabled !== undefined && { enabled: args.enabled }),
5818
7719
  ...(args.crossfadeMs !== undefined && { crossfadeMs: args.crossfadeMs }),
5819
7720
  });
5820
- // The forward path is fire-and-forget — poll until the WebView's apply
5821
- // republishes the snapshot (same pattern as resolveNewId).
5822
7721
  const deadline = Date.now() + 3000;
5823
7722
  let sm;
7723
+ let acknowledged = false;
5824
7724
  for (;;) {
5825
7725
  const state = await getVideoStateRaw();
5826
- sm = state.songMode;
5827
- const landed = sm?.available === true &&
5828
- Array.isArray(sm.nodes) &&
5829
- sm.nodes.length === args.sections.length;
5830
- if (landed || Date.now() > deadline)
7726
+ const ack = state.lastCommandAck;
7727
+ if (ack?.requestId === requestId) {
7728
+ if (ack.ok !== true)
7729
+ return error(ack.error ?? "Song arrangement was rejected.");
7730
+ sm = state.songMode;
7731
+ acknowledged = true;
7732
+ break;
7733
+ }
7734
+ if (Date.now() > deadline)
5831
7735
  break;
5832
7736
  await sleep(150);
5833
7737
  }
7738
+ if (!acknowledged)
7739
+ return error("Song arrangement was sent but its completion was not acknowledged. Open PAM's window and use a current plugin build; inspect song_mode op:'get' before retrying.");
5834
7740
  if (!sm || sm.available !== true)
5835
7741
  return error("Arrangement command sent, but no song-mode read-model came back — this needs PAM ≥ 1.5.7 " +
5836
7742
  "with the window OPEN. On older builds fall back to op 'setSequence' (headless raw steps).");
@@ -5915,11 +7821,13 @@ server.registerTool("export_song", {
5915
7821
  "• 'live' with cellId — legacy headless master-mix capture into an empty cell (works with the window " +
5916
7822
  "closed on PAM ≥ 1.5.0). The captured WAV also lands in PAM's Resamples folder.\n" +
5917
7823
  "• 'offline' — the DETERMINISTIC multi-section bounce (PAM ≥ 1.5.7 with offline song export): every " +
5918
- "variation section applies sample-accurately on its anchor; renders faster than realtime with no bar cap. " +
7824
+ "variation section applies sample-accurately on its anchor and renders faster than realtime; `bars` is still required and capped at 64. " +
5919
7825
  "Audio-only works headless (window closed); includeVideo runs the offline video pump with per-section VX " +
5920
7826
  "recalls (window open, WebCodecs). Caveats: preset-path steps hold the previous section's sound (pending), " +
5921
7827
  "and wall-clock sources (Magenta, Notochord, camera layers) cannot render offline — use 'live' for those.\n\n" +
5922
7828
  "Live paths render in REAL TIME (a 90 s song takes ~90 s) and cap at 64 bars per capture. " +
7829
+ "This is the entry point for arrangement bounces: strategy 'offline' issues the same render as " +
7830
+ "`export_audio` with mode 'song-mode', and the live and audio+video strategies exist only here. " +
5923
7831
  "Verify audio results with get_audio_info (resolve cell captures against the Resamples location " +
5924
7832
  "from get_default_locations).",
5925
7833
  inputSchema: {
@@ -5928,7 +7836,7 @@ server.registerTool("export_song", {
5928
7836
  .number()
5929
7837
  .min(1)
5930
7838
  .max(64)
5931
- .describe("Total arrangement length in bars (the full timeline). Required for 'live'."),
7839
+ .describe("Total arrangement length in bars (the full timeline), 1-64. Required for every strategy."),
5932
7840
  cellId: z
5933
7841
  .number()
5934
7842
  .min(1)
@@ -5942,7 +7850,10 @@ server.registerTool("export_song", {
5942
7850
  name: z.string().optional().describe("Output name for WebView Song Export renders (default 'Song')."),
5943
7851
  normalize: z.boolean().optional().describe("Normalize the captured audio (WebView Song Export paths)."),
5944
7852
  encoderId: z.string().optional().describe("Recording encoder preset for includeVideo (e.g. mp4-h264)."),
5945
- bitDepth: z.number().default(24).describe("Bit depth (16 or 24; legacy cell-capture + offline paths)."),
7853
+ bitDepth: z
7854
+ .union([z.literal(16), z.literal(24)])
7855
+ .default(24)
7856
+ .describe("Bit depth: 16 or 24 (legacy cell-capture + offline paths)."),
5946
7857
  tailBars: z
5947
7858
  .number()
5948
7859
  .min(0)
@@ -5951,7 +7862,7 @@ server.registerTool("export_song", {
5951
7862
  .describe("For legacy cell-capture 'live': extra bars captured after the arrangement end for tails."),
5952
7863
  },
5953
7864
  }, async ({ strategy, bars, cellId, includeVideo, name, normalize, encoderId, bitDepth, tailBars }) => {
5954
- const check = await requireBridge();
7865
+ const check = await requireBridgeForCells(cellId);
5955
7866
  if (check !== true)
5956
7867
  return check;
5957
7868
  // -------- Offline audio-only: headless multi-section bounce --------
@@ -6095,9 +8006,10 @@ server.registerTool("set_metronome", {
6095
8006
  // ============================================================
6096
8007
  server.registerTool("analyze_sample_tempo", {
6097
8008
  title: "Analyze Sample Tempo",
6098
- description: "Run BPM detection and transient/slice analysis on an audio file. " +
6099
- "Result is dispatched asynchronously into PAM's state — fetch it via get_state after a short delay (~100-500ms). " +
6100
- "Use this before resample_sample_to_bpm if you don't already know the source BPM.",
8009
+ description: "Run BPM detection and transient/slice analysis on an audio file. The result lands in the plugin's " +
8010
+ "sample-metadata cache and the editor's sample library; the bridge does not capture it, so there is no " +
8011
+ "get_state key that reads it back. Run it before resample_sample_to_bpm so the stretch has a source BPM, " +
8012
+ "and use get_audio_info for a file's own format properties.",
6101
8013
  inputSchema: {
6102
8014
  filePath: z.string().describe("Path to the audio file."),
6103
8015
  transientSensitivity: z
@@ -6156,11 +8068,11 @@ server.registerTool("preview_slice", {
6156
8068
  title: "Preview Slice",
6157
8069
  description: "Audition a single slice of a cell's sample.",
6158
8070
  inputSchema: {
6159
- cellId: z.number().min(1).max(8),
6160
- sliceNumber: z.number().min(0).describe("Slice index."),
8071
+ cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
8072
+ sliceNumber: z.number().min(0).describe("Slice index (0-based)."),
6161
8073
  },
6162
8074
  }, async ({ cellId, sliceNumber }) => {
6163
- const check = await requireBridge();
8075
+ const check = await requireBridgeForCells(cellId);
6164
8076
  if (check !== true)
6165
8077
  return check;
6166
8078
  await bridge.send("previewSlice", cellId, sliceNumber);
@@ -6171,7 +8083,7 @@ server.registerTool("resample_sample_to_bpm", {
6171
8083
  description: "Time-stretch a cell's sample to the current project BPM (or a target BPM). " +
6172
8084
  "Non-destructive — use restore_original_sample to revert.",
6173
8085
  inputSchema: {
6174
- cellId: z.number().min(1).max(8),
8086
+ cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
6175
8087
  sourceBpm: z
6176
8088
  .number()
6177
8089
  .min(20)
@@ -6196,10 +8108,10 @@ server.registerTool("restore_original_sample", {
6196
8108
  title: "Restore Original Sample",
6197
8109
  description: "Revert a cell's sample to its un-edited source (undoes resample / trim / etc.).",
6198
8110
  inputSchema: {
6199
- cellId: z.number().min(1).max(8),
8111
+ cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
6200
8112
  },
6201
8113
  }, async ({ cellId }) => {
6202
- const check = await requireBridge();
8114
+ const check = await requireBridgeForCells(cellId);
6203
8115
  if (check !== true)
6204
8116
  return check;
6205
8117
  await bridge.send("restoreOriginalSample", { cellId });
@@ -6213,7 +8125,7 @@ server.registerTool("build_sample_chain", {
6213
8125
  cellId: z.number().min(1).max(8).describe("Destination cell."),
6214
8126
  },
6215
8127
  }, async ({ filePaths, cellId }) => {
6216
- const check = await requireBridge();
8128
+ const check = await requireBridgeForCells(cellId);
6217
8129
  if (check !== true)
6218
8130
  return check;
6219
8131
  const result = await bridge.send("buildSampleChain", filePaths, cellId);
@@ -6223,11 +8135,11 @@ server.registerTool("export_slice", {
6223
8135
  title: "Export Slice",
6224
8136
  description: "Export a single slice of a cell's sample as a standalone WAV.",
6225
8137
  inputSchema: {
6226
- cellId: z.number().min(1).max(8),
6227
- sliceIndex: z.number().min(0),
8138
+ cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
8139
+ sliceIndex: z.number().min(0).describe("Slice index (0-based)."),
6228
8140
  },
6229
8141
  }, async ({ cellId, sliceIndex }) => {
6230
- const check = await requireBridge();
8142
+ const check = await requireBridgeForCells(cellId);
6231
8143
  if (check !== true)
6232
8144
  return check;
6233
8145
  await bridge.send("exportSliceForCell", { cellId, sliceIndex });
@@ -6241,7 +8153,8 @@ server.registerTool("live_resample", {
6241
8153
  description: "Capture PAM's audio graph output into a cell as a new sample, in real time. Ops: 'start' / 'stop'. " +
6242
8154
  "Transport MUST be playing for the capture to produce audio. Because the capture runs live, " +
6243
8155
  "Song Mode section changes, modulation, and variation switches are all captured faithfully — " +
6244
- "this is the building block for full-track / Song Mode bounces (see export_song).\n\n" +
8156
+ "this is the building block for full-track / Song Mode bounces (see export_song). On finalize the captured clip " +
8157
+ "REPLACES whatever sample the destination cell held — use an empty cell, or reload the preset before saving anything afterwards.\n\n" +
6245
8158
  "`source` selects what to record: 'cell' (the destination cell's own output), 'input' (PAM's audio input), " +
6246
8159
  "or 'fullmix' (the whole master mix — use this to bounce the entire arrangement).",
6247
8160
  inputSchema: {
@@ -6272,14 +8185,21 @@ server.registerTool("live_resample", {
6272
8185
  .describe("For 'stop': finalize the captured clip (false discards)."),
6273
8186
  },
6274
8187
  }, async (args) => {
6275
- const check = await requireBridge();
8188
+ const check = await requireBridgeForCells(args.op === "start" ? args.cellId : undefined);
6276
8189
  if (check !== true)
6277
8190
  return check;
6278
8191
  if (args.op === "start") {
8192
+ if (activeLiveResample) {
8193
+ return error(`A live resample started by this server is already targeting cell ${activeLiveResample.cellId}. Stop it first.`);
8194
+ }
6279
8195
  if (typeof args.cellId !== "number" || typeof args.bars !== "number")
6280
8196
  return error("'start' requires cellId + bars");
6281
8197
  const source = args.source ?? "cell";
6282
8198
  const captureSource = source === "fullmix" ? 2 : source === "input" ? 1 : 0;
8199
+ const beforeRaw = await bridge.send("getState");
8200
+ const beforeCells = isRecord(beforeRaw) && isRecord(beforeRaw.cells) ? beforeRaw.cells : {};
8201
+ const beforeCell = beforeCells[String(args.cellId)];
8202
+ const before = isRecord(beforeCell) ? beforeCell : {};
6283
8203
  await bridge.send("resampleGraphToCell", JSON.stringify({
6284
8204
  cellId: args.cellId,
6285
8205
  bars: args.bars,
@@ -6290,10 +8210,48 @@ server.registerTool("live_resample", {
6290
8210
  cellOnly: source === "cell",
6291
8211
  normalize: args.normalize ?? false,
6292
8212
  }));
8213
+ activeLiveResample = {
8214
+ cellId: args.cellId,
8215
+ previousPath: typeof before.filePath === "string" ? before.filePath : undefined,
8216
+ };
6293
8217
  return text(`Started live resample → cell ${args.cellId} (${args.bars} bars, source '${source}').`);
6294
8218
  }
6295
- await bridge.send("stopLiveResample", args.finalize ?? true);
6296
- return text(`Stopped live resample (finalize=${args.finalize ?? true}).`);
8219
+ const finalize = args.finalize ?? true;
8220
+ await bridge.send("stopLiveResample", finalize);
8221
+ const capture = activeLiveResample;
8222
+ activeLiveResample = undefined;
8223
+ if (!finalize)
8224
+ return text("Stopped live resample and discarded the capture.");
8225
+ if (!capture)
8226
+ return error("Stopped live resample, but this server did not start it and cannot identify the destination cell.");
8227
+ let capturedPath;
8228
+ for (let attempt = 0; attempt < 20; attempt++) {
8229
+ const stateRaw = await bridge.send("getState");
8230
+ const state = isRecord(stateRaw) ? stateRaw : {};
8231
+ const stateCells = isRecord(state.cells) ? state.cells : {};
8232
+ const rawCell = stateCells[String(capture.cellId)];
8233
+ const cell = isRecord(rawCell) ? rawCell : {};
8234
+ if (typeof cell.filePath === "string" && cell.filePath && cell.filePath !== capture.previousPath) {
8235
+ capturedPath = cell.filePath;
8236
+ break;
8237
+ }
8238
+ await new Promise((resolveWait) => setTimeout(resolveWait, 250));
8239
+ }
8240
+ if (!capturedPath) {
8241
+ return error(`Capture finalized into cell ${capture.cellId}, but its new file path was not available after 5 seconds.`);
8242
+ }
8243
+ if (!isAbsolute(capturedPath)) {
8244
+ const locations = await bridge.send("getDefaultLocations");
8245
+ const userSamples = Array.isArray(locations)
8246
+ ? locations.find((location) => isRecord(location) && location.name === "User Samples")
8247
+ : undefined;
8248
+ if (isRecord(userSamples) && typeof userSamples.path === "string") {
8249
+ const normalized = capturedPath.replaceAll("\\", "/");
8250
+ const userRelative = normalized.replace(/^Samples\/User\//, "");
8251
+ capturedPath = join(userSamples.path, userRelative);
8252
+ }
8253
+ }
8254
+ return text(`Stopped live resample and finalized cell ${capture.cellId}.\nFile: ${capturedPath}`);
6297
8255
  });
6298
8256
  // ============================================================
6299
8257
  // TOOLS — Plugin host (scan/load/unload/param/bypass on hosted VST3/AU/CLAP)
@@ -6475,7 +8433,7 @@ server.registerTool("list_loaded_plugins", {
6475
8433
  title: "List Loaded Plugins",
6476
8434
  description: "List plugins currently hosted in PAM cells (instrument slot only — FX-side hosting is not exposed). " +
6477
8435
  "Each entry: cellId, name, manufacturer, format, " +
6478
- "path, isInstrument, bypassed, and the plugin's full param list (index, name, label, value, defaultValue). " +
8436
+ "path, isInstrument, bypassed, and the plugin's full param list (index, name, label, value, defaultValue, discrete, numSteps, and valueStrings for a discrete param). " +
6479
8437
  "Use the param `index` with `set_plugin_param`.",
6480
8438
  inputSchema: {},
6481
8439
  }, async () => {
@@ -6517,7 +8475,8 @@ server.registerTool("load_plugin", {
6517
8475
  ...(args.uniqueId ? { uniqueId: args.uniqueId } : {}),
6518
8476
  ...(args.name ? { name: args.name } : {}),
6519
8477
  }));
6520
- return text(raw);
8478
+ // Native answers a success with an object (`{ok: true, …}`), a failure with a string.
8479
+ return text(typeof raw === "string" ? raw : JSON.stringify(raw));
6521
8480
  });
6522
8481
  server.registerTool("unload_plugin", {
6523
8482
  title: "Unload Plugin From Cell",
@@ -6540,10 +8499,10 @@ server.registerTool("set_plugin_param", {
6540
8499
  description: "Set a parameter on a hosted plugin by cell + parameter index. Value is normalized 0..1. " +
6541
8500
  "Use `list_loaded_plugins` to find param indices and names. " +
6542
8501
  "This is the dedicated path for hosted plugin params — `set_parameters` won't reach them. " +
6543
- "Targets the cell's instrument-slot plugin only.",
8502
+ "It exists for a single instrument-slot value; use `set_plugin_params` for a batch or for the FX slot.",
6544
8503
  inputSchema: {
6545
- cellId: z.number().int().min(1),
6546
- paramIndex: z.number().int().min(0),
8504
+ cellId: z.number().int().min(1).describe("Cell hosting the plugin."),
8505
+ paramIndex: z.number().int().min(0).describe("Parameter index from list_loaded_plugins."),
6547
8506
  value: z.number().min(0).max(1).describe("Normalized 0..1."),
6548
8507
  },
6549
8508
  }, async (args) => {
@@ -6565,8 +8524,8 @@ server.registerTool("set_plugin_bypass", {
6565
8524
  description: "Toggle bypass on the hosted plugin in a cell (instrument slot). Bypassed plugins are silenced; the cell sampler " +
6566
8525
  "still plays in parallel.",
6567
8526
  inputSchema: {
6568
- cellId: z.number().int().min(1),
6569
- bypassed: z.boolean(),
8527
+ cellId: z.number().int().min(1).describe("Cell hosting the plugin."),
8528
+ bypassed: z.boolean().describe("true silences the hosted plugin, false re-enables it."),
6570
8529
  },
6571
8530
  }, async (args) => {
6572
8531
  const check = await requireBridge();
@@ -6665,6 +8624,22 @@ server.registerTool("get_audio_device_setup", {
6665
8624
  const setup = await bridge.send("getAudioDeviceSetup");
6666
8625
  return json(setup);
6667
8626
  });
8627
+ server.registerTool("get_memory_report", {
8628
+ title: "Get Memory Report",
8629
+ description: "Report the instance's audio memory: vfsBytes (decoded samples resident in the engine's virtual file system), " +
8630
+ "vfsRetiredBytes (superseded buffers not yet reclaimed), vfsEntryCount, looperRingBytes (allocated looper " +
8631
+ "capture rings), and residentBytes (whole-process RSS; residentBytesAvailable is false when the platform " +
8632
+ "could not answer, in which case residentBytes is 0 rather than empty). Use it to check whether loading, " +
8633
+ "replacing or auditioning samples leaves memory flat or growing.",
8634
+ inputSchema: {},
8635
+ annotations: { readOnlyHint: true },
8636
+ }, async () => {
8637
+ const check = await requireBridge();
8638
+ if (check !== true)
8639
+ return check;
8640
+ const report = await bridge.send("getMemoryReport");
8641
+ return json(report);
8642
+ });
6668
8643
  server.registerTool("set_audio_device", {
6669
8644
  title: "Set Audio Output Device",
6670
8645
  description: "Switch the active audio OUTPUT device by name (standalone only — plugin-hosted builds let the DAW own the " +
@@ -6771,7 +8746,9 @@ server.registerTool("get_sample_usage", {
6771
8746
  description: "Cross-reference samples and presets. mode 'presetUsesSamples' lists the samples a preset references; " +
6772
8747
  "mode 'presetsUsingSample' lists presets that reference a given sample (or any sample under a folder).",
6773
8748
  inputSchema: {
6774
- mode: z.enum(["presetUsesSamples", "presetsUsingSample"]),
8749
+ mode: z
8750
+ .enum(["presetUsesSamples", "presetsUsingSample"])
8751
+ .describe("Which direction to cross-reference: from a preset to its samples, or from a sample to the presets using it."),
6775
8752
  path: z
6776
8753
  .string()
6777
8754
  .describe("Relative preset path (presetUsesSamples) or relative sample path (presetsUsingSample)."),
@@ -6796,7 +8773,9 @@ server.registerTool("repair_preset_samples", {
6796
8773
  "library (optionally scoped to one preset). mode 'rescan' re-audits a preset for still-missing samples and " +
6797
8774
  "reports them. Useful after moving a sample library or importing presets from another machine.",
6798
8775
  inputSchema: {
6799
- mode: z.enum(["fix", "rescan"]),
8776
+ mode: z
8777
+ .enum(["fix", "rescan"])
8778
+ .describe("'fix' relinks moved samples by searching the library; 'rescan' only reports what is still missing."),
6800
8779
  presetPath: z
6801
8780
  .string()
6802
8781
  .optional()
@@ -6840,7 +8819,9 @@ server.registerTool("preview_sample", {
6840
8819
  server.registerTool("set_plugin_params", {
6841
8820
  title: "Set Hosted Plugin Params (batch)",
6842
8821
  description: "Set multiple hosted-plugin parameters in one call (more efficient than set_plugin_param per value). Values are " +
6843
- "0..1 normalized. Use list_loaded_plugins to discover paramIndex values. role defaults to 'instrument'.",
8822
+ "0..1 normalized. Use list_loaded_plugins to discover paramIndex values. role defaults to 'instrument'. " +
8823
+ "role:'effect' reaches the FX slot, but list_loaded_plugins reports instrument-slot plugins only, so " +
8824
+ "effect-slot param indices are not discoverable through MCP.",
6844
8825
  inputSchema: {
6845
8826
  cellId: z.number().int().min(1).describe("Cell number."),
6846
8827
  role: z.enum(["instrument", "effect"]).optional().describe("Which hosted slot (default 'instrument')."),
@@ -6927,6 +8908,9 @@ const SET_VIEW_MAIN_VIEW = {
6927
8908
  export: "Export",
6928
8909
  presets: "PresetBrowser",
6929
8910
  browser: "PresetBrowser",
8911
+ // MFX-only surfaces. The handler rejects them for other products.
8912
+ patch: "MfxRouting",
8913
+ patchbay: "MfxRouting",
6930
8914
  };
6931
8915
  // Dev-tree twin of the in-plugin assistant's set_view (toolpack-gated like
6932
8916
  // ask_assistant — the published server deliberately excludes window/view
@@ -6946,12 +8930,13 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
6946
8930
  view: z
6947
8931
  .union([z.string(), z.number()])
6948
8932
  .optional()
6949
- .describe("Main view: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets."),
8933
+ .describe("Main view: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets. " +
8934
+ "MFX also takes patch (the cable patchbay)."),
6950
8935
  cellId: z.number().int().optional().describe("Cell to select/focus (1-8)."),
6951
8936
  gridSize: z.number().optional().describe("How many pads the grid shows: 1, 4, or 8."),
6952
8937
  },
6953
8938
  }, async ({ view: viewArg, cellId, gridSize }) => {
6954
- const check = await requireBridge();
8939
+ const check = await requireBridgeForCells(cellId);
6955
8940
  if (check !== true)
6956
8941
  return check;
6957
8942
  let view = viewArg === undefined ? undefined : String(viewArg);
@@ -6967,7 +8952,12 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
6967
8952
  if (typeof view === "string" && view.trim()) {
6968
8953
  token = view.trim().toLowerCase();
6969
8954
  if (!(token in SET_VIEW_MAIN_VIEW))
6970
- return error(`Unknown view: ${view}. Views: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets.`);
8955
+ return error(`Unknown view: ${view}. Views: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets, patch.`);
8956
+ if (token === "patch" || token === "patchbay") {
8957
+ const manifest = await loadManifestForProductGuard();
8958
+ if (manifest && !isMfxManifest(manifest))
8959
+ return error(`${token} is available in MFX only.`);
8960
+ }
6971
8961
  applied.view = token;
6972
8962
  }
6973
8963
  if (gridSize !== undefined) {
@@ -7081,9 +9071,12 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
7081
9071
  playing: Boolean(transport.playing ?? transport.isPlaying),
7082
9072
  preset,
7083
9073
  sel: sel ?? null,
7084
- cells: (overview.cells ?? [])
7085
- .filter((c) => c.sample || c.synth)
7086
- .map((c) => ({ id: c.id, name: c.sample ?? `${c.synth}(synth)` })),
9074
+ muted: (overview.cells ?? []).filter((c) => c.muted).map((c) => c.cellId),
9075
+ cells: (overview.cells ?? []).flatMap((c) => {
9076
+ const name = c.sample?.path || c.sample?.name
9077
+ || (c.synthEngine && c.synthEngine.id > 0 ? `${c.synthEngine.name}(synth)` : undefined);
9078
+ return name ? [{ id: c.cellId, name }] : [];
9079
+ }),
7087
9080
  };
7088
9081
  }
7089
9082
  }
@@ -7185,13 +9178,55 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
7185
9178
  // ============================================================
7186
9179
  // Start
7187
9180
  // ============================================================
9181
+ // A stdio server that dies takes its whole tool surface with it, and the client
9182
+ // only ever reports "Connection closed". Nothing short of an explicit exit is
9183
+ // worth that, so stray async faults are logged to stderr and the server lives.
9184
+ // The rule must stay BOUNDED: a fault that repeats every tick turns "the server
9185
+ // lives" into a permanent 100% CPU spin, and each report re-formats a tsx
9186
+ // source-mapped stack. Five orphaned servers burned a core each for over a week.
9187
+ const FAULT_WINDOW_MS = 10_000;
9188
+ const FAULT_LIMIT = 100;
9189
+ let faultTimes = [];
9190
+ function surviveFault(label, value) {
9191
+ const now = Date.now();
9192
+ faultTimes = faultTimes.filter((t) => now - t < FAULT_WINDOW_MS);
9193
+ faultTimes.push(now);
9194
+ if (faultTimes.length > FAULT_LIMIT)
9195
+ process.exit(1);
9196
+ try {
9197
+ console.error(label, value);
9198
+ }
9199
+ catch {
9200
+ process.exit(1);
9201
+ }
9202
+ }
9203
+ // EPIPE on our own stdio is the dead-client signal, not a stray fault: the SDK
9204
+ // writes JSON-RPC to stdout unguarded, and routing that through the handler
9205
+ // below made the logger re-raise the fault it was reporting. Exit at the source.
9206
+ for (const s of [process.stdout, process.stderr]) {
9207
+ s.on("error", (e) => {
9208
+ if (e?.code === "EPIPE")
9209
+ process.exit(0);
9210
+ });
9211
+ }
9212
+ process.on("unhandledRejection", (reason) => {
9213
+ surviveFault("[pam-mcp] unhandled rejection (server continuing):", reason);
9214
+ });
9215
+ process.on("uncaughtException", (err) => {
9216
+ surviveFault("[pam-mcp] uncaught exception (server continuing):", err);
9217
+ });
7188
9218
  async function main() {
7189
9219
  if (await handleCliArgs())
7190
9220
  return;
7191
9221
  // Try to connect to plugin on startup (non-blocking)
7192
- void bridge.connect().then((ok) => {
9222
+ void bridge
9223
+ .connect()
9224
+ .then((ok) => {
7193
9225
  if (ok)
7194
9226
  syncProductHint();
9227
+ })
9228
+ .catch((e) => {
9229
+ console.error("[pam-mcp] startup bridge connect failed:", e);
7195
9230
  });
7196
9231
  const transport = new StdioServerTransport();
7197
9232
  await server.connect(transport);
@@ -7202,9 +9237,27 @@ main().catch((e) => {
7202
9237
  console.error("Fatal:", e);
7203
9238
  process.exit(1);
7204
9239
  });
7205
- process.on("SIGINT", async () => {
9240
+ // MCP clients terminate stdio servers with SIGTERM as often as SIGINT; without
9241
+ // both, the bridge socket is left for the OS to reap.
9242
+ let shuttingDown = false;
9243
+ const shutdown = async () => {
9244
+ if (shuttingDown)
9245
+ return;
9246
+ shuttingDown = true;
7206
9247
  bridge.disconnect();
7207
- await server.close();
9248
+ try {
9249
+ await server.close();
9250
+ }
9251
+ catch {
9252
+ // Already torn down — exiting anyway.
9253
+ }
7208
9254
  process.exit(0);
7209
- });
9255
+ };
9256
+ process.on("SIGINT", shutdown);
9257
+ process.on("SIGTERM", shutdown);
9258
+ // A client that dies without signalling leaves no signal at all — the process is
9259
+ // reparented to launchd and only stdin EOF says the session is gone. Without this
9260
+ // the server outlives every crashed client; ten-day-old orphans were measured.
9261
+ process.stdin.on("end", () => void shutdown());
9262
+ process.stdin.on("close", () => void shutdown());
7210
9263
  //# sourceMappingURL=server.js.map