@map-audio/pam-mcp-server 1.18.1 → 1.21.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.
package/dist/server.js CHANGED
@@ -3,12 +3,14 @@ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mc
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { completable } from "@modelcontextprotocol/sdk/server/completable.js";
5
5
  import { z } from "zod";
6
+ import { buildVariationSnapshotBase } from "./generated/variationSnapshot.js";
6
7
  import { readFile } from "node:fs/promises";
7
8
  import { existsSync, readFileSync } from "node:fs";
8
9
  import { inflateSync } from "node:zlib";
9
10
  import { basename, dirname, resolve } from "node:path";
10
11
  import { fileURLToPath } from "node:url";
11
- import { loadManifest, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, setActiveProductHint, walkPresets, } from "./manifest.js";
12
+ import { createDrumSynthCatalog, } from "./drumSynth.js";
13
+ import { loadManifest, cellEffectNames, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, setActiveProductHint, walkPresets, } from "./manifest.js";
12
14
  import { PluginBridge } from "./bridge.js";
13
15
  import { buildMixMeasurementFromState, currentFlatFromState, parseMixAnalysisPayload, suggestMix, } from "./mixAssist.js";
14
16
  import { normalizePresetLookupPath, selectSaveVariationSourcePath, } from "./presetSave.js";
@@ -207,42 +209,93 @@ async function readExistingVariations(presetPath) {
207
209
  return Array(PRESET_VARIATION_SLOTS).fill(null);
208
210
  }
209
211
  }
210
- // Mirrors interop.ts VARIATION_EXCLUDED_PARAMS (activePresetPath is excluded separately below, matching interop.ts)
212
+ const VARIATION_CONTRACT = (() => {
213
+ const here = dirname(fileURLToPath(import.meta.url));
214
+ const candidates = [
215
+ resolve(here, "..", "..", "..", "shared", "variationSessionKeys.json"), // repo checkout
216
+ resolve(here, "variationSessionKeys.json"), // published dist/
217
+ ];
218
+ for (const file of candidates) {
219
+ if (!existsSync(file))
220
+ continue;
221
+ const contract = JSON.parse(readFileSync(file, "utf8"));
222
+ if (Array.isArray(contract.all) && contract.all.length > 0)
223
+ return contract;
224
+ }
225
+ throw new Error("pam-mcp: variationSessionKeys.json not found. Run `node scripts/generate-variation-session-keys.mjs` " +
226
+ "(repo) or rebuild the package — saving a variation without it would bake session state into the slot.");
227
+ })();
228
+ // `transport` is live host position, reported in state but never variation content.
211
229
  const VARIATION_EXCLUDED_PARAMS = [
212
- "variations",
213
- "interfaceCache",
214
- "songMode",
215
- "volume",
216
- "hqMode",
217
- "previewSamplePath",
218
- "previewCellId",
219
- "transport",
220
- "snapshotRateHz",
221
- "looperCrossfade",
222
- "looperLength",
223
- "looperVolume",
224
- "looperFilter",
225
- "loopers",
226
- "looperPlaybackBars",
227
- "looperTrack1Level",
228
- "looperTrack2Level",
229
- "looperTrack3Level",
230
- "looperTrack4Level",
231
- "looperTrack5Level",
232
- "looperTrack6Level",
233
- "looperTrack7Level",
234
- "looperTrack8Level",
230
+ ...new Set([...(VARIATION_CONTRACT.all ?? []), "transport"]),
235
231
  ];
236
232
  /**
237
- * Build a variation snapshot string from plugin state (matching interop.ts logic).
233
+ * Build a variation snapshot string from plugin state.
234
+ *
235
+ * The exclusion set comes from the generated contract, so this matches what a
236
+ * UI save strips. It does NOT match a UI save in one respect: interop.ts awaits
237
+ * `refreshLoadedPluginStateBlobs()` first and stamps each hosted plugin's
238
+ * identity and bypass from `pluginHostStore` — data that lives in the WebView,
239
+ * not in the headless bridge's state. `hostedPluginCells` reports the cells
240
+ * where that difference bites so callers are told rather than silently handed a
241
+ * lossy slot.
242
+ *
243
+ * This path is the FALLBACK. A PAM build carrying the `getSaveSnapshot` action
244
+ * hands back the native canonical snapshot (hosted-plugin state included) and
245
+ * never reaches here.
238
246
  */
