@map-audio/pam-mcp-server 1.12.0 → 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/README.md +21 -3
- package/dist/bridge.d.ts +17 -3
- package/dist/bridge.js +81 -20
- package/dist/bridge.js.map +1 -1
- package/dist/drumSynth.d.ts +96 -0
- package/dist/drumSynth.js +193 -0
- package/dist/drumSynth.js.map +1 -0
- package/dist/drumSynthEngines.json +1360 -0
- package/dist/fgemma-format.d.ts +119 -0
- package/dist/fgemma-format.js +292 -0
- package/dist/fgemma-format.js.map +1 -0
- package/dist/fgemma-runner.d.ts +27 -0
- package/dist/fgemma-runner.js +475 -0
- package/dist/fgemma-runner.js.map +1 -0
- package/dist/generated/variationSnapshot.d.ts +31 -0
- package/dist/generated/variationSnapshot.js +62 -0
- package/dist/generated/variationSnapshot.js.map +1 -0
- package/dist/manifest-cell1.json +8245 -0
- package/dist/manifest-mfx.json +14060 -0
- package/dist/manifest.d.ts +14 -1
- package/dist/manifest.js +133 -34
- package/dist/manifest.js.map +1 -1
- package/dist/manifest.json +2322 -157
- package/dist/mixAssist.d.ts +155 -0
- package/dist/mixAssist.js +652 -0
- package/dist/mixAssist.js.map +1 -0
- package/dist/presetSave.d.ts +10 -0
- package/dist/presetSave.js +22 -0
- package/dist/presetSave.js.map +1 -0
- package/dist/server.js +2667 -236
- package/dist/server.js.map +1 -1
- package/dist/variationSessionKeys.json +110 -0
- package/package.json +8 -3
package/dist/server.js
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { completable } from "@modelcontextprotocol/sdk/server/completable.js";
|
|
4
5
|
import { z } from "zod";
|
|
6
|
+
import { buildVariationSnapshotBase } from "./generated/variationSnapshot.js";
|
|
5
7
|
import { readFile } from "node:fs/promises";
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
9
|
import { inflateSync } from "node:zlib";
|
|
7
10
|
import { basename, dirname, resolve } from "node:path";
|
|
8
11
|
import { fileURLToPath } from "node:url";
|
|
9
|
-
import {
|
|
12
|
+
import { createDrumSynthCatalog, } from "./drumSynth.js";
|
|
13
|
+
import { loadManifest, cellEffectNames, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, setActiveProductHint, walkPresets, } from "./manifest.js";
|
|
10
14
|
import { PluginBridge } from "./bridge.js";
|
|
15
|
+
import { buildMixMeasurementFromState, currentFlatFromState, parseMixAnalysisPayload, suggestMix, } from "./mixAssist.js";
|
|
16
|
+
import { normalizePresetLookupPath, selectSaveVariationSourcePath, } from "./presetSave.js";
|
|
17
|
+
import { STOP_SEQUENCES, buildGenerationPrompt, parseFunctionCalls, renderContextLine, renderFunctionCall, renderFunctionResponse, truncateAtFabricatedResponse, withContext, } from "./fgemma-format.js";
|
|
11
18
|
// --- State ---
|
|
12
19
|
// Variation slot count. Must stay in sync with PRESET_VARIATION_SLOTS in
|
|
13
20
|
// interface/src/stores/Preset.ts. Defined locally because the MCP package
|
|
@@ -16,6 +23,8 @@ const PRESET_VARIATION_SLOTS = 8;
|
|
|
16
23
|
const bridge = new PluginBridge();
|
|
17
24
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
18
25
|
const cellModeChangeSeq = {};
|
|
26
|
+
let lastSavedPresetName;
|
|
27
|
+
let lastSavedPresetPath;
|
|
19
28
|
// --- Helpers ---
|
|
20
29
|
function text(s) {
|
|
21
30
|
return { content: [{ type: "text", text: s }] };
|
|
@@ -23,12 +32,78 @@ function text(s) {
|
|
|
23
32
|
function json(obj) {
|
|
24
33
|
return text(JSON.stringify(obj, null, 2));
|
|
25
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Structured result: the same JSON in the text block (for text-only consumers,
|
|
37
|
+
* including ask_assistant's internal dispatch) plus structuredContent for
|
|
38
|
+
* clients that consume the tool's outputSchema. Only use from tools that
|
|
39
|
+
* declare an outputSchema — the SDK requires structuredContent on success then.
|
|
40
|
+
*/
|
|
41
|
+
function structured(obj) {
|
|
42
|
+
const record = isRecord(obj) ? obj : { value: obj };
|
|
43
|
+
return {
|
|
44
|
+
content: [{ type: "text", text: JSON.stringify(record, null, 2) }],
|
|
45
|
+
structuredContent: record,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** structured() for bridge results that may arrive as a raw JSON string. */
|
|
49
|
+
function structuredFromBridge(raw) {
|
|
50
|
+
if (typeof raw === "string") {
|
|
51
|
+
try {
|
|
52
|
+
return structured(JSON.parse(raw));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return structured({ value: raw });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return structured(raw);
|
|
59
|
+
}
|
|
26
60
|
function error(msg) {
|
|
27
61
|
return { content: [{ type: "text", text: msg }], isError: true };
|
|
28
62
|
}
|
|
29
|
-
|
|
63
|
+
function isMfxManifest(manifest) {
|
|
64
|
+
return (manifest.name?.toLowerCase() === "mfx" ||
|
|
65
|
+
manifest.bundleId?.toLowerCase() === "map.audio.mfx" ||
|
|
66
|
+
manifest.pluginCode?.toLowerCase() === "amfx");
|
|
67
|
+
}
|
|
68
|
+
const MFX_CELL_CONFIG_KEYS = new Set(["sidechainFrom"]);
|
|
69
|
+
async function loadManifestForProductGuard() {
|
|
30
70
|
try {
|
|
31
|
-
|
|
71
|
+
return await loadManifest();
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function manifestMaxCellId(manifest) {
|
|
78
|
+
if (!manifest)
|
|
79
|
+
return null;
|
|
80
|
+
const entries = [
|
|
81
|
+
...(manifest.parameters ?? []),
|
|
82
|
+
...(manifest.stateVariables ?? []),
|
|
83
|
+
...(manifest.parameterInfos ?? []),
|
|
84
|
+
];
|
|
85
|
+
const ids = new Set();
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
const cellMatch = typeof entry.cell === "string" ? /^cell(\d+)$/.exec(entry.cell) : null;
|
|
88
|
+
const paramMatch = typeof entry.paramId === "string" ? /^cell(\d+)_/.exec(entry.paramId) : null;
|
|
89
|
+
const raw = cellMatch?.[1] ?? paramMatch?.[1];
|
|
90
|
+
if (raw)
|
|
91
|
+
ids.add(Number(raw));
|
|
92
|
+
}
|
|
93
|
+
return ids.size > 0 ? Math.max(...ids) : null;
|
|
94
|
+
}
|
|
95
|
+
function validateCellIdForManifest(cellId, manifest) {
|
|
96
|
+
const maxCellId = manifestMaxCellId(manifest);
|
|
97
|
+
if (maxCellId !== null && cellId > maxCellId) {
|
|
98
|
+
const productName = manifest?.name ?? "This product";
|
|
99
|
+
const noun = maxCellId === 1 ? "cell" : "cells";
|
|
100
|
+
return error(`${productName} exposes ${maxCellId} ${noun}; cell ${cellId} is not available in this product.`);
|
|
101
|
+
}
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
function readPackageVersion() {
|
|
105
|
+
try {
|
|
106
|
+
const raw = readFileSync(resolve(PACKAGE_ROOT, "package.json"), "utf-8");
|
|
32
107
|
const pkg = JSON.parse(raw);
|
|
33
108
|
return typeof pkg.version === "string" ? pkg.version : "unknown";
|
|
34
109
|
}
|
|
@@ -36,14 +111,17 @@ async function readPackageVersion() {
|
|
|
36
111
|
return "unknown";
|
|
37
112
|
}
|
|
38
113
|
}
|
|
114
|
+
// Single source of truth for the server version: package.json. The MCP handshake,
|
|
115
|
+
// --version, and --help must all report the same number.
|
|
116
|
+
const SERVER_VERSION = readPackageVersion();
|
|
39
117
|
async function handleCliArgs() {
|
|
40
118
|
const args = new Set(process.argv.slice(2));
|
|
41
119
|
if (args.has("--version") || args.has("-v")) {
|
|
42
|
-
console.log(
|
|
120
|
+
console.log(SERVER_VERSION);
|
|
43
121
|
return true;
|
|
44
122
|
}
|
|
45
123
|
if (args.has("--help") || args.has("-h")) {
|
|
46
|
-
const version =
|
|
124
|
+
const version = SERVER_VERSION;
|
|
47
125
|
console.log(`pam-mcp ${version}\n\n` +
|
|
48
126
|
"MCP stdio server for PAM. Configure your MCP client to launch this command; " +
|
|
49
127
|
"it reads ~/.pam-mcp-bridge.json to find a running PAM instance.");
|
|
@@ -51,14 +129,24 @@ async function handleCliArgs() {
|
|
|
51
129
|
}
|
|
52
130
|
return false;
|
|
53
131
|
}
|
|
132
|
+
// Point offline tools (param validation, design_context) at the manifest of
|
|
133
|
+
// the product we're actually connected to.
|
|
134
|
+
function syncProductHint() {
|
|
135
|
+
setActiveProductHint(bridge.getConnectedInstance()?.product);
|
|
136
|
+
}
|
|
54
137
|
async function requireBridge() {
|
|
55
138
|
if (bridge.isConnected())
|
|
56
139
|
return true;
|
|
57
140
|
const ok = await bridge.connect();
|
|
58
141
|
if (!ok) {
|
|
142
|
+
const pin = process.env.PAM_BRIDGE_PRODUCT;
|
|
59
143
|
return error("Not connected to PAM plugin. Start PAM (standalone or in a DAW) with MCP bridge enabled. " +
|
|
60
|
-
"The bridge writes its port to ~/.pam-mcp-bridge.json on startup."
|
|
144
|
+
"The bridge writes its port to ~/.pam-mcp-bridge.json on startup." +
|
|
145
|
+
(pin
|
|
146
|
+
? ` Note: PAM_BRIDGE_PRODUCT pins this server to '${pin}' — no matching instance was found (connection_status lists what is running).`
|
|
147
|
+
: ""));
|
|
61
148
|
}
|
|
149
|
+
syncProductHint();
|
|
62
150
|
return true;
|
|
63
151
|
}
|
|
64
152
|
/**
|
|
@@ -121,42 +209,93 @@ async function readExistingVariations(presetPath) {
|
|
|
121
209
|
return Array(PRESET_VARIATION_SLOTS).fill(null);
|
|
122
210
|
}
|
|
123
211
|
}
|
|
124
|
-
|
|
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.
|
|
125
229
|
const VARIATION_EXCLUDED_PARAMS = [
|
|
126
|
-
"
|
|
127
|
-
"interfaceCache",
|
|
128
|
-
"songMode",
|
|
129
|
-
"volume",
|
|
130
|
-
"hqMode",
|
|
131
|
-
"previewSamplePath",
|
|
132
|
-
"previewCellId",
|
|
133
|
-
"transport",
|
|
134
|
-
"snapshotRateHz",
|
|
135
|
-
"looperCrossfade",
|
|
136
|
-
"looperLength",
|
|
137
|
-
"looperVolume",
|
|
138
|
-
"looperFilter",
|
|
139
|
-
"loopers",
|
|
140
|
-
"looperPlaybackBars",
|
|
141
|
-
"looperTrack1Level",
|
|
142
|
-
"looperTrack2Level",
|
|
143
|
-
"looperTrack3Level",
|
|
144
|
-
"looperTrack4Level",
|
|
145
|
-
"looperTrack5Level",
|
|
146
|
-
"looperTrack6Level",
|
|
147
|
-
"looperTrack7Level",
|
|
148
|
-
"looperTrack8Level",
|
|
230
|
+
...new Set([...(VARIATION_CONTRACT.all ?? []), "transport"]),
|
|
149
231
|
];
|
|
150
232
|
/**
|
|
151
|
-
* Build a variation snapshot string from plugin state
|
|
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.
|
|
152
246
|
*/
|
|
153
247
|
function buildVariationString(state) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
+
}
|
|
269
|
+
}
|
|
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]));
|
|
157
283
|
}
|
|
158
|
-
|
|
159
|
-
|
|
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.");
|
|
160
299
|
}
|
|
161
300
|
function formatParam(p) {
|
|
162
301
|
let desc = `${p.paramId}: ${p.displayName}`;
|
|
@@ -176,35 +315,490 @@ function formatParam(p) {
|
|
|
176
315
|
desc += ` — ${p.tooltip}`;
|
|
177
316
|
return desc;
|
|
178
317
|
}
|
|
318
|
+
const FULL_STATE_RESPONSE_LIMIT_CHARS = 24_000;
|
|
319
|
+
// Cell effects are derived per product from the manifest (`cellEffectNames`) —
|
|
320
|
+
// this constant is only the fallback for a manifest that failed to load. The
|
|
321
|
+
// hardcoded eight it used to be is why get_design_overview under-reported:
|
|
322
|
+
// limiter, qverb, eq8, panner, gain, vocoder (and MFX's wavefold/resonator)
|
|
323
|
+
// were enabled in state but absent from `enabledEffects`.
|
|
324
|
+
const CELL_EFFECTS_FALLBACK = [
|
|
325
|
+
"filter", "delay", "crusher", "flanger", "eq", "fdn", "gate", "compressor",
|
|
326
|
+
];
|
|
327
|
+
const PATTERN_LANES = {
|
|
328
|
+
note: "midiSequence",
|
|
329
|
+
pitch: "pitchSequence",
|
|
330
|
+
slice: "sliceSequence",
|
|
331
|
+
stretch: "stretchSequence",
|
|
332
|
+
};
|
|
333
|
+
const LFO_DIVISIONS = [
|
|
334
|
+
"16 bar", "8 bar", "4 bar", "2 bar", "1 bar",
|
|
335
|
+
"1/2D", "1/2", "1/2T",
|
|
336
|
+
"1/4D", "1/4", "1/4T",
|
|
337
|
+
"1/8D", "1/8", "1/8T",
|
|
338
|
+
"1/16D", "1/16", "1/16T",
|
|
339
|
+
"1/32D", "1/32", "1/32T",
|
|
340
|
+
"1/64D", "1/64", "1/64T",
|
|
341
|
+
];
|
|
342
|
+
function levenshtein(a, b) {
|
|
343
|
+
const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
344
|
+
const curr = Array(b.length + 1).fill(0);
|
|
345
|
+
for (let i = 1; i <= a.length; i++) {
|
|
346
|
+
curr[0] = i;
|
|
347
|
+
for (let j = 1; j <= b.length; j++) {
|
|
348
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
349
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
350
|
+
}
|
|
351
|
+
for (let j = 0; j <= b.length; j++)
|
|
352
|
+
prev[j] = curr[j];
|
|
353
|
+
}
|
|
354
|
+
return prev[b.length];
|
|
355
|
+
}
|
|
356
|
+
function similarity(a, b) {
|
|
357
|
+
if (a === b)
|
|
358
|
+
return 1;
|
|
359
|
+
const maxLen = Math.max(a.length, b.length);
|
|
360
|
+
if (maxLen === 0)
|
|
361
|
+
return 1;
|
|
362
|
+
return 1 - levenshtein(a, b) / maxLen;
|
|
363
|
+
}
|
|
364
|
+
function paramSuffix(paramId) {
|
|
365
|
+
const m = paramId.match(/^(?:cell\d+|macro|lfo\d+)_?(.*)$/);
|
|
366
|
+
return m?.[1] || paramId;
|
|
367
|
+
}
|
|
368
|
+
function explicitParamAliases(paramId) {
|
|
369
|
+
const aliases = new Set();
|
|
370
|
+
const aliasSuffixes = {
|
|
371
|
+
delayTime: "delayDelayTime",
|
|
372
|
+
crusherBits: "crusherBitReduction",
|
|
373
|
+
crusherBitDepth: "crusherBitReduction",
|
|
374
|
+
crusherDownsample: "crusherRateReduction",
|
|
375
|
+
fdnDecay: "fdnFeedback",
|
|
376
|
+
filterResonance: "filterQ",
|
|
377
|
+
};
|
|
378
|
+
const lfo0 = paramId.match(/^lfo0_(.+)$/);
|
|
379
|
+
if (lfo0)
|
|
380
|
+
aliases.add(`lfo1_${lfo0[1]}`);
|
|
381
|
+
const cellMatch = paramId.match(/^(cell\d+_)(.+)$/);
|
|
382
|
+
const macroMatch = paramId.match(/^(macro_)(.+)$/);
|
|
383
|
+
const prefix = cellMatch?.[1] ?? macroMatch?.[1] ?? "";
|
|
384
|
+
const suffix = cellMatch?.[2] ?? macroMatch?.[2] ?? paramId;
|
|
385
|
+
const replacement = aliasSuffixes[suffix];
|
|
386
|
+
if (replacement)
|
|
387
|
+
aliases.add(`${prefix}${replacement}`);
|
|
388
|
+
return [...aliases];
|
|
389
|
+
}
|
|
390
|
+
function suggestParams(paramId, index, opts = {}) {
|
|
391
|
+
const explicit = explicitParamAliases(paramId).filter((id) => {
|
|
392
|
+
const p = index.get(id);
|
|
393
|
+
return p && (!opts.modulatableOnly || p.isModulatable);
|
|
394
|
+
});
|
|
395
|
+
const scored = [...index.values()]
|
|
396
|
+
.filter((p) => !opts.modulatableOnly || p.isModulatable)
|
|
397
|
+
.map((p) => {
|
|
398
|
+
const full = similarity(paramId.toLowerCase(), p.paramId.toLowerCase());
|
|
399
|
+
const suffix = similarity(paramSuffix(paramId).toLowerCase(), paramSuffix(p.paramId).toLowerCase());
|
|
400
|
+
const sameCell = paramId.match(/^cell\d+_/)?.[0] === p.paramId.match(/^cell\d+_/)?.[0] ? 0.12 : 0;
|
|
401
|
+
const sameFamily = paramId.startsWith("macro_") && p.paramId.startsWith("macro_") ? 0.08 :
|
|
402
|
+
paramId.startsWith("lfo") && p.paramId.startsWith("lfo") ? 0.08 :
|
|
403
|
+
0;
|
|
404
|
+
return { id: p.paramId, score: Math.max(full, suffix) + sameCell + sameFamily };
|
|
405
|
+
})
|
|
406
|
+
.filter((x) => x.score >= 0.52)
|
|
407
|
+
.sort((a, b) => b.score - a.score)
|
|
408
|
+
.map((x) => x.id);
|
|
409
|
+
return [...new Set([...explicit, ...scored])].slice(0, 5);
|
|
410
|
+
}
|
|
411
|
+
function formatUnknownParamError(paramId, index, opts = {}) {
|
|
412
|
+
const suggestions = suggestParams(paramId, index, opts);
|
|
413
|
+
const hint = paramId.startsWith("lfo0_")
|
|
414
|
+
? " LFO parameter IDs are 1-based: use lfo1_... through lfo6_..."
|
|
415
|
+
: "";
|
|
416
|
+
const scope = opts.modulatableOnly ? " modulatable parameter" : " parameter";
|
|
417
|
+
return `Unknown${scope}: ${paramId}.${hint}${suggestions.length ? ` Did you mean ${suggestions.join(", ")}?` : ""}`;
|
|
418
|
+
}
|
|
419
|
+
function formatNotModulatableError(paramId, index) {
|
|
420
|
+
const suggestions = suggestParams(paramId, index, { modulatableOnly: true });
|
|
421
|
+
return `Parameter '${paramId}' is not modulatable.${suggestions.length ? ` Try modulatable target ${suggestions.join(", ")}.` : ""}`;
|
|
422
|
+
}
|
|
423
|
+
function formatRangeError(param, value) {
|
|
424
|
+
const unit = param.suffix ? param.suffix : "unitless";
|
|
425
|
+
const traits = [
|
|
426
|
+
param.bipolar ? "bipolar" : "unipolar",
|
|
427
|
+
unit,
|
|
428
|
+
param.strRepr && param.strRepr.length > 0
|
|
429
|
+
? `values: ${param.strRepr.map((v, i) => `${i}=${v}`).join(", ")}`
|
|
430
|
+
: undefined,
|
|
431
|
+
].filter(Boolean);
|
|
432
|
+
const panHint = param.paramId.endsWith("_pan") && Math.abs(value) > 1 && Math.abs(value) <= 100
|
|
433
|
+
? `; ${value / 100} is approximately ${Math.abs(value)}% ${value < 0 ? "left" : "right"}`
|
|
434
|
+
: "";
|
|
435
|
+
return `${param.paramId}: ${value} out of range [${param.min}..${param.max}] (${traits.join("; ")}${panHint})`;
|
|
436
|
+
}
|
|
437
|
+
function jsonWithLimit(obj, tooLargeHint) {
|
|
438
|
+
const serialized = JSON.stringify(obj, null, 2);
|
|
439
|
+
if (serialized.length > FULL_STATE_RESPONSE_LIMIT_CHARS) {
|
|
440
|
+
return error(`Result is ${serialized.length.toLocaleString()} characters, above the ${FULL_STATE_RESPONSE_LIMIT_CHARS.toLocaleString()} character safety cap. ` +
|
|
441
|
+
tooLargeHint);
|
|
442
|
+
}
|
|
443
|
+
return text(serialized);
|
|
444
|
+
}
|
|
445
|
+
function isRecord(value) {
|
|
446
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
447
|
+
}
|
|
448
|
+
function cellPrefixSet(cellIds) {
|
|
449
|
+
return new Set((cellIds ?? []).map((id) => `cell${id}_`));
|
|
450
|
+
}
|
|
451
|
+
function paramTargetsCell(value, cells) {
|
|
452
|
+
if (!isRecord(value))
|
|
453
|
+
return false;
|
|
454
|
+
const paramId = typeof value.paramId === "string" ? value.paramId : undefined;
|
|
455
|
+
if (paramId && [...cells].some((prefix) => paramId.startsWith(prefix)))
|
|
456
|
+
return true;
|
|
457
|
+
const cellId = typeof value.cellId === "number" ? value.cellId : undefined;
|
|
458
|
+
return cellId !== undefined && cells.has(`cell${cellId}_`);
|
|
459
|
+
}
|
|
460
|
+
function filterState(state, opts) {
|
|
461
|
+
if (!isRecord(state))
|
|
462
|
+
return state;
|
|
463
|
+
const keySet = opts.keys?.length ? new Set(opts.keys) : null;
|
|
464
|
+
const prefixes = cellPrefixSet(opts.cellIds);
|
|
465
|
+
const filterByCell = prefixes.size > 0;
|
|
466
|
+
if (!keySet && !filterByCell)
|
|
467
|
+
return state;
|
|
468
|
+
const out = {};
|
|
469
|
+
for (const [key, value] of Object.entries(state)) {
|
|
470
|
+
if (keySet?.has(key)) {
|
|
471
|
+
out[key] = value;
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
if (!filterByCell)
|
|
475
|
+
continue;
|
|
476
|
+
if ([...prefixes].some((prefix) => key.startsWith(prefix))) {
|
|
477
|
+
out[key] = value;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if (key === "cells" && isRecord(value)) {
|
|
481
|
+
const cells = {};
|
|
482
|
+
for (const id of opts.cellIds ?? []) {
|
|
483
|
+
if (value[String(id)] !== undefined)
|
|
484
|
+
cells[String(id)] = value[String(id)];
|
|
485
|
+
}
|
|
486
|
+
out.cells = cells;
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (["lfos", "envelopes", "vary", "paramSeqs"].includes(key) && Array.isArray(value)) {
|
|
490
|
+
out[key] = value.filter((entry) => paramTargetsCell(entry, prefixes));
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return out;
|
|
494
|
+
}
|
|
495
|
+
function filterSectionResult(section, result, opts) {
|
|
496
|
+
const keySet = opts.keys?.length ? new Set(opts.keys) : null;
|
|
497
|
+
const prefixes = cellPrefixSet(opts.cellIds);
|
|
498
|
+
if (section === "cells" && isRecord(result) && prefixes.size > 0) {
|
|
499
|
+
const out = {};
|
|
500
|
+
for (const id of opts.cellIds ?? []) {
|
|
501
|
+
if (result[String(id)] !== undefined)
|
|
502
|
+
out[String(id)] = result[String(id)];
|
|
503
|
+
}
|
|
504
|
+
return out;
|
|
505
|
+
}
|
|
506
|
+
if (["lfos", "envelopes", "vary", "paramSeqs"].includes(section) && Array.isArray(result) && prefixes.size > 0) {
|
|
507
|
+
return result.filter((entry) => paramTargetsCell(entry, prefixes));
|
|
508
|
+
}
|
|
509
|
+
if (keySet && isRecord(result)) {
|
|
510
|
+
const out = {};
|
|
511
|
+
for (const key of keySet) {
|
|
512
|
+
if (result[key] !== undefined)
|
|
513
|
+
out[key] = result[key];
|
|
514
|
+
}
|
|
515
|
+
return out;
|
|
516
|
+
}
|
|
517
|
+
return result;
|
|
518
|
+
}
|
|
519
|
+
function countEvents(value) {
|
|
520
|
+
return Array.isArray(value) ? value.length : 0;
|
|
521
|
+
}
|
|
522
|
+
function stemName(path) {
|
|
523
|
+
if (typeof path !== "string" || path.length === 0)
|
|
524
|
+
return undefined;
|
|
525
|
+
return basename(path);
|
|
526
|
+
}
|
|
179
527
|
// --- Server ---
|
|
180
528
|
const server = new McpServer({
|
|
181
529
|
name: "pam",
|
|
182
|
-
|
|
530
|
+
title: "PAM Audio Sampler",
|
|
531
|
+
version: SERVER_VERSION,
|
|
183
532
|
}, {
|
|
184
|
-
instructions: "PAM is an audio sampler plugin.
|
|
533
|
+
instructions: "PAM is an audio sampler plugin. Start design/read work with design_context (curated param " +
|
|
534
|
+
"cheat sheet) and get_design_overview (compact live snapshot) instead of full get_state or " +
|
|
535
|
+
"broad get_parameter_info; when you need detail, call get_state with section/keys/cellIds filters. " +
|
|
536
|
+
"When browsing samples, ALWAYS call get_default_locations " +
|
|
185
537
|
"first to discover the user's actual library paths — never guess paths like ~/Music/PAM. " +
|
|
186
538
|
"The sample library lives under ~/Library/Application Support/, NOT ~/Music/. " +
|
|
187
539
|
"Use the returned paths with list_samples. When loading samples, use load_sample with " +
|
|
188
540
|
"paths from list_samples. Transport must be playing for sequencer output. " +
|
|
189
541
|
"To enable experimental features (plugin host, AI engines) call update_settings with both " +
|
|
190
542
|
"experimentalMode:true AND the specific sub-flag (e.g. experimentalPluginHost:true). " +
|
|
191
|
-
"The
|
|
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. " +
|
|
192
545
|
"Tools added in server version 1.7.0 — update_settings, configure_transport, " +
|
|
193
546
|
"get_audio_device_setup, set_midi_io, set_midi_mapping, delete_preset, " +
|
|
194
547
|
"set_preset_factory_status, get_sample_usage, repair_preset_samples, preview_sample, " +
|
|
195
548
|
"set_plugin_params, skip_current_plugin_scan, remove_plugin_from_blocklist — require PAM ≥ 1.7.0. " +
|
|
196
549
|
"VX video tools (get_video_state, video_command, add_video_layer, update_video_layer, " +
|
|
197
550
|
"remove_video_layer, apply_video_layout, add_video_fx, set_video_fx, remove_video_fx, " +
|
|
198
|
-
"bind_video_param, set_video_settings) require PAM ≥ 1.8.0 AND PAM's window to be OPEN " +
|
|
199
|
-
"(the video engine renders in the WebView; there is no headless renderer).
|
|
551
|
+
"bind_video_param, set_video_settings, set_video_layer_source) require PAM ≥ 1.8.0 AND PAM's window to be OPEN " +
|
|
552
|
+
"(the video engine renders in the WebView; there is no headless renderer). " +
|
|
553
|
+
"capture_ui, set_visual, set_drum_synth_wave, and randomize with target 'synth' are " +
|
|
554
|
+
"WebView-routed too and need the window open for the same reason. For " +
|
|
200
555
|
"audio-reactive control, note the visual audio-react params (visualWaveDisplace, etc.) " +
|
|
201
556
|
"are regular plugin params — drive them with set_parameters; layers/FX/bindings go through " +
|
|
202
557
|
"the video tools. When building a scene, call get_video_state first to learn layer/fx ids. " +
|
|
558
|
+
"AI model lifecycle tools (download_model, get_model_status, load_model, unload_model, " +
|
|
559
|
+
"delete_model, list_models; engines aigen|magenta|notochord) require PAM ≥ 1.9.0 plus the " +
|
|
560
|
+
"matching experimental flags; load/unload/delete can block for seconds — poll " +
|
|
561
|
+
"get_model_status rather than tight-looping. " +
|
|
203
562
|
"Earlier tool sets (looper, song mode, modulation edits, MIDI recording, sample analysis, " +
|
|
204
|
-
"live resample, variation scheduling, AI generation) require PAM ≥ 1.
|
|
563
|
+
"live resample, variation scheduling, AI generation) require PAM ≥ 1.5.0. " +
|
|
564
|
+
"SONG WORKFLOW: author sections as variation slots (save_variation), arrange them with " +
|
|
565
|
+
"song_mode (op setArrangement authors real Song Mode grid nodes and can pin a VX variation " +
|
|
566
|
+
"slot per section via vxVariationIndex; op setSequence is the older headless raw-step path), " +
|
|
567
|
+
"manage visual variation slots with vx_variation (save/load/clear/fill — the VX siblings of " +
|
|
568
|
+
"save_variation), then bounce with export_song (includeVideo:true films the VX scene with " +
|
|
569
|
+
"variation follow live). song_mode setArrangement/get/assignVx, vx_variation, and " +
|
|
570
|
+
"export_song includeVideo are WebView-routed (window open, PAM ≥ 1.5.7). " +
|
|
205
571
|
"If a tool fails with \"PAM build does not support 'X'\", the connected plugin is older than " +
|
|
206
|
-
"this MCP server; the user must update PAM (updating the npm package alone is not enough)."
|
|
207
|
-
|
|
572
|
+
"this MCP server; the user must update PAM (updating the npm package alone is not enough). " +
|
|
573
|
+
"Tool annotations are accurate: readOnlyHint tools never mutate the plugin, destructiveHint " +
|
|
574
|
+
"tools discard user state (confirm intent before calling them). When several product " +
|
|
575
|
+
"instances run at once (PAM + MFX), select_instance chooses which one the live tools drive " +
|
|
576
|
+
"(PAM_BRIDGE_PRODUCT pins it at launch). Guided workflow prompts are available: build-a-beat, " +
|
|
577
|
+
"make-a-song, design-a-preset, mix-and-master, troubleshoot-bridge.",
|
|
578
|
+
});
|
|
579
|
+
// --- tool annotation policy ---------------------------------------------------
|
|
580
|
+
// Single audit surface for MCP tool annotations. Every tool MUST have an entry;
|
|
581
|
+
// the registerTool shim below merges it (inline annotations at the call site win
|
|
582
|
+
// per key) and warns on stderr when a tool registers unclassified — and
|
|
583
|
+
// scripts/check-tool-surface.mjs fails the release on a gap. openWorldHint is
|
|
584
|
+
// false server-wide: every tool targets the local plugin instance.
|
|
585
|
+
// Policy: readOnlyHint = no plugin/file mutation. destructiveHint = discards
|
|
586
|
+
// user state that isn't trivially recoverable (delete/clear/reset/overwrite the
|
|
587
|
+
// working state); slot-scoped saves follow the save_variation precedent (false).
|
|
588
|
+
// idempotentHint = repeating the same args reaches the same end state.
|
|
589
|
+
const RO = { readOnlyHint: true };
|
|
590
|
+
const W = { destructiveHint: false }; // additive / one-shot write
|
|
591
|
+
const WI = { destructiveHint: false, idempotentHint: true };
|
|
592
|
+
const D = { destructiveHint: true };
|
|
593
|
+
const DI = { destructiveHint: true, idempotentHint: true };
|
|
594
|
+
const TOOL_ANNOTATIONS = {
|
|
595
|
+
// Reads (offline + live introspection)
|
|
596
|
+
get_parameter_info: RO,
|
|
597
|
+
list_presets: RO,
|
|
598
|
+
read_preset: RO,
|
|
599
|
+
get_manifest_summary: RO,
|
|
600
|
+
design_context: RO,
|
|
601
|
+
get_state: RO,
|
|
602
|
+
get_design_overview: RO,
|
|
603
|
+
get_transport: RO,
|
|
604
|
+
get_master_analysis: RO,
|
|
605
|
+
suggest_mix: RO, // pure function over cached analysis + current state
|
|
606
|
+
connection_status: RO,
|
|
607
|
+
list_samples: RO,
|
|
608
|
+
get_default_locations: RO,
|
|
609
|
+
get_audio_info: RO,
|
|
610
|
+
get_video_state: RO,
|
|
611
|
+
capture_ui: RO, // writes a screenshot file, mutates no plugin state
|
|
612
|
+
get_sample_peaks: RO,
|
|
613
|
+
get_audio_device_setup: RO,
|
|
614
|
+
get_sample_usage: RO,
|
|
615
|
+
get_model_status: RO,
|
|
616
|
+
list_models: RO,
|
|
617
|
+
workbench_get_state: RO,
|
|
618
|
+
workbench_list_takes: RO,
|
|
619
|
+
workbench_sidecar_status: RO,
|
|
620
|
+
list_visuals: RO,
|
|
621
|
+
get_visual_data_url: RO,
|
|
622
|
+
list_plugins: RO,
|
|
623
|
+
get_plugin_scan_status: RO,
|
|
624
|
+
list_loaded_plugins: RO,
|
|
625
|
+
get_mix_analysis: W, // refresh:true runs an offline analysis render
|
|
626
|
+
// Parameter / cell / modulation writes
|
|
627
|
+
set_parameters: WI,
|
|
628
|
+
load_sample: WI,
|
|
629
|
+
update_pattern: WI,
|
|
630
|
+
add_modulation: W,
|
|
631
|
+
update_modulation: WI,
|
|
632
|
+
clear_modulations: DI,
|
|
633
|
+
reset_patterns: DI,
|
|
634
|
+
update_effect_config: WI,
|
|
635
|
+
update_cell_config: WI,
|
|
636
|
+
set_cell_mute: WI,
|
|
637
|
+
set_master_effects: WI,
|
|
638
|
+
add_param_seq: W,
|
|
639
|
+
update_param_seq: WI,
|
|
640
|
+
remove_param_seq: DI,
|
|
641
|
+
set_drum_synth: WI,
|
|
642
|
+
set_drum_synth_wave: WI,
|
|
643
|
+
stop_cell: WI,
|
|
644
|
+
reset_cell: DI,
|
|
645
|
+
copy_cell: DI, // overwrites the destination cell
|
|
646
|
+
randomize: W,
|
|
647
|
+
randomize_samples: W,
|
|
648
|
+
randomize_patterns: W,
|
|
649
|
+
// Transport / MIDI
|
|
650
|
+
start_transport: WI,
|
|
651
|
+
stop_transport: WI,
|
|
652
|
+
full_stop_transport: WI,
|
|
653
|
+
set_bpm: WI,
|
|
654
|
+
set_metronome: WI,
|
|
655
|
+
configure_transport: WI,
|
|
656
|
+
trigger_midi: W,
|
|
657
|
+
trigger_midi_cc: W,
|
|
658
|
+
trigger_midi_program_change: W,
|
|
659
|
+
midi_recording: W,
|
|
660
|
+
// Presets / variations
|
|
661
|
+
load_preset: DI, // replaces the working state (unsaved edits are lost)
|
|
662
|
+
save_preset: WI,
|
|
663
|
+
new_preset: DI,
|
|
664
|
+
import_preset: WI,
|
|
665
|
+
export_preset: W,
|
|
666
|
+
update_preset_metadata: WI,
|
|
667
|
+
delete_preset: DI,
|
|
668
|
+
set_preset_factory_status: WI,
|
|
669
|
+
repair_preset_samples: WI,
|
|
670
|
+
load_variation: WI,
|
|
671
|
+
save_variation: WI,
|
|
672
|
+
queue_variation: WI,
|
|
673
|
+
cancel_queued_variation: WI,
|
|
674
|
+
update_variations: WI,
|
|
675
|
+
variation_hold: WI,
|
|
676
|
+
// Sample ops
|
|
677
|
+
preview_sample: WI, // audible only, no state change
|
|
678
|
+
preview_slice: WI,
|
|
679
|
+
trim_sample: W, // restore_original_sample recovers the pre-edit file
|
|
680
|
+
reverse_sample: W,
|
|
681
|
+
gain_sample: W,
|
|
682
|
+
normalize_sample: WI,
|
|
683
|
+
analyze_sample_tempo: WI, // dispatches results into state
|
|
684
|
+
resample_sample_to_bpm: WI,
|
|
685
|
+
restore_original_sample: DI, // discards all working edits on the sample
|
|
686
|
+
build_sample_chain: W,
|
|
687
|
+
export_slice: W,
|
|
688
|
+
// Looper
|
|
689
|
+
arm_looper_track: WI,
|
|
690
|
+
set_looper_track: WI,
|
|
691
|
+
clear_looper_track: DI, // looper state is global, not per-preset
|
|
692
|
+
set_looper_track_selection: WI,
|
|
693
|
+
start_looper_recording: W,
|
|
694
|
+
stop_looper_recording: WI,
|
|
695
|
+
capture_looper_now: W,
|
|
696
|
+
load_audio_to_looper_track: WI,
|
|
697
|
+
request_looper_warp: WI,
|
|
698
|
+
looper_settings: WI,
|
|
699
|
+
looper_variation: W,
|
|
700
|
+
looper_variation_sync: WI,
|
|
701
|
+
live_resample: W,
|
|
702
|
+
// Song mode / export
|
|
703
|
+
song_mode: W,
|
|
704
|
+
export_song: W,
|
|
705
|
+
export_audio: W,
|
|
706
|
+
// Settings / devices
|
|
707
|
+
update_settings: WI,
|
|
708
|
+
set_audio_device: WI,
|
|
709
|
+
set_mfx_routing: WI,
|
|
710
|
+
set_midi_io: WI,
|
|
711
|
+
set_midi_mapping: WI,
|
|
712
|
+
// Plugin host
|
|
713
|
+
scan_plugins: WI,
|
|
714
|
+
cancel_plugin_scan: WI,
|
|
715
|
+
skip_current_plugin_scan: W, // blocklists the currently scanning plugin
|
|
716
|
+
remove_plugin_from_blocklist: WI,
|
|
717
|
+
load_plugin: WI,
|
|
718
|
+
unload_plugin: DI, // discards the hosted plugin's state
|
|
719
|
+
set_plugin_param: WI,
|
|
720
|
+
set_plugin_params: WI,
|
|
721
|
+
set_plugin_bypass: WI,
|
|
722
|
+
// AI models / generation
|
|
723
|
+
download_model: WI,
|
|
724
|
+
load_model: WI,
|
|
725
|
+
unload_model: WI,
|
|
726
|
+
delete_model: DI,
|
|
727
|
+
generate_sample: W,
|
|
728
|
+
generate_kit: W,
|
|
729
|
+
// Workbench
|
|
730
|
+
workbench_set_state: WI,
|
|
731
|
+
workbench_render: W,
|
|
732
|
+
workbench_load_take: WI,
|
|
733
|
+
workbench_undo: W,
|
|
734
|
+
workbench_reset_chain: DI,
|
|
735
|
+
// Visuals / VX video
|
|
736
|
+
set_visual: WI,
|
|
737
|
+
delete_visual: DI,
|
|
738
|
+
import_visual_folder: WI,
|
|
739
|
+
video_command: D, // generic escape hatch — includes resetWorkspace
|
|
740
|
+
add_video_layer: W,
|
|
741
|
+
update_video_layer: WI,
|
|
742
|
+
set_video_layer_source: WI,
|
|
743
|
+
remove_video_layer: DI,
|
|
744
|
+
apply_video_layout: WI,
|
|
745
|
+
add_video_fx: W,
|
|
746
|
+
set_video_fx: WI,
|
|
747
|
+
remove_video_fx: DI,
|
|
748
|
+
bind_video_param: WI,
|
|
749
|
+
set_video_settings: WI,
|
|
750
|
+
vx_variation: W,
|
|
751
|
+
// Connection management
|
|
752
|
+
select_instance: WI,
|
|
753
|
+
// Dev-tree-only tools
|
|
754
|
+
set_view: WI,
|
|
755
|
+
ask_assistant: D, // executes arbitrary model-chosen tool calls
|
|
756
|
+
};
|
|
757
|
+
const internalTools = new Map();
|
|
758
|
+
{
|
|
759
|
+
const origRegisterTool = server.registerTool.bind(server);
|
|
760
|
+
server.registerTool = ((name, config, handler) => {
|
|
761
|
+
internalTools.set(name, { shape: config.inputSchema, handler });
|
|
762
|
+
// Merge the central annotation policy. Inline annotations at the call site
|
|
763
|
+
// win per key; openWorldHint is false server-wide (local plugin only).
|
|
764
|
+
const cfg = config;
|
|
765
|
+
if (!TOOL_ANNOTATIONS[name]) {
|
|
766
|
+
console.error(`[pam-mcp] tool '${name}' has no TOOL_ANNOTATIONS entry — classify it (check-tool-surface fails on this)`);
|
|
767
|
+
}
|
|
768
|
+
cfg.annotations = { openWorldHint: false, ...TOOL_ANNOTATIONS[name], ...cfg.annotations };
|
|
769
|
+
return origRegisterTool(name, config, handler);
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
async function callInternalTool(name, args) {
|
|
773
|
+
const tool = internalTools.get(name);
|
|
774
|
+
if (!tool)
|
|
775
|
+
return { text: `Unknown tool: ${name}.`, isError: true };
|
|
776
|
+
// Direct handler calls skip the SDK's zod layer — parse here so schema
|
|
777
|
+
// defaults apply and unknown keys strip, exactly like a real MCP call
|
|
778
|
+
// (list_samples without offset/limit would otherwise slice(undefined, NaN)
|
|
779
|
+
// to an empty page).
|
|
780
|
+
let parsed = args;
|
|
781
|
+
if (tool.shape) {
|
|
782
|
+
tool.parser ??= z.object(tool.shape);
|
|
783
|
+
const r = tool.parser.safeParse(args);
|
|
784
|
+
if (!r.success) {
|
|
785
|
+
const issues = r.error.issues.map((i) => `${i.path.join(".") || "(args)"}: ${i.message}`).join("; ");
|
|
786
|
+
return { text: `Invalid arguments for ${name} — ${issues}`, isError: true };
|
|
787
|
+
}
|
|
788
|
+
parsed = r.data;
|
|
789
|
+
}
|
|
790
|
+
try {
|
|
791
|
+
const result = await tool.handler(parsed);
|
|
792
|
+
const out = (result.content ?? [])
|
|
793
|
+
.filter((c) => c.type === "text")
|
|
794
|
+
.map((c) => c.text ?? "")
|
|
795
|
+
.join("\n");
|
|
796
|
+
return { text: out, isError: Boolean(result.isError) };
|
|
797
|
+
}
|
|
798
|
+
catch (e) {
|
|
799
|
+
return { text: `Tool error: ${e.message}`, isError: true };
|
|
800
|
+
}
|
|
801
|
+
}
|
|
208
802
|
// ============================================================
|
|
209
803
|
// TOOLS — Offline (work without plugin running)
|
|
210
804
|
// ============================================================
|
|
@@ -241,7 +835,7 @@ server.registerTool("get_parameter_info", {
|
|
|
241
835
|
if (paramId) {
|
|
242
836
|
const p = index.get(paramId);
|
|
243
837
|
if (!p)
|
|
244
|
-
return error(
|
|
838
|
+
return error(formatUnknownParamError(paramId, index));
|
|
245
839
|
return json(p);
|
|
246
840
|
}
|
|
247
841
|
let params;
|
|
@@ -269,8 +863,10 @@ server.registerTool("get_parameter_info", {
|
|
|
269
863
|
if (params.length === 0)
|
|
270
864
|
return text("No parameters found matching criteria.");
|
|
271
865
|
if (params.length > 50) {
|
|
272
|
-
|
|
273
|
-
|
|
866
|
+
const shown = params.slice(0, 80);
|
|
867
|
+
return text(`${params.length} parameters found. Showing first ${shown.length}. ` +
|
|
868
|
+
"Use paramId, cell, effect, search, type, or design_context to narrow the response.\n\n" +
|
|
869
|
+
shown.map(formatParam).join("\n"));
|
|
274
870
|
}
|
|
275
871
|
return json(params);
|
|
276
872
|
});
|
|
@@ -347,6 +943,16 @@ server.registerTool("get_manifest_summary", {
|
|
|
347
943
|
description: "Get a high-level summary of PAM's capabilities: number of cells, effects, " +
|
|
348
944
|
"modulators, and parameter counts.",
|
|
349
945
|
inputSchema: {},
|
|
946
|
+
outputSchema: {
|
|
947
|
+
product: z.string().optional(),
|
|
948
|
+
company: z.string().optional(),
|
|
949
|
+
totalParameters: z.number().optional(),
|
|
950
|
+
cells: z.array(z.string()).optional(),
|
|
951
|
+
effects: z.array(z.string()).optional(),
|
|
952
|
+
globalParameters: z.number().optional(),
|
|
953
|
+
modulatableParameters: z.number().optional(),
|
|
954
|
+
pluginParameters: z.number().optional(),
|
|
955
|
+
},
|
|
350
956
|
annotations: { readOnlyHint: true },
|
|
351
957
|
}, async () => {
|
|
352
958
|
const manifest = await loadManifest();
|
|
@@ -355,7 +961,7 @@ server.registerTool("get_manifest_summary", {
|
|
|
355
961
|
const effects = new Set(params.filter((p) => p.effect).map((p) => p.effect));
|
|
356
962
|
const global = params.filter((p) => !p.cell);
|
|
357
963
|
const modulatable = params.filter((p) => p.isModulatable);
|
|
358
|
-
return
|
|
964
|
+
return structured({
|
|
359
965
|
product: manifest.name,
|
|
360
966
|
company: manifest.company,
|
|
361
967
|
totalParameters: params.length,
|
|
@@ -366,6 +972,98 @@ server.registerTool("get_manifest_summary", {
|
|
|
366
972
|
pluginParameters: params.filter((p) => p.isPluginParameter).length,
|
|
367
973
|
});
|
|
368
974
|
});
|
|
975
|
+
server.registerTool("design_context", {
|
|
976
|
+
title: "Design Context",
|
|
977
|
+
description: "Compact parameter-design cheat sheet for agents: macro targets, per-effect key params, musical ranges, " +
|
|
978
|
+
"unit notes, LFO/delay division hints, MIDI cell note map, and variation slot semantics.",
|
|
979
|
+
inputSchema: {
|
|
980
|
+
cellId: z
|
|
981
|
+
.number()
|
|
982
|
+
.int()
|
|
983
|
+
.min(1)
|
|
984
|
+
.max(8)
|
|
985
|
+
.default(1)
|
|
986
|
+
.describe("Cell number to use for per-cell example param IDs. Defaults to cell 1."),
|
|
987
|
+
},
|
|
988
|
+
annotations: { readOnlyHint: true },
|
|
989
|
+
}, async ({ cellId }) => {
|
|
990
|
+
const manifest = await loadManifest();
|
|
991
|
+
const params = manifest.parameters;
|
|
992
|
+
const byId = new Map(params.map((p) => [p.paramId, p]));
|
|
993
|
+
const example = (suffix) => byId.get(`cell${cellId}_${suffix}`)?.paramId ?? `cell${cellId}_${suffix}`;
|
|
994
|
+
const macroTargetsByEffect = {};
|
|
995
|
+
for (const p of params.filter((param) => param.paramId.startsWith("macro_") && param.isModulatable)) {
|
|
996
|
+
const key = p.effect ?? "global";
|
|
997
|
+
if (!macroTargetsByEffect[key])
|
|
998
|
+
macroTargetsByEffect[key] = [];
|
|
999
|
+
macroTargetsByEffect[key].push(p.paramId);
|
|
1000
|
+
}
|
|
1001
|
+
for (const key of Object.keys(macroTargetsByEffect)) {
|
|
1002
|
+
macroTargetsByEffect[key] = macroTargetsByEffect[key].sort();
|
|
1003
|
+
}
|
|
1004
|
+
const filterType = byId.get(example("filterType"));
|
|
1005
|
+
const lfoQuick = [
|
|
1006
|
+
["16 bar", 0, 0.000],
|
|
1007
|
+
["4 bar", 2, 0.091],
|
|
1008
|
+
["2 bar", 3, 0.136],
|
|
1009
|
+
["1 bar", 4, 0.182],
|
|
1010
|
+
["1/2", 6, 0.273],
|
|
1011
|
+
["1/4", 9, 0.409],
|
|
1012
|
+
["1/8", 12, 0.545],
|
|
1013
|
+
["1/16", 15, 0.682],
|
|
1014
|
+
["1/32", 18, 0.818],
|
|
1015
|
+
["1/64", 21, 0.955],
|
|
1016
|
+
].map(([division, index, value]) => ({ division, index, lfoRate: value }));
|
|
1017
|
+
return json({
|
|
1018
|
+
product: manifest.name,
|
|
1019
|
+
exampleCell: cellId,
|
|
1020
|
+
macroTargetsByEffect,
|
|
1021
|
+
keyPerCellParams: {
|
|
1022
|
+
mixer: [example("volume"), example("pan"), example("gainGain")],
|
|
1023
|
+
filter: [example("filterCutoff"), example("filterQ"), example("filterType")],
|
|
1024
|
+
delay: [example("delayDelayTime"), example("delayFeedback"), example("delayTilt"), example("delayMix")],
|
|
1025
|
+
crusher: [example("crusherBitReduction"), example("crusherRateReduction"), example("crusherDrive"), example("crusherMix")],
|
|
1026
|
+
fdn: [example("fdnSize"), example("fdnFeedback"), example("fdnModDepth"), example("fdnMix")],
|
|
1027
|
+
gate: [example("gateTime"), example("gateProgram"), example("gateMix")],
|
|
1028
|
+
compressor: [example("compressorThreshold"), example("compressorRatio"), example("compressorAttack"), example("compressorRelease")],
|
|
1029
|
+
drumSynth: [example("synthEngine"), example("synthVolume"), example("synth_p1"), example("synth_p2"), example("synth_p3")],
|
|
1030
|
+
},
|
|
1031
|
+
unitNotes: {
|
|
1032
|
+
pan: "cellN_pan is -1..1, so -0.15 is about 15% left; use cellN_pannerBalance for -100..100%.",
|
|
1033
|
+
percentages: "Most mix/feedback/depth controls are 0..100 percent.",
|
|
1034
|
+
filterCutoff: "Use real Hz values; move at least 1.5 octaves for obviously audible modulation.",
|
|
1035
|
+
delayDelayTime: "With delaySyncEnabled=1, normalized 0..1 maps to musical divisions; do not use cellN_delayTime.",
|
|
1036
|
+
lfoRate: "LFO IDs are 1-based: lfo1_... through lfo6_.... Sync ON uses index/22.",
|
|
1037
|
+
},
|
|
1038
|
+
musicalRanges: {
|
|
1039
|
+
filterCutoff: "dark 300-900 Hz, mid 900-3500 Hz, open 3500-12000 Hz",
|
|
1040
|
+
delayFeedback: "10-35% controlled, 35-60% dubby, >70% runaway risk",
|
|
1041
|
+
wetMix: "10-25% subtle, 25-45% audible, >60% wash/destroy",
|
|
1042
|
+
crusherBitReduction: "7-16 bits musical grit, 1-4 bits brutal",
|
|
1043
|
+
volumeDb: "Layer cells with -12..0 dB staging before master processing",
|
|
1044
|
+
},
|
|
1045
|
+
lfoSyncTable: {
|
|
1046
|
+
formula: "set lfoN_lfoSyncEnabled=1 and lfoN_lfoRate=index/22",
|
|
1047
|
+
quick: lfoQuick,
|
|
1048
|
+
fullDivisions: LFO_DIVISIONS,
|
|
1049
|
+
shapes: ["0=Sine", "1=Saw", "2=Tri", "3=Square", "4=Random", "5=Wander", "6=Custom"],
|
|
1050
|
+
},
|
|
1051
|
+
delaySyncDivisions: [
|
|
1052
|
+
"1/32T", "1/32", "1/32D", "1/16T", "1/16", "1/16D",
|
|
1053
|
+
"1/8T", "1/8", "1/8D", "1/4T", "1/4", "1/4D",
|
|
1054
|
+
"1/2T", "1/2", "1/2D", "1",
|
|
1055
|
+
],
|
|
1056
|
+
filterTypes: filterType?.strRepr?.map((label, index) => `${index}=${label}`) ?? [
|
|
1057
|
+
"0=Lowpass", "1=Highpass", "2=Bandpass", "3=Peak", "4=Notch",
|
|
1058
|
+
],
|
|
1059
|
+
midiNoteMap: Object.fromEntries(Array.from({ length: 8 }, (_, i) => {
|
|
1060
|
+
const id = i + 1;
|
|
1061
|
+
return [`cell${id}`, 47 + id];
|
|
1062
|
+
})),
|
|
1063
|
+
variationSlots: "API is 0-based (0..7); PAM UI labels slots 1..8. Save/load variation index 0 = UI slot 1.",
|
|
1064
|
+
modulationGuidance: "Primary movement should target per-cell params for distinct audible changes. Use macro_* for performance-level fan-out across cells.",
|
|
1065
|
+
});
|
|
1066
|
+
});
|
|
369
1067
|
// Standard full update — graph dispatch + KV sync + parameter updates.
|
|
370
1068
|
const KV_OPTS_STANDARD = {
|
|
371
1069
|
shouldDispatch: true,
|
|
@@ -394,6 +1092,20 @@ const KV_OPTS_PATTERN = {
|
|
|
394
1092
|
updateParameters: false,
|
|
395
1093
|
broadcastFollowers: false,
|
|
396
1094
|
};
|
|
1095
|
+
// Master-chain config — KV + config-resource sync, NO graph dispatch. Every
|
|
1096
|
+
// master key (comp incl. the full promoted Punch surface, tape, iron, clipper,
|
|
1097
|
+
// limiter, EQ/width) is live-polled from master_config by its native node, so
|
|
1098
|
+
// a rebuild here is pure dropout risk (the plan's no-rebuild contract).
|
|
1099
|
+
// Mirrors interop.ts updateMasterConfig's options.
|
|
1100
|
+
const KV_OPTS_MASTER_CONFIG = {
|
|
1101
|
+
shouldDispatch: false,
|
|
1102
|
+
syncKVResources: true,
|
|
1103
|
+
syncConfigResources: true,
|
|
1104
|
+
skipFileLoadingIfUnchanged: true,
|
|
1105
|
+
resyncKVAfterDispatch: false,
|
|
1106
|
+
updateParameters: true,
|
|
1107
|
+
broadcastFollowers: false,
|
|
1108
|
+
};
|
|
397
1109
|
// Effect config toggle — lighter KV path, no graph dispatch.
|
|
398
1110
|
const KV_OPTS_EFFECT_CONFIG = {
|
|
399
1111
|
shouldDispatch: false,
|
|
@@ -423,30 +1135,136 @@ async function applyKv(delta, options, directNodeOps) {
|
|
|
423
1135
|
}
|
|
424
1136
|
server.registerTool("get_state", {
|
|
425
1137
|
title: "Get Plugin State",
|
|
426
|
-
description: "Read the current
|
|
427
|
-
"
|
|
1138
|
+
description: "Read the current state of the running PAM plugin. Prefer section, keys, cellIds, or get_design_overview " +
|
|
1139
|
+
"over an unfiltered full-state read; large full-state payloads are capped to protect agent context.",
|
|
428
1140
|
inputSchema: {
|
|
429
1141
|
section: z
|
|
430
1142
|
.enum(["full", "cells", "lfos", "macros", "envelopes", "vary", "paramSeqs", "loopers", "songMode"])
|
|
431
1143
|
.optional()
|
|
432
1144
|
.describe("Return only a specific section of state. Defaults to 'full'. Use get_transport for transport data."),
|
|
1145
|
+
keys: z
|
|
1146
|
+
.array(z.string())
|
|
1147
|
+
.optional()
|
|
1148
|
+
.describe("Exact top-level state keys to return, e.g. ['cell1_filterCutoff','cell1_midiSequence']."),
|
|
1149
|
+
cellIds: z
|
|
1150
|
+
.array(z.number().int().min(1).max(8))
|
|
1151
|
+
.optional()
|
|
1152
|
+
.describe("Return only cell-scoped keys/config/modulation for these cells."),
|
|
1153
|
+
allowLarge: z
|
|
1154
|
+
.boolean()
|
|
1155
|
+
.optional()
|
|
1156
|
+
.describe("Set true to force an uncapped full-state response. Prefer filters unless you truly need everything."),
|
|
433
1157
|
},
|
|
434
1158
|
annotations: { readOnlyHint: true },
|
|
435
|
-
}, async ({ section }) => {
|
|
1159
|
+
}, async ({ section, keys, cellIds, allowLarge }) => {
|
|
436
1160
|
const check = await requireBridge();
|
|
437
1161
|
if (check !== true)
|
|
438
1162
|
return check;
|
|
1163
|
+
const hasFilters = (keys?.length ?? 0) > 0 || (cellIds?.length ?? 0) > 0;
|
|
439
1164
|
if (section && section !== "full") {
|
|
440
1165
|
const result = await bridge.send("getState", section);
|
|
441
|
-
|
|
1166
|
+
const filtered = filterSectionResult(section, result, { keys, cellIds });
|
|
1167
|
+
return jsonWithLimit(filtered ?? `Section '${section}' not found in state.`, "Use a narrower section, keys, or cellIds filter.");
|
|
442
1168
|
}
|
|
443
1169
|
const state = await bridge.send("getState");
|
|
444
|
-
|
|
1170
|
+
const filtered = filterState(state, { keys, cellIds });
|
|
1171
|
+
if (!hasFilters && !allowLarge) {
|
|
1172
|
+
return jsonWithLimit(filtered, "Use get_design_overview for preset work, or get_state with section, keys, or cellIds. Pass allowLarge:true only for offline debugging.");
|
|
1173
|
+
}
|
|
1174
|
+
return json(filtered);
|
|
1175
|
+
});
|
|
1176
|
+
server.registerTool("get_design_overview", {
|
|
1177
|
+
title: "Get Design Overview",
|
|
1178
|
+
description: "Return a compact preset-design snapshot: cells, loaded samples/synth engines, enabled effects, pattern counts, " +
|
|
1179
|
+
"modulation link counts, mixer values, BPM, and master FX. Use this instead of full get_state during preset design.",
|
|
1180
|
+
inputSchema: {
|
|
1181
|
+
cellIds: z
|
|
1182
|
+
.array(z.number().int().min(1).max(8))
|
|
1183
|
+
.optional()
|
|
1184
|
+
.describe("Optional subset of cells to include. Defaults to all cells present in the selected manifest."),
|
|
1185
|
+
},
|
|
1186
|
+
annotations: { readOnlyHint: true },
|
|
1187
|
+
}, async ({ cellIds }) => {
|
|
1188
|
+
const check = await requireBridge();
|
|
1189
|
+
if (check !== true)
|
|
1190
|
+
return check;
|
|
1191
|
+
const [stateRaw, transportRaw, manifest] = await Promise.all([
|
|
1192
|
+
bridge.send("getState"),
|
|
1193
|
+
bridge.send("getTransport").catch(() => undefined),
|
|
1194
|
+
loadManifest(),
|
|
1195
|
+
]);
|
|
1196
|
+
const state = isRecord(stateRaw) ? stateRaw : {};
|
|
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;
|
|
1201
|
+
const manifestCellIds = [...new Set(manifest.parameters
|
|
1202
|
+
.map((p) => p.cell)
|
|
1203
|
+
.filter((cell) => typeof cell === "string" && /^cell\d+$/.test(cell))
|
|
1204
|
+
.map((cell) => Number(cell.replace("cell", ""))))].sort((a, b) => a - b);
|
|
1205
|
+
const ids = cellIds?.length ? cellIds : manifestCellIds;
|
|
1206
|
+
const cellsObj = isRecord(state.cells) ? state.cells : {};
|
|
1207
|
+
const modArrays = {
|
|
1208
|
+
lfo: Array.isArray(state.lfos) ? state.lfos : [],
|
|
1209
|
+
envelope: Array.isArray(state.envelopes) ? state.envelopes : [],
|
|
1210
|
+
vary: Array.isArray(state.vary) ? state.vary : [],
|
|
1211
|
+
paramSeq: Array.isArray(state.paramSeqs) ? state.paramSeqs : [],
|
|
1212
|
+
};
|
|
1213
|
+
const cells = ids.map((id) => {
|
|
1214
|
+
const prefix = `cell${id}_`;
|
|
1215
|
+
const rawCellState = cellsObj[String(id)];
|
|
1216
|
+
const cellState = isRecord(rawCellState) ? rawCellState : {};
|
|
1217
|
+
const samplePath = cellState.filePath ??
|
|
1218
|
+
cellState.samplePath ??
|
|
1219
|
+
state[`${prefix}filePath`] ??
|
|
1220
|
+
state[`${prefix}samplePath`];
|
|
1221
|
+
const modulation = Object.fromEntries(Object.entries(modArrays).map(([kind, arr]) => [
|
|
1222
|
+
kind,
|
|
1223
|
+
arr.filter((entry) => paramTargetsCell(entry, new Set([prefix]))).length,
|
|
1224
|
+
]));
|
|
1225
|
+
return {
|
|
1226
|
+
cellId: id,
|
|
1227
|
+
sample: {
|
|
1228
|
+
name: stemName(samplePath),
|
|
1229
|
+
path: typeof samplePath === "string" ? samplePath : undefined,
|
|
1230
|
+
},
|
|
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),
|
|
1234
|
+
patternEvents: Object.fromEntries(Object.entries(PATTERN_LANES).map(([lane, suffix]) => [
|
|
1235
|
+
lane,
|
|
1236
|
+
countEvents(state[`${prefix}${suffix}`]),
|
|
1237
|
+
])),
|
|
1238
|
+
modulation,
|
|
1239
|
+
mixer: {
|
|
1240
|
+
volume: state[`${prefix}volume`],
|
|
1241
|
+
pan: state[`${prefix}pan`],
|
|
1242
|
+
gain: state[`${prefix}gainGain`],
|
|
1243
|
+
},
|
|
1244
|
+
};
|
|
1245
|
+
});
|
|
1246
|
+
return json({
|
|
1247
|
+
product: manifest.name,
|
|
1248
|
+
bpm: state.bpm ?? transport.bpm,
|
|
1249
|
+
playing: transport.playing,
|
|
1250
|
+
masterFx: {
|
|
1251
|
+
compressorEnabled: state.masterCompressorEnabled,
|
|
1252
|
+
limiterEnabled: state.masterLimiterEnabled,
|
|
1253
|
+
tapeEnabled: state.masterTapeEnabled,
|
|
1254
|
+
},
|
|
1255
|
+
totals: {
|
|
1256
|
+
cellsWithSamples: cells.filter((c) => c.sample.path).length,
|
|
1257
|
+
activePatternEvents: cells.reduce((sum, c) => sum + Object.values(c.patternEvents).reduce((a, b) => a + b, 0), 0),
|
|
1258
|
+
modulationLinks: cells.reduce((sum, c) => sum + Object.values(c.modulation).reduce((a, b) => a + b, 0), 0),
|
|
1259
|
+
},
|
|
1260
|
+
cells,
|
|
1261
|
+
});
|
|
445
1262
|
});
|
|
446
1263
|
server.registerTool("set_parameters", {
|
|
447
1264
|
title: "Set Parameters",
|
|
448
1265
|
description: "Set one or more PAM parameters. Values are validated against the manifest. " +
|
|
449
|
-
"
|
|
1266
|
+
"Effect enable flags such as cell1_filterEnabled are settable here too, so params + enables can land in one call " +
|
|
1267
|
+
"when a graph dispatch is acceptable. Use get_parameter_info or design_context to discover valid IDs and ranges.",
|
|
450
1268
|
inputSchema: {
|
|
451
1269
|
parameters: z
|
|
452
1270
|
.record(z.string(), z.number())
|
|
@@ -460,11 +1278,11 @@ server.registerTool("set_parameters", {
|
|
|
460
1278
|
for (const [paramId, value] of Object.entries(parameters)) {
|
|
461
1279
|
const p = index.get(paramId);
|
|
462
1280
|
if (!p) {
|
|
463
|
-
errors.push(
|
|
1281
|
+
errors.push(formatUnknownParamError(paramId, index));
|
|
464
1282
|
continue;
|
|
465
1283
|
}
|
|
466
1284
|
if (value < p.min || value > p.max) {
|
|
467
|
-
errors.push(
|
|
1285
|
+
errors.push(formatRangeError(p, value));
|
|
468
1286
|
}
|
|
469
1287
|
}
|
|
470
1288
|
if (errors.length > 0)
|
|
@@ -542,11 +1360,21 @@ server.registerTool("get_model_status", {
|
|
|
542
1360
|
title: "Get AI Model Status",
|
|
543
1361
|
description: "Report downloaded / loaded / loading / download-in-flight + version + disk size + live download progress for an AI engine.",
|
|
544
1362
|
inputSchema: { engine: MODEL_ENGINE },
|
|
1363
|
+
outputSchema: {
|
|
1364
|
+
engine: z.string().optional(),
|
|
1365
|
+
enabled: z.boolean().optional(),
|
|
1366
|
+
downloaded: z.boolean().optional(),
|
|
1367
|
+
loaded: z.boolean().optional(),
|
|
1368
|
+
inFlight: z.boolean().optional(),
|
|
1369
|
+
version: z.string().optional(),
|
|
1370
|
+
state: z.string().optional(),
|
|
1371
|
+
progress: z.number().optional(),
|
|
1372
|
+
},
|
|
545
1373
|
}, async ({ engine }) => {
|
|
546
1374
|
const check = await requireBridge();
|
|
547
1375
|
if (check !== true)
|
|
548
1376
|
return check;
|
|
549
|
-
return
|
|
1377
|
+
return structuredFromBridge(await bridge.send("modelStatus", { engine }));
|
|
550
1378
|
});
|
|
551
1379
|
server.registerTool("load_model", {
|
|
552
1380
|
title: "Load AI Model",
|
|
@@ -798,11 +1626,15 @@ server.registerTool("workbench_sidecar_status", {
|
|
|
798
1626
|
title: "Workbench: Sidecar Status",
|
|
799
1627
|
description: "Ping the SA3 render sidecar (127.0.0.1:8093). Returns { connected, loaded } — loaded means a pipeline is resident (first render after a cold start also pays the model load).",
|
|
800
1628
|
inputSchema: {},
|
|
1629
|
+
outputSchema: {
|
|
1630
|
+
connected: z.boolean().optional(),
|
|
1631
|
+
loaded: z.boolean().optional(),
|
|
1632
|
+
},
|
|
801
1633
|
}, async () => {
|
|
802
1634
|
const check = await requireBridge();
|
|
803
1635
|
if (check !== true)
|
|
804
1636
|
return check;
|
|
805
|
-
return
|
|
1637
|
+
return structuredFromBridge(await bridge.send("workbenchSidecarStatus"));
|
|
806
1638
|
});
|
|
807
1639
|
server.registerTool("trigger_midi", {
|
|
808
1640
|
title: "Trigger MIDI",
|
|
@@ -867,13 +1699,13 @@ server.registerTool("add_modulation", {
|
|
|
867
1699
|
.min(1)
|
|
868
1700
|
.max(8)
|
|
869
1701
|
.optional()
|
|
870
|
-
.describe("
|
|
1702
|
+
.describe("Required for envelope/vary: source cell driving the follower (1-8). This is not the target cell; target is inferred from paramId."),
|
|
871
1703
|
lfoIndex: z
|
|
872
1704
|
.number()
|
|
873
1705
|
.min(0)
|
|
874
1706
|
.max(5)
|
|
875
1707
|
.optional()
|
|
876
|
-
.describe("LFO generator index
|
|
1708
|
+
.describe("Required for LFO: generator index 0-5. Parameter IDs remain 1-based (lfo1_... through lfo6_...)."),
|
|
877
1709
|
strength: z
|
|
878
1710
|
.number()
|
|
879
1711
|
.min(0)
|
|
@@ -888,19 +1720,19 @@ server.registerTool("add_modulation", {
|
|
|
888
1720
|
.boolean()
|
|
889
1721
|
.optional()
|
|
890
1722
|
.describe("Envelope only: route from the cell's pitch-tracked source instead of amp. " +
|
|
891
|
-
"false (default) = AMP; true & pitchDuck=false =
|
|
1723
|
+
"false (default) = AMP; true & pitchDuck=false = PITCH (comb); true & pitchDuck=true = DUCK."),
|
|
892
1724
|
pitchDuck: z
|
|
893
1725
|
.boolean()
|
|
894
1726
|
.optional()
|
|
895
|
-
.describe("Envelope only: when pitch=true, switch from
|
|
1727
|
+
.describe("Envelope only: when pitch=true, switch from PITCH (comb) to DUCK semantics (notch). Ignored if pitch=false."),
|
|
896
1728
|
},
|
|
897
1729
|
}, async ({ type, paramId, cellId, lfoIndex, strength, bipolar, pitch, pitchDuck }) => {
|
|
898
1730
|
const index = await getParameterIndex();
|
|
899
1731
|
const param = index.get(paramId);
|
|
900
1732
|
if (!param)
|
|
901
|
-
return error(
|
|
1733
|
+
return error(formatUnknownParamError(paramId, index, { modulatableOnly: true }));
|
|
902
1734
|
if (!param.isModulatable)
|
|
903
|
-
return error(
|
|
1735
|
+
return error(formatNotModulatableError(paramId, index));
|
|
904
1736
|
const check = await requireBridge();
|
|
905
1737
|
if (check !== true)
|
|
906
1738
|
return check;
|
|
@@ -916,12 +1748,12 @@ server.registerTool("add_modulation", {
|
|
|
916
1748
|
}
|
|
917
1749
|
else if (type === "envelope") {
|
|
918
1750
|
if (!cellId)
|
|
919
|
-
return error("cellId is required for envelope modulation.");
|
|
1751
|
+
return error("cellId is required for envelope modulation. It is the source cell driving the follower; the target cell is inferred from paramId.");
|
|
920
1752
|
await bridge.send("addEnvelope", paramId, cellId, strength, bipolar, effectEnableParams, pitch ?? false, pitchDuck ?? false);
|
|
921
1753
|
}
|
|
922
1754
|
else if (type === "vary") {
|
|
923
1755
|
if (!cellId)
|
|
924
|
-
return error("cellId is required for vary modulation.");
|
|
1756
|
+
return error("cellId is required for vary modulation. It is the source cell driving the follower; the target cell is inferred from paramId.");
|
|
925
1757
|
await bridge.send("addVary", paramId, cellId, strength, bipolar, effectEnableParams);
|
|
926
1758
|
}
|
|
927
1759
|
return text(`Added ${type} modulation to ${paramId} (strength: ${strength}%).`);
|
|
@@ -994,12 +1826,17 @@ server.registerTool("update_pattern", {
|
|
|
994
1826
|
});
|
|
995
1827
|
server.registerTool("randomize", {
|
|
996
1828
|
title: "Randomize",
|
|
997
|
-
description: "Trigger PAM's randomization engine to generate random sounds and patterns."
|
|
1829
|
+
description: "Trigger PAM's randomization engine to generate random sounds and patterns. " +
|
|
1830
|
+
"target:'synth' runs the musical, engine-aware drum-synth randomizer (picks an engine + " +
|
|
1831
|
+
"shapes its params per cell) — it is routed through the WebView, so it requires PAM's window " +
|
|
1832
|
+
"to be OPEN (samples/sequences/full run natively and do not). target:'synth' needs PAM ≥ 1.13.0.",
|
|
998
1833
|
inputSchema: {
|
|
1834
|
+
// 'lfos'/'mods' added for the assistant surface (1.15.0) — WebView-routed
|
|
1835
|
+
// like 'synth'. The describe() text is pinned (exported toolpack bytes).
|
|
999
1836
|
target: z
|
|
1000
|
-
.enum(["full", "samples", "sequences"])
|
|
1837
|
+
.enum(["full", "samples", "sequences", "synth", "lfos", "mods"])
|
|
1001
1838
|
.default("full")
|
|
1002
|
-
.describe("What to randomize."),
|
|
1839
|
+
.describe("What to randomize. 'synth' = the built-in drum-synth machines (PAM window must be open)."),
|
|
1003
1840
|
cellIds: z
|
|
1004
1841
|
.array(z.number().min(1).max(8))
|
|
1005
1842
|
.optional()
|
|
@@ -1025,6 +1862,21 @@ server.registerTool("randomize", {
|
|
|
1025
1862
|
const check = await requireBridge();
|
|
1026
1863
|
if (check !== true)
|
|
1027
1864
|
return check;
|
|
1865
|
+
if (target === "synth") {
|
|
1866
|
+
// Routed to the canonical WebView randomizer (single source of truth) — needs the
|
|
1867
|
+
// window open. Empty array → all non-excluded cells.
|
|
1868
|
+
await bridge.send("randomizeDrumSynth", cellIds ?? []);
|
|
1869
|
+
return text(`Randomized drum synths${cellIds ? ` for cells ${cellIds.join(", ")}` : " (all cells)"}. ` +
|
|
1870
|
+
`(Drum-synth randomize runs in the WebView — if nothing changed, ensure PAM's window is open.)`);
|
|
1871
|
+
}
|
|
1872
|
+
if (target === "lfos" || target === "mods") {
|
|
1873
|
+
// The data-driven modulation randomizer lives in WebView JS
|
|
1874
|
+
// (interop.ts randomizeLfos/randomizeEnvelopes/randomizeVary) — routed
|
|
1875
|
+
// like 'synth'. Needs PAM ≥ 1.15.0 and the window open.
|
|
1876
|
+
await bridge.send("randomizeModulations", target, mutationAmount ?? -1);
|
|
1877
|
+
return text(`Randomized ${target === "lfos" ? "LFO modulation links" : "all modulation (LFOs, envelopes, vary)"}. ` +
|
|
1878
|
+
`(Runs in the WebView — if nothing changed, ensure PAM's window is open.)`);
|
|
1879
|
+
}
|
|
1028
1880
|
await bridge.send("randomize", target, cellIds ?? [], genreHint ?? "", bpmHint ?? -1, mutationAmount ?? -1);
|
|
1029
1881
|
return text(`Randomized ${target}${cellIds ? ` for cells ${cellIds.join(", ")}` : ""}.`);
|
|
1030
1882
|
});
|
|
@@ -1039,7 +1891,7 @@ server.registerTool("randomize_samples", {
|
|
|
1039
1891
|
genreHint: z
|
|
1040
1892
|
.string()
|
|
1041
1893
|
.optional()
|
|
1042
|
-
.describe("Genre hint to guide sample selection. Supported:
|
|
1894
|
+
.describe("Genre hint to guide sample selection. Supported: boom_bap (aliases hip-hop, lofi), trap, drill, house, techno, dnb (aliases jungle, breakbeat, breaks). Other values fall back to neutral role-based selection; omit to auto-detect from the library."),
|
|
1043
1895
|
bpmHint: z
|
|
1044
1896
|
.number()
|
|
1045
1897
|
.min(20)
|
|
@@ -1127,13 +1979,148 @@ server.registerTool("get_transport", {
|
|
|
1127
1979
|
title: "Get Transport",
|
|
1128
1980
|
description: "Get current transport state (BPM, playing, position).",
|
|
1129
1981
|
inputSchema: {},
|
|
1982
|
+
outputSchema: {
|
|
1983
|
+
bpm: z.number().optional(),
|
|
1984
|
+
isPlaying: z.boolean().optional(),
|
|
1985
|
+
ppqPosition: z.number().optional(),
|
|
1986
|
+
},
|
|
1130
1987
|
annotations: { readOnlyHint: true },
|
|
1131
1988
|
}, async () => {
|
|
1132
1989
|
const check = await requireBridge();
|
|
1133
1990
|
if (check !== true)
|
|
1134
1991
|
return check;
|
|
1135
1992
|
const transport = await bridge.send("getTransport");
|
|
1136
|
-
return
|
|
1993
|
+
return structuredFromBridge(transport);
|
|
1994
|
+
});
|
|
1995
|
+
server.registerTool("get_master_analysis", {
|
|
1996
|
+
title: "Get Master Analysis",
|
|
1997
|
+
description: "Read the master-bus loudness measurement (BS.1770-5 / EBU R128): momentary/short-term/integrated " +
|
|
1998
|
+
"LUFS (m/s/i), rolling 30 s ungated loudness (roll), loudness range (lra, stable after 60 s — " +
|
|
1999
|
+
"check lraStable), true peak dBTP (tp; tp3s = 3 s window), sample peak (sp), PSR/PLR crest " +
|
|
2000
|
+
"metrics, stereo correlation (corr; corrLow = <120 Hz band), elapsed ms since reset, and ageMs " +
|
|
2001
|
+
"(snapshot staleness). Absent field = no reading yet. Works headless (window can be closed); " +
|
|
2002
|
+
"audio must be flowing — start the transport or play something first. Guard rails for " +
|
|
2003
|
+
"mastering moves: keep tp <= -1 dBTP and PSR >= 8 in the loudest section.",
|
|
2004
|
+
inputSchema: {},
|
|
2005
|
+
annotations: { readOnlyHint: true },
|
|
2006
|
+
}, async () => {
|
|
2007
|
+
const check = await requireBridge();
|
|
2008
|
+
if (check !== true)
|
|
2009
|
+
return check;
|
|
2010
|
+
const analysis = await bridge.send("getMasterAnalysis");
|
|
2011
|
+
return json(analysis);
|
|
2012
|
+
});
|
|
2013
|
+
server.registerTool("get_mix_analysis", {
|
|
2014
|
+
title: "Get Per-Cell Mix Analysis",
|
|
2015
|
+
description: "Per-cell stem measurements from a faster-than-realtime offline bounce: LUFS (i/s/roll), LRA, " +
|
|
2016
|
+
"true/sample peak (tp/sp), PSR/PLR crest, stereo correlation, onset density, and a 25-band Bark " +
|
|
2017
|
+
"spectrum per cell, plus the full-mix master block. Pass refresh:true to run a new analysis " +
|
|
2018
|
+
"(renders each sounding cell soloed from bar 0 — takes a few seconds; audio does NOT need to be " +
|
|
2019
|
+
"playing). Cached result otherwise. Requires PAM ≥ 1.17.0. PAM only.",
|
|
2020
|
+
inputSchema: {
|
|
2021
|
+
refresh: z.boolean().optional().describe("Run a fresh analysis instead of returning the cached one."),
|
|
2022
|
+
durationSeconds: z.number().min(5).max(30).optional().describe("Analysis render length (default 8 bars at the current BPM, clamped 5-30 s)."),
|
|
2023
|
+
cellIds: z.array(z.number().int().min(1).max(8)).optional().describe("Cells to analyze (default: every cell with a sample or active drum synth)."),
|
|
2024
|
+
},
|
|
2025
|
+
}, async ({ refresh, durationSeconds, cellIds }) => {
|
|
2026
|
+
const check = await requireBridge();
|
|
2027
|
+
if (check !== true)
|
|
2028
|
+
return check;
|
|
2029
|
+
if (refresh) {
|
|
2030
|
+
const started = (await bridge.send("analyzeMix", { durationSeconds, cellIds }));
|
|
2031
|
+
if (started.status !== "started")
|
|
2032
|
+
return error("Mix analysis is busy — an offline render already owns the engine. Retry when it finishes.");
|
|
2033
|
+
const t0 = Date.now();
|
|
2034
|
+
while (Date.now() - t0 < 120_000) {
|
|
2035
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
2036
|
+
const status = (await bridge.send("getMixAnalysis"));
|
|
2037
|
+
if (status.status !== "running")
|
|
2038
|
+
return json(status);
|
|
2039
|
+
}
|
|
2040
|
+
return error("Mix analysis timed out after 120 s.");
|
|
2041
|
+
}
|
|
2042
|
+
const result = await bridge.send("getMixAnalysis");
|
|
2043
|
+
return json(result);
|
|
2044
|
+
});
|
|
2045
|
+
// Epoch of the last OFFLINE analysis suggest_mix applied moves from — the
|
|
2046
|
+
// stacking guard's memory (live payloads refresh their epoch, so they never
|
|
2047
|
+
// stay consumed). Session-scoped by design: a server restart just re-allows a
|
|
2048
|
+
// suggest, which the refresh-first workflow tolerates.
|
|
2049
|
+
let lastAppliedMixEpoch = -1;
|
|
2050
|
+
server.registerTool("suggest_mix", {
|
|
2051
|
+
title: "Suggest Mix Balance Moves",
|
|
2052
|
+
description: "Runs the Mix Assist rule engine over the latest per-cell analysis (get_mix_analysis): " +
|
|
2053
|
+
"partial-loudness fader balance, pan spread, bounded masking EQ cuts, and optional compressor " +
|
|
2054
|
+
"settings. Returns absolute moves with per-move rationale and guard notes; apply:true lands them " +
|
|
2055
|
+
"as one state update. Run get_mix_analysis with refresh:true first if the mix changed. " +
|
|
2056
|
+
"Requires PAM ≥ 1.17.0. PAM only.",
|
|
2057
|
+
inputSchema: {
|
|
2058
|
+
scopes: z
|
|
2059
|
+
.array(z.enum(["balance", "pan", "eq", "dynamics"]))
|
|
2060
|
+
.optional()
|
|
2061
|
+
.describe("Stages to run (default balance+pan+eq; dynamics is opt-in)."),
|
|
2062
|
+
apply: z.boolean().optional().describe("Apply the suggested moves to the running plugin."),
|
|
2063
|
+
},
|
|
2064
|
+
}, async ({ scopes, apply }) => {
|
|
2065
|
+
const check = await requireBridge();
|
|
2066
|
+
if (check !== true)
|
|
2067
|
+
return check;
|
|
2068
|
+
const r = (await bridge.send("getMixAnalysis"));
|
|
2069
|
+
if (!r?.available)
|
|
2070
|
+
return error("No mix analysis available — run get_mix_analysis with refresh:true first.");
|
|
2071
|
+
const analysis = parseMixAnalysisPayload(r.analysis);
|
|
2072
|
+
if (!analysis)
|
|
2073
|
+
return error("Cached mix analysis is malformed — re-run get_mix_analysis with refresh:true.");
|
|
2074
|
+
// Stacking guard (mirrors the panel's consumed-epoch gate): the engine's
|
|
2075
|
+
// deltas come from the analysis but land on the LIVE params, so suggesting
|
|
2076
|
+
// again from an analysis that was already applied doubles the correction.
|
|
2077
|
+
if (!analysis.live && analysis.epoch === lastAppliedMixEpoch)
|
|
2078
|
+
return error("This analysis was already applied — re-run get_mix_analysis with refresh:true to measure the new balance first.");
|
|
2079
|
+
const state = (await bridge.send("getState"));
|
|
2080
|
+
// Guard-rail parity with the in-app panel (context.ts contract): macros
|
|
2081
|
+
// count as modulation, and cells whose source was removed after the
|
|
2082
|
+
// analysis must not receive moves.
|
|
2083
|
+
const mods = {
|
|
2084
|
+
lfos: state.lfos,
|
|
2085
|
+
envelopes: state.envelopes,
|
|
2086
|
+
vary: state.vary,
|
|
2087
|
+
paramSeqs: state.paramSeqs,
|
|
2088
|
+
macros: state.macros,
|
|
2089
|
+
};
|
|
2090
|
+
const hasSource = (id) => {
|
|
2091
|
+
const cells = state.cells;
|
|
2092
|
+
if (cells?.[String(id)]?.filePath)
|
|
2093
|
+
return true;
|
|
2094
|
+
if (Number(state[`cell${id}_synthEngine`] ?? 0) > 0.5)
|
|
2095
|
+
return true;
|
|
2096
|
+
if (Number(state[`cell${id}_magentaEngine`] ?? 0) > 0.5)
|
|
2097
|
+
return true;
|
|
2098
|
+
const pluginPath = state[`cell${id}_pluginPath`];
|
|
2099
|
+
return typeof pluginPath === "string" && pluginPath.length > 0;
|
|
2100
|
+
};
|
|
2101
|
+
const measurement = buildMixMeasurementFromState(analysis, state, mods, { hasSource });
|
|
2102
|
+
const current = currentFlatFromState(analysis, state);
|
|
2103
|
+
// Rider ownership (the panel's parked-bal rule): while Auto Mix AUTO runs,
|
|
2104
|
+
// the cMixRide rider owns the balance — fader suggestions would
|
|
2105
|
+
// double-drive it on top of the signed trims.
|
|
2106
|
+
const riderOwnsBalance = Number(state.masterMixRideEnabled ?? 0) === 1 && Number(state.masterMixRideAuto ?? 0) === 1;
|
|
2107
|
+
let resolvedScopes = scopes ?? ["balance", "pan", "eq"];
|
|
2108
|
+
const balanceParked = riderOwnsBalance && resolvedScopes.includes("balance");
|
|
2109
|
+
if (balanceParked)
|
|
2110
|
+
resolvedScopes = resolvedScopes.filter((s) => s !== "balance");
|
|
2111
|
+
if (resolvedScopes.length === 0)
|
|
2112
|
+
return error("Auto Mix is running and owns the balance — request pan, eq, or dynamics scopes while it is on.");
|
|
2113
|
+
const suggestion = suggestMix(measurement, { scopes: resolvedScopes }, current);
|
|
2114
|
+
const parkedNote = balanceParked
|
|
2115
|
+
? { balanceParked: true, note: "Auto Mix AUTO owns the balance — balance scope was skipped." }
|
|
2116
|
+
: {};
|
|
2117
|
+
if (apply && suggestion.moves.length > 0) {
|
|
2118
|
+
await applyKv(suggestion.flat, KV_OPTS_STANDARD);
|
|
2119
|
+
if (!analysis.live)
|
|
2120
|
+
lastAppliedMixEpoch = analysis.epoch;
|
|
2121
|
+
return json({ ...suggestion, ...parkedNote, applied: true });
|
|
2122
|
+
}
|
|
2123
|
+
return json({ ...suggestion, ...parkedNote });
|
|
1137
2124
|
});
|
|
1138
2125
|
server.registerTool("new_preset", {
|
|
1139
2126
|
title: "New Preset",
|
|
@@ -1154,7 +2141,11 @@ server.registerTool("save_preset", {
|
|
|
1154
2141
|
"Writes to the user preset folder by default; pass `path` for a specific location. " +
|
|
1155
2142
|
"Use `update_preset_metadata` to edit an existing preset without re-saving plugin state.",
|
|
1156
2143
|
inputSchema: {
|
|
1157
|
-
|
|
2144
|
+
// Optional since 1.15.0: omitted name = re-save the ACTIVE user preset in
|
|
2145
|
+
// place (the UI Save button's semantics; the assistant's bare "save").
|
|
2146
|
+
// The describe() text is pinned — the exported assistant toolpack keeps
|
|
2147
|
+
// property descriptions, and the model is trained against those bytes.
|
|
2148
|
+
name: z.string().optional().describe("Preset name (used for filename if `path` omitted)."),
|
|
1158
2149
|
author: z.string().optional().describe("Author name."),
|
|
1159
2150
|
category: z
|
|
1160
2151
|
.string()
|
|
@@ -1179,26 +2170,56 @@ server.registerTool("save_preset", {
|
|
|
1179
2170
|
const check = await requireBridge();
|
|
1180
2171
|
if (check !== true)
|
|
1181
2172
|
return check;
|
|
2173
|
+
const { state, canonical } = await fetchSaveSnapshot();
|
|
2174
|
+
// Bare save (no name): the UI Save button's semantics — overwrite the
|
|
2175
|
+
// ACTIVE user preset in place, preserving its on-disk metadata. Mirrors
|
|
2176
|
+
// the in-plugin assistant executor (error strings included — the model's
|
|
2177
|
+
// repair turns are trained on those shapes). Factory/Init need a name.
|
|
2178
|
+
const extras = rest;
|
|
2179
|
+
if (!name?.trim()) {
|
|
2180
|
+
const activePath = typeof state.activePresetPath === "string" ? state.activePresetPath : "";
|
|
2181
|
+
if (!activePath || activePath === "Init")
|
|
2182
|
+
return error('No preset is active — give this one a name, e.g. save_preset({ name: "Night Drive" }).');
|
|
2183
|
+
const readPath = normalizePresetLookupPath(activePath);
|
|
2184
|
+
if (readPath.startsWith("Factory/"))
|
|
2185
|
+
return error("The current preset is a factory patch — save a copy under a new name instead.");
|
|
2186
|
+
name = (activePath.split("/").pop() || "").replace(/\.preset$/, "");
|
|
2187
|
+
if (!name)
|
|
2188
|
+
return error(`Could not derive a name from the active preset path (${activePath}) — pass name explicitly.`);
|
|
2189
|
+
if (path === undefined)
|
|
2190
|
+
path = activePath;
|
|
2191
|
+
try {
|
|
2192
|
+
const parsed = parsePresetFile(await readFile(await resolvePresetPath(readPath), "utf-8"));
|
|
2193
|
+
for (const [k, v] of Object.entries(parsed.metadata)) {
|
|
2194
|
+
// Structural keys are rebuilt below; everything else (author/tags/
|
|
2195
|
+
// category/…) carries over unless the caller overrode it.
|
|
2196
|
+
if (["name", "factory", "path", "variations", "currentVariation"].includes(k))
|
|
2197
|
+
continue;
|
|
2198
|
+
if (extras[k] === undefined)
|
|
2199
|
+
extras[k] = v;
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
catch {
|
|
2203
|
+
// unreadable/new — save fresh with whatever the caller passed
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
1182
2206
|
const metadata = { name, factory: false };
|
|
1183
2207
|
if (path !== undefined)
|
|
1184
2208
|
metadata.path = path;
|
|
1185
|
-
for (const [k, v] of Object.entries(
|
|
2209
|
+
for (const [k, v] of Object.entries(extras)) {
|
|
1186
2210
|
if (v !== undefined)
|
|
1187
2211
|
metadata[k] = v;
|
|
1188
2212
|
}
|
|
1189
2213
|
// Match UI behavior: auto-save current state to variation slot 0 if no variations exist,
|
|
1190
2214
|
// otherwise update the current active variation slot.
|
|
1191
|
-
const state = (await bridge.send("getState"));
|
|
1192
2215
|
const variationString = buildVariationString(state);
|
|
1193
2216
|
const presetRelPath = path || `User/${name}.preset`;
|
|
1194
|
-
|
|
1195
|
-
//
|
|
1196
|
-
//
|
|
1197
|
-
//
|
|
1198
|
-
const
|
|
1199
|
-
|
|
1200
|
-
: presetRelPath;
|
|
1201
|
-
const existingVariations = await readExistingVariations(readPath);
|
|
2217
|
+
const readPath = normalizePresetLookupPath(presetRelPath);
|
|
2218
|
+
// Save As must copy the active preset's complete variation bank. Reading
|
|
2219
|
+
// the new destination starts from eight empty slots because that file does
|
|
2220
|
+
// not exist yet, which used to collapse every named save to one slot.
|
|
2221
|
+
const variationSourcePath = selectSaveVariationSourcePath(state.activePresetPath, presetRelPath);
|
|
2222
|
+
const existingVariations = await readExistingVariations(variationSourcePath);
|
|
1202
2223
|
const hasVariations = existingVariations.some((v) => v !== null);
|
|
1203
2224
|
if (!hasVariations) {
|
|
1204
2225
|
existingVariations[0] = variationString;
|
|
@@ -1212,7 +2233,10 @@ server.registerTool("save_preset", {
|
|
|
1212
2233
|
}
|
|
1213
2234
|
metadata.variations = existingVariations;
|
|
1214
2235
|
const result = (await bridge.send("savePreset", metadata));
|
|
1215
|
-
|
|
2236
|
+
lastSavedPresetName = name;
|
|
2237
|
+
lastSavedPresetPath = result?.path || readPath;
|
|
2238
|
+
return text(`Saved preset: ${name}\nPath: ${result?.path || "(unknown path)"}` +
|
|
2239
|
+
hostedPluginSaveWarning(state, canonical));
|
|
1216
2240
|
});
|
|
1217
2241
|
server.registerTool("save_variation", {
|
|
1218
2242
|
title: "Save Variation",
|
|
@@ -1245,8 +2269,8 @@ server.registerTool("save_variation", {
|
|
|
1245
2269
|
const check = await requireBridge();
|
|
1246
2270
|
if (check !== true)
|
|
1247
2271
|
return check;
|
|
1248
|
-
// 1.
|
|
1249
|
-
const state =
|
|
2272
|
+
// 1. Canonical save snapshot (engine + hosted-plugin state), then shape it
|
|
2273
|
+
const { state, canonical } = await fetchSaveSnapshot();
|
|
1250
2274
|
const variationString = buildVariationString(state);
|
|
1251
2275
|
// 2. Determine preset name and disk path — needed to read existing variations and metadata
|
|
1252
2276
|
let presetName = name;
|
|
@@ -1259,11 +2283,15 @@ server.registerTool("save_variation", {
|
|
|
1259
2283
|
// Strip "Presets/" prefix if present — resolvePresetPath already prepends
|
|
1260
2284
|
// the presets base directory, so "Presets/User/X" would double up to
|
|
1261
2285
|
// ".../Presets/Presets/User/X" and silently fail to read back variations.
|
|
1262
|
-
diskPath =
|
|
2286
|
+
diskPath = normalizePresetLookupPath(activePath);
|
|
2287
|
+
}
|
|
2288
|
+
if (!presetName && lastSavedPresetName) {
|
|
2289
|
+
presetName = lastSavedPresetName;
|
|
2290
|
+
diskPath = lastSavedPresetPath;
|
|
1263
2291
|
}
|
|
1264
2292
|
if (!presetName) {
|
|
1265
|
-
return
|
|
1266
|
-
"
|
|
2293
|
+
return error("No preset name provided and no active preset loaded.\n" +
|
|
2294
|
+
"Next call: save_preset({ name: 'Your Preset Name', ...metadata }) first, or call save_variation with name: 'Your Preset Name'.");
|
|
1267
2295
|
}
|
|
1268
2296
|
if (!diskPath) {
|
|
1269
2297
|
diskPath = `User/${presetName}.preset`;
|
|
@@ -1302,10 +2330,13 @@ server.registerTool("save_variation", {
|
|
|
1302
2330
|
resultSuffix = "\n(Note: active preset is a factory preset — saved as new user preset)";
|
|
1303
2331
|
}
|
|
1304
2332
|
const result = (await bridge.send("savePreset", metadata));
|
|
2333
|
+
lastSavedPresetName = presetName;
|
|
2334
|
+
lastSavedPresetPath = result?.path || diskPath;
|
|
1305
2335
|
return text(`Saved variation ${index} for "${presetName}"\n` +
|
|
1306
2336
|
`Path: ${result?.path || "(unknown path)"}\n` +
|
|
1307
2337
|
`Filled slots: ${variations.filter((v) => v !== null).length}/8` +
|
|
1308
|
-
resultSuffix
|
|
2338
|
+
resultSuffix +
|
|
2339
|
+
hostedPluginSaveWarning(state, canonical));
|
|
1309
2340
|
});
|
|
1310
2341
|
server.registerTool("update_preset_metadata", {
|
|
1311
2342
|
title: "Update Preset Metadata",
|
|
@@ -1419,30 +2450,141 @@ server.registerTool("reverse_sample", {
|
|
|
1419
2450
|
await bridge.send("reverseSampleForCell", cellId);
|
|
1420
2451
|
return text(`Reversed sample in cell ${cellId}.`);
|
|
1421
2452
|
});
|
|
2453
|
+
const BRIDGE_INSTANCE_SHAPE = z.object({
|
|
2454
|
+
port: z.number().optional(),
|
|
2455
|
+
instanceId: z.string().optional(),
|
|
2456
|
+
product: z.string().optional(),
|
|
2457
|
+
pid: z.number().optional(),
|
|
2458
|
+
});
|
|
1422
2459
|
server.registerTool("connection_status", {
|
|
1423
2460
|
title: "Connection Status",
|
|
1424
2461
|
description: "Check if the MCP server can connect to a running PAM plugin instance. " +
|
|
1425
2462
|
"Returns connection details or instructions for enabling the bridge.",
|
|
1426
2463
|
inputSchema: {},
|
|
2464
|
+
outputSchema: {
|
|
2465
|
+
instances: z.array(BRIDGE_INSTANCE_SHAPE.passthrough()).optional(),
|
|
2466
|
+
connected: z.boolean().optional(),
|
|
2467
|
+
connectedTo: BRIDGE_INSTANCE_SHAPE.passthrough().nullable().optional(),
|
|
2468
|
+
note: z.string().optional(),
|
|
2469
|
+
},
|
|
1427
2470
|
annotations: { readOnlyHint: true },
|
|
1428
2471
|
}, async () => {
|
|
1429
2472
|
const instances = await bridge.discover();
|
|
1430
2473
|
if (instances.length === 0) {
|
|
1431
|
-
return
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
"
|
|
1435
|
-
|
|
2474
|
+
return structured({
|
|
2475
|
+
instances: [],
|
|
2476
|
+
connected: false,
|
|
2477
|
+
note: "No running PAM instances found. To use live tools, PAM needs the MCP bridge enabled: " +
|
|
2478
|
+
"start PAM (standalone or in a DAW) and the bridge will write its port to ~/.pam-mcp-bridge.json. " +
|
|
2479
|
+
"Offline tools (get_parameter_info, list_presets, read_preset) work without a connection.",
|
|
2480
|
+
});
|
|
1436
2481
|
}
|
|
1437
2482
|
const connected = bridge.isConnected() || (await bridge.connect());
|
|
1438
|
-
|
|
2483
|
+
if (connected)
|
|
2484
|
+
syncProductHint();
|
|
2485
|
+
return structured({
|
|
1439
2486
|
instances,
|
|
1440
2487
|
connected,
|
|
2488
|
+
connectedTo: bridge.getConnectedInstance(),
|
|
1441
2489
|
note: connected
|
|
1442
|
-
?
|
|
2490
|
+
? instances.length > 1
|
|
2491
|
+
? "Connected. Multiple instances are running — use select_instance to drive a specific product."
|
|
2492
|
+
: "Connected to PAM. All tools available."
|
|
1443
2493
|
: "Found instances but could not connect. The plugin may have shut down.",
|
|
1444
2494
|
});
|
|
1445
2495
|
});
|
|
2496
|
+
server.registerTool("select_instance", {
|
|
2497
|
+
title: "Select Plugin Instance",
|
|
2498
|
+
description: "Choose which running plugin instance (PAM / CELL1 / MFX) the live tools drive when more than " +
|
|
2499
|
+
"one is running. Match by product name, instanceId, or port — connection_status lists them. " +
|
|
2500
|
+
"Reconnects if currently connected to a different instance, and points offline parameter " +
|
|
2501
|
+
"validation at the selected product's manifest. The PAM_BRIDGE_PRODUCT env var pins the same " +
|
|
2502
|
+
"choice at server launch.",
|
|
2503
|
+
inputSchema: {
|
|
2504
|
+
product: z.string().optional().describe("Product name: PAM, CELL1, or MFX (case-insensitive)."),
|
|
2505
|
+
instanceId: z.string().optional().describe("Exact instanceId from connection_status."),
|
|
2506
|
+
port: z.number().int().optional().describe("Exact TCP port from connection_status."),
|
|
2507
|
+
},
|
|
2508
|
+
outputSchema: {
|
|
2509
|
+
connected: z.boolean().optional(),
|
|
2510
|
+
connectedTo: BRIDGE_INSTANCE_SHAPE.passthrough().nullable().optional(),
|
|
2511
|
+
note: z.string().optional(),
|
|
2512
|
+
},
|
|
2513
|
+
}, async ({ product, instanceId, port }) => {
|
|
2514
|
+
if (!product && !instanceId && port === undefined) {
|
|
2515
|
+
return error("Pass at least one of product, instanceId, or port. Call connection_status to list running instances.");
|
|
2516
|
+
}
|
|
2517
|
+
const ok = await bridge.connect({ product, instanceId, port });
|
|
2518
|
+
if (!ok) {
|
|
2519
|
+
const instances = await bridge.discover();
|
|
2520
|
+
const listing = instances.length
|
|
2521
|
+
? ` Running instances: ${instances
|
|
2522
|
+
.map((i) => `${i.product} (port ${i.port}, id ${i.instanceId})`)
|
|
2523
|
+
.join("; ")}.`
|
|
2524
|
+
: " No running instances found.";
|
|
2525
|
+
return error(`No running instance matches that selector.${listing}`);
|
|
2526
|
+
}
|
|
2527
|
+
syncProductHint();
|
|
2528
|
+
return structured({
|
|
2529
|
+
connected: true,
|
|
2530
|
+
connectedTo: bridge.getConnectedInstance(),
|
|
2531
|
+
note: "Live tools now drive this instance.",
|
|
2532
|
+
});
|
|
2533
|
+
});
|
|
2534
|
+
// Bounded recursive name search under a library root (BFS — shallow matches
|
|
2535
|
+
// first), over the bridge's listDirectory. Mirrors the in-plugin executor's
|
|
2536
|
+
// searchLibrary (assistantExecutor.ts) so the assistant's trained browse chain
|
|
2537
|
+
// (list_samples{path,search} → load_sample of a path-joined result) behaves
|
|
2538
|
+
// identically through the dev server. Returned names are relative to the
|
|
2539
|
+
// searched root; listDirectory accepts base-relative paths, so recursion can
|
|
2540
|
+
// use each entry's `path` verbatim.
|
|
2541
|
+
async function searchSampleLibrary(rootPath, term, audioOnly) {
|
|
2542
|
+
const t = term.toLowerCase();
|
|
2543
|
+
const matches = [];
|
|
2544
|
+
const queue = [{ path: rootPath, prefix: "", depth: 0 }];
|
|
2545
|
+
const options = JSON.stringify({ audioOnly, sort: "name" });
|
|
2546
|
+
let dirReads = 0;
|
|
2547
|
+
while (queue.length && dirReads < 40 && matches.length < 60) {
|
|
2548
|
+
const { path, prefix, depth } = queue.shift();
|
|
2549
|
+
dirReads++;
|
|
2550
|
+
let entries = [];
|
|
2551
|
+
try {
|
|
2552
|
+
const res = await bridge.send("listDirectory", path, options);
|
|
2553
|
+
if (Array.isArray(res))
|
|
2554
|
+
entries = res;
|
|
2555
|
+
}
|
|
2556
|
+
catch {
|
|
2557
|
+
continue; // unreadable subdir — keep sweeping the rest
|
|
2558
|
+
}
|
|
2559
|
+
for (const e of entries) {
|
|
2560
|
+
const name = typeof e.name === "string" ? e.name : "";
|
|
2561
|
+
if (!name)
|
|
2562
|
+
continue;
|
|
2563
|
+
if (e.isDir) {
|
|
2564
|
+
if (depth < 3 && typeof e.path === "string")
|
|
2565
|
+
queue.push({ path: e.path, prefix: `${prefix}${name}/`, depth: depth + 1 });
|
|
2566
|
+
}
|
|
2567
|
+
else if (name.toLowerCase().includes(t)) {
|
|
2568
|
+
matches.push(`${prefix}${name}`);
|
|
2569
|
+
if (matches.length >= 60)
|
|
2570
|
+
break;
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
return matches;
|
|
2575
|
+
}
|
|
2576
|
+
// Search-result payloads feed the assistant's 256-token continuation round —
|
|
2577
|
+
// trim to that budget instead of letting the listing collapse to an opaque
|
|
2578
|
+
// truncation string (executor parity: assistantExecutor.ts fitListing).
|
|
2579
|
+
function fitListing(base, itemsKey, items, moreKey, total) {
|
|
2580
|
+
let page = items;
|
|
2581
|
+
for (;;) {
|
|
2582
|
+
const payload = { ...base, [itemsKey]: page, ...(total > page.length ? { [moreKey]: total - page.length } : {}) };
|
|
2583
|
+
if (JSON.stringify(payload).length <= 380 || page.length <= 2)
|
|
2584
|
+
return payload;
|
|
2585
|
+
page = page.slice(0, Math.max(2, Math.floor(page.length / 2)));
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
1446
2588
|
server.registerTool("list_samples", {
|
|
1447
2589
|
title: "List Samples",
|
|
1448
2590
|
description: "Browse sample library directories. IMPORTANT: Do NOT guess paths — always call " +
|
|
@@ -1474,12 +2616,25 @@ server.registerTool("list_samples", {
|
|
|
1474
2616
|
.max(200)
|
|
1475
2617
|
.default(50)
|
|
1476
2618
|
.describe("Maximum number of entries to return (1-200). Default: 50."),
|
|
2619
|
+
// Declared LAST so the exported assistant toolpack keeps its property
|
|
2620
|
+
// order byte-identical (export-toolpack.mjs overwrites this prop's text).
|
|
2621
|
+
search: z
|
|
2622
|
+
.string()
|
|
2623
|
+
.optional()
|
|
2624
|
+
.describe("Filter by file name, searched recursively under `path` (e.g. 'kick', '808')."),
|
|
1477
2625
|
},
|
|
1478
2626
|
annotations: { readOnlyHint: true },
|
|
1479
|
-
}, async ({ path: dirPath, audioOnly, sort, offset, limit }) => {
|
|
2627
|
+
}, async ({ path: dirPath, audioOnly, sort, offset, limit, search }) => {
|
|
1480
2628
|
const check = await requireBridge();
|
|
1481
2629
|
if (check !== true)
|
|
1482
2630
|
return check;
|
|
2631
|
+
const term = search?.trim();
|
|
2632
|
+
if (term) {
|
|
2633
|
+
const matches = await searchSampleLibrary(dirPath, term, audioOnly);
|
|
2634
|
+
// Executor-shaped payload; empty files:[] is a SUCCESS (the assistant
|
|
2635
|
+
// reports the miss in text instead of repair-looping).
|
|
2636
|
+
return json(fitListing({ path: dirPath }, "files", matches.slice(0, 10), "moreFiles", matches.length));
|
|
2637
|
+
}
|
|
1483
2638
|
const options = JSON.stringify({ audioOnly, sort });
|
|
1484
2639
|
const result = await bridge.send("listDirectory", dirPath, options);
|
|
1485
2640
|
if (!Array.isArray(result) || result.length === 0) {
|
|
@@ -1678,7 +2833,7 @@ const VIDEO_LAYER_FX = "hueRotate, glow, audioReact, vibration, chromaticAberrat
|
|
|
1678
2833
|
"mirror, trails(drawing/text), beam(drawing/text), shake, rolls, barrelBlur, edge, " +
|
|
1679
2834
|
"halftone, patches, brightnessContrast, lines, feedback, warp, datamosh, slicer, " +
|
|
1680
2835
|
"polarSlitScan, cellVoronoi, spectralZones, paletteLut, asciiDither, pixelSort, " +
|
|
1681
|
-
"cameraShake, blur";
|
|
2836
|
+
"cameraShake, blur, cymatics, lineJitter, crt, vhs";
|
|
1682
2837
|
// Output/global FX chain ids (videoStore, DEFAULT_VIDEO_FX_CONFIG).
|
|
1683
2838
|
const VIDEO_OUTPUT_FX = "lines, barrelBlur, brightnessContrast, edge, glow, shake, rgbShift, halftone, mirror, " +
|
|
1684
2839
|
"patches, pixelate, rolls, feedback, warp, datamosh, slicer, positionXY, speed, " +
|
|
@@ -1704,12 +2859,24 @@ async function getVideoStateRaw() {
|
|
|
1704
2859
|
return (typeof r === "string" ? JSON.parse(r) : r) ?? {};
|
|
1705
2860
|
}
|
|
1706
2861
|
const sendVideoCommand = (command) => bridge.send("videoCommand", command);
|
|
1707
|
-
|
|
1708
|
-
|
|
2862
|
+
let videoRequestCounter = 0;
|
|
2863
|
+
const mintVideoRequestId = () => `mcp-${process.pid}-${Date.now().toString(36)}-${++videoRequestCounter}`;
|
|
2864
|
+
/** Create ops are fire-and-forget. Ack-capable PAM builds record the command's
|
|
2865
|
+
* result in the snapshot as `lastCommandAck` keyed by our requestId — exact
|
|
2866
|
+
* under concurrent edits. Older builds never publish an ack, so the snapshot
|
|
2867
|
+
* id-set diff stays as the fallback. */
|
|
2868
|
+
async function resolveNewId(requestId, beforeIds, pick, ackIdKey, timeoutMs = 2500) {
|
|
1709
2869
|
const deadline = Date.now() + timeoutMs;
|
|
1710
2870
|
while (Date.now() < deadline) {
|
|
1711
2871
|
await sleep(120);
|
|
1712
2872
|
const s = await getVideoStateRaw();
|
|
2873
|
+
const ack = s.lastCommandAck;
|
|
2874
|
+
if (ack && ack.requestId === requestId) {
|
|
2875
|
+
const minted = ack.result?.[ackIdKey];
|
|
2876
|
+
if (typeof minted === "string" && minted.length > 0)
|
|
2877
|
+
return minted;
|
|
2878
|
+
return null; // acked but failed / no id — don't misattribute via the diff
|
|
2879
|
+
}
|
|
1713
2880
|
const found = pick(s).find((id) => !beforeIds.has(id));
|
|
1714
2881
|
if (found)
|
|
1715
2882
|
return found;
|
|
@@ -1720,9 +2887,14 @@ server.registerTool("get_video_state", {
|
|
|
1720
2887
|
title: "Get Video State",
|
|
1721
2888
|
description: "Read the full VX video scene: layers (in render order, top-first) with their " +
|
|
1722
2889
|
"per-layer FX chains, the output/global FX chain, audio-reactive bindings, " +
|
|
1723
|
-
"mod-matrix routes, the 3D model overlay,
|
|
1724
|
-
"
|
|
1725
|
-
"
|
|
2890
|
+
"mod-matrix routes, the 3D model overlay, and global render/sync settings " +
|
|
2891
|
+
"(incl. vxVariationFollowAudio + VX bar-sync). Also carries: vxVariations " +
|
|
2892
|
+
"(8-slot occupancy booleans) + currentVxVariation; the songMode arrangement " +
|
|
2893
|
+
"read-model (nodes with per-section VX slot assignments, compiled steps with " +
|
|
2894
|
+
"the VX slot each recalls, arrangementBars); and the poll targets for " +
|
|
2895
|
+
"long-running jobs — export (exportVideo), recording (start/stop/saveVideoRecording), " +
|
|
2896
|
+
"songExport (exportSong). Layer/FX/binding ids returned here are what the other " +
|
|
2897
|
+
"video tools address. If `available` is false, open PAM's window so the video UI mounts.",
|
|
1726
2898
|
inputSchema: {},
|
|
1727
2899
|
annotations: { readOnlyHint: true },
|
|
1728
2900
|
}, async () => {
|
|
@@ -1793,6 +2965,10 @@ server.registerTool("video_command", {
|
|
|
1793
2965
|
"moveLayer{layerId,toIndex}, duplicateLayer{layerId}, renameLayer{layerId,name}, " +
|
|
1794
2966
|
"setLayerEnabled{layerId,enabled?}, toggleSolo{layerId}, selectLayer{layerId}, " +
|
|
1795
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}; " +
|
|
1796
2972
|
"addLayerFx{layerId,fxType,params?,beforeFxId?}, removeLayerFx{layerId,fxId}, " +
|
|
1797
2973
|
"removeAllLayerFx{layerId}, setLayerFxParam{layerId,fxId,param,value|params}, " +
|
|
1798
2974
|
"setLayerFxEnabled{layerId,fxId,enabled?}, reorderLayerFx{layerId,fxIds}, " +
|
|
@@ -1803,7 +2979,16 @@ server.registerTool("video_command", {
|
|
|
1803
2979
|
"setOutputFxEnabled{instanceId,enabled}, setOutputFxStrength{instanceId,strength}; " +
|
|
1804
2980
|
"addModRoute{route}, removeModRoute{id}, updateModRoute{id,updates}, clearModRoutes, " +
|
|
1805
2981
|
"setModEnabled{enabled}; setModel{sourcePath?,visible?,blendMode?,audioReactive?,autoRotate?,transform?}, " +
|
|
1806
|
-
"resetModel; setSettings{...
|
|
2982
|
+
"resetModel; setSettings{...incl vxVariationFollowAudio,vxBarSyncEnabled,vxBarSyncIntervalBars}, " +
|
|
2983
|
+
"setActiveCell{cellId} (deprecated no-op), resetWorkspace; " +
|
|
2984
|
+
"VX variation slots: saveVxVariation{index 0-7}, loadVxVariation{index}, clearVxVariation{index}, " +
|
|
2985
|
+
"fillVxVariationsRandomized (occupancy/current in get_video_state; or use the vx_variation tool); " +
|
|
2986
|
+
"export/record: exportVideo{durationInSeconds,encoderId?,filename?,fps?,width?,height?,bitrate?,audioBitDepth?}, " +
|
|
2987
|
+
"cancelVideoExport, startVideoRecording{encoderId?,saveMode?,fps?,barSync?,bars?,skipSilence?,normalize?}, " +
|
|
2988
|
+
"stopVideoRecording, saveVideoRecording (poll get_video_state export/recording); " +
|
|
2989
|
+
"song: setSongArrangement{sections:[{variationIndex,bars?,repeat?,muted?,probability?,presetPath?,label?,vxVariationIndex?,vxScenePath?}],connections?:[{from,to,probability?}],loop?,enabled?,crossfadeMs?}, " +
|
|
2990
|
+
"setSongNodeVx{nodeId|stepIndex,index|null,vxScenePath?}, exportSong{name?,bars?,normalize?,includeVideo?,encoderId?,strategy?}, " +
|
|
2991
|
+
"cancelSongExport (poll get_video_state songExport/songMode; or use the song_mode / export_song tools). " +
|
|
1807
2992
|
"Layer fx target a per-layer chain; output fx target the global chain (ids differ). " +
|
|
1808
2993
|
VIDEO_BIND_SOURCES,
|
|
1809
2994
|
inputSchema: {
|
|
@@ -1826,23 +3011,33 @@ server.registerTool("add_video_layer", {
|
|
|
1826
3011
|
"update_video_layer and stack FX with add_video_fx.",
|
|
1827
3012
|
inputSchema: {
|
|
1828
3013
|
kind: z
|
|
1829
|
-
.enum(["video", "image", "camera", "text", "drawing", "model3d", "adjustment"])
|
|
1830
|
-
.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'}."),
|
|
1831
3016
|
name: z.string().optional().describe("Display name (auto-generated if omitted)."),
|
|
1832
3017
|
insertAt: z
|
|
1833
3018
|
.number()
|
|
1834
3019
|
.int()
|
|
1835
3020
|
.optional()
|
|
1836
3021
|
.describe("Data-index to insert at (default: top of stack)."),
|
|
3022
|
+
sourcePath: z
|
|
3023
|
+
.string()
|
|
3024
|
+
.optional()
|
|
3025
|
+
.describe("Absolute file path to image/video/model media to load into the new layer (no file picker). " +
|
|
3026
|
+
"Bytes are read natively and handed to the WebView as a blob. Large videos are bounded by maxBytes."),
|
|
3027
|
+
maxBytes: z
|
|
3028
|
+
.number()
|
|
3029
|
+
.optional()
|
|
3030
|
+
.describe("Max bytes to read for sourcePath (default ~16MB; raise for video, e.g. 50000000)."),
|
|
1837
3031
|
},
|
|
1838
|
-
}, async ({ kind, name, insertAt }) => {
|
|
3032
|
+
}, async ({ kind, name, insertAt, sourcePath, maxBytes }) => {
|
|
1839
3033
|
const check = await requireBridge();
|
|
1840
3034
|
if (check !== true)
|
|
1841
3035
|
return check;
|
|
1842
3036
|
const before = await getVideoStateRaw();
|
|
1843
3037
|
const beforeIds = new Set((before.layers ?? []).map((l) => l.id));
|
|
1844
|
-
|
|
1845
|
-
|
|
3038
|
+
const requestId = mintVideoRequestId();
|
|
3039
|
+
await sendVideoCommand({ op: "addLayer", kind, name, insertAt, sourcePath, maxBytes, requestId });
|
|
3040
|
+
const id = await resolveNewId(requestId, beforeIds, (s) => (s.layers ?? []).map((l) => l.id), "layerId");
|
|
1846
3041
|
return text(id
|
|
1847
3042
|
? `Added ${kind} layer: ${id}`
|
|
1848
3043
|
: `Added ${kind} layer (window may be closed; call get_video_state to confirm).`);
|
|
@@ -1890,6 +3085,23 @@ server.registerTool("update_video_layer", {
|
|
|
1890
3085
|
const result = await sendVideoCommand({ op: "updateLayer", layerId, patch });
|
|
1891
3086
|
return json(result);
|
|
1892
3087
|
});
|
|
3088
|
+
server.registerTool("set_video_layer_source", {
|
|
3089
|
+
title: "Set Video Layer Source",
|
|
3090
|
+
description: "Load image/video/model media into an existing VX layer from a file path — the headless " +
|
|
3091
|
+
"equivalent of the file picker. Reads bytes natively and assigns a blob the layer renders. " +
|
|
3092
|
+
"PAM's window must be open (VX renders in the WebView). Large videos: raise maxBytes.",
|
|
3093
|
+
inputSchema: {
|
|
3094
|
+
layerId: z.string().describe("Target layer id (from get_video_state / add_video_layer)."),
|
|
3095
|
+
sourcePath: z.string().describe("Absolute path to the media file."),
|
|
3096
|
+
maxBytes: z.number().optional().describe("Max bytes to read (default ~16MB; raise for video)."),
|
|
3097
|
+
},
|
|
3098
|
+
}, async ({ layerId, sourcePath, maxBytes }) => {
|
|
3099
|
+
const check = await requireBridge();
|
|
3100
|
+
if (check !== true)
|
|
3101
|
+
return check;
|
|
3102
|
+
await sendVideoCommand({ op: "setLayerSource", layerId, sourcePath, maxBytes });
|
|
3103
|
+
return { content: [{ type: "text", text: `Loading ${sourcePath} into layer ${layerId}.` }] };
|
|
3104
|
+
});
|
|
1893
3105
|
server.registerTool("remove_video_layer", {
|
|
1894
3106
|
title: "Remove Video Layer",
|
|
1895
3107
|
description: "Remove a layer (and its FX + bindings). The master layer cannot be removed.",
|
|
@@ -1946,8 +3158,9 @@ server.registerTool("add_video_fx", {
|
|
|
1946
3158
|
if (target === "output") {
|
|
1947
3159
|
const before = await getVideoStateRaw();
|
|
1948
3160
|
const beforeIds = new Set((before.outputFx ?? []).map((f) => f.instanceId));
|
|
1949
|
-
|
|
1950
|
-
|
|
3161
|
+
const requestId = mintVideoRequestId();
|
|
3162
|
+
await sendVideoCommand({ op: "addOutputFx", fxId: fxType, requestId });
|
|
3163
|
+
const id = await resolveNewId(requestId, beforeIds, (s) => (s.outputFx ?? []).map((f) => f.instanceId), "instanceId");
|
|
1951
3164
|
// Apply param overrides now that the instance exists.
|
|
1952
3165
|
if (params && id)
|
|
1953
3166
|
for (const [param, value] of Object.entries(params))
|
|
@@ -1959,11 +3172,12 @@ server.registerTool("add_video_fx", {
|
|
|
1959
3172
|
const before = await getVideoStateRaw();
|
|
1960
3173
|
const layer = (before.layers ?? []).find((l) => l.id === layerId);
|
|
1961
3174
|
const beforeIds = new Set((layer?.fx ?? []).map((f) => f.id));
|
|
1962
|
-
|
|
1963
|
-
|
|
3175
|
+
const requestId = mintVideoRequestId();
|
|
3176
|
+
await sendVideoCommand({ op: "addLayerFx", layerId, fxType, params, beforeFxId, requestId });
|
|
3177
|
+
const id = await resolveNewId(requestId, beforeIds, (s) => {
|
|
1964
3178
|
const l = (s.layers ?? []).find((x) => x.id === layerId);
|
|
1965
3179
|
return (l?.fx ?? []).map((f) => f.id);
|
|
1966
|
-
});
|
|
3180
|
+
}, "fxId");
|
|
1967
3181
|
return text(id ? `Added ${fxType} to layer ${layerId}: ${id}` : `Added ${fxType} (confirm via get_video_state).`);
|
|
1968
3182
|
});
|
|
1969
3183
|
server.registerTool("set_video_fx", {
|
|
@@ -2087,8 +3301,9 @@ server.registerTool("set_video_settings", {
|
|
|
2087
3301
|
title: "Set Video Settings",
|
|
2088
3302
|
description: "Set global VX render/sync settings: syncMode (off|beat-sync|beat-match), loopBars, " +
|
|
2089
3303
|
"aspectRatio (auto|16:9|4:3|1:1|stretch), renderQuality (low|medium|high), " +
|
|
2090
|
-
"designAspect (w/h number or null), letterboxColor / canvasColor (#hex), the
|
|
2091
|
-
"
|
|
3304
|
+
"designAspect (w/h number or null), letterboxColor / canvasColor (#hex), the " +
|
|
3305
|
+
"mod-matrix master enable, VX-variation follow-audio, and VX bar-sync (queue slot " +
|
|
3306
|
+
"loads on the next bar boundary while playing). resetWorkspace clears the " +
|
|
2092
3307
|
"scene (preserves the master layer).",
|
|
2093
3308
|
inputSchema: {
|
|
2094
3309
|
syncMode: z.enum(["off", "beat-sync", "beat-match"]).optional(),
|
|
@@ -2099,10 +3314,22 @@ server.registerTool("set_video_settings", {
|
|
|
2099
3314
|
letterboxColor: z.string().optional(),
|
|
2100
3315
|
canvasColor: z.string().optional(),
|
|
2101
3316
|
modEnabled: z.boolean().optional().describe("Mod-matrix master enable."),
|
|
2102
|
-
|
|
3317
|
+
vxVariationFollowAudio: z
|
|
3318
|
+
.boolean()
|
|
3319
|
+
.optional()
|
|
3320
|
+
.describe("VX variation slots follow audio variation switches (default true)."),
|
|
3321
|
+
vxBarSyncEnabled: z
|
|
3322
|
+
.boolean()
|
|
3323
|
+
.optional()
|
|
3324
|
+
.describe("Queue VX-local slot loads on the next bar-sync boundary while the transport plays."),
|
|
3325
|
+
vxBarSyncIntervalBars: z
|
|
3326
|
+
.number()
|
|
3327
|
+
.optional()
|
|
3328
|
+
.describe("VX bar-sync interval in bars (-1 = longest-pattern sentinel)."),
|
|
3329
|
+
activeCell: z.number().int().optional().describe("Deprecated/no-op: VX is a single global scene (no per-cell video)."),
|
|
2103
3330
|
resetWorkspace: z.boolean().optional().describe("If true, clear the whole scene first."),
|
|
2104
3331
|
},
|
|
2105
|
-
}, async ({ syncMode, loopBars, aspectRatio, renderQuality, designAspect, letterboxColor, canvasColor, modEnabled, activeCell, resetWorkspace }) => {
|
|
3332
|
+
}, async ({ syncMode, loopBars, aspectRatio, renderQuality, designAspect, letterboxColor, canvasColor, modEnabled, vxVariationFollowAudio, vxBarSyncEnabled, vxBarSyncIntervalBars, activeCell, resetWorkspace }) => {
|
|
2106
3333
|
const check = await requireBridge();
|
|
2107
3334
|
if (check !== true)
|
|
2108
3335
|
return check;
|
|
@@ -2127,10 +3354,75 @@ server.registerTool("set_video_settings", {
|
|
|
2127
3354
|
settings.letterboxColor = letterboxColor;
|
|
2128
3355
|
if (canvasColor !== undefined)
|
|
2129
3356
|
settings.canvasColor = canvasColor;
|
|
3357
|
+
if (vxVariationFollowAudio !== undefined)
|
|
3358
|
+
settings.vxVariationFollowAudio = vxVariationFollowAudio;
|
|
3359
|
+
if (vxBarSyncEnabled !== undefined)
|
|
3360
|
+
settings.vxBarSyncEnabled = vxBarSyncEnabled;
|
|
3361
|
+
if (vxBarSyncIntervalBars !== undefined)
|
|
3362
|
+
settings.vxBarSyncIntervalBars = vxBarSyncIntervalBars;
|
|
2130
3363
|
if (Object.keys(settings).length > 0)
|
|
2131
3364
|
await sendVideoCommand({ op: "setSettings", ...settings });
|
|
2132
3365
|
return text("Video settings updated.");
|
|
2133
3366
|
});
|
|
3367
|
+
server.registerTool("vx_variation", {
|
|
3368
|
+
title: "VX Variation Slots",
|
|
3369
|
+
description: "Manage the 8 VX (visual) variation slots — the visual siblings of the audio " +
|
|
3370
|
+
"variation slots (save_variation / load_variation). 'save' snapshots the live VX " +
|
|
3371
|
+
"scene into a slot; 'load' recalls one (an empty slot moves the index but keeps the " +
|
|
3372
|
+
"current visuals); 'clear' empties a slot; 'fill' saves 8 randomized scene variations " +
|
|
3373
|
+
"into all slots. With vxVariationFollowAudio on (default), audio variation switches — " +
|
|
3374
|
+
"including Song Mode section changes — recall the matching VX slot automatically, so " +
|
|
3375
|
+
"saving VX slots alongside audio slots gives every song section its own visuals " +
|
|
3376
|
+
"(pin a different slot per section via song_mode assignVx). WebView-routed: PAM's " +
|
|
3377
|
+
"window must be OPEN. Verify via get_video_state → vxVariations / currentVxVariation.",
|
|
3378
|
+
inputSchema: {
|
|
3379
|
+
op: z.enum(["save", "load", "clear", "fill"]).describe("Slot operation."),
|
|
3380
|
+
index: z
|
|
3381
|
+
.number()
|
|
3382
|
+
.int()
|
|
3383
|
+
.min(0)
|
|
3384
|
+
.max(7)
|
|
3385
|
+
.optional()
|
|
3386
|
+
.describe("Slot index 0-7 (required for save/load/clear; ignored for fill)."),
|
|
3387
|
+
},
|
|
3388
|
+
}, async ({ op, index }) => {
|
|
3389
|
+
const check = await requireBridge();
|
|
3390
|
+
if (check !== true)
|
|
3391
|
+
return check;
|
|
3392
|
+
if (op !== "fill" && typeof index !== "number")
|
|
3393
|
+
return error(`'${op}' requires index (0-7).`);
|
|
3394
|
+
const opMap = {
|
|
3395
|
+
save: "saveVxVariation",
|
|
3396
|
+
load: "loadVxVariation",
|
|
3397
|
+
clear: "clearVxVariation",
|
|
3398
|
+
fill: "fillVxVariationsRandomized",
|
|
3399
|
+
};
|
|
3400
|
+
await sendVideoCommand(op === "fill" ? { op: opMap.fill } : { op: opMap[op], index });
|
|
3401
|
+
// Fire-and-forget forward path — poll until the snapshot reflects the op
|
|
3402
|
+
// (save→occupied, clear→empty, load→index moved [sticky-empty rule], fill→all occupied).
|
|
3403
|
+
const expectMet = (s) => {
|
|
3404
|
+
const slots = Array.isArray(s.vxVariations) ? s.vxVariations : [];
|
|
3405
|
+
if (op === "save")
|
|
3406
|
+
return slots[index] === true;
|
|
3407
|
+
if (op === "clear")
|
|
3408
|
+
return slots[index] === false;
|
|
3409
|
+
if (op === "load")
|
|
3410
|
+
return s.currentVxVariation === index;
|
|
3411
|
+
return slots.length > 0 && slots.every(Boolean);
|
|
3412
|
+
};
|
|
3413
|
+
let state = await getVideoStateRaw();
|
|
3414
|
+
const deadline = Date.now() + 2500;
|
|
3415
|
+
while (state.available !== false && !expectMet(state) && Date.now() < deadline) {
|
|
3416
|
+
await sleep(150);
|
|
3417
|
+
state = await getVideoStateRaw();
|
|
3418
|
+
}
|
|
3419
|
+
if (state.available === false)
|
|
3420
|
+
return error("VX unavailable — open PAM's window (the video engine renders in the WebView).");
|
|
3421
|
+
const slots = Array.isArray(state.vxVariations) ? state.vxVariations : [];
|
|
3422
|
+
return text(`vx_variation ${op}${op === "fill" ? "" : ` slot ${index}`} ${expectMet(state) ? "applied" : "sent (state not yet reflecting it — window open? rejected op?)"}. ` +
|
|
3423
|
+
`Occupied slots: [${slots.map((o, i) => (o ? i : null)).filter((v) => v !== null).join(", ")}], ` +
|
|
3424
|
+
`current: ${String(state.currentVxVariation ?? "?")}.`);
|
|
3425
|
+
});
|
|
2134
3426
|
// ============================================================
|
|
2135
3427
|
// Composite cell-operation tools
|
|
2136
3428
|
// ============================================================
|
|
@@ -2152,6 +3444,10 @@ server.registerTool("reset_cell", {
|
|
|
2152
3444
|
const check = await requireBridge();
|
|
2153
3445
|
if (check !== true)
|
|
2154
3446
|
return check;
|
|
3447
|
+
const manifest = await loadManifestForProductGuard();
|
|
3448
|
+
const cellCheck = validateCellIdForManifest(cellId, manifest);
|
|
3449
|
+
if (cellCheck !== true)
|
|
3450
|
+
return cellCheck;
|
|
2155
3451
|
const [cellParams, envelopes] = await Promise.all([
|
|
2156
3452
|
getParametersByCell(`cell${cellId}`),
|
|
2157
3453
|
bridge.send("getState", "envelopes"),
|
|
@@ -2185,6 +3481,13 @@ server.registerTool("copy_cell", {
|
|
|
2185
3481
|
const check = await requireBridge();
|
|
2186
3482
|
if (check !== true)
|
|
2187
3483
|
return check;
|
|
3484
|
+
const manifest = await loadManifestForProductGuard();
|
|
3485
|
+
const sourceCheck = validateCellIdForManifest(sourceCellId, manifest);
|
|
3486
|
+
if (sourceCheck !== true)
|
|
3487
|
+
return sourceCheck;
|
|
3488
|
+
const targetCheck = validateCellIdForManifest(targetCellId, manifest);
|
|
3489
|
+
if (targetCheck !== true)
|
|
3490
|
+
return targetCheck;
|
|
2188
3491
|
// Full state needed: cell params live as flat top-level cellN_* keys, no section.
|
|
2189
3492
|
const state = (await bridge.send("getState"));
|
|
2190
3493
|
const srcPrefix = `cell${sourceCellId}_`;
|
|
@@ -2285,6 +3588,10 @@ server.registerTool("clear_modulations", {
|
|
|
2285
3588
|
const check = await requireBridge();
|
|
2286
3589
|
if (check !== true)
|
|
2287
3590
|
return check;
|
|
3591
|
+
const manifest = await loadManifestForProductGuard();
|
|
3592
|
+
const cellCheck = validateCellIdForManifest(cellId, manifest);
|
|
3593
|
+
if (cellCheck !== true)
|
|
3594
|
+
return cellCheck;
|
|
2288
3595
|
const [lfosRaw, varyRaw, envsRaw] = await Promise.all([
|
|
2289
3596
|
bridge.send("getState", "lfos"),
|
|
2290
3597
|
bridge.send("getState", "vary"),
|
|
@@ -2317,7 +3624,7 @@ server.registerTool("clear_modulations", {
|
|
|
2317
3624
|
});
|
|
2318
3625
|
server.registerTool("reset_patterns", {
|
|
2319
3626
|
title: "Reset Patterns",
|
|
2320
|
-
description: "Clear all sequencer lanes (note, pitch, slice, stretch) for a single cell.",
|
|
3627
|
+
description: "Clear all sequencer lanes (note, pitch, slice, stretch) for a single PAM/Cell1 sampler cell.",
|
|
2321
3628
|
inputSchema: {
|
|
2322
3629
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
2323
3630
|
},
|
|
@@ -2326,6 +3633,15 @@ server.registerTool("reset_patterns", {
|
|
|
2326
3633
|
const check = await requireBridge();
|
|
2327
3634
|
if (check !== true)
|
|
2328
3635
|
return check;
|
|
3636
|
+
const manifest = await loadManifestForProductGuard();
|
|
3637
|
+
const cellCheck = validateCellIdForManifest(cellId, manifest);
|
|
3638
|
+
if (cellCheck !== true)
|
|
3639
|
+
return cellCheck;
|
|
3640
|
+
const hasPatternLanes = (manifest?.stateVariables ?? []).some((entry) => entry.paramId === `cell${cellId}_midiSequence`);
|
|
3641
|
+
if (manifest && !hasPatternLanes) {
|
|
3642
|
+
const productName = manifest.name ?? "This product";
|
|
3643
|
+
return error(`${productName} has no sampler MIDI/pitch/slice/stretch pattern lanes.`);
|
|
3644
|
+
}
|
|
2329
3645
|
const delta = { __forceRender__: true };
|
|
2330
3646
|
for (const suffix of PATTERN_SUFFIXES) {
|
|
2331
3647
|
delta[`cell${cellId}_${suffix}`] = [];
|
|
@@ -2335,7 +3651,8 @@ server.registerTool("reset_patterns", {
|
|
|
2335
3651
|
});
|
|
2336
3652
|
server.registerTool("update_effect_config", {
|
|
2337
3653
|
title: "Update Effect Config",
|
|
2338
|
-
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. " +
|
|
2339
3656
|
"Uses the lighter KV path that does not rebuild the audio graph. Pass 1 to enable, 0 to disable.",
|
|
2340
3657
|
inputSchema: {
|
|
2341
3658
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
@@ -2347,11 +3664,29 @@ server.registerTool("update_effect_config", {
|
|
|
2347
3664
|
fdnEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
2348
3665
|
gateEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
2349
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(),
|
|
2350
3681
|
},
|
|
2351
3682
|
}, async ({ cellId, ...config }) => {
|
|
2352
3683
|
const check = await requireBridge();
|
|
2353
3684
|
if (check !== true)
|
|
2354
3685
|
return check;
|
|
3686
|
+
const manifest = await loadManifestForProductGuard();
|
|
3687
|
+
const cellCheck = validateCellIdForManifest(cellId, manifest);
|
|
3688
|
+
if (cellCheck !== true)
|
|
3689
|
+
return cellCheck;
|
|
2355
3690
|
const delta = {};
|
|
2356
3691
|
for (const [key, value] of Object.entries(config)) {
|
|
2357
3692
|
if (value !== undefined)
|
|
@@ -2366,11 +3701,94 @@ server.registerTool("update_effect_config", {
|
|
|
2366
3701
|
.join(", ");
|
|
2367
3702
|
return text(`Updated effect config for cell ${cellId}: ${summary}`);
|
|
2368
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
|
+
});
|
|
2369
3786
|
server.registerTool("update_cell_config", {
|
|
2370
3787
|
title: "Update Cell Config",
|
|
2371
3788
|
description: "Update cell-level configuration properties like polyphony, MIDI settings, varispeed, and modulation latch. " +
|
|
2372
3789
|
"These are KV-only properties that don't appear in the parameter manifest. " +
|
|
2373
|
-
"Uses a lightweight KV path without graph rebuild."
|
|
3790
|
+
"Uses a lightweight KV path without graph rebuild. " +
|
|
3791
|
+
"When bound to MFX, this tool only accepts sidechainFrom; sampler/MIDI/instrument config is PAM/Cell1-only.",
|
|
2374
3792
|
inputSchema: {
|
|
2375
3793
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
2376
3794
|
isPoly: z.union([z.literal(0), z.literal(1)]).optional().describe("Polyphonic mode (0=mono, 1=poly)."),
|
|
@@ -2380,8 +3798,36 @@ server.registerTool("update_cell_config", {
|
|
|
2380
3798
|
modLatch: z.union([z.literal(0), z.literal(1)]).optional().describe("Latch slice/pitch/stretch modulation at trigger."),
|
|
2381
3799
|
varispeed: z.union([z.literal(0), z.literal(1)]).optional().describe("Varispeed mode (pitch = speed) vs independent pitch shift."),
|
|
2382
3800
|
armed: z.union([z.literal(0), z.literal(1)]).optional().describe("Whether cell accepts recorded MIDI."),
|
|
2383
|
-
polyRootNote: z.number().min(0).max(127).optional().describe("Root note for poly pitch calculation."),
|
|
2384
|
-
|
|
3801
|
+
polyRootNote: z.number().min(0).max(127).optional().describe("Root note for poly pitch calculation. In Slice Keymap mode this is the key that triggers slice 1."),
|
|
3802
|
+
sliceKeymapMode: z
|
|
3803
|
+
.union([z.literal(0), z.literal(1)])
|
|
3804
|
+
.optional()
|
|
3805
|
+
.describe("Slice Keymap mode: incoming MIDI notes select slices by index instead of pitch (polyRootNote -> slice 1, +1 semitone -> slice 2, ...), each at its native pitch. Works in poly (polyphonic slice play) and mono (each new note chokes the previous slice). Requires the cell's sample to be sliced; notes outside the slice range are silent. The slice lane is ignored while this is on. 0=off, 1=on."),
|
|
3806
|
+
sliceTriggerLength: z
|
|
3807
|
+
.number()
|
|
3808
|
+
.int()
|
|
3809
|
+
.min(0)
|
|
3810
|
+
.max(2)
|
|
3811
|
+
.optional()
|
|
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)."),
|
|
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."),
|
|
2385
3831
|
scaleRoot: z.number().min(0).max(11).optional().describe("Root note of the scale (0=C, 1=C#, ..., 11=B)."),
|
|
2386
3832
|
pitchMode: z.number().int().min(0).max(3).optional().describe("Pitch engine: 0=Varispeed, 1=Formant, 2=Pitch Shift Quantize, 3=Varispeed Quantize."),
|
|
2387
3833
|
stretchMode: z.number().int().min(0).max(3).optional().describe("Stretch engine: 0=OLA, 1=Cloud, 2=Phase Vocoder, 3=GrainRate."),
|
|
@@ -2401,36 +3847,107 @@ server.registerTool("update_cell_config", {
|
|
|
2401
3847
|
const check = await requireBridge();
|
|
2402
3848
|
if (check !== true)
|
|
2403
3849
|
return check;
|
|
3850
|
+
const manifest = await loadManifestForProductGuard();
|
|
3851
|
+
const cellCheck = validateCellIdForManifest(cellId, manifest);
|
|
3852
|
+
if (cellCheck !== true)
|
|
3853
|
+
return cellCheck;
|
|
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) {
|
|
3864
|
+
return error("At least one config property must be provided.");
|
|
3865
|
+
}
|
|
3866
|
+
if (manifest && isMfxManifest(manifest)) {
|
|
3867
|
+
const unsupported = providedKeys.filter((key) => !MFX_CELL_CONFIG_KEYS.has(key));
|
|
3868
|
+
if (unsupported.length > 0) {
|
|
3869
|
+
return error("update_cell_config in MFX only accepts sidechainFrom. " +
|
|
3870
|
+
`Sampler/MIDI/instrument config keys are PAM/Cell1-only: ${unsupported.join(", ")}.`);
|
|
3871
|
+
}
|
|
3872
|
+
}
|
|
2404
3873
|
const cellConfig = {};
|
|
2405
3874
|
const flatDelta = {};
|
|
2406
3875
|
for (const [key, value] of Object.entries(config)) {
|
|
2407
|
-
if (value
|
|
3876
|
+
if (value === undefined)
|
|
3877
|
+
continue;
|
|
3878
|
+
if (PARAM_ROUTED_KEYS.has(key))
|
|
3879
|
+
flatDelta[`cell${cellId}_${key}`] = value;
|
|
3880
|
+
else
|
|
2408
3881
|
cellConfig[key] = value;
|
|
2409
3882
|
}
|
|
2410
|
-
if (Object.keys(cellConfig).length === 0) {
|
|
2411
|
-
return error("At least one config property must be provided.");
|
|
2412
|
-
}
|
|
2413
3883
|
// Auto-enable compressor when setting sidechain source
|
|
2414
3884
|
if (cellConfig.sidechainFrom !== undefined && cellConfig.sidechainFrom !== null) {
|
|
2415
3885
|
flatDelta[`cell${cellId}_compressorEnabled`] = 1;
|
|
2416
3886
|
}
|
|
2417
|
-
|
|
3887
|
+
// Mirror interop.ts updateCellConfig: a pitch/stretch engine change OR a
|
|
3888
|
+
// sliceKeymapMode flip (which changes pitch interpretation) bumps
|
|
3889
|
+
// modeChangeSeq so the audio thread flushes its Signalsmith analysis state.
|
|
3890
|
+
if (cellConfig.pitchMode !== undefined ||
|
|
3891
|
+
cellConfig.stretchMode !== undefined ||
|
|
3892
|
+
cellConfig.sliceKeymapMode !== undefined) {
|
|
2418
3893
|
const next = (cellModeChangeSeq[cellId] ?? Date.now()) + 1;
|
|
2419
3894
|
cellModeChangeSeq[cellId] = next;
|
|
2420
3895
|
cellConfig.modeChangeSeq = next;
|
|
2421
3896
|
}
|
|
2422
|
-
const delta = {
|
|
3897
|
+
const delta = { ...flatDelta };
|
|
3898
|
+
if (Object.keys(cellConfig).length > 0)
|
|
3899
|
+
delta.cells = { [cellId]: cellConfig };
|
|
2423
3900
|
await applyKv(delta, KV_OPTS_CELL_CONFIG);
|
|
2424
|
-
const summary =
|
|
3901
|
+
const summary = [
|
|
3902
|
+
...Object.entries(cellConfig).map(([k, v]) => `${k}=${v}`),
|
|
3903
|
+
...Object.entries(flatDelta).map(([k, v]) => `${k}=${v}`),
|
|
3904
|
+
].join(", ");
|
|
3905
|
+
return text(`Updated cell ${cellId} config: ${summary}`);
|
|
3906
|
+
});
|
|
3907
|
+
server.registerTool("set_cell_mute", {
|
|
3908
|
+
title: "Mute / Solo Cell",
|
|
3909
|
+
description: "Mute, unmute, solo, or unsolo a cell in the mixer — the real channel mute/solo, cutting the whole channel " +
|
|
3910
|
+
"(source + all effect tails) rather than pulling the volume fader down. Soloing a cell silences every other " +
|
|
3911
|
+
"non-soloed cell until unsoloed. cell{N}_mute / cell{N}_solo are live-polled state variables (no graph rebuild). " +
|
|
3912
|
+
"Prefer this over set_parameter volume -60 when the user says mute, unmute, solo, unsolo, isolate, or 'just this cell'.",
|
|
3913
|
+
inputSchema: {
|
|
3914
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
3915
|
+
mute: z.boolean().optional().describe("Mute (true) or unmute (false) the cell's channel."),
|
|
3916
|
+
solo: z.boolean().optional().describe("Solo (true) or unsolo (false) the cell — solo silences all other non-soloed cells."),
|
|
3917
|
+
},
|
|
3918
|
+
}, async ({ cellId, mute, solo }) => {
|
|
3919
|
+
const check = await requireBridge();
|
|
3920
|
+
if (check !== true)
|
|
3921
|
+
return check;
|
|
3922
|
+
const manifest = await loadManifestForProductGuard();
|
|
3923
|
+
const cellCheck = validateCellIdForManifest(cellId, manifest);
|
|
3924
|
+
if (cellCheck !== true)
|
|
3925
|
+
return cellCheck;
|
|
3926
|
+
const delta = {};
|
|
3927
|
+
if (typeof mute === "boolean")
|
|
3928
|
+
delta[`cell${cellId}_mute`] = mute ? 1 : 0;
|
|
3929
|
+
if (typeof solo === "boolean")
|
|
3930
|
+
delta[`cell${cellId}_solo`] = solo ? 1 : 0;
|
|
3931
|
+
if (Object.keys(delta).length === 0) {
|
|
3932
|
+
return error("Provide mute and/or solo.");
|
|
3933
|
+
}
|
|
3934
|
+
// cell{N}_mute / cell{N}_solo are flat state vars projected to
|
|
3935
|
+
// cell{N}_config.enabled by KVStateSyncManager — same options the UI's
|
|
3936
|
+
// applyStateDelta uses (KV_OPTS_STANDARD); the projection needs
|
|
3937
|
+
// syncConfigResources, no graph rebuild required but the standard path is
|
|
3938
|
+
// the exact UI parity.
|
|
3939
|
+
await applyKv(delta, KV_OPTS_STANDARD);
|
|
3940
|
+
const summary = Object.entries(delta)
|
|
2425
3941
|
.map(([k, v]) => `${k}=${v}`)
|
|
2426
3942
|
.join(", ");
|
|
2427
|
-
return text(`
|
|
3943
|
+
return text(`Cell ${cellId}: ${summary}`);
|
|
2428
3944
|
});
|
|
2429
3945
|
server.registerTool("set_master_effects", {
|
|
2430
3946
|
title: "Set Master Effects",
|
|
2431
3947
|
description: "Configure the master bus Punch compressor, Limiter, and Tape colour. " +
|
|
2432
3948
|
"The Punch compressor is a musical bus compressor — use it for glue and pump. " +
|
|
2433
|
-
"The Limiter is a
|
|
3949
|
+
"The Limiter is a zero-latency brickwall safety stage (enable/disable only). " +
|
|
3950
|
+
"Check results with get_master_analysis (keep tp <= -1 dBTP, PSR >= 8 in the loudest section). " +
|
|
2434
3951
|
"The Tape is a vintage-tape coloration stage (ADAA tanh/asymmetric saturation + HF compression + wow/flutter), " +
|
|
2435
3952
|
"the single biggest lever for lo-fi / dusty / 'pushed' master character (hip-hop, lo-fi, Memphis, dub). " +
|
|
2436
3953
|
"Pass enabled flags to toggle each on/off. Pass parameters to shape the sound. " +
|
|
@@ -2438,7 +3955,8 @@ server.registerTool("set_master_effects", {
|
|
|
2438
3955
|
"medium release (80-200ms), ratio 4-8, autoMakeup true. " +
|
|
2439
3956
|
"For lo-fi/Memphis/dusty glue: tapeEnabled=1, tapeMode=1 (Lo-Fi), masterTapeDrive 0.55-0.7, " +
|
|
2440
3957
|
"masterTapeTone -0.4..-0.1 (darker), masterTapeWear 0.5-0.7 (wow/flutter). " +
|
|
2441
|
-
"
|
|
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.",
|
|
2442
3960
|
inputSchema: {
|
|
2443
3961
|
compressorEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable Punch compressor (0=off, 1=on)."),
|
|
2444
3962
|
limiterEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable master Limiter (0=off, 1=on)."),
|
|
@@ -2453,13 +3971,55 @@ server.registerTool("set_master_effects", {
|
|
|
2453
3971
|
masterCompCeiling: z.number().min(-6).max(0).optional().describe("Output ceiling in dB (default -0.05)."),
|
|
2454
3972
|
masterCompInputGain: z.number().min(-24).max(24).optional().describe("Input gain in dB (default 0)."),
|
|
2455
3973
|
masterCompKnee: z.number().min(0).max(30).optional().describe("Knee width in dB (default 0)."),
|
|
3974
|
+
masterCompOutTrim: z.number().min(-12).max(12).optional().describe("Output trim in dB (default 0) — the Punch panel's Makeup knob (post-compression level, separate from masterCompMakeupGain)."),
|
|
3975
|
+
masterCompMix: z.number().min(0).max(1).optional().describe("Punch parallel dry/wet (0-1, default 1 = fully compressed)."),
|
|
2456
3976
|
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')."),
|
|
2457
3977
|
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."),
|
|
2458
3978
|
masterTapeMix: z.number().min(0).max(1).optional().describe("Tape dry/wet (0-1, default 1 = fully wet)."),
|
|
2459
|
-
masterTapeMode: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional().describe("Tape character: 0=Clean (gentle tanh), 1=Lo-Fi (most degradation, dusty), 2=Amp (hard asymmetric drive). Default 0."),
|
|
3979
|
+
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."),
|
|
2460
3980
|
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."),
|
|
3981
|
+
masterEqEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the master EQ (trim/tilt/shelves/bells)."),
|
|
3982
|
+
masterWidthEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the stereo utility (width + elliptical bass-mono)."),
|
|
3983
|
+
masterInputTrim: z.number().min(-24).max(24).optional().describe("Master input trim in dB (gain staging into the chain; default 0)."),
|
|
3984
|
+
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)."),
|
|
3985
|
+
masterEqLsFreq: z.number().min(20).max(500).optional().describe("Low-shelf corner Hz (default 90)."),
|
|
3986
|
+
masterEqLsGain: z.number().min(-12).max(12).optional().describe("Low-shelf gain dB (default 0)."),
|
|
3987
|
+
masterEqHsFreq: z.number().min(1000).max(16000).optional().describe("High-shelf corner Hz (default 8000)."),
|
|
3988
|
+
masterEqHsGain: z.number().min(-12).max(12).optional().describe("High-shelf gain dB (default 0)."),
|
|
3989
|
+
masterWidth: z.number().min(0).max(2).optional().describe("Stereo width (0=mono, 1=unity, 2=double side; default 1). Check corr in get_master_analysis after widening."),
|
|
3990
|
+
masterBassMonoFreq: z.number().min(0).max(300).optional().describe("Elliptical bass-mono corner Hz; < 20 = off. Engage (~100-120) when corrLow < +0.7."),
|
|
3991
|
+
masterClipEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the oversampled soft clipper (post-Tape, pre-Limiter). Adds 23 samples of reported latency while on."),
|
|
3992
|
+
masterClipDrive: z.number().min(0).max(12).optional().describe("Clipper drive dB (default 0). Staging guidance: the clipper shaves the sharpest 2-3 dB so the limiter works 1-2 dB."),
|
|
3993
|
+
masterClipMix: z.number().min(0).max(1).optional().describe("Clipper parallel blend (1 = fully clipped)."),
|
|
3994
|
+
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."),
|
|
3995
|
+
masterIronEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the console/transformer iron colour stage (post-Tape, pre-Clipper; zero latency)."),
|
|
3996
|
+
masterIronDrive: z.number().min(0).max(12).optional().describe("Iron drive dB (default 0). Level-compensated — drive adds colour, not loudness."),
|
|
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)."),
|
|
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."),
|
|
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."),
|
|
4009
|
+
masterEqBells: z
|
|
4010
|
+
.array(z.object({
|
|
4011
|
+
freq: z.number().min(30).max(16000).optional(),
|
|
4012
|
+
gain: z.number().min(-12).max(12).optional(),
|
|
4013
|
+
q: z.number().min(0.3).max(8).optional(),
|
|
4014
|
+
dyn: z.union([z.literal(0), z.literal(1)]).optional().describe("Dynamic option: the band's gain rides the in-band level."),
|
|
4015
|
+
dynThresh: z.number().min(-60).max(0).optional().describe("Dynamic threshold dB (default -24)."),
|
|
4016
|
+
dynRange: z.number().min(-12).max(12).optional().describe("Dynamic range dB (negative ducks when loud — de-harsh; default -3)."),
|
|
4017
|
+
}))
|
|
4018
|
+
.max(4)
|
|
4019
|
+
.optional()
|
|
4020
|
+
.describe("The 4 parametric bells in order (defaults 200/700/2000/5000 Hz). Pass null-ish entries to skip a band."),
|
|
2461
4021
|
},
|
|
2462
|
-
}, async ({ compressorEnabled, limiterEnabled, tapeEnabled, ...effectParams }) => {
|
|
4022
|
+
}, async ({ compressorEnabled, limiterEnabled, tapeEnabled, masterEqBells, ...effectParams }) => {
|
|
2463
4023
|
const check = await requireBridge();
|
|
2464
4024
|
if (check !== true)
|
|
2465
4025
|
return check;
|
|
@@ -2471,8 +4031,28 @@ server.registerTool("set_master_effects", {
|
|
|
2471
4031
|
delta.masterLimiterEnabled = limiterEnabled;
|
|
2472
4032
|
if (tapeEnabled !== undefined)
|
|
2473
4033
|
delta.masterTapeEnabled = tapeEnabled;
|
|
2474
|
-
//
|
|
2475
|
-
|
|
4034
|
+
// Bell array → flat masterEqB{n}{Field} keys.
|
|
4035
|
+
if (masterEqBells) {
|
|
4036
|
+
masterEqBells.forEach((bell, i) => {
|
|
4037
|
+
if (!bell)
|
|
4038
|
+
return;
|
|
4039
|
+
const base = `masterEqB${i + 1}`;
|
|
4040
|
+
if (bell.freq !== undefined)
|
|
4041
|
+
delta[`${base}Freq`] = bell.freq;
|
|
4042
|
+
if (bell.gain !== undefined)
|
|
4043
|
+
delta[`${base}Gain`] = bell.gain;
|
|
4044
|
+
if (bell.q !== undefined)
|
|
4045
|
+
delta[`${base}Q`] = bell.q;
|
|
4046
|
+
if (bell.dyn !== undefined)
|
|
4047
|
+
delta[`${base}Dyn`] = bell.dyn;
|
|
4048
|
+
if (bell.dynThresh !== undefined)
|
|
4049
|
+
delta[`${base}DynThresh`] = bell.dynThresh;
|
|
4050
|
+
if (bell.dynRange !== undefined)
|
|
4051
|
+
delta[`${base}DynRange`] = bell.dynRange;
|
|
4052
|
+
});
|
|
4053
|
+
}
|
|
4054
|
+
// Compressor + tape + limiter + EQ parameters are already the flat state
|
|
4055
|
+
// keys read by the DSP graph / native master nodes. Pass through.
|
|
2476
4056
|
for (const [key, value] of Object.entries(effectParams)) {
|
|
2477
4057
|
if (value !== undefined)
|
|
2478
4058
|
delta[key] = value;
|
|
@@ -2480,47 +4060,61 @@ server.registerTool("set_master_effects", {
|
|
|
2480
4060
|
if (Object.keys(delta).length === 0) {
|
|
2481
4061
|
return error("At least one parameter must be provided.");
|
|
2482
4062
|
}
|
|
2483
|
-
//
|
|
2484
|
-
|
|
4063
|
+
// No-dispatch path: every master key is live-polled from master_config
|
|
4064
|
+
// (the full Punch surface included), so a graph rebuild here would only
|
|
4065
|
+
// risk dropouts during automated mastering.
|
|
4066
|
+
await applyKv(delta, KV_OPTS_MASTER_CONFIG);
|
|
2485
4067
|
const summary = Object.entries(delta)
|
|
2486
4068
|
.map(([k, v]) => `${k}=${v}`)
|
|
2487
4069
|
.join(", ");
|
|
2488
4070
|
return text(`Updated master effects: ${summary}`);
|
|
2489
4071
|
});
|
|
2490
4072
|
// Built-in per-cell drum synth ("machines"). Engine enum index = drumsynth::Engine
|
|
2491
|
-
// (Off = 0).
|
|
2492
|
-
//
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
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
|
+
}
|
|
2513
4103
|
server.registerTool("set_drum_synth", {
|
|
2514
4104
|
title: "Set Drum Synth (built-in machine)",
|
|
2515
4105
|
description: "Select and shape a cell's built-in drum-synth 'machine' — a real-time synthesized voice that sums into the cell " +
|
|
2516
|
-
|
|
2517
|
-
"
|
|
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 " +
|
|
2518
4108
|
"bassline (drive it with a pitch lane + pitch-quantize) — ideal for 808 sub-bass, trap, Memphis. Use a cell with NO " +
|
|
2519
4109
|
"sample for a pure synth voice, or layer over a sample. Each engine reinterprets the 8 generic params (p1..p8); pass " +
|
|
2520
|
-
"only the ones you want to nuance
|
|
2521
|
-
|
|
2522
|
-
"
|
|
2523
|
-
"
|
|
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. " +
|
|
4117
|
+
"On products without it, this tool returns a product error instead of writing inert synth keys.",
|
|
2524
4118
|
inputSchema: {
|
|
2525
4119
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
2526
4120
|
engine: z.enum(DRUM_SYNTH_ENGINE_NAMES).describe("Engine name. 'Off' disables the synth voice (sample-only cell)."),
|
|
@@ -2538,17 +4132,21 @@ server.registerTool("set_drum_synth", {
|
|
|
2538
4132
|
const check = await requireBridge();
|
|
2539
4133
|
if (check !== true)
|
|
2540
4134
|
return check;
|
|
4135
|
+
const manifest = await loadManifestForProductGuard();
|
|
4136
|
+
const cellCheck = validateCellIdForManifest(cellId, manifest);
|
|
4137
|
+
if (cellCheck !== true)
|
|
4138
|
+
return cellCheck;
|
|
4139
|
+
if (manifest && manifest.drumSynth?.enabled !== true) {
|
|
4140
|
+
return error("set_drum_synth is PAM-only. This product manifest does not expose the built-in drum synth.");
|
|
4141
|
+
}
|
|
2541
4142
|
const engineId = DRUM_SYNTH_ENGINE_NAMES.indexOf(engine);
|
|
2542
4143
|
if (engineId < 0)
|
|
2543
4144
|
return error(`Unknown drum synth engine: ${engine}`);
|
|
2544
|
-
const
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
desc.params.forEach(([, def], i) => {
|
|
2550
|
-
delta[`cell${cellId}_synth_p${i + 1}`] = def;
|
|
2551
|
-
});
|
|
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) {
|
|
2552
4150
|
for (const [key, value] of Object.entries(overrides)) {
|
|
2553
4151
|
if (value === undefined)
|
|
2554
4152
|
continue;
|
|
@@ -2561,9 +4159,47 @@ server.registerTool("set_drum_synth", {
|
|
|
2561
4159
|
delta[`cell${cellId}_synthVolume`] = volume;
|
|
2562
4160
|
// synthEngine is a graph-render key → needs a graph dispatch (KV_OPTS_STANDARD).
|
|
2563
4161
|
await applyKv(delta, KV_OPTS_STANDARD);
|
|
2564
|
-
const labels =
|
|
4162
|
+
const labels = desc ? desc.params.map((p) => p.label).join(",") : "—";
|
|
2565
4163
|
return text(`Cell ${cellId} drum synth → ${engine}${engineId > 0 ? ` (p1..pN = ${labels})` : ""}.`);
|
|
2566
4164
|
});
|
|
4165
|
+
server.registerTool("set_drum_synth_wave", {
|
|
4166
|
+
title: "Set Drum Synth Wave Curve (Wave engine)",
|
|
4167
|
+
description: "Set the custom single-cycle waveform for a cell's drum-synth WAVE engine (engine #16). " +
|
|
4168
|
+
"Provide 2–16 breakpoints { t, v, tension } (t = phase 0..1, v = amplitude 0..1 with 0.5 the zero-crossing " +
|
|
4169
|
+
"centre, tension −1..+1 with 0 = linear). PAM bakes them into a DC-removed, peak-normalized single cycle " +
|
|
4170
|
+
"that the Wave engine's Draw param (p8) crossfades against the factory bank. Only the Wave engine reads it — " +
|
|
4171
|
+
"select 'Wave' via set_drum_synth and raise Draw (p8) to hear it. Runs in the WebView (the curve transform " +
|
|
4172
|
+
"is single-sourced there), so PAM's window must be OPEN. Needs PAM ≥ 1.13.0.",
|
|
4173
|
+
inputSchema: {
|
|
4174
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
4175
|
+
points: z
|
|
4176
|
+
.array(z.object({
|
|
4177
|
+
t: z.number().min(0).max(1).describe("Phase position 0..1 (left→right)."),
|
|
4178
|
+
v: z.number().min(0).max(1).describe("Amplitude 0..1 (0.5 = zero-crossing centre)."),
|
|
4179
|
+
tension: z.number().min(-1).max(1).default(0).describe("Segment curve −1..+1 (0 = linear)."),
|
|
4180
|
+
}))
|
|
4181
|
+
.min(2)
|
|
4182
|
+
.max(16)
|
|
4183
|
+
.describe("2–16 breakpoints defining the single cycle, ordered left→right by t."),
|
|
4184
|
+
},
|
|
4185
|
+
}, async ({ cellId, points }) => {
|
|
4186
|
+
const check = await requireBridge();
|
|
4187
|
+
if (check !== true)
|
|
4188
|
+
return check;
|
|
4189
|
+
const manifest = await loadManifestForProductGuard();
|
|
4190
|
+
const cellCheck = validateCellIdForManifest(cellId, manifest);
|
|
4191
|
+
if (cellCheck !== true)
|
|
4192
|
+
return cellCheck;
|
|
4193
|
+
if (manifest && manifest.drumSynth?.enabled !== true) {
|
|
4194
|
+
return error("set_drum_synth_wave is PAM-only. This product manifest does not expose the built-in drum synth.");
|
|
4195
|
+
}
|
|
4196
|
+
await bridge.send("setDrumSynthWaveCurve", {
|
|
4197
|
+
cellId,
|
|
4198
|
+
points: points.map((p) => ({ t: p.t, v: p.v, tension: p.tension ?? 0 })),
|
|
4199
|
+
});
|
|
4200
|
+
return text(`Set cell ${cellId} Wave-engine custom waveform (${points.length} breakpoints). ` +
|
|
4201
|
+
`Select the Wave engine and raise Draw (p8) to hear it. (Runs in the WebView — ensure PAM's window is open.)`);
|
|
4202
|
+
});
|
|
2567
4203
|
server.registerTool("add_param_seq", {
|
|
2568
4204
|
title: "Add Parameter Sequencer",
|
|
2569
4205
|
description: "Add a parameter sequencer (step modulation) targeting any modulatable parameter. " +
|
|
@@ -2586,8 +4222,11 @@ server.registerTool("add_param_seq", {
|
|
|
2586
4222
|
if (check !== true)
|
|
2587
4223
|
return check;
|
|
2588
4224
|
const index = await getParameterIndex();
|
|
2589
|
-
|
|
2590
|
-
|
|
4225
|
+
const param = index.get(paramId);
|
|
4226
|
+
if (!param)
|
|
4227
|
+
return error(formatUnknownParamError(paramId, index, { modulatableOnly: true }));
|
|
4228
|
+
if (!param.isModulatable)
|
|
4229
|
+
return error(formatNotModulatableError(paramId, index));
|
|
2591
4230
|
const existing = await bridge.send("getState", "paramSeqs");
|
|
2592
4231
|
const paramSeqs = (Array.isArray(existing) ? existing : []).filter((seq) => seq?.paramId !== paramId);
|
|
2593
4232
|
const newSeq = {
|
|
@@ -2696,11 +4335,30 @@ async function pollExportStatus(maxWaitMs = 180_000, pollMs = 500) {
|
|
|
2696
4335
|
while (Date.now() - t0 < maxWaitMs) {
|
|
2697
4336
|
await new Promise((r) => setTimeout(r, pollMs));
|
|
2698
4337
|
const status = (await bridge.send("getExportStatus"));
|
|
2699
|
-
if (status.complete)
|
|
4338
|
+
if (status.complete) {
|
|
4339
|
+
lastExportLoudness = status.loudness ?? null;
|
|
2700
4340
|
return status.success ? "success" : "failed";
|
|
4341
|
+
}
|
|
2701
4342
|
}
|
|
2702
4343
|
return "timeout";
|
|
2703
4344
|
}
|
|
4345
|
+
// Loudness report of the last completed bounce (set by pollExportStatus).
|
|
4346
|
+
let lastExportLoudness = null;
|
|
4347
|
+
function loudnessReportLine() {
|
|
4348
|
+
const l = lastExportLoudness;
|
|
4349
|
+
if (!l || typeof l.i !== "number")
|
|
4350
|
+
return "";
|
|
4351
|
+
const parts = [`${l.i.toFixed(1)} LUFS-I`];
|
|
4352
|
+
if (typeof l.lra === "number")
|
|
4353
|
+
parts.push(`LRA ${l.lra.toFixed(1)}`);
|
|
4354
|
+
if (typeof l.tp === "number")
|
|
4355
|
+
parts.push(`TP ${l.tp.toFixed(1)} dBTP`);
|
|
4356
|
+
if (typeof l.psr === "number")
|
|
4357
|
+
parts.push(`PSR ${l.psr.toFixed(1)}`);
|
|
4358
|
+
if (typeof l.plr === "number")
|
|
4359
|
+
parts.push(`PLR ${l.plr.toFixed(1)}`);
|
|
4360
|
+
return `\nLoudness: ${parts.join(" · ")}`;
|
|
4361
|
+
}
|
|
2704
4362
|
server.registerTool("export_audio", {
|
|
2705
4363
|
title: "Export Audio",
|
|
2706
4364
|
description: "Render and export audio from the current preset to a WAV file in PAM's Exports folder. " +
|
|
@@ -2709,9 +4367,9 @@ server.registerTool("export_audio", {
|
|
|
2709
4367
|
"Modes: 'main' (full mix, default) · 'individual' (one cell solo, needs cellId) · " +
|
|
2710
4368
|
"'all-cells' / 'selected-cells' (per-cell stems, the latter needs selectedCellIds) · " +
|
|
2711
4369
|
"'per-bus' (per output bus) · 'song-mode' (the Song Mode timeline).\n\n" +
|
|
2712
|
-
"NOTE on 'song-mode': the
|
|
2713
|
-
"
|
|
2714
|
-
"
|
|
4370
|
+
"NOTE on 'song-mode': the offline bounce applies EVERY section on its anchor (PAM ≥ 1.5.7 with " +
|
|
4371
|
+
"offline song export; older builds render only the first section). Wall-clock sources " +
|
|
4372
|
+
"(Magenta/Notochord) render silent offline — use the `export_song` tool's live strategy for those.",
|
|
2715
4373
|
inputSchema: {
|
|
2716
4374
|
bars: z.number().min(1).max(128).default(4).describe("Bars to render (ignored for 'song-mode', which derives length from the timeline)."),
|
|
2717
4375
|
cellId: z
|
|
@@ -2756,7 +4414,8 @@ server.registerTool("export_audio", {
|
|
|
2756
4414
|
const outcome = await pollExportStatus();
|
|
2757
4415
|
if (outcome === "success")
|
|
2758
4416
|
return text(`Audio exported successfully.\nFile: ${outputPath}\n` +
|
|
2759
|
-
`Duration: ${durationSecs.toFixed(1)}s (${bars} bars at ${bpm} BPM, ${bitDepth}-bit)`
|
|
4417
|
+
`Duration: ${durationSecs.toFixed(1)}s (${bars} bars at ${bpm} BPM, ${bitDepth}-bit)` +
|
|
4418
|
+
loudnessReportLine());
|
|
2760
4419
|
if (outcome === "failed")
|
|
2761
4420
|
return error("Audio export failed.");
|
|
2762
4421
|
return text(`Export timed out. The render may still be running.\nExpected output: ${outputPath}`);
|
|
@@ -2787,6 +4446,7 @@ server.registerTool("export_audio", {
|
|
|
2787
4446
|
if (outcome === "success")
|
|
2788
4447
|
return text(`Export complete (mode '${effectiveMode}').\n` +
|
|
2789
4448
|
`File(s) written to PAM's Exports folder, base name: ${filename}.wav` +
|
|
4449
|
+
loudnessReportLine() +
|
|
2790
4450
|
(effectiveMode === "song-mode"
|
|
2791
4451
|
? "\n\nReminder: the offline song-mode bounce captures only the first section's state. " +
|
|
2792
4452
|
"Use export_song for a full track with section changes."
|
|
@@ -2905,6 +4565,24 @@ Use \`update_pattern\` with \`type: "slice"\` to sequence slice changes over tim
|
|
|
2905
4565
|
- Different patternSpeed creates rate variation: slice lane at 2x speed = double-time slice changes
|
|
2906
4566
|
- Combine with \`prob\` field (0-1) on events for controlled randomness
|
|
2907
4567
|
|
|
4568
|
+
### Slice Keymap — Trigger Slices from MIDI
|
|
4569
|
+
Map incoming MIDI notes directly to slices (Octatrack / Geist style): the cell's Root note
|
|
4570
|
+
plays slice 1, root+1 semitone plays slice 2, and so on chromatically up. The slice
|
|
4571
|
+
sequencer lane above is ignored while this is on — the note picks the slice.
|
|
4572
|
+
|
|
4573
|
+
\`\`\`
|
|
4574
|
+
update_cell_config: cellId=1, sliceKeymapMode=1, polyRootNote=60, sliceTriggerLength=0
|
|
4575
|
+
trigger_midi: cellId=1, note=60 # -> slice 1
|
|
4576
|
+
trigger_midi: cellId=1, note=62 # -> slice 3
|
|
4577
|
+
\`\`\`
|
|
4578
|
+
- Requires the cell's sample to be sliced first. Notes outside the slice range are silent.
|
|
4579
|
+
- \`polyRootNote\` = the key that triggers slice 1.
|
|
4580
|
+
- \`sliceTriggerLength\`: 0=Boundary (stop at next slice edge), 1=Sample End, 2=Note Off (needs cell mode=Gate).
|
|
4581
|
+
- Set \`isPoly=1\` for polyphonic slice play (held notes layer); leave poly off for monophonic
|
|
4582
|
+
jamming (each new note chokes the previous slice). Slice Keymap works in both.
|
|
4583
|
+
- Each slice plays at its own per-slice pitch, so the keymap performs a chopped sample at
|
|
4584
|
+
native rate. Played notes can be recorded into the cell's MIDI sequence and looped back.
|
|
4585
|
+
|
|
2908
4586
|
## Sidechain Compression — The Pump
|
|
2909
4587
|
|
|
2910
4588
|
### Setup via MCP
|
|
@@ -3026,7 +4704,7 @@ Variations exclude: global volume, looper state.
|
|
|
3026
4704
|
|
|
3027
4705
|
## Master Effects — The Final Stage
|
|
3028
4706
|
|
|
3029
|
-
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
|
|
3030
4708
|
|
|
3031
4709
|
### Punch Compressor (Musical Bus Comp)
|
|
3032
4710
|
\`\`\`
|
|
@@ -3147,7 +4825,8 @@ update_cell_config: cellId=5, scaleName="pentatonicMinor", scaleRoot=0
|
|
|
3147
4825
|
\`\`\`
|
|
3148
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
|
|
3149
4827
|
|
|
3150
|
-
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.)
|
|
3151
4830
|
|
|
3152
4831
|
### Playback Modes
|
|
3153
4832
|
\`cell{N}_mode\`: 0=Trigger (one-shot), 1=Gate (plays while held, default), 2=Loop (continuous)
|
|
@@ -3186,6 +4865,89 @@ 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,
|
|
|
3186
4865
|
],
|
|
3187
4866
|
}));
|
|
3188
4867
|
// ============================================================
|
|
4868
|
+
// PROMPTS — guided workflows (client-invokable, e.g. slash
|
|
4869
|
+
// commands in Claude Code). Each stages the same flows the
|
|
4870
|
+
// README and tool descriptions teach, with exact tool names.
|
|
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).
|
|
4875
|
+
const GENRE_SUGGESTIONS = ["boom_bap", "trap", "drill", "house", "techno", "dnb"];
|
|
4876
|
+
const completeGenre = (value) => GENRE_SUGGESTIONS.filter((g) => g.startsWith((value ?? "").toLowerCase()));
|
|
4877
|
+
// completable() tags the schema OBJECT with a symbol, so ordering is delicate:
|
|
4878
|
+
// .describe()/.optional() clone schemas and drop the tag, SDK 1.29 unwraps
|
|
4879
|
+
// optionals when DETECTING completable prompt args but not when RESOLVING the
|
|
4880
|
+
// completer at request time — the tag must sit on both the inner type and the
|
|
4881
|
+
// outer optional. Covered by the completion assertion in check-tool-surface's
|
|
4882
|
+
// sibling smoke; revisit when the SDK unifies the two lookups.
|
|
4883
|
+
const genreArg = completable(completable(z.string().describe("Genre hint, e.g. techno, house, dnb, trap, boom_bap, drill."), completeGenre).optional(), completeGenre);
|
|
4884
|
+
function promptText(text) {
|
|
4885
|
+
return {
|
|
4886
|
+
messages: [
|
|
4887
|
+
{ role: "user", content: { type: "text", text } },
|
|
4888
|
+
],
|
|
4889
|
+
};
|
|
4890
|
+
}
|
|
4891
|
+
server.registerPrompt("build-a-beat", {
|
|
4892
|
+
title: "Build a Beat",
|
|
4893
|
+
description: "Guided kit-building + pattern workflow: coherent samples, genre patterns, transport, refinement, save.",
|
|
4894
|
+
argsSchema: {
|
|
4895
|
+
genre: genreArg,
|
|
4896
|
+
bpm: z.string().optional().describe("Target tempo in BPM."),
|
|
4897
|
+
},
|
|
4898
|
+
}, ({ genre, bpm }) => promptText(`Build a beat in PAM${genre ? ` in the style of ${genre}` : ""}${bpm ? ` at ${bpm} BPM` : ""}. Work in stages, listening between them:\n\n` +
|
|
4899
|
+
`1. connection_status to confirm the bridge, then get_design_overview for the current state.\n` +
|
|
4900
|
+
`2. Load a kit: randomize_samples${genre ? ` with genreHint '${genre}'` : " (pass a genreHint)"} — the native engine picks role-coherent samples; prefer it over manual browsing. For specific sounds: get_default_locations → list_samples → load_sample.\n` +
|
|
4901
|
+
`3. ${bpm ? `set_bpm to ${bpm}` : "set_bpm to the genre's tempo"}, then randomize_patterns to generate genre-appropriate patterns from the loaded sample roles.\n` +
|
|
4902
|
+
`4. start_transport — sequencer output needs the transport rolling.\n` +
|
|
4903
|
+
`5. Refine: update_pattern for individual lanes, set_parameters for levels/filters (ranges via design_context), update_effect_config to switch FX on, add_modulation for movement.\n` +
|
|
4904
|
+
`6. save_preset once it works, and update_preset_metadata with genre tags and BPM.`));
|
|
4905
|
+
server.registerPrompt("make-a-song", {
|
|
4906
|
+
title: "Make a Song",
|
|
4907
|
+
description: "Arrange the current preset into sections with variations + Song Mode, then bounce a full track.",
|
|
4908
|
+
argsSchema: {
|
|
4909
|
+
bars: z.string().optional().describe("Approximate song length in bars."),
|
|
4910
|
+
},
|
|
4911
|
+
}, ({ bars }) => promptText(`Turn the current PAM preset into an arranged song${bars ? ` of roughly ${bars} bars` : ""}:\n\n` +
|
|
4912
|
+
`1. Design 2-4 contrasting sections (intro / groove / peak / outro): shape the state for each, then save_variation into slots 0-7.\n` +
|
|
4913
|
+
`2. Arrange with song_mode op 'setArrangement' — an ordered sections list ({variationIndex, bars, repeat}, minimum 2 sections). Read it back with op 'get'.\n` +
|
|
4914
|
+
`3. Audition: start_transport with Song Mode driving the timeline; queue_variation switches sections live.\n` +
|
|
4915
|
+
`4. Bounce with export_song — strategy 'live' captures the ground-truth master mix (required for Magenta/Notochord/automation), strategy 'offline' is the deterministic multi-section bounce with no bar cap. includeVideo:true films the VX scene (window must be open).\n` +
|
|
4916
|
+
`5. The rendered file lands in PAM's Exports/Resamples locations — get_default_locations resolves them.`));
|
|
4917
|
+
server.registerPrompt("design-a-preset", {
|
|
4918
|
+
title: "Design a Preset",
|
|
4919
|
+
description: "Sound-design workflow from a creative brief to a saved preset.",
|
|
4920
|
+
argsSchema: {
|
|
4921
|
+
brief: z.string().describe("Creative brief, e.g. 'dusty lo-fi kit with a wobbly sub'."),
|
|
4922
|
+
},
|
|
4923
|
+
}, ({ brief }) => promptText(`Design a PAM preset from this brief: "${brief}"\n\n` +
|
|
4924
|
+
`1. Read design_context (curated parameter cheat sheet) and get_design_overview first — avoid full get_state dumps.\n` +
|
|
4925
|
+
`2. Source sounds: randomize_samples with a genre hint, or get_default_locations → list_samples → load_sample. generate_sample creates bespoke hits (AI engines need update_settings experimental flags + a downloaded model).\n` +
|
|
4926
|
+
`3. Shape each cell: set_parameters (design_context teaches ranges and units), update_effect_config for FX chains, set_drum_synth for synthesized drum layers.\n` +
|
|
4927
|
+
`4. Add movement: add_modulation (LFO / envelope / vary), add_param_seq for stepped motion.\n` +
|
|
4928
|
+
`5. Write patterns with update_pattern, then start_transport to audition.\n` +
|
|
4929
|
+
`6. save_preset with a fitting name and update_preset_metadata (category, tags, BPM).`));
|
|
4930
|
+
server.registerPrompt("mix-and-master", {
|
|
4931
|
+
title: "Mix and Master",
|
|
4932
|
+
description: "Balance the cells and shape the master bus using the analysis + suggestion tools.",
|
|
4933
|
+
argsSchema: {},
|
|
4934
|
+
}, () => promptText(`Balance and master the current PAM project:\n\n` +
|
|
4935
|
+
`1. get_mix_analysis with refresh:true (offline per-cell stem measurements — takes a few seconds) and get_master_analysis (live loudness snapshot).\n` +
|
|
4936
|
+
`2. suggest_mix for rule-based balance / pan / EQ / dynamics moves; apply the returned parameter moves with set_parameters and enable keys with update_effect_config.\n` +
|
|
4937
|
+
`3. Shape the master bus with set_master_effects (compressor, tape, limiter — design_context lists the keys).\n` +
|
|
4938
|
+
`4. Re-check get_master_analysis and iterate toward the loudness target.\n` +
|
|
4939
|
+
`5. Render with export_audio (mode 'main'; 'all-cells' for stems).`));
|
|
4940
|
+
server.registerPrompt("troubleshoot-bridge", {
|
|
4941
|
+
title: "Troubleshoot the PAM Bridge",
|
|
4942
|
+
description: "Diagnose why live tools can't reach the plugin.",
|
|
4943
|
+
argsSchema: {},
|
|
4944
|
+
}, () => promptText(`Diagnose the PAM MCP connection:\n\n` +
|
|
4945
|
+
`1. connection_status. No instances → PAM isn't running or the bridge is off (PAM Settings → Experimental Mode → MCP Bridge, default port 9847). Multiple instances → select_instance picks which product the live tools drive.\n` +
|
|
4946
|
+
`2. Instances found but not connected → the plugin may have shut down, or its message thread is busy (heavy render); retry once.\n` +
|
|
4947
|
+
`3. Timeouts on WebView-routed tools (video tools, capture_ui, set_drum_synth_wave, randomize target 'synth') → the plugin window must be OPEN.\n` +
|
|
4948
|
+
`4. "PAM build does not support 'X'" errors → the plugin is older than this server; the user must update PAM (updating the npm package alone is not enough).\n` +
|
|
4949
|
+
`5. Offline tools always work without a plugin: get_parameter_info, design_context, list_presets, read_preset, get_manifest_summary.`));
|
|
4950
|
+
// ============================================================
|
|
3189
4951
|
// TOOLS — Looper (state deltas)
|
|
3190
4952
|
// ============================================================
|
|
3191
4953
|
// applyStateDelta-equivalent options: full update, dispatch graph, sync KV.
|
|
@@ -3297,7 +5059,7 @@ server.registerTool("set_looper_track", {
|
|
|
3297
5059
|
if (typeof rest.sourceCell === "number")
|
|
3298
5060
|
trackDelta.sourceCell = rest.sourceCell;
|
|
3299
5061
|
if (typeof rest.autoSelect === "boolean")
|
|
3300
|
-
trackDelta.autoSelect = rest.autoSelect
|
|
5062
|
+
trackDelta.autoSelect = rest.autoSelect;
|
|
3301
5063
|
if (typeof rest.recordedBpm === "number")
|
|
3302
5064
|
trackDelta.recordedBpm = rest.recordedBpm;
|
|
3303
5065
|
if (Object.keys(trackDelta).length === 0)
|
|
@@ -3378,6 +5140,10 @@ server.registerTool("start_looper_recording", {
|
|
|
3378
5140
|
const check = await requireBridge();
|
|
3379
5141
|
if (check !== true)
|
|
3380
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 }])));
|
|
3381
5147
|
const meta = trackMetadata ??
|
|
3382
5148
|
armedCellIds.map((cellId) => ({
|
|
3383
5149
|
cellId,
|
|
@@ -3593,7 +5359,9 @@ server.registerTool("looper_variation_sync", {
|
|
|
3593
5359
|
server.registerTool("queue_variation", {
|
|
3594
5360
|
title: "Queue Variation Switch",
|
|
3595
5361
|
description: "Schedule a global variation switch on the next bar boundary. " +
|
|
3596
|
-
"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).",
|
|
3597
5365
|
inputSchema: {
|
|
3598
5366
|
index: z.number().min(0).max(7).describe("Variation slot 0-7."),
|
|
3599
5367
|
intervalBars: z.number().min(0.125).max(64).describe("Bars before switching."),
|
|
@@ -3602,13 +5370,48 @@ server.registerTool("queue_variation", {
|
|
|
3602
5370
|
.boolean()
|
|
3603
5371
|
.default(true)
|
|
3604
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)."),
|
|
3605
5398
|
},
|
|
3606
|
-
}, async ({ index, intervalBars, fadeMs, resetPhase }) => {
|
|
5399
|
+
}, async ({ index, intervalBars, fadeMs, resetPhase, morphBars, morphMode, morphCurve, transition }) => {
|
|
3607
5400
|
const check = await requireBridge();
|
|
3608
5401
|
if (check !== true)
|
|
3609
5402
|
return check;
|
|
3610
|
-
await bridge.send("queueVariationOnBars", {
|
|
3611
|
-
|
|
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}.`);
|
|
3612
5415
|
});
|
|
3613
5416
|
server.registerTool("cancel_queued_variation", {
|
|
3614
5417
|
title: "Cancel Queued Variation",
|
|
@@ -3762,16 +5565,17 @@ server.registerTool("midi_recording", {
|
|
|
3762
5565
|
// ============================================================
|
|
3763
5566
|
server.registerTool("update_modulation", {
|
|
3764
5567
|
title: "Update Modulation",
|
|
3765
|
-
description: "Apply a delta to a modulation source: LFO, Envelope, Vary, Macro, ParamSeq config,
|
|
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. " +
|
|
3766
5570
|
"The payload is type-specific; see modulation update flows in interop.ts (applyLfoUpdate / updateEnvelope / etc.). " +
|
|
3767
5571
|
"Use add_modulation to first attach a source to a target — then this tool to edit it.",
|
|
3768
5572
|
inputSchema: {
|
|
3769
5573
|
type: z
|
|
3770
|
-
.enum(["lfo", "envelope", "vary", "macro", "paramSeq", "paramSeqSequence"])
|
|
5574
|
+
.enum(["lfo", "envelope", "vary", "macro", "paramSeq", "paramSeqSequence", "freeSeqSequence"])
|
|
3771
5575
|
.describe("Which modulation system to update."),
|
|
3772
5576
|
payload: z
|
|
3773
5577
|
.record(z.string(), z.unknown())
|
|
3774
|
-
.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."),
|
|
3775
5579
|
},
|
|
3776
5580
|
}, async ({ type, payload }) => {
|
|
3777
5581
|
const check = await requireBridge();
|
|
@@ -3788,20 +5592,82 @@ server.registerTool("update_modulation", {
|
|
|
3788
5592
|
? "applyMacroUpdate"
|
|
3789
5593
|
: type === "paramSeq"
|
|
3790
5594
|
? "applyParamSeqUpdate"
|
|
3791
|
-
: "
|
|
5595
|
+
: type === "paramSeqSequence"
|
|
5596
|
+
? "applyParamSeqSequenceUpdate"
|
|
5597
|
+
: "applyFreeSeqSequenceUpdate";
|
|
3792
5598
|
await bridge.send(action, data);
|
|
3793
5599
|
return text(`Applied ${type} update.`);
|
|
3794
5600
|
});
|
|
3795
5601
|
// ============================================================
|
|
3796
5602
|
// TOOLS — Song mode
|
|
3797
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).";
|
|
3798
5644
|
server.registerTool("song_mode", {
|
|
3799
5645
|
title: "Song Mode",
|
|
3800
|
-
description: "Control PAM's song mode (timeline of variation/preset
|
|
3801
|
-
"'
|
|
5646
|
+
description: "Control PAM's song mode (timeline of variation/preset sections). Ops: 'setArrangement', " +
|
|
5647
|
+
"'get', 'assignVx', 'setSequence', 'clear', 'toggle', 'setLoopPoints', 'toggleLoop', " +
|
|
5648
|
+
"'setCrossfadeMs', 'setPlayhead'.\n\n" +
|
|
5649
|
+
"PREFER 'setArrangement' (PAM ≥ 1.5.7, window open): it authors real Song Mode grid nodes " +
|
|
5650
|
+
"from a `sections` list, with an optional weighted `connections` graph. The result is " +
|
|
5651
|
+
"visible/editable in the UI, and each section can pin a " +
|
|
5652
|
+
"VX (visual) variation slot via `vxVariationIndex` (omit = visuals follow the section's " +
|
|
5653
|
+
"audio slot 1:1; see the vx_variation tool), optionally inside a specific VX scene via " +
|
|
5654
|
+
"`vxScenePath` (the .vxpreset loads before the section plays, so sections can pull visuals " +
|
|
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" +
|
|
5662
|
+
"'setSequence' is the older HEADLESS raw-step path (works with the window closed): steps " +
|
|
5663
|
+
"land in the C++ player but carry no grid nodes, so the UI can't edit them and VX can only " +
|
|
5664
|
+
"follow 1:1 by audio slot.",
|
|
3802
5665
|
inputSchema: {
|
|
3803
5666
|
op: z
|
|
3804
5667
|
.enum([
|
|
5668
|
+
"setArrangement",
|
|
5669
|
+
"get",
|
|
5670
|
+
"assignVx",
|
|
3805
5671
|
"setSequence",
|
|
3806
5672
|
"clear",
|
|
3807
5673
|
"toggle",
|
|
@@ -3811,6 +5677,96 @@ server.registerTool("song_mode", {
|
|
|
3811
5677
|
"setPlayhead",
|
|
3812
5678
|
])
|
|
3813
5679
|
.describe("Operation."),
|
|
5680
|
+
sections: z
|
|
5681
|
+
.array(z.object({
|
|
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."),
|
|
5683
|
+
bars: z.number().positive().optional().describe("Section length in bars (default: auto from longest pattern)."),
|
|
5684
|
+
repeat: z.number().int().min(1).optional().describe("Times to play before advancing (default 1)."),
|
|
5685
|
+
muted: z.boolean().optional().describe("Pass through without an audio trigger."),
|
|
5686
|
+
probability: z.number().min(0).max(1).optional().describe("Trigger probability (0-1)."),
|
|
5687
|
+
presetPath: z.string().optional().describe("Cross-preset section: at this section's boundary the song switches to THIS preset file, playing its variations[variationIndex] slot. With lanePresetSections on (PAM >= 1.20.1) it transitions exactly like a variation section (prepared off the audible path, full morph family); otherwise the legacy in-place load applies."),
|
|
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),
|
|
5704
|
+
vxVariationIndex: z
|
|
5705
|
+
.number()
|
|
5706
|
+
.int()
|
|
5707
|
+
.min(0)
|
|
5708
|
+
.max(7)
|
|
5709
|
+
.optional()
|
|
5710
|
+
.describe("VX variation slot recalled while this section plays (omit = follow the audio slot)."),
|
|
5711
|
+
vxScenePath: z
|
|
5712
|
+
.string()
|
|
5713
|
+
.optional()
|
|
5714
|
+
.describe("VX scene (.vxpreset path) whose slots vxVariationIndex indexes into — loaded before the " +
|
|
5715
|
+
"section plays, so sections can pull visuals from different scenes. Requires vxVariationIndex; " +
|
|
5716
|
+
"omit = the live scene's slots."),
|
|
5717
|
+
}))
|
|
5718
|
+
.min(1)
|
|
5719
|
+
.optional()
|
|
5720
|
+
.describe("For 'setArrangement': section list. Without `connections`, sections play in order."),
|
|
5721
|
+
connections: z
|
|
5722
|
+
.array(z.object({
|
|
5723
|
+
from: z.number().int().min(0).describe("Source section index."),
|
|
5724
|
+
to: z.number().int().min(0).describe("Destination section index."),
|
|
5725
|
+
probability: z
|
|
5726
|
+
.number()
|
|
5727
|
+
.min(0)
|
|
5728
|
+
.max(1)
|
|
5729
|
+
.optional()
|
|
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),
|
|
5746
|
+
}))
|
|
5747
|
+
.optional()
|
|
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),
|
|
5753
|
+
loop: z.boolean().optional().describe("For 'setArrangement': loop the whole arrangement."),
|
|
5754
|
+
nodeId: z.string().optional().describe("For 'assignVx': grid node id (from op 'get')."),
|
|
5755
|
+
stepIndex: z.number().int().min(0).optional().describe("For 'assignVx': compiled step index (alternative to nodeId)."),
|
|
5756
|
+
vxIndex: z
|
|
5757
|
+
.number()
|
|
5758
|
+
.int()
|
|
5759
|
+
.min(0)
|
|
5760
|
+
.max(7)
|
|
5761
|
+
.nullable()
|
|
5762
|
+
.optional()
|
|
5763
|
+
.describe("For 'assignVx': VX slot 0-7, or null to clear (follow the audio slot)."),
|
|
5764
|
+
vxScenePath: z
|
|
5765
|
+
.string()
|
|
5766
|
+
.nullable()
|
|
5767
|
+
.optional()
|
|
5768
|
+
.describe("For 'assignVx': pin the scene (.vxpreset path) vxIndex indexes into (requires vxIndex; " +
|
|
5769
|
+
"null/'' clears the pin → the live scene's slots; omit = keep the node's current pin)."),
|
|
3814
5770
|
steps: z
|
|
3815
5771
|
.array(z.object({
|
|
3816
5772
|
variationIndex: z.number().min(0).max(7).describe("Variation slot 0-7."),
|
|
@@ -3824,8 +5780,8 @@ server.registerTool("song_mode", {
|
|
|
3824
5780
|
probability: z.number().min(0).max(1).optional().describe("Trigger probability (0-1)."),
|
|
3825
5781
|
}))
|
|
3826
5782
|
.optional()
|
|
3827
|
-
.describe("For 'setSequence': step list."),
|
|
3828
|
-
enabled: z.boolean().optional().describe("For 'toggle' / 'toggleLoop': enabled state."),
|
|
5783
|
+
.describe("For 'setSequence': raw step list (headless path; prefer 'setArrangement')."),
|
|
5784
|
+
enabled: z.boolean().optional().describe("For 'toggle' / 'toggleLoop' / 'setArrangement': enabled state."),
|
|
3829
5785
|
start: z
|
|
3830
5786
|
.number()
|
|
3831
5787
|
.nullable()
|
|
@@ -3849,6 +5805,66 @@ server.registerTool("song_mode", {
|
|
|
3849
5805
|
if (check !== true)
|
|
3850
5806
|
return check;
|
|
3851
5807
|
switch (args.op) {
|
|
5808
|
+
case "setArrangement": {
|
|
5809
|
+
if (!args.sections || args.sections.length === 0)
|
|
5810
|
+
return error("'setArrangement' requires sections");
|
|
5811
|
+
await sendVideoCommand({
|
|
5812
|
+
op: "setSongArrangement",
|
|
5813
|
+
sections: args.sections,
|
|
5814
|
+
...(args.connections !== undefined && { connections: args.connections }),
|
|
5815
|
+
...(args.defaultTransition !== undefined && { defaultTransition: args.defaultTransition }),
|
|
5816
|
+
...(args.loop !== undefined && { loop: args.loop }),
|
|
5817
|
+
...(args.enabled !== undefined && { enabled: args.enabled }),
|
|
5818
|
+
...(args.crossfadeMs !== undefined && { crossfadeMs: args.crossfadeMs }),
|
|
5819
|
+
});
|
|
5820
|
+
// The forward path is fire-and-forget — poll until the WebView's apply
|
|
5821
|
+
// republishes the snapshot (same pattern as resolveNewId).
|
|
5822
|
+
const deadline = Date.now() + 3000;
|
|
5823
|
+
let sm;
|
|
5824
|
+
for (;;) {
|
|
5825
|
+
const state = await getVideoStateRaw();
|
|
5826
|
+
sm = state.songMode;
|
|
5827
|
+
const landed = sm?.available === true &&
|
|
5828
|
+
Array.isArray(sm.nodes) &&
|
|
5829
|
+
sm.nodes.length === args.sections.length;
|
|
5830
|
+
if (landed || Date.now() > deadline)
|
|
5831
|
+
break;
|
|
5832
|
+
await sleep(150);
|
|
5833
|
+
}
|
|
5834
|
+
if (!sm || sm.available !== true)
|
|
5835
|
+
return error("Arrangement command sent, but no song-mode read-model came back — this needs PAM ≥ 1.5.7 " +
|
|
5836
|
+
"with the window OPEN. On older builds fall back to op 'setSequence' (headless raw steps).");
|
|
5837
|
+
const nodes = Array.isArray(sm.nodes) ? sm.nodes.length : 0;
|
|
5838
|
+
if (nodes !== args.sections.length)
|
|
5839
|
+
return error(`Arrangement did not land within 3s (${nodes} nodes vs ${args.sections.length} sections) — the ` +
|
|
5840
|
+
"command may have been rejected (variationIndex/vxVariationIndex must be 0-7; bars must be greater than 0).");
|
|
5841
|
+
return text(`Song arrangement set: ${nodes} sections, ${Array.isArray(sm.steps) ? sm.steps.length : 0} steps, ` +
|
|
5842
|
+
`${String(sm.arrangementBars)} bars${args.loop ? ", looped" : ""}${args.enabled ? ", song mode ON" : ""}. ` +
|
|
5843
|
+
"Read it back with op 'get'.");
|
|
5844
|
+
}
|
|
5845
|
+
case "get": {
|
|
5846
|
+
const state = await getVideoStateRaw();
|
|
5847
|
+
if (state.available === false)
|
|
5848
|
+
return error("VX/song read-model unavailable — open PAM's window (WebView-routed).");
|
|
5849
|
+
const sm = state.songMode;
|
|
5850
|
+
if (!sm)
|
|
5851
|
+
return error("No songMode read-model in the snapshot — the connected PAM predates it (needs ≥ 1.5.7).");
|
|
5852
|
+
return json(sm);
|
|
5853
|
+
}
|
|
5854
|
+
case "assignVx": {
|
|
5855
|
+
if (args.nodeId === undefined && args.stepIndex === undefined)
|
|
5856
|
+
return error("'assignVx' requires nodeId or stepIndex");
|
|
5857
|
+
await sendVideoCommand({
|
|
5858
|
+
op: "setSongNodeVx",
|
|
5859
|
+
...(args.nodeId !== undefined && { nodeId: args.nodeId }),
|
|
5860
|
+
...(args.stepIndex !== undefined && { stepIndex: args.stepIndex }),
|
|
5861
|
+
index: args.vxIndex ?? null,
|
|
5862
|
+
...(args.vxScenePath !== undefined && { vxScenePath: args.vxScenePath }),
|
|
5863
|
+
});
|
|
5864
|
+
return text(`VX assignment sent (${args.nodeId ?? `step ${args.stepIndex}`} → ${args.vxIndex ?? "follow audio"}` +
|
|
5865
|
+
`${args.vxIndex != null && args.vxScenePath ? ` in ${args.vxScenePath}` : ""}). ` +
|
|
5866
|
+
"Verify with op 'get' (nodes[].vxVariationIndex / vxScenePath).");
|
|
5867
|
+
}
|
|
3852
5868
|
case "setSequence":
|
|
3853
5869
|
if (!args.steps)
|
|
3854
5870
|
return error("'setSequence' requires steps");
|
|
@@ -3888,17 +5904,24 @@ server.registerTool("song_mode", {
|
|
|
3888
5904
|
});
|
|
3889
5905
|
server.registerTool("export_song", {
|
|
3890
5906
|
title: "Export Song (Full Track)",
|
|
3891
|
-
description: "Bounce the full Song Mode arrangement
|
|
3892
|
-
"Set up the arrangement first with the `song_mode` tool (
|
|
3893
|
-
"
|
|
3894
|
-
"• 'live'
|
|
3895
|
-
"
|
|
3896
|
-
"
|
|
3897
|
-
"
|
|
3898
|
-
"
|
|
3899
|
-
"
|
|
3900
|
-
"
|
|
3901
|
-
"
|
|
5907
|
+
description: "Bounce the full Song Mode arrangement — the 'render my whole track' button. " +
|
|
5908
|
+
"Set up the arrangement first with the `song_mode` tool (prefer op setArrangement), then call this.\n\n" +
|
|
5909
|
+
"Strategies:\n" +
|
|
5910
|
+
"• 'live' + includeVideo:true — full A/V render: plays the arrangement in real time, films the VX " +
|
|
5911
|
+
"scene (VX variation follow runs live, so per-section visuals land exactly as assigned) and muxes " +
|
|
5912
|
+
"the master mix into a video file. Needs PAM ≥ 1.5.7 with the window OPEN and at least one VX layer.\n" +
|
|
5913
|
+
"• 'live' without cellId — bar-synced audio WAV via the same WebView Song Export (lands in " +
|
|
5914
|
+
"VX/Resamples; window must be open, PAM ≥ 1.5.7). No destination cell consumed.\n" +
|
|
5915
|
+
"• 'live' with cellId — legacy headless master-mix capture into an empty cell (works with the window " +
|
|
5916
|
+
"closed on PAM ≥ 1.5.0). The captured WAV also lands in PAM's Resamples folder.\n" +
|
|
5917
|
+
"• 'offline' — the DETERMINISTIC multi-section bounce (PAM ≥ 1.5.7 with offline song export): every " +
|
|
5918
|
+
"variation section applies sample-accurately on its anchor; renders faster than realtime with no bar cap. " +
|
|
5919
|
+
"Audio-only works headless (window closed); includeVideo runs the offline video pump with per-section VX " +
|
|
5920
|
+
"recalls (window open, WebCodecs). Caveats: preset-path steps hold the previous section's sound (pending), " +
|
|
5921
|
+
"and wall-clock sources (Magenta, Notochord, camera layers) cannot render offline — use 'live' for those.\n\n" +
|
|
5922
|
+
"Live paths render in REAL TIME (a 90 s song takes ~90 s) and cap at 64 bars per capture. " +
|
|
5923
|
+
"Verify audio results with get_audio_info (resolve cell captures against the Resamples location " +
|
|
5924
|
+
"from get_default_locations).",
|
|
3902
5925
|
inputSchema: {
|
|
3903
5926
|
strategy: z.enum(["live", "offline"]).default("live").describe("Capture strategy."),
|
|
3904
5927
|
bars: z
|
|
@@ -3911,41 +5934,113 @@ server.registerTool("export_song", {
|
|
|
3911
5934
|
.min(1)
|
|
3912
5935
|
.max(8)
|
|
3913
5936
|
.optional()
|
|
3914
|
-
.describe("For 'live': empty destination cell the bounced
|
|
3915
|
-
|
|
5937
|
+
.describe("For legacy headless 'live': empty destination cell for the bounced clip. Omit to use the WebView Song Export (window open)."),
|
|
5938
|
+
includeVideo: z
|
|
5939
|
+
.boolean()
|
|
5940
|
+
.default(false)
|
|
5941
|
+
.describe("Film the VX scene alongside the audio and mux to video (window open, VX layers required)."),
|
|
5942
|
+
name: z.string().optional().describe("Output name for WebView Song Export renders (default 'Song')."),
|
|
5943
|
+
normalize: z.boolean().optional().describe("Normalize the captured audio (WebView Song Export paths)."),
|
|
5944
|
+
encoderId: z.string().optional().describe("Recording encoder preset for includeVideo (e.g. mp4-h264)."),
|
|
5945
|
+
bitDepth: z.number().default(24).describe("Bit depth (16 or 24; legacy cell-capture + offline paths)."),
|
|
3916
5946
|
tailBars: z
|
|
3917
5947
|
.number()
|
|
3918
5948
|
.min(0)
|
|
3919
5949
|
.max(8)
|
|
3920
5950
|
.default(1)
|
|
3921
|
-
.describe("For 'live': extra bars captured after the arrangement end for
|
|
5951
|
+
.describe("For legacy cell-capture 'live': extra bars captured after the arrangement end for tails."),
|
|
3922
5952
|
},
|
|
3923
|
-
}, async ({ strategy, bars, cellId, bitDepth, tailBars }) => {
|
|
5953
|
+
}, async ({ strategy, bars, cellId, includeVideo, name, normalize, encoderId, bitDepth, tailBars }) => {
|
|
3924
5954
|
const check = await requireBridge();
|
|
3925
5955
|
if (check !== true)
|
|
3926
5956
|
return check;
|
|
3927
|
-
// -------- Offline
|
|
3928
|
-
|
|
5957
|
+
// -------- Offline audio-only: headless multi-section bounce --------
|
|
5958
|
+
// (Offline WITH video routes through the WebView Song Export below — the
|
|
5959
|
+
// pump needs the window; strategy rides the op.)
|
|
5960
|
+
if (strategy === "offline" && !includeVideo) {
|
|
3929
5961
|
const filename = `pam_song_${Date.now()}`;
|
|
3930
5962
|
const transport = (await bridge.send("getTransport"));
|
|
3931
5963
|
const bpm = typeof transport.bpm === "number" && transport.bpm > 0 ? transport.bpm : 120;
|
|
3932
5964
|
await bridge.send("exportAudio", {
|
|
3933
5965
|
filename,
|
|
3934
5966
|
mode: "song-mode",
|
|
5967
|
+
// Estimate only — exportSongMode computes the real length from the arrangement.
|
|
3935
5968
|
durationInSeconds: (bars * 4 * 60) / bpm,
|
|
3936
5969
|
bitDepth,
|
|
3937
5970
|
});
|
|
3938
5971
|
const outcome = await pollExportStatus();
|
|
3939
5972
|
if (outcome === "success")
|
|
3940
|
-
return text(`Offline song bounce complete: ${filename}.wav (in PAM's Exports folder)
|
|
3941
|
-
"
|
|
5973
|
+
return text(`Offline song bounce complete: ${filename}.wav (in PAM's Exports folder) — every variation section ` +
|
|
5974
|
+
"applied on its anchor.\n" +
|
|
5975
|
+
"Caveats: preset-path steps hold the previous section (pending); Magenta/Notochord render silent " +
|
|
5976
|
+
"offline (use strategy 'live' for generative sources). Verify with get_audio_info.");
|
|
3942
5977
|
if (outcome === "failed")
|
|
3943
|
-
return error("Offline song bounce failed. Is Song Mode enabled with a non-empty sequence?");
|
|
5978
|
+
return error("Offline song bounce failed (or was cancelled). Is Song Mode enabled with a non-empty sequence?");
|
|
3944
5979
|
return text("Offline song bounce timed out. The render may still be running.");
|
|
3945
5980
|
}
|
|
3946
|
-
// --------
|
|
3947
|
-
|
|
3948
|
-
|
|
5981
|
+
// -------- VX-capture path (WebView Song Export): audio WAV or audio+video --------
|
|
5982
|
+
// Chosen when the caller wants video, or asked for 'live' without a destination
|
|
5983
|
+
// cell — runSongExport bar-syncs the capture, runs VX variation follow live, and
|
|
5984
|
+
// needs no free cell. Fire-and-forget op + snapshot polling (songExport status).
|
|
5985
|
+
if (includeVideo || typeof cellId !== "number") {
|
|
5986
|
+
const pre = await getVideoStateRaw();
|
|
5987
|
+
if (pre.available === false)
|
|
5988
|
+
return error(includeVideo
|
|
5989
|
+
? "includeVideo needs PAM's window OPEN (the VX scene renders in the WebView)."
|
|
5990
|
+
: "'live' without cellId uses the WebView Song Export (window must be OPEN). Open the window, or pass an empty destination cellId for the headless cell-capture path.");
|
|
5991
|
+
if (!("songExport" in pre))
|
|
5992
|
+
return error("The connected PAM predates the Song Export op (needs ≥ 1.5.7). " +
|
|
5993
|
+
"Pass an empty destination cellId for the legacy live capture instead.");
|
|
5994
|
+
const preJob = pre.songExport ?? {};
|
|
5995
|
+
if (preJob.state === "running")
|
|
5996
|
+
return error("A song export is already running — poll get_video_state songExport, or cancel it first.");
|
|
5997
|
+
await sendVideoCommand({
|
|
5998
|
+
op: "exportSong",
|
|
5999
|
+
bars,
|
|
6000
|
+
includeVideo,
|
|
6001
|
+
strategy,
|
|
6002
|
+
...(name !== undefined && { name }),
|
|
6003
|
+
...(normalize !== undefined && { normalize }),
|
|
6004
|
+
...(encoderId !== undefined && { encoderId }),
|
|
6005
|
+
});
|
|
6006
|
+
// The op returns immediately; confirm OUR job actually started. A rejected
|
|
6007
|
+
// op (sync guards: empty arrangement, busy capture, no VX layers) leaves
|
|
6008
|
+
// the previous status — with its old startedAt stamp — in place.
|
|
6009
|
+
let started = false;
|
|
6010
|
+
for (let i = 0; i < 10; i++) {
|
|
6011
|
+
await sleep(500);
|
|
6012
|
+
const s = await getVideoStateRaw();
|
|
6013
|
+
const job = s.songExport ?? {};
|
|
6014
|
+
if (typeof job.startedAt === "number" && job.startedAt !== preJob.startedAt) {
|
|
6015
|
+
started = true;
|
|
6016
|
+
break;
|
|
6017
|
+
}
|
|
6018
|
+
}
|
|
6019
|
+
if (!started)
|
|
6020
|
+
return error("Song export did not start — likely a guard failure (empty arrangement, another capture " +
|
|
6021
|
+
"running, or includeVideo with no VX layers). Check song_mode get / get_video_state.");
|
|
6022
|
+
const transport = (await bridge.send("getTransport"));
|
|
6023
|
+
const bpm = typeof transport.bpm === "number" && transport.bpm > 0 ? transport.bpm : 120;
|
|
6024
|
+
// Capture length + bar-sync start slack + save/mux margin.
|
|
6025
|
+
const deadline = Date.now() + ((bars + 2) * 4 * 60 * 1000) / bpm + 120_000;
|
|
6026
|
+
let last = {};
|
|
6027
|
+
while (Date.now() < deadline) {
|
|
6028
|
+
await sleep(2000);
|
|
6029
|
+
const s = await getVideoStateRaw();
|
|
6030
|
+
last = s.songExport ?? {};
|
|
6031
|
+
if (last.state === "done")
|
|
6032
|
+
return text(`Song export complete (${bars} bars, ${includeVideo ? "audio+video" : "audio WAV"}).\n` +
|
|
6033
|
+
`File: ${String(last.path ?? "(unknown — check VX/Resamples / Visuals)")}\n` +
|
|
6034
|
+
(includeVideo ? "" : "Verify with get_audio_info."));
|
|
6035
|
+
if (last.state === "failed")
|
|
6036
|
+
return error(`Song export failed: ${String(last.error ?? "unknown")}`);
|
|
6037
|
+
if (last.state === "cancelled")
|
|
6038
|
+
return error("Song export was cancelled.");
|
|
6039
|
+
}
|
|
6040
|
+
return error(`Song export timed out (last status: ${JSON.stringify(last)}). It may still be running — ` +
|
|
6041
|
+
"poll get_video_state songExport, or cancel via video_command {op:'cancelSongExport'}.");
|
|
6042
|
+
}
|
|
6043
|
+
// -------- Legacy live strategy: headless master capture into a destination cell --------
|
|
3949
6044
|
const transport = (await bridge.send("getTransport"));
|
|
3950
6045
|
const bpm = typeof transport.bpm === "number" && transport.bpm > 0 ? transport.bpm : 120;
|
|
3951
6046
|
const totalBars = bars + (tailBars ?? 1);
|
|
@@ -4235,6 +6330,13 @@ function isPathUnderWhitelist(p) {
|
|
|
4235
6330
|
// accepts entries observed here so an agent can't instantiate arbitrary
|
|
4236
6331
|
// binaries.
|
|
4237
6332
|
const knownPluginIdentifiers = new Set();
|
|
6333
|
+
// PAM's bridge returns some plugin-host results as a JSON OBJECT in `result`
|
|
6334
|
+
// (newer builds) and others as a pre-serialized string (older builds). The
|
|
6335
|
+
// handlers below must accept both — treating an object as a string made
|
|
6336
|
+
// text() emit a non-string (MCP result-validation error) and JSON.parse
|
|
6337
|
+
// throw, leaving knownPluginIdentifiers empty so load_plugin refused every
|
|
6338
|
+
// path.
|
|
6339
|
+
const bridgeResultText = (raw) => typeof raw === "string" ? raw : JSON.stringify(raw);
|
|
4238
6340
|
// Hosted-plugin tools target PAM cells. Cap to the manifest's cell count
|
|
4239
6341
|
// (falls back to 8 — PAM's shipping max) rather than the C++ slot
|
|
4240
6342
|
// reservation of 32.
|
|
@@ -4281,12 +6383,12 @@ server.registerTool("list_plugins", {
|
|
|
4281
6383
|
filter.isInstrument = args.isInstrument;
|
|
4282
6384
|
if (args.nameContains)
|
|
4283
6385
|
filter.nameContains = args.nameContains;
|
|
4284
|
-
const raw =
|
|
6386
|
+
const raw = await bridge.send("listPlugins", filter);
|
|
4285
6387
|
// Record every fileOrIdentifier so load_plugin can validate that the
|
|
4286
6388
|
// caller-supplied path was discovered by PAM's own scanner.
|
|
4287
6389
|
try {
|
|
4288
|
-
const parsed = JSON.parse(raw);
|
|
4289
|
-
if (Array.isArray(parsed
|
|
6390
|
+
const parsed = (typeof raw === "string" ? JSON.parse(raw) : raw);
|
|
6391
|
+
if (Array.isArray(parsed?.plugins)) {
|
|
4290
6392
|
for (const p of parsed.plugins) {
|
|
4291
6393
|
if (typeof p.fileOrIdentifier === "string") {
|
|
4292
6394
|
knownPluginIdentifiers.add(p.fileOrIdentifier);
|
|
@@ -4297,7 +6399,7 @@ server.registerTool("list_plugins", {
|
|
|
4297
6399
|
catch {
|
|
4298
6400
|
// C++ payload not JSON — fall through; load_plugin will reject unknowns.
|
|
4299
6401
|
}
|
|
4300
|
-
return text(raw);
|
|
6402
|
+
return text(bridgeResultText(raw));
|
|
4301
6403
|
});
|
|
4302
6404
|
server.registerTool("scan_plugins", {
|
|
4303
6405
|
title: "Scan Plugins",
|
|
@@ -4353,12 +6455,21 @@ server.registerTool("get_plugin_scan_status", {
|
|
|
4353
6455
|
"`fraction` (0..1), `elapsedMs`, `isStall` (true when the in-flight plugin hasn't returned in 2s), " +
|
|
4354
6456
|
"and `updatedAtMs`. Poll while a scan is running.",
|
|
4355
6457
|
inputSchema: {},
|
|
6458
|
+
outputSchema: {
|
|
6459
|
+
isScanning: z.boolean().optional(),
|
|
6460
|
+
format: z.string().optional(),
|
|
6461
|
+
currentPath: z.string().optional(),
|
|
6462
|
+
fraction: z.number().optional(),
|
|
6463
|
+
elapsedMs: z.number().optional(),
|
|
6464
|
+
isStall: z.boolean().optional(),
|
|
6465
|
+
updatedAtMs: z.number().optional(),
|
|
6466
|
+
},
|
|
4356
6467
|
}, async () => {
|
|
4357
6468
|
const check = await requireBridge();
|
|
4358
6469
|
if (check !== true)
|
|
4359
6470
|
return check;
|
|
4360
|
-
const raw =
|
|
4361
|
-
return
|
|
6471
|
+
const raw = await bridge.send("getPluginScanStatus");
|
|
6472
|
+
return structuredFromBridge(raw);
|
|
4362
6473
|
});
|
|
4363
6474
|
server.registerTool("list_loaded_plugins", {
|
|
4364
6475
|
title: "List Loaded Plugins",
|
|
@@ -4371,8 +6482,8 @@ server.registerTool("list_loaded_plugins", {
|
|
|
4371
6482
|
const check = await requireBridge();
|
|
4372
6483
|
if (check !== true)
|
|
4373
6484
|
return check;
|
|
4374
|
-
const raw =
|
|
4375
|
-
return text(raw);
|
|
6485
|
+
const raw = await bridge.send("listLoadedPlugins");
|
|
6486
|
+
return text(bridgeResultText(raw));
|
|
4376
6487
|
});
|
|
4377
6488
|
server.registerTool("load_plugin", {
|
|
4378
6489
|
title: "Load Plugin Into Cell",
|
|
@@ -4383,6 +6494,10 @@ server.registerTool("load_plugin", {
|
|
|
4383
6494
|
cellId: z.number().int().min(1).describe("Target cell (1-based)."),
|
|
4384
6495
|
path: z.string().describe("Plugin fileOrIdentifier from list_plugins."),
|
|
4385
6496
|
format: PLUGIN_FORMAT.describe("Plugin format."),
|
|
6497
|
+
uniqueId: z.number().int().optional().describe("Optional type disambiguator for multi-type bundles (one .vst3 exposing several plugin types, " +
|
|
6498
|
+
"e.g. Serum2.vst3 = instrument + FX). Pass the uniqueId from the list_plugins entry you want. " +
|
|
6499
|
+
"Absent → PAM prefers the instrument type at that path."),
|
|
6500
|
+
name: z.string().optional().describe("Optional type name disambiguator (the list_plugins `name`), used when uniqueId is unavailable."),
|
|
4386
6501
|
},
|
|
4387
6502
|
}, async (args) => {
|
|
4388
6503
|
const check = await requireBridge();
|
|
@@ -4399,6 +6514,8 @@ server.registerTool("load_plugin", {
|
|
|
4399
6514
|
cellId: clamped,
|
|
4400
6515
|
path: args.path,
|
|
4401
6516
|
format: args.format,
|
|
6517
|
+
...(args.uniqueId ? { uniqueId: args.uniqueId } : {}),
|
|
6518
|
+
...(args.name ? { name: args.name } : {}),
|
|
4402
6519
|
}));
|
|
4403
6520
|
return text(raw);
|
|
4404
6521
|
});
|
|
@@ -4471,7 +6588,7 @@ server.registerTool("update_settings", {
|
|
|
4471
6588
|
"experimental-feature gates from an agent. Common boolean keys: experimentalMode, " +
|
|
4472
6589
|
"experimentalPluginHost, experimentalMcpBridge. To enable the plugin host, set BOTH " +
|
|
4473
6590
|
"experimentalMode:true AND the specific sub-flag (e.g. experimentalPluginHost:true) in one call. " +
|
|
4474
|
-
"
|
|
6591
|
+
"Set drumSynthEnabled:false to hide its UI without muting loaded synths. Other keys " +
|
|
4475
6592
|
"include UI/library preferences — call get_state or inspect the returned object for the current set.",
|
|
4476
6593
|
inputSchema: {
|
|
4477
6594
|
settings: z
|
|
@@ -4548,6 +6665,24 @@ server.registerTool("get_audio_device_setup", {
|
|
|
4548
6665
|
const setup = await bridge.send("getAudioDeviceSetup");
|
|
4549
6666
|
return json(setup);
|
|
4550
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
|
+
});
|
|
4551
6686
|
server.registerTool("set_midi_io", {
|
|
4552
6687
|
title: "Set MIDI I/O Devices",
|
|
4553
6688
|
description: "Select MIDI input/output devices (standalone). Use get_audio_device_setup to discover device IDs first. " +
|
|
@@ -4581,16 +6716,16 @@ server.registerTool("set_midi_io", {
|
|
|
4581
6716
|
server.registerTool("set_midi_mapping", {
|
|
4582
6717
|
title: "Set MIDI Action Mapping",
|
|
4583
6718
|
description: "Configure the global MIDI-controller mappings that trigger PAM actions: variation switch, preset change, " +
|
|
4584
|
-
"looper record, live resample, and looper-variation switch. Set the enable flag,
|
|
4585
|
-
"preset source, or bar-sync for each target. One field per call.",
|
|
6719
|
+
"looper record, live resample, VX video record toggle, and looper-variation switch. Set the enable flag, " +
|
|
6720
|
+
"MIDI channel, CC number, preset source, or bar-sync for each target. One field per call.",
|
|
4586
6721
|
inputSchema: {
|
|
4587
6722
|
target: z
|
|
4588
|
-
.enum(["variation", "preset", "looper", "resample", "looperVariation"])
|
|
4589
|
-
.describe("Which action's mapping to configure."),
|
|
6723
|
+
.enum(["variation", "preset", "looper", "resample", "vxRecord", "looperVariation"])
|
|
6724
|
+
.describe("Which action's mapping to configure. 'vxRecord' toggles VX video recording — requires the PAM window open (VX renders in the WebView)."),
|
|
4590
6725
|
field: z
|
|
4591
6726
|
.enum(["enabled", "channel", "ccNumber", "source", "barSync"])
|
|
4592
6727
|
.describe("Field to set. 'source' is preset-only; 'barSync' is variation/looperVariation-only; " +
|
|
4593
|
-
"looper/resample have no source/barSync."),
|
|
6728
|
+
"looper/resample/vxRecord have no source/barSync."),
|
|
4594
6729
|
value: z
|
|
4595
6730
|
.union([z.number(), z.boolean()])
|
|
4596
6731
|
.describe("boolean for enabled/barSync; number for channel (1-16), ccNumber (0-127), or source."),
|
|
@@ -4755,13 +6890,309 @@ server.registerTool("remove_plugin_from_blocklist", {
|
|
|
4755
6890
|
return text(`Removed ${fileOrIdentifier} from the plugin blocklist.`);
|
|
4756
6891
|
});
|
|
4757
6892
|
// ============================================================
|
|
6893
|
+
// TOOL — ask_assistant (drive the FunctionGemma assistant end-to-end)
|
|
6894
|
+
// ============================================================
|
|
6895
|
+
// The in-server twin of the ASK bar: renders the SAME FunctionGemma prompt
|
|
6896
|
+
// (shared fgemma-format module + live [ctx] line), generates against a local
|
|
6897
|
+
// OpenAI-compatible endpoint (llama-server serving the fine-tune), parses the
|
|
6898
|
+
// call blocks, and executes them through this server's own handlers. Lets an
|
|
6899
|
+
// MCP client (Claude Code) test the assistant model directly against a live
|
|
6900
|
+
// PAM. Stateless per call — thread follow-ups by passing back `modelTurn`
|
|
6901
|
+
// from the previous trace as `history`.
|
|
6902
|
+
//
|
|
6903
|
+
// Dev-tree only: registered only when the fgemma toolpack resolves (the
|
|
6904
|
+
// published npm package doesn't carry it — the tool would be dead weight).
|
|
6905
|
+
const ASSISTANT_TOOLPACK_PATH = resolve(PACKAGE_ROOT, "../fgemma-poc/out/toolpack.json");
|
|
6906
|
+
// The assistant's view vocabulary → MainViewTypes (stores/Interface.ts).
|
|
6907
|
+
// Pinned to the in-plugin executor's VIEW_MAP (assistantExecutor.ts) — one
|
|
6908
|
+
// vocabulary across tool arg, [ctx] view= field, and this dev twin. null =
|
|
6909
|
+
// the cell grid (selectMainView(null) semantics in the WebView).
|
|
6910
|
+
const SET_VIEW_MAIN_VIEW = {
|
|
6911
|
+
cells: null,
|
|
6912
|
+
pads: null,
|
|
6913
|
+
grid: null,
|
|
6914
|
+
mixer: "Mixer",
|
|
6915
|
+
ctrl: "CtrlAll",
|
|
6916
|
+
ctrlall: "CtrlAll",
|
|
6917
|
+
looper: "Looper",
|
|
6918
|
+
song: "SongMode",
|
|
6919
|
+
lfo: "LFO",
|
|
6920
|
+
envelope: "Envelope",
|
|
6921
|
+
env: "Envelope",
|
|
6922
|
+
vary: "Vary",
|
|
6923
|
+
paramseq: "ParamSeq",
|
|
6924
|
+
video: "Video",
|
|
6925
|
+
vx: "Video",
|
|
6926
|
+
visuals: "Video",
|
|
6927
|
+
export: "Export",
|
|
6928
|
+
presets: "PresetBrowser",
|
|
6929
|
+
browser: "PresetBrowser",
|
|
6930
|
+
};
|
|
6931
|
+
// Dev-tree twin of the in-plugin assistant's set_view (toolpack-gated like
|
|
6932
|
+
// ask_assistant — the published server deliberately excludes window/view
|
|
6933
|
+
// chrome). Error strings mirror the executor exactly: the model's repair
|
|
6934
|
+
// turns are trained on those shapes.
|
|
6935
|
+
if (existsSync(ASSISTANT_TOOLPACK_PATH))
|
|
6936
|
+
server.registerTool("set_view", {
|
|
6937
|
+
title: "Set View (assistant surface)",
|
|
6938
|
+
description: "Navigate PAM's UI: switch the main view, focus a cell, or set the pad-grid size. " +
|
|
6939
|
+
"Dev-tree twin of the in-plugin assistant's set_view, routed through the WebView — " +
|
|
6940
|
+
"PAM's window must be OPEN. On PAM builds without the setView bridge action it falls " +
|
|
6941
|
+
"back to the capture_ui compose path (view + gridSize only; cell selection needs a " +
|
|
6942
|
+
"rebuilt plugin).",
|
|
6943
|
+
inputSchema: {
|
|
6944
|
+
// Union: "8 cell view" phrasings make the model emit view:8 (a number) —
|
|
6945
|
+
// accept it so the gridSize coercion below can run instead of a zod reject.
|
|
6946
|
+
view: z
|
|
6947
|
+
.union([z.string(), z.number()])
|
|
6948
|
+
.optional()
|
|
6949
|
+
.describe("Main view: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets."),
|
|
6950
|
+
cellId: z.number().int().optional().describe("Cell to select/focus (1-8)."),
|
|
6951
|
+
gridSize: z.number().optional().describe("How many pads the grid shows: 1, 4, or 8."),
|
|
6952
|
+
},
|
|
6953
|
+
}, async ({ view: viewArg, cellId, gridSize }) => {
|
|
6954
|
+
const check = await requireBridge();
|
|
6955
|
+
if (check !== true)
|
|
6956
|
+
return check;
|
|
6957
|
+
let view = viewArg === undefined ? undefined : String(viewArg);
|
|
6958
|
+
// Numeric view token → gridSize (executor does the same coercion); an
|
|
6959
|
+
// explicit gridSize wins and the numeric view is dropped either way.
|
|
6960
|
+
if (view !== undefined && /^[148]$/.test(view.trim())) {
|
|
6961
|
+
if (gridSize === undefined)
|
|
6962
|
+
gridSize = Number(view.trim());
|
|
6963
|
+
view = undefined;
|
|
6964
|
+
}
|
|
6965
|
+
const applied = {};
|
|
6966
|
+
let token;
|
|
6967
|
+
if (typeof view === "string" && view.trim()) {
|
|
6968
|
+
token = view.trim().toLowerCase();
|
|
6969
|
+
if (!(token in SET_VIEW_MAIN_VIEW))
|
|
6970
|
+
return error(`Unknown view: ${view}. Views: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets.`);
|
|
6971
|
+
applied.view = token;
|
|
6972
|
+
}
|
|
6973
|
+
if (gridSize !== undefined) {
|
|
6974
|
+
if (gridSize !== 1 && gridSize !== 4 && gridSize !== 8)
|
|
6975
|
+
return error("gridSize must be 1, 4, or 8.");
|
|
6976
|
+
applied.gridSize = gridSize;
|
|
6977
|
+
}
|
|
6978
|
+
if (cellId !== undefined) {
|
|
6979
|
+
if (!Number.isInteger(cellId) || cellId < 1 || cellId > 8)
|
|
6980
|
+
return error("cellId must be 1-8.");
|
|
6981
|
+
applied.cellId = cellId;
|
|
6982
|
+
}
|
|
6983
|
+
if (!Object.keys(applied).length)
|
|
6984
|
+
return error("Provide view, cellId, and/or gridSize.");
|
|
6985
|
+
try {
|
|
6986
|
+
await bridge.send("setView", {
|
|
6987
|
+
...(token !== undefined ? { view: token } : {}),
|
|
6988
|
+
...(cellId !== undefined ? { cellId } : {}),
|
|
6989
|
+
...(gridSize !== undefined ? { gridSize } : {}),
|
|
6990
|
+
});
|
|
6991
|
+
return json(applied);
|
|
6992
|
+
}
|
|
6993
|
+
catch (e) {
|
|
6994
|
+
const msg = e.message;
|
|
6995
|
+
if (!/unknown action|does not support/i.test(msg))
|
|
6996
|
+
return error(`set_view failed: ${msg}`);
|
|
6997
|
+
// Pre-1.15.0 PAM build — compose via the shipped captureUi handler
|
|
6998
|
+
// (applies mainView/gridSize in the WebView before its rasterize; the
|
|
6999
|
+
// capture itself is discarded). No cell-selection fallback exists.
|
|
7000
|
+
if (token === undefined && gridSize === undefined)
|
|
7001
|
+
return error("Cell selection needs the setView bridge action — this PAM build predates it (rebuild PAM).");
|
|
7002
|
+
const setup = { settleMs: 0 };
|
|
7003
|
+
if (token !== undefined)
|
|
7004
|
+
setup.mainView = SET_VIEW_MAIN_VIEW[token];
|
|
7005
|
+
if (gridSize !== undefined)
|
|
7006
|
+
setup.gridSize = gridSize;
|
|
7007
|
+
try {
|
|
7008
|
+
await bridge.send("captureUi", setup);
|
|
7009
|
+
}
|
|
7010
|
+
catch (e2) {
|
|
7011
|
+
return error(`set_view failed: ${e2.message}`);
|
|
7012
|
+
}
|
|
7013
|
+
delete applied.cellId; // not applied on the fallback path — don't over-claim
|
|
7014
|
+
return json(applied);
|
|
7015
|
+
}
|
|
7016
|
+
});
|
|
7017
|
+
if (existsSync(ASSISTANT_TOOLPACK_PATH))
|
|
7018
|
+
server.registerTool("ask_assistant", {
|
|
7019
|
+
title: "Ask Assistant (FunctionGemma e2e test)",
|
|
7020
|
+
description: "Run one natural-language command through the on-device assistant model: render the " +
|
|
7021
|
+
"FunctionGemma prompt (with the live [ctx] state line), generate via a local llama-server, " +
|
|
7022
|
+
"parse the tool calls, execute them against PAM, and return the full trace. Requires " +
|
|
7023
|
+
"llama-server running with the fine-tuned GGUF (--special). For multi-turn tests, pass the " +
|
|
7024
|
+
"previous trace's modelTurn back via history.",
|
|
7025
|
+
inputSchema: {
|
|
7026
|
+
text: z.string().describe("The user command, e.g. 'mute the kick' or '174 bpm dnb kit'."),
|
|
7027
|
+
endpoint: z
|
|
7028
|
+
.string()
|
|
7029
|
+
.optional()
|
|
7030
|
+
.describe("OpenAI-compatible /v1 base URL (default env PAM_LLM_URL or http://127.0.0.1:8080/v1)."),
|
|
7031
|
+
sel: z
|
|
7032
|
+
.number()
|
|
7033
|
+
.int()
|
|
7034
|
+
.optional()
|
|
7035
|
+
.describe("Selected-cell override for the [ctx] line (the bridge can't see UI selection)."),
|
|
7036
|
+
noCtx: z.boolean().optional().describe("Skip the [ctx] line (test the no-context prompt shape)."),
|
|
7037
|
+
history: z
|
|
7038
|
+
.array(z.object({ user: z.string(), model: z.string() }))
|
|
7039
|
+
.optional()
|
|
7040
|
+
.describe("Prior exchanges to thread (user text + the modelTurn from that trace)."),
|
|
7041
|
+
maxRounds: z.number().int().min(1).max(5).optional().describe("Continuation rounds (default 3)."),
|
|
7042
|
+
},
|
|
7043
|
+
}, async ({ text: userText, endpoint, sel, noCtx, history, maxRounds }) => {
|
|
7044
|
+
// Toolpack: the model-facing surface (dev tree; regenerate with export-toolpack.mjs).
|
|
7045
|
+
let pack;
|
|
7046
|
+
try {
|
|
7047
|
+
const doc = JSON.parse(readFileSync(ASSISTANT_TOOLPACK_PATH, "utf-8"));
|
|
7048
|
+
pack = doc.tools;
|
|
7049
|
+
}
|
|
7050
|
+
catch {
|
|
7051
|
+
return error("assistantToolpack not found — run `node scripts/export-toolpack.mjs` in packages/mcp-server first.");
|
|
7052
|
+
}
|
|
7053
|
+
const declared = new Set(pack.map((t) => t.name));
|
|
7054
|
+
const llmUrl = (endpoint || process.env.PAM_LLM_URL || "http://127.0.0.1:8080/v1").replace(/\/$/, "");
|
|
7055
|
+
// Live [ctx] line from this server's own read tools (sel is UI-only state
|
|
7056
|
+
// the bridge can't see — honor the override, else none).
|
|
7057
|
+
let ctx = null;
|
|
7058
|
+
if (!noCtx) {
|
|
7059
|
+
try {
|
|
7060
|
+
const t = await callInternalTool("get_transport", {});
|
|
7061
|
+
const o = await callInternalTool("get_design_overview", {});
|
|
7062
|
+
if (!t.isError && !o.isError) {
|
|
7063
|
+
const transport = JSON.parse(t.text);
|
|
7064
|
+
const overview = JSON.parse(o.text);
|
|
7065
|
+
// preset= IS bridge-visible (state.activePresetPath) — only view/sel
|
|
7066
|
+
// are UI-only. Renders `none` for Init/Default, like the executor.
|
|
7067
|
+
let preset = null;
|
|
7068
|
+
try {
|
|
7069
|
+
const s = await callInternalTool("get_state", { keys: ["activePresetPath"] });
|
|
7070
|
+
const activePath = JSON.parse(s.text).activePresetPath;
|
|
7071
|
+
if (typeof activePath === "string" && activePath && activePath !== "Init") {
|
|
7072
|
+
const stem = (activePath.split("/").pop() ?? "").replace(/\.preset$/, "");
|
|
7073
|
+
preset = stem && stem !== "Default" ? stem : null;
|
|
7074
|
+
}
|
|
7075
|
+
}
|
|
7076
|
+
catch {
|
|
7077
|
+
preset = null;
|
|
7078
|
+
}
|
|
7079
|
+
ctx = {
|
|
7080
|
+
bpm: Number(transport.bpm ?? 120),
|
|
7081
|
+
playing: Boolean(transport.playing ?? transport.isPlaying),
|
|
7082
|
+
preset,
|
|
7083
|
+
sel: sel ?? null,
|
|
7084
|
+
cells: (overview.cells ?? [])
|
|
7085
|
+
.filter((c) => c.sample || c.synth)
|
|
7086
|
+
.map((c) => ({ id: c.id, name: c.sample ?? `${c.synth}(synth)` })),
|
|
7087
|
+
};
|
|
7088
|
+
}
|
|
7089
|
+
}
|
|
7090
|
+
catch {
|
|
7091
|
+
ctx = null; // no PAM — trained no-ctx prompt shape still works
|
|
7092
|
+
}
|
|
7093
|
+
}
|
|
7094
|
+
let prompt = buildGenerationPrompt(pack, withContext(userText, ctx), {
|
|
7095
|
+
bos: "none",
|
|
7096
|
+
history,
|
|
7097
|
+
});
|
|
7098
|
+
const trace = { ctxLine: ctx ? renderContextLine(ctx) : null, rounds: [], executed: [], modelTurn: "" };
|
|
7099
|
+
const rounds = maxRounds ?? 3;
|
|
7100
|
+
// A repair round that re-emits the exact call that just errored will error
|
|
7101
|
+
// identically forever — cut the loop (executor parity).
|
|
7102
|
+
const failedCalls = new Set();
|
|
7103
|
+
for (let round = 0; round < rounds; round++) {
|
|
7104
|
+
const t0 = Date.now();
|
|
7105
|
+
let raw;
|
|
7106
|
+
try {
|
|
7107
|
+
const resp = await fetch(`${llmUrl}/completions`, {
|
|
7108
|
+
method: "POST",
|
|
7109
|
+
headers: { "Content-Type": "application/json" },
|
|
7110
|
+
body: JSON.stringify({ prompt, max_tokens: 256, temperature: 0, stop: STOP_SEQUENCES, stream: false }),
|
|
7111
|
+
});
|
|
7112
|
+
if (!resp.ok)
|
|
7113
|
+
return error(`model endpoint ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
|
|
7114
|
+
raw = (await resp.json()).choices?.[0]?.text ?? "";
|
|
7115
|
+
}
|
|
7116
|
+
catch (e) {
|
|
7117
|
+
return error(`cannot reach model endpoint ${llmUrl} — start llama-server with the fine-tuned GGUF and --special. (${e.message})`);
|
|
7118
|
+
}
|
|
7119
|
+
raw = truncateAtFabricatedResponse(raw);
|
|
7120
|
+
const calls = parseFunctionCalls(raw);
|
|
7121
|
+
trace.rounds.push({ raw, ms: Date.now() - t0, calls });
|
|
7122
|
+
if (!calls.length) {
|
|
7123
|
+
trace.text = raw.trim();
|
|
7124
|
+
trace.modelTurn += trace.text;
|
|
7125
|
+
return json(trace);
|
|
7126
|
+
}
|
|
7127
|
+
const unknown = calls.filter((c) => !declared.has(c.name));
|
|
7128
|
+
if (unknown.length) {
|
|
7129
|
+
trace.refused = `undeclared tool(s): ${unknown.map((c) => c.name).join(", ")}`;
|
|
7130
|
+
return json(trace);
|
|
7131
|
+
}
|
|
7132
|
+
let continuation = raw.trimEnd();
|
|
7133
|
+
let responses = "";
|
|
7134
|
+
let wantContinue = false;
|
|
7135
|
+
for (const call of calls) {
|
|
7136
|
+
const signature = `${call.name} ${JSON.stringify(call.arguments)}`;
|
|
7137
|
+
if (failedCalls.has(signature)) {
|
|
7138
|
+
trace.stopped = `model repeated a call that already failed (${call.name}) — loop cut`;
|
|
7139
|
+
return json(trace);
|
|
7140
|
+
}
|
|
7141
|
+
// The flat set_parameter alias maps onto the real object-map tool.
|
|
7142
|
+
const real = pack.find((t) => t.name === call.name)?.aliasOf === "set_parameters"
|
|
7143
|
+
? {
|
|
7144
|
+
name: "set_parameters",
|
|
7145
|
+
arguments: { parameters: { [String(call.arguments.paramId)]: call.arguments.value } },
|
|
7146
|
+
}
|
|
7147
|
+
: call;
|
|
7148
|
+
const result = await callInternalTool(real.name, real.arguments);
|
|
7149
|
+
if (result.isError)
|
|
7150
|
+
failedCalls.add(signature);
|
|
7151
|
+
trace.executed.push({
|
|
7152
|
+
name: call.name,
|
|
7153
|
+
arguments: call.arguments,
|
|
7154
|
+
result: result.text.length > 400 ? `${result.text.slice(0, 400)}…` : result.text,
|
|
7155
|
+
isError: result.isError,
|
|
7156
|
+
});
|
|
7157
|
+
let payload;
|
|
7158
|
+
if (result.isError)
|
|
7159
|
+
payload = { error: result.text.slice(0, 400) };
|
|
7160
|
+
else {
|
|
7161
|
+
try {
|
|
7162
|
+
const parsed = JSON.parse(result.text);
|
|
7163
|
+
payload =
|
|
7164
|
+
parsed && typeof parsed === "object" && !Array.isArray(parsed) && result.text.length <= 400
|
|
7165
|
+
? parsed
|
|
7166
|
+
: { result: result.text.slice(0, 400) };
|
|
7167
|
+
}
|
|
7168
|
+
catch {
|
|
7169
|
+
payload = { result: result.text.slice(0, 400) };
|
|
7170
|
+
}
|
|
7171
|
+
}
|
|
7172
|
+
const response = renderFunctionResponse(call.name, payload);
|
|
7173
|
+
continuation += response;
|
|
7174
|
+
responses += response;
|
|
7175
|
+
if (result.isError || /^(get_|list_|connection_status)/.test(call.name))
|
|
7176
|
+
wantContinue = true;
|
|
7177
|
+
}
|
|
7178
|
+
trace.modelTurn += calls.map(renderFunctionCall).join("") + responses;
|
|
7179
|
+
if (!wantContinue)
|
|
7180
|
+
return json(trace);
|
|
7181
|
+
prompt += continuation;
|
|
7182
|
+
}
|
|
7183
|
+
return json(trace);
|
|
7184
|
+
});
|
|
7185
|
+
// ============================================================
|
|
4758
7186
|
// Start
|
|
4759
7187
|
// ============================================================
|
|
4760
7188
|
async function main() {
|
|
4761
7189
|
if (await handleCliArgs())
|
|
4762
7190
|
return;
|
|
4763
7191
|
// Try to connect to plugin on startup (non-blocking)
|
|
4764
|
-
void bridge.connect()
|
|
7192
|
+
void bridge.connect().then((ok) => {
|
|
7193
|
+
if (ok)
|
|
7194
|
+
syncProductHint();
|
|
7195
|
+
});
|
|
4765
7196
|
const transport = new StdioServerTransport();
|
|
4766
7197
|
await server.connect(transport);
|
|
4767
7198
|
// Log to stderr (stdout is the MCP JSON-RPC channel)
|