@map-audio/pam-mcp-server 1.12.0 → 1.18.1
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 -5
- package/dist/bridge.d.ts +17 -3
- package/dist/bridge.js +81 -20
- package/dist/bridge.js.map +1 -1
- 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/manifest-cell1.json +7950 -0
- package/dist/manifest-mfx.json +13381 -0
- package/dist/manifest.d.ts +2 -1
- package/dist/manifest.js +79 -29
- package/dist/manifest.js.map +1 -1
- package/dist/manifest.json +821 -130
- package/dist/mixAssist.d.ts +155 -0
- package/dist/mixAssist.js +647 -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 +2204 -140
- package/dist/server.js.map +1 -1
- package/package.json +5 -2
package/dist/server.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
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";
|
|
5
6
|
import { readFile } from "node:fs/promises";
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
8
|
import { inflateSync } from "node:zlib";
|
|
7
9
|
import { basename, dirname, resolve } from "node:path";
|
|
8
10
|
import { fileURLToPath } from "node:url";
|
|
9
|
-
import { loadManifest, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, walkPresets, } from "./manifest.js";
|
|
11
|
+
import { loadManifest, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, setActiveProductHint, walkPresets, } from "./manifest.js";
|
|
10
12
|
import { PluginBridge } from "./bridge.js";
|
|
13
|
+
import { buildMixMeasurementFromState, currentFlatFromState, parseMixAnalysisPayload, suggestMix, } from "./mixAssist.js";
|
|
14
|
+
import { normalizePresetLookupPath, selectSaveVariationSourcePath, } from "./presetSave.js";
|
|
15
|
+
import { STOP_SEQUENCES, buildGenerationPrompt, parseFunctionCalls, renderContextLine, renderFunctionCall, renderFunctionResponse, truncateAtFabricatedResponse, withContext, } from "./fgemma-format.js";
|
|
11
16
|
// --- State ---
|
|
12
17
|
// Variation slot count. Must stay in sync with PRESET_VARIATION_SLOTS in
|
|
13
18
|
// interface/src/stores/Preset.ts. Defined locally because the MCP package
|
|
@@ -16,6 +21,8 @@ const PRESET_VARIATION_SLOTS = 8;
|
|
|
16
21
|
const bridge = new PluginBridge();
|
|
17
22
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
18
23
|
const cellModeChangeSeq = {};
|
|
24
|
+
let lastSavedPresetName;
|
|
25
|
+
let lastSavedPresetPath;
|
|
19
26
|
// --- Helpers ---
|
|
20
27
|
function text(s) {
|
|
21
28
|
return { content: [{ type: "text", text: s }] };
|
|
@@ -23,12 +30,78 @@ function text(s) {
|
|
|
23
30
|
function json(obj) {
|
|
24
31
|
return text(JSON.stringify(obj, null, 2));
|
|
25
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Structured result: the same JSON in the text block (for text-only consumers,
|
|
35
|
+
* including ask_assistant's internal dispatch) plus structuredContent for
|
|
36
|
+
* clients that consume the tool's outputSchema. Only use from tools that
|
|
37
|
+
* declare an outputSchema — the SDK requires structuredContent on success then.
|
|
38
|
+
*/
|
|
39
|
+
function structured(obj) {
|
|
40
|
+
const record = isRecord(obj) ? obj : { value: obj };
|
|
41
|
+
return {
|
|
42
|
+
content: [{ type: "text", text: JSON.stringify(record, null, 2) }],
|
|
43
|
+
structuredContent: record,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** structured() for bridge results that may arrive as a raw JSON string. */
|
|
47
|
+
function structuredFromBridge(raw) {
|
|
48
|
+
if (typeof raw === "string") {
|
|
49
|
+
try {
|
|
50
|
+
return structured(JSON.parse(raw));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return structured({ value: raw });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return structured(raw);
|
|
57
|
+
}
|
|
26
58
|
function error(msg) {
|
|
27
59
|
return { content: [{ type: "text", text: msg }], isError: true };
|
|
28
60
|
}
|
|
29
|
-
|
|
61
|
+
function isMfxManifest(manifest) {
|
|
62
|
+
return (manifest.name?.toLowerCase() === "mfx" ||
|
|
63
|
+
manifest.bundleId?.toLowerCase() === "map.audio.mfx" ||
|
|
64
|
+
manifest.pluginCode?.toLowerCase() === "amfx");
|
|
65
|
+
}
|
|
66
|
+
const MFX_CELL_CONFIG_KEYS = new Set(["sidechainFrom"]);
|
|
67
|
+
async function loadManifestForProductGuard() {
|
|
68
|
+
try {
|
|
69
|
+
return await loadManifest();
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function manifestMaxCellId(manifest) {
|
|
76
|
+
if (!manifest)
|
|
77
|
+
return null;
|
|
78
|
+
const entries = [
|
|
79
|
+
...(manifest.parameters ?? []),
|
|
80
|
+
...(manifest.stateVariables ?? []),
|
|
81
|
+
...(manifest.parameterInfos ?? []),
|
|
82
|
+
];
|
|
83
|
+
const ids = new Set();
|
|
84
|
+
for (const entry of entries) {
|
|
85
|
+
const cellMatch = typeof entry.cell === "string" ? /^cell(\d+)$/.exec(entry.cell) : null;
|
|
86
|
+
const paramMatch = typeof entry.paramId === "string" ? /^cell(\d+)_/.exec(entry.paramId) : null;
|
|
87
|
+
const raw = cellMatch?.[1] ?? paramMatch?.[1];
|
|
88
|
+
if (raw)
|
|
89
|
+
ids.add(Number(raw));
|
|
90
|
+
}
|
|
91
|
+
return ids.size > 0 ? Math.max(...ids) : null;
|
|
92
|
+
}
|
|
93
|
+
function validateCellIdForManifest(cellId, manifest) {
|
|
94
|
+
const maxCellId = manifestMaxCellId(manifest);
|
|
95
|
+
if (maxCellId !== null && cellId > maxCellId) {
|
|
96
|
+
const productName = manifest?.name ?? "This product";
|
|
97
|
+
const noun = maxCellId === 1 ? "cell" : "cells";
|
|
98
|
+
return error(`${productName} exposes ${maxCellId} ${noun}; cell ${cellId} is not available in this product.`);
|
|
99
|
+
}
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
function readPackageVersion() {
|
|
30
103
|
try {
|
|
31
|
-
const raw =
|
|
104
|
+
const raw = readFileSync(resolve(PACKAGE_ROOT, "package.json"), "utf-8");
|
|
32
105
|
const pkg = JSON.parse(raw);
|
|
33
106
|
return typeof pkg.version === "string" ? pkg.version : "unknown";
|
|
34
107
|
}
|
|
@@ -36,14 +109,17 @@ async function readPackageVersion() {
|
|
|
36
109
|
return "unknown";
|
|
37
110
|
}
|
|
38
111
|
}
|
|
112
|
+
// Single source of truth for the server version: package.json. The MCP handshake,
|
|
113
|
+
// --version, and --help must all report the same number.
|
|
114
|
+
const SERVER_VERSION = readPackageVersion();
|
|
39
115
|
async function handleCliArgs() {
|
|
40
116
|
const args = new Set(process.argv.slice(2));
|
|
41
117
|
if (args.has("--version") || args.has("-v")) {
|
|
42
|
-
console.log(
|
|
118
|
+
console.log(SERVER_VERSION);
|
|
43
119
|
return true;
|
|
44
120
|
}
|
|
45
121
|
if (args.has("--help") || args.has("-h")) {
|
|
46
|
-
const version =
|
|
122
|
+
const version = SERVER_VERSION;
|
|
47
123
|
console.log(`pam-mcp ${version}\n\n` +
|
|
48
124
|
"MCP stdio server for PAM. Configure your MCP client to launch this command; " +
|
|
49
125
|
"it reads ~/.pam-mcp-bridge.json to find a running PAM instance.");
|
|
@@ -51,14 +127,24 @@ async function handleCliArgs() {
|
|
|
51
127
|
}
|
|
52
128
|
return false;
|
|
53
129
|
}
|
|
130
|
+
// Point offline tools (param validation, design_context) at the manifest of
|
|
131
|
+
// the product we're actually connected to.
|
|
132
|
+
function syncProductHint() {
|
|
133
|
+
setActiveProductHint(bridge.getConnectedInstance()?.product);
|
|
134
|
+
}
|
|
54
135
|
async function requireBridge() {
|
|
55
136
|
if (bridge.isConnected())
|
|
56
137
|
return true;
|
|
57
138
|
const ok = await bridge.connect();
|
|
58
139
|
if (!ok) {
|
|
140
|
+
const pin = process.env.PAM_BRIDGE_PRODUCT;
|
|
59
141
|
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."
|
|
142
|
+
"The bridge writes its port to ~/.pam-mcp-bridge.json on startup." +
|
|
143
|
+
(pin
|
|
144
|
+
? ` Note: PAM_BRIDGE_PRODUCT pins this server to '${pin}' — no matching instance was found (connection_status lists what is running).`
|
|
145
|
+
: ""));
|
|
61
146
|
}
|
|
147
|
+
syncProductHint();
|
|
62
148
|
return true;
|
|
63
149
|
}
|
|
64
150
|
/**
|
|
@@ -176,12 +262,218 @@ function formatParam(p) {
|
|
|
176
262
|
desc += ` — ${p.tooltip}`;
|
|
177
263
|
return desc;
|
|
178
264
|
}
|
|
265
|
+
const FULL_STATE_RESPONSE_LIMIT_CHARS = 24_000;
|
|
266
|
+
const CELL_EFFECTS = ["filter", "delay", "crusher", "flanger", "eq", "fdn", "gate", "compressor"];
|
|
267
|
+
const PATTERN_LANES = {
|
|
268
|
+
note: "midiSequence",
|
|
269
|
+
pitch: "pitchSequence",
|
|
270
|
+
slice: "sliceSequence",
|
|
271
|
+
stretch: "stretchSequence",
|
|
272
|
+
};
|
|
273
|
+
const LFO_DIVISIONS = [
|
|
274
|
+
"16 bar", "8 bar", "4 bar", "2 bar", "1 bar",
|
|
275
|
+
"1/2D", "1/2", "1/2T",
|
|
276
|
+
"1/4D", "1/4", "1/4T",
|
|
277
|
+
"1/8D", "1/8", "1/8T",
|
|
278
|
+
"1/16D", "1/16", "1/16T",
|
|
279
|
+
"1/32D", "1/32", "1/32T",
|
|
280
|
+
"1/64D", "1/64", "1/64T",
|
|
281
|
+
];
|
|
282
|
+
function levenshtein(a, b) {
|
|
283
|
+
const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
284
|
+
const curr = Array(b.length + 1).fill(0);
|
|
285
|
+
for (let i = 1; i <= a.length; i++) {
|
|
286
|
+
curr[0] = i;
|
|
287
|
+
for (let j = 1; j <= b.length; j++) {
|
|
288
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
289
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
290
|
+
}
|
|
291
|
+
for (let j = 0; j <= b.length; j++)
|
|
292
|
+
prev[j] = curr[j];
|
|
293
|
+
}
|
|
294
|
+
return prev[b.length];
|
|
295
|
+
}
|
|
296
|
+
function similarity(a, b) {
|
|
297
|
+
if (a === b)
|
|
298
|
+
return 1;
|
|
299
|
+
const maxLen = Math.max(a.length, b.length);
|
|
300
|
+
if (maxLen === 0)
|
|
301
|
+
return 1;
|
|
302
|
+
return 1 - levenshtein(a, b) / maxLen;
|
|
303
|
+
}
|
|
304
|
+
function paramSuffix(paramId) {
|
|
305
|
+
const m = paramId.match(/^(?:cell\d+|macro|lfo\d+)_?(.*)$/);
|
|
306
|
+
return m?.[1] || paramId;
|
|
307
|
+
}
|
|
308
|
+
function explicitParamAliases(paramId) {
|
|
309
|
+
const aliases = new Set();
|
|
310
|
+
const aliasSuffixes = {
|
|
311
|
+
delayTime: "delayDelayTime",
|
|
312
|
+
crusherBits: "crusherBitReduction",
|
|
313
|
+
crusherBitDepth: "crusherBitReduction",
|
|
314
|
+
crusherDownsample: "crusherRateReduction",
|
|
315
|
+
fdnDecay: "fdnFeedback",
|
|
316
|
+
filterResonance: "filterQ",
|
|
317
|
+
};
|
|
318
|
+
const lfo0 = paramId.match(/^lfo0_(.+)$/);
|
|
319
|
+
if (lfo0)
|
|
320
|
+
aliases.add(`lfo1_${lfo0[1]}`);
|
|
321
|
+
const cellMatch = paramId.match(/^(cell\d+_)(.+)$/);
|
|
322
|
+
const macroMatch = paramId.match(/^(macro_)(.+)$/);
|
|
323
|
+
const prefix = cellMatch?.[1] ?? macroMatch?.[1] ?? "";
|
|
324
|
+
const suffix = cellMatch?.[2] ?? macroMatch?.[2] ?? paramId;
|
|
325
|
+
const replacement = aliasSuffixes[suffix];
|
|
326
|
+
if (replacement)
|
|
327
|
+
aliases.add(`${prefix}${replacement}`);
|
|
328
|
+
return [...aliases];
|
|
329
|
+
}
|
|
330
|
+
function suggestParams(paramId, index, opts = {}) {
|
|
331
|
+
const explicit = explicitParamAliases(paramId).filter((id) => {
|
|
332
|
+
const p = index.get(id);
|
|
333
|
+
return p && (!opts.modulatableOnly || p.isModulatable);
|
|
334
|
+
});
|
|
335
|
+
const scored = [...index.values()]
|
|
336
|
+
.filter((p) => !opts.modulatableOnly || p.isModulatable)
|
|
337
|
+
.map((p) => {
|
|
338
|
+
const full = similarity(paramId.toLowerCase(), p.paramId.toLowerCase());
|
|
339
|
+
const suffix = similarity(paramSuffix(paramId).toLowerCase(), paramSuffix(p.paramId).toLowerCase());
|
|
340
|
+
const sameCell = paramId.match(/^cell\d+_/)?.[0] === p.paramId.match(/^cell\d+_/)?.[0] ? 0.12 : 0;
|
|
341
|
+
const sameFamily = paramId.startsWith("macro_") && p.paramId.startsWith("macro_") ? 0.08 :
|
|
342
|
+
paramId.startsWith("lfo") && p.paramId.startsWith("lfo") ? 0.08 :
|
|
343
|
+
0;
|
|
344
|
+
return { id: p.paramId, score: Math.max(full, suffix) + sameCell + sameFamily };
|
|
345
|
+
})
|
|
346
|
+
.filter((x) => x.score >= 0.52)
|
|
347
|
+
.sort((a, b) => b.score - a.score)
|
|
348
|
+
.map((x) => x.id);
|
|
349
|
+
return [...new Set([...explicit, ...scored])].slice(0, 5);
|
|
350
|
+
}
|
|
351
|
+
function formatUnknownParamError(paramId, index, opts = {}) {
|
|
352
|
+
const suggestions = suggestParams(paramId, index, opts);
|
|
353
|
+
const hint = paramId.startsWith("lfo0_")
|
|
354
|
+
? " LFO parameter IDs are 1-based: use lfo1_... through lfo6_..."
|
|
355
|
+
: "";
|
|
356
|
+
const scope = opts.modulatableOnly ? " modulatable parameter" : " parameter";
|
|
357
|
+
return `Unknown${scope}: ${paramId}.${hint}${suggestions.length ? ` Did you mean ${suggestions.join(", ")}?` : ""}`;
|
|
358
|
+
}
|
|
359
|
+
function formatNotModulatableError(paramId, index) {
|
|
360
|
+
const suggestions = suggestParams(paramId, index, { modulatableOnly: true });
|
|
361
|
+
return `Parameter '${paramId}' is not modulatable.${suggestions.length ? ` Try modulatable target ${suggestions.join(", ")}.` : ""}`;
|
|
362
|
+
}
|
|
363
|
+
function formatRangeError(param, value) {
|
|
364
|
+
const unit = param.suffix ? param.suffix : "unitless";
|
|
365
|
+
const traits = [
|
|
366
|
+
param.bipolar ? "bipolar" : "unipolar",
|
|
367
|
+
unit,
|
|
368
|
+
param.strRepr && param.strRepr.length > 0
|
|
369
|
+
? `values: ${param.strRepr.map((v, i) => `${i}=${v}`).join(", ")}`
|
|
370
|
+
: undefined,
|
|
371
|
+
].filter(Boolean);
|
|
372
|
+
const panHint = param.paramId.endsWith("_pan") && Math.abs(value) > 1 && Math.abs(value) <= 100
|
|
373
|
+
? `; ${value / 100} is approximately ${Math.abs(value)}% ${value < 0 ? "left" : "right"}`
|
|
374
|
+
: "";
|
|
375
|
+
return `${param.paramId}: ${value} out of range [${param.min}..${param.max}] (${traits.join("; ")}${panHint})`;
|
|
376
|
+
}
|
|
377
|
+
function jsonWithLimit(obj, tooLargeHint) {
|
|
378
|
+
const serialized = JSON.stringify(obj, null, 2);
|
|
379
|
+
if (serialized.length > FULL_STATE_RESPONSE_LIMIT_CHARS) {
|
|
380
|
+
return error(`Result is ${serialized.length.toLocaleString()} characters, above the ${FULL_STATE_RESPONSE_LIMIT_CHARS.toLocaleString()} character safety cap. ` +
|
|
381
|
+
tooLargeHint);
|
|
382
|
+
}
|
|
383
|
+
return text(serialized);
|
|
384
|
+
}
|
|
385
|
+
function isRecord(value) {
|
|
386
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
387
|
+
}
|
|
388
|
+
function cellPrefixSet(cellIds) {
|
|
389
|
+
return new Set((cellIds ?? []).map((id) => `cell${id}_`));
|
|
390
|
+
}
|
|
391
|
+
function paramTargetsCell(value, cells) {
|
|
392
|
+
if (!isRecord(value))
|
|
393
|
+
return false;
|
|
394
|
+
const paramId = typeof value.paramId === "string" ? value.paramId : undefined;
|
|
395
|
+
if (paramId && [...cells].some((prefix) => paramId.startsWith(prefix)))
|
|
396
|
+
return true;
|
|
397
|
+
const cellId = typeof value.cellId === "number" ? value.cellId : undefined;
|
|
398
|
+
return cellId !== undefined && cells.has(`cell${cellId}_`);
|
|
399
|
+
}
|
|
400
|
+
function filterState(state, opts) {
|
|
401
|
+
if (!isRecord(state))
|
|
402
|
+
return state;
|
|
403
|
+
const keySet = opts.keys?.length ? new Set(opts.keys) : null;
|
|
404
|
+
const prefixes = cellPrefixSet(opts.cellIds);
|
|
405
|
+
const filterByCell = prefixes.size > 0;
|
|
406
|
+
if (!keySet && !filterByCell)
|
|
407
|
+
return state;
|
|
408
|
+
const out = {};
|
|
409
|
+
for (const [key, value] of Object.entries(state)) {
|
|
410
|
+
if (keySet?.has(key)) {
|
|
411
|
+
out[key] = value;
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (!filterByCell)
|
|
415
|
+
continue;
|
|
416
|
+
if ([...prefixes].some((prefix) => key.startsWith(prefix))) {
|
|
417
|
+
out[key] = value;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (key === "cells" && isRecord(value)) {
|
|
421
|
+
const cells = {};
|
|
422
|
+
for (const id of opts.cellIds ?? []) {
|
|
423
|
+
if (value[String(id)] !== undefined)
|
|
424
|
+
cells[String(id)] = value[String(id)];
|
|
425
|
+
}
|
|
426
|
+
out.cells = cells;
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (["lfos", "envelopes", "vary", "paramSeqs"].includes(key) && Array.isArray(value)) {
|
|
430
|
+
out[key] = value.filter((entry) => paramTargetsCell(entry, prefixes));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return out;
|
|
434
|
+
}
|
|
435
|
+
function filterSectionResult(section, result, opts) {
|
|
436
|
+
const keySet = opts.keys?.length ? new Set(opts.keys) : null;
|
|
437
|
+
const prefixes = cellPrefixSet(opts.cellIds);
|
|
438
|
+
if (section === "cells" && isRecord(result) && prefixes.size > 0) {
|
|
439
|
+
const out = {};
|
|
440
|
+
for (const id of opts.cellIds ?? []) {
|
|
441
|
+
if (result[String(id)] !== undefined)
|
|
442
|
+
out[String(id)] = result[String(id)];
|
|
443
|
+
}
|
|
444
|
+
return out;
|
|
445
|
+
}
|
|
446
|
+
if (["lfos", "envelopes", "vary", "paramSeqs"].includes(section) && Array.isArray(result) && prefixes.size > 0) {
|
|
447
|
+
return result.filter((entry) => paramTargetsCell(entry, prefixes));
|
|
448
|
+
}
|
|
449
|
+
if (keySet && isRecord(result)) {
|
|
450
|
+
const out = {};
|
|
451
|
+
for (const key of keySet) {
|
|
452
|
+
if (result[key] !== undefined)
|
|
453
|
+
out[key] = result[key];
|
|
454
|
+
}
|
|
455
|
+
return out;
|
|
456
|
+
}
|
|
457
|
+
return result;
|
|
458
|
+
}
|
|
459
|
+
function countEvents(value) {
|
|
460
|
+
return Array.isArray(value) ? value.length : 0;
|
|
461
|
+
}
|
|
462
|
+
function stemName(path) {
|
|
463
|
+
if (typeof path !== "string" || path.length === 0)
|
|
464
|
+
return undefined;
|
|
465
|
+
return basename(path);
|
|
466
|
+
}
|
|
179
467
|
// --- Server ---
|
|
180
468
|
const server = new McpServer({
|
|
181
469
|
name: "pam",
|
|
182
|
-
|
|
470
|
+
title: "PAM Audio Sampler",
|
|
471
|
+
version: SERVER_VERSION,
|
|
183
472
|
}, {
|
|
184
|
-
instructions: "PAM is an audio sampler plugin.
|
|
473
|
+
instructions: "PAM is an audio sampler plugin. Start design/read work with design_context (curated param " +
|
|
474
|
+
"cheat sheet) and get_design_overview (compact live snapshot) instead of full get_state or " +
|
|
475
|
+
"broad get_parameter_info; when you need detail, call get_state with section/keys/cellIds filters. " +
|
|
476
|
+
"When browsing samples, ALWAYS call get_default_locations " +
|
|
185
477
|
"first to discover the user's actual library paths — never guess paths like ~/Music/PAM. " +
|
|
186
478
|
"The sample library lives under ~/Library/Application Support/, NOT ~/Music/. " +
|
|
187
479
|
"Use the returned paths with list_samples. When loading samples, use load_sample with " +
|
|
@@ -195,16 +487,255 @@ const server = new McpServer({
|
|
|
195
487
|
"set_plugin_params, skip_current_plugin_scan, remove_plugin_from_blocklist — require PAM ≥ 1.7.0. " +
|
|
196
488
|
"VX video tools (get_video_state, video_command, add_video_layer, update_video_layer, " +
|
|
197
489
|
"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).
|
|
490
|
+
"bind_video_param, set_video_settings, set_video_layer_source) require PAM ≥ 1.8.0 AND PAM's window to be OPEN " +
|
|
491
|
+
"(the video engine renders in the WebView; there is no headless renderer). " +
|
|
492
|
+
"capture_ui, set_visual, set_drum_synth_wave, and randomize with target 'synth' are " +
|
|
493
|
+
"WebView-routed too and need the window open for the same reason. For " +
|
|
200
494
|
"audio-reactive control, note the visual audio-react params (visualWaveDisplace, etc.) " +
|
|
201
495
|
"are regular plugin params — drive them with set_parameters; layers/FX/bindings go through " +
|
|
202
496
|
"the video tools. When building a scene, call get_video_state first to learn layer/fx ids. " +
|
|
497
|
+
"AI model lifecycle tools (download_model, get_model_status, load_model, unload_model, " +
|
|
498
|
+
"delete_model, list_models; engines aigen|magenta|notochord) require PAM ≥ 1.9.0 plus the " +
|
|
499
|
+
"matching experimental flags; load/unload/delete can block for seconds — poll " +
|
|
500
|
+
"get_model_status rather than tight-looping. " +
|
|
203
501
|
"Earlier tool sets (looper, song mode, modulation edits, MIDI recording, sample analysis, " +
|
|
204
|
-
"live resample, variation scheduling, AI generation) require PAM ≥ 1.
|
|
502
|
+
"live resample, variation scheduling, AI generation) require PAM ≥ 1.5.0. " +
|
|
503
|
+
"SONG WORKFLOW: author sections as variation slots (save_variation), arrange them with " +
|
|
504
|
+
"song_mode (op setArrangement authors real Song Mode grid nodes and can pin a VX variation " +
|
|
505
|
+
"slot per section via vxVariationIndex; op setSequence is the older headless raw-step path), " +
|
|
506
|
+
"manage visual variation slots with vx_variation (save/load/clear/fill — the VX siblings of " +
|
|
507
|
+
"save_variation), then bounce with export_song (includeVideo:true films the VX scene with " +
|
|
508
|
+
"variation follow live). song_mode setArrangement/get/assignVx, vx_variation, and " +
|
|
509
|
+
"export_song includeVideo are WebView-routed (window open, PAM ≥ 1.5.7). " +
|
|
205
510
|
"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
|
-
|
|
511
|
+
"this MCP server; the user must update PAM (updating the npm package alone is not enough). " +
|
|
512
|
+
"Tool annotations are accurate: readOnlyHint tools never mutate the plugin, destructiveHint " +
|
|
513
|
+
"tools discard user state (confirm intent before calling them). When several product " +
|
|
514
|
+
"instances run at once (PAM + MFX), select_instance chooses which one the live tools drive " +
|
|
515
|
+
"(PAM_BRIDGE_PRODUCT pins it at launch). Guided workflow prompts are available: build-a-beat, " +
|
|
516
|
+
"make-a-song, design-a-preset, mix-and-master, troubleshoot-bridge.",
|
|
517
|
+
});
|
|
518
|
+
// --- tool annotation policy ---------------------------------------------------
|
|
519
|
+
// Single audit surface for MCP tool annotations. Every tool MUST have an entry;
|
|
520
|
+
// the registerTool shim below merges it (inline annotations at the call site win
|
|
521
|
+
// per key) and warns on stderr when a tool registers unclassified — and
|
|
522
|
+
// scripts/check-tool-surface.mjs fails the release on a gap. openWorldHint is
|
|
523
|
+
// false server-wide: every tool targets the local plugin instance.
|
|
524
|
+
// Policy: readOnlyHint = no plugin/file mutation. destructiveHint = discards
|
|
525
|
+
// user state that isn't trivially recoverable (delete/clear/reset/overwrite the
|
|
526
|
+
// working state); slot-scoped saves follow the save_variation precedent (false).
|
|
527
|
+
// idempotentHint = repeating the same args reaches the same end state.
|
|
528
|
+
const RO = { readOnlyHint: true };
|
|
529
|
+
const W = { destructiveHint: false }; // additive / one-shot write
|
|
530
|
+
const WI = { destructiveHint: false, idempotentHint: true };
|
|
531
|
+
const D = { destructiveHint: true };
|
|
532
|
+
const DI = { destructiveHint: true, idempotentHint: true };
|
|
533
|
+
const TOOL_ANNOTATIONS = {
|
|
534
|
+
// Reads (offline + live introspection)
|
|
535
|
+
get_parameter_info: RO,
|
|
536
|
+
list_presets: RO,
|
|
537
|
+
read_preset: RO,
|
|
538
|
+
get_manifest_summary: RO,
|
|
539
|
+
design_context: RO,
|
|
540
|
+
get_state: RO,
|
|
541
|
+
get_design_overview: RO,
|
|
542
|
+
get_transport: RO,
|
|
543
|
+
get_master_analysis: RO,
|
|
544
|
+
suggest_mix: RO, // pure function over cached analysis + current state
|
|
545
|
+
connection_status: RO,
|
|
546
|
+
list_samples: RO,
|
|
547
|
+
get_default_locations: RO,
|
|
548
|
+
get_audio_info: RO,
|
|
549
|
+
get_video_state: RO,
|
|
550
|
+
capture_ui: RO, // writes a screenshot file, mutates no plugin state
|
|
551
|
+
get_sample_peaks: RO,
|
|
552
|
+
get_audio_device_setup: RO,
|
|
553
|
+
get_sample_usage: RO,
|
|
554
|
+
get_model_status: RO,
|
|
555
|
+
list_models: RO,
|
|
556
|
+
workbench_get_state: RO,
|
|
557
|
+
workbench_list_takes: RO,
|
|
558
|
+
workbench_sidecar_status: RO,
|
|
559
|
+
list_visuals: RO,
|
|
560
|
+
get_visual_data_url: RO,
|
|
561
|
+
list_plugins: RO,
|
|
562
|
+
get_plugin_scan_status: RO,
|
|
563
|
+
list_loaded_plugins: RO,
|
|
564
|
+
get_mix_analysis: W, // refresh:true runs an offline analysis render
|
|
565
|
+
// Parameter / cell / modulation writes
|
|
566
|
+
set_parameters: WI,
|
|
567
|
+
load_sample: WI,
|
|
568
|
+
update_pattern: WI,
|
|
569
|
+
add_modulation: W,
|
|
570
|
+
update_modulation: WI,
|
|
571
|
+
clear_modulations: DI,
|
|
572
|
+
reset_patterns: DI,
|
|
573
|
+
update_effect_config: WI,
|
|
574
|
+
update_cell_config: WI,
|
|
575
|
+
set_cell_mute: WI,
|
|
576
|
+
set_master_effects: WI,
|
|
577
|
+
add_param_seq: W,
|
|
578
|
+
update_param_seq: WI,
|
|
579
|
+
remove_param_seq: DI,
|
|
580
|
+
set_drum_synth: WI,
|
|
581
|
+
set_drum_synth_wave: WI,
|
|
582
|
+
stop_cell: WI,
|
|
583
|
+
reset_cell: DI,
|
|
584
|
+
copy_cell: DI, // overwrites the destination cell
|
|
585
|
+
randomize: W,
|
|
586
|
+
randomize_samples: W,
|
|
587
|
+
randomize_patterns: W,
|
|
588
|
+
// Transport / MIDI
|
|
589
|
+
start_transport: WI,
|
|
590
|
+
stop_transport: WI,
|
|
591
|
+
full_stop_transport: WI,
|
|
592
|
+
set_bpm: WI,
|
|
593
|
+
set_metronome: WI,
|
|
594
|
+
configure_transport: WI,
|
|
595
|
+
trigger_midi: W,
|
|
596
|
+
trigger_midi_cc: W,
|
|
597
|
+
trigger_midi_program_change: W,
|
|
598
|
+
midi_recording: W,
|
|
599
|
+
// Presets / variations
|
|
600
|
+
load_preset: DI, // replaces the working state (unsaved edits are lost)
|
|
601
|
+
save_preset: WI,
|
|
602
|
+
new_preset: DI,
|
|
603
|
+
import_preset: WI,
|
|
604
|
+
export_preset: W,
|
|
605
|
+
update_preset_metadata: WI,
|
|
606
|
+
delete_preset: DI,
|
|
607
|
+
set_preset_factory_status: WI,
|
|
608
|
+
repair_preset_samples: WI,
|
|
609
|
+
load_variation: WI,
|
|
610
|
+
save_variation: WI,
|
|
611
|
+
queue_variation: WI,
|
|
612
|
+
cancel_queued_variation: WI,
|
|
613
|
+
update_variations: WI,
|
|
614
|
+
variation_hold: WI,
|
|
615
|
+
// Sample ops
|
|
616
|
+
preview_sample: WI, // audible only, no state change
|
|
617
|
+
preview_slice: WI,
|
|
618
|
+
trim_sample: W, // restore_original_sample recovers the pre-edit file
|
|
619
|
+
reverse_sample: W,
|
|
620
|
+
gain_sample: W,
|
|
621
|
+
normalize_sample: WI,
|
|
622
|
+
analyze_sample_tempo: WI, // dispatches results into state
|
|
623
|
+
resample_sample_to_bpm: WI,
|
|
624
|
+
restore_original_sample: DI, // discards all working edits on the sample
|
|
625
|
+
build_sample_chain: W,
|
|
626
|
+
export_slice: W,
|
|
627
|
+
// Looper
|
|
628
|
+
arm_looper_track: WI,
|
|
629
|
+
set_looper_track: WI,
|
|
630
|
+
clear_looper_track: DI, // looper state is global, not per-preset
|
|
631
|
+
set_looper_track_selection: WI,
|
|
632
|
+
start_looper_recording: W,
|
|
633
|
+
stop_looper_recording: WI,
|
|
634
|
+
capture_looper_now: W,
|
|
635
|
+
load_audio_to_looper_track: WI,
|
|
636
|
+
request_looper_warp: WI,
|
|
637
|
+
looper_settings: WI,
|
|
638
|
+
looper_variation: W,
|
|
639
|
+
looper_variation_sync: WI,
|
|
640
|
+
live_resample: W,
|
|
641
|
+
// Song mode / export
|
|
642
|
+
song_mode: W,
|
|
643
|
+
export_song: W,
|
|
644
|
+
export_audio: W,
|
|
645
|
+
// Settings / devices
|
|
646
|
+
update_settings: WI,
|
|
647
|
+
set_midi_io: WI,
|
|
648
|
+
set_midi_mapping: WI,
|
|
649
|
+
// Plugin host
|
|
650
|
+
scan_plugins: WI,
|
|
651
|
+
cancel_plugin_scan: WI,
|
|
652
|
+
skip_current_plugin_scan: W, // blocklists the currently scanning plugin
|
|
653
|
+
remove_plugin_from_blocklist: WI,
|
|
654
|
+
load_plugin: WI,
|
|
655
|
+
unload_plugin: DI, // discards the hosted plugin's state
|
|
656
|
+
set_plugin_param: WI,
|
|
657
|
+
set_plugin_params: WI,
|
|
658
|
+
set_plugin_bypass: WI,
|
|
659
|
+
// AI models / generation
|
|
660
|
+
download_model: WI,
|
|
661
|
+
load_model: WI,
|
|
662
|
+
unload_model: WI,
|
|
663
|
+
delete_model: DI,
|
|
664
|
+
generate_sample: W,
|
|
665
|
+
generate_kit: W,
|
|
666
|
+
// Workbench
|
|
667
|
+
workbench_set_state: WI,
|
|
668
|
+
workbench_render: W,
|
|
669
|
+
workbench_load_take: WI,
|
|
670
|
+
workbench_undo: W,
|
|
671
|
+
workbench_reset_chain: DI,
|
|
672
|
+
// Visuals / VX video
|
|
673
|
+
set_visual: WI,
|
|
674
|
+
delete_visual: DI,
|
|
675
|
+
import_visual_folder: WI,
|
|
676
|
+
video_command: D, // generic escape hatch — includes resetWorkspace
|
|
677
|
+
add_video_layer: W,
|
|
678
|
+
update_video_layer: WI,
|
|
679
|
+
set_video_layer_source: WI,
|
|
680
|
+
remove_video_layer: DI,
|
|
681
|
+
apply_video_layout: WI,
|
|
682
|
+
add_video_fx: W,
|
|
683
|
+
set_video_fx: WI,
|
|
684
|
+
remove_video_fx: DI,
|
|
685
|
+
bind_video_param: WI,
|
|
686
|
+
set_video_settings: WI,
|
|
687
|
+
vx_variation: W,
|
|
688
|
+
// Connection management
|
|
689
|
+
select_instance: WI,
|
|
690
|
+
// Dev-tree-only tools
|
|
691
|
+
set_view: WI,
|
|
692
|
+
ask_assistant: D, // executes arbitrary model-chosen tool calls
|
|
693
|
+
};
|
|
694
|
+
const internalTools = new Map();
|
|
695
|
+
{
|
|
696
|
+
const origRegisterTool = server.registerTool.bind(server);
|
|
697
|
+
server.registerTool = ((name, config, handler) => {
|
|
698
|
+
internalTools.set(name, { shape: config.inputSchema, handler });
|
|
699
|
+
// Merge the central annotation policy. Inline annotations at the call site
|
|
700
|
+
// win per key; openWorldHint is false server-wide (local plugin only).
|
|
701
|
+
const cfg = config;
|
|
702
|
+
if (!TOOL_ANNOTATIONS[name]) {
|
|
703
|
+
console.error(`[pam-mcp] tool '${name}' has no TOOL_ANNOTATIONS entry — classify it (check-tool-surface fails on this)`);
|
|
704
|
+
}
|
|
705
|
+
cfg.annotations = { openWorldHint: false, ...TOOL_ANNOTATIONS[name], ...cfg.annotations };
|
|
706
|
+
return origRegisterTool(name, config, handler);
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
async function callInternalTool(name, args) {
|
|
710
|
+
const tool = internalTools.get(name);
|
|
711
|
+
if (!tool)
|
|
712
|
+
return { text: `Unknown tool: ${name}.`, isError: true };
|
|
713
|
+
// Direct handler calls skip the SDK's zod layer — parse here so schema
|
|
714
|
+
// defaults apply and unknown keys strip, exactly like a real MCP call
|
|
715
|
+
// (list_samples without offset/limit would otherwise slice(undefined, NaN)
|
|
716
|
+
// to an empty page).
|
|
717
|
+
let parsed = args;
|
|
718
|
+
if (tool.shape) {
|
|
719
|
+
tool.parser ??= z.object(tool.shape);
|
|
720
|
+
const r = tool.parser.safeParse(args);
|
|
721
|
+
if (!r.success) {
|
|
722
|
+
const issues = r.error.issues.map((i) => `${i.path.join(".") || "(args)"}: ${i.message}`).join("; ");
|
|
723
|
+
return { text: `Invalid arguments for ${name} — ${issues}`, isError: true };
|
|
724
|
+
}
|
|
725
|
+
parsed = r.data;
|
|
726
|
+
}
|
|
727
|
+
try {
|
|
728
|
+
const result = await tool.handler(parsed);
|
|
729
|
+
const out = (result.content ?? [])
|
|
730
|
+
.filter((c) => c.type === "text")
|
|
731
|
+
.map((c) => c.text ?? "")
|
|
732
|
+
.join("\n");
|
|
733
|
+
return { text: out, isError: Boolean(result.isError) };
|
|
734
|
+
}
|
|
735
|
+
catch (e) {
|
|
736
|
+
return { text: `Tool error: ${e.message}`, isError: true };
|
|
737
|
+
}
|
|
738
|
+
}
|
|
208
739
|
// ============================================================
|
|
209
740
|
// TOOLS — Offline (work without plugin running)
|
|
210
741
|
// ============================================================
|
|
@@ -241,7 +772,7 @@ server.registerTool("get_parameter_info", {
|
|
|
241
772
|
if (paramId) {
|
|
242
773
|
const p = index.get(paramId);
|
|
243
774
|
if (!p)
|
|
244
|
-
return error(
|
|
775
|
+
return error(formatUnknownParamError(paramId, index));
|
|
245
776
|
return json(p);
|
|
246
777
|
}
|
|
247
778
|
let params;
|
|
@@ -269,8 +800,10 @@ server.registerTool("get_parameter_info", {
|
|
|
269
800
|
if (params.length === 0)
|
|
270
801
|
return text("No parameters found matching criteria.");
|
|
271
802
|
if (params.length > 50) {
|
|
272
|
-
|
|
273
|
-
|
|
803
|
+
const shown = params.slice(0, 80);
|
|
804
|
+
return text(`${params.length} parameters found. Showing first ${shown.length}. ` +
|
|
805
|
+
"Use paramId, cell, effect, search, type, or design_context to narrow the response.\n\n" +
|
|
806
|
+
shown.map(formatParam).join("\n"));
|
|
274
807
|
}
|
|
275
808
|
return json(params);
|
|
276
809
|
});
|
|
@@ -347,6 +880,16 @@ server.registerTool("get_manifest_summary", {
|
|
|
347
880
|
description: "Get a high-level summary of PAM's capabilities: number of cells, effects, " +
|
|
348
881
|
"modulators, and parameter counts.",
|
|
349
882
|
inputSchema: {},
|
|
883
|
+
outputSchema: {
|
|
884
|
+
product: z.string().optional(),
|
|
885
|
+
company: z.string().optional(),
|
|
886
|
+
totalParameters: z.number().optional(),
|
|
887
|
+
cells: z.array(z.string()).optional(),
|
|
888
|
+
effects: z.array(z.string()).optional(),
|
|
889
|
+
globalParameters: z.number().optional(),
|
|
890
|
+
modulatableParameters: z.number().optional(),
|
|
891
|
+
pluginParameters: z.number().optional(),
|
|
892
|
+
},
|
|
350
893
|
annotations: { readOnlyHint: true },
|
|
351
894
|
}, async () => {
|
|
352
895
|
const manifest = await loadManifest();
|
|
@@ -355,7 +898,7 @@ server.registerTool("get_manifest_summary", {
|
|
|
355
898
|
const effects = new Set(params.filter((p) => p.effect).map((p) => p.effect));
|
|
356
899
|
const global = params.filter((p) => !p.cell);
|
|
357
900
|
const modulatable = params.filter((p) => p.isModulatable);
|
|
358
|
-
return
|
|
901
|
+
return structured({
|
|
359
902
|
product: manifest.name,
|
|
360
903
|
company: manifest.company,
|
|
361
904
|
totalParameters: params.length,
|
|
@@ -366,6 +909,98 @@ server.registerTool("get_manifest_summary", {
|
|
|
366
909
|
pluginParameters: params.filter((p) => p.isPluginParameter).length,
|
|
367
910
|
});
|
|
368
911
|
});
|
|
912
|
+
server.registerTool("design_context", {
|
|
913
|
+
title: "Design Context",
|
|
914
|
+
description: "Compact parameter-design cheat sheet for agents: macro targets, per-effect key params, musical ranges, " +
|
|
915
|
+
"unit notes, LFO/delay division hints, MIDI cell note map, and variation slot semantics.",
|
|
916
|
+
inputSchema: {
|
|
917
|
+
cellId: z
|
|
918
|
+
.number()
|
|
919
|
+
.int()
|
|
920
|
+
.min(1)
|
|
921
|
+
.max(8)
|
|
922
|
+
.default(1)
|
|
923
|
+
.describe("Cell number to use for per-cell example param IDs. Defaults to cell 1."),
|
|
924
|
+
},
|
|
925
|
+
annotations: { readOnlyHint: true },
|
|
926
|
+
}, async ({ cellId }) => {
|
|
927
|
+
const manifest = await loadManifest();
|
|
928
|
+
const params = manifest.parameters;
|
|
929
|
+
const byId = new Map(params.map((p) => [p.paramId, p]));
|
|
930
|
+
const example = (suffix) => byId.get(`cell${cellId}_${suffix}`)?.paramId ?? `cell${cellId}_${suffix}`;
|
|
931
|
+
const macroTargetsByEffect = {};
|
|
932
|
+
for (const p of params.filter((param) => param.paramId.startsWith("macro_") && param.isModulatable)) {
|
|
933
|
+
const key = p.effect ?? "global";
|
|
934
|
+
if (!macroTargetsByEffect[key])
|
|
935
|
+
macroTargetsByEffect[key] = [];
|
|
936
|
+
macroTargetsByEffect[key].push(p.paramId);
|
|
937
|
+
}
|
|
938
|
+
for (const key of Object.keys(macroTargetsByEffect)) {
|
|
939
|
+
macroTargetsByEffect[key] = macroTargetsByEffect[key].sort();
|
|
940
|
+
}
|
|
941
|
+
const filterType = byId.get(example("filterType"));
|
|
942
|
+
const lfoQuick = [
|
|
943
|
+
["16 bar", 0, 0.000],
|
|
944
|
+
["4 bar", 2, 0.091],
|
|
945
|
+
["2 bar", 3, 0.136],
|
|
946
|
+
["1 bar", 4, 0.182],
|
|
947
|
+
["1/2", 6, 0.273],
|
|
948
|
+
["1/4", 9, 0.409],
|
|
949
|
+
["1/8", 12, 0.545],
|
|
950
|
+
["1/16", 15, 0.682],
|
|
951
|
+
["1/32", 18, 0.818],
|
|
952
|
+
["1/64", 21, 0.955],
|
|
953
|
+
].map(([division, index, value]) => ({ division, index, lfoRate: value }));
|
|
954
|
+
return json({
|
|
955
|
+
product: manifest.name,
|
|
956
|
+
exampleCell: cellId,
|
|
957
|
+
macroTargetsByEffect,
|
|
958
|
+
keyPerCellParams: {
|
|
959
|
+
mixer: [example("volume"), example("pan"), example("gainGain")],
|
|
960
|
+
filter: [example("filterCutoff"), example("filterQ"), example("filterType")],
|
|
961
|
+
delay: [example("delayDelayTime"), example("delayFeedback"), example("delayTilt"), example("delayMix")],
|
|
962
|
+
crusher: [example("crusherBitReduction"), example("crusherRateReduction"), example("crusherDrive"), example("crusherMix")],
|
|
963
|
+
fdn: [example("fdnSize"), example("fdnFeedback"), example("fdnModDepth"), example("fdnMix")],
|
|
964
|
+
gate: [example("gateTime"), example("gateProgram"), example("gateMix")],
|
|
965
|
+
compressor: [example("compressorThreshold"), example("compressorRatio"), example("compressorAttack"), example("compressorRelease")],
|
|
966
|
+
drumSynth: [example("synthEngine"), example("synthVolume"), example("synth_p1"), example("synth_p2"), example("synth_p3")],
|
|
967
|
+
},
|
|
968
|
+
unitNotes: {
|
|
969
|
+
pan: "cellN_pan is -1..1, so -0.15 is about 15% left; use cellN_pannerBalance for -100..100%.",
|
|
970
|
+
percentages: "Most mix/feedback/depth controls are 0..100 percent.",
|
|
971
|
+
filterCutoff: "Use real Hz values; move at least 1.5 octaves for obviously audible modulation.",
|
|
972
|
+
delayDelayTime: "With delaySyncEnabled=1, normalized 0..1 maps to musical divisions; do not use cellN_delayTime.",
|
|
973
|
+
lfoRate: "LFO IDs are 1-based: lfo1_... through lfo6_.... Sync ON uses index/22.",
|
|
974
|
+
},
|
|
975
|
+
musicalRanges: {
|
|
976
|
+
filterCutoff: "dark 300-900 Hz, mid 900-3500 Hz, open 3500-12000 Hz",
|
|
977
|
+
delayFeedback: "10-35% controlled, 35-60% dubby, >70% runaway risk",
|
|
978
|
+
wetMix: "10-25% subtle, 25-45% audible, >60% wash/destroy",
|
|
979
|
+
crusherBitReduction: "7-16 bits musical grit, 1-4 bits brutal",
|
|
980
|
+
volumeDb: "Layer cells with -12..0 dB staging before master processing",
|
|
981
|
+
},
|
|
982
|
+
lfoSyncTable: {
|
|
983
|
+
formula: "set lfoN_lfoSyncEnabled=1 and lfoN_lfoRate=index/22",
|
|
984
|
+
quick: lfoQuick,
|
|
985
|
+
fullDivisions: LFO_DIVISIONS,
|
|
986
|
+
shapes: ["0=Sine", "1=Saw", "2=Tri", "3=Square", "4=Random", "5=Wander", "6=Custom"],
|
|
987
|
+
},
|
|
988
|
+
delaySyncDivisions: [
|
|
989
|
+
"1/32T", "1/32", "1/32D", "1/16T", "1/16", "1/16D",
|
|
990
|
+
"1/8T", "1/8", "1/8D", "1/4T", "1/4", "1/4D",
|
|
991
|
+
"1/2T", "1/2", "1/2D", "1",
|
|
992
|
+
],
|
|
993
|
+
filterTypes: filterType?.strRepr?.map((label, index) => `${index}=${label}`) ?? [
|
|
994
|
+
"0=Lowpass", "1=Highpass", "2=Bandpass", "3=Peak", "4=Notch",
|
|
995
|
+
],
|
|
996
|
+
midiNoteMap: Object.fromEntries(Array.from({ length: 8 }, (_, i) => {
|
|
997
|
+
const id = i + 1;
|
|
998
|
+
return [`cell${id}`, 47 + id];
|
|
999
|
+
})),
|
|
1000
|
+
variationSlots: "API is 0-based (0..7); PAM UI labels slots 1..8. Save/load variation index 0 = UI slot 1.",
|
|
1001
|
+
modulationGuidance: "Primary movement should target per-cell params for distinct audible changes. Use macro_* for performance-level fan-out across cells.",
|
|
1002
|
+
});
|
|
1003
|
+
});
|
|
369
1004
|
// Standard full update — graph dispatch + KV sync + parameter updates.
|
|
370
1005
|
const KV_OPTS_STANDARD = {
|
|
371
1006
|
shouldDispatch: true,
|
|
@@ -394,6 +1029,20 @@ const KV_OPTS_PATTERN = {
|
|
|
394
1029
|
updateParameters: false,
|
|
395
1030
|
broadcastFollowers: false,
|
|
396
1031
|
};
|
|
1032
|
+
// Master-chain config — KV + config-resource sync, NO graph dispatch. Every
|
|
1033
|
+
// master key (comp incl. the full promoted Punch surface, tape, iron, clipper,
|
|
1034
|
+
// limiter, EQ/width) is live-polled from master_config by its native node, so
|
|
1035
|
+
// a rebuild here is pure dropout risk (the plan's no-rebuild contract).
|
|
1036
|
+
// Mirrors interop.ts updateMasterConfig's options.
|
|
1037
|
+
const KV_OPTS_MASTER_CONFIG = {
|
|
1038
|
+
shouldDispatch: false,
|
|
1039
|
+
syncKVResources: true,
|
|
1040
|
+
syncConfigResources: true,
|
|
1041
|
+
skipFileLoadingIfUnchanged: true,
|
|
1042
|
+
resyncKVAfterDispatch: false,
|
|
1043
|
+
updateParameters: true,
|
|
1044
|
+
broadcastFollowers: false,
|
|
1045
|
+
};
|
|
397
1046
|
// Effect config toggle — lighter KV path, no graph dispatch.
|
|
398
1047
|
const KV_OPTS_EFFECT_CONFIG = {
|
|
399
1048
|
shouldDispatch: false,
|
|
@@ -423,30 +1072,132 @@ async function applyKv(delta, options, directNodeOps) {
|
|
|
423
1072
|
}
|
|
424
1073
|
server.registerTool("get_state", {
|
|
425
1074
|
title: "Get Plugin State",
|
|
426
|
-
description: "Read the current
|
|
427
|
-
"
|
|
1075
|
+
description: "Read the current state of the running PAM plugin. Prefer section, keys, cellIds, or get_design_overview " +
|
|
1076
|
+
"over an unfiltered full-state read; large full-state payloads are capped to protect agent context.",
|
|
428
1077
|
inputSchema: {
|
|
429
1078
|
section: z
|
|
430
1079
|
.enum(["full", "cells", "lfos", "macros", "envelopes", "vary", "paramSeqs", "loopers", "songMode"])
|
|
431
1080
|
.optional()
|
|
432
1081
|
.describe("Return only a specific section of state. Defaults to 'full'. Use get_transport for transport data."),
|
|
1082
|
+
keys: z
|
|
1083
|
+
.array(z.string())
|
|
1084
|
+
.optional()
|
|
1085
|
+
.describe("Exact top-level state keys to return, e.g. ['cell1_filterCutoff','cell1_midiSequence']."),
|
|
1086
|
+
cellIds: z
|
|
1087
|
+
.array(z.number().int().min(1).max(8))
|
|
1088
|
+
.optional()
|
|
1089
|
+
.describe("Return only cell-scoped keys/config/modulation for these cells."),
|
|
1090
|
+
allowLarge: z
|
|
1091
|
+
.boolean()
|
|
1092
|
+
.optional()
|
|
1093
|
+
.describe("Set true to force an uncapped full-state response. Prefer filters unless you truly need everything."),
|
|
433
1094
|
},
|
|
434
1095
|
annotations: { readOnlyHint: true },
|
|
435
|
-
}, async ({ section }) => {
|
|
1096
|
+
}, async ({ section, keys, cellIds, allowLarge }) => {
|
|
436
1097
|
const check = await requireBridge();
|
|
437
1098
|
if (check !== true)
|
|
438
1099
|
return check;
|
|
1100
|
+
const hasFilters = (keys?.length ?? 0) > 0 || (cellIds?.length ?? 0) > 0;
|
|
439
1101
|
if (section && section !== "full") {
|
|
440
1102
|
const result = await bridge.send("getState", section);
|
|
441
|
-
|
|
1103
|
+
const filtered = filterSectionResult(section, result, { keys, cellIds });
|
|
1104
|
+
return jsonWithLimit(filtered ?? `Section '${section}' not found in state.`, "Use a narrower section, keys, or cellIds filter.");
|
|
442
1105
|
}
|
|
443
1106
|
const state = await bridge.send("getState");
|
|
444
|
-
|
|
1107
|
+
const filtered = filterState(state, { keys, cellIds });
|
|
1108
|
+
if (!hasFilters && !allowLarge) {
|
|
1109
|
+
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.");
|
|
1110
|
+
}
|
|
1111
|
+
return json(filtered);
|
|
1112
|
+
});
|
|
1113
|
+
server.registerTool("get_design_overview", {
|
|
1114
|
+
title: "Get Design Overview",
|
|
1115
|
+
description: "Return a compact preset-design snapshot: cells, loaded samples/synth engines, enabled effects, pattern counts, " +
|
|
1116
|
+
"modulation link counts, mixer values, BPM, and master FX. Use this instead of full get_state during preset design.",
|
|
1117
|
+
inputSchema: {
|
|
1118
|
+
cellIds: z
|
|
1119
|
+
.array(z.number().int().min(1).max(8))
|
|
1120
|
+
.optional()
|
|
1121
|
+
.describe("Optional subset of cells to include. Defaults to all cells present in the selected manifest."),
|
|
1122
|
+
},
|
|
1123
|
+
annotations: { readOnlyHint: true },
|
|
1124
|
+
}, async ({ cellIds }) => {
|
|
1125
|
+
const check = await requireBridge();
|
|
1126
|
+
if (check !== true)
|
|
1127
|
+
return check;
|
|
1128
|
+
const [stateRaw, transportRaw, manifest] = await Promise.all([
|
|
1129
|
+
bridge.send("getState"),
|
|
1130
|
+
bridge.send("getTransport").catch(() => undefined),
|
|
1131
|
+
loadManifest(),
|
|
1132
|
+
]);
|
|
1133
|
+
const state = isRecord(stateRaw) ? stateRaw : {};
|
|
1134
|
+
const transport = isRecord(transportRaw) ? transportRaw : {};
|
|
1135
|
+
const manifestCellIds = [...new Set(manifest.parameters
|
|
1136
|
+
.map((p) => p.cell)
|
|
1137
|
+
.filter((cell) => typeof cell === "string" && /^cell\d+$/.test(cell))
|
|
1138
|
+
.map((cell) => Number(cell.replace("cell", ""))))].sort((a, b) => a - b);
|
|
1139
|
+
const ids = cellIds?.length ? cellIds : manifestCellIds;
|
|
1140
|
+
const cellsObj = isRecord(state.cells) ? state.cells : {};
|
|
1141
|
+
const modArrays = {
|
|
1142
|
+
lfo: Array.isArray(state.lfos) ? state.lfos : [],
|
|
1143
|
+
envelope: Array.isArray(state.envelopes) ? state.envelopes : [],
|
|
1144
|
+
vary: Array.isArray(state.vary) ? state.vary : [],
|
|
1145
|
+
paramSeq: Array.isArray(state.paramSeqs) ? state.paramSeqs : [],
|
|
1146
|
+
};
|
|
1147
|
+
const cells = ids.map((id) => {
|
|
1148
|
+
const prefix = `cell${id}_`;
|
|
1149
|
+
const rawCellState = cellsObj[String(id)];
|
|
1150
|
+
const cellState = isRecord(rawCellState) ? rawCellState : {};
|
|
1151
|
+
const samplePath = cellState.filePath ??
|
|
1152
|
+
cellState.samplePath ??
|
|
1153
|
+
state[`${prefix}filePath`] ??
|
|
1154
|
+
state[`${prefix}samplePath`];
|
|
1155
|
+
const modulation = Object.fromEntries(Object.entries(modArrays).map(([kind, arr]) => [
|
|
1156
|
+
kind,
|
|
1157
|
+
arr.filter((entry) => paramTargetsCell(entry, new Set([prefix]))).length,
|
|
1158
|
+
]));
|
|
1159
|
+
return {
|
|
1160
|
+
cellId: id,
|
|
1161
|
+
sample: {
|
|
1162
|
+
name: stemName(samplePath),
|
|
1163
|
+
path: typeof samplePath === "string" ? samplePath : undefined,
|
|
1164
|
+
},
|
|
1165
|
+
synthEngine: state[`${prefix}synthEngine`] ?? 0,
|
|
1166
|
+
enabledEffects: CELL_EFFECTS.filter((effectName) => state[`${prefix}${effectName}Enabled`] === 1 || state[`${prefix}${effectName}Enabled`] === true),
|
|
1167
|
+
patternEvents: Object.fromEntries(Object.entries(PATTERN_LANES).map(([lane, suffix]) => [
|
|
1168
|
+
lane,
|
|
1169
|
+
countEvents(state[`${prefix}${suffix}`]),
|
|
1170
|
+
])),
|
|
1171
|
+
modulation,
|
|
1172
|
+
mixer: {
|
|
1173
|
+
volume: state[`${prefix}volume`],
|
|
1174
|
+
pan: state[`${prefix}pan`],
|
|
1175
|
+
gain: state[`${prefix}gainGain`],
|
|
1176
|
+
},
|
|
1177
|
+
};
|
|
1178
|
+
});
|
|
1179
|
+
return json({
|
|
1180
|
+
product: manifest.name,
|
|
1181
|
+
bpm: state.bpm ?? transport.bpm,
|
|
1182
|
+
playing: transport.playing,
|
|
1183
|
+
masterFx: {
|
|
1184
|
+
compressorEnabled: state.masterCompressorEnabled,
|
|
1185
|
+
limiterEnabled: state.masterLimiterEnabled,
|
|
1186
|
+
tapeEnabled: state.masterTapeEnabled,
|
|
1187
|
+
},
|
|
1188
|
+
totals: {
|
|
1189
|
+
cellsWithSamples: cells.filter((c) => c.sample.path).length,
|
|
1190
|
+
activePatternEvents: cells.reduce((sum, c) => sum + Object.values(c.patternEvents).reduce((a, b) => a + b, 0), 0),
|
|
1191
|
+
modulationLinks: cells.reduce((sum, c) => sum + Object.values(c.modulation).reduce((a, b) => a + b, 0), 0),
|
|
1192
|
+
},
|
|
1193
|
+
cells,
|
|
1194
|
+
});
|
|
445
1195
|
});
|
|
446
1196
|
server.registerTool("set_parameters", {
|
|
447
1197
|
title: "Set Parameters",
|
|
448
1198
|
description: "Set one or more PAM parameters. Values are validated against the manifest. " +
|
|
449
|
-
"
|
|
1199
|
+
"Effect enable flags such as cell1_filterEnabled are settable here too, so params + enables can land in one call " +
|
|
1200
|
+
"when a graph dispatch is acceptable. Use get_parameter_info or design_context to discover valid IDs and ranges.",
|
|
450
1201
|
inputSchema: {
|
|
451
1202
|
parameters: z
|
|
452
1203
|
.record(z.string(), z.number())
|
|
@@ -460,11 +1211,11 @@ server.registerTool("set_parameters", {
|
|
|
460
1211
|
for (const [paramId, value] of Object.entries(parameters)) {
|
|
461
1212
|
const p = index.get(paramId);
|
|
462
1213
|
if (!p) {
|
|
463
|
-
errors.push(
|
|
1214
|
+
errors.push(formatUnknownParamError(paramId, index));
|
|
464
1215
|
continue;
|
|
465
1216
|
}
|
|
466
1217
|
if (value < p.min || value > p.max) {
|
|
467
|
-
errors.push(
|
|
1218
|
+
errors.push(formatRangeError(p, value));
|
|
468
1219
|
}
|
|
469
1220
|
}
|
|
470
1221
|
if (errors.length > 0)
|
|
@@ -542,11 +1293,21 @@ server.registerTool("get_model_status", {
|
|
|
542
1293
|
title: "Get AI Model Status",
|
|
543
1294
|
description: "Report downloaded / loaded / loading / download-in-flight + version + disk size + live download progress for an AI engine.",
|
|
544
1295
|
inputSchema: { engine: MODEL_ENGINE },
|
|
1296
|
+
outputSchema: {
|
|
1297
|
+
engine: z.string().optional(),
|
|
1298
|
+
enabled: z.boolean().optional(),
|
|
1299
|
+
downloaded: z.boolean().optional(),
|
|
1300
|
+
loaded: z.boolean().optional(),
|
|
1301
|
+
inFlight: z.boolean().optional(),
|
|
1302
|
+
version: z.string().optional(),
|
|
1303
|
+
state: z.string().optional(),
|
|
1304
|
+
progress: z.number().optional(),
|
|
1305
|
+
},
|
|
545
1306
|
}, async ({ engine }) => {
|
|
546
1307
|
const check = await requireBridge();
|
|
547
1308
|
if (check !== true)
|
|
548
1309
|
return check;
|
|
549
|
-
return
|
|
1310
|
+
return structuredFromBridge(await bridge.send("modelStatus", { engine }));
|
|
550
1311
|
});
|
|
551
1312
|
server.registerTool("load_model", {
|
|
552
1313
|
title: "Load AI Model",
|
|
@@ -798,11 +1559,15 @@ server.registerTool("workbench_sidecar_status", {
|
|
|
798
1559
|
title: "Workbench: Sidecar Status",
|
|
799
1560
|
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
1561
|
inputSchema: {},
|
|
1562
|
+
outputSchema: {
|
|
1563
|
+
connected: z.boolean().optional(),
|
|
1564
|
+
loaded: z.boolean().optional(),
|
|
1565
|
+
},
|
|
801
1566
|
}, async () => {
|
|
802
1567
|
const check = await requireBridge();
|
|
803
1568
|
if (check !== true)
|
|
804
1569
|
return check;
|
|
805
|
-
return
|
|
1570
|
+
return structuredFromBridge(await bridge.send("workbenchSidecarStatus"));
|
|
806
1571
|
});
|
|
807
1572
|
server.registerTool("trigger_midi", {
|
|
808
1573
|
title: "Trigger MIDI",
|
|
@@ -867,13 +1632,13 @@ server.registerTool("add_modulation", {
|
|
|
867
1632
|
.min(1)
|
|
868
1633
|
.max(8)
|
|
869
1634
|
.optional()
|
|
870
|
-
.describe("
|
|
1635
|
+
.describe("Required for envelope/vary: source cell driving the follower (1-8). This is not the target cell; target is inferred from paramId."),
|
|
871
1636
|
lfoIndex: z
|
|
872
1637
|
.number()
|
|
873
1638
|
.min(0)
|
|
874
1639
|
.max(5)
|
|
875
1640
|
.optional()
|
|
876
|
-
.describe("LFO generator index
|
|
1641
|
+
.describe("Required for LFO: generator index 0-5. Parameter IDs remain 1-based (lfo1_... through lfo6_...)."),
|
|
877
1642
|
strength: z
|
|
878
1643
|
.number()
|
|
879
1644
|
.min(0)
|
|
@@ -898,9 +1663,9 @@ server.registerTool("add_modulation", {
|
|
|
898
1663
|
const index = await getParameterIndex();
|
|
899
1664
|
const param = index.get(paramId);
|
|
900
1665
|
if (!param)
|
|
901
|
-
return error(
|
|
1666
|
+
return error(formatUnknownParamError(paramId, index, { modulatableOnly: true }));
|
|
902
1667
|
if (!param.isModulatable)
|
|
903
|
-
return error(
|
|
1668
|
+
return error(formatNotModulatableError(paramId, index));
|
|
904
1669
|
const check = await requireBridge();
|
|
905
1670
|
if (check !== true)
|
|
906
1671
|
return check;
|
|
@@ -916,12 +1681,12 @@ server.registerTool("add_modulation", {
|
|
|
916
1681
|
}
|
|
917
1682
|
else if (type === "envelope") {
|
|
918
1683
|
if (!cellId)
|
|
919
|
-
return error("cellId is required for envelope modulation.");
|
|
1684
|
+
return error("cellId is required for envelope modulation. It is the source cell driving the follower; the target cell is inferred from paramId.");
|
|
920
1685
|
await bridge.send("addEnvelope", paramId, cellId, strength, bipolar, effectEnableParams, pitch ?? false, pitchDuck ?? false);
|
|
921
1686
|
}
|
|
922
1687
|
else if (type === "vary") {
|
|
923
1688
|
if (!cellId)
|
|
924
|
-
return error("cellId is required for vary modulation.");
|
|
1689
|
+
return error("cellId is required for vary modulation. It is the source cell driving the follower; the target cell is inferred from paramId.");
|
|
925
1690
|
await bridge.send("addVary", paramId, cellId, strength, bipolar, effectEnableParams);
|
|
926
1691
|
}
|
|
927
1692
|
return text(`Added ${type} modulation to ${paramId} (strength: ${strength}%).`);
|
|
@@ -994,12 +1759,17 @@ server.registerTool("update_pattern", {
|
|
|
994
1759
|
});
|
|
995
1760
|
server.registerTool("randomize", {
|
|
996
1761
|
title: "Randomize",
|
|
997
|
-
description: "Trigger PAM's randomization engine to generate random sounds and patterns."
|
|
1762
|
+
description: "Trigger PAM's randomization engine to generate random sounds and patterns. " +
|
|
1763
|
+
"target:'synth' runs the musical, engine-aware drum-synth randomizer (picks an engine + " +
|
|
1764
|
+
"shapes its params per cell) — it is routed through the WebView, so it requires PAM's window " +
|
|
1765
|
+
"to be OPEN (samples/sequences/full run natively and do not). target:'synth' needs PAM ≥ 1.13.0.",
|
|
998
1766
|
inputSchema: {
|
|
1767
|
+
// 'lfos'/'mods' added for the assistant surface (1.15.0) — WebView-routed
|
|
1768
|
+
// like 'synth'. The describe() text is pinned (exported toolpack bytes).
|
|
999
1769
|
target: z
|
|
1000
|
-
.enum(["full", "samples", "sequences"])
|
|
1770
|
+
.enum(["full", "samples", "sequences", "synth", "lfos", "mods"])
|
|
1001
1771
|
.default("full")
|
|
1002
|
-
.describe("What to randomize."),
|
|
1772
|
+
.describe("What to randomize. 'synth' = the built-in drum-synth machines (PAM window must be open)."),
|
|
1003
1773
|
cellIds: z
|
|
1004
1774
|
.array(z.number().min(1).max(8))
|
|
1005
1775
|
.optional()
|
|
@@ -1025,6 +1795,21 @@ server.registerTool("randomize", {
|
|
|
1025
1795
|
const check = await requireBridge();
|
|
1026
1796
|
if (check !== true)
|
|
1027
1797
|
return check;
|
|
1798
|
+
if (target === "synth") {
|
|
1799
|
+
// Routed to the canonical WebView randomizer (single source of truth) — needs the
|
|
1800
|
+
// window open. Empty array → all non-excluded cells.
|
|
1801
|
+
await bridge.send("randomizeDrumSynth", cellIds ?? []);
|
|
1802
|
+
return text(`Randomized drum synths${cellIds ? ` for cells ${cellIds.join(", ")}` : " (all cells)"}. ` +
|
|
1803
|
+
`(Drum-synth randomize runs in the WebView — if nothing changed, ensure PAM's window is open.)`);
|
|
1804
|
+
}
|
|
1805
|
+
if (target === "lfos" || target === "mods") {
|
|
1806
|
+
// The data-driven modulation randomizer lives in WebView JS
|
|
1807
|
+
// (interop.ts randomizeLfos/randomizeEnvelopes/randomizeVary) — routed
|
|
1808
|
+
// like 'synth'. Needs PAM ≥ 1.15.0 and the window open.
|
|
1809
|
+
await bridge.send("randomizeModulations", target, mutationAmount ?? -1);
|
|
1810
|
+
return text(`Randomized ${target === "lfos" ? "LFO modulation links" : "all modulation (LFOs, envelopes, vary)"}. ` +
|
|
1811
|
+
`(Runs in the WebView — if nothing changed, ensure PAM's window is open.)`);
|
|
1812
|
+
}
|
|
1028
1813
|
await bridge.send("randomize", target, cellIds ?? [], genreHint ?? "", bpmHint ?? -1, mutationAmount ?? -1);
|
|
1029
1814
|
return text(`Randomized ${target}${cellIds ? ` for cells ${cellIds.join(", ")}` : ""}.`);
|
|
1030
1815
|
});
|
|
@@ -1039,7 +1824,7 @@ server.registerTool("randomize_samples", {
|
|
|
1039
1824
|
genreHint: z
|
|
1040
1825
|
.string()
|
|
1041
1826
|
.optional()
|
|
1042
|
-
.describe("Genre hint to guide sample selection. Supported:
|
|
1827
|
+
.describe("Genre hint to guide sample selection. Supported: boom_bap (aliases hip-hop, lofi), trap, drill, house, techno, dnb (aliases jungle, breakbeat, breaks). Other values fall back to neutral role-based selection; omit to auto-detect from the library."),
|
|
1043
1828
|
bpmHint: z
|
|
1044
1829
|
.number()
|
|
1045
1830
|
.min(20)
|
|
@@ -1127,13 +1912,148 @@ server.registerTool("get_transport", {
|
|
|
1127
1912
|
title: "Get Transport",
|
|
1128
1913
|
description: "Get current transport state (BPM, playing, position).",
|
|
1129
1914
|
inputSchema: {},
|
|
1915
|
+
outputSchema: {
|
|
1916
|
+
bpm: z.number().optional(),
|
|
1917
|
+
isPlaying: z.boolean().optional(),
|
|
1918
|
+
ppqPosition: z.number().optional(),
|
|
1919
|
+
},
|
|
1130
1920
|
annotations: { readOnlyHint: true },
|
|
1131
1921
|
}, async () => {
|
|
1132
1922
|
const check = await requireBridge();
|
|
1133
1923
|
if (check !== true)
|
|
1134
1924
|
return check;
|
|
1135
1925
|
const transport = await bridge.send("getTransport");
|
|
1136
|
-
return
|
|
1926
|
+
return structuredFromBridge(transport);
|
|
1927
|
+
});
|
|
1928
|
+
server.registerTool("get_master_analysis", {
|
|
1929
|
+
title: "Get Master Analysis",
|
|
1930
|
+
description: "Read the master-bus loudness measurement (BS.1770-5 / EBU R128): momentary/short-term/integrated " +
|
|
1931
|
+
"LUFS (m/s/i), rolling 30 s ungated loudness (roll), loudness range (lra, stable after 60 s — " +
|
|
1932
|
+
"check lraStable), true peak dBTP (tp; tp3s = 3 s window), sample peak (sp), PSR/PLR crest " +
|
|
1933
|
+
"metrics, stereo correlation (corr; corrLow = <120 Hz band), elapsed ms since reset, and ageMs " +
|
|
1934
|
+
"(snapshot staleness). Absent field = no reading yet. Works headless (window can be closed); " +
|
|
1935
|
+
"audio must be flowing — start the transport or play something first. Guard rails for " +
|
|
1936
|
+
"mastering moves: keep tp <= -1 dBTP and PSR >= 8 in the loudest section.",
|
|
1937
|
+
inputSchema: {},
|
|
1938
|
+
annotations: { readOnlyHint: true },
|
|
1939
|
+
}, async () => {
|
|
1940
|
+
const check = await requireBridge();
|
|
1941
|
+
if (check !== true)
|
|
1942
|
+
return check;
|
|
1943
|
+
const analysis = await bridge.send("getMasterAnalysis");
|
|
1944
|
+
return json(analysis);
|
|
1945
|
+
});
|
|
1946
|
+
server.registerTool("get_mix_analysis", {
|
|
1947
|
+
title: "Get Per-Cell Mix Analysis",
|
|
1948
|
+
description: "Per-cell stem measurements from a faster-than-realtime offline bounce: LUFS (i/s/roll), LRA, " +
|
|
1949
|
+
"true/sample peak (tp/sp), PSR/PLR crest, stereo correlation, onset density, and a 25-band Bark " +
|
|
1950
|
+
"spectrum per cell, plus the full-mix master block. Pass refresh:true to run a new analysis " +
|
|
1951
|
+
"(renders each sounding cell soloed from bar 0 — takes a few seconds; audio does NOT need to be " +
|
|
1952
|
+
"playing). Cached result otherwise. Requires PAM ≥ 1.17.0. PAM only.",
|
|
1953
|
+
inputSchema: {
|
|
1954
|
+
refresh: z.boolean().optional().describe("Run a fresh analysis instead of returning the cached one."),
|
|
1955
|
+
durationSeconds: z.number().min(5).max(30).optional().describe("Analysis render length (default 8 bars at the current BPM, clamped 5-30 s)."),
|
|
1956
|
+
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)."),
|
|
1957
|
+
},
|
|
1958
|
+
}, async ({ refresh, durationSeconds, cellIds }) => {
|
|
1959
|
+
const check = await requireBridge();
|
|
1960
|
+
if (check !== true)
|
|
1961
|
+
return check;
|
|
1962
|
+
if (refresh) {
|
|
1963
|
+
const started = (await bridge.send("analyzeMix", { durationSeconds, cellIds }));
|
|
1964
|
+
if (started.status !== "started")
|
|
1965
|
+
return error("Mix analysis is busy — an offline render already owns the engine. Retry when it finishes.");
|
|
1966
|
+
const t0 = Date.now();
|
|
1967
|
+
while (Date.now() - t0 < 120_000) {
|
|
1968
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
1969
|
+
const status = (await bridge.send("getMixAnalysis"));
|
|
1970
|
+
if (status.status !== "running")
|
|
1971
|
+
return json(status);
|
|
1972
|
+
}
|
|
1973
|
+
return error("Mix analysis timed out after 120 s.");
|
|
1974
|
+
}
|
|
1975
|
+
const result = await bridge.send("getMixAnalysis");
|
|
1976
|
+
return json(result);
|
|
1977
|
+
});
|
|
1978
|
+
// Epoch of the last OFFLINE analysis suggest_mix applied moves from — the
|
|
1979
|
+
// stacking guard's memory (live payloads refresh their epoch, so they never
|
|
1980
|
+
// stay consumed). Session-scoped by design: a server restart just re-allows a
|
|
1981
|
+
// suggest, which the refresh-first workflow tolerates.
|
|
1982
|
+
let lastAppliedMixEpoch = -1;
|
|
1983
|
+
server.registerTool("suggest_mix", {
|
|
1984
|
+
title: "Suggest Mix Balance Moves",
|
|
1985
|
+
description: "Runs the Mix Assist rule engine over the latest per-cell analysis (get_mix_analysis): " +
|
|
1986
|
+
"partial-loudness fader balance, pan spread, bounded masking EQ cuts, and optional compressor " +
|
|
1987
|
+
"settings. Returns absolute moves with per-move rationale and guard notes; apply:true lands them " +
|
|
1988
|
+
"as one state update. Run get_mix_analysis with refresh:true first if the mix changed. " +
|
|
1989
|
+
"Requires PAM ≥ 1.17.0. PAM only.",
|
|
1990
|
+
inputSchema: {
|
|
1991
|
+
scopes: z
|
|
1992
|
+
.array(z.enum(["balance", "pan", "eq", "dynamics"]))
|
|
1993
|
+
.optional()
|
|
1994
|
+
.describe("Stages to run (default balance+pan+eq; dynamics is opt-in)."),
|
|
1995
|
+
apply: z.boolean().optional().describe("Apply the suggested moves to the running plugin."),
|
|
1996
|
+
},
|
|
1997
|
+
}, async ({ scopes, apply }) => {
|
|
1998
|
+
const check = await requireBridge();
|
|
1999
|
+
if (check !== true)
|
|
2000
|
+
return check;
|
|
2001
|
+
const r = (await bridge.send("getMixAnalysis"));
|
|
2002
|
+
if (!r?.available)
|
|
2003
|
+
return error("No mix analysis available — run get_mix_analysis with refresh:true first.");
|
|
2004
|
+
const analysis = parseMixAnalysisPayload(r.analysis);
|
|
2005
|
+
if (!analysis)
|
|
2006
|
+
return error("Cached mix analysis is malformed — re-run get_mix_analysis with refresh:true.");
|
|
2007
|
+
// Stacking guard (mirrors the panel's consumed-epoch gate): the engine's
|
|
2008
|
+
// deltas come from the analysis but land on the LIVE params, so suggesting
|
|
2009
|
+
// again from an analysis that was already applied doubles the correction.
|
|
2010
|
+
if (!analysis.live && analysis.epoch === lastAppliedMixEpoch)
|
|
2011
|
+
return error("This analysis was already applied — re-run get_mix_analysis with refresh:true to measure the new balance first.");
|
|
2012
|
+
const state = (await bridge.send("getState"));
|
|
2013
|
+
// Guard-rail parity with the in-app panel (context.ts contract): macros
|
|
2014
|
+
// count as modulation, and cells whose source was removed after the
|
|
2015
|
+
// analysis must not receive moves.
|
|
2016
|
+
const mods = {
|
|
2017
|
+
lfos: state.lfos,
|
|
2018
|
+
envelopes: state.envelopes,
|
|
2019
|
+
vary: state.vary,
|
|
2020
|
+
paramSeqs: state.paramSeqs,
|
|
2021
|
+
macros: state.macros,
|
|
2022
|
+
};
|
|
2023
|
+
const hasSource = (id) => {
|
|
2024
|
+
const cells = state.cells;
|
|
2025
|
+
if (cells?.[String(id)]?.filePath)
|
|
2026
|
+
return true;
|
|
2027
|
+
if (Number(state[`cell${id}_synthEngine`] ?? 0) > 0.5)
|
|
2028
|
+
return true;
|
|
2029
|
+
if (Number(state[`cell${id}_magentaEngine`] ?? 0) > 0.5)
|
|
2030
|
+
return true;
|
|
2031
|
+
const pluginPath = state[`cell${id}_pluginPath`];
|
|
2032
|
+
return typeof pluginPath === "string" && pluginPath.length > 0;
|
|
2033
|
+
};
|
|
2034
|
+
const measurement = buildMixMeasurementFromState(analysis, state, mods, { hasSource });
|
|
2035
|
+
const current = currentFlatFromState(analysis, state);
|
|
2036
|
+
// Rider ownership (the panel's parked-bal rule): while Auto Mix AUTO runs,
|
|
2037
|
+
// the cMixRide rider owns the balance — fader suggestions would
|
|
2038
|
+
// double-drive it on top of the signed trims.
|
|
2039
|
+
const riderOwnsBalance = Number(state.masterMixRideEnabled ?? 0) === 1 && Number(state.masterMixRideAuto ?? 0) === 1;
|
|
2040
|
+
let resolvedScopes = scopes ?? ["balance", "pan", "eq"];
|
|
2041
|
+
const balanceParked = riderOwnsBalance && resolvedScopes.includes("balance");
|
|
2042
|
+
if (balanceParked)
|
|
2043
|
+
resolvedScopes = resolvedScopes.filter((s) => s !== "balance");
|
|
2044
|
+
if (resolvedScopes.length === 0)
|
|
2045
|
+
return error("Auto Mix is running and owns the balance — request pan, eq, or dynamics scopes while it is on.");
|
|
2046
|
+
const suggestion = suggestMix(measurement, { scopes: resolvedScopes }, current);
|
|
2047
|
+
const parkedNote = balanceParked
|
|
2048
|
+
? { balanceParked: true, note: "Auto Mix AUTO owns the balance — balance scope was skipped." }
|
|
2049
|
+
: {};
|
|
2050
|
+
if (apply && suggestion.moves.length > 0) {
|
|
2051
|
+
await applyKv(suggestion.flat, KV_OPTS_STANDARD);
|
|
2052
|
+
if (!analysis.live)
|
|
2053
|
+
lastAppliedMixEpoch = analysis.epoch;
|
|
2054
|
+
return json({ ...suggestion, ...parkedNote, applied: true });
|
|
2055
|
+
}
|
|
2056
|
+
return json({ ...suggestion, ...parkedNote });
|
|
1137
2057
|
});
|
|
1138
2058
|
server.registerTool("new_preset", {
|
|
1139
2059
|
title: "New Preset",
|
|
@@ -1154,7 +2074,11 @@ server.registerTool("save_preset", {
|
|
|
1154
2074
|
"Writes to the user preset folder by default; pass `path` for a specific location. " +
|
|
1155
2075
|
"Use `update_preset_metadata` to edit an existing preset without re-saving plugin state.",
|
|
1156
2076
|
inputSchema: {
|
|
1157
|
-
|
|
2077
|
+
// Optional since 1.15.0: omitted name = re-save the ACTIVE user preset in
|
|
2078
|
+
// place (the UI Save button's semantics; the assistant's bare "save").
|
|
2079
|
+
// The describe() text is pinned — the exported assistant toolpack keeps
|
|
2080
|
+
// property descriptions, and the model is trained against those bytes.
|
|
2081
|
+
name: z.string().optional().describe("Preset name (used for filename if `path` omitted)."),
|
|
1158
2082
|
author: z.string().optional().describe("Author name."),
|
|
1159
2083
|
category: z
|
|
1160
2084
|
.string()
|
|
@@ -1179,26 +2103,56 @@ server.registerTool("save_preset", {
|
|
|
1179
2103
|
const check = await requireBridge();
|
|
1180
2104
|
if (check !== true)
|
|
1181
2105
|
return check;
|
|
2106
|
+
const state = (await bridge.send("getState"));
|
|
2107
|
+
// Bare save (no name): the UI Save button's semantics — overwrite the
|
|
2108
|
+
// ACTIVE user preset in place, preserving its on-disk metadata. Mirrors
|
|
2109
|
+
// the in-plugin assistant executor (error strings included — the model's
|
|
2110
|
+
// repair turns are trained on those shapes). Factory/Init need a name.
|
|
2111
|
+
const extras = rest;
|
|
2112
|
+
if (!name?.trim()) {
|
|
2113
|
+
const activePath = typeof state.activePresetPath === "string" ? state.activePresetPath : "";
|
|
2114
|
+
if (!activePath || activePath === "Init")
|
|
2115
|
+
return error('No preset is active — give this one a name, e.g. save_preset({ name: "Night Drive" }).');
|
|
2116
|
+
const readPath = normalizePresetLookupPath(activePath);
|
|
2117
|
+
if (readPath.startsWith("Factory/"))
|
|
2118
|
+
return error("The current preset is a factory patch — save a copy under a new name instead.");
|
|
2119
|
+
name = (activePath.split("/").pop() || "").replace(/\.preset$/, "");
|
|
2120
|
+
if (!name)
|
|
2121
|
+
return error(`Could not derive a name from the active preset path (${activePath}) — pass name explicitly.`);
|
|
2122
|
+
if (path === undefined)
|
|
2123
|
+
path = activePath;
|
|
2124
|
+
try {
|
|
2125
|
+
const parsed = parsePresetFile(await readFile(await resolvePresetPath(readPath), "utf-8"));
|
|
2126
|
+
for (const [k, v] of Object.entries(parsed.metadata)) {
|
|
2127
|
+
// Structural keys are rebuilt below; everything else (author/tags/
|
|
2128
|
+
// category/…) carries over unless the caller overrode it.
|
|
2129
|
+
if (["name", "factory", "path", "variations", "currentVariation"].includes(k))
|
|
2130
|
+
continue;
|
|
2131
|
+
if (extras[k] === undefined)
|
|
2132
|
+
extras[k] = v;
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
catch {
|
|
2136
|
+
// unreadable/new — save fresh with whatever the caller passed
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
1182
2139
|
const metadata = { name, factory: false };
|
|
1183
2140
|
if (path !== undefined)
|
|
1184
2141
|
metadata.path = path;
|
|
1185
|
-
for (const [k, v] of Object.entries(
|
|
2142
|
+
for (const [k, v] of Object.entries(extras)) {
|
|
1186
2143
|
if (v !== undefined)
|
|
1187
2144
|
metadata[k] = v;
|
|
1188
2145
|
}
|
|
1189
2146
|
// Match UI behavior: auto-save current state to variation slot 0 if no variations exist,
|
|
1190
2147
|
// otherwise update the current active variation slot.
|
|
1191
|
-
const state = (await bridge.send("getState"));
|
|
1192
2148
|
const variationString = buildVariationString(state);
|
|
1193
2149
|
const presetRelPath = path || `User/${name}.preset`;
|
|
1194
|
-
|
|
1195
|
-
//
|
|
1196
|
-
//
|
|
1197
|
-
//
|
|
1198
|
-
const
|
|
1199
|
-
|
|
1200
|
-
: presetRelPath;
|
|
1201
|
-
const existingVariations = await readExistingVariations(readPath);
|
|
2150
|
+
const readPath = normalizePresetLookupPath(presetRelPath);
|
|
2151
|
+
// Save As must copy the active preset's complete variation bank. Reading
|
|
2152
|
+
// the new destination starts from eight empty slots because that file does
|
|
2153
|
+
// not exist yet, which used to collapse every named save to one slot.
|
|
2154
|
+
const variationSourcePath = selectSaveVariationSourcePath(state.activePresetPath, presetRelPath);
|
|
2155
|
+
const existingVariations = await readExistingVariations(variationSourcePath);
|
|
1202
2156
|
const hasVariations = existingVariations.some((v) => v !== null);
|
|
1203
2157
|
if (!hasVariations) {
|
|
1204
2158
|
existingVariations[0] = variationString;
|
|
@@ -1212,6 +2166,8 @@ server.registerTool("save_preset", {
|
|
|
1212
2166
|
}
|
|
1213
2167
|
metadata.variations = existingVariations;
|
|
1214
2168
|
const result = (await bridge.send("savePreset", metadata));
|
|
2169
|
+
lastSavedPresetName = name;
|
|
2170
|
+
lastSavedPresetPath = result?.path || readPath;
|
|
1215
2171
|
return text(`Saved preset: ${name}\nPath: ${result?.path || "(unknown path)"}`);
|
|
1216
2172
|
});
|
|
1217
2173
|
server.registerTool("save_variation", {
|
|
@@ -1259,11 +2215,15 @@ server.registerTool("save_variation", {
|
|
|
1259
2215
|
// Strip "Presets/" prefix if present — resolvePresetPath already prepends
|
|
1260
2216
|
// the presets base directory, so "Presets/User/X" would double up to
|
|
1261
2217
|
// ".../Presets/Presets/User/X" and silently fail to read back variations.
|
|
1262
|
-
diskPath =
|
|
2218
|
+
diskPath = normalizePresetLookupPath(activePath);
|
|
2219
|
+
}
|
|
2220
|
+
if (!presetName && lastSavedPresetName) {
|
|
2221
|
+
presetName = lastSavedPresetName;
|
|
2222
|
+
diskPath = lastSavedPresetPath;
|
|
1263
2223
|
}
|
|
1264
2224
|
if (!presetName) {
|
|
1265
|
-
return
|
|
1266
|
-
"
|
|
2225
|
+
return error("No preset name provided and no active preset loaded.\n" +
|
|
2226
|
+
"Next call: save_preset({ name: 'Your Preset Name', ...metadata }) first, or call save_variation with name: 'Your Preset Name'.");
|
|
1267
2227
|
}
|
|
1268
2228
|
if (!diskPath) {
|
|
1269
2229
|
diskPath = `User/${presetName}.preset`;
|
|
@@ -1302,6 +2262,8 @@ server.registerTool("save_variation", {
|
|
|
1302
2262
|
resultSuffix = "\n(Note: active preset is a factory preset — saved as new user preset)";
|
|
1303
2263
|
}
|
|
1304
2264
|
const result = (await bridge.send("savePreset", metadata));
|
|
2265
|
+
lastSavedPresetName = presetName;
|
|
2266
|
+
lastSavedPresetPath = result?.path || diskPath;
|
|
1305
2267
|
return text(`Saved variation ${index} for "${presetName}"\n` +
|
|
1306
2268
|
`Path: ${result?.path || "(unknown path)"}\n` +
|
|
1307
2269
|
`Filled slots: ${variations.filter((v) => v !== null).length}/8` +
|
|
@@ -1419,30 +2381,141 @@ server.registerTool("reverse_sample", {
|
|
|
1419
2381
|
await bridge.send("reverseSampleForCell", cellId);
|
|
1420
2382
|
return text(`Reversed sample in cell ${cellId}.`);
|
|
1421
2383
|
});
|
|
2384
|
+
const BRIDGE_INSTANCE_SHAPE = z.object({
|
|
2385
|
+
port: z.number().optional(),
|
|
2386
|
+
instanceId: z.string().optional(),
|
|
2387
|
+
product: z.string().optional(),
|
|
2388
|
+
pid: z.number().optional(),
|
|
2389
|
+
});
|
|
1422
2390
|
server.registerTool("connection_status", {
|
|
1423
2391
|
title: "Connection Status",
|
|
1424
2392
|
description: "Check if the MCP server can connect to a running PAM plugin instance. " +
|
|
1425
2393
|
"Returns connection details or instructions for enabling the bridge.",
|
|
1426
2394
|
inputSchema: {},
|
|
2395
|
+
outputSchema: {
|
|
2396
|
+
instances: z.array(BRIDGE_INSTANCE_SHAPE.passthrough()).optional(),
|
|
2397
|
+
connected: z.boolean().optional(),
|
|
2398
|
+
connectedTo: BRIDGE_INSTANCE_SHAPE.passthrough().nullable().optional(),
|
|
2399
|
+
note: z.string().optional(),
|
|
2400
|
+
},
|
|
1427
2401
|
annotations: { readOnlyHint: true },
|
|
1428
2402
|
}, async () => {
|
|
1429
2403
|
const instances = await bridge.discover();
|
|
1430
2404
|
if (instances.length === 0) {
|
|
1431
|
-
return
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
"
|
|
1435
|
-
|
|
2405
|
+
return structured({
|
|
2406
|
+
instances: [],
|
|
2407
|
+
connected: false,
|
|
2408
|
+
note: "No running PAM instances found. To use live tools, PAM needs the MCP bridge enabled: " +
|
|
2409
|
+
"start PAM (standalone or in a DAW) and the bridge will write its port to ~/.pam-mcp-bridge.json. " +
|
|
2410
|
+
"Offline tools (get_parameter_info, list_presets, read_preset) work without a connection.",
|
|
2411
|
+
});
|
|
1436
2412
|
}
|
|
1437
2413
|
const connected = bridge.isConnected() || (await bridge.connect());
|
|
1438
|
-
|
|
2414
|
+
if (connected)
|
|
2415
|
+
syncProductHint();
|
|
2416
|
+
return structured({
|
|
1439
2417
|
instances,
|
|
1440
2418
|
connected,
|
|
2419
|
+
connectedTo: bridge.getConnectedInstance(),
|
|
1441
2420
|
note: connected
|
|
1442
|
-
?
|
|
2421
|
+
? instances.length > 1
|
|
2422
|
+
? "Connected. Multiple instances are running — use select_instance to drive a specific product."
|
|
2423
|
+
: "Connected to PAM. All tools available."
|
|
1443
2424
|
: "Found instances but could not connect. The plugin may have shut down.",
|
|
1444
2425
|
});
|
|
1445
2426
|
});
|
|
2427
|
+
server.registerTool("select_instance", {
|
|
2428
|
+
title: "Select Plugin Instance",
|
|
2429
|
+
description: "Choose which running plugin instance (PAM / CELL1 / MFX) the live tools drive when more than " +
|
|
2430
|
+
"one is running. Match by product name, instanceId, or port — connection_status lists them. " +
|
|
2431
|
+
"Reconnects if currently connected to a different instance, and points offline parameter " +
|
|
2432
|
+
"validation at the selected product's manifest. The PAM_BRIDGE_PRODUCT env var pins the same " +
|
|
2433
|
+
"choice at server launch.",
|
|
2434
|
+
inputSchema: {
|
|
2435
|
+
product: z.string().optional().describe("Product name: PAM, CELL1, or MFX (case-insensitive)."),
|
|
2436
|
+
instanceId: z.string().optional().describe("Exact instanceId from connection_status."),
|
|
2437
|
+
port: z.number().int().optional().describe("Exact TCP port from connection_status."),
|
|
2438
|
+
},
|
|
2439
|
+
outputSchema: {
|
|
2440
|
+
connected: z.boolean().optional(),
|
|
2441
|
+
connectedTo: BRIDGE_INSTANCE_SHAPE.passthrough().nullable().optional(),
|
|
2442
|
+
note: z.string().optional(),
|
|
2443
|
+
},
|
|
2444
|
+
}, async ({ product, instanceId, port }) => {
|
|
2445
|
+
if (!product && !instanceId && port === undefined) {
|
|
2446
|
+
return error("Pass at least one of product, instanceId, or port. Call connection_status to list running instances.");
|
|
2447
|
+
}
|
|
2448
|
+
const ok = await bridge.connect({ product, instanceId, port });
|
|
2449
|
+
if (!ok) {
|
|
2450
|
+
const instances = await bridge.discover();
|
|
2451
|
+
const listing = instances.length
|
|
2452
|
+
? ` Running instances: ${instances
|
|
2453
|
+
.map((i) => `${i.product} (port ${i.port}, id ${i.instanceId})`)
|
|
2454
|
+
.join("; ")}.`
|
|
2455
|
+
: " No running instances found.";
|
|
2456
|
+
return error(`No running instance matches that selector.${listing}`);
|
|
2457
|
+
}
|
|
2458
|
+
syncProductHint();
|
|
2459
|
+
return structured({
|
|
2460
|
+
connected: true,
|
|
2461
|
+
connectedTo: bridge.getConnectedInstance(),
|
|
2462
|
+
note: "Live tools now drive this instance.",
|
|
2463
|
+
});
|
|
2464
|
+
});
|
|
2465
|
+
// Bounded recursive name search under a library root (BFS — shallow matches
|
|
2466
|
+
// first), over the bridge's listDirectory. Mirrors the in-plugin executor's
|
|
2467
|
+
// searchLibrary (assistantExecutor.ts) so the assistant's trained browse chain
|
|
2468
|
+
// (list_samples{path,search} → load_sample of a path-joined result) behaves
|
|
2469
|
+
// identically through the dev server. Returned names are relative to the
|
|
2470
|
+
// searched root; listDirectory accepts base-relative paths, so recursion can
|
|
2471
|
+
// use each entry's `path` verbatim.
|
|
2472
|
+
async function searchSampleLibrary(rootPath, term, audioOnly) {
|
|
2473
|
+
const t = term.toLowerCase();
|
|
2474
|
+
const matches = [];
|
|
2475
|
+
const queue = [{ path: rootPath, prefix: "", depth: 0 }];
|
|
2476
|
+
const options = JSON.stringify({ audioOnly, sort: "name" });
|
|
2477
|
+
let dirReads = 0;
|
|
2478
|
+
while (queue.length && dirReads < 40 && matches.length < 60) {
|
|
2479
|
+
const { path, prefix, depth } = queue.shift();
|
|
2480
|
+
dirReads++;
|
|
2481
|
+
let entries = [];
|
|
2482
|
+
try {
|
|
2483
|
+
const res = await bridge.send("listDirectory", path, options);
|
|
2484
|
+
if (Array.isArray(res))
|
|
2485
|
+
entries = res;
|
|
2486
|
+
}
|
|
2487
|
+
catch {
|
|
2488
|
+
continue; // unreadable subdir — keep sweeping the rest
|
|
2489
|
+
}
|
|
2490
|
+
for (const e of entries) {
|
|
2491
|
+
const name = typeof e.name === "string" ? e.name : "";
|
|
2492
|
+
if (!name)
|
|
2493
|
+
continue;
|
|
2494
|
+
if (e.isDir) {
|
|
2495
|
+
if (depth < 3 && typeof e.path === "string")
|
|
2496
|
+
queue.push({ path: e.path, prefix: `${prefix}${name}/`, depth: depth + 1 });
|
|
2497
|
+
}
|
|
2498
|
+
else if (name.toLowerCase().includes(t)) {
|
|
2499
|
+
matches.push(`${prefix}${name}`);
|
|
2500
|
+
if (matches.length >= 60)
|
|
2501
|
+
break;
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
return matches;
|
|
2506
|
+
}
|
|
2507
|
+
// Search-result payloads feed the assistant's 256-token continuation round —
|
|
2508
|
+
// trim to that budget instead of letting the listing collapse to an opaque
|
|
2509
|
+
// truncation string (executor parity: assistantExecutor.ts fitListing).
|
|
2510
|
+
function fitListing(base, itemsKey, items, moreKey, total) {
|
|
2511
|
+
let page = items;
|
|
2512
|
+
for (;;) {
|
|
2513
|
+
const payload = { ...base, [itemsKey]: page, ...(total > page.length ? { [moreKey]: total - page.length } : {}) };
|
|
2514
|
+
if (JSON.stringify(payload).length <= 380 || page.length <= 2)
|
|
2515
|
+
return payload;
|
|
2516
|
+
page = page.slice(0, Math.max(2, Math.floor(page.length / 2)));
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
1446
2519
|
server.registerTool("list_samples", {
|
|
1447
2520
|
title: "List Samples",
|
|
1448
2521
|
description: "Browse sample library directories. IMPORTANT: Do NOT guess paths — always call " +
|
|
@@ -1474,12 +2547,25 @@ server.registerTool("list_samples", {
|
|
|
1474
2547
|
.max(200)
|
|
1475
2548
|
.default(50)
|
|
1476
2549
|
.describe("Maximum number of entries to return (1-200). Default: 50."),
|
|
2550
|
+
// Declared LAST so the exported assistant toolpack keeps its property
|
|
2551
|
+
// order byte-identical (export-toolpack.mjs overwrites this prop's text).
|
|
2552
|
+
search: z
|
|
2553
|
+
.string()
|
|
2554
|
+
.optional()
|
|
2555
|
+
.describe("Filter by file name, searched recursively under `path` (e.g. 'kick', '808')."),
|
|
1477
2556
|
},
|
|
1478
2557
|
annotations: { readOnlyHint: true },
|
|
1479
|
-
}, async ({ path: dirPath, audioOnly, sort, offset, limit }) => {
|
|
2558
|
+
}, async ({ path: dirPath, audioOnly, sort, offset, limit, search }) => {
|
|
1480
2559
|
const check = await requireBridge();
|
|
1481
2560
|
if (check !== true)
|
|
1482
2561
|
return check;
|
|
2562
|
+
const term = search?.trim();
|
|
2563
|
+
if (term) {
|
|
2564
|
+
const matches = await searchSampleLibrary(dirPath, term, audioOnly);
|
|
2565
|
+
// Executor-shaped payload; empty files:[] is a SUCCESS (the assistant
|
|
2566
|
+
// reports the miss in text instead of repair-looping).
|
|
2567
|
+
return json(fitListing({ path: dirPath }, "files", matches.slice(0, 10), "moreFiles", matches.length));
|
|
2568
|
+
}
|
|
1483
2569
|
const options = JSON.stringify({ audioOnly, sort });
|
|
1484
2570
|
const result = await bridge.send("listDirectory", dirPath, options);
|
|
1485
2571
|
if (!Array.isArray(result) || result.length === 0) {
|
|
@@ -1678,7 +2764,7 @@ const VIDEO_LAYER_FX = "hueRotate, glow, audioReact, vibration, chromaticAberrat
|
|
|
1678
2764
|
"mirror, trails(drawing/text), beam(drawing/text), shake, rolls, barrelBlur, edge, " +
|
|
1679
2765
|
"halftone, patches, brightnessContrast, lines, feedback, warp, datamosh, slicer, " +
|
|
1680
2766
|
"polarSlitScan, cellVoronoi, spectralZones, paletteLut, asciiDither, pixelSort, " +
|
|
1681
|
-
"cameraShake, blur";
|
|
2767
|
+
"cameraShake, blur, cymatics, lineJitter, crt, vhs";
|
|
1682
2768
|
// Output/global FX chain ids (videoStore, DEFAULT_VIDEO_FX_CONFIG).
|
|
1683
2769
|
const VIDEO_OUTPUT_FX = "lines, barrelBlur, brightnessContrast, edge, glow, shake, rgbShift, halftone, mirror, " +
|
|
1684
2770
|
"patches, pixelate, rolls, feedback, warp, datamosh, slicer, positionXY, speed, " +
|
|
@@ -1704,12 +2790,24 @@ async function getVideoStateRaw() {
|
|
|
1704
2790
|
return (typeof r === "string" ? JSON.parse(r) : r) ?? {};
|
|
1705
2791
|
}
|
|
1706
2792
|
const sendVideoCommand = (command) => bridge.send("videoCommand", command);
|
|
1707
|
-
|
|
1708
|
-
|
|
2793
|
+
let videoRequestCounter = 0;
|
|
2794
|
+
const mintVideoRequestId = () => `mcp-${process.pid}-${Date.now().toString(36)}-${++videoRequestCounter}`;
|
|
2795
|
+
/** Create ops are fire-and-forget. Ack-capable PAM builds record the command's
|
|
2796
|
+
* result in the snapshot as `lastCommandAck` keyed by our requestId — exact
|
|
2797
|
+
* under concurrent edits. Older builds never publish an ack, so the snapshot
|
|
2798
|
+
* id-set diff stays as the fallback. */
|
|
2799
|
+
async function resolveNewId(requestId, beforeIds, pick, ackIdKey, timeoutMs = 2500) {
|
|
1709
2800
|
const deadline = Date.now() + timeoutMs;
|
|
1710
2801
|
while (Date.now() < deadline) {
|
|
1711
2802
|
await sleep(120);
|
|
1712
2803
|
const s = await getVideoStateRaw();
|
|
2804
|
+
const ack = s.lastCommandAck;
|
|
2805
|
+
if (ack && ack.requestId === requestId) {
|
|
2806
|
+
const minted = ack.result?.[ackIdKey];
|
|
2807
|
+
if (typeof minted === "string" && minted.length > 0)
|
|
2808
|
+
return minted;
|
|
2809
|
+
return null; // acked but failed / no id — don't misattribute via the diff
|
|
2810
|
+
}
|
|
1713
2811
|
const found = pick(s).find((id) => !beforeIds.has(id));
|
|
1714
2812
|
if (found)
|
|
1715
2813
|
return found;
|
|
@@ -1720,9 +2818,14 @@ server.registerTool("get_video_state", {
|
|
|
1720
2818
|
title: "Get Video State",
|
|
1721
2819
|
description: "Read the full VX video scene: layers (in render order, top-first) with their " +
|
|
1722
2820
|
"per-layer FX chains, the output/global FX chain, audio-reactive bindings, " +
|
|
1723
|
-
"mod-matrix routes, the 3D model overlay,
|
|
1724
|
-
"
|
|
1725
|
-
"
|
|
2821
|
+
"mod-matrix routes, the 3D model overlay, and global render/sync settings " +
|
|
2822
|
+
"(incl. vxVariationFollowAudio + VX bar-sync). Also carries: vxVariations " +
|
|
2823
|
+
"(8-slot occupancy booleans) + currentVxVariation; the songMode arrangement " +
|
|
2824
|
+
"read-model (nodes with per-section VX slot assignments, compiled steps with " +
|
|
2825
|
+
"the VX slot each recalls, arrangementBars); and the poll targets for " +
|
|
2826
|
+
"long-running jobs — export (exportVideo), recording (start/stop/saveVideoRecording), " +
|
|
2827
|
+
"songExport (exportSong). Layer/FX/binding ids returned here are what the other " +
|
|
2828
|
+
"video tools address. If `available` is false, open PAM's window so the video UI mounts.",
|
|
1726
2829
|
inputSchema: {},
|
|
1727
2830
|
annotations: { readOnlyHint: true },
|
|
1728
2831
|
}, async () => {
|
|
@@ -1803,7 +2906,16 @@ server.registerTool("video_command", {
|
|
|
1803
2906
|
"setOutputFxEnabled{instanceId,enabled}, setOutputFxStrength{instanceId,strength}; " +
|
|
1804
2907
|
"addModRoute{route}, removeModRoute{id}, updateModRoute{id,updates}, clearModRoutes, " +
|
|
1805
2908
|
"setModEnabled{enabled}; setModel{sourcePath?,visible?,blendMode?,audioReactive?,autoRotate?,transform?}, " +
|
|
1806
|
-
"resetModel; setSettings{...
|
|
2909
|
+
"resetModel; setSettings{...incl vxVariationFollowAudio,vxBarSyncEnabled,vxBarSyncIntervalBars}, " +
|
|
2910
|
+
"setActiveCell{cellId} (deprecated no-op), resetWorkspace; " +
|
|
2911
|
+
"VX variation slots: saveVxVariation{index 0-7}, loadVxVariation{index}, clearVxVariation{index}, " +
|
|
2912
|
+
"fillVxVariationsRandomized (occupancy/current in get_video_state; or use the vx_variation tool); " +
|
|
2913
|
+
"export/record: exportVideo{durationInSeconds,encoderId?,filename?,fps?,width?,height?,bitrate?,audioBitDepth?}, " +
|
|
2914
|
+
"cancelVideoExport, startVideoRecording{encoderId?,saveMode?,fps?,barSync?,bars?,skipSilence?,normalize?}, " +
|
|
2915
|
+
"stopVideoRecording, saveVideoRecording (poll get_video_state export/recording); " +
|
|
2916
|
+
"song: setSongArrangement{sections:[{variationIndex,bars?,repeat?,muted?,probability?,presetPath?,label?,vxVariationIndex?,vxScenePath?}],connections?:[{from,to,probability?}],loop?,enabled?,crossfadeMs?}, " +
|
|
2917
|
+
"setSongNodeVx{nodeId|stepIndex,index|null,vxScenePath?}, exportSong{name?,bars?,normalize?,includeVideo?,encoderId?,strategy?}, " +
|
|
2918
|
+
"cancelSongExport (poll get_video_state songExport/songMode; or use the song_mode / export_song tools). " +
|
|
1807
2919
|
"Layer fx target a per-layer chain; output fx target the global chain (ids differ). " +
|
|
1808
2920
|
VIDEO_BIND_SOURCES,
|
|
1809
2921
|
inputSchema: {
|
|
@@ -1834,15 +2946,25 @@ server.registerTool("add_video_layer", {
|
|
|
1834
2946
|
.int()
|
|
1835
2947
|
.optional()
|
|
1836
2948
|
.describe("Data-index to insert at (default: top of stack)."),
|
|
2949
|
+
sourcePath: z
|
|
2950
|
+
.string()
|
|
2951
|
+
.optional()
|
|
2952
|
+
.describe("Absolute file path to image/video/model media to load into the new layer (no file picker). " +
|
|
2953
|
+
"Bytes are read natively and handed to the WebView as a blob. Large videos are bounded by maxBytes."),
|
|
2954
|
+
maxBytes: z
|
|
2955
|
+
.number()
|
|
2956
|
+
.optional()
|
|
2957
|
+
.describe("Max bytes to read for sourcePath (default ~16MB; raise for video, e.g. 50000000)."),
|
|
1837
2958
|
},
|
|
1838
|
-
}, async ({ kind, name, insertAt }) => {
|
|
2959
|
+
}, async ({ kind, name, insertAt, sourcePath, maxBytes }) => {
|
|
1839
2960
|
const check = await requireBridge();
|
|
1840
2961
|
if (check !== true)
|
|
1841
2962
|
return check;
|
|
1842
2963
|
const before = await getVideoStateRaw();
|
|
1843
2964
|
const beforeIds = new Set((before.layers ?? []).map((l) => l.id));
|
|
1844
|
-
|
|
1845
|
-
|
|
2965
|
+
const requestId = mintVideoRequestId();
|
|
2966
|
+
await sendVideoCommand({ op: "addLayer", kind, name, insertAt, sourcePath, maxBytes, requestId });
|
|
2967
|
+
const id = await resolveNewId(requestId, beforeIds, (s) => (s.layers ?? []).map((l) => l.id), "layerId");
|
|
1846
2968
|
return text(id
|
|
1847
2969
|
? `Added ${kind} layer: ${id}`
|
|
1848
2970
|
: `Added ${kind} layer (window may be closed; call get_video_state to confirm).`);
|
|
@@ -1890,6 +3012,23 @@ server.registerTool("update_video_layer", {
|
|
|
1890
3012
|
const result = await sendVideoCommand({ op: "updateLayer", layerId, patch });
|
|
1891
3013
|
return json(result);
|
|
1892
3014
|
});
|
|
3015
|
+
server.registerTool("set_video_layer_source", {
|
|
3016
|
+
title: "Set Video Layer Source",
|
|
3017
|
+
description: "Load image/video/model media into an existing VX layer from a file path — the headless " +
|
|
3018
|
+
"equivalent of the file picker. Reads bytes natively and assigns a blob the layer renders. " +
|
|
3019
|
+
"PAM's window must be open (VX renders in the WebView). Large videos: raise maxBytes.",
|
|
3020
|
+
inputSchema: {
|
|
3021
|
+
layerId: z.string().describe("Target layer id (from get_video_state / add_video_layer)."),
|
|
3022
|
+
sourcePath: z.string().describe("Absolute path to the media file."),
|
|
3023
|
+
maxBytes: z.number().optional().describe("Max bytes to read (default ~16MB; raise for video)."),
|
|
3024
|
+
},
|
|
3025
|
+
}, async ({ layerId, sourcePath, maxBytes }) => {
|
|
3026
|
+
const check = await requireBridge();
|
|
3027
|
+
if (check !== true)
|
|
3028
|
+
return check;
|
|
3029
|
+
await sendVideoCommand({ op: "setLayerSource", layerId, sourcePath, maxBytes });
|
|
3030
|
+
return { content: [{ type: "text", text: `Loading ${sourcePath} into layer ${layerId}.` }] };
|
|
3031
|
+
});
|
|
1893
3032
|
server.registerTool("remove_video_layer", {
|
|
1894
3033
|
title: "Remove Video Layer",
|
|
1895
3034
|
description: "Remove a layer (and its FX + bindings). The master layer cannot be removed.",
|
|
@@ -1946,8 +3085,9 @@ server.registerTool("add_video_fx", {
|
|
|
1946
3085
|
if (target === "output") {
|
|
1947
3086
|
const before = await getVideoStateRaw();
|
|
1948
3087
|
const beforeIds = new Set((before.outputFx ?? []).map((f) => f.instanceId));
|
|
1949
|
-
|
|
1950
|
-
|
|
3088
|
+
const requestId = mintVideoRequestId();
|
|
3089
|
+
await sendVideoCommand({ op: "addOutputFx", fxId: fxType, requestId });
|
|
3090
|
+
const id = await resolveNewId(requestId, beforeIds, (s) => (s.outputFx ?? []).map((f) => f.instanceId), "instanceId");
|
|
1951
3091
|
// Apply param overrides now that the instance exists.
|
|
1952
3092
|
if (params && id)
|
|
1953
3093
|
for (const [param, value] of Object.entries(params))
|
|
@@ -1959,11 +3099,12 @@ server.registerTool("add_video_fx", {
|
|
|
1959
3099
|
const before = await getVideoStateRaw();
|
|
1960
3100
|
const layer = (before.layers ?? []).find((l) => l.id === layerId);
|
|
1961
3101
|
const beforeIds = new Set((layer?.fx ?? []).map((f) => f.id));
|
|
1962
|
-
|
|
1963
|
-
|
|
3102
|
+
const requestId = mintVideoRequestId();
|
|
3103
|
+
await sendVideoCommand({ op: "addLayerFx", layerId, fxType, params, beforeFxId, requestId });
|
|
3104
|
+
const id = await resolveNewId(requestId, beforeIds, (s) => {
|
|
1964
3105
|
const l = (s.layers ?? []).find((x) => x.id === layerId);
|
|
1965
3106
|
return (l?.fx ?? []).map((f) => f.id);
|
|
1966
|
-
});
|
|
3107
|
+
}, "fxId");
|
|
1967
3108
|
return text(id ? `Added ${fxType} to layer ${layerId}: ${id}` : `Added ${fxType} (confirm via get_video_state).`);
|
|
1968
3109
|
});
|
|
1969
3110
|
server.registerTool("set_video_fx", {
|
|
@@ -2087,8 +3228,9 @@ server.registerTool("set_video_settings", {
|
|
|
2087
3228
|
title: "Set Video Settings",
|
|
2088
3229
|
description: "Set global VX render/sync settings: syncMode (off|beat-sync|beat-match), loopBars, " +
|
|
2089
3230
|
"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
|
-
"
|
|
3231
|
+
"designAspect (w/h number or null), letterboxColor / canvasColor (#hex), the " +
|
|
3232
|
+
"mod-matrix master enable, VX-variation follow-audio, and VX bar-sync (queue slot " +
|
|
3233
|
+
"loads on the next bar boundary while playing). resetWorkspace clears the " +
|
|
2092
3234
|
"scene (preserves the master layer).",
|
|
2093
3235
|
inputSchema: {
|
|
2094
3236
|
syncMode: z.enum(["off", "beat-sync", "beat-match"]).optional(),
|
|
@@ -2099,10 +3241,22 @@ server.registerTool("set_video_settings", {
|
|
|
2099
3241
|
letterboxColor: z.string().optional(),
|
|
2100
3242
|
canvasColor: z.string().optional(),
|
|
2101
3243
|
modEnabled: z.boolean().optional().describe("Mod-matrix master enable."),
|
|
2102
|
-
|
|
3244
|
+
vxVariationFollowAudio: z
|
|
3245
|
+
.boolean()
|
|
3246
|
+
.optional()
|
|
3247
|
+
.describe("VX variation slots follow audio variation switches (default true)."),
|
|
3248
|
+
vxBarSyncEnabled: z
|
|
3249
|
+
.boolean()
|
|
3250
|
+
.optional()
|
|
3251
|
+
.describe("Queue VX-local slot loads on the next bar-sync boundary while the transport plays."),
|
|
3252
|
+
vxBarSyncIntervalBars: z
|
|
3253
|
+
.number()
|
|
3254
|
+
.optional()
|
|
3255
|
+
.describe("VX bar-sync interval in bars (-1 = longest-pattern sentinel)."),
|
|
3256
|
+
activeCell: z.number().int().optional().describe("Deprecated/no-op: VX is a single global scene (no per-cell video)."),
|
|
2103
3257
|
resetWorkspace: z.boolean().optional().describe("If true, clear the whole scene first."),
|
|
2104
3258
|
},
|
|
2105
|
-
}, async ({ syncMode, loopBars, aspectRatio, renderQuality, designAspect, letterboxColor, canvasColor, modEnabled, activeCell, resetWorkspace }) => {
|
|
3259
|
+
}, async ({ syncMode, loopBars, aspectRatio, renderQuality, designAspect, letterboxColor, canvasColor, modEnabled, vxVariationFollowAudio, vxBarSyncEnabled, vxBarSyncIntervalBars, activeCell, resetWorkspace }) => {
|
|
2106
3260
|
const check = await requireBridge();
|
|
2107
3261
|
if (check !== true)
|
|
2108
3262
|
return check;
|
|
@@ -2127,10 +3281,75 @@ server.registerTool("set_video_settings", {
|
|
|
2127
3281
|
settings.letterboxColor = letterboxColor;
|
|
2128
3282
|
if (canvasColor !== undefined)
|
|
2129
3283
|
settings.canvasColor = canvasColor;
|
|
3284
|
+
if (vxVariationFollowAudio !== undefined)
|
|
3285
|
+
settings.vxVariationFollowAudio = vxVariationFollowAudio;
|
|
3286
|
+
if (vxBarSyncEnabled !== undefined)
|
|
3287
|
+
settings.vxBarSyncEnabled = vxBarSyncEnabled;
|
|
3288
|
+
if (vxBarSyncIntervalBars !== undefined)
|
|
3289
|
+
settings.vxBarSyncIntervalBars = vxBarSyncIntervalBars;
|
|
2130
3290
|
if (Object.keys(settings).length > 0)
|
|
2131
3291
|
await sendVideoCommand({ op: "setSettings", ...settings });
|
|
2132
3292
|
return text("Video settings updated.");
|
|
2133
3293
|
});
|
|
3294
|
+
server.registerTool("vx_variation", {
|
|
3295
|
+
title: "VX Variation Slots",
|
|
3296
|
+
description: "Manage the 8 VX (visual) variation slots — the visual siblings of the audio " +
|
|
3297
|
+
"variation slots (save_variation / load_variation). 'save' snapshots the live VX " +
|
|
3298
|
+
"scene into a slot; 'load' recalls one (an empty slot moves the index but keeps the " +
|
|
3299
|
+
"current visuals); 'clear' empties a slot; 'fill' saves 8 randomized scene variations " +
|
|
3300
|
+
"into all slots. With vxVariationFollowAudio on (default), audio variation switches — " +
|
|
3301
|
+
"including Song Mode section changes — recall the matching VX slot automatically, so " +
|
|
3302
|
+
"saving VX slots alongside audio slots gives every song section its own visuals " +
|
|
3303
|
+
"(pin a different slot per section via song_mode assignVx). WebView-routed: PAM's " +
|
|
3304
|
+
"window must be OPEN. Verify via get_video_state → vxVariations / currentVxVariation.",
|
|
3305
|
+
inputSchema: {
|
|
3306
|
+
op: z.enum(["save", "load", "clear", "fill"]).describe("Slot operation."),
|
|
3307
|
+
index: z
|
|
3308
|
+
.number()
|
|
3309
|
+
.int()
|
|
3310
|
+
.min(0)
|
|
3311
|
+
.max(7)
|
|
3312
|
+
.optional()
|
|
3313
|
+
.describe("Slot index 0-7 (required for save/load/clear; ignored for fill)."),
|
|
3314
|
+
},
|
|
3315
|
+
}, async ({ op, index }) => {
|
|
3316
|
+
const check = await requireBridge();
|
|
3317
|
+
if (check !== true)
|
|
3318
|
+
return check;
|
|
3319
|
+
if (op !== "fill" && typeof index !== "number")
|
|
3320
|
+
return error(`'${op}' requires index (0-7).`);
|
|
3321
|
+
const opMap = {
|
|
3322
|
+
save: "saveVxVariation",
|
|
3323
|
+
load: "loadVxVariation",
|
|
3324
|
+
clear: "clearVxVariation",
|
|
3325
|
+
fill: "fillVxVariationsRandomized",
|
|
3326
|
+
};
|
|
3327
|
+
await sendVideoCommand(op === "fill" ? { op: opMap.fill } : { op: opMap[op], index });
|
|
3328
|
+
// Fire-and-forget forward path — poll until the snapshot reflects the op
|
|
3329
|
+
// (save→occupied, clear→empty, load→index moved [sticky-empty rule], fill→all occupied).
|
|
3330
|
+
const expectMet = (s) => {
|
|
3331
|
+
const slots = Array.isArray(s.vxVariations) ? s.vxVariations : [];
|
|
3332
|
+
if (op === "save")
|
|
3333
|
+
return slots[index] === true;
|
|
3334
|
+
if (op === "clear")
|
|
3335
|
+
return slots[index] === false;
|
|
3336
|
+
if (op === "load")
|
|
3337
|
+
return s.currentVxVariation === index;
|
|
3338
|
+
return slots.length > 0 && slots.every(Boolean);
|
|
3339
|
+
};
|
|
3340
|
+
let state = await getVideoStateRaw();
|
|
3341
|
+
const deadline = Date.now() + 2500;
|
|
3342
|
+
while (state.available !== false && !expectMet(state) && Date.now() < deadline) {
|
|
3343
|
+
await sleep(150);
|
|
3344
|
+
state = await getVideoStateRaw();
|
|
3345
|
+
}
|
|
3346
|
+
if (state.available === false)
|
|
3347
|
+
return error("VX unavailable — open PAM's window (the video engine renders in the WebView).");
|
|
3348
|
+
const slots = Array.isArray(state.vxVariations) ? state.vxVariations : [];
|
|
3349
|
+
return text(`vx_variation ${op}${op === "fill" ? "" : ` slot ${index}`} ${expectMet(state) ? "applied" : "sent (state not yet reflecting it — window open? rejected op?)"}. ` +
|
|
3350
|
+
`Occupied slots: [${slots.map((o, i) => (o ? i : null)).filter((v) => v !== null).join(", ")}], ` +
|
|
3351
|
+
`current: ${String(state.currentVxVariation ?? "?")}.`);
|
|
3352
|
+
});
|
|
2134
3353
|
// ============================================================
|
|
2135
3354
|
// Composite cell-operation tools
|
|
2136
3355
|
// ============================================================
|
|
@@ -2152,6 +3371,10 @@ server.registerTool("reset_cell", {
|
|
|
2152
3371
|
const check = await requireBridge();
|
|
2153
3372
|
if (check !== true)
|
|
2154
3373
|
return check;
|
|
3374
|
+
const manifest = await loadManifestForProductGuard();
|
|
3375
|
+
const cellCheck = validateCellIdForManifest(cellId, manifest);
|
|
3376
|
+
if (cellCheck !== true)
|
|
3377
|
+
return cellCheck;
|
|
2155
3378
|
const [cellParams, envelopes] = await Promise.all([
|
|
2156
3379
|
getParametersByCell(`cell${cellId}`),
|
|
2157
3380
|
bridge.send("getState", "envelopes"),
|
|
@@ -2185,6 +3408,13 @@ server.registerTool("copy_cell", {
|
|
|
2185
3408
|
const check = await requireBridge();
|
|
2186
3409
|
if (check !== true)
|
|
2187
3410
|
return check;
|
|
3411
|
+
const manifest = await loadManifestForProductGuard();
|
|
3412
|
+
const sourceCheck = validateCellIdForManifest(sourceCellId, manifest);
|
|
3413
|
+
if (sourceCheck !== true)
|
|
3414
|
+
return sourceCheck;
|
|
3415
|
+
const targetCheck = validateCellIdForManifest(targetCellId, manifest);
|
|
3416
|
+
if (targetCheck !== true)
|
|
3417
|
+
return targetCheck;
|
|
2188
3418
|
// Full state needed: cell params live as flat top-level cellN_* keys, no section.
|
|
2189
3419
|
const state = (await bridge.send("getState"));
|
|
2190
3420
|
const srcPrefix = `cell${sourceCellId}_`;
|
|
@@ -2285,6 +3515,10 @@ server.registerTool("clear_modulations", {
|
|
|
2285
3515
|
const check = await requireBridge();
|
|
2286
3516
|
if (check !== true)
|
|
2287
3517
|
return check;
|
|
3518
|
+
const manifest = await loadManifestForProductGuard();
|
|
3519
|
+
const cellCheck = validateCellIdForManifest(cellId, manifest);
|
|
3520
|
+
if (cellCheck !== true)
|
|
3521
|
+
return cellCheck;
|
|
2288
3522
|
const [lfosRaw, varyRaw, envsRaw] = await Promise.all([
|
|
2289
3523
|
bridge.send("getState", "lfos"),
|
|
2290
3524
|
bridge.send("getState", "vary"),
|
|
@@ -2317,7 +3551,7 @@ server.registerTool("clear_modulations", {
|
|
|
2317
3551
|
});
|
|
2318
3552
|
server.registerTool("reset_patterns", {
|
|
2319
3553
|
title: "Reset Patterns",
|
|
2320
|
-
description: "Clear all sequencer lanes (note, pitch, slice, stretch) for a single cell.",
|
|
3554
|
+
description: "Clear all sequencer lanes (note, pitch, slice, stretch) for a single PAM/Cell1 sampler cell.",
|
|
2321
3555
|
inputSchema: {
|
|
2322
3556
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
2323
3557
|
},
|
|
@@ -2326,6 +3560,15 @@ server.registerTool("reset_patterns", {
|
|
|
2326
3560
|
const check = await requireBridge();
|
|
2327
3561
|
if (check !== true)
|
|
2328
3562
|
return check;
|
|
3563
|
+
const manifest = await loadManifestForProductGuard();
|
|
3564
|
+
const cellCheck = validateCellIdForManifest(cellId, manifest);
|
|
3565
|
+
if (cellCheck !== true)
|
|
3566
|
+
return cellCheck;
|
|
3567
|
+
const hasPatternLanes = (manifest?.stateVariables ?? []).some((entry) => entry.paramId === `cell${cellId}_midiSequence`);
|
|
3568
|
+
if (manifest && !hasPatternLanes) {
|
|
3569
|
+
const productName = manifest.name ?? "This product";
|
|
3570
|
+
return error(`${productName} has no sampler MIDI/pitch/slice/stretch pattern lanes.`);
|
|
3571
|
+
}
|
|
2329
3572
|
const delta = { __forceRender__: true };
|
|
2330
3573
|
for (const suffix of PATTERN_SUFFIXES) {
|
|
2331
3574
|
delta[`cell${cellId}_${suffix}`] = [];
|
|
@@ -2352,6 +3595,10 @@ server.registerTool("update_effect_config", {
|
|
|
2352
3595
|
const check = await requireBridge();
|
|
2353
3596
|
if (check !== true)
|
|
2354
3597
|
return check;
|
|
3598
|
+
const manifest = await loadManifestForProductGuard();
|
|
3599
|
+
const cellCheck = validateCellIdForManifest(cellId, manifest);
|
|
3600
|
+
if (cellCheck !== true)
|
|
3601
|
+
return cellCheck;
|
|
2355
3602
|
const delta = {};
|
|
2356
3603
|
for (const [key, value] of Object.entries(config)) {
|
|
2357
3604
|
if (value !== undefined)
|
|
@@ -2370,7 +3617,8 @@ server.registerTool("update_cell_config", {
|
|
|
2370
3617
|
title: "Update Cell Config",
|
|
2371
3618
|
description: "Update cell-level configuration properties like polyphony, MIDI settings, varispeed, and modulation latch. " +
|
|
2372
3619
|
"These are KV-only properties that don't appear in the parameter manifest. " +
|
|
2373
|
-
"Uses a lightweight KV path without graph rebuild."
|
|
3620
|
+
"Uses a lightweight KV path without graph rebuild. " +
|
|
3621
|
+
"When bound to MFX, this tool only accepts sidechainFrom; sampler/MIDI/instrument config is PAM/Cell1-only.",
|
|
2374
3622
|
inputSchema: {
|
|
2375
3623
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
2376
3624
|
isPoly: z.union([z.literal(0), z.literal(1)]).optional().describe("Polyphonic mode (0=mono, 1=poly)."),
|
|
@@ -2380,7 +3628,18 @@ server.registerTool("update_cell_config", {
|
|
|
2380
3628
|
modLatch: z.union([z.literal(0), z.literal(1)]).optional().describe("Latch slice/pitch/stretch modulation at trigger."),
|
|
2381
3629
|
varispeed: z.union([z.literal(0), z.literal(1)]).optional().describe("Varispeed mode (pitch = speed) vs independent pitch shift."),
|
|
2382
3630
|
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."),
|
|
3631
|
+
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."),
|
|
3632
|
+
sliceKeymapMode: z
|
|
3633
|
+
.union([z.literal(0), z.literal(1)])
|
|
3634
|
+
.optional()
|
|
3635
|
+
.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."),
|
|
3636
|
+
sliceTriggerLength: z
|
|
3637
|
+
.number()
|
|
3638
|
+
.int()
|
|
3639
|
+
.min(0)
|
|
3640
|
+
.max(2)
|
|
3641
|
+
.optional()
|
|
3642
|
+
.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)."),
|
|
2384
3643
|
scaleName: z.string().optional().describe("Scale name for pitch sequencer quantization (e.g. 'chromatic', 'major', 'minor')."),
|
|
2385
3644
|
scaleRoot: z.number().min(0).max(11).optional().describe("Root note of the scale (0=C, 1=C#, ..., 11=B)."),
|
|
2386
3645
|
pitchMode: z.number().int().min(0).max(3).optional().describe("Pitch engine: 0=Varispeed, 1=Formant, 2=Pitch Shift Quantize, 3=Varispeed Quantize."),
|
|
@@ -2401,6 +3660,10 @@ server.registerTool("update_cell_config", {
|
|
|
2401
3660
|
const check = await requireBridge();
|
|
2402
3661
|
if (check !== true)
|
|
2403
3662
|
return check;
|
|
3663
|
+
const manifest = await loadManifestForProductGuard();
|
|
3664
|
+
const cellCheck = validateCellIdForManifest(cellId, manifest);
|
|
3665
|
+
if (cellCheck !== true)
|
|
3666
|
+
return cellCheck;
|
|
2404
3667
|
const cellConfig = {};
|
|
2405
3668
|
const flatDelta = {};
|
|
2406
3669
|
for (const [key, value] of Object.entries(config)) {
|
|
@@ -2410,11 +3673,23 @@ server.registerTool("update_cell_config", {
|
|
|
2410
3673
|
if (Object.keys(cellConfig).length === 0) {
|
|
2411
3674
|
return error("At least one config property must be provided.");
|
|
2412
3675
|
}
|
|
3676
|
+
if (manifest && isMfxManifest(manifest)) {
|
|
3677
|
+
const unsupported = Object.keys(cellConfig).filter((key) => !MFX_CELL_CONFIG_KEYS.has(key));
|
|
3678
|
+
if (unsupported.length > 0) {
|
|
3679
|
+
return error("update_cell_config in MFX only accepts sidechainFrom. " +
|
|
3680
|
+
`Sampler/MIDI/instrument config keys are PAM/Cell1-only: ${unsupported.join(", ")}.`);
|
|
3681
|
+
}
|
|
3682
|
+
}
|
|
2413
3683
|
// Auto-enable compressor when setting sidechain source
|
|
2414
3684
|
if (cellConfig.sidechainFrom !== undefined && cellConfig.sidechainFrom !== null) {
|
|
2415
3685
|
flatDelta[`cell${cellId}_compressorEnabled`] = 1;
|
|
2416
3686
|
}
|
|
2417
|
-
|
|
3687
|
+
// Mirror interop.ts updateCellConfig: a pitch/stretch engine change OR a
|
|
3688
|
+
// sliceKeymapMode flip (which changes pitch interpretation) bumps
|
|
3689
|
+
// modeChangeSeq so the audio thread flushes its Signalsmith analysis state.
|
|
3690
|
+
if (cellConfig.pitchMode !== undefined ||
|
|
3691
|
+
cellConfig.stretchMode !== undefined ||
|
|
3692
|
+
cellConfig.sliceKeymapMode !== undefined) {
|
|
2418
3693
|
const next = (cellModeChangeSeq[cellId] ?? Date.now()) + 1;
|
|
2419
3694
|
cellModeChangeSeq[cellId] = next;
|
|
2420
3695
|
cellConfig.modeChangeSeq = next;
|
|
@@ -2426,11 +3701,50 @@ server.registerTool("update_cell_config", {
|
|
|
2426
3701
|
.join(", ");
|
|
2427
3702
|
return text(`Updated cell ${cellId} config: ${summary}`);
|
|
2428
3703
|
});
|
|
3704
|
+
server.registerTool("set_cell_mute", {
|
|
3705
|
+
title: "Mute / Solo Cell",
|
|
3706
|
+
description: "Mute, unmute, solo, or unsolo a cell in the mixer — the real channel mute/solo, cutting the whole channel " +
|
|
3707
|
+
"(source + all effect tails) rather than pulling the volume fader down. Soloing a cell silences every other " +
|
|
3708
|
+
"non-soloed cell until unsoloed. cell{N}_mute / cell{N}_solo are live-polled state variables (no graph rebuild). " +
|
|
3709
|
+
"Prefer this over set_parameter volume -60 when the user says mute, unmute, solo, unsolo, isolate, or 'just this cell'.",
|
|
3710
|
+
inputSchema: {
|
|
3711
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
3712
|
+
mute: z.boolean().optional().describe("Mute (true) or unmute (false) the cell's channel."),
|
|
3713
|
+
solo: z.boolean().optional().describe("Solo (true) or unsolo (false) the cell — solo silences all other non-soloed cells."),
|
|
3714
|
+
},
|
|
3715
|
+
}, async ({ cellId, mute, solo }) => {
|
|
3716
|
+
const check = await requireBridge();
|
|
3717
|
+
if (check !== true)
|
|
3718
|
+
return check;
|
|
3719
|
+
const manifest = await loadManifestForProductGuard();
|
|
3720
|
+
const cellCheck = validateCellIdForManifest(cellId, manifest);
|
|
3721
|
+
if (cellCheck !== true)
|
|
3722
|
+
return cellCheck;
|
|
3723
|
+
const delta = {};
|
|
3724
|
+
if (typeof mute === "boolean")
|
|
3725
|
+
delta[`cell${cellId}_mute`] = mute ? 1 : 0;
|
|
3726
|
+
if (typeof solo === "boolean")
|
|
3727
|
+
delta[`cell${cellId}_solo`] = solo ? 1 : 0;
|
|
3728
|
+
if (Object.keys(delta).length === 0) {
|
|
3729
|
+
return error("Provide mute and/or solo.");
|
|
3730
|
+
}
|
|
3731
|
+
// cell{N}_mute / cell{N}_solo are flat state vars projected to
|
|
3732
|
+
// cell{N}_config.enabled by KVStateSyncManager — same options the UI's
|
|
3733
|
+
// applyStateDelta uses (KV_OPTS_STANDARD); the projection needs
|
|
3734
|
+
// syncConfigResources, no graph rebuild required but the standard path is
|
|
3735
|
+
// the exact UI parity.
|
|
3736
|
+
await applyKv(delta, KV_OPTS_STANDARD);
|
|
3737
|
+
const summary = Object.entries(delta)
|
|
3738
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
3739
|
+
.join(", ");
|
|
3740
|
+
return text(`Cell ${cellId}: ${summary}`);
|
|
3741
|
+
});
|
|
2429
3742
|
server.registerTool("set_master_effects", {
|
|
2430
3743
|
title: "Set Master Effects",
|
|
2431
3744
|
description: "Configure the master bus Punch compressor, Limiter, and Tape colour. " +
|
|
2432
3745
|
"The Punch compressor is a musical bus compressor — use it for glue and pump. " +
|
|
2433
|
-
"The Limiter is a
|
|
3746
|
+
"The Limiter is a zero-latency brickwall safety stage (enable/disable only). " +
|
|
3747
|
+
"Check results with get_master_analysis (keep tp <= -1 dBTP, PSR >= 8 in the loudest section). " +
|
|
2434
3748
|
"The Tape is a vintage-tape coloration stage (ADAA tanh/asymmetric saturation + HF compression + wow/flutter), " +
|
|
2435
3749
|
"the single biggest lever for lo-fi / dusty / 'pushed' master character (hip-hop, lo-fi, Memphis, dub). " +
|
|
2436
3750
|
"Pass enabled flags to toggle each on/off. Pass parameters to shape the sound. " +
|
|
@@ -2438,7 +3752,7 @@ server.registerTool("set_master_effects", {
|
|
|
2438
3752
|
"medium release (80-200ms), ratio 4-8, autoMakeup true. " +
|
|
2439
3753
|
"For lo-fi/Memphis/dusty glue: tapeEnabled=1, tapeMode=1 (Lo-Fi), masterTapeDrive 0.55-0.7, " +
|
|
2440
3754
|
"masterTapeTone -0.4..-0.1 (darker), masterTapeWear 0.5-0.7 (wow/flutter). " +
|
|
2441
|
-
"NOTE: the Tape requires a PAM build that ships it (
|
|
3755
|
+
"NOTE: the Tape requires a PAM build that ships it (flat state key masterTapeEnabled — master-chain keys are not manifest params). On older builds these keys are ignored.",
|
|
2442
3756
|
inputSchema: {
|
|
2443
3757
|
compressorEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable Punch compressor (0=off, 1=on)."),
|
|
2444
3758
|
limiterEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable master Limiter (0=off, 1=on)."),
|
|
@@ -2453,13 +3767,46 @@ server.registerTool("set_master_effects", {
|
|
|
2453
3767
|
masterCompCeiling: z.number().min(-6).max(0).optional().describe("Output ceiling in dB (default -0.05)."),
|
|
2454
3768
|
masterCompInputGain: z.number().min(-24).max(24).optional().describe("Input gain in dB (default 0)."),
|
|
2455
3769
|
masterCompKnee: z.number().min(0).max(30).optional().describe("Knee width in dB (default 0)."),
|
|
3770
|
+
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)."),
|
|
3771
|
+
masterCompMix: z.number().min(0).max(1).optional().describe("Punch parallel dry/wet (0-1, default 1 = fully compressed)."),
|
|
2456
3772
|
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
3773
|
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
3774
|
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."),
|
|
3775
|
+
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
3776
|
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."),
|
|
3777
|
+
masterEqEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the master EQ (trim/tilt/shelves/bells)."),
|
|
3778
|
+
masterWidthEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the stereo utility (width + elliptical bass-mono)."),
|
|
3779
|
+
masterInputTrim: z.number().min(-24).max(24).optional().describe("Master input trim in dB (gain staging into the chain; default 0)."),
|
|
3780
|
+
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)."),
|
|
3781
|
+
masterEqLsFreq: z.number().min(20).max(500).optional().describe("Low-shelf corner Hz (default 90)."),
|
|
3782
|
+
masterEqLsGain: z.number().min(-12).max(12).optional().describe("Low-shelf gain dB (default 0)."),
|
|
3783
|
+
masterEqHsFreq: z.number().min(1000).max(16000).optional().describe("High-shelf corner Hz (default 8000)."),
|
|
3784
|
+
masterEqHsGain: z.number().min(-12).max(12).optional().describe("High-shelf gain dB (default 0)."),
|
|
3785
|
+
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."),
|
|
3786
|
+
masterBassMonoFreq: z.number().min(0).max(300).optional().describe("Elliptical bass-mono corner Hz; < 20 = off. Engage (~100-120) when corrLow < +0.7."),
|
|
3787
|
+
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."),
|
|
3788
|
+
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."),
|
|
3789
|
+
masterClipMix: z.number().min(0).max(1).optional().describe("Clipper parallel blend (1 = fully clipped)."),
|
|
3790
|
+
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."),
|
|
3791
|
+
masterIronEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the console/transformer iron colour stage (post-Tape, pre-Clipper; zero latency)."),
|
|
3792
|
+
masterIronDrive: z.number().min(0).max(12).optional().describe("Iron drive dB (default 0). Level-compensated — drive adds colour, not loudness."),
|
|
3793
|
+
masterIronAmount: z.number().min(0).max(1).optional().describe("Transformer (magnetizing-branch) depth 0..1 — LF-concentrated saturation, the 'iron' signature (default 0.5)."),
|
|
3794
|
+
masterIronMode: z.union([z.literal(0), z.literal(1)]).optional().describe("0=Console (amp-forward, subtle), 1=Transformer (heavier LF iron)."),
|
|
3795
|
+
masterLoudnessAutoReset: z.union([z.literal(0), z.literal(1)]).optional().describe("Loudness meter auto-reset on host transport start (default 1). 0 keeps measurements running across transport stops."),
|
|
3796
|
+
masterEqBells: z
|
|
3797
|
+
.array(z.object({
|
|
3798
|
+
freq: z.number().min(30).max(16000).optional(),
|
|
3799
|
+
gain: z.number().min(-12).max(12).optional(),
|
|
3800
|
+
q: z.number().min(0.3).max(8).optional(),
|
|
3801
|
+
dyn: z.union([z.literal(0), z.literal(1)]).optional().describe("Dynamic option: the band's gain rides the in-band level."),
|
|
3802
|
+
dynThresh: z.number().min(-60).max(0).optional().describe("Dynamic threshold dB (default -24)."),
|
|
3803
|
+
dynRange: z.number().min(-12).max(12).optional().describe("Dynamic range dB (negative ducks when loud — de-harsh; default -3)."),
|
|
3804
|
+
}))
|
|
3805
|
+
.max(4)
|
|
3806
|
+
.optional()
|
|
3807
|
+
.describe("The 4 parametric bells in order (defaults 200/700/2000/5000 Hz). Pass null-ish entries to skip a band."),
|
|
2461
3808
|
},
|
|
2462
|
-
}, async ({ compressorEnabled, limiterEnabled, tapeEnabled, ...effectParams }) => {
|
|
3809
|
+
}, async ({ compressorEnabled, limiterEnabled, tapeEnabled, masterEqBells, ...effectParams }) => {
|
|
2463
3810
|
const check = await requireBridge();
|
|
2464
3811
|
if (check !== true)
|
|
2465
3812
|
return check;
|
|
@@ -2471,8 +3818,28 @@ server.registerTool("set_master_effects", {
|
|
|
2471
3818
|
delta.masterLimiterEnabled = limiterEnabled;
|
|
2472
3819
|
if (tapeEnabled !== undefined)
|
|
2473
3820
|
delta.masterTapeEnabled = tapeEnabled;
|
|
2474
|
-
//
|
|
2475
|
-
|
|
3821
|
+
// Bell array → flat masterEqB{n}{Field} keys.
|
|
3822
|
+
if (masterEqBells) {
|
|
3823
|
+
masterEqBells.forEach((bell, i) => {
|
|
3824
|
+
if (!bell)
|
|
3825
|
+
return;
|
|
3826
|
+
const base = `masterEqB${i + 1}`;
|
|
3827
|
+
if (bell.freq !== undefined)
|
|
3828
|
+
delta[`${base}Freq`] = bell.freq;
|
|
3829
|
+
if (bell.gain !== undefined)
|
|
3830
|
+
delta[`${base}Gain`] = bell.gain;
|
|
3831
|
+
if (bell.q !== undefined)
|
|
3832
|
+
delta[`${base}Q`] = bell.q;
|
|
3833
|
+
if (bell.dyn !== undefined)
|
|
3834
|
+
delta[`${base}Dyn`] = bell.dyn;
|
|
3835
|
+
if (bell.dynThresh !== undefined)
|
|
3836
|
+
delta[`${base}DynThresh`] = bell.dynThresh;
|
|
3837
|
+
if (bell.dynRange !== undefined)
|
|
3838
|
+
delta[`${base}DynRange`] = bell.dynRange;
|
|
3839
|
+
});
|
|
3840
|
+
}
|
|
3841
|
+
// Compressor + tape + limiter + EQ parameters are already the flat state
|
|
3842
|
+
// keys read by the DSP graph / native master nodes. Pass through.
|
|
2476
3843
|
for (const [key, value] of Object.entries(effectParams)) {
|
|
2477
3844
|
if (value !== undefined)
|
|
2478
3845
|
delta[key] = value;
|
|
@@ -2480,8 +3847,10 @@ server.registerTool("set_master_effects", {
|
|
|
2480
3847
|
if (Object.keys(delta).length === 0) {
|
|
2481
3848
|
return error("At least one parameter must be provided.");
|
|
2482
3849
|
}
|
|
2483
|
-
//
|
|
2484
|
-
|
|
3850
|
+
// No-dispatch path: every master key is live-polled from master_config
|
|
3851
|
+
// (the full Punch surface included), so a graph rebuild here would only
|
|
3852
|
+
// risk dropouts during automated mastering.
|
|
3853
|
+
await applyKv(delta, KV_OPTS_MASTER_CONFIG);
|
|
2485
3854
|
const summary = Object.entries(delta)
|
|
2486
3855
|
.map(([k, v]) => `${k}=${v}`)
|
|
2487
3856
|
.join(", ");
|
|
@@ -2520,7 +3889,7 @@ server.registerTool("set_drum_synth", {
|
|
|
2520
3889
|
"only the ones you want to nuance — unspecified params seed to the engine's musical default. Per-engine p-labels: " +
|
|
2521
3890
|
DRUM_SYNTH_ENGINES.map((e) => `${e.name}=[${e.params.map((p) => p[0]).join(",")}]`).join("; ") + ". " +
|
|
2522
3891
|
"NOTE: requires a PAM build with drum synth (manifest.drumSynth.enabled) — a standard PAM feature, no settings flag. " +
|
|
2523
|
-
"On
|
|
3892
|
+
"On products without it, this tool returns a product error instead of writing inert synth keys.",
|
|
2524
3893
|
inputSchema: {
|
|
2525
3894
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
2526
3895
|
engine: z.enum(DRUM_SYNTH_ENGINE_NAMES).describe("Engine name. 'Off' disables the synth voice (sample-only cell)."),
|
|
@@ -2538,6 +3907,13 @@ server.registerTool("set_drum_synth", {
|
|
|
2538
3907
|
const check = await requireBridge();
|
|
2539
3908
|
if (check !== true)
|
|
2540
3909
|
return check;
|
|
3910
|
+
const manifest = await loadManifestForProductGuard();
|
|
3911
|
+
const cellCheck = validateCellIdForManifest(cellId, manifest);
|
|
3912
|
+
if (cellCheck !== true)
|
|
3913
|
+
return cellCheck;
|
|
3914
|
+
if (manifest && manifest.drumSynth?.enabled !== true) {
|
|
3915
|
+
return error("set_drum_synth is PAM-only. This product manifest does not expose the built-in drum synth.");
|
|
3916
|
+
}
|
|
2541
3917
|
const engineId = DRUM_SYNTH_ENGINE_NAMES.indexOf(engine);
|
|
2542
3918
|
if (engineId < 0)
|
|
2543
3919
|
return error(`Unknown drum synth engine: ${engine}`);
|
|
@@ -2564,6 +3940,44 @@ server.registerTool("set_drum_synth", {
|
|
|
2564
3940
|
const labels = engineId > 0 ? DRUM_SYNTH_ENGINES[engineId - 1].params.map((p) => p[0]).join(",") : "—";
|
|
2565
3941
|
return text(`Cell ${cellId} drum synth → ${engine}${engineId > 0 ? ` (p1..pN = ${labels})` : ""}.`);
|
|
2566
3942
|
});
|
|
3943
|
+
server.registerTool("set_drum_synth_wave", {
|
|
3944
|
+
title: "Set Drum Synth Wave Curve (Wave engine)",
|
|
3945
|
+
description: "Set the custom single-cycle waveform for a cell's drum-synth WAVE engine (engine #16). " +
|
|
3946
|
+
"Provide 2–16 breakpoints { t, v, tension } (t = phase 0..1, v = amplitude 0..1 with 0.5 the zero-crossing " +
|
|
3947
|
+
"centre, tension −1..+1 with 0 = linear). PAM bakes them into a DC-removed, peak-normalized single cycle " +
|
|
3948
|
+
"that the Wave engine's Draw param (p8) crossfades against the factory bank. Only the Wave engine reads it — " +
|
|
3949
|
+
"select 'Wave' via set_drum_synth and raise Draw (p8) to hear it. Runs in the WebView (the curve transform " +
|
|
3950
|
+
"is single-sourced there), so PAM's window must be OPEN. Needs PAM ≥ 1.13.0.",
|
|
3951
|
+
inputSchema: {
|
|
3952
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
3953
|
+
points: z
|
|
3954
|
+
.array(z.object({
|
|
3955
|
+
t: z.number().min(0).max(1).describe("Phase position 0..1 (left→right)."),
|
|
3956
|
+
v: z.number().min(0).max(1).describe("Amplitude 0..1 (0.5 = zero-crossing centre)."),
|
|
3957
|
+
tension: z.number().min(-1).max(1).default(0).describe("Segment curve −1..+1 (0 = linear)."),
|
|
3958
|
+
}))
|
|
3959
|
+
.min(2)
|
|
3960
|
+
.max(16)
|
|
3961
|
+
.describe("2–16 breakpoints defining the single cycle, ordered left→right by t."),
|
|
3962
|
+
},
|
|
3963
|
+
}, async ({ cellId, points }) => {
|
|
3964
|
+
const check = await requireBridge();
|
|
3965
|
+
if (check !== true)
|
|
3966
|
+
return check;
|
|
3967
|
+
const manifest = await loadManifestForProductGuard();
|
|
3968
|
+
const cellCheck = validateCellIdForManifest(cellId, manifest);
|
|
3969
|
+
if (cellCheck !== true)
|
|
3970
|
+
return cellCheck;
|
|
3971
|
+
if (manifest && manifest.drumSynth?.enabled !== true) {
|
|
3972
|
+
return error("set_drum_synth_wave is PAM-only. This product manifest does not expose the built-in drum synth.");
|
|
3973
|
+
}
|
|
3974
|
+
await bridge.send("setDrumSynthWaveCurve", {
|
|
3975
|
+
cellId,
|
|
3976
|
+
points: points.map((p) => ({ t: p.t, v: p.v, tension: p.tension ?? 0 })),
|
|
3977
|
+
});
|
|
3978
|
+
return text(`Set cell ${cellId} Wave-engine custom waveform (${points.length} breakpoints). ` +
|
|
3979
|
+
`Select the Wave engine and raise Draw (p8) to hear it. (Runs in the WebView — ensure PAM's window is open.)`);
|
|
3980
|
+
});
|
|
2567
3981
|
server.registerTool("add_param_seq", {
|
|
2568
3982
|
title: "Add Parameter Sequencer",
|
|
2569
3983
|
description: "Add a parameter sequencer (step modulation) targeting any modulatable parameter. " +
|
|
@@ -2586,8 +4000,11 @@ server.registerTool("add_param_seq", {
|
|
|
2586
4000
|
if (check !== true)
|
|
2587
4001
|
return check;
|
|
2588
4002
|
const index = await getParameterIndex();
|
|
2589
|
-
|
|
2590
|
-
|
|
4003
|
+
const param = index.get(paramId);
|
|
4004
|
+
if (!param)
|
|
4005
|
+
return error(formatUnknownParamError(paramId, index, { modulatableOnly: true }));
|
|
4006
|
+
if (!param.isModulatable)
|
|
4007
|
+
return error(formatNotModulatableError(paramId, index));
|
|
2591
4008
|
const existing = await bridge.send("getState", "paramSeqs");
|
|
2592
4009
|
const paramSeqs = (Array.isArray(existing) ? existing : []).filter((seq) => seq?.paramId !== paramId);
|
|
2593
4010
|
const newSeq = {
|
|
@@ -2696,11 +4113,30 @@ async function pollExportStatus(maxWaitMs = 180_000, pollMs = 500) {
|
|
|
2696
4113
|
while (Date.now() - t0 < maxWaitMs) {
|
|
2697
4114
|
await new Promise((r) => setTimeout(r, pollMs));
|
|
2698
4115
|
const status = (await bridge.send("getExportStatus"));
|
|
2699
|
-
if (status.complete)
|
|
4116
|
+
if (status.complete) {
|
|
4117
|
+
lastExportLoudness = status.loudness ?? null;
|
|
2700
4118
|
return status.success ? "success" : "failed";
|
|
4119
|
+
}
|
|
2701
4120
|
}
|
|
2702
4121
|
return "timeout";
|
|
2703
4122
|
}
|
|
4123
|
+
// Loudness report of the last completed bounce (set by pollExportStatus).
|
|
4124
|
+
let lastExportLoudness = null;
|
|
4125
|
+
function loudnessReportLine() {
|
|
4126
|
+
const l = lastExportLoudness;
|
|
4127
|
+
if (!l || typeof l.i !== "number")
|
|
4128
|
+
return "";
|
|
4129
|
+
const parts = [`${l.i.toFixed(1)} LUFS-I`];
|
|
4130
|
+
if (typeof l.lra === "number")
|
|
4131
|
+
parts.push(`LRA ${l.lra.toFixed(1)}`);
|
|
4132
|
+
if (typeof l.tp === "number")
|
|
4133
|
+
parts.push(`TP ${l.tp.toFixed(1)} dBTP`);
|
|
4134
|
+
if (typeof l.psr === "number")
|
|
4135
|
+
parts.push(`PSR ${l.psr.toFixed(1)}`);
|
|
4136
|
+
if (typeof l.plr === "number")
|
|
4137
|
+
parts.push(`PLR ${l.plr.toFixed(1)}`);
|
|
4138
|
+
return `\nLoudness: ${parts.join(" · ")}`;
|
|
4139
|
+
}
|
|
2704
4140
|
server.registerTool("export_audio", {
|
|
2705
4141
|
title: "Export Audio",
|
|
2706
4142
|
description: "Render and export audio from the current preset to a WAV file in PAM's Exports folder. " +
|
|
@@ -2709,9 +4145,9 @@ server.registerTool("export_audio", {
|
|
|
2709
4145
|
"Modes: 'main' (full mix, default) · 'individual' (one cell solo, needs cellId) · " +
|
|
2710
4146
|
"'all-cells' / 'selected-cells' (per-cell stems, the latter needs selectedCellIds) · " +
|
|
2711
4147
|
"'per-bus' (per output bus) · 'song-mode' (the Song Mode timeline).\n\n" +
|
|
2712
|
-
"NOTE on 'song-mode': the
|
|
2713
|
-
"
|
|
2714
|
-
"
|
|
4148
|
+
"NOTE on 'song-mode': the offline bounce applies EVERY section on its anchor (PAM ≥ 1.5.7 with " +
|
|
4149
|
+
"offline song export; older builds render only the first section). Wall-clock sources " +
|
|
4150
|
+
"(Magenta/Notochord) render silent offline — use the `export_song` tool's live strategy for those.",
|
|
2715
4151
|
inputSchema: {
|
|
2716
4152
|
bars: z.number().min(1).max(128).default(4).describe("Bars to render (ignored for 'song-mode', which derives length from the timeline)."),
|
|
2717
4153
|
cellId: z
|
|
@@ -2756,7 +4192,8 @@ server.registerTool("export_audio", {
|
|
|
2756
4192
|
const outcome = await pollExportStatus();
|
|
2757
4193
|
if (outcome === "success")
|
|
2758
4194
|
return text(`Audio exported successfully.\nFile: ${outputPath}\n` +
|
|
2759
|
-
`Duration: ${durationSecs.toFixed(1)}s (${bars} bars at ${bpm} BPM, ${bitDepth}-bit)`
|
|
4195
|
+
`Duration: ${durationSecs.toFixed(1)}s (${bars} bars at ${bpm} BPM, ${bitDepth}-bit)` +
|
|
4196
|
+
loudnessReportLine());
|
|
2760
4197
|
if (outcome === "failed")
|
|
2761
4198
|
return error("Audio export failed.");
|
|
2762
4199
|
return text(`Export timed out. The render may still be running.\nExpected output: ${outputPath}`);
|
|
@@ -2787,6 +4224,7 @@ server.registerTool("export_audio", {
|
|
|
2787
4224
|
if (outcome === "success")
|
|
2788
4225
|
return text(`Export complete (mode '${effectiveMode}').\n` +
|
|
2789
4226
|
`File(s) written to PAM's Exports folder, base name: ${filename}.wav` +
|
|
4227
|
+
loudnessReportLine() +
|
|
2790
4228
|
(effectiveMode === "song-mode"
|
|
2791
4229
|
? "\n\nReminder: the offline song-mode bounce captures only the first section's state. " +
|
|
2792
4230
|
"Use export_song for a full track with section changes."
|
|
@@ -2905,6 +4343,24 @@ Use \`update_pattern\` with \`type: "slice"\` to sequence slice changes over tim
|
|
|
2905
4343
|
- Different patternSpeed creates rate variation: slice lane at 2x speed = double-time slice changes
|
|
2906
4344
|
- Combine with \`prob\` field (0-1) on events for controlled randomness
|
|
2907
4345
|
|
|
4346
|
+
### Slice Keymap — Trigger Slices from MIDI
|
|
4347
|
+
Map incoming MIDI notes directly to slices (Octatrack / Geist style): the cell's Root note
|
|
4348
|
+
plays slice 1, root+1 semitone plays slice 2, and so on chromatically up. The slice
|
|
4349
|
+
sequencer lane above is ignored while this is on — the note picks the slice.
|
|
4350
|
+
|
|
4351
|
+
\`\`\`
|
|
4352
|
+
update_cell_config: cellId=1, sliceKeymapMode=1, polyRootNote=60, sliceTriggerLength=0
|
|
4353
|
+
trigger_midi: cellId=1, note=60 # -> slice 1
|
|
4354
|
+
trigger_midi: cellId=1, note=62 # -> slice 3
|
|
4355
|
+
\`\`\`
|
|
4356
|
+
- Requires the cell's sample to be sliced first. Notes outside the slice range are silent.
|
|
4357
|
+
- \`polyRootNote\` = the key that triggers slice 1.
|
|
4358
|
+
- \`sliceTriggerLength\`: 0=Boundary (stop at next slice edge), 1=Sample End, 2=Note Off (needs cell mode=Gate).
|
|
4359
|
+
- Set \`isPoly=1\` for polyphonic slice play (held notes layer); leave poly off for monophonic
|
|
4360
|
+
jamming (each new note chokes the previous slice). Slice Keymap works in both.
|
|
4361
|
+
- Each slice plays at its own per-slice pitch, so the keymap performs a chopped sample at
|
|
4362
|
+
native rate. Played notes can be recorded into the cell's MIDI sequence and looped back.
|
|
4363
|
+
|
|
2908
4364
|
## Sidechain Compression — The Pump
|
|
2909
4365
|
|
|
2910
4366
|
### Setup via MCP
|
|
@@ -3186,6 +4642,86 @@ 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
4642
|
],
|
|
3187
4643
|
}));
|
|
3188
4644
|
// ============================================================
|
|
4645
|
+
// PROMPTS — guided workflows (client-invokable, e.g. slash
|
|
4646
|
+
// commands in Claude Code). Each stages the same flows the
|
|
4647
|
+
// README and tool descriptions teach, with exact tool names.
|
|
4648
|
+
// ============================================================
|
|
4649
|
+
const GENRE_SUGGESTIONS = ["boom_bap", "trap", "drill", "house", "techno", "dnb"];
|
|
4650
|
+
const completeGenre = (value) => GENRE_SUGGESTIONS.filter((g) => g.startsWith((value ?? "").toLowerCase()));
|
|
4651
|
+
// completable() tags the schema OBJECT with a symbol, so ordering is delicate:
|
|
4652
|
+
// .describe()/.optional() clone schemas and drop the tag, SDK 1.29 unwraps
|
|
4653
|
+
// optionals when DETECTING completable prompt args but not when RESOLVING the
|
|
4654
|
+
// completer at request time — the tag must sit on both the inner type and the
|
|
4655
|
+
// outer optional. Covered by the completion assertion in check-tool-surface's
|
|
4656
|
+
// sibling smoke; revisit when the SDK unifies the two lookups.
|
|
4657
|
+
const genreArg = completable(completable(z.string().describe("Genre hint, e.g. techno, house, dnb, trap, boom_bap, drill."), completeGenre).optional(), completeGenre);
|
|
4658
|
+
function promptText(text) {
|
|
4659
|
+
return {
|
|
4660
|
+
messages: [
|
|
4661
|
+
{ role: "user", content: { type: "text", text } },
|
|
4662
|
+
],
|
|
4663
|
+
};
|
|
4664
|
+
}
|
|
4665
|
+
server.registerPrompt("build-a-beat", {
|
|
4666
|
+
title: "Build a Beat",
|
|
4667
|
+
description: "Guided kit-building + pattern workflow: coherent samples, genre patterns, transport, refinement, save.",
|
|
4668
|
+
argsSchema: {
|
|
4669
|
+
genre: genreArg,
|
|
4670
|
+
bpm: z.string().optional().describe("Target tempo in BPM."),
|
|
4671
|
+
},
|
|
4672
|
+
}, ({ genre, bpm }) => promptText(`Build a beat in PAM${genre ? ` in the style of ${genre}` : ""}${bpm ? ` at ${bpm} BPM` : ""}. Work in stages, listening between them:\n\n` +
|
|
4673
|
+
`1. connection_status to confirm the bridge, then get_design_overview for the current state.\n` +
|
|
4674
|
+
`2. Load a kit: randomize_samples${genre ? ` with genreHint '${genre}'` : " (pass a genreHint)"} — the native engine picks role-coherent samples; prefer it over manual browsing. For specific sounds: get_default_locations → list_samples → load_sample.\n` +
|
|
4675
|
+
`3. ${bpm ? `set_bpm to ${bpm}` : "set_bpm to the genre's tempo"}, then randomize_patterns to generate genre-appropriate patterns from the loaded sample roles.\n` +
|
|
4676
|
+
`4. start_transport — sequencer output needs the transport rolling.\n` +
|
|
4677
|
+
`5. Refine: update_pattern for individual lanes, set_parameters for levels/filters (ranges via design_context), update_effect_config to switch FX on, add_modulation for movement.\n` +
|
|
4678
|
+
`6. save_preset once it works, and update_preset_metadata with genre tags and BPM.`));
|
|
4679
|
+
server.registerPrompt("make-a-song", {
|
|
4680
|
+
title: "Make a Song",
|
|
4681
|
+
description: "Arrange the current preset into sections with variations + Song Mode, then bounce a full track.",
|
|
4682
|
+
argsSchema: {
|
|
4683
|
+
bars: z.string().optional().describe("Approximate song length in bars."),
|
|
4684
|
+
},
|
|
4685
|
+
}, ({ bars }) => promptText(`Turn the current PAM preset into an arranged song${bars ? ` of roughly ${bars} bars` : ""}:\n\n` +
|
|
4686
|
+
`1. Design 2-4 contrasting sections (intro / groove / peak / outro): shape the state for each, then save_variation into slots 0-7.\n` +
|
|
4687
|
+
`2. Arrange with song_mode op 'setArrangement' — an ordered sections list ({variationIndex, bars, repeat}, minimum 2 sections). Read it back with op 'get'.\n` +
|
|
4688
|
+
`3. Audition: start_transport with Song Mode driving the timeline; queue_variation switches sections live.\n` +
|
|
4689
|
+
`4. Bounce with export_song — strategy 'live' captures the ground-truth master mix (required for Magenta/Notochord/automation), strategy 'offline' is the deterministic multi-section bounce with no bar cap. includeVideo:true films the VX scene (window must be open).\n` +
|
|
4690
|
+
`5. The rendered file lands in PAM's Exports/Resamples locations — get_default_locations resolves them.`));
|
|
4691
|
+
server.registerPrompt("design-a-preset", {
|
|
4692
|
+
title: "Design a Preset",
|
|
4693
|
+
description: "Sound-design workflow from a creative brief to a saved preset.",
|
|
4694
|
+
argsSchema: {
|
|
4695
|
+
brief: z.string().describe("Creative brief, e.g. 'dusty lo-fi kit with a wobbly sub'."),
|
|
4696
|
+
},
|
|
4697
|
+
}, ({ brief }) => promptText(`Design a PAM preset from this brief: "${brief}"\n\n` +
|
|
4698
|
+
`1. Read design_context (curated parameter cheat sheet) and get_design_overview first — avoid full get_state dumps.\n` +
|
|
4699
|
+
`2. Source sounds: randomize_samples with a genre hint, or get_default_locations → list_samples → load_sample. generate_sample creates bespoke hits (AI engines need update_settings experimental flags + a downloaded model).\n` +
|
|
4700
|
+
`3. Shape each cell: set_parameters (design_context teaches ranges and units), update_effect_config for FX chains, set_drum_synth for synthesized drum layers.\n` +
|
|
4701
|
+
`4. Add movement: add_modulation (LFO / envelope / vary), add_param_seq for stepped motion.\n` +
|
|
4702
|
+
`5. Write patterns with update_pattern, then start_transport to audition.\n` +
|
|
4703
|
+
`6. save_preset with a fitting name and update_preset_metadata (category, tags, BPM).`));
|
|
4704
|
+
server.registerPrompt("mix-and-master", {
|
|
4705
|
+
title: "Mix and Master",
|
|
4706
|
+
description: "Balance the cells and shape the master bus using the analysis + suggestion tools.",
|
|
4707
|
+
argsSchema: {},
|
|
4708
|
+
}, () => promptText(`Balance and master the current PAM project:\n\n` +
|
|
4709
|
+
`1. get_mix_analysis with refresh:true (offline per-cell stem measurements — takes a few seconds) and get_master_analysis (live loudness snapshot).\n` +
|
|
4710
|
+
`2. suggest_mix for rule-based balance / pan / EQ / dynamics moves; apply the returned parameter moves with set_parameters and enable keys with update_effect_config.\n` +
|
|
4711
|
+
`3. Shape the master bus with set_master_effects (compressor, tape, limiter — design_context lists the keys).\n` +
|
|
4712
|
+
`4. Re-check get_master_analysis and iterate toward the loudness target.\n` +
|
|
4713
|
+
`5. Render with export_audio (mode 'main'; 'all-cells' for stems).`));
|
|
4714
|
+
server.registerPrompt("troubleshoot-bridge", {
|
|
4715
|
+
title: "Troubleshoot the PAM Bridge",
|
|
4716
|
+
description: "Diagnose why live tools can't reach the plugin.",
|
|
4717
|
+
argsSchema: {},
|
|
4718
|
+
}, () => promptText(`Diagnose the PAM MCP connection:\n\n` +
|
|
4719
|
+
`1. connection_status. No instances → PAM isn't running or the bridge is off (PAM Settings → Experimental Mode → MCP Bridge, default port 9847). Multiple instances → select_instance picks which product the live tools drive.\n` +
|
|
4720
|
+
`2. Instances found but not connected → the plugin may have shut down, or its message thread is busy (heavy render); retry once.\n` +
|
|
4721
|
+
`3. Timeouts on WebView-routed tools (video tools, capture_ui, set_drum_synth_wave, randomize target 'synth') → the plugin window must be OPEN.\n` +
|
|
4722
|
+
`4. "PAM build does not support 'X'" errors → the plugin is older than this server; the user must update PAM (updating the npm package alone is not enough).\n` +
|
|
4723
|
+
`5. Offline tools always work without a plugin: get_parameter_info, design_context, list_presets, read_preset, get_manifest_summary.`));
|
|
4724
|
+
// ============================================================
|
|
3189
4725
|
// TOOLS — Looper (state deltas)
|
|
3190
4726
|
// ============================================================
|
|
3191
4727
|
// applyStateDelta-equivalent options: full update, dispatch graph, sync KV.
|
|
@@ -3797,11 +5333,27 @@ server.registerTool("update_modulation", {
|
|
|
3797
5333
|
// ============================================================
|
|
3798
5334
|
server.registerTool("song_mode", {
|
|
3799
5335
|
title: "Song Mode",
|
|
3800
|
-
description: "Control PAM's song mode (timeline of variation/preset
|
|
3801
|
-
"'
|
|
5336
|
+
description: "Control PAM's song mode (timeline of variation/preset sections). Ops: 'setArrangement', " +
|
|
5337
|
+
"'get', 'assignVx', 'setSequence', 'clear', 'toggle', 'setLoopPoints', 'toggleLoop', " +
|
|
5338
|
+
"'setCrossfadeMs', 'setPlayhead'.\n\n" +
|
|
5339
|
+
"PREFER 'setArrangement' (PAM ≥ 1.5.7, window open): it authors real Song Mode grid nodes " +
|
|
5340
|
+
"from a `sections` list, with an optional weighted `connections` graph. The result is " +
|
|
5341
|
+
"visible/editable in the UI, and each section can pin a " +
|
|
5342
|
+
"VX (visual) variation slot via `vxVariationIndex` (omit = visuals follow the section's " +
|
|
5343
|
+
"audio slot 1:1; see the vx_variation tool), optionally inside a specific VX scene via " +
|
|
5344
|
+
"`vxScenePath` (the .vxpreset loads before the section plays, so sections can pull visuals " +
|
|
5345
|
+
"from different scenes). 'get' reads the arrangement back (nodes, compiled steps with the " +
|
|
5346
|
+
"VX scene+slot each recalls, total bars); 'assignVx' re-pins one section's VX slot (and " +
|
|
5347
|
+
"optional scene) after authoring.\n\n" +
|
|
5348
|
+
"'setSequence' is the older HEADLESS raw-step path (works with the window closed): steps " +
|
|
5349
|
+
"land in the C++ player but carry no grid nodes, so the UI can't edit them and VX can only " +
|
|
5350
|
+
"follow 1:1 by audio slot.",
|
|
3802
5351
|
inputSchema: {
|
|
3803
5352
|
op: z
|
|
3804
5353
|
.enum([
|
|
5354
|
+
"setArrangement",
|
|
5355
|
+
"get",
|
|
5356
|
+
"assignVx",
|
|
3805
5357
|
"setSequence",
|
|
3806
5358
|
"clear",
|
|
3807
5359
|
"toggle",
|
|
@@ -3811,6 +5363,62 @@ server.registerTool("song_mode", {
|
|
|
3811
5363
|
"setPlayhead",
|
|
3812
5364
|
])
|
|
3813
5365
|
.describe("Operation."),
|
|
5366
|
+
sections: z
|
|
5367
|
+
.array(z.object({
|
|
5368
|
+
variationIndex: z.number().min(0).max(7).describe("Audio variation slot 0-7 this section plays."),
|
|
5369
|
+
bars: z.number().positive().optional().describe("Section length in bars (default: auto from longest pattern)."),
|
|
5370
|
+
repeat: z.number().int().min(1).optional().describe("Times to play before advancing (default 1)."),
|
|
5371
|
+
muted: z.boolean().optional().describe("Pass through without an audio trigger."),
|
|
5372
|
+
probability: z.number().min(0).max(1).optional().describe("Trigger probability (0-1)."),
|
|
5373
|
+
presetPath: z.string().optional().describe("Load this preset instead of switching variation."),
|
|
5374
|
+
label: z.string().optional().describe("Section label shown on the grid node (e.g. 'Intro', 'Hook')."),
|
|
5375
|
+
vxVariationIndex: z
|
|
5376
|
+
.number()
|
|
5377
|
+
.int()
|
|
5378
|
+
.min(0)
|
|
5379
|
+
.max(7)
|
|
5380
|
+
.optional()
|
|
5381
|
+
.describe("VX variation slot recalled while this section plays (omit = follow the audio slot)."),
|
|
5382
|
+
vxScenePath: z
|
|
5383
|
+
.string()
|
|
5384
|
+
.optional()
|
|
5385
|
+
.describe("VX scene (.vxpreset path) whose slots vxVariationIndex indexes into — loaded before the " +
|
|
5386
|
+
"section plays, so sections can pull visuals from different scenes. Requires vxVariationIndex; " +
|
|
5387
|
+
"omit = the live scene's slots."),
|
|
5388
|
+
}))
|
|
5389
|
+
.min(1)
|
|
5390
|
+
.optional()
|
|
5391
|
+
.describe("For 'setArrangement': section list. Without `connections`, sections play in order."),
|
|
5392
|
+
connections: z
|
|
5393
|
+
.array(z.object({
|
|
5394
|
+
from: z.number().int().min(0).describe("Source section index."),
|
|
5395
|
+
to: z.number().int().min(0).describe("Destination section index."),
|
|
5396
|
+
probability: z
|
|
5397
|
+
.number()
|
|
5398
|
+
.min(0)
|
|
5399
|
+
.max(1)
|
|
5400
|
+
.optional()
|
|
5401
|
+
.describe("Branch probability (0-1). Sibling routes normalize to 100%."),
|
|
5402
|
+
}))
|
|
5403
|
+
.optional()
|
|
5404
|
+
.describe("For 'setArrangement': optional cable graph. Multiple cables may leave or enter a section."),
|
|
5405
|
+
loop: z.boolean().optional().describe("For 'setArrangement': loop the whole arrangement."),
|
|
5406
|
+
nodeId: z.string().optional().describe("For 'assignVx': grid node id (from op 'get')."),
|
|
5407
|
+
stepIndex: z.number().int().min(0).optional().describe("For 'assignVx': compiled step index (alternative to nodeId)."),
|
|
5408
|
+
vxIndex: z
|
|
5409
|
+
.number()
|
|
5410
|
+
.int()
|
|
5411
|
+
.min(0)
|
|
5412
|
+
.max(7)
|
|
5413
|
+
.nullable()
|
|
5414
|
+
.optional()
|
|
5415
|
+
.describe("For 'assignVx': VX slot 0-7, or null to clear (follow the audio slot)."),
|
|
5416
|
+
vxScenePath: z
|
|
5417
|
+
.string()
|
|
5418
|
+
.nullable()
|
|
5419
|
+
.optional()
|
|
5420
|
+
.describe("For 'assignVx': pin the scene (.vxpreset path) vxIndex indexes into (requires vxIndex; " +
|
|
5421
|
+
"null/'' clears the pin → the live scene's slots; omit = keep the node's current pin)."),
|
|
3814
5422
|
steps: z
|
|
3815
5423
|
.array(z.object({
|
|
3816
5424
|
variationIndex: z.number().min(0).max(7).describe("Variation slot 0-7."),
|
|
@@ -3824,8 +5432,8 @@ server.registerTool("song_mode", {
|
|
|
3824
5432
|
probability: z.number().min(0).max(1).optional().describe("Trigger probability (0-1)."),
|
|
3825
5433
|
}))
|
|
3826
5434
|
.optional()
|
|
3827
|
-
.describe("For 'setSequence': step list."),
|
|
3828
|
-
enabled: z.boolean().optional().describe("For 'toggle' / 'toggleLoop': enabled state."),
|
|
5435
|
+
.describe("For 'setSequence': raw step list (headless path; prefer 'setArrangement')."),
|
|
5436
|
+
enabled: z.boolean().optional().describe("For 'toggle' / 'toggleLoop' / 'setArrangement': enabled state."),
|
|
3829
5437
|
start: z
|
|
3830
5438
|
.number()
|
|
3831
5439
|
.nullable()
|
|
@@ -3849,6 +5457,65 @@ server.registerTool("song_mode", {
|
|
|
3849
5457
|
if (check !== true)
|
|
3850
5458
|
return check;
|
|
3851
5459
|
switch (args.op) {
|
|
5460
|
+
case "setArrangement": {
|
|
5461
|
+
if (!args.sections || args.sections.length === 0)
|
|
5462
|
+
return error("'setArrangement' requires sections");
|
|
5463
|
+
await sendVideoCommand({
|
|
5464
|
+
op: "setSongArrangement",
|
|
5465
|
+
sections: args.sections,
|
|
5466
|
+
...(args.connections !== undefined && { connections: args.connections }),
|
|
5467
|
+
...(args.loop !== undefined && { loop: args.loop }),
|
|
5468
|
+
...(args.enabled !== undefined && { enabled: args.enabled }),
|
|
5469
|
+
...(args.crossfadeMs !== undefined && { crossfadeMs: args.crossfadeMs }),
|
|
5470
|
+
});
|
|
5471
|
+
// The forward path is fire-and-forget — poll until the WebView's apply
|
|
5472
|
+
// republishes the snapshot (same pattern as resolveNewId).
|
|
5473
|
+
const deadline = Date.now() + 3000;
|
|
5474
|
+
let sm;
|
|
5475
|
+
for (;;) {
|
|
5476
|
+
const state = await getVideoStateRaw();
|
|
5477
|
+
sm = state.songMode;
|
|
5478
|
+
const landed = sm?.available === true &&
|
|
5479
|
+
Array.isArray(sm.nodes) &&
|
|
5480
|
+
sm.nodes.length === args.sections.length;
|
|
5481
|
+
if (landed || Date.now() > deadline)
|
|
5482
|
+
break;
|
|
5483
|
+
await sleep(150);
|
|
5484
|
+
}
|
|
5485
|
+
if (!sm || sm.available !== true)
|
|
5486
|
+
return error("Arrangement command sent, but no song-mode read-model came back — this needs PAM ≥ 1.5.7 " +
|
|
5487
|
+
"with the window OPEN. On older builds fall back to op 'setSequence' (headless raw steps).");
|
|
5488
|
+
const nodes = Array.isArray(sm.nodes) ? sm.nodes.length : 0;
|
|
5489
|
+
if (nodes !== args.sections.length)
|
|
5490
|
+
return error(`Arrangement did not land within 3s (${nodes} nodes vs ${args.sections.length} sections) — the ` +
|
|
5491
|
+
"command may have been rejected (variationIndex/vxVariationIndex must be 0-7; bars must be greater than 0).");
|
|
5492
|
+
return text(`Song arrangement set: ${nodes} sections, ${Array.isArray(sm.steps) ? sm.steps.length : 0} steps, ` +
|
|
5493
|
+
`${String(sm.arrangementBars)} bars${args.loop ? ", looped" : ""}${args.enabled ? ", song mode ON" : ""}. ` +
|
|
5494
|
+
"Read it back with op 'get'.");
|
|
5495
|
+
}
|
|
5496
|
+
case "get": {
|
|
5497
|
+
const state = await getVideoStateRaw();
|
|
5498
|
+
if (state.available === false)
|
|
5499
|
+
return error("VX/song read-model unavailable — open PAM's window (WebView-routed).");
|
|
5500
|
+
const sm = state.songMode;
|
|
5501
|
+
if (!sm)
|
|
5502
|
+
return error("No songMode read-model in the snapshot — the connected PAM predates it (needs ≥ 1.5.7).");
|
|
5503
|
+
return json(sm);
|
|
5504
|
+
}
|
|
5505
|
+
case "assignVx": {
|
|
5506
|
+
if (args.nodeId === undefined && args.stepIndex === undefined)
|
|
5507
|
+
return error("'assignVx' requires nodeId or stepIndex");
|
|
5508
|
+
await sendVideoCommand({
|
|
5509
|
+
op: "setSongNodeVx",
|
|
5510
|
+
...(args.nodeId !== undefined && { nodeId: args.nodeId }),
|
|
5511
|
+
...(args.stepIndex !== undefined && { stepIndex: args.stepIndex }),
|
|
5512
|
+
index: args.vxIndex ?? null,
|
|
5513
|
+
...(args.vxScenePath !== undefined && { vxScenePath: args.vxScenePath }),
|
|
5514
|
+
});
|
|
5515
|
+
return text(`VX assignment sent (${args.nodeId ?? `step ${args.stepIndex}`} → ${args.vxIndex ?? "follow audio"}` +
|
|
5516
|
+
`${args.vxIndex != null && args.vxScenePath ? ` in ${args.vxScenePath}` : ""}). ` +
|
|
5517
|
+
"Verify with op 'get' (nodes[].vxVariationIndex / vxScenePath).");
|
|
5518
|
+
}
|
|
3852
5519
|
case "setSequence":
|
|
3853
5520
|
if (!args.steps)
|
|
3854
5521
|
return error("'setSequence' requires steps");
|
|
@@ -3888,17 +5555,24 @@ server.registerTool("song_mode", {
|
|
|
3888
5555
|
});
|
|
3889
5556
|
server.registerTool("export_song", {
|
|
3890
5557
|
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
|
-
"
|
|
5558
|
+
description: "Bounce the full Song Mode arrangement — the 'render my whole track' button. " +
|
|
5559
|
+
"Set up the arrangement first with the `song_mode` tool (prefer op setArrangement), then call this.\n\n" +
|
|
5560
|
+
"Strategies:\n" +
|
|
5561
|
+
"• 'live' + includeVideo:true — full A/V render: plays the arrangement in real time, films the VX " +
|
|
5562
|
+
"scene (VX variation follow runs live, so per-section visuals land exactly as assigned) and muxes " +
|
|
5563
|
+
"the master mix into a video file. Needs PAM ≥ 1.5.7 with the window OPEN and at least one VX layer.\n" +
|
|
5564
|
+
"• 'live' without cellId — bar-synced audio WAV via the same WebView Song Export (lands in " +
|
|
5565
|
+
"VX/Resamples; window must be open, PAM ≥ 1.5.7). No destination cell consumed.\n" +
|
|
5566
|
+
"• 'live' with cellId — legacy headless master-mix capture into an empty cell (works with the window " +
|
|
5567
|
+
"closed on PAM ≥ 1.5.0). The captured WAV also lands in PAM's Resamples folder.\n" +
|
|
5568
|
+
"• 'offline' — the DETERMINISTIC multi-section bounce (PAM ≥ 1.5.7 with offline song export): every " +
|
|
5569
|
+
"variation section applies sample-accurately on its anchor; renders faster than realtime with no bar cap. " +
|
|
5570
|
+
"Audio-only works headless (window closed); includeVideo runs the offline video pump with per-section VX " +
|
|
5571
|
+
"recalls (window open, WebCodecs). Caveats: preset-path steps hold the previous section's sound (pending), " +
|
|
5572
|
+
"and wall-clock sources (Magenta, Notochord, camera layers) cannot render offline — use 'live' for those.\n\n" +
|
|
5573
|
+
"Live paths render in REAL TIME (a 90 s song takes ~90 s) and cap at 64 bars per capture. " +
|
|
5574
|
+
"Verify audio results with get_audio_info (resolve cell captures against the Resamples location " +
|
|
5575
|
+
"from get_default_locations).",
|
|
3902
5576
|
inputSchema: {
|
|
3903
5577
|
strategy: z.enum(["live", "offline"]).default("live").describe("Capture strategy."),
|
|
3904
5578
|
bars: z
|
|
@@ -3911,41 +5585,113 @@ server.registerTool("export_song", {
|
|
|
3911
5585
|
.min(1)
|
|
3912
5586
|
.max(8)
|
|
3913
5587
|
.optional()
|
|
3914
|
-
.describe("For 'live': empty destination cell the bounced
|
|
3915
|
-
|
|
5588
|
+
.describe("For legacy headless 'live': empty destination cell for the bounced clip. Omit to use the WebView Song Export (window open)."),
|
|
5589
|
+
includeVideo: z
|
|
5590
|
+
.boolean()
|
|
5591
|
+
.default(false)
|
|
5592
|
+
.describe("Film the VX scene alongside the audio and mux to video (window open, VX layers required)."),
|
|
5593
|
+
name: z.string().optional().describe("Output name for WebView Song Export renders (default 'Song')."),
|
|
5594
|
+
normalize: z.boolean().optional().describe("Normalize the captured audio (WebView Song Export paths)."),
|
|
5595
|
+
encoderId: z.string().optional().describe("Recording encoder preset for includeVideo (e.g. mp4-h264)."),
|
|
5596
|
+
bitDepth: z.number().default(24).describe("Bit depth (16 or 24; legacy cell-capture + offline paths)."),
|
|
3916
5597
|
tailBars: z
|
|
3917
5598
|
.number()
|
|
3918
5599
|
.min(0)
|
|
3919
5600
|
.max(8)
|
|
3920
5601
|
.default(1)
|
|
3921
|
-
.describe("For 'live': extra bars captured after the arrangement end for
|
|
5602
|
+
.describe("For legacy cell-capture 'live': extra bars captured after the arrangement end for tails."),
|
|
3922
5603
|
},
|
|
3923
|
-
}, async ({ strategy, bars, cellId, bitDepth, tailBars }) => {
|
|
5604
|
+
}, async ({ strategy, bars, cellId, includeVideo, name, normalize, encoderId, bitDepth, tailBars }) => {
|
|
3924
5605
|
const check = await requireBridge();
|
|
3925
5606
|
if (check !== true)
|
|
3926
5607
|
return check;
|
|
3927
|
-
// -------- Offline
|
|
3928
|
-
|
|
5608
|
+
// -------- Offline audio-only: headless multi-section bounce --------
|
|
5609
|
+
// (Offline WITH video routes through the WebView Song Export below — the
|
|
5610
|
+
// pump needs the window; strategy rides the op.)
|
|
5611
|
+
if (strategy === "offline" && !includeVideo) {
|
|
3929
5612
|
const filename = `pam_song_${Date.now()}`;
|
|
3930
5613
|
const transport = (await bridge.send("getTransport"));
|
|
3931
5614
|
const bpm = typeof transport.bpm === "number" && transport.bpm > 0 ? transport.bpm : 120;
|
|
3932
5615
|
await bridge.send("exportAudio", {
|
|
3933
5616
|
filename,
|
|
3934
5617
|
mode: "song-mode",
|
|
5618
|
+
// Estimate only — exportSongMode computes the real length from the arrangement.
|
|
3935
5619
|
durationInSeconds: (bars * 4 * 60) / bpm,
|
|
3936
5620
|
bitDepth,
|
|
3937
5621
|
});
|
|
3938
5622
|
const outcome = await pollExportStatus();
|
|
3939
5623
|
if (outcome === "success")
|
|
3940
|
-
return text(`Offline song bounce complete: ${filename}.wav (in PAM's Exports folder)
|
|
3941
|
-
"
|
|
5624
|
+
return text(`Offline song bounce complete: ${filename}.wav (in PAM's Exports folder) — every variation section ` +
|
|
5625
|
+
"applied on its anchor.\n" +
|
|
5626
|
+
"Caveats: preset-path steps hold the previous section (pending); Magenta/Notochord render silent " +
|
|
5627
|
+
"offline (use strategy 'live' for generative sources). Verify with get_audio_info.");
|
|
3942
5628
|
if (outcome === "failed")
|
|
3943
|
-
return error("Offline song bounce failed. Is Song Mode enabled with a non-empty sequence?");
|
|
5629
|
+
return error("Offline song bounce failed (or was cancelled). Is Song Mode enabled with a non-empty sequence?");
|
|
3944
5630
|
return text("Offline song bounce timed out. The render may still be running.");
|
|
3945
5631
|
}
|
|
3946
|
-
// --------
|
|
3947
|
-
|
|
3948
|
-
|
|
5632
|
+
// -------- VX-capture path (WebView Song Export): audio WAV or audio+video --------
|
|
5633
|
+
// Chosen when the caller wants video, or asked for 'live' without a destination
|
|
5634
|
+
// cell — runSongExport bar-syncs the capture, runs VX variation follow live, and
|
|
5635
|
+
// needs no free cell. Fire-and-forget op + snapshot polling (songExport status).
|
|
5636
|
+
if (includeVideo || typeof cellId !== "number") {
|
|
5637
|
+
const pre = await getVideoStateRaw();
|
|
5638
|
+
if (pre.available === false)
|
|
5639
|
+
return error(includeVideo
|
|
5640
|
+
? "includeVideo needs PAM's window OPEN (the VX scene renders in the WebView)."
|
|
5641
|
+
: "'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.");
|
|
5642
|
+
if (!("songExport" in pre))
|
|
5643
|
+
return error("The connected PAM predates the Song Export op (needs ≥ 1.5.7). " +
|
|
5644
|
+
"Pass an empty destination cellId for the legacy live capture instead.");
|
|
5645
|
+
const preJob = pre.songExport ?? {};
|
|
5646
|
+
if (preJob.state === "running")
|
|
5647
|
+
return error("A song export is already running — poll get_video_state songExport, or cancel it first.");
|
|
5648
|
+
await sendVideoCommand({
|
|
5649
|
+
op: "exportSong",
|
|
5650
|
+
bars,
|
|
5651
|
+
includeVideo,
|
|
5652
|
+
strategy,
|
|
5653
|
+
...(name !== undefined && { name }),
|
|
5654
|
+
...(normalize !== undefined && { normalize }),
|
|
5655
|
+
...(encoderId !== undefined && { encoderId }),
|
|
5656
|
+
});
|
|
5657
|
+
// The op returns immediately; confirm OUR job actually started. A rejected
|
|
5658
|
+
// op (sync guards: empty arrangement, busy capture, no VX layers) leaves
|
|
5659
|
+
// the previous status — with its old startedAt stamp — in place.
|
|
5660
|
+
let started = false;
|
|
5661
|
+
for (let i = 0; i < 10; i++) {
|
|
5662
|
+
await sleep(500);
|
|
5663
|
+
const s = await getVideoStateRaw();
|
|
5664
|
+
const job = s.songExport ?? {};
|
|
5665
|
+
if (typeof job.startedAt === "number" && job.startedAt !== preJob.startedAt) {
|
|
5666
|
+
started = true;
|
|
5667
|
+
break;
|
|
5668
|
+
}
|
|
5669
|
+
}
|
|
5670
|
+
if (!started)
|
|
5671
|
+
return error("Song export did not start — likely a guard failure (empty arrangement, another capture " +
|
|
5672
|
+
"running, or includeVideo with no VX layers). Check song_mode get / get_video_state.");
|
|
5673
|
+
const transport = (await bridge.send("getTransport"));
|
|
5674
|
+
const bpm = typeof transport.bpm === "number" && transport.bpm > 0 ? transport.bpm : 120;
|
|
5675
|
+
// Capture length + bar-sync start slack + save/mux margin.
|
|
5676
|
+
const deadline = Date.now() + ((bars + 2) * 4 * 60 * 1000) / bpm + 120_000;
|
|
5677
|
+
let last = {};
|
|
5678
|
+
while (Date.now() < deadline) {
|
|
5679
|
+
await sleep(2000);
|
|
5680
|
+
const s = await getVideoStateRaw();
|
|
5681
|
+
last = s.songExport ?? {};
|
|
5682
|
+
if (last.state === "done")
|
|
5683
|
+
return text(`Song export complete (${bars} bars, ${includeVideo ? "audio+video" : "audio WAV"}).\n` +
|
|
5684
|
+
`File: ${String(last.path ?? "(unknown — check VX/Resamples / Visuals)")}\n` +
|
|
5685
|
+
(includeVideo ? "" : "Verify with get_audio_info."));
|
|
5686
|
+
if (last.state === "failed")
|
|
5687
|
+
return error(`Song export failed: ${String(last.error ?? "unknown")}`);
|
|
5688
|
+
if (last.state === "cancelled")
|
|
5689
|
+
return error("Song export was cancelled.");
|
|
5690
|
+
}
|
|
5691
|
+
return error(`Song export timed out (last status: ${JSON.stringify(last)}). It may still be running — ` +
|
|
5692
|
+
"poll get_video_state songExport, or cancel via video_command {op:'cancelSongExport'}.");
|
|
5693
|
+
}
|
|
5694
|
+
// -------- Legacy live strategy: headless master capture into a destination cell --------
|
|
3949
5695
|
const transport = (await bridge.send("getTransport"));
|
|
3950
5696
|
const bpm = typeof transport.bpm === "number" && transport.bpm > 0 ? transport.bpm : 120;
|
|
3951
5697
|
const totalBars = bars + (tailBars ?? 1);
|
|
@@ -4235,6 +5981,13 @@ function isPathUnderWhitelist(p) {
|
|
|
4235
5981
|
// accepts entries observed here so an agent can't instantiate arbitrary
|
|
4236
5982
|
// binaries.
|
|
4237
5983
|
const knownPluginIdentifiers = new Set();
|
|
5984
|
+
// PAM's bridge returns some plugin-host results as a JSON OBJECT in `result`
|
|
5985
|
+
// (newer builds) and others as a pre-serialized string (older builds). The
|
|
5986
|
+
// handlers below must accept both — treating an object as a string made
|
|
5987
|
+
// text() emit a non-string (MCP result-validation error) and JSON.parse
|
|
5988
|
+
// throw, leaving knownPluginIdentifiers empty so load_plugin refused every
|
|
5989
|
+
// path.
|
|
5990
|
+
const bridgeResultText = (raw) => typeof raw === "string" ? raw : JSON.stringify(raw);
|
|
4238
5991
|
// Hosted-plugin tools target PAM cells. Cap to the manifest's cell count
|
|
4239
5992
|
// (falls back to 8 — PAM's shipping max) rather than the C++ slot
|
|
4240
5993
|
// reservation of 32.
|
|
@@ -4281,12 +6034,12 @@ server.registerTool("list_plugins", {
|
|
|
4281
6034
|
filter.isInstrument = args.isInstrument;
|
|
4282
6035
|
if (args.nameContains)
|
|
4283
6036
|
filter.nameContains = args.nameContains;
|
|
4284
|
-
const raw =
|
|
6037
|
+
const raw = await bridge.send("listPlugins", filter);
|
|
4285
6038
|
// Record every fileOrIdentifier so load_plugin can validate that the
|
|
4286
6039
|
// caller-supplied path was discovered by PAM's own scanner.
|
|
4287
6040
|
try {
|
|
4288
|
-
const parsed = JSON.parse(raw);
|
|
4289
|
-
if (Array.isArray(parsed
|
|
6041
|
+
const parsed = (typeof raw === "string" ? JSON.parse(raw) : raw);
|
|
6042
|
+
if (Array.isArray(parsed?.plugins)) {
|
|
4290
6043
|
for (const p of parsed.plugins) {
|
|
4291
6044
|
if (typeof p.fileOrIdentifier === "string") {
|
|
4292
6045
|
knownPluginIdentifiers.add(p.fileOrIdentifier);
|
|
@@ -4297,7 +6050,7 @@ server.registerTool("list_plugins", {
|
|
|
4297
6050
|
catch {
|
|
4298
6051
|
// C++ payload not JSON — fall through; load_plugin will reject unknowns.
|
|
4299
6052
|
}
|
|
4300
|
-
return text(raw);
|
|
6053
|
+
return text(bridgeResultText(raw));
|
|
4301
6054
|
});
|
|
4302
6055
|
server.registerTool("scan_plugins", {
|
|
4303
6056
|
title: "Scan Plugins",
|
|
@@ -4353,12 +6106,21 @@ server.registerTool("get_plugin_scan_status", {
|
|
|
4353
6106
|
"`fraction` (0..1), `elapsedMs`, `isStall` (true when the in-flight plugin hasn't returned in 2s), " +
|
|
4354
6107
|
"and `updatedAtMs`. Poll while a scan is running.",
|
|
4355
6108
|
inputSchema: {},
|
|
6109
|
+
outputSchema: {
|
|
6110
|
+
isScanning: z.boolean().optional(),
|
|
6111
|
+
format: z.string().optional(),
|
|
6112
|
+
currentPath: z.string().optional(),
|
|
6113
|
+
fraction: z.number().optional(),
|
|
6114
|
+
elapsedMs: z.number().optional(),
|
|
6115
|
+
isStall: z.boolean().optional(),
|
|
6116
|
+
updatedAtMs: z.number().optional(),
|
|
6117
|
+
},
|
|
4356
6118
|
}, async () => {
|
|
4357
6119
|
const check = await requireBridge();
|
|
4358
6120
|
if (check !== true)
|
|
4359
6121
|
return check;
|
|
4360
|
-
const raw =
|
|
4361
|
-
return
|
|
6122
|
+
const raw = await bridge.send("getPluginScanStatus");
|
|
6123
|
+
return structuredFromBridge(raw);
|
|
4362
6124
|
});
|
|
4363
6125
|
server.registerTool("list_loaded_plugins", {
|
|
4364
6126
|
title: "List Loaded Plugins",
|
|
@@ -4371,8 +6133,8 @@ server.registerTool("list_loaded_plugins", {
|
|
|
4371
6133
|
const check = await requireBridge();
|
|
4372
6134
|
if (check !== true)
|
|
4373
6135
|
return check;
|
|
4374
|
-
const raw =
|
|
4375
|
-
return text(raw);
|
|
6136
|
+
const raw = await bridge.send("listLoadedPlugins");
|
|
6137
|
+
return text(bridgeResultText(raw));
|
|
4376
6138
|
});
|
|
4377
6139
|
server.registerTool("load_plugin", {
|
|
4378
6140
|
title: "Load Plugin Into Cell",
|
|
@@ -4383,6 +6145,10 @@ server.registerTool("load_plugin", {
|
|
|
4383
6145
|
cellId: z.number().int().min(1).describe("Target cell (1-based)."),
|
|
4384
6146
|
path: z.string().describe("Plugin fileOrIdentifier from list_plugins."),
|
|
4385
6147
|
format: PLUGIN_FORMAT.describe("Plugin format."),
|
|
6148
|
+
uniqueId: z.number().int().optional().describe("Optional type disambiguator for multi-type bundles (one .vst3 exposing several plugin types, " +
|
|
6149
|
+
"e.g. Serum2.vst3 = instrument + FX). Pass the uniqueId from the list_plugins entry you want. " +
|
|
6150
|
+
"Absent → PAM prefers the instrument type at that path."),
|
|
6151
|
+
name: z.string().optional().describe("Optional type name disambiguator (the list_plugins `name`), used when uniqueId is unavailable."),
|
|
4386
6152
|
},
|
|
4387
6153
|
}, async (args) => {
|
|
4388
6154
|
const check = await requireBridge();
|
|
@@ -4399,6 +6165,8 @@ server.registerTool("load_plugin", {
|
|
|
4399
6165
|
cellId: clamped,
|
|
4400
6166
|
path: args.path,
|
|
4401
6167
|
format: args.format,
|
|
6168
|
+
...(args.uniqueId ? { uniqueId: args.uniqueId } : {}),
|
|
6169
|
+
...(args.name ? { name: args.name } : {}),
|
|
4402
6170
|
}));
|
|
4403
6171
|
return text(raw);
|
|
4404
6172
|
});
|
|
@@ -4581,16 +6349,16 @@ server.registerTool("set_midi_io", {
|
|
|
4581
6349
|
server.registerTool("set_midi_mapping", {
|
|
4582
6350
|
title: "Set MIDI Action Mapping",
|
|
4583
6351
|
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.",
|
|
6352
|
+
"looper record, live resample, VX video record toggle, and looper-variation switch. Set the enable flag, " +
|
|
6353
|
+
"MIDI channel, CC number, preset source, or bar-sync for each target. One field per call.",
|
|
4586
6354
|
inputSchema: {
|
|
4587
6355
|
target: z
|
|
4588
|
-
.enum(["variation", "preset", "looper", "resample", "looperVariation"])
|
|
4589
|
-
.describe("Which action's mapping to configure."),
|
|
6356
|
+
.enum(["variation", "preset", "looper", "resample", "vxRecord", "looperVariation"])
|
|
6357
|
+
.describe("Which action's mapping to configure. 'vxRecord' toggles VX video recording — requires the PAM window open (VX renders in the WebView)."),
|
|
4590
6358
|
field: z
|
|
4591
6359
|
.enum(["enabled", "channel", "ccNumber", "source", "barSync"])
|
|
4592
6360
|
.describe("Field to set. 'source' is preset-only; 'barSync' is variation/looperVariation-only; " +
|
|
4593
|
-
"looper/resample have no source/barSync."),
|
|
6361
|
+
"looper/resample/vxRecord have no source/barSync."),
|
|
4594
6362
|
value: z
|
|
4595
6363
|
.union([z.number(), z.boolean()])
|
|
4596
6364
|
.describe("boolean for enabled/barSync; number for channel (1-16), ccNumber (0-127), or source."),
|
|
@@ -4755,13 +6523,309 @@ server.registerTool("remove_plugin_from_blocklist", {
|
|
|
4755
6523
|
return text(`Removed ${fileOrIdentifier} from the plugin blocklist.`);
|
|
4756
6524
|
});
|
|
4757
6525
|
// ============================================================
|
|
6526
|
+
// TOOL — ask_assistant (drive the FunctionGemma assistant end-to-end)
|
|
6527
|
+
// ============================================================
|
|
6528
|
+
// The in-server twin of the ASK bar: renders the SAME FunctionGemma prompt
|
|
6529
|
+
// (shared fgemma-format module + live [ctx] line), generates against a local
|
|
6530
|
+
// OpenAI-compatible endpoint (llama-server serving the fine-tune), parses the
|
|
6531
|
+
// call blocks, and executes them through this server's own handlers. Lets an
|
|
6532
|
+
// MCP client (Claude Code) test the assistant model directly against a live
|
|
6533
|
+
// PAM. Stateless per call — thread follow-ups by passing back `modelTurn`
|
|
6534
|
+
// from the previous trace as `history`.
|
|
6535
|
+
//
|
|
6536
|
+
// Dev-tree only: registered only when the fgemma toolpack resolves (the
|
|
6537
|
+
// published npm package doesn't carry it — the tool would be dead weight).
|
|
6538
|
+
const ASSISTANT_TOOLPACK_PATH = resolve(PACKAGE_ROOT, "../fgemma-poc/out/toolpack.json");
|
|
6539
|
+
// The assistant's view vocabulary → MainViewTypes (stores/Interface.ts).
|
|
6540
|
+
// Pinned to the in-plugin executor's VIEW_MAP (assistantExecutor.ts) — one
|
|
6541
|
+
// vocabulary across tool arg, [ctx] view= field, and this dev twin. null =
|
|
6542
|
+
// the cell grid (selectMainView(null) semantics in the WebView).
|
|
6543
|
+
const SET_VIEW_MAIN_VIEW = {
|
|
6544
|
+
cells: null,
|
|
6545
|
+
pads: null,
|
|
6546
|
+
grid: null,
|
|
6547
|
+
mixer: "Mixer",
|
|
6548
|
+
ctrl: "CtrlAll",
|
|
6549
|
+
ctrlall: "CtrlAll",
|
|
6550
|
+
looper: "Looper",
|
|
6551
|
+
song: "SongMode",
|
|
6552
|
+
lfo: "LFO",
|
|
6553
|
+
envelope: "Envelope",
|
|
6554
|
+
env: "Envelope",
|
|
6555
|
+
vary: "Vary",
|
|
6556
|
+
paramseq: "ParamSeq",
|
|
6557
|
+
video: "Video",
|
|
6558
|
+
vx: "Video",
|
|
6559
|
+
visuals: "Video",
|
|
6560
|
+
export: "Export",
|
|
6561
|
+
presets: "PresetBrowser",
|
|
6562
|
+
browser: "PresetBrowser",
|
|
6563
|
+
};
|
|
6564
|
+
// Dev-tree twin of the in-plugin assistant's set_view (toolpack-gated like
|
|
6565
|
+
// ask_assistant — the published server deliberately excludes window/view
|
|
6566
|
+
// chrome). Error strings mirror the executor exactly: the model's repair
|
|
6567
|
+
// turns are trained on those shapes.
|
|
6568
|
+
if (existsSync(ASSISTANT_TOOLPACK_PATH))
|
|
6569
|
+
server.registerTool("set_view", {
|
|
6570
|
+
title: "Set View (assistant surface)",
|
|
6571
|
+
description: "Navigate PAM's UI: switch the main view, focus a cell, or set the pad-grid size. " +
|
|
6572
|
+
"Dev-tree twin of the in-plugin assistant's set_view, routed through the WebView — " +
|
|
6573
|
+
"PAM's window must be OPEN. On PAM builds without the setView bridge action it falls " +
|
|
6574
|
+
"back to the capture_ui compose path (view + gridSize only; cell selection needs a " +
|
|
6575
|
+
"rebuilt plugin).",
|
|
6576
|
+
inputSchema: {
|
|
6577
|
+
// Union: "8 cell view" phrasings make the model emit view:8 (a number) —
|
|
6578
|
+
// accept it so the gridSize coercion below can run instead of a zod reject.
|
|
6579
|
+
view: z
|
|
6580
|
+
.union([z.string(), z.number()])
|
|
6581
|
+
.optional()
|
|
6582
|
+
.describe("Main view: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets."),
|
|
6583
|
+
cellId: z.number().int().optional().describe("Cell to select/focus (1-8)."),
|
|
6584
|
+
gridSize: z.number().optional().describe("How many pads the grid shows: 1, 4, or 8."),
|
|
6585
|
+
},
|
|
6586
|
+
}, async ({ view: viewArg, cellId, gridSize }) => {
|
|
6587
|
+
const check = await requireBridge();
|
|
6588
|
+
if (check !== true)
|
|
6589
|
+
return check;
|
|
6590
|
+
let view = viewArg === undefined ? undefined : String(viewArg);
|
|
6591
|
+
// Numeric view token → gridSize (executor does the same coercion); an
|
|
6592
|
+
// explicit gridSize wins and the numeric view is dropped either way.
|
|
6593
|
+
if (view !== undefined && /^[148]$/.test(view.trim())) {
|
|
6594
|
+
if (gridSize === undefined)
|
|
6595
|
+
gridSize = Number(view.trim());
|
|
6596
|
+
view = undefined;
|
|
6597
|
+
}
|
|
6598
|
+
const applied = {};
|
|
6599
|
+
let token;
|
|
6600
|
+
if (typeof view === "string" && view.trim()) {
|
|
6601
|
+
token = view.trim().toLowerCase();
|
|
6602
|
+
if (!(token in SET_VIEW_MAIN_VIEW))
|
|
6603
|
+
return error(`Unknown view: ${view}. Views: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets.`);
|
|
6604
|
+
applied.view = token;
|
|
6605
|
+
}
|
|
6606
|
+
if (gridSize !== undefined) {
|
|
6607
|
+
if (gridSize !== 1 && gridSize !== 4 && gridSize !== 8)
|
|
6608
|
+
return error("gridSize must be 1, 4, or 8.");
|
|
6609
|
+
applied.gridSize = gridSize;
|
|
6610
|
+
}
|
|
6611
|
+
if (cellId !== undefined) {
|
|
6612
|
+
if (!Number.isInteger(cellId) || cellId < 1 || cellId > 8)
|
|
6613
|
+
return error("cellId must be 1-8.");
|
|
6614
|
+
applied.cellId = cellId;
|
|
6615
|
+
}
|
|
6616
|
+
if (!Object.keys(applied).length)
|
|
6617
|
+
return error("Provide view, cellId, and/or gridSize.");
|
|
6618
|
+
try {
|
|
6619
|
+
await bridge.send("setView", {
|
|
6620
|
+
...(token !== undefined ? { view: token } : {}),
|
|
6621
|
+
...(cellId !== undefined ? { cellId } : {}),
|
|
6622
|
+
...(gridSize !== undefined ? { gridSize } : {}),
|
|
6623
|
+
});
|
|
6624
|
+
return json(applied);
|
|
6625
|
+
}
|
|
6626
|
+
catch (e) {
|
|
6627
|
+
const msg = e.message;
|
|
6628
|
+
if (!/unknown action|does not support/i.test(msg))
|
|
6629
|
+
return error(`set_view failed: ${msg}`);
|
|
6630
|
+
// Pre-1.15.0 PAM build — compose via the shipped captureUi handler
|
|
6631
|
+
// (applies mainView/gridSize in the WebView before its rasterize; the
|
|
6632
|
+
// capture itself is discarded). No cell-selection fallback exists.
|
|
6633
|
+
if (token === undefined && gridSize === undefined)
|
|
6634
|
+
return error("Cell selection needs the setView bridge action — this PAM build predates it (rebuild PAM).");
|
|
6635
|
+
const setup = { settleMs: 0 };
|
|
6636
|
+
if (token !== undefined)
|
|
6637
|
+
setup.mainView = SET_VIEW_MAIN_VIEW[token];
|
|
6638
|
+
if (gridSize !== undefined)
|
|
6639
|
+
setup.gridSize = gridSize;
|
|
6640
|
+
try {
|
|
6641
|
+
await bridge.send("captureUi", setup);
|
|
6642
|
+
}
|
|
6643
|
+
catch (e2) {
|
|
6644
|
+
return error(`set_view failed: ${e2.message}`);
|
|
6645
|
+
}
|
|
6646
|
+
delete applied.cellId; // not applied on the fallback path — don't over-claim
|
|
6647
|
+
return json(applied);
|
|
6648
|
+
}
|
|
6649
|
+
});
|
|
6650
|
+
if (existsSync(ASSISTANT_TOOLPACK_PATH))
|
|
6651
|
+
server.registerTool("ask_assistant", {
|
|
6652
|
+
title: "Ask Assistant (FunctionGemma e2e test)",
|
|
6653
|
+
description: "Run one natural-language command through the on-device assistant model: render the " +
|
|
6654
|
+
"FunctionGemma prompt (with the live [ctx] state line), generate via a local llama-server, " +
|
|
6655
|
+
"parse the tool calls, execute them against PAM, and return the full trace. Requires " +
|
|
6656
|
+
"llama-server running with the fine-tuned GGUF (--special). For multi-turn tests, pass the " +
|
|
6657
|
+
"previous trace's modelTurn back via history.",
|
|
6658
|
+
inputSchema: {
|
|
6659
|
+
text: z.string().describe("The user command, e.g. 'mute the kick' or '174 bpm dnb kit'."),
|
|
6660
|
+
endpoint: z
|
|
6661
|
+
.string()
|
|
6662
|
+
.optional()
|
|
6663
|
+
.describe("OpenAI-compatible /v1 base URL (default env PAM_LLM_URL or http://127.0.0.1:8080/v1)."),
|
|
6664
|
+
sel: z
|
|
6665
|
+
.number()
|
|
6666
|
+
.int()
|
|
6667
|
+
.optional()
|
|
6668
|
+
.describe("Selected-cell override for the [ctx] line (the bridge can't see UI selection)."),
|
|
6669
|
+
noCtx: z.boolean().optional().describe("Skip the [ctx] line (test the no-context prompt shape)."),
|
|
6670
|
+
history: z
|
|
6671
|
+
.array(z.object({ user: z.string(), model: z.string() }))
|
|
6672
|
+
.optional()
|
|
6673
|
+
.describe("Prior exchanges to thread (user text + the modelTurn from that trace)."),
|
|
6674
|
+
maxRounds: z.number().int().min(1).max(5).optional().describe("Continuation rounds (default 3)."),
|
|
6675
|
+
},
|
|
6676
|
+
}, async ({ text: userText, endpoint, sel, noCtx, history, maxRounds }) => {
|
|
6677
|
+
// Toolpack: the model-facing surface (dev tree; regenerate with export-toolpack.mjs).
|
|
6678
|
+
let pack;
|
|
6679
|
+
try {
|
|
6680
|
+
const doc = JSON.parse(readFileSync(ASSISTANT_TOOLPACK_PATH, "utf-8"));
|
|
6681
|
+
pack = doc.tools;
|
|
6682
|
+
}
|
|
6683
|
+
catch {
|
|
6684
|
+
return error("assistantToolpack not found — run `node scripts/export-toolpack.mjs` in packages/mcp-server first.");
|
|
6685
|
+
}
|
|
6686
|
+
const declared = new Set(pack.map((t) => t.name));
|
|
6687
|
+
const llmUrl = (endpoint || process.env.PAM_LLM_URL || "http://127.0.0.1:8080/v1").replace(/\/$/, "");
|
|
6688
|
+
// Live [ctx] line from this server's own read tools (sel is UI-only state
|
|
6689
|
+
// the bridge can't see — honor the override, else none).
|
|
6690
|
+
let ctx = null;
|
|
6691
|
+
if (!noCtx) {
|
|
6692
|
+
try {
|
|
6693
|
+
const t = await callInternalTool("get_transport", {});
|
|
6694
|
+
const o = await callInternalTool("get_design_overview", {});
|
|
6695
|
+
if (!t.isError && !o.isError) {
|
|
6696
|
+
const transport = JSON.parse(t.text);
|
|
6697
|
+
const overview = JSON.parse(o.text);
|
|
6698
|
+
// preset= IS bridge-visible (state.activePresetPath) — only view/sel
|
|
6699
|
+
// are UI-only. Renders `none` for Init/Default, like the executor.
|
|
6700
|
+
let preset = null;
|
|
6701
|
+
try {
|
|
6702
|
+
const s = await callInternalTool("get_state", { keys: ["activePresetPath"] });
|
|
6703
|
+
const activePath = JSON.parse(s.text).activePresetPath;
|
|
6704
|
+
if (typeof activePath === "string" && activePath && activePath !== "Init") {
|
|
6705
|
+
const stem = (activePath.split("/").pop() ?? "").replace(/\.preset$/, "");
|
|
6706
|
+
preset = stem && stem !== "Default" ? stem : null;
|
|
6707
|
+
}
|
|
6708
|
+
}
|
|
6709
|
+
catch {
|
|
6710
|
+
preset = null;
|
|
6711
|
+
}
|
|
6712
|
+
ctx = {
|
|
6713
|
+
bpm: Number(transport.bpm ?? 120),
|
|
6714
|
+
playing: Boolean(transport.playing ?? transport.isPlaying),
|
|
6715
|
+
preset,
|
|
6716
|
+
sel: sel ?? null,
|
|
6717
|
+
cells: (overview.cells ?? [])
|
|
6718
|
+
.filter((c) => c.sample || c.synth)
|
|
6719
|
+
.map((c) => ({ id: c.id, name: c.sample ?? `${c.synth}(synth)` })),
|
|
6720
|
+
};
|
|
6721
|
+
}
|
|
6722
|
+
}
|
|
6723
|
+
catch {
|
|
6724
|
+
ctx = null; // no PAM — trained no-ctx prompt shape still works
|
|
6725
|
+
}
|
|
6726
|
+
}
|
|
6727
|
+
let prompt = buildGenerationPrompt(pack, withContext(userText, ctx), {
|
|
6728
|
+
bos: "none",
|
|
6729
|
+
history,
|
|
6730
|
+
});
|
|
6731
|
+
const trace = { ctxLine: ctx ? renderContextLine(ctx) : null, rounds: [], executed: [], modelTurn: "" };
|
|
6732
|
+
const rounds = maxRounds ?? 3;
|
|
6733
|
+
// A repair round that re-emits the exact call that just errored will error
|
|
6734
|
+
// identically forever — cut the loop (executor parity).
|
|
6735
|
+
const failedCalls = new Set();
|
|
6736
|
+
for (let round = 0; round < rounds; round++) {
|
|
6737
|
+
const t0 = Date.now();
|
|
6738
|
+
let raw;
|
|
6739
|
+
try {
|
|
6740
|
+
const resp = await fetch(`${llmUrl}/completions`, {
|
|
6741
|
+
method: "POST",
|
|
6742
|
+
headers: { "Content-Type": "application/json" },
|
|
6743
|
+
body: JSON.stringify({ prompt, max_tokens: 256, temperature: 0, stop: STOP_SEQUENCES, stream: false }),
|
|
6744
|
+
});
|
|
6745
|
+
if (!resp.ok)
|
|
6746
|
+
return error(`model endpoint ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
|
|
6747
|
+
raw = (await resp.json()).choices?.[0]?.text ?? "";
|
|
6748
|
+
}
|
|
6749
|
+
catch (e) {
|
|
6750
|
+
return error(`cannot reach model endpoint ${llmUrl} — start llama-server with the fine-tuned GGUF and --special. (${e.message})`);
|
|
6751
|
+
}
|
|
6752
|
+
raw = truncateAtFabricatedResponse(raw);
|
|
6753
|
+
const calls = parseFunctionCalls(raw);
|
|
6754
|
+
trace.rounds.push({ raw, ms: Date.now() - t0, calls });
|
|
6755
|
+
if (!calls.length) {
|
|
6756
|
+
trace.text = raw.trim();
|
|
6757
|
+
trace.modelTurn += trace.text;
|
|
6758
|
+
return json(trace);
|
|
6759
|
+
}
|
|
6760
|
+
const unknown = calls.filter((c) => !declared.has(c.name));
|
|
6761
|
+
if (unknown.length) {
|
|
6762
|
+
trace.refused = `undeclared tool(s): ${unknown.map((c) => c.name).join(", ")}`;
|
|
6763
|
+
return json(trace);
|
|
6764
|
+
}
|
|
6765
|
+
let continuation = raw.trimEnd();
|
|
6766
|
+
let responses = "";
|
|
6767
|
+
let wantContinue = false;
|
|
6768
|
+
for (const call of calls) {
|
|
6769
|
+
const signature = `${call.name} ${JSON.stringify(call.arguments)}`;
|
|
6770
|
+
if (failedCalls.has(signature)) {
|
|
6771
|
+
trace.stopped = `model repeated a call that already failed (${call.name}) — loop cut`;
|
|
6772
|
+
return json(trace);
|
|
6773
|
+
}
|
|
6774
|
+
// The flat set_parameter alias maps onto the real object-map tool.
|
|
6775
|
+
const real = pack.find((t) => t.name === call.name)?.aliasOf === "set_parameters"
|
|
6776
|
+
? {
|
|
6777
|
+
name: "set_parameters",
|
|
6778
|
+
arguments: { parameters: { [String(call.arguments.paramId)]: call.arguments.value } },
|
|
6779
|
+
}
|
|
6780
|
+
: call;
|
|
6781
|
+
const result = await callInternalTool(real.name, real.arguments);
|
|
6782
|
+
if (result.isError)
|
|
6783
|
+
failedCalls.add(signature);
|
|
6784
|
+
trace.executed.push({
|
|
6785
|
+
name: call.name,
|
|
6786
|
+
arguments: call.arguments,
|
|
6787
|
+
result: result.text.length > 400 ? `${result.text.slice(0, 400)}…` : result.text,
|
|
6788
|
+
isError: result.isError,
|
|
6789
|
+
});
|
|
6790
|
+
let payload;
|
|
6791
|
+
if (result.isError)
|
|
6792
|
+
payload = { error: result.text.slice(0, 400) };
|
|
6793
|
+
else {
|
|
6794
|
+
try {
|
|
6795
|
+
const parsed = JSON.parse(result.text);
|
|
6796
|
+
payload =
|
|
6797
|
+
parsed && typeof parsed === "object" && !Array.isArray(parsed) && result.text.length <= 400
|
|
6798
|
+
? parsed
|
|
6799
|
+
: { result: result.text.slice(0, 400) };
|
|
6800
|
+
}
|
|
6801
|
+
catch {
|
|
6802
|
+
payload = { result: result.text.slice(0, 400) };
|
|
6803
|
+
}
|
|
6804
|
+
}
|
|
6805
|
+
const response = renderFunctionResponse(call.name, payload);
|
|
6806
|
+
continuation += response;
|
|
6807
|
+
responses += response;
|
|
6808
|
+
if (result.isError || /^(get_|list_|connection_status)/.test(call.name))
|
|
6809
|
+
wantContinue = true;
|
|
6810
|
+
}
|
|
6811
|
+
trace.modelTurn += calls.map(renderFunctionCall).join("") + responses;
|
|
6812
|
+
if (!wantContinue)
|
|
6813
|
+
return json(trace);
|
|
6814
|
+
prompt += continuation;
|
|
6815
|
+
}
|
|
6816
|
+
return json(trace);
|
|
6817
|
+
});
|
|
6818
|
+
// ============================================================
|
|
4758
6819
|
// Start
|
|
4759
6820
|
// ============================================================
|
|
4760
6821
|
async function main() {
|
|
4761
6822
|
if (await handleCliArgs())
|
|
4762
6823
|
return;
|
|
4763
6824
|
// Try to connect to plugin on startup (non-blocking)
|
|
4764
|
-
void bridge.connect()
|
|
6825
|
+
void bridge.connect().then((ok) => {
|
|
6826
|
+
if (ok)
|
|
6827
|
+
syncProductHint();
|
|
6828
|
+
});
|
|
4765
6829
|
const transport = new StdioServerTransport();
|
|
4766
6830
|
await server.connect(transport);
|
|
4767
6831
|
// Log to stderr (stdout is the MCP JSON-RPC channel)
|