239
247
  function buildVariationString(state) {
240
- const stateForVariation = { ...state };
241
- for (const param of VARIATION_EXCLUDED_PARAMS) {
242
- delete stateForVariation[param];
248
+ return JSON.stringify(buildVariationSnapshotBase(state, {
249
+ sessionKeys: VARIATION_EXCLUDED_PARAMS,
250
+ visualParamIds: VARIATION_CONTRACT.visualParamIds ?? [],
251
+ lfoGeneratorCount: VARIATION_CONTRACT.lfoGeneratorCount ?? 6,
252
+ }));
253
+ }
254
+ /**
255
+ * The canonical save-time state — engine state PLUS every live hosted plugin's
256
+ * identity, bypass and state blob (PluginProcessor::buildCanonicalSaveSnapshot).
257
+ *
258
+ * `getState` returns the live map, which carries no hosted-plugin blob, so a
259
+ * variation built from it silently dropped the hosted patch (A1). Falls back to
260
+ * `getState` on a PAM build that predates the action, and reports the fallback
261
+ * so the save result can say the snapshot is lossy.
262
+ */
263
+ async function fetchSaveSnapshot() {
264
+ try {
265
+ const snap = await bridge.send("getSaveSnapshot");
266
+ if (snap && typeof snap === "object") {
267
+ return { state: snap, canonical: true };
268
+ }
243
269
  }
244
- delete stateForVariation.activePresetPath;
245
- return JSON.stringify(stateForVariation);
270
+ catch {
271
+ // Older PAM: action not registered. Fall through.
272
+ }
273
+ const state = (await bridge.send("getState"));
274
+ return { state, canonical: false };
275
+ }
276
+ /** Cells whose hosted plugin this snapshot cannot fully capture. */
277
+ function hostedPluginCells(state) {
278
+ const cells = new Set();
279
+ for (const key of Object.keys(state)) {
280
+ const m = /^cell(\d+)_plugin(?:Fx)?Path$/.exec(key);
281
+ if (m && state[key])
282
+ cells.add(Number(m[1]));
283
+ }
284
+ return [...cells].sort((a, b) => a - b);
285
+ }
286
+ /** Appended to a save result when the snapshot is known to be lossy. */
287
+ function hostedPluginSaveWarning(state, canonical = false) {
288
+ // The canonical snapshot stamps hosted-plugin state on the message thread, so
289
+ // there is nothing to warn about.
290
+ if (canonical)
291
+ return "";
292
+ const cells = hostedPluginCells(state);
293
+ if (!cells.length)
294
+ return "";
295
+ return (`\n\n⚠️ Cell${cells.length > 1 ? "s" : ""} ${cells.join(", ")} host${cells.length > 1 ? "" : "s"} a plugin. ` +
296
+ "This save captured the plugin's last-known state, not its live one — the bridge cannot refresh a " +
297
+ "hosted plugin's state blob (that runs in the plugin UI). Re-save from the PAM window to capture " +
298
+ "current hosted-plugin settings.");
246
299
  }
247
300
  function formatParam(p) {
248
301
  let desc = `${p.paramId}: ${p.displayName}`;
@@ -263,7 +316,14 @@ function formatParam(p) {
263
316
  return desc;
264
317
  }
265
318
  const FULL_STATE_RESPONSE_LIMIT_CHARS = 24_000;
266
- const CELL_EFFECTS = ["filter", "delay", "crusher", "flanger", "eq", "fdn", "gate", "compressor"];
319
+ // Cell effects are derived per product from the manifest (`cellEffectNames`) —
320
+ // this constant is only the fallback for a manifest that failed to load. The
321
+ // hardcoded eight it used to be is why get_design_overview under-reported:
322
+ // limiter, qverb, eq8, panner, gain, vocoder (and MFX's wavefold/resonator)
323
+ // were enabled in state but absent from `enabledEffects`.
324
+ const CELL_EFFECTS_FALLBACK = [
325
+ "filter", "delay", "crusher", "flanger", "eq", "fdn", "gate", "compressor",
326
+ ];
267
327
  const PATTERN_LANES = {
268
328
  note: "midiSequence",
269
329
  pitch: "pitchSequence",
@@ -480,7 +540,8 @@ const server = new McpServer({
480
540
  "paths from list_samples. Transport must be playing for sequencer output. " +
481
541
  "To enable experimental features (plugin host, AI engines) call update_settings with both " +
482
542
  "experimentalMode:true AND the specific sub-flag (e.g. experimentalPluginHost:true). " +
483
- "The drum synth is a standard feature (PAM ≥ 1.5) — no settings flag needed. " +
543
+ "The standard Drum Synth UI follows drumSynthEnabled (default true); this preference does not mute synth audio. " +
544
+ "set_mfx_routing is the MFX cable patchbay and errors on PAM/Cell1; set_audio_device is standalone-only. " +
484
545
  "Tools added in server version 1.7.0 — update_settings, configure_transport, " +
485
546
  "get_audio_device_setup, set_midi_io, set_midi_mapping, delete_preset, " +
486
547
  "set_preset_factory_status, get_sample_usage, repair_preset_samples, preview_sample, " +
@@ -644,6 +705,8 @@ const TOOL_ANNOTATIONS = {
644
705
  export_audio: W,
645
706
  // Settings / devices
646
707
  update_settings: WI,
708
+ set_audio_device: WI,
709
+ set_mfx_routing: WI,
647
710
  set_midi_io: WI,
648
711
  set_midi_mapping: WI,
649
712
  // Plugin host
@@ -1132,6 +1195,9 @@ server.registerTool("get_design_overview", {
1132
1195
  ]);
1133
1196
  const state = isRecord(stateRaw) ? stateRaw : {};
1134
1197
  const transport = isRecord(transportRaw) ? transportRaw : {};
1198
+ // Product-aware: MFX carries wavefold/resonator, PAM/Cell1 do not.
1199
+ const derivedEffects = cellEffectNames(manifest);
1200
+ const effectNames = derivedEffects.length ? derivedEffects : CELL_EFFECTS_FALLBACK;
1135
1201
  const manifestCellIds = [...new Set(manifest.parameters
1136
1202
  .map((p) => p.cell)
1137
1203
  .filter((cell) => typeof cell === "string" && /^cell\d+$/.test(cell))
@@ -1162,8 +1228,9 @@ server.registerTool("get_design_overview", {
1162
1228
  name: stemName(samplePath),
1163
1229
  path: typeof samplePath === "string" ? samplePath : undefined,
1164
1230
  },
1165
- synthEngine: state[`${prefix}synthEngine`] ?? 0,
1166
- enabledEffects: CELL_EFFECTS.filter((effectName) => state[`${prefix}${effectName}Enabled`] === 1 || state[`${prefix}${effectName}Enabled`] === true),
1231
+ // Both halves: the enum an agent writes back, and the name it reasons about.
1232
+ synthEngine: describeSynthEngine(state[`${prefix}synthEngine`]),
1233
+ enabledEffects: effectNames.filter((effectName) => state[`${prefix}${effectName}Enabled`] === 1 || state[`${prefix}${effectName}Enabled`] === true),
1167
1234
  patternEvents: Object.fromEntries(Object.entries(PATTERN_LANES).map(([lane, suffix]) => [
1168
1235
  lane,
1169
1236
  countEvents(state[`${prefix}${suffix}`]),
@@ -1653,11 +1720,11 @@ server.registerTool("add_modulation", {
1653
1720
  .boolean()
1654
1721
  .optional()
1655
1722
  .describe("Envelope only: route from the cell's pitch-tracked source instead of amp. " +
1656
- "false (default) = AMP; true & pitchDuck=false = BOOST; true & pitchDuck=true = DUCK."),
1723
+ "false (default) = AMP; true & pitchDuck=false = PITCH (comb); true & pitchDuck=true = DUCK."),
1657
1724
  pitchDuck: z
1658
1725
  .boolean()
1659
1726
  .optional()
1660
- .describe("Envelope only: when pitch=true, switch from BOOST to DUCK semantics (notch). Ignored if pitch=false."),
1727
+ .describe("Envelope only: when pitch=true, switch from PITCH (comb) to DUCK semantics (notch). Ignored if pitch=false."),
1661
1728
  },
1662
1729
  }, async ({ type, paramId, cellId, lfoIndex, strength, bipolar, pitch, pitchDuck }) => {
1663
1730
  const index = await getParameterIndex();
@@ -2103,7 +2170,7 @@ server.registerTool("save_preset", {
2103
2170
  const check = await requireBridge();
2104
2171
  if (check !== true)
2105
2172
  return check;
2106
- const state = (await bridge.send("getState"));
2173
+ const { state, canonical } = await fetchSaveSnapshot();
2107
2174
  // Bare save (no name): the UI Save button's semantics — overwrite the
2108
2175
  // ACTIVE user preset in place, preserving its on-disk metadata. Mirrors
2109
2176
  // the in-plugin assistant executor (error strings included — the model's
@@ -2168,7 +2235,8 @@ server.registerTool("save_preset", {
2168
2235
  const result = (await bridge.send("savePreset", metadata));
2169
2236
  lastSavedPresetName = name;
2170
2237
  lastSavedPresetPath = result?.path || readPath;
2171
- return text(`Saved preset: ${name}\nPath: ${result?.path || "(unknown path)"}`);
2238
+ return text(`Saved preset: ${name}\nPath: ${result?.path || "(unknown path)"}` +
2239
+ hostedPluginSaveWarning(state, canonical));
2172
2240
  });
2173
2241
  server.registerTool("save_variation", {
2174
2242
  title: "Save Variation",
@@ -2201,8 +2269,8 @@ server.registerTool("save_variation", {
2201
2269
  const check = await requireBridge();
2202
2270
  if (check !== true)
2203
2271
  return check;
2204
- // 1. Get current full state and build variation snapshot
2205
- const state = (await bridge.send("getState"));
2272
+ // 1. Canonical save snapshot (engine + hosted-plugin state), then shape it
2273
+ const { state, canonical } = await fetchSaveSnapshot();
2206
2274
  const variationString = buildVariationString(state);
2207
2275
  // 2. Determine preset name and disk path — needed to read existing variations and metadata
2208
2276
  let presetName = name;
@@ -2267,7 +2335,8 @@ server.registerTool("save_variation", {
2267
2335
  return text(`Saved variation ${index} for "${presetName}"\n` +
2268
2336
  `Path: ${result?.path || "(unknown path)"}\n` +
2269
2337
  `Filled slots: ${variations.filter((v) => v !== null).length}/8` +
2270
- resultSuffix);
2338
+ resultSuffix +
2339
+ hostedPluginSaveWarning(state, canonical));
2271
2340
  });
2272
2341
  server.registerTool("update_preset_metadata", {
2273
2342
  title: "Update Preset Metadata",
@@ -2896,6 +2965,10 @@ server.registerTool("video_command", {
2896
2965
  "moveLayer{layerId,toIndex}, duplicateLayer{layerId}, renameLayer{layerId,name}, " +
2897
2966
  "setLayerEnabled{layerId,enabled?}, toggleSolo{layerId}, selectLayer{layerId}, " +
2898
2967
  "resetLayer{layerId}, applyLayout{preset}, randomizeLayer{layerId}; " +
2968
+ "setShaderSource{layerId,source} (GLSL fragment for a 'shader' layer; auto-parses " +
2969
+ "'// @param uP_x 0..1 = 0.5 \"Label\"' rows into sliders/bind targets; read " +
2970
+ "layers[].shaderCompile in get_video_state for the compile ok/infoLog and repair), " +
2971
+ "setShaderParam{layerId,paramKey,value}; " +
2899
2972
  "addLayerFx{layerId,fxType,params?,beforeFxId?}, removeLayerFx{layerId,fxId}, " +
2900
2973
  "removeAllLayerFx{layerId}, setLayerFxParam{layerId,fxId,param,value|params}, " +
2901
2974
  "setLayerFxEnabled{layerId,fxId,enabled?}, reorderLayerFx{layerId,fxIds}, " +
@@ -2938,8 +3011,8 @@ server.registerTool("add_video_layer", {
2938
3011
  "update_video_layer and stack FX with add_video_fx.",
2939
3012
  inputSchema: {
2940
3013
  kind: z
2941
- .enum(["video", "image", "camera", "text", "drawing", "model3d", "adjustment"])
2942
- .describe("Layer kind."),
3014
+ .enum(["video", "image", "camera", "text", "drawing", "model3d", "shader", "adjustment"])
3015
+ .describe("Layer kind. 'shader' is a user-GLSL fragment layer — write its source with video_command {op:'setShaderSource'}."),
2943
3016
  name: z.string().optional().describe("Display name (auto-generated if omitted)."),
2944
3017
  insertAt: z
2945
3018
  .number()
@@ -3578,7 +3651,8 @@ server.registerTool("reset_patterns", {
3578
3651
  });
3579
3652
  server.registerTool("update_effect_config", {
3580
3653
  title: "Update Effect Config",
3581
- description: "Toggle cell effects on/off (filter, delay, crusher, flanger, eq, fdn, gate, compressor). " +
3654
+ description: "Toggle cell effects on/off (filter, delay, crusher, flanger, eq, eq8, fdn, gate, compressor, panner, gain, vocoder, limiter, qverb, wavefold, resonator). " +
3655
+ "wavefold and resonator exist on MFX only. " +
3582
3656
  "Uses the lighter KV path that does not rebuild the audio graph. Pass 1 to enable, 0 to disable.",
3583
3657
  inputSchema: {
3584
3658
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
@@ -3590,6 +3664,20 @@ server.registerTool("update_effect_config", {
3590
3664
  fdnEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3591
3665
  gateEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3592
3666
  compressorEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3667
+ // The page-2 effects. These ship on PAM/Cell1 too, so leaving them out of the
3668
+ // schema made them unreachable over the bridge even though the handler is
3669
+ // generic (it just builds `cell{N}_{key}`) and the state vars exist in
3670
+ // build.js alongside the eight above.
3671
+ eq8Enabled: z.union([z.literal(0), z.literal(1)]).optional(),
3672
+ pannerEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3673
+ gainEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3674
+ vocoderEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3675
+ limiterEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3676
+ qverbEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3677
+ // MFX-only page-2 signature effects; harmless no-ops on PAM/Cell1, whose
3678
+ // manifests carry no cellN_wavefoldEnabled / cellN_resonatorEnabled key.
3679
+ wavefoldEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3680
+ resonatorEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
3593
3681
  },
3594
3682
  }, async ({ cellId, ...config }) => {
3595
3683
  const check = await requireBridge();
@@ -3613,6 +3701,88 @@ server.registerTool("update_effect_config", {
3613
3701
  .join(", ");
3614
3702
  return text(`Updated effect config for cell ${cellId}: ${summary}`);
3615
3703
  });
3704
+ // MFX cable patchbay. `mfx_routing` is a non-numeric state variable, so it was
3705
+ // unreachable through set_parameters (numbers only) — an agent could shape an MFX
3706
+ // cell but never patch one to another, which is the whole product.
3707
+ //
3708
+ // Validation mirrors parseRoutingEdges (interface/src/dsp/mfxRouting.ts): the
3709
+ // plugin re-validates on receipt, so this pass exists to fail loudly here rather
3710
+ // than have a typo silently drop a cable.
3711
+ const MFX_ROUTING_SOURCE_HINT = "'input', 'sidechain', or 'cellN'. 'output' is a destination only.";
3712
+ const MFX_ROUTING_SINK_HINT = "'output' or 'cellN'. 'input'/'sidechain' are sources only.";
3713
+ function mfxPortCell(port) {
3714
+ const m = /^cell([1-9]\d*)$/.exec(port); // canonical form only — 'cell01' is not a port
3715
+ return m ? Number(m[1]) : null;
3716
+ }
3717
+ function validateMfxEdges(edges, maxCells) {
3718
+ const seen = new Set();
3719
+ const out = [];
3720
+ const inRange = (port) => {
3721
+ const c = mfxPortCell(port);
3722
+ return c !== null && c >= 1 && c <= maxCells;
3723
+ };
3724
+ for (const { from, to } of edges) {
3725
+ if (from !== "input" && from !== "sidechain" && !inRange(from)) {
3726
+ return { ok: false, reason: `invalid source port '${from}' — expected ${MFX_ROUTING_SOURCE_HINT}` };
3727
+ }
3728
+ if (to !== "output" && !inRange(to)) {
3729
+ return { ok: false, reason: `invalid destination port '${to}' — expected ${MFX_ROUTING_SINK_HINT}` };
3730
+ }
3731
+ const key = `${from}->${to}`;
3732
+ if (seen.has(key))
3733
+ continue; // dedupe, matching the plugin
3734
+ seen.add(key);
3735
+ out.push({ from, to });
3736
+ }
3737
+ return { ok: true, edges: out };
3738
+ }
3739
+ server.registerTool("set_mfx_routing", {
3740
+ title: "Set MFX Cable Routing",
3741
+ description: "Read or replace the MFX cable patch — the graph that decides which cell feeds which. MFX-only; on PAM/Cell1 " +
3742
+ "this returns a product error.\n\n" +
3743
+ "Ports: 'input' (the host's audio in) and 'sidechain' are sources; 'output' is the destination; 'cellN' is both. " +
3744
+ "A self-edge cellN→cellN is a feedback loop, and input→output is a direct dry path. Duplicate cables are dropped.\n\n" +
3745
+ "A plain serial chain on 4 cells is input→cell1, cell1→cell2, cell2→cell3, cell3→cell4, cell4→output. " +
3746
+ "Parallel is input→each cell plus each cell→output.\n\n" +
3747
+ "'set' REPLACES the whole patch, so send the full cable list. Per-cable levels live in mfx_edge_gains and are " +
3748
+ "left alone; a new cable takes the graph's own default (unity forward, 0.5 on a back or self edge). " +
3749
+ "Re-patching rebuilds the audio graph, like reordering FX.",
3750
+ inputSchema: {
3751
+ op: z.enum(["get", "set"]).describe("'get' reads the current cables; 'set' replaces them."),
3752
+ edges: z
3753
+ .array(z.object({
3754
+ from: z.string().describe(`Source port: ${MFX_ROUTING_SOURCE_HINT}`),
3755
+ to: z.string().describe(`Destination port: ${MFX_ROUTING_SINK_HINT}`),
3756
+ }))
3757
+ .optional()
3758
+ .describe("Full cable list for 'set'. An empty array clears the patch (silence)."),
3759
+ },
3760
+ annotations: { destructiveHint: false, idempotentHint: true },
3761
+ }, async ({ op, edges }) => {
3762
+ const check = await requireBridge();
3763
+ if (check !== true)
3764
+ return check;
3765
+ const manifest = await loadManifestForProductGuard();
3766
+ if (manifest && !isMfxManifest(manifest)) {
3767
+ return error(`set_mfx_routing is MFX-only. ${manifest.name ?? "This product"} has no cable patchbay — ` +
3768
+ "its cells run in a fixed per-cell chain.");
3769
+ }
3770
+ if (op === "get") {
3771
+ const raw = await bridge.send("getState", "mfx_routing");
3772
+ return json(isRecord(raw) ? raw.mfx_routing ?? raw : raw);
3773
+ }
3774
+ if (!edges)
3775
+ return error("op 'set' requires an `edges` array (pass [] to clear the patch).");
3776
+ const maxCells = manifestMaxCellId(manifest) ?? 4;
3777
+ const validated = validateMfxEdges(edges, maxCells);
3778
+ if (!validated.ok) {
3779
+ return error(`${validated.reason}. This product exposes cells 1..${maxCells}.`);
3780
+ }
3781
+ // mfx_routing is a GRAPH_RENDER state var — a re-patch must rebuild the graph.
3782
+ await applyKv({ mfx_routing: validated.edges }, KV_OPTS_STANDARD);
3783
+ const summary = validated.edges.map((e) => `${e.from}→${e.to}`).join(", ");
3784
+ return text(`MFX routing set: ${validated.edges.length} cable(s)${summary ? ` — ${summary}` : " (patch cleared)"}.`);
3785
+ });
3616
3786
  server.registerTool("update_cell_config", {
3617
3787
  title: "Update Cell Config",
3618
3788
  description: "Update cell-level configuration properties like polyphony, MIDI settings, varispeed, and modulation latch. " +
@@ -3640,7 +3810,24 @@ server.registerTool("update_cell_config", {
3640
3810
  .max(2)
3641
3811
  .optional()
3642
3812
  .describe("Slice playback length when sliceKeymapMode=1: 0=Boundary (stop at the next slice edge), 1=Sample End (play to end of sample), 2=Note Off (sustain until key release; requires the cell to be in Gate mode)."),
3643
- scaleName: z.string().optional().describe("Scale name for pitch sequencer quantization (e.g. 'chromatic', 'major', 'minor')."),
3813
+ scaleName: z
3814
+ .enum([
3815
+ "chromatic",
3816
+ "major",
3817
+ "minor",
3818
+ "pentatonicMajor",
3819
+ "pentatonicMinor",
3820
+ "dorian",
3821
+ "mixolydian",
3822
+ "blues",
3823
+ "harmonicMinor",
3824
+ "phrygian",
3825
+ "lydian",
3826
+ "locrian",
3827
+ ])
3828
+ .optional()
3829
+ .describe("Scale name for pitch sequencer / note quantization ('chromatic' = no filter). " +
3830
+ "An out-of-vocabulary string would silently play as chromatic while junk persists into the preset, so the name is validated."),
3644
3831
  scaleRoot: z.number().min(0).max(11).optional().describe("Root note of the scale (0=C, 1=C#, ..., 11=B)."),
3645
3832
  pitchMode: z.number().int().min(0).max(3).optional().describe("Pitch engine: 0=Varispeed, 1=Formant, 2=Pitch Shift Quantize, 3=Varispeed Quantize."),
3646
3833
  stretchMode: z.number().int().min(0).max(3).optional().describe("Stretch engine: 0=OLA, 1=Cloud, 2=Phase Vocoder, 3=GrainRate."),
@@ -3664,22 +3851,35 @@ server.registerTool("update_cell_config", {
3664
3851
  const cellCheck = validateCellIdForManifest(cellId, manifest);
3665
3852
  if (cellCheck !== true)
3666
3853
  return cellCheck;
3667
- const cellConfig = {};
3668
- const flatDelta = {};
3669
- for (const [key, value] of Object.entries(config)) {
3670
- if (value !== undefined)
3671
- cellConfig[key] = value;
3672
- }
3673
- if (Object.keys(cellConfig).length === 0) {
3854
+ // These four are DAW params (cell{N}_quantScale, …), not cell-config keys —
3855
+ // nothing consumes them on the cells{} config channel (the sampler reads
3856
+ // the param signal inputs), so a cells{} write was a silent no-op that
3857
+ // also persisted junk keys into the preset. Route them to flat param keys,
3858
+ // which applyKv maps onto the JUCE params (updateParameters: true).
3859
+ const PARAM_ROUTED_KEYS = new Set(["quantScale", "quantRoot", "spread", "chaos"]);
3860
+ const providedKeys = Object.entries(config)
3861
+ .filter(([, value]) => value !== undefined)
3862
+ .map(([key]) => key);
3863
+ if (providedKeys.length === 0) {
3674
3864
  return error("At least one config property must be provided.");
3675
3865
  }
3676
3866
  if (manifest && isMfxManifest(manifest)) {
3677
- const unsupported = Object.keys(cellConfig).filter((key) => !MFX_CELL_CONFIG_KEYS.has(key));
3867
+ const unsupported = providedKeys.filter((key) => !MFX_CELL_CONFIG_KEYS.has(key));
3678
3868
  if (unsupported.length > 0) {
3679
3869
  return error("update_cell_config in MFX only accepts sidechainFrom. " +
3680
3870
  `Sampler/MIDI/instrument config keys are PAM/Cell1-only: ${unsupported.join(", ")}.`);
3681
3871
  }
3682
3872
  }
3873
+ const cellConfig = {};
3874
+ const flatDelta = {};
3875
+ for (const [key, value] of Object.entries(config)) {
3876
+ if (value === undefined)
3877
+ continue;
3878
+ if (PARAM_ROUTED_KEYS.has(key))
3879
+ flatDelta[`cell${cellId}_${key}`] = value;
3880
+ else
3881
+ cellConfig[key] = value;
3882
+ }
3683
3883
  // Auto-enable compressor when setting sidechain source
3684
3884
  if (cellConfig.sidechainFrom !== undefined && cellConfig.sidechainFrom !== null) {
3685
3885
  flatDelta[`cell${cellId}_compressorEnabled`] = 1;
@@ -3694,11 +3894,14 @@ server.registerTool("update_cell_config", {
3694
3894
  cellModeChangeSeq[cellId] = next;
3695
3895
  cellConfig.modeChangeSeq = next;
3696
3896
  }
3697
- const delta = { cells: { [cellId]: cellConfig }, ...flatDelta };
3897
+ const delta = { ...flatDelta };
3898
+ if (Object.keys(cellConfig).length > 0)
3899
+ delta.cells = { [cellId]: cellConfig };
3698
3900
  await applyKv(delta, KV_OPTS_CELL_CONFIG);
3699
- const summary = Object.entries(cellConfig)
3700
- .map(([k, v]) => `${k}=${v}`)
3701
- .join(", ");
3901
+ const summary = [
3902
+ ...Object.entries(cellConfig).map(([k, v]) => `${k}=${v}`),
3903
+ ...Object.entries(flatDelta).map(([k, v]) => `${k}=${v}`),
3904
+ ].join(", ");
3702
3905
  return text(`Updated cell ${cellId} config: ${summary}`);
3703
3906
  });
3704
3907
  server.registerTool("set_cell_mute", {
@@ -3752,7 +3955,8 @@ server.registerTool("set_master_effects", {
3752
3955
  "medium release (80-200ms), ratio 4-8, autoMakeup true. " +
3753
3956
  "For lo-fi/Memphis/dusty glue: tapeEnabled=1, tapeMode=1 (Lo-Fi), masterTapeDrive 0.55-0.7, " +
3754
3957
  "masterTapeTone -0.4..-0.1 (darker), masterTapeWear 0.5-0.7 (wow/flutter). " +
3755
- "NOTE: the Tape requires a PAM build that ships it (flat state key masterTapeEnabled — master-chain keys are not manifest params). On older builds these keys are ignored.",
3958
+ "Squeeze (masterSqueeze*) is the 3-band up/down density stage between Punch and Tape — for dense modern-electronic masters set masterSqueezeEnabled=1 with masterSqueezeDepth 0.2-0.4; it lifts quiet detail and limits loud bands per band (a character stage, not glue — Punch stays the glue). " +
3959
+ "NOTE: the Tape and Squeeze stages require a PAM build that ships them (flat state keys, not manifest params). On older builds these keys are ignored.",
3756
3960
  inputSchema: {
3757
3961
  compressorEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable Punch compressor (0=off, 1=on)."),
3758
3962
  limiterEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable master Limiter (0=off, 1=on)."),
@@ -3792,6 +3996,15 @@ server.registerTool("set_master_effects", {
3792
3996
  masterIronDrive: z.number().min(0).max(12).optional().describe("Iron drive dB (default 0). Level-compensated — drive adds colour, not loudness."),
3793
3997
  masterIronAmount: z.number().min(0).max(1).optional().describe("Transformer (magnetizing-branch) depth 0..1 — LF-concentrated saturation, the 'iron' signature (default 0.5)."),
3794
3998
  masterIronMode: z.union([z.literal(0), z.literal(1)]).optional().describe("0=Console (amp-forward, subtle), 1=Transformer (heavier LF iron)."),
3999
+ 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."),
4000
+ 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."),
4001
+ 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)."),
4002
+ masterSqueezeTone: z.number().min(-1).max(1).optional().describe("Squeeze tone: bipolar low<->high band tilt, ±6 dB at the extremes (default 0)."),
4003
+ masterSqueezeInGain: z.number().min(-12).max(12).optional().describe("Squeeze input gain dB (default 0) — drives program into the fixed thresholds (more In = more of everything)."),
4004
+ masterSqueezeOutTrim: z.number().min(-12).max(12).optional().describe("Squeeze output trim dB (default 0)."),
4005
+ 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)."),
4006
+ 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)."),
4007
+ masterSqueezeUpCapDb: z.number().min(0).max(24).optional().describe("Squeeze upward max-boost clamp dB (default 12) — bounds how far quiet material (tails, noise floor) is lifted."),
3795
4008
  masterLoudnessAutoReset: z.union([z.literal(0), z.literal(1)]).optional().describe("Loudness meter auto-reset on host transport start (default 1). 0 keeps measurements running across transport stops."),
3796
4009
  masterEqBells: z
3797
4010
  .array(z.object({
@@ -3857,38 +4070,50 @@ server.registerTool("set_master_effects", {
3857
4070
  return text(`Updated master effects: ${summary}`);
3858
4071
  });
3859
4072
  // Built-in per-cell drum synth ("machines"). Engine enum index = drumsynth::Engine
3860
- // (Off = 0). Per-engine param labels + musical defaults mirror DRUM_SYNTH_ENGINES
3861
- // in interface/src/dsp/modules/drumSynth.ts — keep in lockstep (append-only).
3862
- const DRUM_SYNTH_ENGINES = [
3863
- { name: "Kick", params: [["Tune", 0.25], ["Pitch", 0.5], ["P.Decay", 0.35], ["Decay", 0.5], ["Click", 0.3], ["Drive", 0.35], ["Tone", 0.6]] },
3864
- { name: "Snare", params: [["Tune", 0.4], ["Snap", 0.45], ["Body", 0.35], ["N.Decay", 0.45], ["N.Tone", 0.5], ["Detune", 0.3], ["Drive", 0.25]] },
3865
- { name: "HiHat", params: [["Tune", 0.5], ["Decay", 0.3], ["Tone", 0.5], ["Metal", 0.6], ["Noise", 0.2]] },
3866
- { name: "Clap", params: [["Tune", 0.45], ["Spread", 0.4], ["Decay", 0.4], ["Tone", 0.5], ["Drive", 0.2]] },
3867
- { name: "Tom", params: [["Tune", 0.4], ["Pitch", 0.4], ["Decay", 0.45], ["Tone", 0.55], ["Noise", 0.2], ["Drive", 0.2]] },
3868
- { name: "FM", params: [["Tune", 0.4], ["Ratio", 0.45], ["FM Amt", 0.4], ["Decay", 0.4], ["FM Dcy", 0.4], ["F.Back", 0.1], ["Drive", 0.2]] },
3869
- { name: "Cymbal", params: [["Tune", 0.5], ["Decay", 0.5], ["Tone", 0.6], ["Metal", 0.7], ["Sizzle", 0.4]] },
3870
- { name: "Cowbell", params: [["Tune", 0.5], ["Decay", 0.4], ["Ratio", 0.45], ["Tone", 0.5], ["Drive", 0.2]] },
3871
- { name: "808", params: [["Tune", 0.35], ["Glide", 0.25], ["P.Decay", 0.4], ["Decay", 0.55], ["Saturate", 0.2], ["Click", 0.25], ["Tone", 0.5]] },
3872
- { name: "Shaker", params: [["Tune", 0.5], ["Color", 0.3], ["Decay", 0.3], ["Attack", 0.4], ["Rattle", 0.0], ["Tone", 0.4]] },
3873
- { name: "Rim", params: [["Tune", 0.4], ["Ring", 0.5], ["Decay", 0.3], ["Mode", 0.4], ["Snap", 0.6], ["Drive", 0.2]] },
3874
- { name: "Conga", params: [["Tune", 0.45], ["Slap", 0.4], ["Decay", 0.4], ["Mode", 0.3], ["Tone", 0.55], ["Skin", 0.25], ["Drive", 0.2]] },
3875
- { name: "Bell", params: [["Tune", 0.5], ["Ratio", 0.4], ["FM Amt", 0.4], ["Decay", 0.5], ["Inharm", 0.3], ["Strike", 0.3], ["Tone", 0.3]] },
3876
- { name: "Pluck", params: [["Tune", 0.4], ["Sustain", 0.6], ["Damp", 0.5], ["Excite", 0.4], ["Tone", 0.6], ["Drive", 0.15], ["Decay", 0.5]] },
3877
- { name: "Zap", params: [["Tune", 0.3], ["Sweep", 0.7], ["S.Time", 0.35], ["Decay", 0.4], ["Timbre", 0.3], ["Noise", 0.1], ["Drive", 0.25], ["Tone", 0.6]] },
3878
- { name: "Wave", params: [["Tune", 0.35], ["Position", 0.2], ["P.Env", 0.4], ["Decay", 0.4], ["Bright", 0.6], ["Morph", 0.0], ["Drive", 0.2]] },
3879
- { name: "Chaos", params: [["Tune", 0.4], ["Chaos", 0.6], ["Decay", 0.4], ["Tone", 0.5], ["Drive", 0.3], ["Noise", 0.1]] },
3880
- ];
3881
- const DRUM_SYNTH_ENGINE_NAMES = ["Off", ...DRUM_SYNTH_ENGINES.map((e) => e.name)];
4073
+ // (Off = 0). The roster, the curated defaults and the engine-switch rule all come
4074
+ // from shared/drumSynthEngines.json — the registry build.js codegens the C++ cook
4075
+ // and the manifest from, and interface/src/dsp/modules/drumSynth.ts derives its
4076
+ // own tables from. The hand-copied table this replaced had drifted on Kick,
4077
+ // Cowbell and 808 (issue #999); the switch rule it shipped with carried knob
4078
+ // positions where the plugin carries engine units.
4079
+ const DRUM_SYNTH_REGISTRY = (() => {
4080
+ const here = dirname(fileURLToPath(import.meta.url));
4081
+ const candidates = [
4082
+ resolve(here, "..", "..", "..", "shared", "drumSynthEngines.json"), // repo checkout
4083
+ resolve(here, "drumSynthEngines.json"), // published dist/
4084
+ ];
4085
+ for (const file of candidates) {
4086
+ if (!existsSync(file))
4087
+ continue;
4088
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
4089
+ if (parsed.engines && Object.keys(parsed.engines).length > 0)
4090
+ return parsed;
4091
+ }
4092
+ throw new Error("pam-mcp: drumSynthEngines.json not found. Rebuild the package (scripts/build-postprocess.mjs " +
4093
+ "vendors it into dist/) — seeding a drum engine without it would write defaults that disagree " +
4094
+ "with the plugin.");
4095
+ })();
4096
+ const drumSynth = createDrumSynthCatalog(DRUM_SYNTH_REGISTRY);
4097
+ const DRUM_SYNTH_ENGINE_NAMES = drumSynth.engineNames;
4098
+ /** State carries the bare enum; agents reason about the name. Report both. */
4099
+ function describeSynthEngine(raw) {
4100
+ const id = Math.round(Number(raw ?? 0));
4101
+ return { id, name: DRUM_SYNTH_ENGINE_NAMES[id] ?? "Off" };
4102
+ }
3882
4103
  server.registerTool("set_drum_synth", {
3883
4104
  title: "Set Drum Synth (built-in machine)",
3884
4105
  description: "Select and shape a cell's built-in drum-synth 'machine' — a real-time synthesized voice that sums into the cell " +
3885
- "alongside (or instead of) its sample. 18 engines: Off, Kick, Snare, HiHat, Clap, Tom, FM, Cymbal, Cowbell, 808, " +
3886
- "Shaker, Rim, Conga, Bell, Pluck, Zap, Wave, Chaos. The 808 engine is a tuned long-glide sub that plays as a chromatic " +
4106
+ `alongside (or instead of) its sample. ${DRUM_SYNTH_ENGINE_NAMES.length} engines: ${DRUM_SYNTH_ENGINE_NAMES.join(", ")}. ` +
4107
+ "The 808 engine is a tuned long-glide sub that plays as a chromatic " +
3887
4108
  "bassline (drive it with a pitch lane + pitch-quantize) — ideal for 808 sub-bass, trap, Memphis. Use a cell with NO " +
3888
4109
  "sample for a pure synth voice, or layer over a sample. Each engine reinterprets the 8 generic params (p1..p8); pass " +
3889
- "only the ones you want to nuance — unspecified params seed to the engine's musical default. Per-engine p-labels: " +
3890
- DRUM_SYNTH_ENGINES.map((e) => `${e.name}=[${e.params.map((p) => p[0]).join(",")}]`).join("; ") + ". " +
3891
- "NOTE: requires a PAM build with drum synth (manifest.drumSynth.enabled) — a standard PAM feature, no settings flag. " +
4110
+ "only the ones you want to nuance. First activation seeds every slot from the engine's musical defaults. On a switch " +
4111
+ "between two active engines, only slots you had moved off their default travel, and they carry the engine VALUE " +
4112
+ "(re-mapped through the destination's own range), so Kick Decay 0.41 becomes Stab Decay 0.336 — the same 198 ms. " +
4113
+ "Untouched slots, and slots whose label or unit has no counterpart, take the destination's default. Per-engine p-labels: " +
4114
+ drumSynth.engines.map((e) => `${e.name}=[${e.params.map((p) => p.label).join(",")}]`).join("; ") + ". " +
4115
+ "NOTE: requires a PAM build with drum synth (manifest.drumSynth.enabled). This headless tool is product-gated, " +
4116
+ "not gated by the UI-only drumSynthEnabled preference. " +
3892
4117
  "On products without it, this tool returns a product error instead of writing inert synth keys.",
3893
4118
  inputSchema: {
3894
4119
  cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
@@ -3917,14 +4142,11 @@ server.registerTool("set_drum_synth", {
3917
4142
  const engineId = DRUM_SYNTH_ENGINE_NAMES.indexOf(engine);
3918
4143
  if (engineId < 0)
3919
4144
  return error(`Unknown drum synth engine: ${engine}`);
3920
- const delta = { [`cell${cellId}_synthEngine`]: engineId };
3921
- if (engineId > 0) {
3922
- // Seed the engine's musical defaults so the voice sounds good immediately,
3923
- // then apply explicit p-overrides on top (nuance only what you mean to).
3924
- const desc = DRUM_SYNTH_ENGINES[engineId - 1];
3925
- desc.params.forEach(([, def], i) => {
3926
- delta[`cell${cellId}_synth_p${i + 1}`] = def;
3927
- });
4145
+ const stateRaw = await bridge.send("getState");
4146
+ const state = isRecord(stateRaw) ? stateRaw : {};
4147
+ const delta = drumSynth.buildEngineSwitchDelta(cellId, engineId, state);
4148
+ const desc = drumSynth.getEngine(engineId);
4149
+ if (desc) {
3928
4150
  for (const [key, value] of Object.entries(overrides)) {
3929
4151
  if (value === undefined)
3930
4152
  continue;
@@ -3937,7 +4159,7 @@ server.registerTool("set_drum_synth", {
3937
4159
  delta[`cell${cellId}_synthVolume`] = volume;
3938
4160
  // synthEngine is a graph-render key → needs a graph dispatch (KV_OPTS_STANDARD).
3939
4161
  await applyKv(delta, KV_OPTS_STANDARD);
3940
- const labels = engineId > 0 ? DRUM_SYNTH_ENGINES[engineId - 1].params.map((p) => p[0]).join(",") : "—";
4162
+ const labels = desc ? desc.params.map((p) => p.label).join(",") : "—";
3941
4163
  return text(`Cell ${cellId} drum synth → ${engine}${engineId > 0 ? ` (p1..pN = ${labels})` : ""}.`);
3942
4164
  });
3943
4165
  server.registerTool("set_drum_synth_wave", {
@@ -4482,7 +4704,7 @@ Variations exclude: global volume, looper state.
4482
4704
 
4483
4705
  ## Master Effects — The Final Stage
4484
4706
 
4485
- Signal chain: Cell outputs → Bus Mix → **Punch Compressor** → **Limiter** → Volume Fader → Output
4707
+ Signal chain: Cell outputs → Bus Mix → **EQ** → **Punch Compressor** → **Squeeze** (3-band up/down) → **Tape** → **Iron** → **Clipper** → **Limiter** → Volume Fader → Output
4486
4708
 
4487
4709
  ### Punch Compressor (Musical Bus Comp)
4488
4710
  \`\`\`
@@ -4603,7 +4825,8 @@ update_cell_config: cellId=5, scaleName="pentatonicMinor", scaleRoot=0
4603
4825
  \`\`\`
4604
4826
  \`scaleRoot\`: 0=C, 1=C#, 2=D, 3=D#, 4=E, 5=F, 6=F#, 7=G, 8=G#, 9=A, 10=A#, 11=B
4605
4827
 
4606
- Available scales: \`chromatic\` (default/none), \`major\`, \`minor\`, \`pentatonicMajor\`, \`pentatonicMinor\`, \`dorian\`, \`mixolydian\`, \`blues\`, \`harmonicMinor\`, \`phrygian\`
4828
+ Available scales: \`chromatic\` (default/none), \`major\`, \`minor\`, \`pentatonicMajor\`, \`pentatonicMinor\`, \`dorian\`, \`mixolydian\`, \`blues\`, \`harmonicMinor\`, \`phrygian\`, \`lydian\`, \`locrian\`
4829
+ (The sampler pitch-quantize lane \`cell{N}_quantScale\` is a separate, deliberately frozen 10-scale DAW param — no lydian/locrian there.)
4607
4830
 
4608
4831
  ### Playback Modes
4609
4832
  \`cell{N}_mode\`: 0=Trigger (one-shot), 1=Gate (plays while held, default), 2=Loop (continuous)
@@ -4646,6 +4869,9 @@ Index → multiplier: 0=1/32, 1=1/16, 2=1/12, 3=1/8, 4=1/6, 5=1/5, 6=1/4, 7=1/3,
4646
4869
  // commands in Claude Code). Each stages the same flows the
4647
4870
  // README and tool descriptions teach, with exact tool names.
4648
4871
  // ============================================================
4872
+ // Mirror of RANDOMIZER_GENRES (shared/randomizer/types.ts) — the published
4873
+ // package can't import repo-relative shared modules, so the copy is pinned by
4874
+ // interface/test/randomizerGenre.test.ts (source-text parity check).
4649
4875
  const GENRE_SUGGESTIONS = ["boom_bap", "trap", "drill", "house", "techno", "dnb"];
4650
4876
  const completeGenre = (value) => GENRE_SUGGESTIONS.filter((g) => g.startsWith((value ?? "").toLowerCase()));
4651
4877
  // completable() tags the schema OBJECT with a symbol, so ordering is delicate:
@@ -4833,7 +5059,7 @@ server.registerTool("set_looper_track", {
4833
5059
  if (typeof rest.sourceCell === "number")
4834
5060
  trackDelta.sourceCell = rest.sourceCell;
4835
5061
  if (typeof rest.autoSelect === "boolean")
4836
- trackDelta.autoSelect = rest.autoSelect ? 1 : 0;
5062
+ trackDelta.autoSelect = rest.autoSelect;
4837
5063
  if (typeof rest.recordedBpm === "number")
4838
5064
  trackDelta.recordedBpm = rest.recordedBpm;
4839
5065
  if (Object.keys(trackDelta).length === 0)
@@ -4914,6 +5140,10 @@ server.registerTool("start_looper_recording", {
4914
5140
  const check = await requireBridge();
4915
5141
  if (check !== true)
4916
5142
  return check;
5143
+ // Arm in UI state too — the plugin's live recording overlay and progress
5144
+ // mount off loopers[id].armed, so a bridge-only record left the timeline
5145
+ // blank while recording ran fine underneath.
5146
+ await applyLoopersDelta(Object.fromEntries(armedCellIds.map((cellId) => [cellId, { armed: 1 }])));
4917
5147
  const meta = trackMetadata ??
4918
5148
  armedCellIds.map((cellId) => ({
4919
5149
  cellId,
@@ -5129,7 +5359,9 @@ server.registerTool("looper_variation_sync", {
5129
5359
  server.registerTool("queue_variation", {
5130
5360
  title: "Queue Variation Switch",
5131
5361
  description: "Schedule a global variation switch on the next bar boundary. " +
5132
- "For an immediate switch, use load_variation instead.",
5362
+ "For an immediate switch, use load_variation instead. Optionally morph " +
5363
+ "into the new section over the bars before the boundary (song mode / an " +
5364
+ "already-engaged queued-flip window only; a from-idle flip hard-switches).",
5133
5365
  inputSchema: {
5134
5366
  index: z.number().min(0).max(7).describe("Variation slot 0-7."),
5135
5367
  intervalBars: z.number().min(0.125).max(64).describe("Bars before switching."),
@@ -5138,13 +5370,48 @@ server.registerTool("queue_variation", {
5138
5370
  .boolean()
5139
5371
  .default(true)
5140
5372
  .describe("Reset pattern phase on switch."),
5373
+ morphBars: z
5374
+ .number()
5375
+ .min(0.25)
5376
+ .max(8)
5377
+ .optional()
5378
+ .describe("Pre-anchor morph window in bars for this flip (PAM >= 1.19.3). Omit = hard switch."),
5379
+ morphMode: z
5380
+ .enum(["auto", "param", "mix", "seq", "all"])
5381
+ .optional()
5382
+ .describe("Morph style: 'param' glides continuous params (default); 'mix' equal-power blends the two sections; 'seq' adds a staged pattern dissolve to the glide (old notes lose trigger probability as new notes gain it); 'all' runs glide + dissolve + blend together; 'auto' lets the engine pick — mix when the sections differ in graph topology (samples, synth engines, routing) or when nothing can glide, param otherwise (PAM >= 1.19.4; seq/all on older builds run param)."),
5383
+ morphCurve: z
5384
+ .enum(["linear", "ease", "exp", "log"])
5385
+ .optional()
5386
+ .describe("Morph easing; default ease."),
5387
+ transition: z
5388
+ .object({
5389
+ patternPolicy: z.enum(["dissolve", "wipe", "cells"]).optional(),
5390
+ mixLaw: z.enum(["equalPower", "equalGain", "correlation"]).optional(),
5391
+ tail: z.enum(["cut", "fade", "ring"]).optional(),
5392
+ tailMaxBars: z.number().min(0.25).max(8).optional(),
5393
+ })
5394
+ .optional()
5395
+ .describe("Advanced transition settings for this flip (PAM >= 1.20.0): pattern policy for a " +
5396
+ "seq/all morph, blend gain law for a mix/all morph, and the outgoing section's " +
5397
+ "tail. Omitted fields keep the defaults (dissolve, equalPower, fade)."),
5141
5398
  },
5142
- }, async ({ index, intervalBars, fadeMs, resetPhase }) => {
5399
+ }, async ({ index, intervalBars, fadeMs, resetPhase, morphBars, morphMode, morphCurve, transition }) => {
5143
5400
  const check = await requireBridge();
5144
5401
  if (check !== true)
5145
5402
  return check;
5146
- await bridge.send("queueVariationOnBars", { index, intervalBars, fadeMs, resetPhase });
5147
- return text(`Queued variation ${index} on next ${intervalBars}-bar boundary.`);
5403
+ await bridge.send("queueVariationOnBars", {
5404
+ index,
5405
+ intervalBars,
5406
+ fadeMs,
5407
+ resetPhase,
5408
+ ...(morphBars !== undefined && { morphBars }),
5409
+ ...(morphMode !== undefined && { morphMode }),
5410
+ ...(morphCurve !== undefined && { morphCurve }),
5411
+ ...(transition !== undefined && { transition }),
5412
+ });
5413
+ const morphNote = morphBars !== undefined ? ` (morph ${morphBars} bars, ${morphMode ?? "param"})` : "";
5414
+ return text(`Queued variation ${index} on next ${intervalBars}-bar boundary${morphNote}.`);
5148
5415
  });
5149
5416
  server.registerTool("cancel_queued_variation", {
5150
5417
  title: "Cancel Queued Variation",
@@ -5298,16 +5565,17 @@ server.registerTool("midi_recording", {
5298
5565
  // ============================================================
5299
5566
  server.registerTool("update_modulation", {
5300
5567
  title: "Update Modulation",
5301
- description: "Apply a delta to a modulation source: LFO, Envelope, Vary, Macro, ParamSeq config, or ParamSeq sequence data. " +
5568
+ description: "Apply a delta to a modulation source: LFO, Envelope, Vary, Macro, ParamSeq config, ParamSeq sequence data, " +
5569
+ "or the Free Sequencer master pattern. " +
5302
5570
  "The payload is type-specific; see modulation update flows in interop.ts (applyLfoUpdate / updateEnvelope / etc.). " +
5303
5571
  "Use add_modulation to first attach a source to a target — then this tool to edit it.",
5304
5572
  inputSchema: {
5305
5573
  type: z
5306
- .enum(["lfo", "envelope", "vary", "macro", "paramSeq", "paramSeqSequence"])
5574
+ .enum(["lfo", "envelope", "vary", "macro", "paramSeq", "paramSeqSequence", "freeSeqSequence"])
5307
5575
  .describe("Which modulation system to update."),
5308
5576
  payload: z
5309
5577
  .record(z.string(), z.unknown())
5310
- .describe("Update payload object. Shape depends on type: lfo expects { index, rate?, depth?, shape?, customCurve?, target? }, envelope expects matrix entry edits, paramSeqSequence expects { id, steps[] }, etc."),
5578
+ .describe("Update payload object. Shape depends on type: lfo expects { index, rate?, depth?, shape?, customCurve?, target? }, envelope expects matrix entry edits, paramSeqSequence expects { id, steps[] }, freeSeqSequence expects { sequence: number[] } (the master step contour every free-mode ParamSeq follows), etc."),
5311
5579
  },
5312
5580
  }, async ({ type, payload }) => {
5313
5581
  const check = await requireBridge();
@@ -5324,13 +5592,55 @@ server.registerTool("update_modulation", {
5324
5592
  ? "applyMacroUpdate"
5325
5593
  : type === "paramSeq"
5326
5594
  ? "applyParamSeqUpdate"
5327
- : "applyParamSeqSequenceUpdate";
5595
+ : type === "paramSeqSequence"
5596
+ ? "applyParamSeqSequenceUpdate"
5597
+ : "applyFreeSeqSequenceUpdate";
5328
5598
  await bridge.send(action, data);
5329
5599
  return text(`Applied ${type} update.`);
5330
5600
  });
5331
5601
  // ============================================================
5332
5602
  // TOOLS — Song mode
5333
5603
  // ============================================================
5604
+ // Advanced transition spec (ADR-001, PAM >= 1.19.6; engine families live from
5605
+ // PAM >= 1.20.0). Honest contract: it persists, round-trips, and reads back
5606
+ // RESOLVED. Pattern policy (dissolve/wipe/cells), mix law (equal power / equal
5607
+ // gain / correlation), tail (cut/fade/ring) and phase run on song VARIATION
5608
+ // sections and queued bar-sync flips; preset destinations coerce to a param
5609
+ // glide, and offline bounces coerce ring→fade and blend→hard switch (each
5610
+ // reported, never silent). Wire enums are append-only.
5611
+ const morphCurveEnum = z.enum(["linear", "ease", "exp", "log"]);
5612
+ const transitionSpecSchema = z.object({
5613
+ mode: z.enum(["auto", "param", "mix", "seq", "all"]).optional(),
5614
+ window: z
5615
+ .object({
5616
+ bars: z.number().min(0).max(8).optional(),
5617
+ paramCurve: morphCurveEnum.optional(),
5618
+ patternCurve: morphCurveEnum.optional(),
5619
+ mixCurve: morphCurveEnum.optional(),
5620
+ })
5621
+ .optional(),
5622
+ pattern: z.object({ policy: z.enum(["dissolve", "wipe", "cells"]).optional() }).optional(),
5623
+ audio: z
5624
+ .object({
5625
+ mixLaw: z.enum(["equalPower", "equalGain", "correlation"]).optional(),
5626
+ tail: z.enum(["cut", "fade", "ring"]).optional(),
5627
+ tailMaxBars: z.number().min(0.25).max(8).optional(),
5628
+ })
5629
+ .optional(),
5630
+ phase: z.enum(["inherit", "reset", "continue"]).optional(),
5631
+ });
5632
+ const TRANSITION_SPEC_HONESTY = "Advanced transition spec (PAM >= 1.19.6; live engine from PAM >= 1.20.0): pattern.policy " +
5633
+ "('dissolve' prob-morphs events, 'wipe' replaces them at a moving frontier, 'cells' hands " +
5634
+ "whole cells over in ascending order), audio.mixLaw ('equalPower' default, 'equalGain' for " +
5635
+ "correlated sections, 'correlation' auto-adapts), audio.tail ('fade' default, 'cut', 'ring' " +
5636
+ "lets voices/FX decay up to tailMaxBars), phase ('reset'/'continue' overrides the song's " +
5637
+ "Reset Phase per cable), window.patternCurve/mixCurve (per-domain easing; patternCurve " +
5638
+ "defaults linear). Runs on variation sections + queued flips, and on preset destinations " +
5639
+ "when the lanePresetSections setting is on (PAM >= 1.20.1 — the dual-lane preset section " +
5640
+ "pipeline; with it off, preset destinations coerce to a param glide). Offline bounces " +
5641
+ "coerce ring→fade, blend→hard switch. 'get' reads back each cable's resolvedTransition " +
5642
+ "(effective values after inheritance and capability coercion) and lastMorphResolution " +
5643
+ "(the engine's latest AUTO decision + reason).";
5334
5644
  server.registerTool("song_mode", {
5335
5645
  title: "Song Mode",
5336
5646
  description: "Control PAM's song mode (timeline of variation/preset sections). Ops: 'setArrangement', " +
@@ -5342,9 +5652,13 @@ server.registerTool("song_mode", {
5342
5652
  "VX (visual) variation slot via `vxVariationIndex` (omit = visuals follow the section's " +
5343
5653
  "audio slot 1:1; see the vx_variation tool), optionally inside a specific VX scene via " +
5344
5654
  "`vxScenePath` (the .vxpreset loads before the section plays, so sections can pull visuals " +
5345
- "from different scenes). 'get' reads the arrangement back (nodes, compiled steps with the " +
5346
- "VX scene+slot each recalls, total bars); 'assignVx' re-pins one section's VX slot (and " +
5347
- "optional scene) after authoring.\n\n" +
5655
+ "from different scenes). A section can also carry `morphBars`/`morphCurve`/`morphMode` to glide the " +
5656
+ "incoming variation's continuous params over the bars before its boundary (PAM >= 1.19.3), and an " +
5657
+ "advanced `transition` spec (PAM >= 1.19.6 — authored metadata; see the field description). " +
5658
+ "'get' reads the arrangement back (nodes, compiled steps with the " +
5659
+ "VX scene+slot each recalls, total bars, and each cable's `resolvedTransition` — the effective " +
5660
+ "transition after inheritance and capability coercion, e.g. preset destinations report param); " +
5661
+ "'assignVx' re-pins one section's VX slot (and optional scene) after authoring.\n\n" +
5348
5662
  "'setSequence' is the older HEADLESS raw-step path (works with the window closed): steps " +
5349
5663
  "land in the C++ player but carry no grid nodes, so the UI can't edit them and VX can only " +
5350
5664
  "follow 1:1 by audio slot.",
@@ -5365,13 +5679,28 @@ server.registerTool("song_mode", {
5365
5679
  .describe("Operation."),
5366
5680
  sections: z
5367
5681
  .array(z.object({
5368
- variationIndex: z.number().min(0).max(7).describe("Audio variation slot 0-7 this section plays."),
5682
+ variationIndex: z.number().min(0).max(7).describe("Audio variation slot 0-7 this section plays. With presetPath set, this selects the variation INSIDE that preset's own bank — the two compose, they are not mutually exclusive."),
5369
5683
  bars: z.number().positive().optional().describe("Section length in bars (default: auto from longest pattern)."),
5370
5684
  repeat: z.number().int().min(1).optional().describe("Times to play before advancing (default 1)."),
5371
5685
  muted: z.boolean().optional().describe("Pass through without an audio trigger."),
5372
5686
  probability: z.number().min(0).max(1).optional().describe("Trigger probability (0-1)."),
5373
- presetPath: z.string().optional().describe("Load this preset instead of switching variation."),
5687
+ presetPath: z.string().optional().describe("Cross-preset section: at this section's boundary the song switches to THIS preset file, playing its variations[variationIndex] slot. With lanePresetSections on (PAM >= 1.20.1) it transitions exactly like a variation section (prepared off the audible path, full morph family); otherwise the legacy in-place load applies."),
5374
5688
  label: z.string().optional().describe("Section label shown on the grid node (e.g. 'Intro', 'Hook')."),
5689
+ morphBars: z
5690
+ .number()
5691
+ .min(0)
5692
+ .max(8)
5693
+ .optional()
5694
+ .describe("Pre-anchor morph window in bars for the transition INTO this section — continuous params glide to the new values, landing at the boundary (PAM >= 1.19.3). 0 = explicit hard switch (never morph, ignores the song's default); omit to inherit the default. Values in (0, 0.25) clamp up to 0.25."),
5695
+ morphCurve: z
5696
+ .enum(["linear", "ease", "exp", "log"])
5697
+ .optional()
5698
+ .describe("Morph glide easing; default ease. Ignored without morphBars."),
5699
+ morphMode: z
5700
+ .enum(["auto", "param", "mix", "seq", "all"])
5701
+ .optional()
5702
+ .describe("Morph realization; default auto — the engine picks mix when the sections differ in graph topology (samples, synth engines, routing) or when nothing can glide, param otherwise (PAM >= 1.19.4; older builds run param). 'param' glides continuous params; 'mix' DJ-blends stems; 'seq' adds a staged pattern dissolve to the glide; 'all' runs glide + dissolve + blend (seq/all on older builds run param). Ignored without morphBars."),
5703
+ transition: transitionSpecSchema.optional().describe(TRANSITION_SPEC_HONESTY),
5375
5704
  vxVariationIndex: z
5376
5705
  .number()
5377
5706
  .int()
@@ -5399,9 +5728,28 @@ server.registerTool("song_mode", {
5399
5728
  .max(1)
5400
5729
  .optional()
5401
5730
  .describe("Branch probability (0-1). Sibling routes normalize to 100%."),
5731
+ morphBars: z
5732
+ .number()
5733
+ .min(0)
5734
+ .max(8)
5735
+ .optional()
5736
+ .describe("Per-cable morph window in bars — overrides the destination section's morphBars for THIS cable only (branching arrangements can give each incoming cable its own window). 0 = hard switch; omit to use the section value / song default."),
5737
+ morphCurve: z
5738
+ .enum(["linear", "ease", "exp", "log"])
5739
+ .optional()
5740
+ .describe("Per-cable morph easing; overrides the destination section's morphCurve."),
5741
+ morphMode: z
5742
+ .enum(["auto", "param", "mix", "seq", "all"])
5743
+ .optional()
5744
+ .describe("Per-cable morph realization; overrides the destination section's morphMode. Same modes as the section field, incl. 'seq' (pattern dissolve) and 'all' (glide + dissolve + blend)."),
5745
+ transition: transitionSpecSchema.optional().describe("Per-cable " + TRANSITION_SPEC_HONESTY),
5402
5746
  }))
5403
5747
  .optional()
5404
- .describe("For 'setArrangement': optional cable graph. Multiple cables may leave or enter a section."),
5748
+ .describe("For 'setArrangement': optional cable graph. Multiple cables may leave or enter a section. Each cable may carry its own morphBars/morphCurve/morphMode/transition."),
5749
+ defaultTransition: transitionSpecSchema
5750
+ .nullable()
5751
+ .optional()
5752
+ .describe("For 'setArrangement': song-level advanced transition defaults inherited by cables without their own `transition`; null clears. " + TRANSITION_SPEC_HONESTY),
5405
5753
  loop: z.boolean().optional().describe("For 'setArrangement': loop the whole arrangement."),
5406
5754
  nodeId: z.string().optional().describe("For 'assignVx': grid node id (from op 'get')."),
5407
5755
  stepIndex: z.number().int().min(0).optional().describe("For 'assignVx': compiled step index (alternative to nodeId)."),
@@ -5464,6 +5812,7 @@ server.registerTool("song_mode", {
5464
5812
  op: "setSongArrangement",
5465
5813
  sections: args.sections,
5466
5814
  ...(args.connections !== undefined && { connections: args.connections }),
5815
+ ...(args.defaultTransition !== undefined && { defaultTransition: args.defaultTransition }),
5467
5816
  ...(args.loop !== undefined && { loop: args.loop }),
5468
5817
  ...(args.enabled !== undefined && { enabled: args.enabled }),
5469
5818
  ...(args.crossfadeMs !== undefined && { crossfadeMs: args.crossfadeMs }),
@@ -6239,7 +6588,7 @@ server.registerTool("update_settings", {
6239
6588
  "experimental-feature gates from an agent. Common boolean keys: experimentalMode, " +
6240
6589
  "experimentalPluginHost, experimentalMcpBridge. To enable the plugin host, set BOTH " +
6241
6590
  "experimentalMode:true AND the specific sub-flag (e.g. experimentalPluginHost:true) in one call. " +
6242
- "The drum synth needs no flag — it is a standard feature on PAM. Other keys " +
6591
+ "Set drumSynthEnabled:false to hide its UI without muting loaded synths. Other keys " +
6243
6592
  "include UI/library preferences — call get_state or inspect the returned object for the current set.",
6244
6593
  inputSchema: {
6245
6594
  settings: z
@@ -6316,6 +6665,24 @@ server.registerTool("get_audio_device_setup", {
6316
6665
  const setup = await bridge.send("getAudioDeviceSetup");
6317
6666
  return json(setup);
6318
6667
  });
6668
+ server.registerTool("set_audio_device", {
6669
+ title: "Set Audio Output Device",
6670
+ description: "Switch the active audio OUTPUT device by name (standalone only — plugin-hosted builds let the DAW own the " +
6671
+ "device, and the call errors there). Use the exact name from get_audio_device_setup's device list. " +
6672
+ "The usual reason to call this headlessly is routing PAM into a loopback device (BlackHole, Soundflower) " +
6673
+ "so a capture run records audio. MIDI devices go through set_midi_io.",
6674
+ inputSchema: {
6675
+ deviceName: z
6676
+ .string()
6677
+ .describe("Output device name exactly as listed by get_audio_device_setup."),
6678
+ },
6679
+ }, async ({ deviceName }) => {
6680
+ const check = await requireBridge();
6681
+ if (check !== true)
6682
+ return check;
6683
+ await bridge.send("setAudioDevice", [deviceName]);
6684
+ return text(`Audio output device set to '${deviceName}'.`);
6685
+ });
6319
6686
  server.registerTool("set_midi_io", {
6320
6687
  title: "Set MIDI I/O Devices",
6321
6688
  description: "Select MIDI input/output devices (standalone). Use get_audio_device_setup to discover device IDs first. " +