@map-audio/pam-mcp-server 1.25.0 → 1.28.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 (75) hide show
  1. package/README.md +32 -10
  2. package/dist/audioAnalysis.d.ts +1 -1
  3. package/dist/audioAnalysis.js +3 -4
  4. package/dist/audioAnalysis.js.map +1 -1
  5. package/dist/bridge.d.ts +5 -1
  6. package/dist/bridge.js +48 -48
  7. package/dist/bridge.js.map +1 -1
  8. package/dist/daemon.d.ts +32 -0
  9. package/dist/daemon.js +871 -0
  10. package/dist/daemon.js.map +7 -0
  11. package/dist/drumSynth.d.ts +12 -0
  12. package/dist/drumSynth.js +15 -3
  13. package/dist/drumSynth.js.map +1 -1
  14. package/dist/drumSynthEngines.json +9 -1
  15. package/dist/{fgemma-format.d.ts → functiongemma-format.d.ts} +25 -7
  16. package/dist/{fgemma-format.js → functiongemma-format.js} +34 -11
  17. package/dist/functiongemma-format.js.map +1 -0
  18. package/dist/functiongemma-grammar.d.ts +14 -0
  19. package/dist/functiongemma-grammar.js +140 -0
  20. package/dist/functiongemma-grammar.js.map +1 -0
  21. package/dist/functiongemma-params.d.ts +33 -0
  22. package/dist/functiongemma-params.js +128 -0
  23. package/dist/functiongemma-params.js.map +1 -0
  24. package/dist/{fgemma-runner.d.ts → functiongemma-runner.d.ts} +8 -5
  25. package/dist/{fgemma-runner.js → functiongemma-runner.js} +107 -25
  26. package/dist/functiongemma-runner.js.map +1 -0
  27. package/dist/generated/kvWriteOptions.d.ts +20 -0
  28. package/dist/generated/kvWriteOptions.js +45 -0
  29. package/dist/generated/kvWriteOptions.js.map +1 -0
  30. package/dist/generated/variationSnapshot.js +0 -19
  31. package/dist/generated/variationSnapshot.js.map +1 -1
  32. package/dist/generativePlayerPresets.json +9 -0
  33. package/dist/generativePlayers.json +15 -1
  34. package/dist/launcher.d.ts +2 -0
  35. package/dist/launcher.js +36 -0
  36. package/dist/launcher.js.map +1 -0
  37. package/dist/manifest-mfx.json +12944 -311
  38. package/dist/manifest-msq.json +11446 -0
  39. package/dist/manifest.d.ts +2 -0
  40. package/dist/manifest.js +12 -27
  41. package/dist/manifest.js.map +1 -1
  42. package/dist/manifest.json +37047 -6487
  43. package/dist/mfxCombos.d.ts +2 -0
  44. package/dist/mfxCombos.js +179 -56
  45. package/dist/mfxCombos.js.map +1 -1
  46. package/dist/midiFile.d.ts +20 -6
  47. package/dist/midiFile.js +22 -14
  48. package/dist/midiFile.js.map +1 -1
  49. package/dist/mixAssist.d.ts +35 -0
  50. package/dist/mixAssist.js +250 -21
  51. package/dist/mixAssist.js.map +1 -1
  52. package/dist/patternLanes.d.ts +40 -0
  53. package/dist/patternLanes.js +108 -0
  54. package/dist/patternLanes.js.map +1 -0
  55. package/dist/players.d.ts +22 -0
  56. package/dist/players.js +70 -0
  57. package/dist/players.js.map +1 -1
  58. package/dist/presetSave.d.ts +3 -0
  59. package/dist/presetSave.js +13 -0
  60. package/dist/presetSave.js.map +1 -1
  61. package/dist/productProfile.d.ts +4 -4
  62. package/dist/productProfile.js +10 -1
  63. package/dist/productProfile.js.map +1 -1
  64. package/dist/qwen-format.d.ts +85 -0
  65. package/dist/qwen-format.js +355 -0
  66. package/dist/qwen-format.js.map +1 -0
  67. package/dist/registration.mjs +41 -0
  68. package/dist/server.js +2112 -470
  69. package/dist/server.js.map +1 -1
  70. package/dist/targetDenylist.json +20 -0
  71. package/dist/variationSessionKeys.json +7 -5
  72. package/package.json +13 -7
  73. package/dist/fgemma-format.js.map +0 -1
  74. package/dist/fgemma-runner.js.map +0 -1
  75. package/dist/manifest-cell1.json +0 -12968
package/dist/server.js CHANGED
@@ -4,26 +4,28 @@ 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 { CELL_CONFIG_KV_OPTIONS, EFFECT_CONFIG_KV_OPTIONS, MASTER_CONFIG_KV_OPTIONS, PATTERN_EDIT_KV_OPTIONS, STATE_UPDATE_KV_OPTIONS, } from "./generated/kvWriteOptions.js";
7
8
  import { CORPUS_META, CORPUS_RANGES } from "./corpusRanges.js";
8
9
  import { CORPUS_FINGERPRINTS, CORPUS_NOVELTY_BASELINE, CORPUS_VARIATION_BASELINE } from "./corpusFingerprints.js";
9
10
  import { buildFingerprint, scoreNovelty, similarity as fingerprintSimilarity } from "./fingerprint.js";
10
11
  import { analyzeMotion, decodeWav } from "./audioAnalysis.js";
11
12
  import { mkdir, readFile, writeFile } from "node:fs/promises";
12
- import { existsSync, readFileSync, statSync } from "node:fs";
13
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
13
14
  import { execFileSync } from "node:child_process";
14
15
  import { inflateSync } from "node:zlib";
15
16
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
16
17
  import { fileURLToPath } from "node:url";
17
18
  import { createDrumSynthCatalog, } from "./drumSynth.js";
18
- import { loadManifest, cellEffectNames, cellEffectOrder, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, setActiveProductHint, walkPresets, } from "./manifest.js";
19
+ import { loadManifest, cellEffectNames, cellEffectOrder, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, productExportDir, setActiveProductHint, walkPresets, } from "./manifest.js";
19
20
  import { PluginBridge } from "./bridge.js";
21
+ import { LANE_TARGET_FIELD, PATTERN_LANE_IDS, PATTERN_LANE_SUFFIX, CC_LANES, PARAM_LANES, ccLaneMayLand, ccLaneIndex, ccLaneSlotField, ccLaneSlotOf, CC_OUT_CONFIG_KEYS, CC_OUT_SLOTS, isCcLane, isCcOutValueSuffix, isParamLane, normalizeCcLaneEvents, normalizeTargetLaneEvents, productHasLane, resolveLaneTarget, } from "./patternLanes.js";
20
22
  import { buildMixMeasurementFromState, currentFlatFromState, parseMixAnalysisPayload, suggestMix, } from "./mixAssist.js";
21
- import { normalizePresetLookupPath, parsePresetFile, selectSaveVariationSourcePath, } from "./presetSave.js";
22
- import { STOP_SEQUENCES, buildGenerationPrompt, parseFunctionCalls, renderContextLine, renderFunctionCall, renderFunctionResponse, truncateAtFabricatedResponse, withContext, } from "./fgemma-format.js";
23
+ import { isFactoryPresetPath, nativePresetPath, normalizePresetLookupPath, parsePresetFile, selectSaveVariationSourcePath, } from "./presetSave.js";
24
+ import { STOP_SEQUENCES, buildGenerationPrompt, parseFunctionCalls, renderContextLine, renderFunctionCall, renderFunctionResponse, truncateAtFabricatedResponse, withContext, } from "./functiongemma-format.js";
23
25
  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";
26
+ import { MFX_COMBO_COUNT, MFX_COMBO_MANUAL_STRENGTH, buildMfxComboTarget, isMfxComboTargetParameter, mfxComboTargetValueFromCurrent, mfxComboTargetValueAfterRangeEdit, parseMfxComboSlots, reanchorMfxComboTarget, resolveMfxCombos, setMfxComboTargetStop, } from "./mfxCombos.js";
27
+ import { GENERATIVE_PLAYERS, LISTEN_MODES, LISTEN_PAIRS, MODULATABLE_PLAYER_FIELDS, NO_POLY_ARM, NO_SCALE_ARM, PATTERN_MODES, PLAYER_GENRES, PLAYER_GRIDS, PLAYER_NAMES, PLAYER_SLOT_FIELDS, cellPlayerType, describeCellPlayer, describePlayerSpec, getPlayerSpec, isMelodicPlayer, crowdedComplementCell, listenPartnerFor, soleComplementPartner, playerDefaultsFor, playerDensityDisplay, playerGenreConfig, playerGridValue, playerMutateDisplay, playerPresetsFor, playerSlotDisplay, presetConfig, randomPlayerSeed, resolvePlayerPreset, resolvePlayerSpec, resolveSlotValue, rolePlayerConfigFor, roleSpreadSummary, snapPlayerValue, } from "./players.js";
28
+ import { beatsPerBar, buildMidiFile, patternSpeedMultiplier, tilePatternEvents } from "./midiFile.js";
27
29
  import { MAX_SLICES, detectOnsetBoundaries, equalBoundaries, normalizeBoundaries } from "./sampleSlicing.js";
28
30
  // --- State ---
29
31
  // Variation slot count. Must stay in sync with PRESET_VARIATION_SLOTS in
@@ -32,7 +34,56 @@ import { MAX_SLICES, detectOnsetBoundaries, equalBoundaries, normalizeBoundaries
32
34
  const PRESET_VARIATION_SLOTS = 8;
33
35
  const SERVER_PRODUCT = mcpProductProfile();
34
36
  const IS_MFX_SERVER = SERVER_PRODUCT.id === "mfx";
37
+ const IS_MSQ_SERVER = SERVER_PRODUCT.id === "msq";
38
+ // A product launcher attaches to its own product only; pam-mcp follows whichever instance is connected.
39
+ const PINNED_PRODUCT = IS_MFX_SERVER || IS_MSQ_SERVER ? SERVER_PRODUCT.id : undefined;
40
+ if (PINNED_PRODUCT)
41
+ process.env.PAM_BRIDGE_PRODUCT = SERVER_PRODUCT.name;
42
+ const DAEMON_SESSION = process.argv.includes("--pamd-session");
43
+ function sessionOption(name) {
44
+ return process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
45
+ }
46
+ const SESSION_DATA_ROOT = sessionOption("data-root");
47
+ const SESSION_OUTPUT_DIR = sessionOption("out-dir");
48
+ const SESSION_DISCOVERY = sessionOption("discovery-file");
49
+ for (const [name, path] of [["data-root", SESSION_DATA_ROOT], ["out-dir", SESSION_OUTPUT_DIR], ["discovery-file", SESSION_DISCOVERY]])
50
+ if (path && !isAbsolute(path))
51
+ throw new Error(`${name} must be absolute`);
52
+ if (SESSION_DATA_ROOT)
53
+ process.env.PAM_AI_DATA_ROOT = SESSION_DATA_ROOT;
54
+ if (DAEMON_SESSION && (!SESSION_DATA_ROOT || !SESSION_OUTPUT_DIR || !SESSION_DISCOVERY))
55
+ throw new Error("pamd session requires captured data-root, out-dir and discovery-file");
56
+ // Loaded only for pam mcp sessions, so the plugin-only server starts without a built pamd client.
57
+ const daemon = DAEMON_SESSION
58
+ ? new (await import("./daemon.js")).DaemonSession(SESSION_DISCOVERY, SESSION_DATA_ROOT, SESSION_OUTPUT_DIR) : undefined;
59
+ const daemonDownloads = new Map();
60
+ function daemonModel(engine, model) {
61
+ if (engine === "aigen")
62
+ return "stable-audio-3-small-sfx";
63
+ if (engine === "magenta")
64
+ return model === "base" ? "mrt2-base" : "mrt2-small";
65
+ throw new Error("unsupported_model: notochord remains managed by the plugin");
66
+ }
35
67
  const bridge = new PluginBridge();
68
+ async function pinnedGeneration(args, pinned, signal) {
69
+ const destination = pinned ?? new PluginBridge();
70
+ const instance = bridge.getConnectedInstance();
71
+ if (!pinned && (!instance || !await destination.connect({ instanceId: instance.instanceId, product: instance.product })))
72
+ throw new Error("plugin_unavailable: selected instance disconnected");
73
+ try {
74
+ const result = await daemon.generate(args, signal);
75
+ if (args.cellId !== undefined) {
76
+ if (typeof result.wavPath !== "string" || !isAbsolute(result.wavPath))
77
+ throw new Error("invalid_result: generated WAV path missing");
78
+ await destination.send("loadSampleToCell", result.wavPath, args.cellId);
79
+ }
80
+ return result;
81
+ }
82
+ finally {
83
+ if (!pinned)
84
+ destination.disconnect();
85
+ }
86
+ }
36
87
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
37
88
  const cellModeChangeSeq = {};
38
89
  let lastSavedPresetName;
@@ -73,12 +124,96 @@ function structuredFromBridge(raw) {
73
124
  function error(msg) {
74
125
  return { content: [{ type: "text", text: msg }], isError: true };
75
126
  }
127
+ function daemonFailureCode(cause) {
128
+ try {
129
+ const detail = JSON.parse(cause instanceof Error ? cause.message : String(cause));
130
+ return isRecord(detail) && typeof detail.code === "string" ? detail.code : undefined;
131
+ }
132
+ catch {
133
+ return undefined;
134
+ }
135
+ }
136
+ const LOST_DAEMON_JOB = new Set(["job_expired", "not_found", "daemon_restarted", "unavailable"]);
137
+ function daemonFailure(cause, prefix = "") {
138
+ const message = cause instanceof Error ? cause.message : String(cause);
139
+ const result = error(prefix + message);
140
+ try {
141
+ const detail = JSON.parse(message);
142
+ if (isRecord(detail) && detail.ok === false && typeof detail.code === "string" && typeof detail.error === "string")
143
+ return { ...result, structuredContent: detail };
144
+ }
145
+ catch { }
146
+ return result;
147
+ }
76
148
  function isMfxManifest(manifest) {
77
149
  return (manifest.name?.toLowerCase() === "mfx" ||
78
150
  manifest.bundleId?.toLowerCase() === "map.audio.mfx" ||
79
151
  manifest.pluginCode?.toLowerCase() === "amfx");
80
152
  }
81
- const MFX_CELL_CONFIG_KEYS = new Set(["sidechainFrom"]);
153
+ function isMsqManifest(manifest) {
154
+ return (manifest.name?.toLowerCase() === "msq" ||
155
+ manifest.bundleId?.toLowerCase() === "map.audio.msq" ||
156
+ manifest.pluginCode?.toLowerCase() === "amsq");
157
+ }
158
+ const PACK_TOOL_NAMES = new Set(["list_packs", "install_pack", "get_pack_install_status", "uninstall_pack"]);
159
+ // MSQ sequences MIDI only. These tools drive a part of PAM it does not have, so on an
160
+ // MSQ instance they answer with the reason instead of writing state nothing reads.
161
+ const MSQ_MISSING_BY_TOOL = new Map([
162
+ ...[
163
+ "load_sample", "randomize_samples", "trim_sample", "reverse_sample", "normalize_sample", "gain_sample",
164
+ "analyze_sample_pitch", "repitch_sample", "analyze_sample_tempo", "get_sample_peaks", "preview_slice",
165
+ "resample_sample_to_bpm", "restore_original_sample", "build_sample_chain", "export_slice", "set_slices",
166
+ "preview_sample", "get_sample_usage", "repair_preset_samples", "set_drum_synth", "set_drum_synth_wave",
167
+ "set_lane_target",
168
+ ].map((tool) => [tool, "sampler"]),
169
+ ...["update_effect_config", "set_effects_enabled", "set_effect_order", "set_spectral_delay_curve"].map((tool) => [tool, "cell effects"]),
170
+ ...["set_master_effects", "get_master_analysis", "get_mix_analysis", "suggest_mix", "set_metronome"].map((tool) => [tool, "audio output"]),
171
+ ...["export_audio", "export_song", "song_mode", "live_resample"].map((tool) => [tool, "audio render or Song Mode"]),
172
+ ["dj_set", "DJ view"],
173
+ ...[
174
+ "arm_looper_track", "set_looper_track", "clear_looper_track", "set_looper_track_selection",
175
+ "start_looper_recording", "stop_looper_recording", "capture_looper_now", "load_audio_to_looper_track",
176
+ "request_looper_warp", "looper_settings", "looper_variation", "looper_variation_sync",
177
+ ].map((tool) => [tool, "looper"]),
178
+ ...[
179
+ "set_visual", "list_visuals", "delete_visual", "get_visual_data_url", "import_visual_folder", "get_video_state",
180
+ "video_command", "add_video_layer", "update_video_layer", "set_video_layer_source", "remove_video_layer",
181
+ "apply_video_layout", "add_video_fx", "set_video_fx", "remove_video_fx", "bind_video_param", "set_video_settings",
182
+ "vx_variation", "analyze_motion",
183
+ ].map((tool) => [tool, "VX video engine"]),
184
+ ...[
185
+ "list_plugins", "scan_plugins", "cancel_plugin_scan", "get_plugin_scan_status", "list_loaded_plugins",
186
+ "load_plugin", "unload_plugin", "set_plugin_param", "set_plugin_params", "set_plugin_bypass",
187
+ "skip_current_plugin_scan", "remove_plugin_from_blocklist",
188
+ ].map((tool) => [tool, "plugin host"]),
189
+ ...[
190
+ "download_model", "get_model_status", "load_model", "unload_model", "delete_model", "list_models",
191
+ "generate_sample", "generate_kit", "workbench_get_state", "workbench_set_state", "workbench_render",
192
+ "workbench_list_takes", "workbench_load_take", "workbench_undo", "workbench_reset_chain",
193
+ "workbench_sidecar_status", "ask_assistant",
194
+ ].map((tool) => [tool, "AI stack"]),
195
+ ...[...PACK_TOOL_NAMES].map((tool) => [tool, "preset pack store"]),
196
+ ]);
197
+ // Tools whose failures carry a code answer MSQ's refusal with one too.
198
+ const MSQ_MISSING_CODES = new Map([
199
+ ...[...PACK_TOOL_NAMES].map((tool) => [tool, "pack_unsupported_product"]),
200
+ ["dj_set", "dj_unsupported_product"],
201
+ ]);
202
+ const MFX_CELL_CONFIG_KEYS = new Set(["sidechainFrom", "cellmixFrom", "cellmixPreFx"]);
203
+ // MSQ cells are MIDI tracks: their note, channel, scale and output wiring, never sampler config.
204
+ const MSQ_CELL_CONFIG_KEYS = new Set([
205
+ "isPoly", "midiNote", "midiChannel", "midiIsolate", "armed", "polyRootNote", "scaleName", "scaleRoot",
206
+ "midiOutChannel", "sendToHost", "midiOutPort", "polyNoteMin", "polyNoteMax", ...CC_OUT_CONFIG_KEYS,
207
+ ...CC_LANES.map(ccLaneSlotField),
208
+ ]);
209
+ // A CC out slot's controller (cell config ccOutCc{K}); 120-127 are channel-mode messages.
210
+ const ccOutControllerSchema = (slot) => z
211
+ .number()
212
+ .int()
213
+ .min(-1)
214
+ .max(119)
215
+ .optional()
216
+ .describe(`Controller number CC out slot ${slot} sends, -1=Off. Its value is the parameter cell{N}_ccOut${slot} (0-127).`);
82
217
  async function loadManifestForProductGuard() {
83
218
  try {
84
219
  return await loadManifest();
@@ -115,7 +250,11 @@ function validateCellIdForManifest(cellId, manifest) {
115
250
  return true;
116
251
  }
117
252
  function readPackageVersion() {
118
- const override = IS_MFX_SERVER ? process.env.MFX_MCP_SERVER_VERSION?.trim() : undefined;
253
+ const override = IS_MFX_SERVER
254
+ ? process.env.MFX_MCP_SERVER_VERSION?.trim()
255
+ : IS_MSQ_SERVER
256
+ ? process.env.MSQ_MCP_SERVER_VERSION?.trim()
257
+ : undefined;
119
258
  if (override)
120
259
  return override;
121
260
  try {
@@ -162,12 +301,12 @@ async function requireBridge() {
162
301
  if (!ok) {
163
302
  const pin = process.env.PAM_BRIDGE_PRODUCT;
164
303
  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.") +
304
+ (PINNED_PRODUCT
305
+ ? `The launcher finds ${SERVER_PRODUCT.name} through the local bridge registry; the plugin records its actual port on startup.`
306
+ : "The bridge writes an authenticated registration under its data folder on startup. If PAM is already running with the bridge enabled, update PAM to a version that supports authenticated MCP connections.") +
168
307
  (pin
169
- ? IS_MFX_SERVER
170
- ? " The MFX launcher is product-pinned, but no matching instance was found."
308
+ ? PINNED_PRODUCT
309
+ ? ` The ${SERVER_PRODUCT.name} launcher is product-pinned, but no matching instance was found.`
171
310
  : ` Note: PAM_BRIDGE_PRODUCT pins this server to '${pin}' — no matching instance was found (connection_status lists what is running).`
172
311
  : ""));
173
312
  }
@@ -178,7 +317,7 @@ async function requireBridge() {
178
317
  * requireBridge() plus product-aware cell bounds, for tools that ADDRESS a cell.
179
318
  *
180
319
  * 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
320
+ * product is on the other end. MFX has 4 cells, so without this
182
321
  * an agent aims at cell 7 and gets a bridge-level failure or a silent no-op
183
322
  * instead of being told the cell does not exist. Read filters (get_state,
184
323
  * get_design_overview) deliberately stay unguarded — returning only the cells
@@ -358,12 +497,6 @@ const FULL_STATE_RESPONSE_LIMIT_CHARS = 24_000;
358
497
  // Preset listings grow with every installed pack; cap the unfiltered dump so it
359
498
  // can't crowd out the agent's context (`search` narrows it).
360
499
  const LIST_PRESETS_MAX = 400;
361
- const PATTERN_LANES = {
362
- note: "midiSequence",
363
- pitch: "pitchSequence",
364
- slice: "sliceSequence",
365
- stretch: "stretchSequence",
366
- };
367
500
  const LFO_DIVISIONS = [
368
501
  "16 bar", "8 bar", "4 bar", "2 bar", "1 bar",
369
502
  "1/2D", "1/2", "1/2T",
@@ -458,6 +591,44 @@ function formatNotModulatableError(paramId, index) {
458
591
  const suggestions = suggestParams(paramId, index, { modulatableOnly: true });
459
592
  return `Parameter '${paramId}' is not modulatable.${suggestions.length ? ` Try modulatable target ${suggestions.join(", ")}.` : ""}`;
460
593
  }
594
+ // Hosted-plugin params exist only while a plugin is loaded, so they resolve against
595
+ // the live instance; the id is the UI's `cell{N}_plugin_{slug(name)}_{index}`.
596
+ const HOSTED_MOD_TARGET_RX = /^cell(\d+)_plugin(Fx)?_[a-z0-9][a-z0-9_]*_(\d+)$/;
597
+ function hostedParamSlug(name) {
598
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 32) || "p";
599
+ }
600
+ function hostedModTargetId(cellId, param, effect = false) {
601
+ return `cell${cellId}_${effect ? "pluginFx" : "plugin"}_${hostedParamSlug(param.name || param.label || `p${param.index}`)}_${param.index}`;
602
+ }
603
+ /** A manifest param, or a hosted-plugin param checked against the loaded instance; a string is the error. */
604
+ async function resolveModulationTarget(paramId, index) {
605
+ const param = index.get(paramId);
606
+ if (param)
607
+ return param.isModulatable ? param : formatNotModulatableError(paramId, index);
608
+ const hosted = paramId.match(HOSTED_MOD_TARGET_RX);
609
+ if (!hosted)
610
+ return formatUnknownParamError(paramId, index, { modulatableOnly: true });
611
+ const cellId = Number(hosted[1]);
612
+ const hostedTarget = { paramId, name: paramId, displayName: paramId, min: 0, max: 1, defaultValue: 0, step: 0.001, skew: 1, isModulatable: true };
613
+ // list_loaded_plugins covers the instrument slot only; an FX id is taken as named.
614
+ if (hosted[2])
615
+ return hostedTarget;
616
+ let listed;
617
+ try {
618
+ listed = (await bridge.send("listLoadedPlugins"));
619
+ }
620
+ catch {
621
+ return `Hosted plugin parameters need a running PAM with the MCP bridge connected (${paramId}).`;
622
+ }
623
+ const plugin = listed?.loadedPlugins?.find((p) => Number(p.cellId) === cellId);
624
+ if (!plugin)
625
+ return `No hosted instrument plugin is loaded in cell ${cellId}; load one before targeting ${paramId}.`;
626
+ const entry = plugin.params?.find((p) => Number(p.index) === Number(hosted[3]));
627
+ if (!entry)
628
+ return `${plugin.name} in cell ${cellId} has no parameter at index ${hosted[3]}.`;
629
+ const expected = hostedModTargetId(cellId, { ...entry, index: Number(entry.index) });
630
+ return expected === paramId ? hostedTarget : `Unknown hosted parameter ${paramId}. Did you mean ${expected}?`;
631
+ }
461
632
  function formatRangeError(param, value) {
462
633
  const unit = param.suffix ? param.suffix : "unitless";
463
634
  const traits = [
@@ -557,6 +728,29 @@ function filterSectionResult(section, result, opts) {
557
728
  function countEvents(value) {
558
729
  return Array.isArray(value) ? value.length : 0;
559
730
  }
731
+ // What an MSQ cell sends: its output wiring, what plays it, and the CC it carries.
732
+ function msqCellSummary(id, cell, state) {
733
+ const prefix = `cell${id}_`;
734
+ const channel = Math.round(Number(cell.midiOutChannel ?? 0));
735
+ return {
736
+ cellId: id,
737
+ muted: state[`${prefix}mute`] === 1 || state[`${prefix}mute`] === true,
738
+ send: Number(cell.sendToHost ?? 1) > 0.5,
739
+ channel: channel >= 1 && channel <= 16 ? channel : `auto (${((id - 1) % 16) + 1})`,
740
+ port: Math.round(Number(cell.midiOutPort ?? 0)),
741
+ triggerNote: cell.midiNote,
742
+ poly: Number(cell.isPoly) > 0.5 ? { low: cell.polyNoteMin ?? 0, high: cell.polyNoteMax ?? 127 } : false,
743
+ player: describeCellPlayer(id, cell, state),
744
+ noteEvents: countEvents(state[`${prefix}${PATTERN_LANE_SUFFIX.note}`]),
745
+ ccLaneEvents: Object.fromEntries(CC_LANES.map((lane) => [lane, countEvents(state[`${prefix}${PATTERN_LANE_SUFFIX[lane]}`])])),
746
+ ccOut: CC_OUT_SLOTS.flatMap((slot) => {
747
+ const controller = Number(cell[`ccOutCc${slot}`]);
748
+ return Number.isInteger(controller) && controller >= 0 && controller <= 119
749
+ ? [{ slot, controller, value: state[`${prefix}ccOut${slot}`] }]
750
+ : [];
751
+ }),
752
+ };
753
+ }
560
754
  function stemName(path) {
561
755
  if (typeof path !== "string" || path.length === 0)
562
756
  return undefined;
@@ -571,11 +765,19 @@ const server = new McpServer({
571
765
  instructions: IS_MFX_SERVER
572
766
  ? "MFX is a four-cell modular FX router: cells are patched with cables and each cell runs its own effect chain. " +
573
767
  "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.",
768
+ : IS_MSQ_SERVER
769
+ ? "MSQ is an eight-cell MIDI sequencer: each cell sends its pattern or Player's notes and its CC out on its own " +
770
+ "MIDI channel to the host's instruments or, standalone, to MIDI devices. It makes no sound itself. Notes need a " +
771
+ "running transport; a CC out slot sends whenever its value moves. Treat tool annotations as authoritative and " +
772
+ "use select_instance when several MSQ instances are running."
773
+ : "PAM is an eight-cell sampler. " +
774
+ "Sequencer notes require a running transport; a cell's CC out slots send whenever their value moves. " +
775
+ "UI, video, and visual tools require the plugin " +
776
+ "window open; experimental plugin-host and AI tools require experimentalMode plus the matching " +
777
+ "feature flag. Treat tool annotations as authoritative and use select_instance when several " +
778
+ "products are running. A 'build does not support' error means update the plugin, not only this server. " +
779
+ "An MSQ instance sequences MIDI only: its cells send notes and CC to the host MIDI out, so sample, effect, " +
780
+ "master, looper, video, plugin-host and AI tools refuse it.",
579
781
  });
580
782
  // --- tool annotation policy ---------------------------------------------------
581
783
  // Single audit surface for MCP tool annotations. Every tool MUST have an entry;
@@ -638,7 +840,10 @@ const TOOL_ANNOTATIONS = {
638
840
  clear_modulations: DI,
639
841
  reset_patterns: DI,
640
842
  update_effect_config: WI,
843
+ set_effects_enabled: WI,
844
+ set_spectral_delay_curve: WI,
641
845
  update_cell_config: WI,
846
+ set_lane_target: WI,
642
847
  set_cell_mute: WI,
643
848
  set_master_effects: WI,
644
849
  add_param_seq: W,
@@ -648,6 +853,7 @@ const TOOL_ANNOTATIONS = {
648
853
  set_drum_synth_wave: WI,
649
854
  list_players: RO,
650
855
  set_player: WI,
856
+ keep_player: W,
651
857
  set_slices: WI,
652
858
  set_free_sequencer: WI,
653
859
  export_midi: W,
@@ -668,12 +874,18 @@ const TOOL_ANNOTATIONS = {
668
874
  trigger_midi_cc: W,
669
875
  trigger_midi_program_change: W,
670
876
  midi_recording: W,
877
+ session_take: W,
671
878
  // Presets / variations
672
879
  load_preset: DI, // replaces the working state (unsaved edits are lost)
673
880
  get_preset_load_status: RO,
674
881
  save_preset: WI,
675
882
  new_preset: DI,
676
883
  import_preset: WI,
884
+ // Preset packs
885
+ list_packs: RO,
886
+ get_pack_install_status: RO,
887
+ install_pack: WI,
888
+ uninstall_pack: DI, // deletes the pack's presets, samples and media
677
889
  export_preset: W,
678
890
  update_preset_metadata: WI,
679
891
  delete_preset: DI,
@@ -691,6 +903,8 @@ const TOOL_ANNOTATIONS = {
691
903
  trim_sample: W, // restore_original_sample recovers the pre-edit file
692
904
  reverse_sample: W,
693
905
  gain_sample: W,
906
+ repitch_sample: W,
907
+ analyze_sample_pitch: RO,
694
908
  normalize_sample: WI,
695
909
  analyze_sample_tempo: WI, // dispatches results into state
696
910
  resample_sample_to_bpm: WI,
@@ -713,6 +927,7 @@ const TOOL_ANNOTATIONS = {
713
927
  live_resample: D, // finalize replaces the destination cell; finalize:false discards the take
714
928
  // Song mode / export
715
929
  song_mode: W,
930
+ dj_set: DI,
716
931
  export_song: W,
717
932
  export_audio: W,
718
933
  // Settings / devices
@@ -795,6 +1010,8 @@ const MFX_TOOL_NAMES = new Set([
795
1010
  "update_modulation",
796
1011
  "clear_modulations",
797
1012
  "update_effect_config",
1013
+ "set_effects_enabled",
1014
+ "set_spectral_delay_curve",
798
1015
  "update_cell_config",
799
1016
  "set_cell_mute",
800
1017
  "set_master_effects",
@@ -826,7 +1043,6 @@ const MFX_TOOL_NAMES = new Set([
826
1043
  "cancel_queued_variation",
827
1044
  "update_variations",
828
1045
  "variation_hold",
829
- "export_audio",
830
1046
  "update_settings",
831
1047
  "get_audio_device_setup",
832
1048
  "set_audio_device",
@@ -847,13 +1063,13 @@ function mfxSurfaceText(value) {
847
1063
  .replaceAll(" Check corr in get_master_analysis after widening.", "")
848
1064
  .replaceAll(" (same constraint as the video tools)", "")
849
1065
  .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
1066
  .replaceAll("PAM-only", "sampler-product-only")
853
1067
  .replaceAll("PAM's", "MFX's")
854
1068
  .replaceAll("PAM", "MFX");
855
1069
  }
856
1070
  const MFX_MAX_CELL = 4;
1071
+ // Copy of syncUtils::kValidBarSyncIntervals (SyncUtils.h); check-parity compares them.
1072
+ const LOOPER_BAR_SYNC_INTERVALS = [0.25, 0.5, 1, 2, 4, 8];
857
1073
  // Zod wrappers are immutable, so narrowing a leaf means rebuilding the
858
1074
  // optional/default/array/union chain around it and re-attaching the description.
859
1075
  function rebuildSchema(schema, narrowLeaf) {
@@ -895,49 +1111,94 @@ const MFX_CELL_FIELDS = new Set([
895
1111
  "cellIds",
896
1112
  "selectedCellIds",
897
1113
  "sidechainFrom",
1114
+ "cellmixFrom",
898
1115
  ]);
899
1116
  // 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.
1117
+ // neither song_mode nor a looper tool is registered on this launcher, and the MIDI
1118
+ // settings page hides the looper, resample and VX mappings.
901
1119
  const MFX_ENUM_DROPS = {
902
1120
  add_modulation: { type: ["vary"] },
1121
+ update_modulation: { type: ["vary"] },
903
1122
  get_state: { section: ["vary", "loopers", "songMode"] },
904
- export_audio: { mode: ["song-mode"] },
1123
+ set_midi_mapping: { target: ["looper", "resample", "vxRecord", "looperVariation"] },
905
1124
  };
906
1125
  const MFX_KEPT_FIELDS = {
907
- update_cell_config: ["cellId", "sidechainFrom"],
1126
+ update_cell_config: ["cellId", "sidechainFrom", "cellmixFrom", "cellmixPreFx"],
908
1127
  };
909
1128
  const MFX_DROPPED_FIELDS = {
910
1129
  select_instance: ["product"],
911
1130
  // MFX cells are effect slots — there is no synth/sampler tab to switch.
912
1131
  capture_ui: ["instrumentView"],
1132
+ // MFX processes host input, which an offline render leaves silent.
1133
+ analyze_motion: ["bars"],
913
1134
  };
914
1135
  const MFX_TOOL_DESCRIPTIONS = {
915
1136
  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.",
1137
+ update_cell_config: "Set one MFX cell's sidechain or Cell Mix source. A non-null sidechain source also enables that cell's " +
1138
+ "compressor, and a Cell Mix source other than -1 enables its Cell Mix card.",
917
1139
  capture_ui: "Render MFX's plugin UI (#app_root) to a PNG via html2canvas and return the saved file path (read it to " +
918
1140
  "view the image). Optionally compose the view first: gridSize (1 or 4), mainView (CellGrid|MfxRouting|" +
919
1141
  "CtrlAll|Envelope|LFO|ParamSeq|Mixer|PresetBrowser; omit/null = cell grid). REQUIRES MFX's window OPEN — " +
920
1142
  "html2canvas needs the live DOM, there is no headless renderer. Beats screen-capture: deterministic, no " +
921
1143
  "cursor/screen control, exact UI bitmap. View arguments switch the live editor and the previous view is " +
922
1144
  "not restored.",
1145
+ copy_cell: "Copy a cell's manifest parameters and remap its LFO and envelope links onto another cell, overwriting the " +
1146
+ "target's. Not copied: parameter sequencers and the cell config block (sidechain source) — re-apply that with " +
1147
+ "update_cell_config.",
1148
+ set_midi_mapping: "Configure the global MIDI-controller mappings that trigger MFX actions: variation switch and preset change. " +
1149
+ "Set the enable flag, MIDI channel, CC number, source (Program Change or CC), or bar-sync for each target. " +
1150
+ "One field per call.",
923
1151
  };
924
1152
  const MFX_FIELD_DESCRIPTIONS = {
925
1153
  capture_ui: { gridSize: "Cell grid size to set before capture: 1 or 4." },
926
- export_audio: { bars: "Bars to render." },
1154
+ analyze_motion: {
1155
+ wavPath: "Absolute path to a WAV of material processed through MFX, such as a DAW bounce. Required on MFX.",
1156
+ },
1157
+ set_midi_mapping: {
1158
+ target: "Which action's mapping to configure.",
1159
+ field: "Field to set. 'source' (0 = Program Change, 1 = CC) applies to preset and variation; 'barSync' is variation-only.",
1160
+ },
1161
+ queue_variation: {
1162
+ fadeMs: "Crossfade duration in ms. Omit all transition options to inherit the native Bar Sync settings.",
1163
+ },
1164
+ update_modulation: {
1165
+ payload: "Update payload object, one property per call. lfo, envelope, macro and paramSeq all take " +
1166
+ "{ index, property, value }, where index is the 0-based position in that state array (get_state " +
1167
+ "section 'lfos' / 'envelopes' / 'macros' / 'paramSeqs') and property is the field name to overwrite. " +
1168
+ "paramSeqSequence takes { index, sequence: number[] }. freeSeqSequence takes { sequence: number[] } — the " +
1169
+ "master step contour every free-mode ParamSeq follows.",
1170
+ },
927
1171
  add_modulation: {
928
1172
  cellId: "Required for envelope: source cell driving the follower (1-4). This is not the target cell; target is inferred from paramId.",
929
1173
  },
930
1174
  };
931
1175
  // Text whose subject is a tool this launcher does not register.
932
1176
  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
1177
  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.");
1178
+ return value
1179
+ .replace("Fills the gap left by get_master_analysis (loudness) and get_mix_analysis (aggregate spectrum + onset " +
1180
+ "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.")
1181
+ .replace(/Pass wavPath to analyze an existing render[\s\S]*$/, "Pass wavPath to a WAV of material processed through MFX, such as a DAW bounce. MFX processes its " +
1182
+ "host's input, so there is no offline render to fall back on.");
1183
+ if (name === "reset_cell")
1184
+ return value.replace("Untouched: envelopes targeting this cell from another source (clear_modulations), pattern lanes (reset_patterns), " +
1185
+ "the cell config block (update_cell_config) and the loaded sample.", "Untouched: envelopes targeting this cell from another source (clear_modulations) and the cell config block " +
1186
+ "(update_cell_config).");
1187
+ if (name === "clear_modulations")
1188
+ return value.replace("LFO, envelope, and vary modulation links", "LFO and envelope modulation links")
1189
+ .replace("for envelopes/vary", "for envelopes");
1190
+ if (name === "update_modulation")
1191
+ return value.replace("LFO, Envelope, Vary, Macro", "LFO, Envelope, Macro");
1192
+ if (name === "get_default_locations")
1193
+ return value.replace(" Use these paths with list_samples to browse the sample library.", "");
1194
+ if (name === "save_variation")
1195
+ return value.replace("(load samples, set parameters, enable effects, add modulation)", "(set parameters, enable effects, patch cables, add modulation)");
1196
+ if (name === "queue_variation")
1197
+ return value.replace(" (song mode / an already-engaged queued-flip window only;", " (an already-engaged queued-flip window only;");
1198
+ if (name === "get_design_overview")
1199
+ return value.replace("cells, loaded samples/synth engines, enabled effects, pattern counts, modulation link counts, mixer values, " +
1200
+ "BPM, and master FX.", "cells, enabled effects and order, sidechain sources, modulation link counts, mixer values, cables, cable " +
1201
+ "levels, Combo assignments, band split, BPM, and transport state.");
941
1202
  if (name === "set_master_effects")
942
1203
  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
1204
  if (name === "set_parameters")
@@ -978,6 +1239,202 @@ function mfxToolConfig(name, config) {
978
1239
  ...(inputSchema ? { inputSchema } : {}),
979
1240
  };
980
1241
  }
1242
+ // MSQ's launcher advertises the pattern layer only. The sampler, effect, master, looper, video,
1243
+ // plugin-host, AI and MFX handlers live in this file too; discovery must not offer them.
1244
+ const MSQ_TOOL_NAMES = new Set([
1245
+ "get_parameter_info", "list_presets", "read_preset", "get_manifest_summary", "variation_distinctness",
1246
+ "get_state", "get_design_overview", "connection_status", "situation", "select_instance", "get_default_locations",
1247
+ "capture_ui", "set_view", "set_parameters", "trigger_midi", "trigger_midi_cc", "trigger_midi_program_change",
1248
+ "stop_cell", "reset_cell", "copy_cell", "update_cell_config", "set_cell_mute", "update_pattern", "reset_patterns",
1249
+ "randomize", "randomize_patterns", "list_players", "set_player", "keep_player", "add_modulation",
1250
+ "update_modulation", "clear_modulations", "add_param_seq", "update_param_seq", "remove_param_seq",
1251
+ "set_free_sequencer", "start_transport", "stop_transport", "full_stop_transport", "get_transport", "set_bpm",
1252
+ "configure_transport", "load_preset", "get_preset_load_status", "new_preset", "save_preset",
1253
+ "update_preset_metadata", "export_preset", "import_preset", "delete_preset", "set_preset_factory_status",
1254
+ "load_variation", "save_variation", "queue_variation", "cancel_queued_variation", "update_variations",
1255
+ "variation_hold", "export_midi", "midi_recording", "update_settings", "get_audio_device_setup",
1256
+ "set_audio_device", "set_midi_io", "set_midi_mapping",
1257
+ ]);
1258
+ // No audio means no envelope follower; the looper, Song Mode, resample and VX mappings have no MSQ surface.
1259
+ const MSQ_ENUM_DROPS = {
1260
+ update_pattern: { type: ["pitch", "slice", "stretch", "paramLane1", "paramLane2", "paramLane3", "paramLane4"] },
1261
+ add_modulation: { type: ["envelope"] },
1262
+ update_modulation: { type: ["envelope"] },
1263
+ get_state: { section: ["envelopes", "loopers", "songMode"] },
1264
+ set_midi_mapping: { target: ["looper", "resample", "vxRecord", "looperVariation"] },
1265
+ set_view: { fullscreen: ["effects"] },
1266
+ };
1267
+ const MSQ_KEPT_FIELDS = {
1268
+ update_cell_config: ["cellId", ...MSQ_CELL_CONFIG_KEYS],
1269
+ };
1270
+ const MSQ_DROPPED_FIELDS = {
1271
+ select_instance: ["product"],
1272
+ capture_ui: ["instrumentView"],
1273
+ get_parameter_info: ["effect"],
1274
+ add_modulation: ["pitch", "pitchDuck"],
1275
+ // Without a lane runtime every queued flip lands hard at its boundary: nothing to morph or crossfade.
1276
+ queue_variation: ["fadeMs", "morphBars", "morphMode", "morphCurve", "transition"],
1277
+ };
1278
+ const MSQ_FIELD_SCHEMAS = {
1279
+ randomize: {
1280
+ target: z
1281
+ .enum(["sequences", "lfos", "mods"])
1282
+ .default("sequences")
1283
+ .describe("What to randomize: the cells' note patterns, or the global LFOs / modulation links."),
1284
+ },
1285
+ set_view: {
1286
+ panel: z
1287
+ .enum(["pattern", "player", "midi"])
1288
+ .optional()
1289
+ .describe("Cell card tab to show, on cellId or else every cell: pattern, player or midi."),
1290
+ },
1291
+ };
1292
+ const MSQ_TOOL_DESCRIPTIONS = {
1293
+ select_instance: "Choose which running MSQ instance the live tools drive. Match by instanceId or port from connection_status. The launcher refuses other products' instances.",
1294
+ get_design_overview: "Return a compact snapshot of what each cell sends: Send, output channel (Auto = cell number), port, trigger " +
1295
+ "note, poly range, Player, note and CC-lane event counts, CC out slots and modulation link counts, plus BPM and " +
1296
+ "transport. Use this instead of full get_state.",
1297
+ randomize: "Trigger MSQ's randomizer. target:'sequences' writes new note patterns natively; 'lfos'/'mods' reshape the " +
1298
+ "global modulation and need MSQ's window open. Returns queued, not completed: poll get_state for the requested " +
1299
+ "changes before another randomize, edit, or save. A preset or variation load can discard pending work.",
1300
+ randomize_patterns: "Generate new note patterns with MSQ's native pattern engine from the genre, BPM and each cell's role. " +
1301
+ "Identical to randomize target:'sequences'. Returns queued: poll get_state for changed cellN_midiSequence " +
1302
+ "values before another randomize, edit, or save.",
1303
+ export_preset: "Export a preset as a zip file written to MSQ's Exports folder as <outputFolderName>.zip. An existing folder " +
1304
+ "or zip of that name is replaced, and presets belonging to a preset pack are refused.",
1305
+ update_settings: "Merge a partial settings delta into MSQ's plugin settings (the Settings panel state) and apply it live. " +
1306
+ "Returns the full merged settings object, so call it with {} to discover the keys; experimentalMode and " +
1307
+ "mcpBridgeEnabled are among them (mcpBridgeEnabled:false closes this bridge after it replies).",
1308
+ set_view: "Navigate MSQ's UI: switch the main view, focus a cell, or set the pad-grid size. Routed through the " +
1309
+ "WebView, so MSQ's window must be OPEN.",
1310
+ capture_ui: "Render MSQ's plugin UI (#app_root) to a PNG via html2canvas and return the saved file path (read it to view " +
1311
+ "the image). Optionally compose the view first: gridSize (1|4|8), mainView (CellGrid|CtrlAll|LFO|Vary|" +
1312
+ "ParamSeq|Mixer|PresetBrowser; omit/null = cell grid). REQUIRES MSQ's window OPEN: html2canvas needs the live " +
1313
+ "DOM. View arguments switch the live editor and the previous view is not restored.",
1314
+ copy_cell: "Copy a cell's manifest parameters and remap its LFO and vary links onto another cell, overwriting the " +
1315
+ "target's. A CC lane lands only on a target slot that is Off or on the same controller; CC out values and " +
1316
+ "controllers, output channel, Send and port stay the target's. Not copied: parameter sequencers and the cell " +
1317
+ "config block (poly, trigger note, scale), which update_cell_config sets.",
1318
+ reset_cell: "Reset a single cell's manifest parameters to their defaults. Untouched: pattern lanes (reset_patterns), the " +
1319
+ "cell config block (update_cell_config) and modulation targeting the cell from elsewhere (clear_modulations). " +
1320
+ "Does not touch other cells.",
1321
+ update_cell_config: "Set one MSQ cell's MIDI config: trigger note, poly and scale, output channel (0 = Auto), Send, standalone " +
1322
+ "port, the CC out slots' controllers and the CC lanes' slots.",
1323
+ get_manifest_summary: "Get a high-level summary of MSQ's capabilities: number of cells, modulators, and parameter counts.",
1324
+ add_modulation: "Add a modulation link (LFO or vary) to a parameter. The parameter must be modulatable (check with " +
1325
+ "get_parameter_info).",
1326
+ update_modulation: "Apply a delta to a modulation source: LFO, Vary, Macro, ParamSeq config, ParamSeq sequence data, or the Free " +
1327
+ "Sequencer master pattern. Use add_modulation to first attach a source to a target, then this tool to edit it.",
1328
+ clear_modulations: "Remove all LFO and vary modulation links targeting a cell's parameters (or driven by that cell, for vary). " +
1329
+ "Parameter sequencers survive; use remove_param_seq for those.",
1330
+ load_variation: "Request a switch to a saved variation slot (0-7), including parameters, patterns, players, cell wiring and " +
1331
+ "modulation. Empty slots are rejected. Verify currentVariation and a slot-specific value with get_state before " +
1332
+ "editing or saving.",
1333
+ new_preset: "Reset MSQ to its initial/default state, equivalent to creating a new empty preset. All cells, modulation and " +
1334
+ "sequences are cleared.",
1335
+ set_cell_mute: "Mute, unmute, solo, or unsolo a cell. A muted cell, or every other cell while one is soloed, sends no notes " +
1336
+ "or CC and releases the notes it holds. cell{N}_mute / cell{N}_solo are live-polled state variables.",
1337
+ read_preset: "Read an MSQ preset file and return its state: parameters, patterns, cell config and variations.",
1338
+ variation_distinctness: "Measure whether a preset's variation slots are actually different, and name what is identical about the " +
1339
+ "pairs that are not. Structural comparison of the saved slot snapshots: no render, no transport, no audition " +
1340
+ "loop, and no risk of the load_variation race. Each failing pair reports the dimensions it duplicates plus the " +
1341
+ "move that would fix it.",
1342
+ set_parameters: "Set one or more MSQ parameters. Values are validated against the manifest; get_parameter_info lists valid " +
1343
+ "IDs and ranges. Writing a macro_* id also enables that macro, matching the UI.",
1344
+ update_pattern: "Write one lane of a cell's pattern: the notes, or a CC lane (ccOut1-ccOut4, the timeline of that CC out " +
1345
+ "slot: each event sends its value on the slot's controller for its duration, then the slot returns to its knob).",
1346
+ save_variation: "Capture the current plugin state and save it to a variation slot (0-7). Use this to build presets with " +
1347
+ "multiple variations, e.g. a build-up from sparse to full. Set patterns, players, parameters and modulation " +
1348
+ "first, then snapshot them into a slot. The preset must have been saved once via save_preset, or pass a name " +
1349
+ "to create it.",
1350
+ situation: "One-call orientation snapshot: discovered instances, the connected instance's product/pid/executable (plus " +
1351
+ "build mtime and a dev-build heuristic when resolvable), server and manifest identity, active preset, " +
1352
+ "transport, and what each cell sends. Start a session here; get_design_overview reads deeper.",
1353
+ get_default_locations: "Get the standard folders MSQ knows (home, desktop, documents, music, downloads, presets), for an export_midi " +
1354
+ "outputDir or an import_preset path.",
1355
+ import_preset: "Import a preset from a zip file or folder into MSQ's preset library. Returns the relative path of the first " +
1356
+ "imported preset, or an empty string on failure. A name already in the library is never overwritten: the " +
1357
+ "import lands as <name>_1, <name>_2 and so on.",
1358
+ reset_patterns: "Clear every pattern lane of one cell: the note lane and the four CC lanes. CC out controllers stay.",
1359
+ queue_variation: "Schedule a global variation switch on the next bar boundary; for an immediate switch use load_variation. " +
1360
+ "MSQ switches at the boundary without a morph.",
1361
+ trigger_midi_cc: "Send a MIDI Control Change message into MSQ's MIDI router. Maps to any MIDI-learned parameter or variation " +
1362
+ "CC binding.",
1363
+ set_midi_mapping: "Configure the global MIDI-controller mappings that trigger MSQ actions: variation switch and preset change. " +
1364
+ "Set the enable flag, MIDI channel, CC number, source (Program Change or CC), or bar-sync for each target. " +
1365
+ "One field per call.",
1366
+ export_midi: "Write the cells' note patterns to a Standard MIDI File (type 1, 480 PPQ, tempo from the transport): one track " +
1367
+ "per cell, across `bars`, on the cell's output channel (Auto = cell number). Each sending CC out slot's value " +
1368
+ "opens the track at tick 0 and its CC lane's locks and releases follow on the lane's own clock; a cell with Send " +
1369
+ "off is left out, as are notes outside a poly cell's Low-High. A Player cell exports the cycles it generates, " +
1370
+ "rendered by MSQ itself; Listen responses are left out. Grid timing from base values: swing, probability, CTRL " +
1371
+ "macros and modulators are not applied. Nothing changes in MSQ. Default location is MSQ's Export folder.",
1372
+ };
1373
+ const MSQ_FIELD_DESCRIPTIONS = {
1374
+ get_parameter_info: { paramId: "Specific parameter ID (e.g. 'cell1_patternSwing'). Omit for all." },
1375
+ get_state: { keys: "Exact top-level state keys to return, e.g. ['cell1_patternSwing','cell1_midiSequence']." },
1376
+ add_modulation: {
1377
+ paramId: "Target parameter ID (e.g. 'cell1_ccOut1').",
1378
+ cellId: "Required for vary: the cell whose triggers drive it (1-8). The target cell is inferred from paramId.",
1379
+ },
1380
+ add_param_seq: { paramId: "Target parameter ID (e.g. 'cell1_ccOut1')." },
1381
+ update_modulation: {
1382
+ payload: "Update payload object, one property per call. lfo, vary, macro and paramSeq all take { index, property, " +
1383
+ "value }, where index is the 0-based position in that state array (get_state section 'lfos' / 'vary' / " +
1384
+ "'macros' / 'paramSeqs') and property is the field name to overwrite. paramSeqSequence takes { index, " +
1385
+ "sequence: number[] }. freeSeqSequence takes { sequence: number[] }, the master step contour every " +
1386
+ "free-mode ParamSeq follows.",
1387
+ },
1388
+ set_parameters: { parameters: "Map of paramId to value. Example: { 'cell1_ccOut1': 100, 'cell2_patternSwing': 0.2 }" },
1389
+ update_pattern: {
1390
+ sequence: "Array of sequence events. For 'note': [{time, note, duration, velocity, prob}]. For CC lanes: " +
1391
+ "[{time, value, duration, prob?}] (values 0-127; times and durations in beats).",
1392
+ },
1393
+ randomize: { genreHint: "Genre hint for the generated patterns (e.g. 'techno', 'hip-hop')." },
1394
+ save_preset: { image: "Relative path to a thumbnail image for the preset browser." },
1395
+ update_preset_metadata: { image: "Relative path to a thumbnail image for the preset browser." },
1396
+ update_cell_config: { polyRootNote: "Root note for poly pitch calculation." },
1397
+ variation_distinctness: { threshold: "Similarity above which a pair fails. Default 0.9." },
1398
+ set_midi_mapping: {
1399
+ target: "Which action's mapping to configure.",
1400
+ field: "Field to set. 'source' (0 = Program Change, 1 = CC) applies to preset and variation; 'barSync' is variation-only.",
1401
+ },
1402
+ set_view: {
1403
+ view: "Main view: cells, mixer, ctrl, lfo, vary, paramseq, presets.",
1404
+ fullscreen: "CTRL page rack to show alone (sequencer, players), or none.",
1405
+ },
1406
+ };
1407
+ function msqSurfaceText(value) {
1408
+ return value
1409
+ .replace(/PAM ?(?:≥|>=) ?\d+(?:\.\d+)*/g, "a current MSQ build")
1410
+ .replaceAll("a PAM build", "an MSQ build")
1411
+ .replaceAll("PAM", "MSQ");
1412
+ }
1413
+ function msqToolConfig(name, config) {
1414
+ const source = config;
1415
+ const kept = MSQ_KEPT_FIELDS[name];
1416
+ const droppedFields = MSQ_DROPPED_FIELDS[name] ?? [];
1417
+ const enumDrops = MSQ_ENUM_DROPS[name] ?? {};
1418
+ const fieldText = MSQ_FIELD_DESCRIPTIONS[name] ?? {};
1419
+ const fieldSchemas = MSQ_FIELD_SCHEMAS[name] ?? {};
1420
+ const inputSchema = source.inputSchema
1421
+ ? Object.fromEntries(Object.entries(source.inputSchema)
1422
+ .filter(([key]) => (!kept || kept.includes(key)) && !droppedFields.includes(key))
1423
+ .map(([key, schema]) => {
1424
+ if (fieldSchemas[key])
1425
+ return [key, fieldSchemas[key]];
1426
+ const narrowed = enumDrops[key] ? rebuildSchema(schema, dropEnumMembers(enumDrops[key])) : schema;
1427
+ const described = fieldText[key] ?? (narrowed.description ? msqSurfaceText(narrowed.description) : undefined);
1428
+ return [key, described ? narrowed.describe(described) : narrowed];
1429
+ }))
1430
+ : undefined;
1431
+ const description = MSQ_TOOL_DESCRIPTIONS[name] ?? (source.description ? msqSurfaceText(source.description) : undefined);
1432
+ return {
1433
+ ...source,
1434
+ ...(description ? { description } : {}),
1435
+ ...(inputSchema ? { inputSchema } : {}),
1436
+ };
1437
+ }
981
1438
  {
982
1439
  const origRegisterTool = server.registerTool.bind(server);
983
1440
  server.registerTool = ((name, config, handler) => {
@@ -985,8 +1442,19 @@ function mfxToolConfig(name, config) {
985
1442
  // tools therefore have no runtime handle to return.
986
1443
  if (IS_MFX_SERVER && !MFX_TOOL_NAMES.has(name))
987
1444
  return undefined;
988
- const productConfig = (IS_MFX_SERVER ? mfxToolConfig(name, config) : config);
989
- internalTools.set(name, { shape: productConfig.inputSchema, handler });
1445
+ if (IS_MSQ_SERVER && !MSQ_TOOL_NAMES.has(name))
1446
+ return undefined;
1447
+ const productConfig = (IS_MFX_SERVER ? mfxToolConfig(name, config) : IS_MSQ_SERVER ? msqToolConfig(name, config) : config);
1448
+ const missing = MSQ_MISSING_BY_TOOL.get(name);
1449
+ // Judged by the instance actually connected, not a manifest hint a previous session left behind.
1450
+ const productHandler = missing
1451
+ ? async (args, extra) => (await requireBridge()) === true && bridge.getConnectedInstance()?.product?.toUpperCase() === "MSQ"
1452
+ ? MSQ_MISSING_CODES.has(name)
1453
+ ? packFailure(MSQ_MISSING_CODES.get(name), `${name} is not available in MSQ: it has no ${missing}.`)
1454
+ : error(`${name} is not available in MSQ: it has no ${missing}.`)
1455
+ : handler(args, extra)
1456
+ : handler;
1457
+ internalTools.set(name, { shape: productConfig.inputSchema, handler: productHandler });
990
1458
  // Merge the central annotation policy. Inline annotations at the call site
991
1459
  // win per key; openWorldHint is false server-wide (local plugin only).
992
1460
  const cfg = productConfig;
@@ -994,7 +1462,7 @@ function mfxToolConfig(name, config) {
994
1462
  console.error(`[${SERVER_PRODUCT.command}] tool '${name}' has no TOOL_ANNOTATIONS entry — classify it (check-tool-surface fails on this)`);
995
1463
  }
996
1464
  cfg.annotations = { openWorldHint: false, ...TOOL_ANNOTATIONS[name], ...cfg.annotations };
997
- return origRegisterTool(name, productConfig, handler);
1465
+ return origRegisterTool(name, productConfig, productHandler);
998
1466
  });
999
1467
  }
1000
1468
  async function callInternalTool(name, args) {
@@ -1407,7 +1875,7 @@ server.registerTool("design_context", {
1407
1875
  filterCutoff: "Use real Hz values; move at least 1.5 octaves for obviously audible modulation.",
1408
1876
  delayDelayTime: "With delaySyncEnabled=1, normalized 0..1 maps to musical divisions; do not use cellN_delayTime.",
1409
1877
  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.",
1878
+ eqGains: "cellN_eq{Low,Mid,High}Gain are -60..12 dB. The -60 floor cuts the band fully, not just a tilt.",
1411
1879
  lfoRate: "LFO IDs are 1-based: lfo1_... through lfo6_.... Sync ON uses index/22.",
1412
1880
  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
1881
  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.",
@@ -1464,7 +1932,7 @@ server.registerTool("design_context", {
1464
1932
  })),
1465
1933
  variationSlots: "API is 0-based (0..7); PAM UI labels slots 1..8. Save/load variation index 0 = UI slot 1.",
1466
1934
  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; " +
1935
+ "RATCHET, PHASE, RIFF, MASK, PERC). cell{N}_playerDensity/Mutate/P1..P5 are modulatable params with macro_player* twins; " +
1468
1936
  "write them through set_player, never set_parameters alone, so the cell config stays in step.",
1469
1937
  modulationGuidance: "Primary movement should target per-cell params for distinct audible changes. Use macro_* for performance-level fan-out across cells. " +
1470
1938
  "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.",
@@ -1560,7 +2028,7 @@ server.registerTool("analyze_motion", {
1560
2028
  "a bar — the last one is what catches a bar-synced gate, tremolo or sidechain pump, whose bars are all " +
1561
2029
  "identical to each other.\n\n" +
1562
2030
  "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 " +
2031
+ "export first and leaves that render in PAM's Exports folder (its path is in the result). Digital silence is " +
1564
2032
  "reported as a RENDER FAILURE with the workaround, not as 'no motion' — offline export is known to produce " +
1565
2033
  "silence in the standalone build.",
1566
2034
  inputSchema: {
@@ -1572,35 +2040,34 @@ server.registerTool("analyze_motion", {
1572
2040
  bpm: z
1573
2041
  .number()
1574
2042
  .optional()
1575
- .describe("Tempo for the per-bar windows. Read from the transport when omitted."),
2043
+ .describe("wavPath only: tempo for the 4/4 per-bar windows (default: the transport's). A render uses its own tempo and meter."),
1576
2044
  },
1577
2045
  annotations: { readOnlyHint: false, destructiveHint: false },
1578
2046
  }, async ({ wavPath, bars, bpm }) => {
1579
2047
  let path = wavPath;
1580
2048
  let effectiveBpm = bpm;
2049
+ if (!path && IS_MFX_SERVER) {
2050
+ return error("Pass wavPath: MFX processes its host's input, so an offline render has no source to analyze.");
2051
+ }
2052
+ // Bar windows in seconds; a render reports its own, from the transport's tempo and meter.
2053
+ let barSeconds;
1581
2054
  if (!path) {
1582
2055
  const check = await requireBridge();
1583
2056
  if (check !== true)
1584
2057
  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
- });
2058
+ // The bars form of exportAudio is the one that reports its output path.
2059
+ const started = await bridge.send("exportAudio", { bars, bitDepth: 24 });
2060
+ const outputPath = isRecord(started) && typeof started.outputPath === "string" ? started.outputPath : undefined;
2061
+ if (!outputPath)
2062
+ return error("This build did not report the render's output path. Re-run with an explicit wavPath.");
1594
2063
  const outcome = await pollExportStatus();
1595
2064
  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`);
2065
+ return error(`Render failed before analysis could run (export ${outcome}).${exportFailureDetail()}`);
2066
+ path = outputPath;
2067
+ const renderedSeconds = isRecord(started) ? Number(started.durationInSeconds) : NaN;
2068
+ barSeconds = renderedSeconds > 0 ? renderedSeconds / bars : undefined;
2069
+ if (isRecord(started) && typeof started.bpm === "number")
2070
+ effectiveBpm = started.bpm;
1604
2071
  }
1605
2072
  else if (effectiveBpm === undefined) {
1606
2073
  const check = await requireBridge();
@@ -1612,7 +2079,7 @@ server.registerTool("analyze_motion", {
1612
2079
  }
1613
2080
  let metrics;
1614
2081
  try {
1615
- metrics = analyzeMotion(decodeWav(await readFile(path)), effectiveBpm);
2082
+ metrics = analyzeMotion(decodeWav(await readFile(path)), barSeconds ?? (effectiveBpm ? (60 / effectiveBpm) * 4 : undefined));
1616
2083
  }
1617
2084
  catch (e) {
1618
2085
  return error(`Could not analyze ${path}: ${e.message}`);
@@ -1745,69 +2212,6 @@ server.registerTool("variation_distinctness", {
1745
2212
  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.",
1746
2213
  });
1747
2214
  });
1748
- // Standard full update — graph dispatch + KV sync + parameter updates.
1749
- const KV_OPTS_STANDARD = {
1750
- shouldDispatch: true,
1751
- syncKVResources: true,
1752
- syncConfigResources: true,
1753
- resyncKVAfterDispatch: true,
1754
- updateParameters: true,
1755
- broadcastFollowers: true,
1756
- };
1757
- // Modulation update — same as standard but no follower broadcast.
1758
- const KV_OPTS_MOD = {
1759
- shouldDispatch: true,
1760
- syncKVResources: true,
1761
- syncConfigResources: true,
1762
- resyncKVAfterDispatch: true,
1763
- updateParameters: true,
1764
- broadcastFollowers: false,
1765
- };
1766
- // Pattern/sequencer update — KV-only, no graph rebuild, no param updates.
1767
- const KV_OPTS_PATTERN = {
1768
- shouldDispatch: true,
1769
- syncKVResources: true,
1770
- syncConfigResources: false,
1771
- skipFileLoadingIfUnchanged: true,
1772
- resyncKVAfterDispatch: false,
1773
- updateParameters: false,
1774
- broadcastFollowers: false,
1775
- };
1776
- // Master-chain config — KV + config-resource sync, NO graph dispatch. Every
1777
- // master key (comp incl. the full promoted Punch surface, tape, iron, clipper,
1778
- // limiter, EQ/width) is live-polled from master_config by its native node, so
1779
- // a rebuild here is pure dropout risk (the plan's no-rebuild contract).
1780
- // Mirrors interop.ts updateMasterConfig's options.
1781
- const KV_OPTS_MASTER_CONFIG = {
1782
- shouldDispatch: false,
1783
- syncKVResources: true,
1784
- syncConfigResources: true,
1785
- skipFileLoadingIfUnchanged: true,
1786
- resyncKVAfterDispatch: false,
1787
- updateParameters: true,
1788
- broadcastFollowers: false,
1789
- };
1790
- // Effect config toggle — lighter KV path, no graph dispatch.
1791
- const KV_OPTS_EFFECT_CONFIG = {
1792
- shouldDispatch: false,
1793
- syncKVResources: true,
1794
- syncConfigResources: true,
1795
- skipFileLoadingIfUnchanged: true,
1796
- resyncKVAfterDispatch: false,
1797
- updateParameters: true,
1798
- broadcastFollowers: true,
1799
- };
1800
- // Cell config update — KV-only, syncs cell config resources, no graph dispatch.
1801
- // Matches interop.ts updateCellConfig() options.
1802
- const KV_OPTS_CELL_CONFIG = {
1803
- shouldDispatch: false,
1804
- syncKVResources: true,
1805
- syncConfigResources: true,
1806
- skipFileLoadingIfUnchanged: true,
1807
- resyncKVAfterDispatch: false,
1808
- updateParameters: true,
1809
- broadcastFollowers: false,
1810
- };
1811
2215
  async function applyKv(delta, options, directNodeOps) {
1812
2216
  const payload = { delta, options };
1813
2217
  if (directNodeOps)
@@ -1890,6 +2294,29 @@ server.registerTool("get_design_overview", {
1890
2294
  vary: Array.isArray(state.vary) ? state.vary : [],
1891
2295
  paramSeq: Array.isArray(state.paramSeqs) ? state.paramSeqs : [],
1892
2296
  };
2297
+ if (isMsqManifest(manifest)) {
2298
+ const cells = ids.map((id) => {
2299
+ const rawCell = cellsObj[String(id)];
2300
+ const targets = new Set([`cell${id}_`]);
2301
+ return {
2302
+ ...msqCellSummary(id, isRecord(rawCell) ? rawCell : {}, state),
2303
+ modulation: Object.fromEntries(Object.entries(modArrays).map(([kind, arr]) => [kind, arr.filter((entry) => paramTargetsCell(entry, targets)).length])),
2304
+ };
2305
+ });
2306
+ return json({
2307
+ product: manifest.name,
2308
+ bpm: state.bpm ?? transport.bpm,
2309
+ playing: transport.isPlaying,
2310
+ totals: {
2311
+ cellsSending: cells.filter((cell) => cell.send && !cell.muted).length,
2312
+ playersRunning: cells.filter((cell) => cell.player).length,
2313
+ noteEvents: cells.reduce((sum, cell) => sum + cell.noteEvents, 0),
2314
+ ccSlots: cells.reduce((sum, cell) => sum + cell.ccOut.length, 0),
2315
+ modulationLinks: cells.reduce((sum, cell) => sum + Object.values(cell.modulation).reduce((subtotal, count) => subtotal + count, 0), 0),
2316
+ },
2317
+ cells,
2318
+ });
2319
+ }
1893
2320
  if (IS_MFX_SERVER) {
1894
2321
  const cells = ids.map((id) => {
1895
2322
  const prefix = `cell${id}_`;
@@ -1917,8 +2344,9 @@ server.registerTool("get_design_overview", {
1917
2344
  return json({
1918
2345
  product: manifest.name,
1919
2346
  bpm: state.bpm ?? transport.bpm,
1920
- playing: transport.playing,
2347
+ playing: transport.isPlaying,
1921
2348
  mix: state.mfx_mix,
2349
+ outputGain: state.mfx_outputGain,
1922
2350
  routing: state.mfx_routing,
1923
2351
  edgeGains: state.mfx_edge_gains,
1924
2352
  split: { lowHz: state.mfx_split_lo_hz, highHz: state.mfx_split_hi_hz },
@@ -1956,10 +2384,14 @@ server.registerTool("get_design_overview", {
1956
2384
  synthEngine: describeSynthEngine(state[`${prefix}synthEngine`]),
1957
2385
  player: describeCellPlayer(id, cellState, state),
1958
2386
  enabledEffects: effectNames.filter((effectName) => state[`${prefix}${effectName}Enabled`] === 1 || state[`${prefix}${effectName}Enabled`] === true),
1959
- patternEvents: Object.fromEntries(Object.entries(PATTERN_LANES).map(([lane, suffix]) => [
2387
+ patternEvents: Object.fromEntries(Object.entries(PATTERN_LANE_SUFFIX).map(([lane, suffix]) => [
1960
2388
  lane,
1961
2389
  countEvents(state[`${prefix}${suffix}`]),
1962
2390
  ])),
2391
+ laneTargets: Object.fromEntries(PARAM_LANES.flatMap((lane) => {
2392
+ const target = cellState[LANE_TARGET_FIELD[lane]];
2393
+ return typeof target === "string" && target ? [[lane, target]] : [];
2394
+ })),
1963
2395
  modulation,
1964
2396
  mixer: {
1965
2397
  volume: state[`${prefix}volume`],
@@ -1971,7 +2403,7 @@ server.registerTool("get_design_overview", {
1971
2403
  return json({
1972
2404
  product: manifest.name,
1973
2405
  bpm: state.bpm ?? transport.bpm,
1974
- playing: transport.playing,
2406
+ playing: transport.isPlaying,
1975
2407
  masterFx: {
1976
2408
  compressorEnabled: state.masterCompressorEnabled,
1977
2409
  limiterEnabled: state.masterLimiterEnabled,
@@ -2067,14 +2499,7 @@ server.registerTool("set_parameters", {
2067
2499
  }
2068
2500
  await bridge.send("applyUnifiedStateKvUpdate", {
2069
2501
  delta,
2070
- options: {
2071
- shouldDispatch: true,
2072
- syncKVResources: true,
2073
- syncConfigResources: true,
2074
- resyncKVAfterDispatch: true,
2075
- updateParameters: true,
2076
- broadcastFollowers: true,
2077
- },
2502
+ options: STATE_UPDATE_KV_OPTIONS,
2078
2503
  });
2079
2504
  return text(`Set ${Object.keys(parameters).length} parameter(s): ${Object.entries(parameters).map(([k, v]) => `${k}=${v}`).join(", ")}` +
2080
2505
  (comboTargetCount > 0 ? `; fanned out ${comboTargetCount} MFX combo target write(s)` : ""));
@@ -2106,9 +2531,25 @@ const MODEL_VARIANT = z
2106
2531
  .describe("Magenta only: which variant (base = 2.4B, needs ~M2 Max/M3 Pro+; small = 230M, default). Ignored for notochord/aigen (single model).");
2107
2532
  server.registerTool("download_model", {
2108
2533
  title: "Download AI Model",
2109
- description: "Start (or resume) downloading model weights for an AI engine. Async — returns immediately with download progress; poll get_model_status. aigen + notochord auto-load on completion; magenta needs a separate load_model. Requires the engine's experimental flags.",
2534
+ description: "Start (or resume) downloading model weights for an AI engine. Returns at once with the download's progress (in a pam mcp session, the daemon job's jobId); poll get_model_status. Downloads through PAM need the engine's experimental flags, and aigen + notochord then auto-load on completion; magenta and pam mcp session downloads need a separate load_model.",
2110
2535
  inputSchema: { engine: MODEL_ENGINE, model: MODEL_VARIANT },
2111
2536
  }, async ({ engine, model }) => {
2537
+ if (DAEMON_SESSION && engine !== "notochord") {
2538
+ try {
2539
+ const running = daemonDownloads.get(engine);
2540
+ if (running?.model === daemonModel(engine, model)) {
2541
+ const job = await daemon.downloadStatus(running).catch(() => undefined);
2542
+ if (job?.inFlight)
2543
+ return json({ ok: true, ...job });
2544
+ }
2545
+ const download = await daemon.startDownload(daemonModel(engine, model));
2546
+ daemonDownloads.set(engine, download);
2547
+ return json(download);
2548
+ }
2549
+ catch (cause) {
2550
+ return daemonFailure(cause);
2551
+ }
2552
+ }
2112
2553
  const check = await requireBridge();
2113
2554
  if (check !== true)
2114
2555
  return check;
@@ -2127,8 +2568,30 @@ server.registerTool("get_model_status", {
2127
2568
  version: z.string().optional(),
2128
2569
  state: z.string().optional(),
2129
2570
  progress: z.number().optional(),
2571
+ jobId: z.string().optional(),
2572
+ daemonEpoch: z.string().optional(),
2130
2573
  },
2131
2574
  }, async ({ engine }) => {
2575
+ const download = DAEMON_SESSION ? daemonDownloads.get(engine) : undefined;
2576
+ let job;
2577
+ if (download) {
2578
+ try {
2579
+ job = await daemon.downloadStatus(download);
2580
+ }
2581
+ catch (cause) {
2582
+ // A job pamd no longer knows stops shadowing the plugin's own status.
2583
+ if (!LOST_DAEMON_JOB.has(daemonFailureCode(cause) ?? ""))
2584
+ return daemonFailure(cause);
2585
+ daemonDownloads.delete(engine);
2586
+ }
2587
+ }
2588
+ if (job) {
2589
+ const plugin = bridge.isConnected() || await bridge.connect()
2590
+ ? await bridge.send("modelStatus", { engine }).then((raw) => typeof raw === "string" ? JSON.parse(raw) : raw).catch(() => undefined)
2591
+ : undefined;
2592
+ const live = isRecord(plugin) ? plugin : { engine };
2593
+ return structured(job.inFlight ? { ...live, ...job } : { ...job, ...live });
2594
+ }
2132
2595
  const check = await requireBridge();
2133
2596
  if (check !== true)
2134
2597
  return check;
@@ -2156,9 +2619,33 @@ server.registerTool("unload_model", {
2156
2619
  });
2157
2620
  server.registerTool("delete_model", {
2158
2621
  title: "Delete AI Model",
2159
- description: "Delete downloaded model weights from disk. Unloads first automatically. DESTRUCTIVE — re-download to restore. magenta delete is variant-agnostic (removes both variants + shared resources).",
2622
+ description: "Delete downloaded model weights from disk. Unloads first automatically. DESTRUCTIVE — re-download to restore. For magenta, model (small|base) removes that variant and keeps the resources the other installed variant shares; omit model to remove both variants and the shared resources.",
2160
2623
  inputSchema: { engine: MODEL_ENGINE, model: MODEL_VARIANT },
2161
- }, async ({ engine, model }) => {
2624
+ }, async ({ engine, model }, extra) => {
2625
+ if (DAEMON_SESSION && engine !== "notochord") {
2626
+ const models = engine === "magenta" && !model ? ["mrt2-small", "mrt2-base"] : [daemonModel(engine, model)];
2627
+ try {
2628
+ if (bridge.isConnected() || await bridge.connect()) {
2629
+ try {
2630
+ await bridge.send("modelDelete", { engine, model });
2631
+ }
2632
+ catch (cause) {
2633
+ const name = engine === "aigen" ? "AIGen" : "Magenta";
2634
+ if (!(cause instanceof Error) || !cause.message.startsWith(`[modelDelete] ${name} disabled —`))
2635
+ throw cause;
2636
+ }
2637
+ }
2638
+ const results = [];
2639
+ for (const id of models)
2640
+ results.push(await daemon.remove(id, extra?.signal));
2641
+ if (models.includes(daemonDownloads.get(engine)?.model ?? ""))
2642
+ daemonDownloads.delete(engine);
2643
+ return json(results);
2644
+ }
2645
+ catch (cause) {
2646
+ return daemonFailure(cause);
2647
+ }
2648
+ }
2162
2649
  const check = await requireBridge();
2163
2650
  if (check !== true)
2164
2651
  return check;
@@ -2176,22 +2663,18 @@ server.registerTool("list_models", {
2176
2663
  });
2177
2664
  server.registerTool("generate_sample", {
2178
2665
  title: "Generate Sample (AI)",
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.",
2666
+ description: "Generate a new audio sample from a text prompt via the on-device AI engine (Stable Audio 3 Small SFX, 2.71 GB; text-to-audio only — audio-to-audio lives in the Workbench tools). 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.",
2180
2667
  inputSchema: {
2181
2668
  category: z
2182
2669
  .enum(["kick", "snare", "hat", "perc", "bass", "synth", "pad", "fx", "loop"])
2183
2670
  .describe("Sound category — drives prompt template defaults."),
2184
2671
  prompt: z.string().describe("Text prompt describing the desired sound."),
2672
+ // The plugin renders Small SFX: OnnxAIGenEngine::kMinDurationSeconds and its Sa3Layout maximum.
2185
2673
  durationSeconds: z
2186
2674
  .number()
2187
- .min(0.1)
2188
- .max(47)
2189
- .describe("Output length in seconds (Stable Audio 3 Small SFX engine cap ≈ 47 s). For loops, pass bar-converted duration: bars * beatsPerBar * 60 / bpm."),
2190
- mode: z
2191
- .enum(["text", "inpaint", "continuation"])
2192
- .optional()
2193
- .default("text")
2194
- .describe("Generation mode. text = unconditional, inpaint = audio-to-audio with mask, continuation = extend existing audio."),
2675
+ .min(0.05)
2676
+ .max(120)
2677
+ .describe("Output length in seconds (the model's window is ~120 s). For loops, pass bar-converted duration: bars * beatsPerBar * 60 / bpm."),
2195
2678
  cellId: z
2196
2679
  .number()
2197
2680
  .int()
@@ -2199,28 +2682,32 @@ server.registerTool("generate_sample", {
2199
2682
  .max(8)
2200
2683
  .optional()
2201
2684
  .describe("If provided, load result directly into this cell."),
2202
- seed: z.number().int().optional().describe("Deterministic seed. Random if omitted."),
2203
- refAudioPath: z.string().optional().describe("Required for inpaint/continuation. Absolute path to a WAV."),
2204
- maskSpec: z
2205
- .string()
2685
+ seed: z
2686
+ .number()
2687
+ .int()
2688
+ .min(0)
2689
+ .max(4294967295)
2206
2690
  .optional()
2207
- .describe('Mask spec for inpaint mode: "tail:0.5" or "transient:0-0.05" or "full".'),
2691
+ .describe("Deterministic seed. Random if omitted or 0; the result's seed reproduces the take."),
2208
2692
  },
2209
- }, async (args) => {
2693
+ }, async (args, extra) => {
2210
2694
  const ok = await requireBridge();
2211
2695
  if (ok !== true)
2212
2696
  return ok;
2213
2697
  try {
2214
- const result = await bridge.send("aiGenerate", args);
2698
+ const cells = await requireBridgeForCells(args.cellId);
2699
+ if (cells !== true)
2700
+ return cells;
2701
+ const result = DAEMON_SESSION ? await pinnedGeneration(args, undefined, extra?.signal) : await bridge.send("aiGenerate", args);
2215
2702
  return json(result);
2216
2703
  }
2217
2704
  catch (e) {
2218
- return error(`AI generation failed: ${e.message}`);
2705
+ return DAEMON_SESSION ? daemonFailure(e, "AI generation failed: ") : error(`AI generation failed: ${e.message}`);
2219
2706
  }
2220
2707
  });
2221
2708
  server.registerTool("generate_kit", {
2222
2709
  title: "Generate Kit (AI)",
2223
- description: "Generate multiple AI samples in parallel (Stable Audio 3 Small SFX) and load them into a list of cells. Useful for building a coherent drum / instrument kit from a single high-level prompt. Requires the on-device model to be downloaded (Settings → Local models).",
2710
+ description: "Generate multiple AI samples one after another (Stable Audio 3 Small SFX) and load them into a list of cells. Useful for building a coherent drum / instrument kit from a single high-level prompt. Requires the on-device model to be downloaded (Settings → Local models).",
2224
2711
  inputSchema: {
2225
2712
  prompt: z.string().describe('High-level kit description (e.g. "dark trap drum kit", "lofi house").'),
2226
2713
  cells: z
@@ -2232,7 +2719,7 @@ server.registerTool("generate_kit", {
2232
2719
  .max(8)
2233
2720
  .describe("Cells to populate. Each gets one sample matching the role."),
2234
2721
  },
2235
- }, async (args) => {
2722
+ }, async (args, extra) => {
2236
2723
  const ok = await requireBridge();
2237
2724
  if (ok !== true)
2238
2725
  return ok;
@@ -2248,11 +2735,29 @@ server.registerTool("generate_kit", {
2248
2735
  durationSeconds: durationForRole(c.role),
2249
2736
  }));
2250
2737
  try {
2738
+ if (DAEMON_SESSION) {
2739
+ const check = await requireBridgeForCells(...cells.map((cell) => cell.id));
2740
+ if (check !== true)
2741
+ return check;
2742
+ const instance = bridge.getConnectedInstance();
2743
+ const destination = new PluginBridge();
2744
+ if (!instance || !await destination.connect({ instanceId: instance.instanceId, product: instance.product }))
2745
+ return error("plugin_unavailable: selected instance disconnected");
2746
+ try {
2747
+ const results = [];
2748
+ for (const request of requests)
2749
+ results.push(await pinnedGeneration(request, destination, extra?.signal));
2750
+ return json(results);
2751
+ }
2752
+ finally {
2753
+ destination.disconnect();
2754
+ }
2755
+ }
2251
2756
  const results = await bridge.send("aiGenerateKit", { requests });
2252
2757
  return json(results);
2253
2758
  }
2254
2759
  catch (e) {
2255
- return error(`Kit generation failed: ${e.message}`);
2760
+ return DAEMON_SESSION ? daemonFailure(e, "Kit generation failed: ") : error(`Kit generation failed: ${e.message}`);
2256
2761
  }
2257
2762
  });
2258
2763
  // --- AI Audio Workbench (Phase A, dev-gated) ---------------------------------
@@ -2319,7 +2824,7 @@ server.registerTool("workbench_set_state", {
2319
2824
  server.registerTool("workbench_render", {
2320
2825
  title: "Workbench: Render",
2321
2826
  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.",
2827
+ "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). Waits until done (up to ~10 min: pipeline rebuilds + multi-region inpaints render sequentially). Returns the take { id, wavPath, prompt, seed, sigma, maskRegions, createdAt, sourceCellId, sourcePath, durationSeconds, activeSourcePath, chainAdvanced, error? }. Source resolution: sourcePath override > activeSourcePath (chain head) > state.sourceCellId's sample file (a sourceCellId override renders that cell's sample). A successful render of the canvas's own source ADVANCES the chain (activeSourcePath = new take's wavPath) — step back with workbench_undo or workbench_reset_chain; renders with a sourcePath/sourceCellId override, or that finished after the chain moved, leave the head alone (chainAdvanced: false).",
2323
2828
  inputSchema: {
2324
2829
  ...WORKBENCH_STATE_FIELDS,
2325
2830
  sourcePath: z.string().optional()
@@ -2506,17 +3011,16 @@ server.registerTool("add_modulation", {
2506
3011
  },
2507
3012
  }, async ({ type, paramId, cellId, lfoIndex, strength, bipolar, pitch, pitchDuck }) => {
2508
3013
  const index = await getParameterIndex();
2509
- const param = index.get(paramId);
2510
- if (!param)
2511
- return error(formatUnknownParamError(paramId, index, { modulatableOnly: true }));
2512
- if (!param.isModulatable)
2513
- return error(formatNotModulatableError(paramId, index));
3014
+ const param = await resolveModulationTarget(paramId, index);
3015
+ if (typeof param === "string")
3016
+ return error(param);
2514
3017
  const check = await requireBridgeForCells(cellId);
2515
3018
  if (check !== true)
2516
3019
  return check;
2517
3020
  // Compute effect-enabled params to auto-enable disabled effects (mirrors UI behavior)
2518
3021
  const effectEnableParams = {};
2519
- if (param.effect && param.cell) {
3022
+ // Only cells have per-effect flags; native enables a macro target itself (MCPBridge::handleAddModulation).
3023
+ if (param.effect && /^cell\d+$/.test(param.cell ?? "")) {
2520
3024
  effectEnableParams[`${param.cell}_${param.effect}Enabled`] = 1;
2521
3025
  }
2522
3026
  if (type === "lfo") {
@@ -2536,34 +3040,42 @@ server.registerTool("add_modulation", {
2536
3040
  }
2537
3041
  return text(`Added ${type} modulation to ${paramId} (strength: ${strength}%).`);
2538
3042
  });
3043
+ /** The full parameter rows (ranges, isModulatable) a lane target is checked against. */
3044
+ async function laneTargetParameters() {
3045
+ return loadManifest()
3046
+ .then((manifest) => manifest.parameters)
3047
+ .catch(() => []);
3048
+ }
3049
+ /** A cell's param-lane target suffix ("" = none). */
3050
+ async function laneTargetOf(cellId, lane) {
3051
+ const cells = await bridge.send("getState", "cells").catch(() => undefined);
3052
+ const cell = isRecord(cells) ? cells[String(cellId)] : undefined;
3053
+ const target = isRecord(cell) ? cell[LANE_TARGET_FIELD[lane]] : undefined;
3054
+ return typeof target === "string" ? target : "";
3055
+ }
2539
3056
  server.registerTool("update_pattern", {
2540
3057
  title: "Update Pattern",
2541
- description: "Write a note, pitch, slice, or stretch sequence to a cell's pattern sequencer.",
3058
+ description: "Write one lane of a cell's pattern: the notes, a value lane (pitch, slice, stretch), a CC lane (ccOut1-ccOut4, " +
3059
+ "the timeline of that CC out slot: each event sends its value on the slot's controller for its duration, then the " +
3060
+ "slot returns to its knob) or a PAM param lane (paramLane1-paramLane4: each event sets the parameter the lane " +
3061
+ "targets for its duration, in that parameter's units). MSQ has note and CC lanes; MFX has none.",
2542
3062
  inputSchema: {
2543
3063
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
2544
- type: z
2545
- .enum(["note", "pitch", "slice", "stretch"])
2546
- .describe("Pattern lane type."),
3064
+ type: z.enum(PATTERN_LANE_IDS).describe("Pattern lane."),
2547
3065
  sequence: z
2548
3066
  .array(z.any())
2549
3067
  .describe("Array of sequence events. For 'note': [{time, note, duration, velocity, prob}]. " +
2550
- "For 'pitch'/'slice'/'stretch': [{time, value, duration}]."),
3068
+ "For value and CC lanes: [{time, value, duration, prob?}] (CC values 0-127; times and durations in beats)."),
2551
3069
  },
2552
3070
  }, async ({ cellId, type, sequence }) => {
2553
3071
  const check = await requireBridgeForCells(cellId);
2554
3072
  if (check !== true)
2555
3073
  return check;
2556
- // Map pattern type to state key suffix
2557
- const keyMap = {
2558
- note: "midiSequence",
2559
- midi: "midiSequence",
2560
- pitch: "pitchSequence",
2561
- slice: "sliceSequence",
2562
- stretch: "stretchSequence",
2563
- };
2564
- const suffix = keyMap[type];
2565
- if (!suffix)
2566
- return error(`Unknown pattern type: ${type}`);
3074
+ const manifest = await loadManifestForProductGuard();
3075
+ if (manifest && !productHasLane(cellId, type, manifest.stateVariables)) {
3076
+ return error(`${manifest.name ?? "This product"} has no ${type} lane.`);
3077
+ }
3078
+ const suffix = PATTERN_LANE_SUFFIX[type];
2567
3079
  // For note sequences, fill in required defaults that PAM's sequencer expects
2568
3080
  let normalizedSequence = sequence;
2569
3081
  if (suffix === "midiSequence") {
@@ -2586,22 +3098,79 @@ server.registerTool("update_pattern", {
2586
3098
  value: evt.value ?? evt.velocity ?? 127,
2587
3099
  }));
2588
3100
  }
3101
+ if (isCcLane(type))
3102
+ normalizedSequence = normalizeCcLaneEvents(sequence);
3103
+ if (isParamLane(type)) {
3104
+ const target = await laneTargetOf(cellId, type);
3105
+ if (!target)
3106
+ return error(`Cell ${cellId}'s ${type} has no target parameter: pick one with set_lane_target first.`);
3107
+ const resolved = resolveLaneTarget(cellId, target, await laneTargetParameters());
3108
+ if ("reason" in resolved)
3109
+ return error(`The ${type} lane targets ${target}: ${resolved.reason}.`);
3110
+ normalizedSequence = normalizeTargetLaneEvents(sequence, resolved.param);
3111
+ }
2589
3112
  const stateKey = `cell${cellId}_${suffix}`;
2590
3113
  await bridge.send("applyUnifiedStateKvUpdate", {
2591
3114
  delta: { [stateKey]: normalizedSequence, __forceRender__: true },
2592
- options: {
2593
- shouldDispatch: true,
2594
- syncKVResources: true,
2595
- syncConfigResources: false,
2596
- skipFileLoadingIfUnchanged: true,
2597
- resyncKVAfterDispatch: false,
2598
- updateParameters: false,
2599
- broadcastFollowers: false,
2600
- },
3115
+ options: PATTERN_EDIT_KV_OPTIONS,
2601
3116
  directNodeOps: [{ key: stateKey, property: "sequence", value: normalizedSequence }],
2602
3117
  });
2603
3118
  return text(`Updated ${type} pattern for cell ${cellId} (${normalizedSequence.length} events).`);
2604
3119
  });
3120
+ server.registerTool("set_lane_target", {
3121
+ title: "Set Lane Target",
3122
+ description: "Point one of a PAM cell's four param lanes (paramLane1-paramLane4) at a modulatable parameter of the same cell " +
3123
+ "(a suffix such as 'filterCutoff'), or clear it with null. Each lane event then sets that parameter for its " +
3124
+ "duration; outside events the parameter follows its knob and modulation. Each param lane loops at its own " +
3125
+ "clock, cell{N}_patternLengthParam{K} and cell{N}_patternSpeedParam{K} (set_parameters; 4 beats at 1x by default), " +
3126
+ "while swing and direction follow the note lane; copy the note lane's length and speed to match it. The lane is cleared (its values meant the old target); another param lane already on that " +
3127
+ "parameter is cleared too. Write the lane with update_pattern in the target's units. For a free-running step " +
3128
+ "contour that adds to the knob, use a ParamSeq instead.",
3129
+ inputSchema: {
3130
+ cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
3131
+ lane: z.enum(PARAM_LANES).describe("Param lane to point."),
3132
+ param: z.string().min(1).nullable().describe("Target parameter suffix (no cellN_ prefix), or null for none."),
3133
+ },
3134
+ }, async ({ cellId, lane, param }) => {
3135
+ const check = await requireBridgeForCells(cellId);
3136
+ if (check !== true)
3137
+ return check;
3138
+ const manifest = await loadManifestForProductGuard();
3139
+ if (manifest && !productHasLane(cellId, lane, manifest.stateVariables)) {
3140
+ return error(`${manifest.name ?? "This product"} has no ${lane} lane.`);
3141
+ }
3142
+ let targetParam = null;
3143
+ if (param !== null) {
3144
+ const resolved = resolveLaneTarget(cellId, param, await laneTargetParameters());
3145
+ if ("reason" in resolved)
3146
+ return error(`Cannot target ${param}: ${resolved.reason}.`);
3147
+ targetParam = resolved.param;
3148
+ }
3149
+ const cells = await bridge.send("getState", "cells").catch(() => undefined);
3150
+ const cell = isRecord(cells) ? cells[String(cellId)] : undefined;
3151
+ const target = param ?? "";
3152
+ const current = isRecord(cell) && typeof cell[LANE_TARGET_FIELD[lane]] === "string" ? cell[LANE_TARGET_FIELD[lane]] : "";
3153
+ if (current === target)
3154
+ return text(`Cell ${cellId} ${lane} already ${target ? `→ ${target}` : "has no target"}; nothing changed.`);
3155
+ const cellDelta = { [LANE_TARGET_FIELD[lane]]: target };
3156
+ const delta = { [`cell${cellId}_${PATTERN_LANE_SUFFIX[lane]}`]: [] };
3157
+ const reverted = [];
3158
+ for (const sibling of PARAM_LANES) {
3159
+ if (sibling === lane || !target || !isRecord(cell) || cell[LANE_TARGET_FIELD[sibling]] !== target)
3160
+ continue;
3161
+ cellDelta[LANE_TARGET_FIELD[sibling]] = "";
3162
+ delta[`cell${cellId}_${PATTERN_LANE_SUFFIX[sibling]}`] = [];
3163
+ reverted.push(sibling);
3164
+ }
3165
+ delta.cells = { [cellId]: cellDelta };
3166
+ // Same as add_modulation: a lane on a bypassed effect's param turns the effect on.
3167
+ if (targetParam?.effect && targetParam.cell)
3168
+ delta[`${targetParam.cell}_${targetParam.effect}Enabled`] = 1;
3169
+ await applyKv(delta, STATE_UPDATE_KV_OPTIONS);
3170
+ const where = target ? `→ ${target}` : "target cleared";
3171
+ const note = reverted.length ? ` ${reverted.join(", ")} lost that target and was cleared.` : "";
3172
+ return text(`Cell ${cellId} ${lane} ${where}; lane cleared.${note}`);
3173
+ });
2605
3174
  server.registerTool("randomize", {
2606
3175
  title: "Randomize",
2607
3176
  description: "Trigger PAM's randomization engine to generate random sounds and patterns. " +
@@ -2723,10 +3292,8 @@ server.registerTool("randomize_samples", {
2723
3292
  let pool = allowedSamplePaths;
2724
3293
  let diversifyNote = "";
2725
3294
  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);
3295
+ const locations = await defaultLocationPaths();
3296
+ const roots = ["User Samples", "Factory Samples"].flatMap((name) => locations.get(name) ?? []);
2730
3297
  if (roots.length) {
2731
3298
  const byFolder = await collectLibraryByFolder(roots);
2732
3299
  const spread = spreadAcrossFolders(byFolder, diversify, seed ?? 0);
@@ -2827,12 +3394,14 @@ server.registerTool("full_stop_transport", {
2827
3394
  });
2828
3395
  server.registerTool("get_transport", {
2829
3396
  title: "Get Transport",
2830
- description: "Get current transport state (BPM, playing, position).",
3397
+ description: "Get current transport state (BPM, playing, position, meter).",
2831
3398
  inputSchema: {},
2832
3399
  outputSchema: {
2833
3400
  bpm: z.number().optional(),
2834
3401
  isPlaying: z.boolean().optional(),
2835
3402
  ppqPosition: z.number().optional(),
3403
+ timeSigNumerator: z.number().optional(),
3404
+ timeSigDenominator: z.number().optional(),
2836
3405
  },
2837
3406
  annotations: { readOnlyHint: true },
2838
3407
  }, async () => {
@@ -2864,8 +3433,10 @@ server.registerTool("get_mix_analysis", {
2864
3433
  title: "Get Per-Cell Mix Analysis",
2865
3434
  description: "Per-cell stem measurements from a faster-than-realtime offline bounce: LUFS (i/s/roll), LRA, " +
2866
3435
  "true/sample peak (tp/sp), PSR/PLR crest, stereo correlation, onset density, and a 25-band Bark " +
2867
- "spectrum per cell, plus the full-mix master block. Pass refresh:true to run a new analysis " +
2868
- "(renders each sounding cell soloed from bar 0 — takes a few seconds; audio does NOT need to be " +
3436
+ "spectrum per cell, plus the full-mix master block. Each cell also carries coact: {otherCellId: " +
3437
+ "fraction of this cell's sounding time the other cell sounds too}. Pass refresh:true to run a new " +
3438
+ "analysis (one in-context render from bar 0; each cell is measured at its post-fader output before " +
3439
+ "the master chain, so muted cells read silent — takes a few seconds; audio does NOT need to be " +
2869
3440
  "playing). Cached result otherwise. Requires a PAM build with Mix Assist support.",
2870
3441
  inputSchema: {
2871
3442
  refresh: z.boolean().optional().describe("Run a fresh analysis instead of returning the cached one."),
@@ -2986,7 +3557,7 @@ server.registerTool("suggest_mix", {
2986
3557
  ? { balanceParked: true, note: "Auto Mix AUTO owns the balance — balance scope was skipped." }
2987
3558
  : {};
2988
3559
  if (apply && suggestion.moves.length > 0) {
2989
- await applyKv(suggestion.flat, KV_OPTS_STANDARD);
3560
+ await applyKv(suggestion.flat, STATE_UPDATE_KV_OPTIONS);
2990
3561
  if (!analysis.live)
2991
3562
  lastAppliedMixEpoch = analysis.epoch;
2992
3563
  return json({ ...suggestion, ...parkedNote, applied: true });
@@ -3070,7 +3641,7 @@ server.registerTool("save_preset", {
3070
3641
  }
3071
3642
  const metadata = { name, factory: false };
3072
3643
  if (path !== undefined)
3073
- metadata.path = path;
3644
+ metadata.path = nativePresetPath(path);
3074
3645
  for (const [k, v] of Object.entries(extras)) {
3075
3646
  if (v !== undefined)
3076
3647
  metadata[k] = v;
@@ -3169,7 +3740,7 @@ server.registerTool("save_variation", {
3169
3740
  let resultSuffix = "";
3170
3741
  const destinationPath = normalizePresetLookupPath(diskPath);
3171
3742
  if (!name && !destinationPath.startsWith("Factory/")) {
3172
- metadata.path = destinationPath;
3743
+ metadata.path = nativePresetPath(destinationPath);
3173
3744
  }
3174
3745
  else if (sourcePath.startsWith("Factory/")) {
3175
3746
  // Cannot write back to factory location; savePreset will create User/{name}.preset
@@ -3280,6 +3851,9 @@ server.registerTool("trim_sample", {
3280
3851
  const check = await requireBridgeForCells(cellId);
3281
3852
  if (check !== true)
3282
3853
  return check;
3854
+ const loaded = await requireCellSample(cellId);
3855
+ if (loaded !== true)
3856
+ return loaded;
3283
3857
  await bridge.send("trimSampleForCell", cellId);
3284
3858
  return text(`Trimmed sample in cell ${cellId}.`);
3285
3859
  });
@@ -3293,6 +3867,9 @@ server.registerTool("reverse_sample", {
3293
3867
  const check = await requireBridgeForCells(cellId);
3294
3868
  if (check !== true)
3295
3869
  return check;
3870
+ const loaded = await requireCellSample(cellId);
3871
+ if (loaded !== true)
3872
+ return loaded;
3296
3873
  await bridge.send("reverseSampleForCell", cellId);
3297
3874
  return text(`Reversed sample in cell ${cellId}.`);
3298
3875
  });
@@ -3316,15 +3893,15 @@ server.registerTool("connection_status", {
3316
3893
  annotations: { readOnlyHint: true },
3317
3894
  }, async () => {
3318
3895
  const discoveredInstances = await bridge.discover();
3319
- const instances = IS_MFX_SERVER
3320
- ? discoveredInstances.filter((instance) => instance.product?.trim().toLowerCase() === "mfx")
3896
+ const instances = PINNED_PRODUCT
3897
+ ? discoveredInstances.filter((instance) => instance.product?.trim().toLowerCase() === PINNED_PRODUCT)
3321
3898
  : discoveredInstances;
3322
3899
  if (instances.length === 0) {
3323
3900
  return structured({
3324
3901
  instances: [],
3325
3902
  connected: false,
3326
3903
  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. ` +
3904
+ `start ${SERVER_PRODUCT.name} (standalone or in a DAW) and enable it in Settings → Integrations → MCP Bridge. ` +
3328
3905
  "Offline tools (get_parameter_info, list_presets, read_preset) work without a connection.",
3329
3906
  });
3330
3907
  }
@@ -3342,6 +3919,54 @@ server.registerTool("connection_status", {
3342
3919
  : "Found instances but could not connect. The plugin may have shut down.",
3343
3920
  });
3344
3921
  });
3922
+ // A plugin instance's pid is its DAW, so `ps` names the host; the plugin's own
3923
+ // binary is one of the host's mapped files (e.g. PAM.vst3/Contents/MacOS/PAM).
3924
+ function hostedPluginBinaries(pid, product) {
3925
+ const pluginBinary = new RegExp(`/${product}\\.(?:vst3|component|clap)/Contents/MacOS/${product}$`, "i");
3926
+ try {
3927
+ const mapped = execFileSync("lsof", ["-a", "-p", String(pid), "-d", "txt", "-Fn"], {
3928
+ encoding: "utf8",
3929
+ timeout: 5000,
3930
+ });
3931
+ return [...new Set(mapped.split("\n").filter((line) => line.startsWith("n")).map((line) => line.slice(1)))]
3932
+ .filter((path) => pluginBinary.test(path));
3933
+ }
3934
+ catch {
3935
+ return [];
3936
+ }
3937
+ }
3938
+ // build.js restages plugins with size and mtime intact, so an identical file in
3939
+ // the checkout's build tree identifies a development build.
3940
+ function matchesRepoBuild(binary, projectRoot) {
3941
+ const entries = (path) => {
3942
+ try {
3943
+ return readdirSync(path);
3944
+ }
3945
+ catch {
3946
+ return [];
3947
+ }
3948
+ };
3949
+ try {
3950
+ const target = statSync(binary);
3951
+ const bundlePath = binary.split("/").slice(-4).join("/");
3952
+ const darwin = join(resolve(projectRoot), "build", "Darwin");
3953
+ for (const config of entries(darwin))
3954
+ for (const artefacts of entries(join(darwin, config)).filter((name) => name.endsWith("_artefacts")))
3955
+ for (const mode of entries(join(darwin, config, artefacts)))
3956
+ for (const format of entries(join(darwin, config, artefacts, mode))) {
3957
+ const candidate = join(darwin, config, artefacts, mode, format, bundlePath);
3958
+ if (!existsSync(candidate))
3959
+ continue;
3960
+ const stats = statSync(candidate);
3961
+ if (stats.size === target.size && Math.abs(stats.mtimeMs - target.mtimeMs) < 1000)
3962
+ return true;
3963
+ }
3964
+ }
3965
+ catch {
3966
+ /* binary or build tree unreadable */
3967
+ }
3968
+ return false;
3969
+ }
3345
3970
  server.registerTool("situation", {
3346
3971
  title: "Situation",
3347
3972
  description: "One-call orientation snapshot: discovered instances, the connected instance's product/pid/executable " +
@@ -3359,21 +3984,34 @@ server.registerTool("situation", {
3359
3984
  let build;
3360
3985
  if (instance?.pid) {
3361
3986
  try {
3362
- const executable = execFileSync("ps", ["-o", "comm=", "-p", String(instance.pid)], {
3987
+ const processExecutable = execFileSync("ps", ["-o", "comm=", "-p", String(instance.pid)], {
3363
3988
  encoding: "utf8",
3364
3989
  }).trim();
3365
- if (executable) {
3366
- build = {
3367
- executable,
3368
- looksLikeDevBuild: executable.includes("/build/") && !executable.startsWith("/Applications/"),
3369
- };
3990
+ if (processExecutable) {
3991
+ const product = instance.product ?? SERVER_PRODUCT.name;
3992
+ const projectRoot = process.env.PAM_PROJECT_ROOT;
3993
+ const hosted = basename(processExecutable).toLowerCase() !== product.toLowerCase();
3994
+ const pluginBinaries = hosted ? hostedPluginBinaries(instance.pid, product) : [];
3995
+ const executable = hosted ? (pluginBinaries.length === 1 ? pluginBinaries[0] : undefined) : processExecutable;
3996
+ build = hosted ? { host: processExecutable } : {};
3997
+ if (pluginBinaries.length > 1)
3998
+ build.pluginBinaries = pluginBinaries;
3999
+ if (executable) {
4000
+ build.executable = executable;
4001
+ if (!hosted) {
4002
+ build.looksLikeDevBuild = executable.includes("/build/") && !executable.startsWith("/Applications/");
4003
+ }
4004
+ else if (projectRoot) {
4005
+ build.looksLikeDevBuild = matchesRepoBuild(executable, projectRoot);
4006
+ }
4007
+ }
3370
4008
  try {
3371
- build.executableMtime = statSync(executable).mtime.toISOString();
4009
+ if (executable)
4010
+ build.executableMtime = statSync(executable).mtime.toISOString();
3372
4011
  }
3373
4012
  catch {
3374
4013
  /* executable path unreadable from this process */
3375
4014
  }
3376
- const projectRoot = process.env.PAM_PROJECT_ROOT;
3377
4015
  if (projectRoot && typeof build.executableMtime === "string") {
3378
4016
  try {
3379
4017
  const headIso = execFileSync("git", ["-C", resolve(projectRoot), "log", "-1", "--format=%cI"], { encoding: "utf8" }).trim();
@@ -3409,10 +4047,23 @@ server.registerTool("situation", {
3409
4047
  .filter((cell) => typeof cell === "string" && /^cell\d+$/.test(cell))
3410
4048
  .map((cell) => Number(cell.replace("cell", ""))))].sort((a, b) => a - b);
3411
4049
  const cellsObj = isRecord(state.cells) ? state.cells : {};
4050
+ const msq = isMsqManifest(manifest);
3412
4051
  const cells = ids.map((id) => {
3413
4052
  const prefix = `cell${id}_`;
3414
4053
  const rawCellState = cellsObj[String(id)];
3415
4054
  const cellState = isRecord(rawCellState) ? rawCellState : {};
4055
+ if (msq) {
4056
+ const summary = msqCellSummary(id, cellState, state);
4057
+ return {
4058
+ cellId: id,
4059
+ muted: summary.muted,
4060
+ send: summary.send,
4061
+ channel: summary.channel,
4062
+ player: summary.player?.name,
4063
+ noteEvents: summary.noteEvents,
4064
+ ccSlots: summary.ccOut.length,
4065
+ };
4066
+ }
3416
4067
  const samplePath = cellState.filePath ??
3417
4068
  cellState.samplePath ??
3418
4069
  state[`${prefix}filePath`] ??
@@ -3429,9 +4080,9 @@ server.registerTool("situation", {
3429
4080
  live = {
3430
4081
  activePreset: state.activePresetPath,
3431
4082
  bpm: state.bpm ?? transport.bpm,
3432
- playing: transport.playing,
4083
+ playing: transport.isPlaying,
3433
4084
  cells,
3434
- ...(IS_MFX_SERVER ? { mix: state.mfx_mix, routing: state.mfx_routing } : {}),
4085
+ ...(IS_MFX_SERVER ? { mix: state.mfx_mix, outputGain: state.mfx_outputGain, routing: state.mfx_routing } : {}),
3435
4086
  };
3436
4087
  }
3437
4088
  }
@@ -3450,13 +4101,13 @@ server.registerTool("situation", {
3450
4101
  });
3451
4102
  server.registerTool("select_instance", {
3452
4103
  title: "Select Plugin Instance",
3453
- description: "Choose which running plugin instance (PAM / CELL1 / MFX) the live tools drive when more than " +
4104
+ description: "Choose which running plugin instance (PAM / MFX / MSQ) the live tools drive when more than " +
3454
4105
  "one is running. Match by product name, instanceId, or port — connection_status lists them. " +
3455
4106
  "Reconnects if currently connected to a different instance, and points offline parameter " +
3456
4107
  "validation at the selected product's manifest. The PAM_BRIDGE_PRODUCT env var pins the same " +
3457
4108
  "choice at server launch.",
3458
4109
  inputSchema: {
3459
- product: z.string().optional().describe("Product name: PAM, CELL1, or MFX (case-insensitive)."),
4110
+ product: z.string().optional().describe("Product name: PAM, MFX, or MSQ (case-insensitive)."),
3460
4111
  instanceId: z.string().optional().describe("Exact instanceId from connection_status."),
3461
4112
  port: z.number().int().optional().describe("Exact TCP port from connection_status."),
3462
4113
  },
@@ -3469,22 +4120,23 @@ server.registerTool("select_instance", {
3469
4120
  if (!product && !instanceId && port === undefined) {
3470
4121
  return error("Pass at least one of product, instanceId, or port. Call connection_status to list running instances.");
3471
4122
  }
3472
- if (IS_MFX_SERVER && product && product.trim().toLowerCase() !== "mfx") {
3473
- return error("The mfx-mcp launcher can select MFX instances only.");
4123
+ if (PINNED_PRODUCT && product && product.trim().toLowerCase() !== PINNED_PRODUCT) {
4124
+ return error(`The ${SERVER_PRODUCT.command} launcher can select ${SERVER_PRODUCT.name} instances only.`);
3474
4125
  }
3475
- const targetProduct = IS_MFX_SERVER ? "MFX" : product;
4126
+ const targetProduct = PINNED_PRODUCT ? SERVER_PRODUCT.name : product;
3476
4127
  const ok = await bridge.connect({ product: targetProduct, instanceId, port });
3477
4128
  if (!ok) {
3478
4129
  const discovered = await bridge.discover();
3479
- const instances = IS_MFX_SERVER
3480
- ? discovered.filter((instance) => instance.product?.trim().toLowerCase() === "mfx")
4130
+ const instances = PINNED_PRODUCT
4131
+ ? discovered.filter((instance) => instance.product?.trim().toLowerCase() === PINNED_PRODUCT)
3481
4132
  : discovered;
3482
4133
  const listing = instances.length
3483
4134
  ? ` Running instances: ${instances
3484
4135
  .map((i) => `${i.product} (port ${i.port}, id ${i.instanceId})`)
3485
4136
  .join("; ")}.`
3486
4137
  : " No running instances found.";
3487
- return error(`No running instance matches that selector.${listing}`);
4138
+ // pamd keys selection recovery on this code (PluginSession selectorNotFound); keep it with any rewording.
4139
+ return { ...error(`No running instance matches that selector.${listing}`), _meta: { code: "instance_not_found" } };
3488
4140
  }
3489
4141
  syncProductHint();
3490
4142
  return structured({
@@ -3537,6 +4189,31 @@ async function searchSampleLibrary(rootPath, term, audioOnly) {
3537
4189
  }
3538
4190
  // Walk the library and return audio files grouped by their parent folder.
3539
4191
  // Bounded like searchSampleLibrary — this runs inside a tool call, not a job.
4192
+ // The sample-edit actions answer "ok" before their async edit runs, even on an empty cell.
4193
+ async function requireCellSample(cellId, field = "filePath") {
4194
+ const cells = await bridge.send("getState", "cells").catch(() => undefined);
4195
+ if (!isRecord(cells))
4196
+ return true;
4197
+ const cell = cells[String(cellId)];
4198
+ const path = isRecord(cell) ? cell[field] : undefined;
4199
+ if (typeof path === "string" && path.length > 0)
4200
+ return true;
4201
+ return error(field === "filePath" ? `Cell ${cellId} has no sample loaded.` : `Cell ${cellId} has no original sample to restore.`);
4202
+ }
4203
+ // getDefaultLocations returns [{name, path, icon}]; the names are fixed in FileHandler.
4204
+ async function defaultLocationPaths() {
4205
+ const locations = await bridge.send("getDefaultLocations").catch(() => undefined);
4206
+ const entries = Array.isArray(locations) ? locations : [];
4207
+ return new Map(entries
4208
+ .filter((entry) => isRecord(entry) && typeof entry.name === "string" && typeof entry.path === "string")
4209
+ .map((entry) => [entry.name, entry.path]));
4210
+ }
4211
+ // Native exports to <base>/Export beside <base>/Samples ("Plugin Samples"); check-parity pins both.
4212
+ // MSQ keeps no sample folder, so its Export resolves from the product's own base folder.
4213
+ async function exportsFolder() {
4214
+ const pluginSamples = (await defaultLocationPaths()).get("Plugin Samples");
4215
+ return pluginSamples ? join(dirname(pluginSamples), "Export") : productExportDir();
4216
+ }
3540
4217
  async function collectLibraryByFolder(roots, maxDirReads = 120) {
3541
4218
  const byFolder = new Map();
3542
4219
  const queue = roots.map((path) => ({ path, depth: 0 }));
@@ -3721,17 +4398,21 @@ server.registerTool("export_preset", {
3721
4398
  const check = await requireBridge();
3722
4399
  if (check !== true)
3723
4400
  return check;
3724
- await bridge.send("exportPresetWithSamples", presetPath, outputFolderName);
3725
- return text(`Exported preset to: ${outputFolderName}`);
4401
+ const result = await bridge.send("exportPresetWithSamples", nativePresetPath(presetPath), outputFolderName);
4402
+ if (result === false) {
4403
+ return error(`Export refused for ${presetPath}: the preset is missing, belongs to a preset pack, or the folder name is invalid.`);
4404
+ }
4405
+ return text(`Exported preset to: ${outputFolderName}.zip in ${SERVER_PRODUCT.name}'s Exports folder`);
3726
4406
  });
3727
4407
  server.registerTool("import_preset", {
3728
4408
  title: "Import Preset",
3729
4409
  description: "Import a preset from a zip file or folder into PAM's preset library. Returns the relative path of the " +
3730
4410
  "first imported preset, or an empty string on failure. Referenced samples are copied into the user sample " +
3731
4411
  "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.",
4412
+ "import lands as <name>_1, <name>_2 and so on. A downloaded .pampack file installs as a whole pack and " +
4413
+ "returns true on success.",
3733
4414
  inputSchema: {
3734
- path: z.string().describe("Absolute path to the preset zip file or folder to import."),
4415
+ path: z.string().describe("Absolute path to the preset zip file, folder or .pampack to import."),
3735
4416
  },
3736
4417
  }, async ({ path: presetPath }) => {
3737
4418
  const check = await requireBridge();
@@ -3740,6 +4421,158 @@ server.registerTool("import_preset", {
3740
4421
  const result = await bridge.send("importPreset", presetPath);
3741
4422
  return json(result);
3742
4423
  });
4424
+ const PACK_JOB_TERMINAL = new Set(["succeeded", "failed", "cancelled"]);
4425
+ const PACK_INSTALL_WAIT_MS = 15 * 60_000;
4426
+ function isPackFailure(value) {
4427
+ return isRecord(value) && value.ok === false && typeof value.code === "string" && typeof value.error === "string";
4428
+ }
4429
+ // The failure shape pamd's CLI-backed tools return: structuredContent {ok:false, code, error}.
4430
+ function packFailure(code, message) {
4431
+ const detail = { ok: false, code, error: message };
4432
+ return {
4433
+ content: [{ type: "text", text: JSON.stringify(detail, null, 2) }],
4434
+ structuredContent: detail,
4435
+ isError: true,
4436
+ };
4437
+ }
4438
+ function parseBridgeValue(raw) {
4439
+ if (typeof raw !== "string")
4440
+ return raw;
4441
+ try {
4442
+ return JSON.parse(raw);
4443
+ }
4444
+ catch {
4445
+ return raw;
4446
+ }
4447
+ }
4448
+ /** The connected instance's product; only a build that reports none falls back to the manifest. */
4449
+ async function connectedProduct() {
4450
+ const reported = bridge.getConnectedInstance()?.product?.toUpperCase();
4451
+ if (reported)
4452
+ return reported;
4453
+ const manifest = await loadManifestForProductGuard();
4454
+ if (!manifest)
4455
+ return undefined;
4456
+ return isMfxManifest(manifest) ? "MFX" : isMsqManifest(manifest) ? "MSQ" : "PAM";
4457
+ }
4458
+ async function requirePackStore() {
4459
+ const check = await requireBridge();
4460
+ if (check !== true)
4461
+ return check;
4462
+ return (await connectedProduct()) === "MFX"
4463
+ ? packFailure("pack_unsupported_product", "MFX has no preset pack store; packs are a PAM feature.")
4464
+ : true;
4465
+ }
4466
+ const PACK_ENTRY = z.object({}).passthrough();
4467
+ const PACK_JOB_OUTPUT = {
4468
+ jobId: z.string().optional(),
4469
+ daemonEpoch: z.string().optional(),
4470
+ state: z.string().optional(),
4471
+ progress: z.number().optional(),
4472
+ result: z.object({ packs: z.array(PACK_ENTRY).optional() }).passthrough().optional(),
4473
+ error: z.object({ code: z.string().optional(), message: z.string().optional() }).passthrough().optional(),
4474
+ };
4475
+ server.registerTool("list_packs", {
4476
+ title: "List Preset Packs",
4477
+ description: "List the map.audio preset pack catalog with this machine's state: installed and catalog version, whether an " +
4478
+ "update is available, whether the licence owns the pack or it is free with PAM, price and download size. " +
4479
+ "Installed packs the catalog no longer lists appear with listed:false.",
4480
+ inputSchema: {},
4481
+ outputSchema: { packs: z.array(PACK_ENTRY) },
4482
+ }, async () => {
4483
+ const check = await requirePackStore();
4484
+ if (check !== true)
4485
+ return check;
4486
+ const listing = parseBridgeValue(await bridge.send("listPacks"));
4487
+ if (isPackFailure(listing))
4488
+ return packFailure(listing.code, listing.error);
4489
+ return structured(listing);
4490
+ });
4491
+ server.registerTool("install_pack", {
4492
+ title: "Install Preset Packs",
4493
+ description: "Download and install preset packs the licence owns (free packs need an active PAM licence), one at a time. " +
4494
+ "Pass packIds, or allOwned:true for every owned pack that is missing or outdated. Waits for the installs and " +
4495
+ "returns {packs:[{packId, version, state}]}; detach:true returns the job at once. A requestKey makes a retry " +
4496
+ "return the original job instead of downloading again. An unowned pack fails with pack_license_required.",
4497
+ inputSchema: {
4498
+ packIds: z.array(z.string().min(1)).min(1).max(64).optional().describe("Pack ids from the catalog, e.g. 'eko'."),
4499
+ allOwned: z.boolean().optional().describe("Install every owned pack that is missing or outdated."),
4500
+ requestKey: z.string().max(128).optional().describe("Stable caller key; reuse it to recover the same job."),
4501
+ detach: z.boolean().optional().describe("Return the job without waiting for the installs."),
4502
+ },
4503
+ outputSchema: { ...PACK_JOB_OUTPUT, packs: z.array(PACK_ENTRY).optional(), note: z.string().optional() },
4504
+ }, async ({ packIds, allOwned, requestKey, detach }) => {
4505
+ if (Boolean(packIds?.length) === Boolean(allOwned))
4506
+ return packFailure("usage", "Pass packIds or allOwned:true, not both.");
4507
+ const check = await requirePackStore();
4508
+ if (check !== true)
4509
+ return check;
4510
+ const started = parseBridgeValue(await bridge.send("installPack", {
4511
+ ...(packIds?.length ? { packIds } : {}),
4512
+ ...(allOwned ? { allOwned: true } : {}),
4513
+ ...(requestKey ? { requestKey } : {}),
4514
+ }));
4515
+ if (isPackFailure(started))
4516
+ return packFailure(started.code, started.error);
4517
+ if (!isRecord(started) || typeof started.jobId !== "string")
4518
+ return error(`Unexpected installPack reply: ${JSON.stringify(started)}`);
4519
+ if (detach)
4520
+ return structured(started);
4521
+ // The job lives in the instance that accepted it; a select_instance meanwhile must not redirect the polls.
4522
+ const accepting = bridge.getConnectedInstance()?.instanceId;
4523
+ let job = started;
4524
+ const deadline = Date.now() + PACK_INSTALL_WAIT_MS;
4525
+ while (!PACK_JOB_TERMINAL.has(String(job.state)) && Date.now() < deadline) {
4526
+ await new Promise((r) => setTimeout(r, 1000));
4527
+ if (bridge.getConnectedInstance()?.instanceId !== accepting)
4528
+ return structured({
4529
+ ...job,
4530
+ note: `Instance ${accepting} is still installing; select it again, then poll get_pack_install_status with this jobId.`,
4531
+ });
4532
+ const next = parseBridgeValue(await bridge.send("getPackInstallStatus", started.jobId));
4533
+ if (isPackFailure(next))
4534
+ return packFailure(next.code, next.error);
4535
+ if (isRecord(next))
4536
+ job = next;
4537
+ }
4538
+ if (job.state === "succeeded")
4539
+ return structured(isRecord(job.result) ? job.result : { packs: [] });
4540
+ if (job.state === "failed" || job.state === "cancelled") {
4541
+ const failure = isRecord(job.error) ? job.error : {};
4542
+ return packFailure(typeof failure.code === "string" ? failure.code : job.state === "cancelled" ? "cancelled" : "pack_install_failed", typeof failure.message === "string" ? failure.message : `Pack install job ${started.jobId} ${String(job.state)}.`);
4543
+ }
4544
+ return structured({ ...job, note: "Still installing; poll get_pack_install_status with this jobId." });
4545
+ });
4546
+ server.registerTool("get_pack_install_status", {
4547
+ title: "Get Pack Install Status",
4548
+ description: "Report a pack install job: state (queued, running, succeeded, failed, cancelled), progress 0..1, per-pack " +
4549
+ "state in result.packs, and error {code, message} when it failed.",
4550
+ inputSchema: { jobId: z.string().min(1).describe("The jobId install_pack returned.") },
4551
+ outputSchema: PACK_JOB_OUTPUT,
4552
+ }, async ({ jobId }) => {
4553
+ const check = await requirePackStore();
4554
+ if (check !== true)
4555
+ return check;
4556
+ const job = parseBridgeValue(await bridge.send("getPackInstallStatus", jobId));
4557
+ if (isPackFailure(job))
4558
+ return packFailure(job.code, job.error);
4559
+ return structured(job);
4560
+ });
4561
+ server.registerTool("uninstall_pack", {
4562
+ title: "Uninstall Preset Pack",
4563
+ description: "Remove an installed preset pack: every preset carrying its pack id, its samples and media, and its installed " +
4564
+ "record. Fails with pack_not_installed when nothing of the pack is on disk or recorded.",
4565
+ inputSchema: { packId: z.string().min(1).describe("Pack id from the catalog, e.g. 'eko'.") },
4566
+ outputSchema: { packId: z.string(), removed: z.boolean() },
4567
+ }, async ({ packId }) => {
4568
+ const check = await requirePackStore();
4569
+ if (check !== true)
4570
+ return check;
4571
+ const removed = parseBridgeValue(await bridge.send("uninstallPack", packId));
4572
+ if (isPackFailure(removed))
4573
+ return packFailure(removed.code, removed.error);
4574
+ return structured(removed);
4575
+ });
3743
4576
  server.registerTool("set_visual", {
3744
4577
  title: "Set Visual",
3745
4578
  description: "Save an image file as a preset visual/thumbnail in PAM's media library. " +
@@ -3862,9 +4695,6 @@ const VIDEO_LAYER_FX = "hueRotate, glow, audioReact, vibration, chromaticAberrat
3862
4695
  "polarSlitScan, cellVoronoi, spectralZones, paletteLut, asciiDither, pixelSort, " +
3863
4696
  "cameraShake, blur, cymatics, lineJitter, crt, vhs";
3864
4697
  // Output/global FX chain ids (videoStore, DEFAULT_VIDEO_FX_CONFIG).
3865
- const VIDEO_OUTPUT_FX = "lines, barrelBlur, brightnessContrast, edge, glow, shake, rgbShift, halftone, mirror, " +
3866
- "patches, pixelate, rolls, feedback, warp, datamosh, slicer, positionXY, speed, " +
3867
- "cellVoronoi, polarSlitScan, spectralZones, audioReact";
3868
4698
  // Binding source vocabulary (sourceKey string form OR a ModSource object).
3869
4699
  const VIDEO_BIND_SOURCES = "Pass `source` as a sourceKey string: " +
3870
4700
  "`g:<id>` global (level, bandLow, bandMid, bandHigh, transient, transientLow, " +
@@ -3901,14 +4731,15 @@ async function resolveNewId(requestId, beforeIds, pick, ackIdKey, timeoutMs = 25
3901
4731
  if (ack && ack.requestId === requestId) {
3902
4732
  const minted = ack.result?.[ackIdKey];
3903
4733
  if (typeof minted === "string" && minted.length > 0)
3904
- return minted;
3905
- return null; // acked but failed / no id — don't misattribute via the diff
4734
+ return { id: minted };
4735
+ // An acked command never falls back to the diff, which could misattribute a concurrent edit.
4736
+ return ack.ok === false ? { error: ack.error || "the command failed" } : {};
3906
4737
  }
3907
4738
  const found = pick(s).find((id) => !beforeIds.has(id));
3908
4739
  if (found)
3909
- return found;
4740
+ return { id: found };
3910
4741
  }
3911
- return null;
4742
+ return {};
3912
4743
  }
3913
4744
  server.registerTool("get_video_state", {
3914
4745
  title: "Get Video State",
@@ -3951,7 +4782,7 @@ server.registerTool("capture_ui", {
3951
4782
  .optional()
3952
4783
  .describe("Switch all cells to the synth or sampler tab before capture."),
3953
4784
  settleMs: z.number().optional().describe("Delay (ms) after view changes before rasterizing. Default 350."),
3954
- timeoutMs: z.number().optional().default(8000).describe("Max ms to wait for the WebView to return the image."),
4785
+ timeoutMs: z.number().optional().default(30000).describe("Max ms to wait for the WebView to return the image. Default 30000."),
3955
4786
  },
3956
4787
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
3957
4788
  }, async ({ gridSize, mainView, instrumentView, settleMs, timeoutMs }) => {
@@ -3970,15 +4801,15 @@ server.registerTool("capture_ui", {
3970
4801
  setup.settleMs = settleMs;
3971
4802
  // Forward is fire-and-forget; poll the C++ cache until the WebView posts the PNG back.
3972
4803
  await bridge.send("captureUi", setup);
3973
- const deadline = Date.now() + (timeoutMs ?? 8000);
4804
+ const deadline = Date.now() + (timeoutMs ?? 30000);
3974
4805
  for (;;) {
3975
4806
  const res = (await bridge.send("getUiCapture", { requestId }));
3976
4807
  if (res.ready && res.requestId === requestId && res.path) {
3977
4808
  return text(`Saved UI screenshot: ${res.path}`);
3978
4809
  }
3979
4810
  if (Date.now() > deadline) {
3980
- return error("capture_ui timed out waiting for the WebView to return the image. Is PAM's window " +
3981
- "open and active? html2canvas needs the live DOM — a closed/suspended editor cannot render.");
4811
+ return error(`capture_ui got no image within ${timeoutMs ?? 30000} ms. Either PAM's window is closed or suspended ` +
4812
+ "(html2canvas needs the live DOM), or a dense view is still rendering on a loaded machine: retry with a larger timeoutMs.");
3982
4813
  }
3983
4814
  await new Promise((r) => setTimeout(r, 150));
3984
4815
  }
@@ -3999,14 +4830,14 @@ server.registerTool("video_command", {
3999
4830
  "addLayerFx{layerId,fxType,params?,beforeFxId?}, removeLayerFx{layerId,fxId}, " +
4000
4831
  "removeAllLayerFx{layerId}, setLayerFxParam{layerId,fxId,param,value|params}, " +
4001
4832
  "setLayerFxEnabled{layerId,fxId,enabled?}, reorderLayerFx{layerId,fxIds}, " +
4002
- "resetLayerFx{layerId,fxId}; bind{layerId,fxId?,paramKey,source,...}, " +
4833
+ "resetLayerFx{layerId,fxId}, setFxParamMeta{layerId,fxId,param,lock?,limit?:{min,max}|null,snap?:step|null}, " +
4834
+ "randomizeFx{layerId,fxId}, setRandomizeAuto{config} (sanitized AutoRandomizeConfig; see get_video_state); " +
4835
+ "bind{layerId,fxId?,paramKey,source,...}, " +
4003
4836
  "unbind{layerId,fxId?,paramKey}, setBindingField{layerId,fxId?,paramKey,field,value}, " +
4004
4837
  "fireImpulse{layerId,fxId?,paramKey}, removeBindingsForLayer{layerId}, clearBindings; " +
4005
4838
  "addOutputFx{fxId}, removeOutputFx{instanceId}, setOutputFxParam{instanceId|fxId,param,value}, " +
4006
- "setOutputFxEnabled{instanceId,enabled}, setOutputFxStrength{instanceId,strength}; " +
4007
- "addModRoute{route}, removeModRoute{id}, updateModRoute{id,updates}, clearModRoutes, " +
4008
- "setModEnabled{enabled}; setModel{sourcePath?,visible?,blendMode?,audioReactive?,autoRotate?,transform?}, " +
4009
- "resetModel; setSettings{...incl vxVariationFollowAudio,vxBarSyncEnabled,vxBarSyncIntervalBars}, " +
4839
+ "setOutputFxEnabled{instanceId,enabled} (the output chain is the master layer's FX); " +
4840
+ "setSettings{...incl vxVariationFollowAudio,vxBarSyncEnabled,vxBarSyncIntervalBars}, " +
4010
4841
  "setActiveCell{cellId} (deprecated no-op), resetWorkspace; " +
4011
4842
  "VX variation slots: saveVxVariation{index 0-7}, loadVxVariation{index}, clearVxVariation{index}, " +
4012
4843
  "fillVxVariationsRandomized (occupancy/current in get_video_state; or use the vx_variation tool); " +
@@ -4064,7 +4895,9 @@ server.registerTool("add_video_layer", {
4064
4895
  const beforeIds = new Set((before.layers ?? []).map((l) => l.id));
4065
4896
  const requestId = mintVideoRequestId();
4066
4897
  await sendVideoCommand({ op: "addLayer", kind, name, insertAt, sourcePath, maxBytes, requestId });
4067
- const id = await resolveNewId(requestId, beforeIds, (s) => (s.layers ?? []).map((l) => l.id), "layerId");
4898
+ const { id, error: failure } = await resolveNewId(requestId, beforeIds, (s) => (s.layers ?? []).map((l) => l.id), "layerId");
4899
+ if (failure)
4900
+ return error(`add_video_layer failed: ${failure}`);
4068
4901
  return text(id
4069
4902
  ? `Added ${kind} layer: ${id}`
4070
4903
  : `Added ${kind} layer (window may be closed; call get_video_state to confirm).`);
@@ -4075,7 +4908,8 @@ server.registerTool("update_video_layer", {
4075
4908
  "(partial) against the current transform; other fields replace. Common: opacity " +
4076
4909
  "(0..1), blendMode (normal|add|multiply|screen), enabled, name, transform " +
4077
4910
  "{x,y(0..1),scale,rotation(rad),anchor}. Kind-specific via `fields`: text layers " +
4078
- "{text,fontSize,fontWeight,color,align,lineHeight}; image/video/model3d {sourcePath,fit}; " +
4911
+ "{text,fontSize,fontWeight,color,align,lineHeight}; image/video/model3d {sourcePath (loads the file, as " +
4912
+ "set_video_layer_source does),fit}; " +
4079
4913
  "camera {deviceId,mirror,fit}; drawing {paintColor,paintWidth}; model3d " +
4080
4914
  "{lightingMode,lightIntensity,rotateRateX/Y/Z,...}. Use get_video_state to see a " +
4081
4915
  "layer's current fields.",
@@ -4098,7 +4932,11 @@ server.registerTool("update_video_layer", {
4098
4932
  const check = await requireBridge();
4099
4933
  if (check !== true)
4100
4934
  return check;
4101
- const patch = { ...(fields ?? {}) };
4935
+ // A sourcePath in the patch is only stored; setLayerSource is what loads the media.
4936
+ const { sourcePath, ...kindFields } = fields ?? {};
4937
+ if (sourcePath !== undefined && typeof sourcePath !== "string")
4938
+ return error("fields.sourcePath must be a file path.");
4939
+ const patch = { ...kindFields };
4102
4940
  if (opacity !== undefined)
4103
4941
  patch.opacity = opacity;
4104
4942
  if (blendMode !== undefined)
@@ -4109,6 +4947,10 @@ server.registerTool("update_video_layer", {
4109
4947
  patch.name = name;
4110
4948
  if (transform !== undefined)
4111
4949
  patch.transform = transform;
4950
+ if (sourcePath !== undefined)
4951
+ await sendVideoCommand({ op: "setLayerSource", layerId, sourcePath });
4952
+ if (Object.keys(patch).length === 0)
4953
+ return text(`Loading ${sourcePath} into layer ${layerId}.`);
4112
4954
  const result = await sendVideoCommand({ op: "updateLayer", layerId, patch });
4113
4955
  return json(result);
4114
4956
  });
@@ -4158,11 +5000,9 @@ server.registerTool("apply_video_layout", {
4158
5000
  });
4159
5001
  server.registerTool("add_video_fx", {
4160
5002
  title: "Add Video FX",
4161
- description: "Append an FX to a layer's chain (target='layer', needs layerId) or to the output/" +
4162
- "global chain (target='output'), and return the new id. Per-layer FX: " +
5003
+ description: "Append an FX to a layer's chain (target='layer', needs layerId) or to the output " +
5004
+ "chain, which is the master layer's chain (target='output'), and return the new id. FX: " +
4163
5005
  VIDEO_LAYER_FX +
4164
- ". Output FX: " +
4165
- VIDEO_OUTPUT_FX +
4166
5006
  ". FX params seed from registry defaults; override via `params`. Use set_video_fx " +
4167
5007
  "to tweak afterwards.",
4168
5008
  inputSchema: {
@@ -4187,7 +5027,9 @@ server.registerTool("add_video_fx", {
4187
5027
  const beforeIds = new Set((before.outputFx ?? []).map((f) => f.instanceId));
4188
5028
  const requestId = mintVideoRequestId();
4189
5029
  await sendVideoCommand({ op: "addOutputFx", fxId: fxType, requestId });
4190
- const id = await resolveNewId(requestId, beforeIds, (s) => (s.outputFx ?? []).map((f) => f.instanceId), "instanceId");
5030
+ const { id, error: failure } = await resolveNewId(requestId, beforeIds, (s) => (s.outputFx ?? []).map((f) => f.instanceId), "instanceId");
5031
+ if (failure)
5032
+ return error(`add_video_fx failed: ${failure}`);
4191
5033
  // Apply param overrides now that the instance exists.
4192
5034
  if (params && id)
4193
5035
  for (const [param, value] of Object.entries(params))
@@ -4201,10 +5043,12 @@ server.registerTool("add_video_fx", {
4201
5043
  const beforeIds = new Set((layer?.fx ?? []).map((f) => f.id));
4202
5044
  const requestId = mintVideoRequestId();
4203
5045
  await sendVideoCommand({ op: "addLayerFx", layerId, fxType, params, beforeFxId, requestId });
4204
- const id = await resolveNewId(requestId, beforeIds, (s) => {
5046
+ const { id, error: failure } = await resolveNewId(requestId, beforeIds, (s) => {
4205
5047
  const l = (s.layers ?? []).find((x) => x.id === layerId);
4206
5048
  return (l?.fx ?? []).map((f) => f.id);
4207
5049
  }, "fxId");
5050
+ if (failure)
5051
+ return error(`add_video_fx failed: ${failure}`);
4208
5052
  return text(id ? `Added ${fxType} to layer ${layerId}: ${id}` : `Added ${fxType} (confirm via get_video_state).`);
4209
5053
  });
4210
5054
  server.registerTool("set_video_fx", {
@@ -4212,7 +5056,7 @@ server.registerTool("set_video_fx", {
4212
5056
  description: "Set FX params / enabled / strength on a per-layer FX (target='layer', needs " +
4213
5057
  "layerId + fxId) or an output FX (target='output', needs instanceId, or fxId to " +
4214
5058
  "auto-create). Pass a single {param,value}, multiple {params}, `enabled`, and/or " +
4215
- "`strength` (output only). Get current ids/params from get_video_state.",
5059
+ "`strength` (unsupported: set the FX mix param). Get current ids/params from get_video_state.",
4216
5060
  inputSchema: {
4217
5061
  target: z.enum(["layer", "output"]).default("layer"),
4218
5062
  layerId: z.string().optional().describe("target='layer': owning layer id."),
@@ -4222,7 +5066,7 @@ server.registerTool("set_video_fx", {
4222
5066
  value: z.number().optional().describe("Value for `param`."),
4223
5067
  params: z.record(z.string(), z.number()).optional().describe("Multiple params {key: value}."),
4224
5068
  enabled: z.boolean().optional().describe("Enable/bypass this FX."),
4225
- strength: z.number().optional().describe("Output FX wet strength (output target only)."),
5069
+ strength: z.number().optional().describe("Unsupported; output FX run on the master layer and have no strength."),
4226
5070
  },
4227
5071
  }, async ({ target, layerId, fxId, instanceId, param, value, params, enabled, strength }) => {
4228
5072
  const check = await requireBridge();
@@ -4245,10 +5089,18 @@ server.registerTool("set_video_fx", {
4245
5089
  if (params)
4246
5090
  for (const [p, v] of Object.entries(params))
4247
5091
  results.push(await sendVideoCommand({ op: "setOutputFxParam", instanceId, fxId, param: p, value: v }));
4248
- if (enabled !== undefined && instanceId)
4249
- results.push(await sendVideoCommand({ op: "setOutputFxEnabled", instanceId, enabled }));
4250
- if (strength !== undefined && instanceId)
4251
- results.push(await sendVideoCommand({ op: "setOutputFxStrength", instanceId, strength }));
5092
+ // A param write by fxId has just auto-created the FX, so resolve the instance after it.
5093
+ let outputInstance = instanceId;
5094
+ if (!outputInstance && fxId && (enabled !== undefined || strength !== undefined)) {
5095
+ const current = await getVideoStateRaw();
5096
+ outputInstance = (current.outputFx ?? []).find((fx) => fx.fxId === fxId)?.instanceId;
5097
+ if (!outputInstance)
5098
+ return error(`set_video_fx: no output ${fxId} to enable or scale; add_video_fx it first.`);
5099
+ }
5100
+ if (enabled !== undefined && outputInstance)
5101
+ results.push(await sendVideoCommand({ op: "setOutputFxEnabled", instanceId: outputInstance, enabled }));
5102
+ if (strength !== undefined && outputInstance)
5103
+ results.push(await sendVideoCommand({ op: "setOutputFxStrength", instanceId: outputInstance, strength }));
4252
5104
  }
4253
5105
  if (results.length === 0)
4254
5106
  return error("set_video_fx: nothing to set (provide param/value, params, enabled, or strength).");
@@ -4296,7 +5148,7 @@ server.registerTool("bind_video_param", {
4296
5148
  .union([z.string(), z.record(z.string(), z.any())])
4297
5149
  .describe("sourceKey string (e.g. 'g:bandLow', 'c:1:onset', 't:beat') or ModSource object."),
4298
5150
  fxId: z.string().optional().describe("FX instance id; omit/'' for a layer-field target."),
4299
- amount: z.number().min(-1).max(1).optional().describe("Modulation depth (-1..1), default 1."),
5151
+ amount: z.number().min(-1).max(1).optional().describe("Modulation depth (-1..1), default 0.5 (the VX UI default)."),
4300
5152
  curve: z.enum(["linear", "exp", "expInverse", "bipolar"]).optional(),
4301
5153
  mode: z.enum(["additive", "replace", "multiply", "centered"]).optional(),
4302
5154
  attackMs: z.number().min(0).optional(),
@@ -4350,7 +5202,6 @@ server.registerTool("set_video_settings", {
4350
5202
  .describe("Canvas design ratio as width/height (e.g. 1.7777 for 16:9). null clears it and follows the window."),
4351
5203
  letterboxColor: z.string().describe("#hex fill for the bars outside the design aspect.").optional(),
4352
5204
  canvasColor: z.string().describe("#hex background behind every VX layer.").optional(),
4353
- modEnabled: z.boolean().optional().describe("Mod-matrix master enable."),
4354
5205
  vxVariationFollowAudio: z
4355
5206
  .boolean()
4356
5207
  .optional()
@@ -4366,7 +5217,7 @@ server.registerTool("set_video_settings", {
4366
5217
  activeCell: z.number().int().optional().describe("Deprecated/no-op: VX is a single global scene (no per-cell video)."),
4367
5218
  resetWorkspace: z.boolean().optional().describe("If true, clear the whole scene first."),
4368
5219
  },
4369
- }, async ({ syncMode, loopBars, aspectRatio, renderQuality, designAspect, letterboxColor, canvasColor, modEnabled, vxVariationFollowAudio, vxBarSyncEnabled, vxBarSyncIntervalBars, activeCell, resetWorkspace }) => {
5220
+ }, async ({ syncMode, loopBars, aspectRatio, renderQuality, designAspect, letterboxColor, canvasColor, vxVariationFollowAudio, vxBarSyncEnabled, vxBarSyncIntervalBars, activeCell, resetWorkspace }) => {
4370
5221
  const check = await requireBridge();
4371
5222
  if (check !== true)
4372
5223
  return check;
@@ -4374,8 +5225,6 @@ server.registerTool("set_video_settings", {
4374
5225
  await sendVideoCommand({ op: "resetWorkspace" });
4375
5226
  if (activeCell !== undefined)
4376
5227
  await sendVideoCommand({ op: "setActiveCell", cellId: activeCell });
4377
- if (modEnabled !== undefined)
4378
- await sendVideoCommand({ op: "setModEnabled", enabled: modEnabled });
4379
5228
  const settings = {};
4380
5229
  if (syncMode !== undefined)
4381
5230
  settings.syncMode = syncMode;
@@ -4463,12 +5312,6 @@ server.registerTool("vx_variation", {
4463
5312
  // ============================================================
4464
5313
  // Composite cell-operation tools
4465
5314
  // ============================================================
4466
- const PATTERN_SUFFIXES = [
4467
- "midiSequence",
4468
- "pitchSequence",
4469
- "sliceSequence",
4470
- "stretchSequence",
4471
- ];
4472
5315
  server.registerTool("reset_cell", {
4473
5316
  title: "Reset Cell",
4474
5317
  description: "Reset a single cell's manifest parameters to their defaults and remove the envelopes whose SOURCE is this cell. " +
@@ -4492,6 +5335,8 @@ server.registerTool("reset_cell", {
4492
5335
  ]);
4493
5336
  const delta = {};
4494
5337
  for (const p of cellParams) {
5338
+ if (isCcOutValueSuffix(p.paramId.replace(/^cell\d+_/, "")))
5339
+ continue;
4495
5340
  if (p.defaultValue !== undefined)
4496
5341
  delta[p.paramId] = p.defaultValue;
4497
5342
  }
@@ -4501,12 +5346,14 @@ server.registerTool("reset_cell", {
4501
5346
  const removedEnvs = envArray.length - filteredEnvs.length;
4502
5347
  if (removedEnvs > 0)
4503
5348
  delta.envelopes = filteredEnvs;
4504
- await applyKv(delta, KV_OPTS_STANDARD);
5349
+ await applyKv(delta, STATE_UPDATE_KV_OPTIONS);
4505
5350
  return text(`Reset cell ${cellId} to defaults (${paramCount} params, removed ${removedEnvs} envelopes).`);
4506
5351
  });
4507
5352
  server.registerTool("copy_cell", {
4508
5353
  title: "Copy Cell",
4509
5354
  description: "Copy a cell's manifest parameters and remap its LFO, envelope and vary links onto another cell, overwriting the target's. " +
5355
+ "Param-lane targets travel with their lanes. A CC lane lands only on a target slot that is Off or on the same " +
5356
+ "controller; CC out values and controllers stay the target's. " +
4510
5357
  "Not copied: the loaded sample, parameter sequencers, and the cell config block (polyphony, MIDI note/channel, scale, " +
4511
5358
  "sidechain source, pitch/stretch engine, slice keymap) — re-apply those with load_sample and update_cell_config.",
4512
5359
  inputSchema: {
@@ -4533,10 +5380,33 @@ server.registerTool("copy_cell", {
4533
5380
  const dstPrefix = `cell${targetCellId}_`;
4534
5381
  const delta = {};
4535
5382
  for (const [key, value] of Object.entries(state)) {
4536
- if (key.startsWith(srcPrefix)) {
5383
+ if (key.startsWith(srcPrefix) && !isCcOutValueSuffix(key.slice(srcPrefix.length))) {
4537
5384
  delta[dstPrefix + key.slice(srcPrefix.length)] = value;
4538
5385
  }
4539
5386
  }
5387
+ // Destination wiring: a CC lane lands only where the slot it locks is Off or on the same controller,
5388
+ // and then brings that slot; one that stays out keeps the destination's.
5389
+ const cellsState = isRecord(state.cells) ? state.cells : {};
5390
+ const srcRaw = cellsState[String(sourceCellId)];
5391
+ const dstRaw = cellsState[String(targetCellId)];
5392
+ const srcCell = isRecord(srcRaw) ? srcRaw : {};
5393
+ const dstCell = isRecord(dstRaw) ? dstRaw : {};
5394
+ const targetDelta = {};
5395
+ for (const lane of CC_LANES) {
5396
+ const slot = ccLaneSlotOf(srcCell, lane);
5397
+ if (slot === 0 || ccLaneMayLand(srcCell[`ccOutCc${slot}`], dstCell[`ccOutCc${slot}`])) {
5398
+ targetDelta[ccLaneSlotField(lane)] = slot;
5399
+ }
5400
+ else {
5401
+ delete delta[`${dstPrefix}${PATTERN_LANE_SUFFIX[lane]}`];
5402
+ targetDelta[ccLaneSlotField(lane)] = ccLaneSlotOf(dstCell, lane);
5403
+ }
5404
+ }
5405
+ for (const lane of PARAM_LANES) {
5406
+ const target = srcCell[LANE_TARGET_FIELD[lane]];
5407
+ targetDelta[LANE_TARGET_FIELD[lane]] = typeof target === "string" ? target : "";
5408
+ }
5409
+ delta.cells = { [targetCellId]: targetDelta };
4540
5410
  // Remap envelopes
4541
5411
  const envelopes = state.envelopes || [];
4542
5412
  if (envelopes.length > 0) {
@@ -4612,7 +5482,7 @@ server.registerTool("copy_cell", {
4612
5482
  if (remapped.length > 0)
4613
5483
  delta.paramSeqs = [...kept, ...remapped];
4614
5484
  }
4615
- await applyKv(delta, KV_OPTS_STANDARD);
5485
+ await applyKv(delta, STATE_UPDATE_KV_OPTIONS);
4616
5486
  return text(`Copied cell ${sourceCellId} → cell ${targetCellId} (${Object.keys(delta).length} keys).`);
4617
5487
  });
4618
5488
  server.registerTool("clear_modulations", {
@@ -4655,7 +5525,7 @@ server.registerTool("clear_modulations", {
4655
5525
  if (Object.keys(delta).length === 0) {
4656
5526
  return text(`No modulations found for cell ${cellId}.`);
4657
5527
  }
4658
- await applyKv(delta, KV_OPTS_MOD);
5528
+ await applyKv(delta, STATE_UPDATE_KV_OPTIONS);
4659
5529
  const removed = (lfos.length - filteredLfos.length) +
4660
5530
  (vary.length - filteredVary.length) +
4661
5531
  (envelopes.length - filteredEnvs.length);
@@ -4663,7 +5533,8 @@ server.registerTool("clear_modulations", {
4663
5533
  });
4664
5534
  server.registerTool("reset_patterns", {
4665
5535
  title: "Reset Patterns",
4666
- description: "Clear all sequencer lanes (note, pitch, slice, stretch) for a single PAM/Cell1 sampler cell.",
5536
+ description: "Clear every pattern lane of one cell (note, pitch, slice, stretch, the CC lanes and the param lanes, as the " +
5537
+ "product has them) and clear the param lanes' targets. CC out controllers stay.",
4667
5538
  inputSchema: {
4668
5539
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
4669
5540
  },
@@ -4682,16 +5553,58 @@ server.registerTool("reset_patterns", {
4682
5553
  return error(`${productName} has no sampler MIDI/pitch/slice/stretch pattern lanes.`);
4683
5554
  }
4684
5555
  const delta = { __forceRender__: true };
4685
- for (const suffix of PATTERN_SUFFIXES) {
4686
- delta[`cell${cellId}_${suffix}`] = [];
5556
+ for (const lane of PATTERN_LANE_IDS) {
5557
+ if (!manifest || productHasLane(cellId, lane, manifest.stateVariables))
5558
+ delta[`cell${cellId}_${PATTERN_LANE_SUFFIX[lane]}`] = [];
5559
+ }
5560
+ if (!manifest || productHasLane(cellId, "paramLane1", manifest.stateVariables)) {
5561
+ delta.cells = { [cellId]: Object.fromEntries(PARAM_LANES.map((lane) => [LANE_TARGET_FIELD[lane], ""])) };
4687
5562
  }
4688
- await applyKv(delta, KV_OPTS_PATTERN);
5563
+ await applyKv(delta, PATTERN_EDIT_KV_OPTIONS);
4689
5564
  return text(`Cleared all patterns for cell ${cellId}.`);
4690
5565
  });
5566
+ server.registerTool("set_effects_enabled", {
5567
+ title: "Enable or Bypass Cell Effects",
5568
+ description: "Enable or bypass cell effects. Omit cellId for every cell (CTRL ALL); omit effect for the entire cell FX chain. " +
5569
+ "This changes cell effect enable flags, preserving their settings and the master bus. Use set_master_effects for master-bus changes.",
5570
+ inputSchema: {
5571
+ enabled: z.boolean().describe("true enables, false bypasses."),
5572
+ cellId: z.number().int().min(1).max(8).optional().describe("Cell number (1-8). Omit for all cells, including empty cells."),
5573
+ effect: z.string().optional().describe("Cell effect name, e.g. delay, crusher, fdn, filter. Omit for all cell effects."),
5574
+ },
5575
+ }, async ({ enabled, cellId, effect }) => {
5576
+ const check = await requireBridge();
5577
+ if (check !== true)
5578
+ return check;
5579
+ const manifest = await loadManifestForProductGuard();
5580
+ if (!manifest)
5581
+ return error("The selected product manifest is unavailable; effect availability cannot be validated safely.");
5582
+ const available = cellEffectOrder(manifest);
5583
+ if (!available.length)
5584
+ return error("The selected product has no cell effect chain.");
5585
+ if (effect !== undefined && !available.includes(effect))
5586
+ return error(`Unknown cell effect: ${effect}. Available effects: ${available.join(", ")}.`);
5587
+ if (cellId !== undefined) {
5588
+ const cellCheck = validateCellIdForManifest(cellId, manifest);
5589
+ if (cellCheck !== true)
5590
+ return cellCheck;
5591
+ }
5592
+ const cellCount = manifestMaxCellId(manifest);
5593
+ if (cellCount === null)
5594
+ return error("The selected product manifest has no cells.");
5595
+ const cellIds = cellId === undefined ? Array.from({ length: cellCount }, (_, i) => i + 1) : [cellId];
5596
+ const effects = effect === undefined ? available : [effect];
5597
+ const delta = Object.fromEntries(cellIds.flatMap((id) => effects.map((name) => [`cell${id}_${name}Enabled`, enabled ? 1 : 0])));
5598
+ const stateRaw = await bridge.send("getState");
5599
+ const state = isRecord(stateRaw) ? stateRaw : {};
5600
+ const changed = Object.entries(delta).filter(([key, value]) => Number(state[key] ?? 0) !== value).length;
5601
+ await applyKv(delta, EFFECT_CONFIG_KV_OPTIONS);
5602
+ return text(JSON.stringify({ cellIds, effects, enabled, changed }));
5603
+ });
4691
5604
  server.registerTool("update_effect_config", {
4692
5605
  title: "Update Effect Config",
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. " +
5606
+ description: "Toggle cell effects on/off (filter, delay, crusher, flanger, chorus, phaser, freqshift, drive, lpc, fmod, specdelay, squeeze, tape, granular, noisegate, codec, cellmix, eq, eq8, tablefilter, fdn, gate, compressor, panner, gain, vocoder, ringmod, vinylbrake, transient, repeat, pitchshift, limiter, shimmer, wavefold, resonator). " +
5607
+ "granular is the Grain card and noisegate the Noise Gate. Wavefold and Resonator ship in every product. Exact availability comes from the selected product manifest. " +
4695
5608
  "Uses the lighter KV path that does not rebuild the audio graph. Pass 1 to enable, 0 to disable.",
4696
5609
  inputSchema: {
4697
5610
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
@@ -4703,7 +5616,7 @@ server.registerTool("update_effect_config", {
4703
5616
  fdnEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4704
5617
  gateEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4705
5618
  compressorEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4706
- // Shared extended effects. These ship on PAM/Cell1 too, so leaving them out of the
5619
+ // Shared extended effects. These ship on PAM too, so leaving them out of the
4707
5620
  // schema made them unreachable over the bridge even though the handler is
4708
5621
  // generic (it just builds `cell{N}_{key}`) and the state vars exist in
4709
5622
  // build.js alongside the eight above.
@@ -4717,9 +5630,19 @@ server.registerTool("update_effect_config", {
4717
5630
  repeatEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4718
5631
  pitchshiftEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4719
5632
  limiterEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4720
- qverbEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4721
5633
  chorusEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4722
5634
  phaserEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
5635
+ freqshiftEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
5636
+ driveEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
5637
+ lpcEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
5638
+ specdelayEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
5639
+ squeezeEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
5640
+ tapeEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
5641
+ granularEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
5642
+ noisegateEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
5643
+ codecEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
5644
+ cellmixEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
5645
+ fmodEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4723
5646
  shimmerEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4724
5647
  tablefilterEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
4725
5648
  // Product-specific extended effects. The handler rejects flags absent from
@@ -4753,17 +5676,50 @@ server.registerTool("update_effect_config", {
4753
5676
  if (Object.keys(delta).length === 0) {
4754
5677
  return error("At least one effect flag must be provided.");
4755
5678
  }
4756
- await applyKv(delta, KV_OPTS_EFFECT_CONFIG);
5679
+ await applyKv(delta, EFFECT_CONFIG_KV_OPTIONS);
4757
5680
  const summary = Object.entries(delta)
4758
5681
  .map(([k, v]) => `${k}=${v}`)
4759
5682
  .join(", ");
4760
5683
  return text(`Updated effect config for cell ${cellId}: ${summary}`);
4761
5684
  });
5685
+ // Spectral Delay's curve is the array state var cell{N}_specdelayBands, which set_parameters
5686
+ // (numbers only) cannot write. KVStateSyncManager projects it to the node, so no WebView is needed.
5687
+ const SPECDELAY_BANDS = 32;
5688
+ server.registerTool("set_spectral_delay_curve", {
5689
+ title: "Set Spectral Delay Curve",
5690
+ description: "Set a cell's Spectral Delay curve: 32 log-spaced bands from 20 Hz to 20 kHz, each 0..1. In TIME mode a " +
5691
+ "band's value is its share of Time (0 = the shortest repeat, 1 = the whole Time); in LEVEL mode it is how " +
5692
+ "much of that band is repeated. Depth scales the curve. Pass shape 'rise' (highs arrive last) or 'fall', or " +
5693
+ "32 explicit bands, low to high. Enable the effect with update_effect_config specdelayEnabled.",
5694
+ inputSchema: {
5695
+ cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
5696
+ shape: z.enum(["rise", "fall"]).optional().describe("A factory curve. Omit when passing bands."),
5697
+ bands: z
5698
+ .array(z.number().min(0).max(1))
5699
+ .length(SPECDELAY_BANDS)
5700
+ .optional()
5701
+ .describe("32 values 0..1, 20 Hz first. Omit when passing shape."),
5702
+ },
5703
+ }, async ({ cellId, shape, bands }) => {
5704
+ const check = await requireBridgeForCells(cellId);
5705
+ if (check !== true)
5706
+ return check;
5707
+ if ((shape === undefined) === (bands === undefined))
5708
+ return error("Pass exactly one of shape or bands.");
5709
+ const manifest = await loadManifestForProductGuard();
5710
+ if (manifest && !cellEffectNames(manifest).includes("specdelay")) {
5711
+ return error(`Spectral Delay is not available in ${manifest.name ?? "the selected product"}.`);
5712
+ }
5713
+ const rise = Array.from({ length: SPECDELAY_BANDS }, (_, b) => b / (SPECDELAY_BANDS - 1));
5714
+ const curve = bands ?? (shape === "fall" ? rise.map((v) => 1 - v) : rise);
5715
+ await applyKv({ [`cell${cellId}_specdelayBands`]: curve }, STATE_UPDATE_KV_OPTIONS);
5716
+ return text(`Set cell ${cellId} Spectral Delay curve (${shape ?? "32 bands"}).`);
5717
+ });
4762
5718
  // MFX cable patchbay. `mfx_routing` is a non-numeric state variable, so it was
4763
5719
  // unreachable through set_parameters (numbers only) — an agent could shape an MFX
4764
5720
  // cell but never patch one to another, which is the whole product.
4765
5721
  //
4766
- // Validation mirrors parseRoutingEdges (interface/src/dsp/products/mfx/mfxRouting.ts): the
5722
+ // Validation mirrors parseRoutingEdges (interface/src/utils/mfxRouting.ts): the
4767
5723
  // plugin re-validates on receipt, so this pass exists to fail loudly here rather
4768
5724
  // than have a typo silently drop a cable.
4769
5725
  const MFX_ROUTING_SOURCE_HINT = "'input', 'sidechain', 'split_lo' / 'split_mid' / 'split_hi', or 'cellN'. " +
@@ -4812,8 +5768,8 @@ function validateMfxEdges(edges, maxCells) {
4812
5768
  }
4813
5769
  server.registerTool("set_mfx_routing", {
4814
5770
  title: "Set MFX Cable Routing",
4815
- description: "Read or replace the MFX cable patch — the graph that decides which cell feeds which. MFX-only; on PAM/Cell1 " +
4816
- "this returns a product error.\n\n" +
5771
+ description: "Read or replace the MFX cable patch — the graph that decides which cell feeds which. MFX-only; other products " +
5772
+ "return a product error.\n\n" +
4817
5773
  "Ports: 'input' (the host's audio in) and 'sidechain' are sources; 'output' is the destination; 'cellN' is both. " +
4818
5774
  "A self-edge cellN→cellN is a feedback loop, and input→output is a direct dry path. Duplicate cables are dropped.\n\n" +
4819
5775
  "SPLIT is a three-band crossover node: cable 'input' (or 'sidechain') to 'split', then patch 'split_lo', " +
@@ -4858,7 +5814,7 @@ server.registerTool("set_mfx_routing", {
4858
5814
  return error(`${validated.reason}. This product exposes cells 1..${maxCells}.`);
4859
5815
  }
4860
5816
  // mfx_routing is a GRAPH_RENDER state var — a re-patch must rebuild the graph.
4861
- await applyKv({ mfx_routing: validated.edges }, KV_OPTS_STANDARD);
5817
+ await applyKv({ mfx_routing: validated.edges }, STATE_UPDATE_KV_OPTIONS);
4862
5818
  const summary = validated.edges.map((e) => `${e.from}→${e.to}`).join(", ");
4863
5819
  return text(`MFX routing set: ${validated.edges.length} cable(s)${summary ? ` — ${summary}` : " (patch cleared)"}.`);
4864
5820
  });
@@ -4895,7 +5851,8 @@ server.registerTool("set_mfx_edge_gain", {
4895
5851
  return error(`Cable ${from}→${to} is not in the current patch. Add it with set_mfx_routing first.`);
4896
5852
  const key = `${from}__${to}`;
4897
5853
  gains[key] = gain;
4898
- await applyKv({ mfx_edge_gains: gains }, KV_OPTS_EFFECT_CONFIG);
5854
+ // Cable gains reach audio only through the graph's mounted refs, which a dispatch updates.
5855
+ await applyKv({ mfx_edge_gains: gains }, STATE_UPDATE_KV_OPTIONS);
4899
5856
  return text(`MFX cable gain set: ${from}→${to} = ${gain}.`);
4900
5857
  });
4901
5858
  const MFX_COMBO_TARGET_INPUT = z.object({
@@ -4954,7 +5911,7 @@ server.registerTool("configure_mfx_combo", {
4954
5911
  const currentTargetValue = state[target.id];
4955
5912
  delta[target.id] = mfxComboTargetValueFromCurrent(target, typeof previousComboValue === "number" ? previousComboValue : 0, value, info, typeof currentTargetValue === "number" ? currentTargetValue : info.defaultValue);
4956
5913
  }
4957
- await applyKv(delta, KV_OPTS_STANDARD);
5914
+ await applyKv(delta, STATE_UPDATE_KV_OPTIONS);
4958
5915
  return json({ combo: index, value, targetsWritten: combo.targets.map((target) => target.id) });
4959
5916
  }
4960
5917
  const materialized = () => ({ ...stored, targets: [...combo.targets] });
@@ -5051,7 +6008,20 @@ server.registerTool("configure_mfx_combo", {
5051
6008
  locked: next?.locked === 1 ? 1 : 0,
5052
6009
  targets: Array.isArray(next?.targets) ? next.targets : null,
5053
6010
  };
5054
- await applyKv({ mfx_combos: { ...slots, [String(index)]: update } }, KV_OPTS_EFFECT_CONFIG);
6011
+ const delta = { mfx_combos: { ...slots, [String(index)]: update } };
6012
+ if ((op === "setTargetStop" || op === "invertTarget") && paramId) {
6013
+ const before = combo.targets.find((target) => target.id === paramId);
6014
+ const after = next?.targets?.find((target) => target.id === paramId);
6015
+ const info = parameterIndex.get(paramId);
6016
+ if (before && after && info) {
6017
+ const current = typeof state[paramId] === "number" ? state[paramId] : info.defaultValue;
6018
+ const comboValue = state[`mfx_combo${index}`];
6019
+ const changed = mfxComboTargetValueAfterRangeEdit(before, after, typeof comboValue === "number" ? comboValue : 0, info, current);
6020
+ if (changed !== current)
6021
+ delta[paramId] = changed;
6022
+ }
6023
+ }
6024
+ await applyKv(delta, EFFECT_CONFIG_KV_OPTIONS);
5055
6025
  return json({ combo: index, slot: update });
5056
6026
  });
5057
6027
  server.registerTool("set_effect_order", {
@@ -5111,15 +6081,16 @@ server.registerTool("set_effect_order", {
5111
6081
  "To bypass a stage set its *Enabled flag via update_effect_config; leaving it out of the chain is not the same thing.");
5112
6082
  }
5113
6083
  // effectOrder rebuilds the cell's FX graph, like an MFX re-patch.
5114
- await applyKv({ [key]: order }, KV_OPTS_STANDARD);
6084
+ await applyKv({ [key]: order }, STATE_UPDATE_KV_OPTIONS);
5115
6085
  return text(`Cell ${cellId} FX chain: ${order.join(" → ")}.`);
5116
6086
  });
5117
6087
  server.registerTool("update_cell_config", {
5118
6088
  title: "Update Cell Config",
5119
- description: "Update cell-level configuration properties like polyphony, MIDI settings, varispeed, and modulation latch. " +
6089
+ description: "Update cell-level configuration properties like polyphony, MIDI settings, MIDI output (channel, Send, CC out controllers), varispeed, and modulation latch. " +
5120
6090
  "These are KV-only properties that don't appear in the parameter manifest. " +
5121
- "Uses a lightweight KV path without graph rebuild. " +
5122
- "When bound to MFX, this tool only accepts sidechainFrom; sampler/MIDI/instrument config is PAM/Cell1-only.",
6091
+ "Most keys take a lightweight KV path; the source keys (sidechainFrom, cellmixFrom, cellmixPreFx) rebuild the graph once. " +
6092
+ "cellmixFrom picks the source the cell's Cell Mix card blends in and switches the card on. " +
6093
+ "When bound to MFX, this tool accepts only sidechainFrom, cellmixFrom and cellmixPreFx; on MSQ it accepts the MIDI and output keys only.",
5123
6094
  inputSchema: {
5124
6095
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
5125
6096
  isPoly: z.union([z.literal(0), z.literal(1)]).optional().describe("Polyphonic mode (0=mono, 1=poly)."),
@@ -5173,6 +6144,34 @@ server.registerTool("update_cell_config", {
5173
6144
  .optional()
5174
6145
  .describe("Sidechain compressor source. null=no sidechain, 0=external audio input, 1-8=cell output. " +
5175
6146
  "Auto-enables the cell's compressor when set to a non-null value."),
6147
+ cellmixFrom: z
6148
+ .number()
6149
+ .int()
6150
+ .min(-1)
6151
+ .max(8)
6152
+ .optional()
6153
+ .describe("Cell Mix source: -1=none, 0=external sidechain input, 1-8=another cell (never this cell). " +
6154
+ "Any value but -1 also switches the cell's Cell Mix card on."),
6155
+ cellmixPreFx: z
6156
+ .boolean()
6157
+ .optional()
6158
+ .describe("Where Cell Mix taps a source cell: true=before its effects, false=after its effects and before its fader."),
6159
+ midiOutChannel: z.number().int().min(0).max(16).optional().describe("MIDI out channel for the cell's CC out (and MSQ's notes): 0=Auto (the cell number), 1-16 explicit."),
6160
+ sendToHost: z.union([z.literal(0), z.literal(1)]).optional().describe("0 keeps the cell's CC out (and MSQ's notes) off the MIDI out."),
6161
+ midiOutPort: z.number().int().min(0).max(3).optional().describe("Standalone only: 0=Main MIDI Output, 1-3=Ports B-D set in Settings."),
6162
+ polyNoteMin: z.number().int().min(0).max(127).optional().describe("Lowest note a poly cell plays (MSQ: sends), at most polyNoteMax."),
6163
+ polyNoteMax: z.number().int().min(0).max(127).optional().describe("Highest note a poly cell plays (MSQ: sends), at least polyNoteMin."),
6164
+ ...Object.fromEntries(CC_OUT_SLOTS.map((slot) => [`ccOutCc${slot}`, ccOutControllerSchema(slot)])),
6165
+ ...Object.fromEntries(CC_LANES.map((lane) => [
6166
+ ccLaneSlotField(lane),
6167
+ z
6168
+ .number()
6169
+ .int()
6170
+ .min(0)
6171
+ .max(CC_OUT_SLOTS.length)
6172
+ .optional()
6173
+ .describe(`CC out slot (1-${CC_OUT_SLOTS.length}) that CC lane ${ccLaneIndex(lane)} locks per event, 0=none. Lane L starts on slot L. The lane loops at its own cell{N}_patternLengthCc${ccLaneIndex(lane)} / patternSpeedCc${ccLaneIndex(lane)}.`),
6174
+ ])),
5176
6175
  },
5177
6176
  }, async ({ cellId, ...config }) => {
5178
6177
  const check = await requireBridge();
@@ -5197,10 +6196,38 @@ server.registerTool("update_cell_config", {
5197
6196
  if (manifest && isMfxManifest(manifest)) {
5198
6197
  const unsupported = providedKeys.filter((key) => !MFX_CELL_CONFIG_KEYS.has(key));
5199
6198
  if (unsupported.length > 0) {
5200
- return error("update_cell_config in MFX only accepts sidechainFrom. " +
5201
- `Sampler/MIDI/instrument config keys are PAM/Cell1-only: ${unsupported.join(", ")}.`);
6199
+ return error(`update_cell_config in MFX accepts only ${[...MFX_CELL_CONFIG_KEYS].join(", ")}. ` +
6200
+ `Sampler/MIDI/instrument config keys are PAM-only: ${unsupported.join(", ")}.`);
5202
6201
  }
5203
6202
  }
6203
+ if (bridge.getConnectedInstance()?.product?.toUpperCase() === "MSQ") {
6204
+ const unsupported = providedKeys.filter((key) => !MSQ_CELL_CONFIG_KEYS.has(key));
6205
+ if (unsupported.length > 0) {
6206
+ return error(`update_cell_config in MSQ accepts MIDI keys only (${[...MSQ_CELL_CONFIG_KEYS].join(", ")}). ` +
6207
+ `MSQ has no sampler: ${unsupported.join(", ")}.`);
6208
+ }
6209
+ }
6210
+ if (config.cellmixFrom === cellId) {
6211
+ return error(`cellmixFrom cannot be cell ${cellId} itself: use -1 for none, 0 for the sidechain input, or another cell.`);
6212
+ }
6213
+ if (config.cellmixFrom !== undefined && config.cellmixFrom >= 1) {
6214
+ const sourceCheck = validateCellIdForManifest(config.cellmixFrom, manifest);
6215
+ if (sourceCheck !== true)
6216
+ return sourceCheck;
6217
+ }
6218
+ // MSQ's router drops every note outside Low-High as stored, so an inverted pair would silence the cell.
6219
+ if (config.polyNoteMin !== undefined || config.polyNoteMax !== undefined) {
6220
+ let current = {};
6221
+ if (config.polyNoteMin === undefined || config.polyNoteMax === undefined) {
6222
+ const state = await bridge.send("getState");
6223
+ const cells = isRecord(state) && isRecord(state.cells) ? state.cells : {};
6224
+ current = isRecord(cells[String(cellId)]) ? cells[String(cellId)] : {};
6225
+ }
6226
+ const low = config.polyNoteMin ?? Number(current.polyNoteMin ?? 0);
6227
+ const high = config.polyNoteMax ?? Number(current.polyNoteMax ?? 127);
6228
+ if (low > high)
6229
+ return error(`polyNoteMin (${low}) must not exceed polyNoteMax (${high}).`);
6230
+ }
5204
6231
  const cellConfig = {};
5205
6232
  const flatDelta = {};
5206
6233
  for (const [key, value] of Object.entries(config)) {
@@ -5211,10 +6238,18 @@ server.registerTool("update_cell_config", {
5211
6238
  else
5212
6239
  cellConfig[key] = value;
5213
6240
  }
6241
+ // An agent's scale is the user's: a Player switched off later must not put its pre-arm scale back.
6242
+ if (cellConfig.scaleName !== undefined || cellConfig.scaleRoot !== undefined) {
6243
+ cellConfig.playerPreScale = NO_SCALE_ARM;
6244
+ cellConfig.playerPreScaleRoot = NO_POLY_ARM;
6245
+ }
5214
6246
  // Auto-enable compressor when setting sidechain source
5215
6247
  if (cellConfig.sidechainFrom !== undefined && cellConfig.sidechainFrom !== null) {
5216
6248
  flatDelta[`cell${cellId}_compressorEnabled`] = 1;
5217
6249
  }
6250
+ if (cellConfig.cellmixFrom !== undefined && cellConfig.cellmixFrom !== -1) {
6251
+ flatDelta[`cell${cellId}_cellmixEnabled`] = 1;
6252
+ }
5218
6253
  // Mirror interop.ts updateCellConfig: a pitch/stretch engine change OR a
5219
6254
  // sliceKeymapMode flip (which changes pitch interpretation) bumps
5220
6255
  // modeChangeSeq so the audio thread flushes its Signalsmith analysis state.
@@ -5228,7 +6263,9 @@ server.registerTool("update_cell_config", {
5228
6263
  const delta = { ...flatDelta };
5229
6264
  if (Object.keys(cellConfig).length > 0)
5230
6265
  delta.cells = { [cellId]: cellConfig };
5231
- await applyKv(delta, KV_OPTS_CELL_CONFIG);
6266
+ // Sidechain and Cell Mix sources are wired at graph build, so they need the dispatch the UI's setters make.
6267
+ const wiresGraph = ["sidechainFrom", "cellmixFrom", "cellmixPreFx"].some((key) => cellConfig[key] !== undefined);
6268
+ await applyKv(delta, wiresGraph ? STATE_UPDATE_KV_OPTIONS : CELL_CONFIG_KV_OPTIONS);
5232
6269
  const summary = [
5233
6270
  ...Object.entries(cellConfig).map(([k, v]) => `${k}=${v}`),
5234
6271
  ...Object.entries(flatDelta).map(([k, v]) => `${k}=${v}`),
@@ -5264,10 +6301,10 @@ server.registerTool("set_cell_mute", {
5264
6301
  }
5265
6302
  // cell{N}_mute / cell{N}_solo are flat state vars projected to
5266
6303
  // cell{N}_config.enabled by KVStateSyncManager — same options the UI's
5267
- // applyStateDelta uses (KV_OPTS_STANDARD); the projection needs
6304
+ // applyStateDelta uses (STATE_UPDATE_KV_OPTIONS); the projection needs
5268
6305
  // syncConfigResources, no graph rebuild required but the standard path is
5269
6306
  // the exact UI parity.
5270
- await applyKv(delta, KV_OPTS_STANDARD);
6307
+ await applyKv(delta, STATE_UPDATE_KV_OPTIONS);
5271
6308
  const summary = Object.entries(delta)
5272
6309
  .map(([k, v]) => `${k}=${v}`)
5273
6310
  .join(", ");
@@ -5303,11 +6340,13 @@ server.registerTool("set_master_effects", {
5303
6340
  masterTapeDrive: z.number().min(0).max(1).optional().describe("Tape drive / saturation amount (0-1, default 0.5). Higher = more harmonic grit + HF compression ('push it harder → it gets duller')."),
5304
6341
  masterTapeTone: z.number().min(-1).max(1).optional().describe("Tape tone tilt (-1 dark .. 0 flat .. +1 bright, default 0). Go negative for dusty/lo-fi."),
5305
6342
  masterTapeMix: z.number().min(0).max(1).optional().describe("Tape dry/wet (0-1, default 1 = fully wet)."),
5306
- masterTapeMode: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]).optional().describe("Tape character: 0=Clean (gentle tanh), 1=Lo-Fi (most degradation, dusty), 2=Amp (hard asymmetric drive), 3=Studio (Jiles-Atherton magnetic hysteresis — the deep hi-fi machine, no wow/hiss). Default 0."),
6343
+ masterTapeMode: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]).optional().describe("Tape character: 0=Clean (gentle tanh), 1=Lo-Fi (most degradation, dusty), 2=Broken (drifting wow/flutter, crackle, hard asymmetric drive), 3=Studio (Jiles-Atherton magnetic hysteresis — the deep hi-fi machine, no wow/hiss). Default 0."),
5307
6344
  masterTapeWear: z.number().min(0).max(1).optional().describe("Tape wear: wow/flutter pitch drift + age (0-1, default 0.5). Raise for warble/sag."),
6345
+ masterTapeOutTrim: z.number().min(-24).max(24).optional().describe("Tape output trim dB on the wet signal, after Mix (default 0). Unity while Tape is off."),
5308
6346
  masterEqEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the master EQ (trim/tilt/shelves/bells)."),
5309
6347
  masterWidthEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the stereo utility (width + elliptical bass-mono)."),
5310
6348
  masterInputTrim: z.number().min(-24).max(24).optional().describe("Master input trim in dB (gain staging into the chain; default 0)."),
6349
+ masterEqOutTrim: z.number().min(-24).max(24).optional().describe("Master EQ output trim dB (default 0). Unity while the EQ is off."),
5311
6350
  masterEqTilt: z.number().min(-6).max(6).optional().describe("Tilt in dB at the extremes around a 650 Hz pivot (+ = brighter; the coarse tonal-balance lever)."),
5312
6351
  masterEqLsFreq: z.number().min(20).max(500).optional().describe("Low-shelf corner Hz (default 90)."),
5313
6352
  masterEqLsGain: z.number().min(-12).max(12).optional().describe("Low-shelf gain dB (default 0)."),
@@ -5319,11 +6358,21 @@ server.registerTool("set_master_effects", {
5319
6358
  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."),
5320
6359
  masterClipMix: z.number().min(0).max(1).optional().describe("Clipper parallel blend (1 = fully clipped)."),
5321
6360
  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."),
6361
+ masterDenoiseEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable master noise reduction before EQ and compression; off by default. Adds one FFT frame of latency while enabled; a DAW that is playing keeps that latency after a switch-off until its transport stops, and Fixed latency mode keeps it always."),
6362
+ masterDenoiseLow: z.number().min(0).max(100).optional().describe("Low-band noise reduction strength, percent (default 50)."),
6363
+ masterDenoiseMid: z.number().min(0).max(100).optional().describe("Mid-band noise reduction strength, percent (default 50)."),
6364
+ masterDenoiseHigh: z.number().min(0).max(100).optional().describe("High-band noise reduction strength, percent (default 50)."),
6365
+ masterDenoiseSplitLow: z.number().min(40).max(2000).optional().describe("Low/mid split in Hz (default 250)."),
6366
+ masterDenoiseSplitHigh: z.number().min(500).max(12000).optional().describe("Mid/high split in Hz (default 4000)."),
6367
+ masterDenoiseMix: z.number().min(0).max(1).optional().describe("Master Denoise dry/wet, 0..1 (default 1)."),
6368
+ masterDenoiseOutTrim: z.number().min(-24).max(24).optional().describe("Master Denoise output trim dB on the wet signal, after Mix (default 0). Unity while Denoise is off."),
6369
+ masterDenoisePrint: z.string().optional().describe("Learned noise print ('pdn1:<sampleRate>:<fftSize>:<base64>'), written by the Denoise panel's Learn button. Pass '' to forget it and return to adaptive tracking; copy a value read from get_state to reuse a print."),
5322
6370
  masterIronEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the console/transformer iron colour stage (post-Tape, pre-Clipper; zero latency)."),
5323
6371
  masterIronDrive: z.number().min(0).max(12).optional().describe("Iron drive dB (default 0). Level-compensated — drive adds colour, not loudness."),
5324
6372
  masterIronAmount: z.number().min(0).max(1).optional().describe("Transformer (magnetizing-branch) depth 0..1 — LF-concentrated saturation, the 'iron' signature (default 0.5)."),
5325
6373
  masterIronMode: z.union([z.literal(0), z.literal(1)]).optional().describe("0=Console (amp-forward, subtle), 1=Transformer (heavier LF iron)."),
5326
- masterSqueezeEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable Squeeze — a 3-band upward+downward compressor (post-Punch, pre-Tape; zero latency). The character (88/2500 Hz splits, thresholds, ratios) is baked; Depth is the main lever."),
6374
+ masterIronOutTrim: z.number().min(-24).max(24).optional().describe("Iron output trim dB on the wet signal (default 0). Unity while Iron is off."),
6375
+ masterSqueezeEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable Squeeze — a 3-band upward+downward compressor (post-Punch, pre-Tape; zero latency). The character (thresholds, ratios) is baked; Depth is the main lever."),
5327
6376
  masterSqueezeDepth: z.number().min(0).max(1).optional().describe("Squeeze depth 0..1 (default 0.3): scales the applied per-band gain toward the full character — a density control, NOT a dry/wet. 0.15-0.3 densifies a master; 0.6+ is the aggressive pumped sound."),
5328
6377
  masterSqueezeTime: z.number().min(0.1).max(10).optional().describe("Squeeze time multiplier on all band attack+release (0.1 fast .. 10 slow, default 1)."),
5329
6378
  masterSqueezeTone: z.number().min(-1).max(1).optional().describe("Squeeze tone: bipolar low<->high band tilt, ±6 dB at the extremes (default 0)."),
@@ -5332,6 +6381,8 @@ server.registerTool("set_master_effects", {
5332
6381
  masterSqueezeUpAmount: z.number().min(0).max(1.5).optional().describe("Squeeze upward-branch trim 0..1.5 (default 1). 0 disables the upward lift (downward-only multiband)."),
5333
6382
  masterSqueezeDownAmount: z.number().min(0).max(1.5).optional().describe("Squeeze downward-branch trim 0..1.5 (default 1). 0 disables the downward limiting (pure upward lift)."),
5334
6383
  masterSqueezeUpCapDb: z.number().min(0).max(24).optional().describe("Squeeze upward max-boost clamp dB (default 12) — bounds how far quiet material (tails, noise floor) is lifted."),
6384
+ masterSqueezeLoCross: z.number().min(10).max(500).optional().describe("Squeeze low/mid crossover Hz (default 88.3)."),
6385
+ masterSqueezeHiCross: z.number().min(500).max(20000).optional().describe("Squeeze mid/high crossover Hz (default 2500)."),
5335
6386
  masterLoudnessAutoReset: z.union([z.literal(0), z.literal(1)]).optional().describe("Loudness meter auto-reset on host transport start (default 1). 0 keeps measurements running across transport stops."),
5336
6387
  masterEqBells: z
5337
6388
  .array(z.object({
@@ -5390,7 +6441,7 @@ server.registerTool("set_master_effects", {
5390
6441
  // No-dispatch path: every master key is live-polled from master_config
5391
6442
  // (the full Punch surface included), so a graph rebuild here would only
5392
6443
  // risk dropouts during automated mastering.
5393
- await applyKv(delta, KV_OPTS_MASTER_CONFIG);
6444
+ await applyKv(delta, MASTER_CONFIG_KV_OPTIONS);
5394
6445
  const summary = Object.entries(delta)
5395
6446
  .map(([k, v]) => `${k}=${v}`)
5396
6447
  .join(", ");
@@ -5399,7 +6450,7 @@ server.registerTool("set_master_effects", {
5399
6450
  // Built-in per-cell drum synth ("machines"). Engine enum index = drumsynth::Engine
5400
6451
  // (Off = 0). The roster, the curated defaults and the engine-switch rule all come
5401
6452
  // from shared/drumSynthEngines.json — the registry build.js codegens the C++ cook
5402
- // and the manifest from, and interface/src/dsp/modules/drumSynth.ts derives its
6453
+ // and the manifest from, and interface/src/utils/drumSynth.ts derives its
5403
6454
  // own tables from. The hand-copied table this replaced had drifted on Kick,
5404
6455
  // Cowbell and 808 (issue #999); the switch rule it shipped with carried knob
5405
6456
  // positions where the plugin carries engine units.
@@ -5422,6 +6473,14 @@ const DRUM_SYNTH_REGISTRY = (() => {
5422
6473
  })();
5423
6474
  const drumSynth = createDrumSynthCatalog(DRUM_SYNTH_REGISTRY);
5424
6475
  const DRUM_SYNTH_ENGINE_NAMES = drumSynth.engineNames;
6476
+ const DRUM_SYNTH_MODE_ROSTER = drumSynth.engines
6477
+ .filter((e) => e.modes.length > 0)
6478
+ .map((e) => `${e.name}=[${e.modes.map((m) => m.name).join(",")}]`)
6479
+ .join("; ");
6480
+ const DRUM_SYNTH_HOLD_ENGINES = drumSynth.engines
6481
+ .filter((e) => e.hold || e.modes.some((m) => m.hold))
6482
+ .map((e) => (e.hold ? e.name : `${e.name} (${e.modes.filter((m) => m.hold).map((m) => m.name).join("/")} mode)`))
6483
+ .join(", ");
5425
6484
  /** State carries the bare enum; agents reason about the name. Report both. */
5426
6485
  function describeSynthEngine(raw) {
5427
6486
  const id = Math.round(Number(raw ?? 0));
@@ -5439,12 +6498,28 @@ server.registerTool("set_drum_synth", {
5439
6498
  "(re-mapped through the destination's own range), so Kick Decay 0.41 becomes Stab Decay 0.336 — the same 198 ms. " +
5440
6499
  "Untouched slots, and slots whose label or unit has no counterpart, take the destination's default. Per-engine p-labels: " +
5441
6500
  drumSynth.engines.map((e) => `${e.name}=[${e.params.map((p) => p.label).join(",")}]`).join("; ") + ". " +
6501
+ `Engine modes are alternative topologies over the same knobs: ${DRUM_SYNTH_MODE_ROSTER}. A mode belongs to ` +
6502
+ "its engine: a change of engine without mode selects the first one, the original render; re-selecting the " +
6503
+ "cell's current engine keeps its mode. hold:true makes each new " +
6504
+ "hit sustain at its post-attack level for as long as its note is held (a sequencer step's gate length or a " +
6505
+ `held MIDI key), then decay at the live Decay rate. Only ${DRUM_SYNTH_HOLD_ENGINES} sustain; on any other ` +
6506
+ "engine or mode the flag is stored but has no effect. The synth-only output stage after the voice sum is four " +
6507
+ "ordinary parameters, set with set_parameters: cell{N}_synthFilterMode (0 Off, 1 LP, 2 BP, 3 HP), " +
6508
+ "cell{N}_synthCutoff (Hz), cell{N}_synthReso (%), cell{N}_synthDrive (%). " +
5442
6509
  "NOTE: requires a PAM build with drum synth (manifest.drumSynth.enabled). This headless tool is product-gated, " +
5443
6510
  "not gated by the UI-only drumSynthEnabled preference. " +
5444
6511
  "On products without it, this tool returns a product error instead of writing inert synth keys.",
5445
6512
  inputSchema: {
5446
6513
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
5447
6514
  engine: z.enum(DRUM_SYNTH_ENGINE_NAMES).describe("Engine name. 'Off' disables the synth voice (sample-only cell)."),
6515
+ mode: z
6516
+ .string()
6517
+ .optional()
6518
+ .describe(`Engine mode name, case-insensitive: ${DRUM_SYNTH_MODE_ROSTER}. Other engines have none. Omitted: kept on the same engine, the first mode on a change.`),
6519
+ hold: z
6520
+ .boolean()
6521
+ .optional()
6522
+ .describe(`Sustain each hit while its note is held, then decay (cell{N}_synthHold). Sustains only on ${DRUM_SYNTH_HOLD_ENGINES}; a trigger_midi note-on then rings until its noteOn:false.`),
5448
6523
  volume: z.number().min(-60).max(12).optional().describe("Synth output level in dB (default 0)."),
5449
6524
  p1: z.number().min(0).max(1).optional().describe("Param 1 (engine-specific; usually Tune). Overrides the engine default."),
5450
6525
  p2: z.number().min(0).max(1).optional().describe("Param 2 (engine-specific)."),
@@ -5455,7 +6530,7 @@ server.registerTool("set_drum_synth", {
5455
6530
  p7: z.number().min(0).max(1).optional().describe("Param 7 (engine-specific)."),
5456
6531
  p8: z.number().min(0).max(1).optional().describe("Param 8 (engine-specific)."),
5457
6532
  },
5458
- }, async ({ cellId, engine, volume, ...overrides }) => {
6533
+ }, async ({ cellId, engine, mode, hold, volume, ...overrides }) => {
5459
6534
  const check = await requireBridge();
5460
6535
  if (check !== true)
5461
6536
  return check;
@@ -5469,10 +6544,17 @@ server.registerTool("set_drum_synth", {
5469
6544
  const engineId = DRUM_SYNTH_ENGINE_NAMES.indexOf(engine);
5470
6545
  if (engineId < 0)
5471
6546
  return error(`Unknown drum synth engine: ${engine}`);
6547
+ const desc = drumSynth.getEngine(engineId);
6548
+ const modes = desc?.modes ?? [];
6549
+ const modeId = mode === undefined ? -1 : modes.findIndex((m) => m.name.toLowerCase() === mode.trim().toLowerCase());
6550
+ if (mode !== undefined && modeId < 0) {
6551
+ return error(modes.length > 0
6552
+ ? `Unknown ${engine} mode: ${mode}. Valid modes: ${modes.map((m) => m.name).join(", ")}.`
6553
+ : `${engine} has no engine modes. Modes exist on ${DRUM_SYNTH_MODE_ROSTER}.`);
6554
+ }
5472
6555
  const stateRaw = await bridge.send("getState");
5473
6556
  const state = isRecord(stateRaw) ? stateRaw : {};
5474
6557
  const delta = drumSynth.buildEngineSwitchDelta(cellId, engineId, state);
5475
- const desc = drumSynth.getEngine(engineId);
5476
6558
  if (desc) {
5477
6559
  for (const [key, value] of Object.entries(overrides)) {
5478
6560
  if (value === undefined)
@@ -5484,10 +6566,26 @@ server.registerTool("set_drum_synth", {
5484
6566
  }
5485
6567
  if (volume !== undefined)
5486
6568
  delta[`cell${cellId}_synthVolume`] = volume;
5487
- // synthEngine is a graph-render key → needs a graph dispatch (KV_OPTS_STANDARD).
5488
- await applyKv(delta, KV_OPTS_STANDARD);
6569
+ if (modeId >= 0)
6570
+ delta[`cell${cellId}_synthMode`] = modeId;
6571
+ if (hold !== undefined)
6572
+ delta[`cell${cellId}_synthHold`] = hold ? 1 : 0;
6573
+ // synthEngine is a graph-render key → needs a graph dispatch (STATE_UPDATE_KV_OPTIONS).
6574
+ await applyKv(delta, STATE_UPDATE_KV_OPTIONS);
5489
6575
  const labels = desc ? desc.params.map((p) => p.label).join(",") : "—";
5490
- return text(`Cell ${cellId} drum synth → ${engine}${engineId > 0 ? ` (p1..pN = ${labels})` : ""}.`);
6576
+ let summary = `Cell ${cellId} drum synth → ${engine}${engineId > 0 ? ` (p1..pN = ${labels})` : ""}.`;
6577
+ if (modeId >= 0)
6578
+ summary += ` Mode ${modes[modeId].name}.`;
6579
+ if (hold === false)
6580
+ summary += " Hold off.";
6581
+ if (hold === true) {
6582
+ const playedMode = Math.round(Number(delta[`cell${cellId}_synthMode`] ?? state[`cell${cellId}_synthMode`] ?? 0));
6583
+ summary += drumSynth.holdsInMode(engineId, playedMode)
6584
+ ? " Hold on: each hit sustains while its note is held."
6585
+ : ` Hold on, but ${engine}${modes.length > 0 ? ` in mode ${modes[playedMode]?.name ?? modes[0].name}` : ""} ` +
6586
+ `does not sustain; it takes effect on ${DRUM_SYNTH_HOLD_ENGINES}.`;
6587
+ }
6588
+ return text(summary);
5491
6589
  });
5492
6590
  server.registerTool("set_drum_synth_wave", {
5493
6591
  title: "Set Drum Synth Wave Curve (Wave engine)",
@@ -5545,7 +6643,7 @@ const playerSlotArg = (n) => z
5545
6643
  server.registerTool("list_players", {
5546
6644
  title: "List Generative Players",
5547
6645
  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 " +
6646
+ "seeded, transport-synced cycle (EUCLID, KIT, TURING, WALK, MARKOV, RATCHET, PHASE, RIFF, MASK, PERC). Returns each " +
5549
6647
  "player's blurb, defaults, P-slot vocabulary (detented positions or 0..1 knobs) and curated preset names, plus " +
5550
6648
  "the role spread (which player each cell arms by default). Offline — no bridge needed. Arm one with set_player.",
5551
6649
  inputSchema: {
@@ -5580,13 +6678,29 @@ server.registerTool("list_players", {
5580
6678
  ],
5581
6679
  });
5582
6680
  });
6681
+ /** The plugin's default-source rule (PlayerListenSection): a cell worth hearing runs a Player or has drawn
6682
+ * notes, and for Harmonic its notes are pitches (`cellNotesArePitches`). */
6683
+ function cellSounds(state, cells, id, mode, midiProduct) {
6684
+ const raw = cells[String(id)];
6685
+ const cell = isRecord(raw) ? raw : {};
6686
+ const drawn = state[`cell${id}_midiSequence`];
6687
+ const sounds = cellPlayerType(cell) > 0 || (Array.isArray(drawn) && drawn.length > 0);
6688
+ if (!sounds || mode !== 3)
6689
+ return sounds;
6690
+ const mono = !midiProduct && Math.round(Number(cell.isPoly ?? 0)) !== 1;
6691
+ const drumPlayer = getPlayerSpec(cellPlayerType(cell))?.tag === "drum";
6692
+ return !mono && (!drumPlayer || Math.round(Number(cell.playerPatternMode ?? 0)) === 1);
6693
+ }
5583
6694
  server.registerTool("set_player", {
5584
6695
  title: "Set Generative Player",
5585
6696
  description: "Arm, edit or switch off a cell's generative Player (PAM-only). Passing `player` arms that generator with the " +
5586
6697
  "roster's own voicing (or a curated `preset`), the track genre on genre-aware players, and a reversible poly " +
5587
6698
  "arm on melodic ones; omit `player` to edit the running player's values in place. Values land in both the cell " +
5588
6699
  "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 " +
6700
+ "cell's pre-arm polyphony and scale. A melodic arm in the plugin UI also gives a chromatic cell the house or song " +
6701
+ "scale; here the scale stays update_cell_config's (scaleName/scaleRoot). Pass p1 to choose PERC's drum; without it " +
6702
+ "PERC arms on Conga. A plugin-UI arm also takes the cell's snap as the grid when the cell never chose one; " +
6703
+ "here pass grid, or the cell keeps its own (1/4 when unset). The player sounds only while the transport runs. The UI shows a running player " +
5590
6704
  "unprompted; to arm from the UI as well, update_settings { experimentalMode: true, experimentalGenerativePatterns: true }.",
5591
6705
  inputSchema: {
5592
6706
  cellIds: z
@@ -5616,19 +6730,33 @@ server.registerTool("set_player", {
5616
6730
  patternMode: z
5617
6731
  .enum(PATTERN_MODES)
5618
6732
  .optional()
5619
- .describe("'mask' (default): the player replaces the drawn pattern. 'keep': the drawn pattern keeps sounding and the player layers over it."),
6733
+ .describe("'mask' (default, shown as Replace in the UI): the player replaces the drawn pattern. 'keep' (shown as Layer): the drawn pattern keeps sounding and the player layers over it."),
6734
+ grid: z
6735
+ .enum(PLAYER_GRIDS)
6736
+ .optional()
6737
+ .describe("The step the player generates on, named as the snap menu names it: 1/N of a beat, so '1/4' is sixteenths (the default), " +
6738
+ "'1/3' eighth-note triplets, '1/2' eighths. A cycle holds at most 128 steps; a longer pattern stretches the step. " +
6739
+ "Swing is off on 1/3, 1/5 and 1/9. Kept per cell; it never follows the snap menu."),
5620
6740
  listenMode: z
5621
6741
  .enum(LISTEN_MODES)
5622
6742
  .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."),
6743
+ .describe("Ensemble listening on the source's last completed cycle: complement gives way where it played and fills its rests, " +
6744
+ "answer replies half a phrase later, harmonic favours the notes it sounds. A soft source note pushes less than the loudest notes the cell hears. " +
6745
+ "Switching listening on without listenSource hears the cell's role partner (the bass hears the kick), as the plugin UI does."),
6746
+ listenSource: z.number().int().min(0).max(8).optional().describe("Listening source: 0 = all other cells, 1-8 = one cell (a cell's own number is skipped)."),
6747
+ listenAmount: z.number().min(0).max(1).optional().describe("Listening strength 0..1: a nudge low down; at 1 the relationship decides which candidates play."),
6748
+ listenPair: z
6749
+ .enum(LISTEN_PAIRS)
6750
+ .optional()
6751
+ .describe("Two cells that complement each other: 'keep' gives this cell the steps they share (its partner gives way), " +
6752
+ "'trade' never settles (they swap those steps every cycle), 'auto' (default) lets the lower cell keep them. " +
6753
+ "Written to both cells of the pair; refused while a cell complements several cells."),
5626
6754
  poly: z
5627
6755
  .boolean()
5628
6756
  .optional()
5629
6757
  .describe("Override the automatic poly arm: true forces poly, false keeps the cell mono even for a melodic player."),
5630
6758
  },
5631
- }, async ({ cellIds, player, preset, genre, density, mutate, p1, p2, p3, p4, p5, seed, patternMode, listenMode, listenSource, listenAmount, poly }) => {
6759
+ }, async ({ cellIds, player, preset, genre, density, mutate, p1, p2, p3, p4, p5, seed, patternMode, grid, listenMode, listenSource, listenAmount, listenPair, poly }) => {
5632
6760
  const check = await requireBridgeForCells(cellIds);
5633
6761
  if (check !== true)
5634
6762
  return check;
@@ -5636,6 +6764,7 @@ server.registerTool("set_player", {
5636
6764
  const gate = playerProductError(manifest);
5637
6765
  if (gate)
5638
6766
  return gate;
6767
+ const midiProduct = !!manifest && isMsqManifest(manifest);
5639
6768
  const playerRef = typeof player === "string" ? player.trim().toLowerCase() : player;
5640
6769
  const wantsOff = playerRef === "off" || playerRef === 0;
5641
6770
  const wantsRole = playerRef === "role";
@@ -5670,11 +6799,20 @@ server.registerTool("set_player", {
5670
6799
  config.isPoly = prePoly;
5671
6800
  if (preRoot !== undefined)
5672
6801
  config.polyRootNote = preRoot;
6802
+ // The scale a melodic arm seeded in the plugin UI goes back with the player.
6803
+ const preScale = typeof cell.playerPreScale === "string" && cell.playerPreScale !== NO_SCALE_ARM ? cell.playerPreScale : undefined;
6804
+ if (preScale !== undefined) {
6805
+ config.scaleName = preScale;
6806
+ config.scaleRoot = recorded("playerPreScaleRoot") ?? 0;
6807
+ }
5673
6808
  config.playerPrePoly = NO_POLY_ARM;
5674
6809
  config.playerPreRoot = NO_POLY_ARM;
6810
+ config.playerPreScale = NO_SCALE_ARM;
6811
+ config.playerPreScaleRoot = NO_POLY_ARM;
5675
6812
  cellsDelta[String(cellId)] = config;
5676
6813
  lines.push(`Cell ${cellId}: ${getPlayerSpec(currentType)?.name ?? "player"} off — drawn pattern restored` +
5677
- `${prePoly !== undefined || preRoot !== undefined ? ", pre-arm polyphony restored" : ""}.`);
6814
+ `${prePoly !== undefined || preRoot !== undefined ? ", pre-arm polyphony restored" : ""}` +
6815
+ `${preScale !== undefined ? ", pre-arm scale restored" : ""}.`);
5678
6816
  continue;
5679
6817
  }
5680
6818
  const roleConfig = wantsRole ? rolePlayerConfigFor(cellId) : undefined;
@@ -5726,12 +6864,23 @@ server.registerTool("set_player", {
5726
6864
  config.playerSeed = seed === "random" ? randomPlayerSeed() : Math.round(seed);
5727
6865
  if (patternMode)
5728
6866
  config.playerPatternMode = PATTERN_MODES.indexOf(patternMode);
6867
+ if (grid)
6868
+ config.playerGrid = playerGridValue(grid);
5729
6869
  if (listenMode)
5730
6870
  config.playerListenMode = LISTEN_MODES.indexOf(listenMode);
5731
- if (listenSource !== undefined)
6871
+ if (listenSource !== undefined && listenSource !== cellId)
5732
6872
  config.playerListenSource = listenSource;
6873
+ // Switching listening on with no source named hears the role partner, never the whole rack by default.
6874
+ const startsListening = (listenSource === undefined || listenSource === cellId) && (config.playerListenMode ?? 0) > 0 &&
6875
+ Math.round(Number(cell.playerListenMode ?? 0)) <= 0 && Math.round(Number(cell.playerListenSource ?? 0)) === 0;
6876
+ if (startsListening) {
6877
+ const mode = config.playerListenMode;
6878
+ config.playerListenSource = listenPartnerFor(cellId, mode, 8, (id) => cellSounds(state, cellsObj, id, mode, midiProduct));
6879
+ }
5733
6880
  if (listenAmount !== undefined)
5734
6881
  config.playerListenAmount = listenAmount;
6882
+ if (listenPair)
6883
+ config.playerListenPair = LISTEN_PAIRS.indexOf(listenPair);
5735
6884
  // Reversible poly arm: record the pre-arm values once, never the armed state.
5736
6885
  const autoPoly = arming && poly !== false && (isMelodicPlayer(spec) || roleConfig?.isPoly === 1);
5737
6886
  let polyNote = "";
@@ -5771,16 +6920,74 @@ server.registerTool("set_player", {
5771
6920
  parts.push(`seed ${config.playerSeed}`);
5772
6921
  if (patternMode)
5773
6922
  parts.push(`pattern ${patternMode}`);
6923
+ if (grid)
6924
+ parts.push(`grid ${grid}`);
5774
6925
  if (listenMode)
5775
- parts.push(`listen ${listenMode}`);
6926
+ parts.push(`listen ${listenMode}${config.playerListenSource !== undefined ? ` to ${config.playerListenSource > 0 ? `cell ${config.playerListenSource}` : "all"}` : ""}`);
6927
+ if (listenSource === cellId)
6928
+ parts.push(`source ${cellId} skipped (a cell cannot listen to itself)`);
6929
+ if (listenPair)
6930
+ parts.push(`pair ${listenPair}`);
5776
6931
  lines.push(`Cell ${cellId}: ${spec.name} ${arming ? "armed" : "updated"} — ${parts.join(", ")}${polyNote}.`);
5777
6932
  }
6933
+ // A pair's choice lands on both cells, as the plugin's pair chips write it.
6934
+ if (listenPair) {
6935
+ const cellOf = (id) => {
6936
+ const raw = cellsObj[String(id)];
6937
+ return { ...(isRecord(raw) ? raw : {}), ...(cellsDelta[String(id)] ?? {}) };
6938
+ };
6939
+ const choice = LISTEN_PAIRS.indexOf(listenPair);
6940
+ const partnerChoice = listenPair === "keep" ? LISTEN_PAIRS.indexOf("auto") : choice;
6941
+ for (const cellId of cellIds) {
6942
+ if (!cellsDelta[String(cellId)])
6943
+ continue;
6944
+ const partner = soleComplementPartner(cellOf, cellId, 8);
6945
+ if (partner < 0) {
6946
+ const crowded = crowdedComplementCell(cellOf, cellId, 8);
6947
+ return error(`Cell ${crowded} complements several cells, so one pair choice would settle all of them; give cell ${crowded} one listenSource first.`);
6948
+ }
6949
+ if (partner === 0) {
6950
+ lines.push(`Cell ${cellId}: no cell complements it back yet; its pair choice applies once one does.`);
6951
+ continue;
6952
+ }
6953
+ if (listenPair === "keep" && cellIds.includes(partner))
6954
+ return error(`Cells ${cellId} and ${partner} complement each other: name one of them for listenPair 'keep'.`);
6955
+ cellsDelta[String(partner)] = { ...(cellsDelta[String(partner)] ?? {}), playerListenPair: partnerChoice };
6956
+ if (!cellIds.includes(partner))
6957
+ lines.push(`Cell ${partner}: ${listenPair === "keep" ? `gives cell ${cellId} the steps they share` : `pair ${listenPair}`}.`);
6958
+ }
6959
+ }
5778
6960
  if (Object.keys(cellsDelta).length === 0)
5779
6961
  return text(lines.join("\n"));
5780
- await applyKv({ ...flatDelta, cells: cellsDelta }, KV_OPTS_CELL_CONFIG);
6962
+ await applyKv({ ...flatDelta, cells: cellsDelta }, CELL_CONFIG_KV_OPTIONS);
5781
6963
  return text(`${lines.join("\n")}\n` +
5782
6964
  "Players sound only while the transport runs; get_design_overview reports each cell's player.");
5783
6965
  });
6966
+ server.registerTool("keep_player", {
6967
+ title: "Keep a Player's Cycles",
6968
+ description: "Commit what a cell's generative Player played into its drawn pattern (PAM-only), through the plugin UI so it is " +
6969
+ "one undo step: 'cycle' merges the cycle playing now, 'previous' the one before it, 'last-two' both, and 'freeze' " +
6970
+ "merges the cycle and switches the Player off. Merges are unions with the drawn notes. Needs the PAM window open " +
6971
+ "and the transport having run a cycle; the commit is asynchronous, so read cell{N}_midiSequence with get_state after.",
6972
+ inputSchema: {
6973
+ cellId: z.number().int().min(1).max(8).describe("The Player cell (1-8)."),
6974
+ keep: z.enum(["cycle", "previous", "last-two", "freeze"]).describe("Which cycles to commit; 'freeze' also switches the Player off."),
6975
+ },
6976
+ }, async ({ cellId, keep }) => {
6977
+ const check = await requireBridgeForCells(cellId);
6978
+ if (check !== true)
6979
+ return check;
6980
+ const gate = playerProductError(await loadManifestForProductGuard());
6981
+ if (gate)
6982
+ return gate;
6983
+ const cells = await bridge.send("getState", "cells").catch(() => undefined);
6984
+ const cell = isRecord(cells) ? cells[String(cellId)] : undefined;
6985
+ if (!(isRecord(cell) && Number(cell.playerType) > 0))
6986
+ return error(`Cell ${cellId} runs no Player — nothing to keep.`);
6987
+ await bridge.send("playerKeep", { cellId, keep });
6988
+ return text(`Cell ${cellId}: ${keep === "freeze" ? "freeze" : `keep ${keep}`} sent to the plugin window. ` +
6989
+ "Nothing lands if that cycle has not played yet; check cell" + cellId + "_midiSequence.");
6990
+ });
5784
6991
  /** A cell's sample as a readable file: absolute as given, else relative to a library root. */
5785
6992
  async function resolveCellSampleFile(filePath) {
5786
6993
  const expanded = expandHome(filePath);
@@ -5870,7 +7077,7 @@ server.registerTool("set_slices", {
5870
7077
  };
5871
7078
  if (currentKnob < 1 || currentKnob > sliceCount)
5872
7079
  delta[`cell${cellId}_slice`] = 1;
5873
- await applyKv(delta, KV_OPTS_CELL_CONFIG);
7080
+ await applyKv(delta, CELL_CONFIG_KV_OPTIONS);
5874
7081
  let sequenceNote = "";
5875
7082
  if (sequence) {
5876
7083
  const transportRaw = await bridge.send("getTransport").catch(() => undefined);
@@ -5908,7 +7115,10 @@ server.registerTool("set_slices", {
5908
7115
  [`cell${cellId}_slice`]: 1,
5909
7116
  cells: { [cellId]: { modLatch: 1 } },
5910
7117
  __forceRender__: true,
5911
- }, KV_OPTS_PATTERN);
7118
+ },
7119
+ // Pattern lengths are DAW params and modLatch is cell config: interop's
7120
+ // applySliceSequenceFromBoundaries syncs both, so this must too.
7121
+ { ...PATTERN_EDIT_KV_OPTIONS, syncConfigResources: true, updateParameters: true });
5912
7122
  sequenceNote = ` Chopped into the slice + note lanes over ${patternLength} beats at ${bpm} BPM; start_transport to hear it.`;
5913
7123
  }
5914
7124
  const positions = resolved.slice(1, -1).map((b) => `${Math.round(b * 1000) / 10}%`);
@@ -5925,11 +7135,12 @@ server.registerTool("add_param_seq", {
5925
7135
  cellId: z.number().min(1).max(8).describe("Cell ID (1-8) — used for routing."),
5926
7136
  sequence: z
5927
7137
  .array(z.number().min(0).max(1))
7138
+ .max(128)
5928
7139
  .optional()
5929
- .describe("Step values (0-1). Defaults to 16 steps of 0.5."),
7140
+ .describe("Step values (0-1), up to 128. Defaults to 16 steps of 0.5."),
5930
7141
  strength: z.number().min(0).max(1).default(0.5).describe("Modulation depth (0-1)."),
5931
7142
  bipolar: z.union([z.literal(0), z.literal(1)]).default(0).describe("0 = unipolar, 1 = bipolar."),
5932
- length: z.number().min(0.0625).max(16).default(1).describe("Pattern length in bars."),
7143
+ length: z.number().min(1 / 64).max(16).default(1).describe("Pattern length in bars."),
5933
7144
  speed: z.number().min(0.0625).max(16).default(1).describe("Pattern speed multiplier."),
5934
7145
  freeMode: z
5935
7146
  .union([z.literal(0), z.literal(1)])
@@ -5945,13 +7156,25 @@ server.registerTool("add_param_seq", {
5945
7156
  if (check !== true)
5946
7157
  return check;
5947
7158
  const index = await getParameterIndex();
5948
- const param = index.get(paramId);
5949
- if (!param)
5950
- return error(formatUnknownParamError(paramId, index, { modulatableOnly: true }));
5951
- if (!param.isModulatable)
5952
- return error(formatNotModulatableError(paramId, index));
7159
+ const param = await resolveModulationTarget(paramId, index);
7160
+ if (typeof param === "string")
7161
+ return error(param);
5953
7162
  const existing = await bridge.send("getState", "paramSeqs");
5954
7163
  const paramSeqs = (Array.isArray(existing) ? existing : []).filter((seq) => seq?.paramId !== paramId);
7164
+ // The target's effect and macro switch on, as the UI's addParamSeq does.
7165
+ const enables = {};
7166
+ if (param.effect && /^cell\d+$/.test(param.cell ?? ""))
7167
+ enables[`${param.cell}_${param.effect}Enabled`] = 1;
7168
+ if (paramId.startsWith("macro_")) {
7169
+ const macrosRaw = await bridge.send("getState", "macros");
7170
+ const macros = Array.isArray(macrosRaw) ? macrosRaw : [];
7171
+ const held = macros.find((m) => m.paramId === paramId);
7172
+ if (held?.enabled !== 1) {
7173
+ enables.macros = held
7174
+ ? macros.map((m) => (m === held ? { ...m, enabled: 1 } : m))
7175
+ : [...macros, { paramId, enabled: 1, strength: 0.5, bipolar: 0, mode: 0 }];
7176
+ }
7177
+ }
5955
7178
  const newSeq = {
5956
7179
  paramId,
5957
7180
  cellId,
@@ -5967,7 +7190,7 @@ server.registerTool("add_param_seq", {
5967
7190
  stepMode,
5968
7191
  };
5969
7192
  paramSeqs.push(newSeq);
5970
- await applyKv({ paramSeqs }, KV_OPTS_MOD);
7193
+ await applyKv({ paramSeqs, ...enables }, STATE_UPDATE_KV_OPTIONS);
5971
7194
  return text(`Added paramSeq for ${paramId} (${newSeq.sequence.length} steps, strength ${strength}).`);
5972
7195
  });
5973
7196
  server.registerTool("update_param_seq", {
@@ -5976,9 +7199,9 @@ server.registerTool("update_param_seq", {
5976
7199
  "Use add_param_seq first if no paramSeq exists for this paramId.",
5977
7200
  inputSchema: {
5978
7201
  paramId: z.string().describe("Target parameter ID."),
5979
- sequence: z.array(z.number().min(0).max(1)).optional().describe("New step values (0-1)."),
7202
+ sequence: z.array(z.number().min(0).max(1)).max(128).optional().describe("New step values (0-1), up to 128."),
5980
7203
  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."),
7204
+ length: z.number().min(1 / 64).max(16).optional().describe("Pattern length in bars."),
5982
7205
  speed: z.number().min(0.0625).max(16).optional().describe("Pattern speed multiplier."),
5983
7206
  bipolar: z.union([z.literal(0), z.literal(1)]).optional().describe("0 = unipolar, 1 = bipolar."),
5984
7207
  enabled: z.union([z.literal(0), z.literal(1)]).optional().describe("0 = bypass the sequencer, 1 = run it."),
@@ -6010,37 +7233,51 @@ server.registerTool("update_param_seq", {
6010
7233
  return error(`No paramSeq found for ${paramId}. Use add_param_seq first.`);
6011
7234
  }
6012
7235
  paramSeqs[idx] = { ...paramSeqs[idx], ...cleanUpdates };
6013
- await applyKv({ paramSeqs }, KV_OPTS_MOD);
7236
+ await applyKv({ paramSeqs }, STATE_UPDATE_KV_OPTIONS);
6014
7237
  return text(`Updated paramSeq ${paramId}: ${Object.keys(cleanUpdates).join(", ")}`);
6015
7238
  });
6016
7239
  server.registerTool("set_free_sequencer", {
6017
7240
  title: "Set Free Sequencer",
6018
- description: "Write the Free Sequencer's master step contour (top-level `freeSeqSequence`, 2..64 steps of 0..1). Every " +
7241
+ description: "Write the Free Sequencer's master step contour (top-level `freeSeqSequence`, 2..128 steps of 0..1). Every " +
6019
7242
  "parameter sequencer mapped to it (freeMode:1 via add_param_seq / update_param_seq) follows these steps AND its " +
6020
7243
  "clock live, each with its own strength, polarity and smoothing; cell-locked and Gate-mode sequencers keep " +
6021
7244
  "their own steps. An empty contour restores the legacy per-sequencer behaviour.",
6022
7245
  inputSchema: {
6023
- sequence: z.array(z.number().min(0).max(1)).min(2).max(64).describe("Master step values, 0..1 each."),
7246
+ sequence: z
7247
+ .array(z.number().min(0).max(1))
7248
+ .max(128)
7249
+ .describe("Master step values, 0..1 each: 2..128 steps, or [] to clear the contour."),
6024
7250
  },
6025
7251
  }, async ({ sequence }) => {
6026
7252
  const check = await requireBridge();
6027
7253
  if (check !== true)
6028
7254
  return check;
7255
+ if (sequence.length === 1)
7256
+ return error("A contour needs 2..128 steps; pass [] to clear it.");
6029
7257
  await bridge.send("applyFreeSeqSequenceUpdate", { sequence });
7258
+ if (sequence.length === 0)
7259
+ return text("Free Sequencer contour cleared; mapped sequencers use their own steps.");
6030
7260
  return text(`Free Sequencer: ${sequence.length} steps written. Map a paramSeq with freeMode:1 to follow it.`);
6031
7261
  });
7262
+ // getMidiExportEvents' cap (MCPBridge::handleGetMidiExportEvents); check-parity compares the two.
7263
+ const MAX_MIDI_EXPORT_BEATS = 1024;
6032
7264
  server.registerTool("export_midi", {
6033
7265
  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)."),
7266
+ description: "Write the cells' note patterns to a Standard MIDI File (type 1, 480 PPQ, tempo and meter from the transport): " +
7267
+ "one track per cell, across `bars`. MSQ writes each cell on its output channel (Auto = cell number), leaves " +
7268
+ "out a cell with Send off and notes outside a poly cell's Low-High; PAM writes notes on the cell number and " +
7269
+ "CC on the cell's MIDI out channel. Each sending CC out slot's value opens the track at tick 0, its CC lane's " +
7270
+ "locks and releases follow on the lane's own clock, and a pedal still down at the end is lifted there. A Player cell exports the cycles it " +
7271
+ "generates (density-gated, PATTERN Keep layered), rendered by PAM itself; Listen responses are left out. " +
7272
+ "Grid timing from base values: swing, probability, CTRL macros and modulators are not applied. Nothing " +
7273
+ "changes in PAM. Default location " +
7274
+ "is PAM's Exports folder.",
7275
+ inputSchema: {
7276
+ filename: z.string().optional().describe("Base name without extension (default <product>_midi_<epoch>)."),
7277
+ cellIds: z.array(z.number().int().min(1).max(8)).optional().describe("Cells to include (default: every cell with notes or CC out)."),
7278
+ bars: z.number().min(1).max(64).optional().describe("Length in bars of the transport's meter (default: the longest included pattern cycle, at least 1)."),
6042
7279
  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)."),
7280
+ outputDir: z.string().optional().describe("Directory to write into (default: PAM's Exports folder)."),
6044
7281
  },
6045
7282
  }, async ({ filename, cellIds, bars, mode, outputDir }) => {
6046
7283
  const check = await requireBridgeForCells(cellIds);
@@ -6049,28 +7286,38 @@ server.registerTool("export_midi", {
6049
7286
  const manifest = await loadManifestForProductGuard();
6050
7287
  if (manifest && isMfxManifest(manifest))
6051
7288
  return error("export_midi needs sampler cells with note patterns; MFX has none.");
6052
- const [stateRaw, transportRaw, locations] = await Promise.all([
7289
+ const [stateRaw, transportRaw, exportsDir] = await Promise.all([
6053
7290
  bridge.send("getState"),
6054
7291
  bridge.send("getTransport").catch(() => undefined),
6055
- bridge.send("getDefaultLocations").catch(() => undefined),
7292
+ exportsFolder(),
6056
7293
  ]);
6057
7294
  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
- }
7295
+ const transport = isRecord(transportRaw) ? transportRaw : {};
7296
+ const bpm = Number(transport.bpm) || Number(state.bpm) || 120;
7297
+ // A build whose getTransport predates the meter writes 4/4, as it always did.
7298
+ const numerator = Math.round(Number(transport.timeSigNumerator));
7299
+ const denominatorPower = Math.round(Math.log2(Number(transport.timeSigDenominator)));
7300
+ const meter = numerator >= 1 && numerator <= 32 && denominatorPower >= 0 && denominatorPower <= 5
7301
+ ? { numerator, denominator: 2 ** denominatorPower }
7302
+ : { numerator: 4, denominator: 4 };
7303
+ const barBeats = beatsPerBar(meter);
7304
+ const dir = outputDir ? expandHome(outputDir) : exportsDir;
6070
7305
  if (!dir)
6071
7306
  return error("Could not resolve PAM's Exports folder from get_default_locations — pass outputDir.");
6072
7307
  const maxCell = manifestMaxCellId(manifest) ?? 8;
6073
7308
  const ids = (cellIds?.length ? cellIds : Array.from({ length: maxCell }, (_, i) => i + 1)).filter((id) => id <= maxCell);
7309
+ const cellConfig = (id) => {
7310
+ const cells = isRecord(state.cells) ? state.cells : {};
7311
+ const config = cells[String(id)];
7312
+ return isRecord(config) ? config : {};
7313
+ };
7314
+ const playerIds = ids.filter((id) => Number(cellConfig(id).playerType) > 0);
7315
+ // A GRAB follower plays its source's notes, so an empty own grid does not rule it out.
7316
+ const followerIds = ids.filter((id) => Number(cellConfig(id).seqFrom) > 0);
7317
+ const ccIds = ids.filter((id) => CC_OUT_CONFIG_KEYS.some((key) => {
7318
+ const controller = cellConfig(id)[key];
7319
+ return typeof controller === "number" && Number.isInteger(controller) && controller >= 0 && controller <= 119;
7320
+ }));
6074
7321
  const cells = ids
6075
7322
  .map((id) => ({
6076
7323
  id,
@@ -6078,39 +7325,101 @@ server.registerTool("export_midi", {
6078
7325
  patternLength: state[`cell${id}_patternLength`],
6079
7326
  speed: state[`cell${id}_patternSpeed`],
6080
7327
  }))
6081
- .filter((cell) => Array.isArray(cell.sequence) && cell.sequence.length > 0);
7328
+ .filter((cell) => (Array.isArray(cell.sequence) && cell.sequence.length > 0) ||
7329
+ playerIds.includes(cell.id) ||
7330
+ followerIds.includes(cell.id) ||
7331
+ ccIds.includes(cell.id));
6082
7332
  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));
7333
+ return error("Nothing to export — the selected cells carry no notes, run no Player and send no CC out.");
7334
+ const cycleOf = (length, speed) => {
7335
+ const bars = Number(length);
7336
+ const speedIndex = Math.round(Number(speed));
6087
7337
  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, "_");
7338
+ return (Number.isFinite(bars) && bars > 0 ? bars : 4) / mul / barBeats;
7339
+ };
7340
+ // A CC lane loops on its own clock, so its cycle counts toward the default length too.
7341
+ const cycleBars = cells.flatMap((cell) => [
7342
+ cycleOf(cell.patternLength, cell.speed),
7343
+ ...CC_LANES.map(ccLaneIndex)
7344
+ .filter((lane) => {
7345
+ const sequence = state[`cell${cell.id}_ccOut${lane}Sequence`];
7346
+ return Array.isArray(sequence) && sequence.length > 0;
7347
+ })
7348
+ .map((lane) => cycleOf(state[`cell${cell.id}_patternLengthCc${lane}`], state[`cell${cell.id}_patternSpeedCc${lane}`])),
7349
+ ]);
7350
+ const maxBars = Math.max(1, Math.floor(MAX_MIDI_EXPORT_BEATS / barBeats));
7351
+ const totalBars = Math.min(maxBars, bars ?? Math.max(1, Math.ceil(Math.max(...cycleBars))));
7352
+ const totalBeats = totalBars * barBeats;
7353
+ // PAM renders the notes itself (Player cycles included); a build without the route
7354
+ // falls back to tiling the drawn grids here. Any other failure is reported, not tiled.
7355
+ let native;
7356
+ try {
7357
+ native = await bridge.send("getMidiExportEvents", { beats: totalBeats, cellIds: cells.map((cell) => cell.id) });
7358
+ }
7359
+ catch (e) {
7360
+ const msg = e.message;
7361
+ if (!/unknown action|does not support/i.test(msg))
7362
+ return error(`export_midi failed: ${msg}`);
7363
+ }
7364
+ const nativeCells = isRecord(native) && Array.isArray(native.cells) ? native.cells.filter(isRecord) : undefined;
7365
+ const staleBuildNote = !nativeCells && playerIds.length > 0
7366
+ ? `This PAM build predates Player-aware export: cell ${playerIds.join(", ")} exported its drawn grid, not the Player. Update PAM.`
7367
+ : !nativeCells && ccIds.length > 0
7368
+ ? `This PAM build predates CC export: cell ${ccIds.join(", ")} left its CC out behind. Update PAM.`
7369
+ : undefined;
7370
+ const tracks = cells
7371
+ .map((cell) => {
7372
+ const rendered = nativeCells?.find((entry) => Number(entry.cellId) === cell.id);
7373
+ // Only a build without the route lands here without channels; it predates MSQ's output channel.
7374
+ const fallbackChannel = Math.min(16, cell.id);
7375
+ const events = rendered && Array.isArray(rendered.events)
7376
+ ? rendered.events.filter(isRecord).map((e) => ({
7377
+ channel: Number(e.channel) || fallbackChannel,
7378
+ note: Number(e.note),
7379
+ velocity: Number(e.velocity),
7380
+ startBeats: Number(e.startBeats),
7381
+ durationBeats: Number(e.durationBeats),
7382
+ }))
7383
+ : tilePatternEvents(cell.sequence, cell.patternLength, cell.speed, totalBeats, fallbackChannel);
7384
+ const cc = rendered && Array.isArray(rendered.cc)
7385
+ ? rendered.cc.filter(isRecord).map((e) => ({
7386
+ channel: Number(e.channel) || fallbackChannel,
7387
+ controller: Number(e.controller),
7388
+ value: Number(e.value),
7389
+ beats: Number(e.beats),
7390
+ }))
7391
+ : [];
7392
+ return { name: `Cell ${cell.id}`, events, cc };
7393
+ })
7394
+ .filter((track) => track.events.length > 0 || track.cc.length > 0);
7395
+ if (tracks.length === 0)
7396
+ return error(staleBuildNote ?? "Nothing to export — the selected cells rendered no notes or CC over that length.");
7397
+ const notes = [];
7398
+ if (staleBuildNote)
7399
+ notes.push(staleBuildNote);
7400
+ const listening = playerIds.filter((id) => Number(cellConfig(id).playerListenMode) > 0 && cells.some((cell) => cell.id === id));
7401
+ if (nativeCells && listening.length > 0)
7402
+ notes.push(`Cell ${listening.join(", ")} listens to other cells; the file carries its cycles without that response.`);
7403
+ const product = manifest && isMsqManifest(manifest) ? "msq" : "pam";
7404
+ const base = (filename?.trim() || `${product}_midi_${Date.now()}`).replace(/\.mid$/i, "").replace(/[\\/:]/g, "_");
6097
7405
  await mkdir(dir, { recursive: true });
6098
7406
  const written = [];
6099
7407
  if (mode === "stems") {
6100
7408
  for (const track of tracks) {
6101
7409
  const path = join(dir, `${base}_cell${track.name.slice(5)}.mid`);
6102
- await writeFile(path, buildMidiFile(bpm, [track], track.name));
7410
+ await writeFile(path, buildMidiFile(bpm, [track], track.name, meter, totalBeats));
6103
7411
  written.push(path);
6104
7412
  }
6105
7413
  }
6106
7414
  else {
6107
7415
  const path = join(dir, `${base}.mid`);
6108
- await writeFile(path, buildMidiFile(bpm, tracks, base));
7416
+ await writeFile(path, buildMidiFile(bpm, tracks, base, meter, totalBeats));
6109
7417
  written.push(path);
6110
7418
  }
6111
7419
  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"));
7420
+ const ccCount = tracks.reduce((sum, track) => sum + track.cc.length, 0);
7421
+ return text(`Exported ${noteCount} notes${ccCount > 0 ? ` and ${ccCount} CC messages` : ""} from ${tracks.length} cell${tracks.length === 1 ? "" : "s"} over ${totalBars} bar${totalBars === 1 ? "" : "s"}${meter.numerator === 4 && meter.denominator === 4 ? "" : ` of ${meter.numerator}/${meter.denominator}`} at ${bpm} BPM.\n` +
7422
+ [...written.map((path) => `File: ${path}`), ...notes].join("\n"));
6114
7423
  });
6115
7424
  server.registerTool("remove_param_seq", {
6116
7425
  title: "Remove Parameter Sequencer",
@@ -6125,7 +7434,7 @@ server.registerTool("remove_param_seq", {
6125
7434
  return check;
6126
7435
  const existing = await bridge.send("getState", "paramSeqs");
6127
7436
  const paramSeqs = (Array.isArray(existing) ? existing : []).filter((seq) => seq?.paramId !== paramId);
6128
- await applyKv({ paramSeqs }, KV_OPTS_MOD);
7437
+ await applyKv({ paramSeqs }, STATE_UPDATE_KV_OPTIONS);
6129
7438
  return text(`Removed paramSeq for ${paramId}.`);
6130
7439
  });
6131
7440
  server.registerTool("normalize_sample", {
@@ -6138,6 +7447,9 @@ server.registerTool("normalize_sample", {
6138
7447
  const check = await requireBridgeForCells(cellId);
6139
7448
  if (check !== true)
6140
7449
  return check;
7450
+ const loaded = await requireCellSample(cellId);
7451
+ if (loaded !== true)
7452
+ return loaded;
6141
7453
  await bridge.send("normalizeSampleForCell", cellId);
6142
7454
  return text(`Normalized sample in cell ${cellId}.`);
6143
7455
  });
@@ -6157,9 +7469,64 @@ server.registerTool("gain_sample", {
6157
7469
  const check = await requireBridgeForCells(cellId);
6158
7470
  if (check !== true)
6159
7471
  return check;
7472
+ const loaded = await requireCellSample(cellId);
7473
+ if (loaded !== true)
7474
+ return loaded;
6160
7475
  await bridge.send("gainSampleForCell", { cellId, gainDb });
6161
7476
  return text(`Applied ${gainDb > 0 ? "+" : ""}${gainDb} dB to the sample in cell ${cellId}.`);
6162
7477
  });
7478
+ // Mirrors pam::repitch::kMaxCoarseSemitones / kMaxFineCents (modules/sampler/SampleRepitch.h).
7479
+ const REPITCH_COARSE_RANGE = 24;
7480
+ const REPITCH_FINE_RANGE = 100;
7481
+ const PITCH_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
7482
+ const pitchName = (midi) => {
7483
+ const note = Math.round(midi);
7484
+ const cents = Math.round((midi - note) * 100);
7485
+ const name = `${PITCH_NAMES[((note % 12) + 12) % 12]}${Math.floor(note / 12) - 1}`;
7486
+ return cents === 0 ? name : `${name} ${cents > 0 ? "+" : ""}${cents} cents`;
7487
+ };
7488
+ server.registerTool("analyze_sample_pitch", {
7489
+ title: "Analyze Sample Pitch",
7490
+ description: "Detect the fundamental of a cell's single-note sample and suggest the repitch_sample shift that centres it on the nearest C. " +
7491
+ "The sampler's root note is a C, so a sample centred on C is the one the global tuning layer retunes correctly.",
7492
+ inputSchema: {
7493
+ cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
7494
+ },
7495
+ }, async ({ cellId }) => {
7496
+ const check = await requireBridgeForCells(cellId);
7497
+ if (check !== true)
7498
+ return check;
7499
+ const result = (await bridge.send("analyzeSamplePitch", cellId));
7500
+ if (!result?.loaded)
7501
+ return error(`Cell ${cellId} has no readable sample.`);
7502
+ const hz = result.hz ?? 0;
7503
+ if (hz <= 0)
7504
+ return text(`No stable pitch in cell ${cellId}'s sample; tune it by ear with repitch_sample.`);
7505
+ const midi = 69 + 12 * Math.log2(hz / 440);
7506
+ const target = Math.round(midi / 12) * 12;
7507
+ const semitones = Math.round(target - midi) || 0;
7508
+ const cents = Math.round((target - midi - semitones) * 100) || 0;
7509
+ return text(`Cell ${cellId} sounds ${pitchName(midi)} (${hz.toFixed(2)} Hz, confidence ${Math.round((result.confidence ?? 0) * 100)}%). ` +
7510
+ `Nearest C is ${pitchName(target)}: repitch_sample with semitones ${semitones}, cents ${cents}.`);
7511
+ });
7512
+ server.registerTool("repitch_sample", {
7513
+ title: "Repitch Sample",
7514
+ description: "Varispeed a cell's sample by semitones plus cents and render it into a new file (offline, destructive): pitch and length change together, like tape. " +
7515
+ "analyze_sample_pitch suggests the shift that centres a single-note sample on C. The original library file is left unchanged.",
7516
+ inputSchema: {
7517
+ cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
7518
+ semitones: z.number().int().min(-REPITCH_COARSE_RANGE).max(REPITCH_COARSE_RANGE).describe("Coarse shift in semitones."),
7519
+ cents: z.number().min(-REPITCH_FINE_RANGE).max(REPITCH_FINE_RANGE).default(0).describe("Fine shift in cents."),
7520
+ },
7521
+ }, async ({ cellId, semitones, cents }) => {
7522
+ if (semitones === 0 && cents === 0)
7523
+ return error("A zero shift leaves the sample unchanged.");
7524
+ const check = await requireBridgeForCells(cellId);
7525
+ if (check !== true)
7526
+ return check;
7527
+ await bridge.send("repitchSampleForCell", { cellId, semitones, cents });
7528
+ return text(`Repitched the sample in cell ${cellId} by ${semitones} semitones ${cents} cents.`);
7529
+ });
6163
7530
  // Poll the offline-render status flag until the export completes or times out.
6164
7531
  async function pollExportStatus(maxWaitMs = 180_000, pollMs = 500) {
6165
7532
  const t0 = Date.now();
@@ -6168,6 +7535,7 @@ async function pollExportStatus(maxWaitMs = 180_000, pollMs = 500) {
6168
7535
  const status = (await bridge.send("getExportStatus"));
6169
7536
  if (status.complete) {
6170
7537
  lastExportLoudness = status.loudness ?? null;
7538
+ lastExportError = typeof status.error === "string" ? status.error : null;
6171
7539
  return status.success ? "success" : "failed";
6172
7540
  }
6173
7541
  }
@@ -6175,6 +7543,11 @@ async function pollExportStatus(maxWaitMs = 180_000, pollMs = 500) {
6175
7543
  }
6176
7544
  // Loudness report of the last completed bounce (set by pollExportStatus).
6177
7545
  let lastExportLoudness = null;
7546
+ // Native reason for the last failed bounce, when the plugin reports one.
7547
+ let lastExportError = null;
7548
+ function exportFailureDetail() {
7549
+ return lastExportError ? ` ${lastExportError}` : "";
7550
+ }
6178
7551
  function loudnessReportLine() {
6179
7552
  const l = lastExportLoudness;
6180
7553
  if (!l || typeof l.i !== "number")
@@ -6192,7 +7565,8 @@ function loudnessReportLine() {
6192
7565
  }
6193
7566
  server.registerTool("export_audio", {
6194
7567
  title: "Export Audio",
6195
- description: "Render and export audio from the current preset to a WAV file in PAM's Exports folder. " +
7568
+ description: "Render and export audio from the current preset in a running PAM instance to a WAV file in PAM's Exports folder. " +
7569
+ "For a saved preset with no PAM open, use the paid `preset render` command through `pam mcp`'s `run` tool; it supports master, stems, variations and songs. " +
6196
7570
  "Waits for completion. Default mode renders the looping preset for `bars` bars at the current BPM. " +
6197
7571
  "Set `mode` to bounce stems or the whole Song Mode arrangement.\n\n" +
6198
7572
  "Modes: 'main' (full mix, default) · 'individual' (one cell solo, needs cellId) · " +
@@ -6250,7 +7624,7 @@ server.registerTool("export_audio", {
6250
7624
  `Duration: ${durationSecs.toFixed(1)}s (${bars} bars at ${bpm} BPM, ${bitDepth}-bit)` +
6251
7625
  loudnessReportLine());
6252
7626
  if (outcome === "failed")
6253
- return error("Audio export failed.");
7627
+ return error(`Audio export failed.${exportFailureDetail()}`);
6254
7628
  return text(`Export timed out. The render may still be running.\nExpected output: ${outputPath}`);
6255
7629
  }
6256
7630
  // Stem / song-mode modes: build full pass-through options (filename + mode +
@@ -6281,7 +7655,7 @@ server.registerTool("export_audio", {
6281
7655
  `File(s) written to ${SERVER_PRODUCT.name}'s Exports folder, base name: ${filename}.wav` +
6282
7656
  loudnessReportLine());
6283
7657
  if (outcome === "failed")
6284
- return error(`Export failed (mode '${effectiveMode}').`);
7658
+ return error(`Export failed (mode '${effectiveMode}').${exportFailureDetail()}`);
6285
7659
  return text(`Export timed out (mode '${effectiveMode}'). The render may still be running.`);
6286
7660
  });
6287
7661
  // ============================================================
@@ -6333,7 +7707,9 @@ server.registerResource("cell-parameters", new ResourceTemplate(`${SERVER_PRODUC
6333
7707
  });
6334
7708
  server.registerResource("global-parameters", `${SERVER_PRODUCT.resourceScheme}://parameters/global`, {
6335
7709
  title: "Global Parameters",
6336
- description: "Non-cell-specific parameters (volume, crossfade, looper, etc.).",
7710
+ description: IS_MSQ_SERVER
7711
+ ? "Non-cell-specific parameters (LFOs, macros, XY, pattern selectors)."
7712
+ : "Non-cell-specific parameters (volume, crossfade, looper, etc.).",
6337
7713
  mimeType: "application/json",
6338
7714
  }, async (uri) => {
6339
7715
  const params = await getGlobalParameters();
@@ -6362,7 +7738,7 @@ MFX has four effects cells. Audio reaches a cell only when the cable patch route
6362
7738
  }],
6363
7739
  }));
6364
7740
  }
6365
- else {
7741
+ else if (!IS_MSQ_SERVER) {
6366
7742
  server.registerResource("beat-making-guide", "pam://guide/beat-making", {
6367
7743
  title: "PAM Beat-Making Guide",
6368
7744
  description: "Comprehensive guide for creating beats with PAM. Covers cell roles, slicer for loops, " +
@@ -6758,12 +8134,37 @@ if (IS_MFX_SERVER) {
6758
8134
  description: "Diagnose why live tools cannot reach MFX.",
6759
8135
  argsSchema: {},
6760
8136
  }, () => 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` +
8137
+ `1. Run connection_status. If no instance appears, start MFX and enable Settings → Integrations → MCP Bridge.\n` +
6762
8138
  `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
8139
  `3. If an instance appears but cannot connect, it may have closed or be busy with a render; retry once.\n` +
6764
8140
  `4. If a live tool is unavailable, update the MFX plugin as well as the mfx-mcp package.`));
6765
8141
  }
6766
- if (!IS_MFX_SERVER)
8142
+ if (IS_MSQ_SERVER) {
8143
+ server.registerPrompt("sequence-a-part", {
8144
+ title: "Sequence a Part",
8145
+ description: "Turn a musical brief into cells that drive the host's instruments, then save it.",
8146
+ argsSchema: {
8147
+ brief: z.string().describe("Musical brief, e.g. 'rolling acid bass on channel 2 with a filter sweep'."),
8148
+ },
8149
+ }, ({ brief }) => promptText(`Sequence this in MSQ: "${brief}"\n\n` +
8150
+ `1. connection_status, then get_design_overview for what each cell sends now.\n` +
8151
+ `2. set_bpm, and configure_transport for the time signature if the brief needs one.\n` +
8152
+ `3. Wire each cell with update_cell_config: output channel (0 = Auto, the cell number) matching the instrument that should play it, trigger note, poly and scale, Send on.\n` +
8153
+ `4. Notes: update_pattern (type 'note'), or set_player for a generated part (list_players first); randomize target:'sequences' gives a starting point.\n` +
8154
+ `5. CC: update_cell_config ccOutCc1-16 picks controllers, set_parameters cell{N}_ccOut{K} sets their values, update_pattern with type 'ccOut1'-'ccOut4' draws CC lanes. add_modulation adds movement.\n` +
8155
+ `6. start_transport: notes leave only while it rolls.\n` +
8156
+ `7. save_preset and update_preset_metadata; export_midi writes the parts to a MIDI file.`));
8157
+ server.registerPrompt("troubleshoot-bridge", {
8158
+ title: "Troubleshoot the MSQ Bridge",
8159
+ description: "Diagnose why live tools cannot reach MSQ, or why its instruments stay silent.",
8160
+ argsSchema: {},
8161
+ }, () => promptText(`Diagnose the MSQ MCP connection:\n\n` +
8162
+ `1. Run connection_status. If no instance appears, start MSQ and enable Settings → Integrations → MCP Bridge.\n` +
8163
+ `2. MSQ records its actual port on startup. If an instance appears but cannot connect, it may have closed; retry once.\n` +
8164
+ `3. MSQ makes no sound itself. If nothing plays, check each cell's Send and channel with get_design_overview, that the host routes MSQ's MIDI out to an instrument listening on that channel, and that the transport is rolling.\n` +
8165
+ `4. If a live tool is unavailable, update the MSQ plugin as well as the msq-mcp package.`));
8166
+ }
8167
+ if (SERVER_PRODUCT.id === "pam")
6767
8168
  server.registerPrompt("build-a-beat", {
6768
8169
  title: "Build a Beat",
6769
8170
  description: "Guided kit-building + pattern workflow: coherent samples, genre patterns, transport, refinement, save.",
@@ -6778,7 +8179,7 @@ if (!IS_MFX_SERVER)
6778
8179
  `4. start_transport — sequencer output needs the transport rolling.\n` +
6779
8180
  `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
8181
  `6. save_preset once it works, and update_preset_metadata with genre tags and BPM.`));
6781
- if (!IS_MFX_SERVER)
8182
+ if (SERVER_PRODUCT.id === "pam")
6782
8183
  server.registerPrompt("make-a-song", {
6783
8184
  title: "Make a Song",
6784
8185
  description: "Arrange the current preset into sections with variations + Song Mode, then bounce a full track.",
@@ -6791,7 +8192,7 @@ if (!IS_MFX_SERVER)
6791
8192
  `3. Audition: start_transport with Song Mode driving the timeline; queue_variation switches sections live.\n` +
6792
8193
  `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
8194
  `5. The rendered file lands in PAM's Exports/Resamples locations — get_default_locations resolves them.`));
6794
- if (!IS_MFX_SERVER)
8195
+ if (SERVER_PRODUCT.id === "pam")
6795
8196
  server.registerPrompt("design-a-preset", {
6796
8197
  title: "Design a Preset",
6797
8198
  description: "Sound-design workflow from a creative brief to a saved preset.",
@@ -6805,7 +8206,7 @@ if (!IS_MFX_SERVER)
6805
8206
  `4. Add movement: add_modulation (LFO / envelope / vary), add_param_seq for stepped motion.\n` +
6806
8207
  `5. Write patterns with update_pattern, then start_transport to audition.\n` +
6807
8208
  `6. save_preset with a fitting name and update_preset_metadata (category, tags, BPM).`));
6808
- if (!IS_MFX_SERVER)
8209
+ if (SERVER_PRODUCT.id === "pam")
6809
8210
  server.registerPrompt("mix-and-master", {
6810
8211
  title: "Mix and Master",
6811
8212
  description: "Balance the cells and shape the master bus using the analysis + suggestion tools.",
@@ -6816,13 +8217,13 @@ if (!IS_MFX_SERVER)
6816
8217
  `3. Shape the master bus with set_master_effects (compressor, tape, limiter — design_context lists the keys).\n` +
6817
8218
  `4. Re-check get_master_analysis and iterate toward the loudness target.\n` +
6818
8219
  `5. Render with export_audio (mode 'main'; 'all-cells' for stems).`));
6819
- if (!IS_MFX_SERVER)
8220
+ if (SERVER_PRODUCT.id === "pam")
6820
8221
  server.registerPrompt("troubleshoot-bridge", {
6821
8222
  title: "Troubleshoot the PAM Bridge",
6822
8223
  description: "Diagnose why live tools can't reach the plugin.",
6823
8224
  argsSchema: {},
6824
8225
  }, () => 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` +
8226
+ `1. connection_status. No instances → PAM isn't running or the bridge is off (PAM Settings → Integrations → MCP Bridge, default port 9847). Multiple instances → select_instance picks which product the live tools drive.\n` +
6826
8227
  `2. Instances found but not connected → the plugin may have shut down, or its message thread is busy (heavy render); retry once.\n` +
6827
8228
  `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
8229
  `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` +
@@ -6830,20 +8231,11 @@ if (!IS_MFX_SERVER)
6830
8231
  // ============================================================
6831
8232
  // TOOLS — Looper (state deltas)
6832
8233
  // ============================================================
6833
- // applyStateDelta-equivalent options: full update, dispatch graph, sync KV.
6834
- const KV_OPTS_LOOPER_STATE = {
6835
- shouldDispatch: true,
6836
- syncKVResources: true,
6837
- syncConfigResources: true,
6838
- resyncKVAfterDispatch: true,
6839
- updateParameters: true,
6840
- broadcastFollowers: true,
6841
- };
6842
8234
  async function applyLoopersDelta(loopers, extra) {
6843
8235
  const delta = { loopers, ...(extra ?? {}) };
6844
8236
  await bridge.send("applyUnifiedStateKvUpdate", {
6845
8237
  delta,
6846
- options: KV_OPTS_LOOPER_STATE,
8238
+ options: STATE_UPDATE_KV_OPTIONS,
6847
8239
  });
6848
8240
  }
6849
8241
  server.registerTool("arm_looper_track", {
@@ -7146,7 +8538,7 @@ server.registerTool("looper_settings", {
7146
8538
  server.registerTool("looper_variation", {
7147
8539
  title: "Looper Variation",
7148
8540
  description: "Manage looper variation slots (clip-set snapshots). Operations: 'apply' (immediate), 'queue' (bar-synced), " +
7149
- "'cancel', 'clear', 'setCurrent', 'save' (snapshot current looper state into the slot).",
8541
+ "'cancel', 'clear', 'setCurrent', 'save' (write a snapshot object from the `data` field into the slot).",
7150
8542
  inputSchema: {
7151
8543
  op: z
7152
8544
  .enum(["apply", "queue", "cancel", "clear", "setCurrent", "save"])
@@ -7159,7 +8551,7 @@ server.registerTool("looper_variation", {
7159
8551
  .describe("Variation slot 0-7 (required for apply/queue/clear/setCurrent/save)."),
7160
8552
  intervalBars: z
7161
8553
  .number()
7162
- .min(0.125)
8554
+ .min(0.25)
7163
8555
  .max(64)
7164
8556
  .optional()
7165
8557
  .describe("Bar interval for 'queue' (required for queue)."),
@@ -7167,7 +8559,7 @@ server.registerTool("looper_variation", {
7167
8559
  data: z
7168
8560
  .record(z.string(), z.unknown())
7169
8561
  .optional()
7170
- .describe("For 'save': the looper variation snapshot (object). Omit this field to clear the slot instead."),
8562
+ .describe("For 'save' (required): the looper variation snapshot object. Use op 'clear' to empty a slot."),
7171
8563
  },
7172
8564
  }, async (args) => {
7173
8565
  const check = await requireBridge();
@@ -7204,10 +8596,10 @@ server.registerTool("looper_variation", {
7204
8596
  case "save":
7205
8597
  if (args.index === undefined)
7206
8598
  return error("'save' requires index");
7207
- await bridge.send("saveLooperVariation", { index: args.index, data: args.data ?? null });
7208
- return text(args.data
7209
- ? `Saved looper variation slot ${args.index}.`
7210
- : `Cleared looper variation slot ${args.index} (no data).`);
8599
+ if (!args.data)
8600
+ return error("'save' requires data (a looper variation snapshot); use op 'clear' to empty a slot.");
8601
+ await bridge.send("saveLooperVariation", { index: args.index, data: args.data });
8602
+ return text(`Saved looper variation slot ${args.index}.`);
7211
8603
  }
7212
8604
  return text("No-op");
7213
8605
  });
@@ -7216,12 +8608,18 @@ server.registerTool("looper_variation_sync", {
7216
8608
  description: "Configure how looper variation switches synchronize to bar boundaries.",
7217
8609
  inputSchema: {
7218
8610
  barSyncEnabled: z.boolean().optional().describe("Toggle bar-sync mode."),
7219
- intervalBars: z.number().min(0.125).max(64).optional().describe("Sync interval in bars."),
8611
+ intervalBars: z
8612
+ .number()
8613
+ .optional()
8614
+ .describe(`Sync interval in bars: one of ${LOOPER_BAR_SYNC_INTERVALS.join(", ")}.`),
7220
8615
  },
7221
8616
  }, async ({ barSyncEnabled, intervalBars }) => {
7222
8617
  const check = await requireBridge();
7223
8618
  if (check !== true)
7224
8619
  return check;
8620
+ if (intervalBars !== undefined && !LOOPER_BAR_SYNC_INTERVALS.includes(intervalBars)) {
8621
+ return error(`intervalBars must be one of ${LOOPER_BAR_SYNC_INTERVALS.join(", ")}; native resets anything else to 1.`);
8622
+ }
7225
8623
  const updates = [];
7226
8624
  if (typeof barSyncEnabled === "boolean") {
7227
8625
  await bridge.send("setLooperVariationBarSyncEnabled", barSyncEnabled);
@@ -7236,6 +8634,18 @@ server.registerTool("looper_variation_sync", {
7236
8634
  // ============================================================
7237
8635
  // TOOLS — Bar-synced variation scheduling + variation hold
7238
8636
  // ============================================================
8637
+ // Shared by queue_variation and the song_mode transition schema below.
8638
+ const swapSpecSchema = z.object({
8639
+ order: z
8640
+ .array(z.number().int().min(1).max(16))
8641
+ .refine((order) => new Set(order).size === order.length, { message: "swap.order must not repeat a cell" })
8642
+ .optional()
8643
+ .describe("Cell Swap hand-over order as 1-based cell numbers (leftmost swaps first); unlisted cells follow in ascending order. Omit or [] for left to right."),
8644
+ timing: z
8645
+ .enum(["immediate", "step", "bar"])
8646
+ .optional()
8647
+ .describe("When a cell whose threshold the curve crossed hands over: 'immediate' at once (default), 'step' at the next 16th-note step, 'bar' at the next bar line, both counted back from the transition's anchor bar."),
8648
+ });
7239
8649
  server.registerTool("queue_variation", {
7240
8650
  title: "Queue Variation Switch",
7241
8651
  description: "Schedule a global variation switch on the next bar boundary. " +
@@ -7244,7 +8654,7 @@ server.registerTool("queue_variation", {
7244
8654
  "already-engaged queued-flip window only; a from-idle flip hard-switches).",
7245
8655
  inputSchema: {
7246
8656
  index: z.number().min(0).max(7).describe("Variation slot 0-7."),
7247
- intervalBars: z.number().min(0.125).max(64).describe("Bars before switching."),
8657
+ intervalBars: z.number().min(0.25).max(64).describe("Bars before switching."),
7248
8658
  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."),
7249
8659
  resetPhase: z
7250
8660
  .boolean()
@@ -7257,9 +8667,9 @@ server.registerTool("queue_variation", {
7257
8667
  .optional()
7258
8668
  .describe("Pre-anchor morph window in bars for this flip. Omit all transition options to inherit native settings."),
7259
8669
  morphMode: z
7260
- .enum(["auto", "param", "mix", "seq", "all"])
8670
+ .enum(["auto", "param", "mix", "seq", "all", "swap"])
7261
8671
  .optional()
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."),
8672
+ .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; 'swap' hands whole cells over one by one across the window (PAM only; order via transition.swap.order); 'auto' lets the engine pick — mix when the sections differ in graph topology (samples, synth engines, routing) or when nothing can glide, param otherwise."),
7263
8673
  morphCurve: z
7264
8674
  .enum(["linear", "ease", "exp", "log"])
7265
8675
  .optional()
@@ -7270,11 +8680,14 @@ server.registerTool("queue_variation", {
7270
8680
  mixLaw: z.enum(["equalPower", "equalGain", "correlation"]).optional(),
7271
8681
  tail: z.enum(["cut", "fade", "ring"]).optional(),
7272
8682
  tailMaxBars: z.number().min(0.25).max(8).optional(),
8683
+ swap: swapSpecSchema
8684
+ .optional()
8685
+ .describe("Cell Swap settings for a swap morph: order (1-based cells, leftmost first; unlisted cells follow ascending) and timing."),
7273
8686
  })
7274
8687
  .optional()
7275
8688
  .describe("Advanced transition settings for this flip: pattern policy for a " +
7276
- "seq/all morph, blend gain law for a mix/all morph, and the outgoing section's " +
7277
- "tail. Omitted fields keep the defaults (dissolve, equalPower, fade)."),
8689
+ "seq/all morph, blend gain law for a mix/all morph, the Cell Swap order for a swap morph, and the outgoing section's " +
8690
+ "tail. Omitted fields keep the defaults (dissolve, equalPower, left to right, fade)."),
7278
8691
  },
7279
8692
  }, async ({ index, intervalBars, fadeMs, resetPhase, morphBars, morphMode, morphCurve, transition }) => {
7280
8693
  const check = await requireBridge();
@@ -7346,14 +8759,25 @@ server.registerTool("variation_hold", {
7346
8759
  if (check !== true)
7347
8760
  return check;
7348
8761
  const updates = [];
8762
+ // The native router holds the live values; settings persist them the way the UI's
8763
+ // setVariationHoldEnabled / syncVariationHoldBindings do.
8764
+ const persisted = {};
7349
8765
  if (typeof enabled === "boolean") {
7350
8766
  await bridge.send("setVariationHoldEnabled", enabled);
8767
+ persisted.variationHoldEnabled = enabled;
7351
8768
  updates.push(`enabled=${enabled}`);
7352
8769
  }
7353
8770
  if (bindings) {
7354
- await bridge.send("syncVariationHoldBindings", bindings);
8771
+ const slots = Array.from({ length: PRESET_VARIATION_SLOTS }, (_, slot) => bindings[slot] ?? { triggerNote: -1, triggerChannel: 0 });
8772
+ await bridge.send("syncVariationHoldBindings", slots);
8773
+ persisted.variationHoldBindings = slots.map(({ triggerNote, triggerChannel }) => ({
8774
+ triggerNote: triggerNote >= 0 ? triggerNote : null,
8775
+ triggerChannel,
8776
+ }));
7355
8777
  updates.push(`bindings=${bindings.length}`);
7356
8778
  }
8779
+ if (Object.keys(persisted).length)
8780
+ await bridge.send("applySettingsUpdate", persisted);
7357
8781
  if (allNotesOff) {
7358
8782
  await bridge.send("variationHoldAllNotesOff");
7359
8783
  updates.push("allNotesOff");
@@ -7440,6 +8864,21 @@ server.registerTool("midi_recording", {
7440
8864
  }
7441
8865
  return text("No-op");
7442
8866
  });
8867
+ server.registerTool("session_take", {
8868
+ title: "Session Take",
8869
+ description: "Record a session take: the master WAV plus a log of PAM's main window, rendered later to video by pam-render. " +
8870
+ "PAM only; needs Settings > Experimental > Session Recorder. 'start' needs the window open. 'stop' saves in the " +
8871
+ "background: poll 'status' until phase is 'idle' (saved to folder) or 'failed' (error). Returns the recorder status.",
8872
+ inputSchema: {
8873
+ op: z.enum(["start", "stop", "status"]).describe("Operation."),
8874
+ },
8875
+ }, async ({ op }) => {
8876
+ const check = await requireBridge();
8877
+ if (check !== true)
8878
+ return check;
8879
+ const status = await bridge.send("sessionTake", op);
8880
+ return text(typeof status === "string" ? status : JSON.stringify(status));
8881
+ });
7443
8882
  // ============================================================
7444
8883
  // TOOLS — Modulation updates (LFO/Env/Vary/Macro/ParamSeq)
7445
8884
  // ============================================================
@@ -7486,7 +8925,7 @@ server.registerTool("update_modulation", {
7486
8925
  // ============================================================
7487
8926
  const morphCurveEnum = z.enum(["linear", "ease", "exp", "log"]);
7488
8927
  const transitionSpecSchema = z.object({
7489
- mode: z.enum(["auto", "param", "mix", "seq", "all"]).optional(),
8928
+ mode: z.enum(["auto", "param", "mix", "seq", "all", "swap"]).optional(),
7490
8929
  window: z
7491
8930
  .object({
7492
8931
  bars: z.number().min(0).max(8).optional(),
@@ -7508,6 +8947,7 @@ const transitionSpecSchema = z.object({
7508
8947
  })
7509
8948
  .optional(),
7510
8949
  phase: z.enum(["inherit", "reset", "continue"]).optional(),
8950
+ swap: swapSpecSchema.optional().describe("Cell Swap settings (mode 'swap'; PAM only)."),
7511
8951
  });
7512
8952
  const TRANSITION_SPEC_HONESTY = "Advanced transition spec: pattern.policy " +
7513
8953
  "('dissolve' prob-morphs events, 'wipe' replaces them at a moving frontier, 'cells' hands " +
@@ -7515,7 +8955,7 @@ const TRANSITION_SPEC_HONESTY = "Advanced transition spec: pattern.policy " +
7515
8955
  "correlated sections, 'correlation' auto-adapts), audio.tail ('fade' default, 'cut', 'ring' " +
7516
8956
  "lets voices/FX decay up to tailMaxBars), phase ('reset'/'continue' overrides the song's " +
7517
8957
  "Reset Phase per cable), window.patternCurve/mixCurve (per-domain easing; patternCurve " +
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 " +
8958
+ "defaults linear). window.staggerBy (off/cell/category), staggerSpread (0..0.9), staggerOrder (forward/reverse), and relations (off/stepped) shape the parameter glide. swap.order (1-based cells) sets the Cell Swap hand-over order and swap.timing (immediate/step/bar) when a crossed cell hands over, for mode 'swap', which swaps whole cells one by one along the curve (PAM only). Runs on variation sections + queued flips, and on preset destinations " +
7519
8959
  "when the lanePresetSections setting is on (the dual-lane preset section " +
7520
8960
  "pipeline; with it off, preset destinations coerce to a param glide). Offline bounces " +
7521
8961
  "coerce ring→fade, blend→hard switch. 'get' reads back each cable's resolvedTransition " +
@@ -7565,7 +9005,7 @@ server.registerTool("song_mode", {
7565
9005
  sections: z
7566
9006
  .array(z.object({
7567
9007
  variationIndex: z.number().min(0).max(7).describe("Audio variation slot 0-7 this section plays. With presetPath set, this selects the variation INSIDE that preset's own bank — the two compose, they are not mutually exclusive."),
7568
- bars: z.number().positive().optional().describe("Section length in bars (default: auto from longest pattern)."),
9008
+ bars: z.number().int().min(1).optional().describe("Section length in whole bars (default: auto from longest pattern)."),
7569
9009
  repeat: z.number().int().min(1).optional().describe("Times to play before advancing (default 1)."),
7570
9010
  muted: z.boolean().optional().describe("Pass through without an audio trigger."),
7571
9011
  probability: z.number().min(0).max(1).optional().describe("Trigger probability (0-1)."),
@@ -7582,9 +9022,9 @@ server.registerTool("song_mode", {
7582
9022
  .optional()
7583
9023
  .describe("Morph glide easing; default ease. Ignored without morphBars."),
7584
9024
  morphMode: z
7585
- .enum(["auto", "param", "mix", "seq", "all"])
9025
+ .enum(["auto", "param", "mix", "seq", "all", "swap"])
7586
9026
  .optional()
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."),
9027
+ .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; 'swap' hands whole cells over one by one (PAM only). Ignored without morphBars."),
7588
9028
  transition: transitionSpecSchema.optional().describe(TRANSITION_SPEC_HONESTY),
7589
9029
  transitionPreset: z
7590
9030
  .string()
@@ -7628,7 +9068,7 @@ server.registerTool("song_mode", {
7628
9068
  .optional()
7629
9069
  .describe("Per-cable morph easing; overrides the destination section's morphCurve."),
7630
9070
  morphMode: z
7631
- .enum(["auto", "param", "mix", "seq", "all"])
9071
+ .enum(["auto", "param", "mix", "seq", "all", "swap"])
7632
9072
  .optional()
7633
9073
  .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)."),
7634
9074
  transition: transitionSpecSchema.optional().describe("Per-cable " + TRANSITION_SPEC_HONESTY),
@@ -7808,9 +9248,118 @@ server.registerTool("song_mode", {
7808
9248
  }
7809
9249
  return text("No-op");
7810
9250
  });
9251
+ // TOOLS — DJ set (docs/plans/DJ_SINGLE_TIMELINE.md D23)
9252
+ const djBarsSchema = z.union([z.literal(1), z.literal(2), z.literal(4), z.literal(8), z.literal(16)]);
9253
+ // shared/dj/set.schema.json's longest window and ring tail, past song_mode's 8 (check-parity compares them).
9254
+ const DJ_TRANSITION_BARS_MAX = 32;
9255
+ // shared/dj/set.schema.json closes every object, so song_mode's spec is closed here section by section.
9256
+ const djTransitionSpecSchema = transitionSpecSchema
9257
+ .extend({
9258
+ window: transitionSpecSchema.shape.window.unwrap()
9259
+ .extend({ bars: z.number().min(0).max(DJ_TRANSITION_BARS_MAX).optional() })
9260
+ .strict()
9261
+ .optional(),
9262
+ pattern: transitionSpecSchema.shape.pattern.unwrap().strict().optional(),
9263
+ audio: transitionSpecSchema.shape.audio.unwrap()
9264
+ .extend({ tailMaxBars: z.number().min(0.25).max(DJ_TRANSITION_BARS_MAX).optional() })
9265
+ .strict()
9266
+ .optional(),
9267
+ swap: swapSpecSchema.strict().optional().describe("Cell Swap settings (mode 'swap')."),
9268
+ })
9269
+ .strict();
9270
+ const djTransitionSchema = z
9271
+ .object({
9272
+ preset: z.string().min(1).max(64).optional().describe("Transition preset id (drop, rise, break, blend, sweep, fill, or a saved one), stamped first."),
9273
+ spec: djTransitionSpecSchema.optional().describe("Advanced transition spec, merged over the preset one level deep in each section."),
9274
+ bars: z.number().min(0).max(DJ_TRANSITION_BARS_MAX).optional().describe(`Window length in bars, 0 to ${DJ_TRANSITION_BARS_MAX}; 0 is a cut.`),
9275
+ mode: z.enum(["auto", "param", "mix", "seq", "all", "swap"]).optional().describe("AUTO, GLIDE (param), BLEND (mix), STEPS (seq), FULL (all) or SWAP."),
9276
+ curve: morphCurveEnum.optional(),
9277
+ })
9278
+ .strict();
9279
+ const djSetDocumentSchema = z
9280
+ .object({
9281
+ queue: z
9282
+ .array(z
9283
+ .object({
9284
+ presetPath: z.string().min(1).max(4096),
9285
+ variationIndex: z.number().int().min(0).max(7).optional().describe("Variation slot; absent plays the preset's first populated one."),
9286
+ bars: z.number().int().min(1).max(64).optional().describe("The item's own length; absent follows stepBars."),
9287
+ transition: djTransitionSchema.optional().describe("The boundary into this item; on queue[0], used only when it queues behind another NOW."),
9288
+ })
9289
+ .strict())
9290
+ .min(1)
9291
+ .max(65)
9292
+ .describe("queue[0] is NOW; the rest play in order."),
9293
+ stepBars: djBarsSchema.optional().describe("Bars each item plays under Auto (default 4)."),
9294
+ launchBars: djBarsSchema.optional().describe("The grid boundaries land on (default 1)."),
9295
+ auto: z.boolean().optional().describe("Step to the next item every stepBars (default true); off holds NOW until a Go."),
9296
+ autoMix: z.boolean().optional().describe("Auto mix: after its dwell, fill NEXT from the library and go (default false)."),
9297
+ defaultTransition: djTransitionSchema.optional().describe("Every boundary without its own (default AUTO, 8 bars, ease); bars, mode or curve alone edit that default."),
9298
+ tempo: z
9299
+ .object({
9300
+ source: z.enum(["master", "followNext"]).optional().describe("The master tempo, or glide to each NEXT's preset tempo."),
9301
+ glideBars: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(4), z.literal(8)]).optional(),
9302
+ bpm: z.number().min(20).max(400).optional().describe("Master tempo to glide to; refused while a DAW or MIDI clock owns it."),
9303
+ })
9304
+ .strict()
9305
+ .optional(),
9306
+ })
9307
+ .strict();
9308
+ const DJ_SET_BOUNDARY = z.object({}).passthrough();
9309
+ server.registerTool("dj_set", {
9310
+ title: "DJ Set",
9311
+ description: "Load a whole DJ set into PAM's DJ view in one call, or read it back (op 'get'). PAM only; the window must be open " +
9312
+ "and the experimental DJ view on (update_settings {experimentalMode:true, experimentalDjView:true}). queue[0] is " +
9313
+ "NOW. Stopped, the set starts on queue[0] (loading it); playing, nothing is cut: a queue[0] equal to NOW keeps " +
9314
+ "playing and the rest replace the queue, else every item queues behind NOW (nowMatched:false), behind any boundary " +
9315
+ "already under way (kept). With an open set, an omitted top-level field keeps its running value. play:true starts " +
9316
+ "the transport, so with auto (the default) the set runs. Pass `set` or the `presets` shorthand. The reply's `set` " +
9317
+ "is canonical: sending it back changes nothing. Failures are {ok:false, code, error}.",
9318
+ inputSchema: {
9319
+ op: z.enum(["set", "get"]).optional().describe("'set' (default) loads; 'get' reads the open set and takes no other field."),
9320
+ set: djSetDocumentSchema.optional(),
9321
+ presets: z.array(z.string().min(1).max(4096)).min(1).max(65).optional().describe("Shorthand: these presets in order, each at its first populated slot."),
9322
+ play: z.boolean().optional().describe("Start the transport after loading."),
9323
+ },
9324
+ outputSchema: {
9325
+ ok: z.boolean(),
9326
+ open: z.boolean().optional(),
9327
+ playing: z.boolean().optional(),
9328
+ bar: z.number().optional(),
9329
+ bpm: z.number().optional(),
9330
+ timeSig: z.array(z.number()).optional(),
9331
+ set: z.object({}).passthrough().nullable().optional(),
9332
+ now: z.object({ startBar: z.number() }).nullable().optional(),
9333
+ boundaries: z.array(DJ_SET_BOUNDARY).optional(),
9334
+ status: z.string().nullable().optional(),
9335
+ nowMatched: z.boolean().optional(),
9336
+ kept: z.number().optional(),
9337
+ code: z.string().optional(),
9338
+ error: z.string().optional(),
9339
+ },
9340
+ }, async ({ op, set, presets, play }) => {
9341
+ if ((op ?? "set") === "get" ? Boolean(set || presets) || play !== undefined : Boolean(set) === Boolean(presets))
9342
+ return packFailure("dj_invalid_set", op === "get" ? "op 'get' takes no other field" : "Pass exactly one of set and presets.");
9343
+ const check = await requireBridge();
9344
+ if (check !== true)
9345
+ return check;
9346
+ const product = await connectedProduct();
9347
+ if (product === "MFX" || product === "GATE" || product === "MSQ")
9348
+ return packFailure("dj_unsupported_product", `${product} has no DJ view; dj_set is a PAM feature.`);
9349
+ const reply = parseBridgeValue(await bridge.send("djSet", {
9350
+ ...(op ? { op } : {}),
9351
+ ...(set ? { set } : {}),
9352
+ ...(presets ? { presets } : {}),
9353
+ ...(play !== undefined ? { play } : {}),
9354
+ }));
9355
+ if (isPackFailure(reply))
9356
+ return packFailure(reply.code, reply.error);
9357
+ return structured(reply);
9358
+ });
7811
9359
  server.registerTool("export_song", {
7812
9360
  title: "Export Song (Full Track)",
7813
- description: "Bounce the full Song Mode arrangement — the 'render my whole track' button. " +
9361
+ description: "Bounce the full Song Mode arrangement in a running PAM instance — the 'render my whole track' button. " +
9362
+ "For a saved preset song with no PAM open, use the paid `preset render` command with song:true through `pam mcp`'s `run` tool. " +
7814
9363
  "Set up the arrangement first with the `song_mode` tool (prefer op setArrangement), then call this.\n\n" +
7815
9364
  "Strategies:\n" +
7816
9365
  "• 'live' + includeVideo:true — full A/V render: plays the arrangement in real time, films the VX " +
@@ -8069,7 +9618,7 @@ server.registerTool("preview_slice", {
8069
9618
  description: "Audition a single slice of a cell's sample.",
8070
9619
  inputSchema: {
8071
9620
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
8072
- sliceNumber: z.number().min(0).describe("Slice index (0-based)."),
9621
+ sliceNumber: z.number().int().min(1).describe("Slice number, 1-based as in the UI (1 = first slice)."),
8073
9622
  },
8074
9623
  }, async ({ cellId, sliceNumber }) => {
8075
9624
  const check = await requireBridgeForCells(cellId);
@@ -8097,12 +9646,15 @@ server.registerTool("resample_sample_to_bpm", {
8097
9646
  .optional()
8098
9647
  .describe("Target BPM (defaults to current project BPM)."),
8099
9648
  },
8100
- }, async (args) => {
9649
+ }, async ({ cellId, sourceBpm, targetBpm }) => {
8101
9650
  const check = await requireBridge();
8102
9651
  if (check !== true)
8103
9652
  return check;
8104
- await bridge.send("resampleSampleToBPM", args);
8105
- return text(`Resampled cell ${args.cellId} to BPM.`);
9653
+ const loaded = await requireCellSample(cellId);
9654
+ if (loaded !== true)
9655
+ return loaded;
9656
+ await bridge.send("resampleSampleToBPM", { cellId, sourceBPM: sourceBpm, targetBPM: targetBpm });
9657
+ return text(`Resampled cell ${cellId} to ${targetBpm ?? "the project"} BPM.`);
8106
9658
  });
8107
9659
  server.registerTool("restore_original_sample", {
8108
9660
  title: "Restore Original Sample",
@@ -8114,6 +9666,9 @@ server.registerTool("restore_original_sample", {
8114
9666
  const check = await requireBridgeForCells(cellId);
8115
9667
  if (check !== true)
8116
9668
  return check;
9669
+ const loaded = await requireCellSample(cellId, "originalFilePath");
9670
+ if (loaded !== true)
9671
+ return loaded;
8117
9672
  await bridge.send("restoreOriginalSample", { cellId });
8118
9673
  return text(`Restored original sample for cell ${cellId}.`);
8119
9674
  });
@@ -8136,7 +9691,7 @@ server.registerTool("export_slice", {
8136
9691
  description: "Export a single slice of a cell's sample as a standalone WAV.",
8137
9692
  inputSchema: {
8138
9693
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
8139
- sliceIndex: z.number().min(0).describe("Slice index (0-based)."),
9694
+ sliceIndex: z.number().int().min(1).describe("Slice number, 1-based as in the UI (1 = first slice)."),
8140
9695
  },
8141
9696
  }, async ({ cellId, sliceIndex }) => {
8142
9697
  const check = await requireBridgeForCells(cellId);
@@ -8167,10 +9722,11 @@ server.registerTool("live_resample", {
8167
9722
  .describe("For 'start': destination cell the captured clip lands in. Use an empty/scratch cell for a full-mix bounce."),
8168
9723
  bars: z
8169
9724
  .number()
8170
- .min(0.125)
9725
+ .int()
9726
+ .min(1)
8171
9727
  .max(64)
8172
9728
  .optional()
8173
- .describe("For 'start': capture length in bars (max 64; the capture also stops on the 'stop' op)."),
9729
+ .describe("For 'start': whole bars to capture (max 64; the capture also stops on the 'stop' op)."),
8174
9730
  source: z
8175
9731
  .enum(["cell", "input", "fullmix"])
8176
9732
  .default("cell")
@@ -8433,15 +9989,20 @@ server.registerTool("list_loaded_plugins", {
8433
9989
  title: "List Loaded Plugins",
8434
9990
  description: "List plugins currently hosted in PAM cells (instrument slot only — FX-side hosting is not exposed). " +
8435
9991
  "Each entry: cellId, name, manufacturer, format, " +
8436
- "path, isInstrument, bypassed, and the plugin's full param list (index, name, label, value, defaultValue, discrete, numSteps, and valueStrings for a discrete param). " +
8437
- "Use the param `index` with `set_plugin_param`.",
9992
+ "path, isInstrument, bypassed, and the plugin's param list (index, name, label, value, defaultValue, discrete, numSteps, and valueStrings for a discrete param; " +
9993
+ "JUCE's MIDI CC proxy parameters are omitted). Use the param `index` with `set_plugin_param`, and its `modTarget` as the paramId for add_modulation / add_param_seq.",
8438
9994
  inputSchema: {},
8439
9995
  }, async () => {
8440
9996
  const check = await requireBridge();
8441
9997
  if (check !== true)
8442
9998
  return check;
8443
9999
  const raw = await bridge.send("listLoadedPlugins");
8444
- return text(bridgeResultText(raw));
10000
+ const listed = typeof raw === "string" ? JSON.parse(raw) : raw;
10001
+ for (const plugin of listed?.loadedPlugins ?? []) {
10002
+ for (const p of plugin.params ?? [])
10003
+ p.modTarget = hostedModTargetId(Number(plugin.cellId), p);
10004
+ }
10005
+ return text(bridgeResultText(listed));
8445
10006
  });
8446
10007
  server.registerTool("load_plugin", {
8447
10008
  title: "Load Plugin Into Cell",
@@ -8545,7 +10106,7 @@ server.registerTool("update_settings", {
8545
10106
  description: "Merge a partial settings delta into PAM's plugin settings (the Settings panel state) and apply it live. " +
8546
10107
  "Returns the full merged settings object so you can discover available keys. This is the only way to flip " +
8547
10108
  "experimental-feature gates from an agent. Common boolean keys: experimentalMode, " +
8548
- "experimentalPluginHost, experimentalMcpBridge. To enable the plugin host, set BOTH " +
10109
+ "experimentalPluginHost, mcpBridgeEnabled (false closes this bridge after it replies). To enable the plugin host, set BOTH " +
8549
10110
  "experimentalMode:true AND the specific sub-flag (e.g. experimentalPluginHost:true) in one call. " +
8550
10111
  "Set drumSynthEnabled:false to hide its UI without muting loaded synths. Other keys " +
8551
10112
  "include UI/library preferences — call get_state or inspect the returned object for the current set.",
@@ -8614,7 +10175,7 @@ server.registerTool("configure_transport", {
8614
10175
  server.registerTool("get_audio_device_setup", {
8615
10176
  title: "Get Audio Device Setup",
8616
10177
  description: "Get the standalone audio/MIDI device setup (sample rate, buffer size, available and active input/output devices). " +
8617
- "Returns null when running plugin-hosted (the DAW owns the device config). Use the device IDs/names from here with set_midi_io.",
10178
+ "Returns {pluginHosted:true} inside a DAW, which owns the device config. Use the device IDs/names from here with set_midi_io.",
8618
10179
  inputSchema: {},
8619
10180
  annotations: { readOnlyHint: true },
8620
10181
  }, async () => {
@@ -8622,6 +10183,9 @@ server.registerTool("get_audio_device_setup", {
8622
10183
  if (check !== true)
8623
10184
  return check;
8624
10185
  const setup = await bridge.send("getAudioDeviceSetup");
10186
+ if (setup === null || (isRecord(setup) && Object.keys(setup).length === 0)) {
10187
+ return json({ pluginHosted: true, note: "The DAW owns the audio and MIDI device setup." });
10188
+ }
8625
10189
  return json(setup);
8626
10190
  });
8627
10191
  server.registerTool("get_memory_report", {
@@ -8655,35 +10219,76 @@ server.registerTool("set_audio_device", {
8655
10219
  const check = await requireBridge();
8656
10220
  if (check !== true)
8657
10221
  return check;
8658
- await bridge.send("setAudioDevice", [deviceName]);
10222
+ await bridge.send("setAudioDevice", deviceName);
8659
10223
  return text(`Audio output device set to '${deviceName}'.`);
8660
10224
  });
8661
10225
  server.registerTool("set_midi_io", {
8662
10226
  title: "Set MIDI I/O Devices",
8663
- description: "Select MIDI input/output devices (standalone). Use get_audio_device_setup to discover device IDs first. " +
8664
- "All fields optional.",
10227
+ description: "Select MIDI input/output devices (standalone). get_audio_device_setup lists them under midiInputs / " +
10228
+ "midiOutputs (and each port's device under midiOutputPorts); pass each device's identifier or exact name. " +
10229
+ "Ports B-D are extra outputs a cell can pick with update_cell_config midiOutPort. All fields optional.",
8665
10230
  inputSchema: {
8666
- inputDeviceIds: z.array(z.string()).optional().describe("Enabled MIDI input device IDs (replaces the active set)."),
8667
- outputDeviceId: z.string().optional().describe("MIDI output device ID."),
10231
+ inputDeviceIds: z
10232
+ .array(z.string())
10233
+ .optional()
10234
+ .describe("MIDI inputs to enable, by identifier or name. Replaces the active set; [] disables all inputs."),
10235
+ outputDeviceId: z.string().optional().describe("MIDI output device, by identifier or name; \"\" selects none."),
8668
10236
  outputEnabled: z.boolean().optional().describe("Enable/disable MIDI output."),
10237
+ outputPortDeviceIds: z
10238
+ .array(z.string())
10239
+ .max(3)
10240
+ .optional()
10241
+ .describe("Devices for Ports B, C and D in order, by identifier or name; \"\" leaves a port with no device."),
8669
10242
  },
8670
10243
  }, async (args) => {
8671
10244
  const check = await requireBridge();
8672
10245
  if (check !== true)
8673
10246
  return check;
8674
10247
  const applied = [];
10248
+ // Native enables only exact identifiers and disables every other input, so an
10249
+ // unresolved name must fail here rather than silently switch MIDI off.
10250
+ const devices = args.inputDeviceIds?.length || args.outputDeviceId || args.outputPortDeviceIds?.some(Boolean)
10251
+ ? await bridge.send("getMidiDevices").catch(() => undefined)
10252
+ : undefined;
10253
+ const resolveDevices = (kind, requested) => {
10254
+ // Builds without getMidiDevices take identifiers as given.
10255
+ if (!isRecord(devices) || !Array.isArray(devices[kind]))
10256
+ return { ids: requested };
10257
+ const listed = devices[kind].filter((device) => isRecord(device) && typeof device.name === "string" && typeof device.identifier === "string");
10258
+ const ids = [];
10259
+ for (const wanted of requested) {
10260
+ const match = listed.find((device) => device.identifier === wanted || device.name === wanted);
10261
+ if (!match)
10262
+ return { error: `Unknown MIDI device '${wanted}'. Available: ${listed.map((device) => device.name).join(", ") || "none"}.` };
10263
+ ids.push(match.identifier);
10264
+ }
10265
+ return { ids };
10266
+ };
10267
+ const inputs = args.inputDeviceIds?.length ? resolveDevices("midiInputs", args.inputDeviceIds) : { ids: [] };
10268
+ if ("error" in inputs)
10269
+ return error(inputs.error);
10270
+ const output = args.outputDeviceId ? resolveDevices("midiOutputs", [args.outputDeviceId]) : { ids: [""] };
10271
+ if ("error" in output)
10272
+ return error(output.error);
8675
10273
  if (args.inputDeviceIds !== undefined) {
8676
- await bridge.send("setMidiInputDevices", args.inputDeviceIds);
8677
- applied.push(`inputs=${args.inputDeviceIds.length}`);
10274
+ await bridge.send("setMidiInputDevices", inputs.ids);
10275
+ applied.push(`inputs=${inputs.ids.length}`);
8678
10276
  }
8679
10277
  if (args.outputDeviceId !== undefined) {
8680
- await bridge.send("setMidiOutputDevice", args.outputDeviceId);
8681
- applied.push(`output=${args.outputDeviceId}`);
10278
+ await bridge.send("setMidiOutputDevice", output.ids[0]);
10279
+ applied.push(`output=${args.outputDeviceId || "none"}`);
8682
10280
  }
8683
10281
  if (args.outputEnabled !== undefined) {
8684
10282
  await bridge.send("setMidiOutputEnabled", args.outputEnabled);
8685
10283
  applied.push(`outputEnabled=${args.outputEnabled}`);
8686
10284
  }
10285
+ for (const [index, wanted] of (args.outputPortDeviceIds ?? []).entries()) {
10286
+ const resolved = wanted ? resolveDevices("midiOutputs", [wanted]) : { ids: [""] };
10287
+ if ("error" in resolved)
10288
+ return error(resolved.error);
10289
+ await bridge.send("setMidiOutputPortDevice", index + 1, resolved.ids[0]);
10290
+ applied.push(`port${"BCD"[index]}=${wanted || "none"}`);
10291
+ }
8687
10292
  if (applied.length === 0)
8688
10293
  return error("No MIDI I/O fields provided.");
8689
10294
  return text(`MIDI I/O set: ${applied.join(", ")}.`);
@@ -8692,18 +10297,19 @@ server.registerTool("set_midi_mapping", {
8692
10297
  title: "Set MIDI Action Mapping",
8693
10298
  description: "Configure the global MIDI-controller mappings that trigger PAM actions: variation switch, preset change, " +
8694
10299
  "looper record, live resample, VX video record toggle, and looper-variation switch. Set the enable flag, " +
8695
- "MIDI channel, CC number, preset source, or bar-sync for each target. One field per call.",
10300
+ "MIDI channel, CC number, source (Program Change or CC), or bar-sync for each target. One field per call.",
8696
10301
  inputSchema: {
8697
10302
  target: z
8698
10303
  .enum(["variation", "preset", "looper", "resample", "vxRecord", "looperVariation"])
8699
10304
  .describe("Which action's mapping to configure. 'vxRecord' toggles VX video recording — requires the PAM window open (VX renders in the WebView)."),
8700
10305
  field: z
8701
10306
  .enum(["enabled", "channel", "ccNumber", "source", "barSync"])
8702
- .describe("Field to set. 'source' is preset-only; 'barSync' is variation/looperVariation-only; " +
8703
- "looper/resample/vxRecord have no source/barSync."),
10307
+ .describe("Field to set. 'source' applies to preset and variation (a variation switch on Program Change takes " +
10308
+ "every program on its channel, so presets no longer load from it there); 'barSync' is " +
10309
+ "variation/looperVariation-only; looper/resample/vxRecord have no source/barSync."),
8704
10310
  value: z
8705
10311
  .union([z.number(), z.boolean()])
8706
- .describe("boolean for enabled/barSync; number for channel (1-16), ccNumber (0-127), or source."),
10312
+ .describe("boolean for enabled/barSync; number for channel (1-16), ccNumber (0-127), or source (0 = Program Change, 1 = CC)."),
8707
10313
  },
8708
10314
  }, async ({ target, field, value }) => {
8709
10315
  const check = await requireBridge();
@@ -8724,7 +10330,11 @@ server.registerTool("delete_preset", {
8724
10330
  const check = await requireBridge();
8725
10331
  if (check !== true)
8726
10332
  return check;
8727
- const result = await bridge.send("removePreset", presetPath);
10333
+ if (isFactoryPresetPath(presetPath))
10334
+ return error("Factory presets are protected and cannot be deleted.");
10335
+ const result = await bridge.send("removePreset", nativePresetPath(presetPath));
10336
+ if (result !== true)
10337
+ return error(`Preset not found or not deletable: ${presetPath}`);
8728
10338
  return json(result);
8729
10339
  });
8730
10340
  server.registerTool("set_preset_factory_status", {
@@ -8738,7 +10348,7 @@ server.registerTool("set_preset_factory_status", {
8738
10348
  const check = await requireBridge();
8739
10349
  if (check !== true)
8740
10350
  return check;
8741
- const result = await bridge.send("togglePresetFactoryStatus", presetPath, makeFactory);
10351
+ const result = await bridge.send("togglePresetFactoryStatus", nativePresetPath(presetPath), makeFactory);
8742
10352
  return json(result);
8743
10353
  });
8744
10354
  server.registerTool("get_sample_usage", {
@@ -8763,14 +10373,14 @@ server.registerTool("get_sample_usage", {
8763
10373
  if (check !== true)
8764
10374
  return check;
8765
10375
  const result = mode === "presetUsesSamples"
8766
- ? await bridge.send("getPresetSampleUsage", relPath)
10376
+ ? await bridge.send("getPresetSampleUsage", nativePresetPath(relPath))
8767
10377
  : await bridge.send("getPresetsUsingSample", relPath, isFolder ?? false);
8768
10378
  return json(result);
8769
10379
  });
8770
10380
  server.registerTool("repair_preset_samples", {
8771
10381
  title: "Repair Preset Sample Paths",
8772
10382
  description: "Fix broken sample references in presets. mode 'fix' auto-relinks moved/renamed samples by searching the " +
8773
- "library (optionally scoped to one preset). mode 'rescan' re-audits a preset for still-missing samples and " +
10383
+ "library (optionally scoped to one preset). mode 'rescan' re-audits the ACTIVE preset for still-missing samples and " +
8774
10384
  "reports them. Useful after moving a sample library or importing presets from another machine.",
8775
10385
  inputSchema: {
8776
10386
  mode: z
@@ -8779,18 +10389,20 @@ server.registerTool("repair_preset_samples", {
8779
10389
  presetPath: z
8780
10390
  .string()
8781
10391
  .optional()
8782
- .describe("Relative preset path. Omit for 'fix' to scan all presets; omit for 'rescan' to audit the active preset."),
10392
+ .describe("'fix' only: relative preset path. Omit to scan all presets. 'rescan' always audits the active preset."),
8783
10393
  },
8784
10394
  }, async ({ mode, presetPath }) => {
8785
10395
  const check = await requireBridge();
8786
10396
  if (check !== true)
8787
10397
  return check;
8788
10398
  if (mode === "fix") {
8789
- const result = await bridge.send("fixPresetSamplePaths", presetPath ?? "");
10399
+ const result = await bridge.send("fixPresetSamplePaths", presetPath ? nativePresetPath(presetPath) : "");
8790
10400
  return json(result);
8791
10401
  }
8792
- await bridge.send("rescanMissingSamples", presetPath ?? "");
8793
- return text(`Re-audited ${presetPath || "active preset"} for missing samples (results dispatched to UI state).`);
10402
+ const missing = await bridge.send("rescanMissingSamples", "");
10403
+ if (!Array.isArray(missing))
10404
+ return text("Re-audited the active preset; this build reports the result only in its UI.");
10405
+ return json({ missing, note: missing.length ? undefined : "The active preset has no missing samples." });
8794
10406
  });
8795
10407
  server.registerTool("preview_sample", {
8796
10408
  title: "Preview Sample",
@@ -8874,16 +10486,16 @@ server.registerTool("remove_plugin_from_blocklist", {
8874
10486
  // TOOL — ask_assistant (drive the FunctionGemma assistant end-to-end)
8875
10487
  // ============================================================
8876
10488
  // The in-server twin of the ASK bar: renders the SAME FunctionGemma prompt
8877
- // (shared fgemma-format module + live [ctx] line), generates against a local
10489
+ // (shared functiongemma-format module + live [ctx] line), generates against a local
8878
10490
  // OpenAI-compatible endpoint (llama-server serving the fine-tune), parses the
8879
10491
  // call blocks, and executes them through this server's own handlers. Lets an
8880
10492
  // MCP client (Claude Code) test the assistant model directly against a live
8881
10493
  // PAM. Stateless per call — thread follow-ups by passing back `modelTurn`
8882
10494
  // from the previous trace as `history`.
8883
10495
  //
8884
- // Dev-tree only: registered only when the fgemma toolpack resolves (the
10496
+ // Dev-tree only: registered only when the FunctionGemma toolpack resolves (the
8885
10497
  // published npm package doesn't carry it — the tool would be dead weight).
8886
- const ASSISTANT_TOOLPACK_PATH = resolve(PACKAGE_ROOT, "../fgemma-poc/out/toolpack.json");
10498
+ const ASSISTANT_TOOLPACK_PATH = resolve(PACKAGE_ROOT, "../ml-research/out/toolpack.json");
8887
10499
  // The assistant's view vocabulary → MainViewTypes (stores/Interface.ts).
8888
10500
  // Pinned to the in-plugin executor's VIEW_MAP (assistantExecutor.ts) — one
8889
10501
  // vocabulary across tool arg, [ctx] view= field, and this dev twin. null =
@@ -8908,6 +10520,9 @@ const SET_VIEW_MAIN_VIEW = {
8908
10520
  export: "Export",
8909
10521
  presets: "PresetBrowser",
8910
10522
  browser: "PresetBrowser",
10523
+ // The preset browser's pack Store tab, PAM only.
10524
+ store: "PresetBrowser",
10525
+ packs: "PresetBrowser",
8911
10526
  // MFX-only surfaces. The handler rejects them for other products.
8912
10527
  patch: "MfxRouting",
8913
10528
  patchbay: "MfxRouting",
@@ -8931,11 +10546,19 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
8931
10546
  .union([z.string(), z.number()])
8932
10547
  .optional()
8933
10548
  .describe("Main view: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets. " +
8934
- "MFX also takes patch (the cable patchbay)."),
10549
+ "PAM also takes store (the preset browser's pack Store tab); MFX also takes patch (the cable patchbay)."),
8935
10550
  cellId: z.number().int().optional().describe("Cell to select/focus (1-8)."),
8936
10551
  gridSize: z.number().optional().describe("How many pads the grid shows: 1, 4, or 8."),
10552
+ panel: z
10553
+ .enum(["sampler", "synth", "pattern", "effects"])
10554
+ .optional()
10555
+ .describe("Cell grid tab to show, on cellId or else every cell: sampler, synth, pattern, effects."),
10556
+ fullscreen: z
10557
+ .enum(["sequencer", "effects", "players", "none"])
10558
+ .optional()
10559
+ .describe("CTRL page rack to show alone (sequencer, effects, players), or none."),
8937
10560
  },
8938
- }, async ({ view: viewArg, cellId, gridSize }) => {
10561
+ }, async ({ view: viewArg, cellId, gridSize, panel, fullscreen }) => {
8939
10562
  const check = await requireBridgeForCells(cellId);
8940
10563
  if (check !== true)
8941
10564
  return check;
@@ -8953,11 +10576,13 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
8953
10576
  token = view.trim().toLowerCase();
8954
10577
  if (!(token in SET_VIEW_MAIN_VIEW))
8955
10578
  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
- }
10579
+ const product = token === "patch" || token === "patchbay" || token === "store" || token === "packs"
10580
+ ? await connectedProduct()
10581
+ : undefined;
10582
+ if ((token === "patch" || token === "patchbay") && product && product !== "MFX")
10583
+ return error(`${token} is available in MFX only.`);
10584
+ if ((token === "store" || token === "packs") && (product === "MFX" || product === "MSQ"))
10585
+ return error(`${token} is available in PAM only.`);
8961
10586
  applied.view = token;
8962
10587
  }
8963
10588
  if (gridSize !== undefined) {
@@ -8970,13 +10595,19 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
8970
10595
  return error("cellId must be 1-8.");
8971
10596
  applied.cellId = cellId;
8972
10597
  }
10598
+ if (panel !== undefined)
10599
+ applied.panel = panel;
10600
+ if (fullscreen !== undefined)
10601
+ applied.fullscreen = fullscreen;
8973
10602
  if (!Object.keys(applied).length)
8974
- return error("Provide view, cellId, and/or gridSize.");
10603
+ return error("Provide view, cellId, gridSize, panel and/or fullscreen.");
8975
10604
  try {
8976
10605
  await bridge.send("setView", {
8977
10606
  ...(token !== undefined ? { view: token } : {}),
8978
10607
  ...(cellId !== undefined ? { cellId } : {}),
8979
10608
  ...(gridSize !== undefined ? { gridSize } : {}),
10609
+ ...(panel !== undefined ? { panel } : {}),
10610
+ ...(fullscreen !== undefined ? { fullscreen } : {}),
8980
10611
  });
8981
10612
  return json(applied);
8982
10613
  }
@@ -9001,6 +10632,8 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
9001
10632
  return error(`set_view failed: ${e2.message}`);
9002
10633
  }
9003
10634
  delete applied.cellId; // not applied on the fallback path — don't over-claim
10635
+ delete applied.panel;
10636
+ delete applied.fullscreen;
9004
10637
  return json(applied);
9005
10638
  }
9006
10639
  });
@@ -9011,7 +10644,8 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
9011
10644
  "FunctionGemma prompt (with the live [ctx] state line), generate via a local llama-server, " +
9012
10645
  "parse the tool calls, execute them against PAM, and return the full trace. Requires " +
9013
10646
  "llama-server running with the fine-tuned GGUF (--special). For multi-turn tests, pass the " +
9014
- "previous trace's modelTurn back via history.",
10647
+ "previous trace's modelTurn back via history. The prompt keeps the v34 [ctx] line and has no " +
10648
+ "[params] line, so for toolpack 36+ models it differs from the plugin's prompt.",
9015
10649
  inputSchema: {
9016
10650
  text: z.string().describe("The user command, e.g. 'mute the kick' or '174 bpm dnb kit'."),
9017
10651
  endpoint: z
@@ -9147,16 +10781,23 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
9147
10781
  result: result.text.length > 400 ? `${result.text.slice(0, 400)}…` : result.text,
9148
10782
  isError: result.isError,
9149
10783
  });
10784
+ // Same wrapping as the in-plugin executor's toResponsePayload, so a
10785
+ // trace here shows the exact function responses the model sees there.
9150
10786
  let payload;
9151
10787
  if (result.isError)
9152
10788
  payload = { error: result.text.slice(0, 400) };
9153
10789
  else {
9154
10790
  try {
9155
10791
  const parsed = JSON.parse(result.text);
9156
- payload =
9157
- parsed && typeof parsed === "object" && !Array.isArray(parsed) && result.text.length <= 400
9158
- ? parsed
9159
- : { result: result.text.slice(0, 400) };
10792
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
10793
+ const obj = parsed;
10794
+ if (obj.ok === true)
10795
+ delete obj.ok;
10796
+ payload = result.text.length <= 400 ? obj : { result: result.text.slice(0, 400) };
10797
+ }
10798
+ else {
10799
+ payload = { result: parsed };
10800
+ }
9160
10801
  }
9161
10802
  catch {
9162
10803
  payload = { result: result.text.slice(0, 400) };
@@ -9245,6 +10886,7 @@ const shutdown = async () => {
9245
10886
  return;
9246
10887
  shuttingDown = true;
9247
10888
  bridge.disconnect();
10889
+ await daemon?.close();
9248
10890
  try {
9249
10891
  await server.close();
9250
10892
  }