@map-audio/pam-mcp-server 1.7.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 +18 -3
- package/dist/bridge.js +122 -19
- 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 +19774 -6576
- 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 +3137 -196
- package/dist/server.js.map +1 -1
- package/package.json +5 -2
package/dist/server.js
CHANGED
|
@@ -1,12 +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";
|
|
8
|
+
import { inflateSync } from "node:zlib";
|
|
6
9
|
import { basename, dirname, resolve } from "node:path";
|
|
7
10
|
import { fileURLToPath } from "node:url";
|
|
8
|
-
import { loadManifest, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, walkPresets, } from "./manifest.js";
|
|
11
|
+
import { loadManifest, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, setActiveProductHint, walkPresets, } from "./manifest.js";
|
|
9
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";
|
|
10
16
|
// --- State ---
|
|
11
17
|
// Variation slot count. Must stay in sync with PRESET_VARIATION_SLOTS in
|
|
12
18
|
// interface/src/stores/Preset.ts. Defined locally because the MCP package
|
|
@@ -15,6 +21,8 @@ const PRESET_VARIATION_SLOTS = 8;
|
|
|
15
21
|
const bridge = new PluginBridge();
|
|
16
22
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
23
|
const cellModeChangeSeq = {};
|
|
24
|
+
let lastSavedPresetName;
|
|
25
|
+
let lastSavedPresetPath;
|
|
18
26
|
// --- Helpers ---
|
|
19
27
|
function text(s) {
|
|
20
28
|
return { content: [{ type: "text", text: s }] };
|
|
@@ -22,12 +30,78 @@ function text(s) {
|
|
|
22
30
|
function json(obj) {
|
|
23
31
|
return text(JSON.stringify(obj, null, 2));
|
|
24
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
|
+
}
|
|
25
58
|
function error(msg) {
|
|
26
59
|
return { content: [{ type: "text", text: msg }], isError: true };
|
|
27
60
|
}
|
|
28
|
-
|
|
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() {
|
|
29
103
|
try {
|
|
30
|
-
const raw =
|
|
104
|
+
const raw = readFileSync(resolve(PACKAGE_ROOT, "package.json"), "utf-8");
|
|
31
105
|
const pkg = JSON.parse(raw);
|
|
32
106
|
return typeof pkg.version === "string" ? pkg.version : "unknown";
|
|
33
107
|
}
|
|
@@ -35,14 +109,17 @@ async function readPackageVersion() {
|
|
|
35
109
|
return "unknown";
|
|
36
110
|
}
|
|
37
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();
|
|
38
115
|
async function handleCliArgs() {
|
|
39
116
|
const args = new Set(process.argv.slice(2));
|
|
40
117
|
if (args.has("--version") || args.has("-v")) {
|
|
41
|
-
console.log(
|
|
118
|
+
console.log(SERVER_VERSION);
|
|
42
119
|
return true;
|
|
43
120
|
}
|
|
44
121
|
if (args.has("--help") || args.has("-h")) {
|
|
45
|
-
const version =
|
|
122
|
+
const version = SERVER_VERSION;
|
|
46
123
|
console.log(`pam-mcp ${version}\n\n` +
|
|
47
124
|
"MCP stdio server for PAM. Configure your MCP client to launch this command; " +
|
|
48
125
|
"it reads ~/.pam-mcp-bridge.json to find a running PAM instance.");
|
|
@@ -50,14 +127,24 @@ async function handleCliArgs() {
|
|
|
50
127
|
}
|
|
51
128
|
return false;
|
|
52
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
|
+
}
|
|
53
135
|
async function requireBridge() {
|
|
54
136
|
if (bridge.isConnected())
|
|
55
137
|
return true;
|
|
56
138
|
const ok = await bridge.connect();
|
|
57
139
|
if (!ok) {
|
|
140
|
+
const pin = process.env.PAM_BRIDGE_PRODUCT;
|
|
58
141
|
return error("Not connected to PAM plugin. Start PAM (standalone or in a DAW) with MCP bridge enabled. " +
|
|
59
|
-
"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
|
+
: ""));
|
|
60
146
|
}
|
|
147
|
+
syncProductHint();
|
|
61
148
|
return true;
|
|
62
149
|
}
|
|
63
150
|
/**
|
|
@@ -76,23 +163,49 @@ function parsePresetFile(raw) {
|
|
|
76
163
|
const dataStr = raw.slice(prefixLen + metadataSize);
|
|
77
164
|
return { metadata: JSON.parse(metadataStr), data: JSON.parse(dataStr) };
|
|
78
165
|
}
|
|
166
|
+
// PAM ≥ 1.4.61 stores variations ONLY in the data block, zlib+base64-compressed with a
|
|
167
|
+
// "PAMZ1:" marker (see helpers/VariationCodec.h). Decompress one entry to plain JSON;
|
|
168
|
+
// plain (older-format) entries pass through unchanged.
|
|
169
|
+
const VARIATION_MARKER = "PAMZ1:";
|
|
170
|
+
function decompressVariationEntry(v) {
|
|
171
|
+
if (typeof v !== "string" || !v.startsWith(VARIATION_MARKER))
|
|
172
|
+
return v;
|
|
173
|
+
try {
|
|
174
|
+
return inflateSync(Buffer.from(v.slice(VARIATION_MARKER.length), "base64")).toString("utf8");
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return v;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Canonical variations array for a parsed preset, as plain-JSON strings (or nulls).
|
|
182
|
+
* The data block is authoritative (current format); metadata.variations is the fallback
|
|
183
|
+
* for legacy presets that still carried the duplicate copy there.
|
|
184
|
+
*/
|
|
185
|
+
function variationsFromParsedPreset(parsed) {
|
|
186
|
+
const fromData = parsed.data?.variations;
|
|
187
|
+
const fromMeta = parsed.metadata?.variations;
|
|
188
|
+
const src = Array.isArray(fromData) && fromData.length === PRESET_VARIATION_SLOTS ? fromData
|
|
189
|
+
: Array.isArray(fromMeta) && fromMeta.length === PRESET_VARIATION_SLOTS ? fromMeta
|
|
190
|
+
: null;
|
|
191
|
+
if (!src)
|
|
192
|
+
return Array(PRESET_VARIATION_SLOTS).fill(null);
|
|
193
|
+
return src.map(decompressVariationEntry);
|
|
194
|
+
}
|
|
79
195
|
/**
|
|
80
196
|
* Read existing variations array from a preset file on disk.
|
|
81
|
-
* Returns Array(8) of strings or nulls.
|
|
197
|
+
* Returns Array(8) of plain-JSON strings or nulls.
|
|
82
198
|
*/
|
|
83
199
|
async function readExistingVariations(presetPath) {
|
|
84
200
|
try {
|
|
85
201
|
const fullPath = await resolvePresetPath(presetPath);
|
|
86
202
|
const raw = await readFile(fullPath, "utf-8");
|
|
87
|
-
|
|
88
|
-
if (metadata.variations && Array.isArray(metadata.variations) && metadata.variations.length === PRESET_VARIATION_SLOTS) {
|
|
89
|
-
return [...metadata.variations];
|
|
90
|
-
}
|
|
203
|
+
return variationsFromParsedPreset(parsePresetFile(raw));
|
|
91
204
|
}
|
|
92
205
|
catch {
|
|
93
206
|
// Preset doesn't exist yet or can't be read — start fresh
|
|
207
|
+
return Array(PRESET_VARIATION_SLOTS).fill(null);
|
|
94
208
|
}
|
|
95
|
-
return Array(PRESET_VARIATION_SLOTS).fill(null);
|
|
96
209
|
}
|
|
97
210
|
// Mirrors interop.ts VARIATION_EXCLUDED_PARAMS (activePresetPath is excluded separately below, matching interop.ts)
|
|
98
211
|
const VARIATION_EXCLUDED_PARAMS = [
|
|
@@ -149,27 +262,480 @@ function formatParam(p) {
|
|
|
149
262
|
desc += ` — ${p.tooltip}`;
|
|
150
263
|
return desc;
|
|
151
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
|
+
}
|
|
152
467
|
// --- Server ---
|
|
153
468
|
const server = new McpServer({
|
|
154
469
|
name: "pam",
|
|
155
|
-
|
|
470
|
+
title: "PAM Audio Sampler",
|
|
471
|
+
version: SERVER_VERSION,
|
|
156
472
|
}, {
|
|
157
|
-
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 " +
|
|
158
477
|
"first to discover the user's actual library paths — never guess paths like ~/Music/PAM. " +
|
|
159
478
|
"The sample library lives under ~/Library/Application Support/, NOT ~/Music/. " +
|
|
160
479
|
"Use the returned paths with list_samples. When loading samples, use load_sample with " +
|
|
161
480
|
"paths from list_samples. Transport must be playing for sequencer output. " +
|
|
162
|
-
"To enable experimental features (
|
|
163
|
-
"experimentalMode:true AND the specific sub-flag (e.g.
|
|
481
|
+
"To enable experimental features (plugin host, AI engines) call update_settings with both " +
|
|
482
|
+
"experimentalMode:true AND the specific sub-flag (e.g. experimentalPluginHost:true). " +
|
|
483
|
+
"The drum synth is a standard feature (PAM ≥ 1.5) — no settings flag needed. " +
|
|
164
484
|
"Tools added in server version 1.7.0 — update_settings, configure_transport, " +
|
|
165
485
|
"get_audio_device_setup, set_midi_io, set_midi_mapping, delete_preset, " +
|
|
166
486
|
"set_preset_factory_status, get_sample_usage, repair_preset_samples, preview_sample, " +
|
|
167
487
|
"set_plugin_params, skip_current_plugin_scan, remove_plugin_from_blocklist — require PAM ≥ 1.7.0. " +
|
|
488
|
+
"VX video tools (get_video_state, video_command, add_video_layer, update_video_layer, " +
|
|
489
|
+
"remove_video_layer, apply_video_layout, add_video_fx, set_video_fx, remove_video_fx, " +
|
|
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 " +
|
|
494
|
+
"audio-reactive control, note the visual audio-react params (visualWaveDisplace, etc.) " +
|
|
495
|
+
"are regular plugin params — drive them with set_parameters; layers/FX/bindings go through " +
|
|
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. " +
|
|
168
501
|
"Earlier tool sets (looper, song mode, modulation edits, MIDI recording, sample analysis, " +
|
|
169
|
-
"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). " +
|
|
170
510
|
"If a tool fails with \"PAM build does not support 'X'\", the connected plugin is older than " +
|
|
171
|
-
"this MCP server; the user must update PAM (updating the npm package alone is not enough)."
|
|
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.",
|
|
172
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
|
+
}
|
|
173
739
|
// ============================================================
|
|
174
740
|
// TOOLS — Offline (work without plugin running)
|
|
175
741
|
// ============================================================
|
|
@@ -206,7 +772,7 @@ server.registerTool("get_parameter_info", {
|
|
|
206
772
|
if (paramId) {
|
|
207
773
|
const p = index.get(paramId);
|
|
208
774
|
if (!p)
|
|
209
|
-
return error(
|
|
775
|
+
return error(formatUnknownParamError(paramId, index));
|
|
210
776
|
return json(p);
|
|
211
777
|
}
|
|
212
778
|
let params;
|
|
@@ -234,8 +800,10 @@ server.registerTool("get_parameter_info", {
|
|
|
234
800
|
if (params.length === 0)
|
|
235
801
|
return text("No parameters found matching criteria.");
|
|
236
802
|
if (params.length > 50) {
|
|
237
|
-
|
|
238
|
-
|
|
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"));
|
|
239
807
|
}
|
|
240
808
|
return json(params);
|
|
241
809
|
});
|
|
@@ -312,6 +880,16 @@ server.registerTool("get_manifest_summary", {
|
|
|
312
880
|
description: "Get a high-level summary of PAM's capabilities: number of cells, effects, " +
|
|
313
881
|
"modulators, and parameter counts.",
|
|
314
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
|
+
},
|
|
315
893
|
annotations: { readOnlyHint: true },
|
|
316
894
|
}, async () => {
|
|
317
895
|
const manifest = await loadManifest();
|
|
@@ -320,7 +898,7 @@ server.registerTool("get_manifest_summary", {
|
|
|
320
898
|
const effects = new Set(params.filter((p) => p.effect).map((p) => p.effect));
|
|
321
899
|
const global = params.filter((p) => !p.cell);
|
|
322
900
|
const modulatable = params.filter((p) => p.isModulatable);
|
|
323
|
-
return
|
|
901
|
+
return structured({
|
|
324
902
|
product: manifest.name,
|
|
325
903
|
company: manifest.company,
|
|
326
904
|
totalParameters: params.length,
|
|
@@ -331,6 +909,98 @@ server.registerTool("get_manifest_summary", {
|
|
|
331
909
|
pluginParameters: params.filter((p) => p.isPluginParameter).length,
|
|
332
910
|
});
|
|
333
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
|
+
});
|
|
334
1004
|
// Standard full update — graph dispatch + KV sync + parameter updates.
|
|
335
1005
|
const KV_OPTS_STANDARD = {
|
|
336
1006
|
shouldDispatch: true,
|
|
@@ -359,6 +1029,20 @@ const KV_OPTS_PATTERN = {
|
|
|
359
1029
|
updateParameters: false,
|
|
360
1030
|
broadcastFollowers: false,
|
|
361
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
|
+
};
|
|
362
1046
|
// Effect config toggle — lighter KV path, no graph dispatch.
|
|
363
1047
|
const KV_OPTS_EFFECT_CONFIG = {
|
|
364
1048
|
shouldDispatch: false,
|
|
@@ -388,30 +1072,132 @@ async function applyKv(delta, options, directNodeOps) {
|
|
|
388
1072
|
}
|
|
389
1073
|
server.registerTool("get_state", {
|
|
390
1074
|
title: "Get Plugin State",
|
|
391
|
-
description: "Read the current
|
|
392
|
-
"
|
|
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.",
|
|
393
1077
|
inputSchema: {
|
|
394
1078
|
section: z
|
|
395
1079
|
.enum(["full", "cells", "lfos", "macros", "envelopes", "vary", "paramSeqs", "loopers", "songMode"])
|
|
396
1080
|
.optional()
|
|
397
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."),
|
|
398
1094
|
},
|
|
399
1095
|
annotations: { readOnlyHint: true },
|
|
400
|
-
}, async ({ section }) => {
|
|
1096
|
+
}, async ({ section, keys, cellIds, allowLarge }) => {
|
|
401
1097
|
const check = await requireBridge();
|
|
402
1098
|
if (check !== true)
|
|
403
1099
|
return check;
|
|
1100
|
+
const hasFilters = (keys?.length ?? 0) > 0 || (cellIds?.length ?? 0) > 0;
|
|
404
1101
|
if (section && section !== "full") {
|
|
405
1102
|
const result = await bridge.send("getState", section);
|
|
406
|
-
|
|
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.");
|
|
407
1105
|
}
|
|
408
1106
|
const state = await bridge.send("getState");
|
|
409
|
-
|
|
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
|
+
});
|
|
410
1195
|
});
|
|
411
1196
|
server.registerTool("set_parameters", {
|
|
412
1197
|
title: "Set Parameters",
|
|
413
1198
|
description: "Set one or more PAM parameters. Values are validated against the manifest. " +
|
|
414
|
-
"
|
|
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.",
|
|
415
1201
|
inputSchema: {
|
|
416
1202
|
parameters: z
|
|
417
1203
|
.record(z.string(), z.number())
|
|
@@ -425,11 +1211,11 @@ server.registerTool("set_parameters", {
|
|
|
425
1211
|
for (const [paramId, value] of Object.entries(parameters)) {
|
|
426
1212
|
const p = index.get(paramId);
|
|
427
1213
|
if (!p) {
|
|
428
|
-
errors.push(
|
|
1214
|
+
errors.push(formatUnknownParamError(paramId, index));
|
|
429
1215
|
continue;
|
|
430
1216
|
}
|
|
431
1217
|
if (value < p.min || value > p.max) {
|
|
432
|
-
errors.push(
|
|
1218
|
+
errors.push(formatRangeError(p, value));
|
|
433
1219
|
}
|
|
434
1220
|
}
|
|
435
1221
|
if (errors.length > 0)
|
|
@@ -482,6 +1268,87 @@ server.registerTool("load_sample", {
|
|
|
482
1268
|
await bridge.send("loadSampleToCell", filePath, cellId);
|
|
483
1269
|
return text(`Loaded ${basename(filePath)} into cell ${cellId}.`);
|
|
484
1270
|
});
|
|
1271
|
+
// ---- Unified AI model management (notochord / aigen / magenta) ----
|
|
1272
|
+
// All six map to the C++ MCPBridge model* actions (helpers/MCPBridge.cpp::handleModelOp).
|
|
1273
|
+
// Each engine must have its experimental flags on (update_settings: experimentalMode
|
|
1274
|
+
// + experimental<Engine>) — the bridge returns a clear error otherwise.
|
|
1275
|
+
const MODEL_ENGINE = z
|
|
1276
|
+
.enum(["notochord", "aigen", "magenta"])
|
|
1277
|
+
.describe("Which AI engine. notochord = MIDI co-player (~18 MB blob); aigen = Stable Audio 3 sample generation (~3.2 GB ONNX); magenta = MagentaRT realtime audio (Apple Silicon, MLX).");
|
|
1278
|
+
const MODEL_VARIANT = z
|
|
1279
|
+
.enum(["base", "small"])
|
|
1280
|
+
.optional()
|
|
1281
|
+
.describe("Magenta only: which variant (base = 2.4B, needs ~M2 Max/M3 Pro+; small = 230M, default). Ignored for notochord/aigen (single model).");
|
|
1282
|
+
server.registerTool("download_model", {
|
|
1283
|
+
title: "Download AI Model",
|
|
1284
|
+
description: "Start (or resume) downloading model weights for an AI engine. Async — returns immediately with download progress; poll get_model_status. aigen + notochord auto-load on completion; magenta needs a separate load_model. Requires the engine's experimental flags.",
|
|
1285
|
+
inputSchema: { engine: MODEL_ENGINE, model: MODEL_VARIANT },
|
|
1286
|
+
}, async ({ engine, model }) => {
|
|
1287
|
+
const check = await requireBridge();
|
|
1288
|
+
if (check !== true)
|
|
1289
|
+
return check;
|
|
1290
|
+
return json(await bridge.send("modelDownload", { engine, model }));
|
|
1291
|
+
});
|
|
1292
|
+
server.registerTool("get_model_status", {
|
|
1293
|
+
title: "Get AI Model Status",
|
|
1294
|
+
description: "Report downloaded / loaded / loading / download-in-flight + version + disk size + live download progress for an AI engine.",
|
|
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
|
+
},
|
|
1306
|
+
}, async ({ engine }) => {
|
|
1307
|
+
const check = await requireBridge();
|
|
1308
|
+
if (check !== true)
|
|
1309
|
+
return check;
|
|
1310
|
+
return structuredFromBridge(await bridge.send("modelStatus", { engine }));
|
|
1311
|
+
});
|
|
1312
|
+
server.registerTool("load_model", {
|
|
1313
|
+
title: "Load AI Model",
|
|
1314
|
+
description: "Load a downloaded model into memory (async — poll get_model_status for loaded:true). Use after unload, or for magenta after download. magenta accepts an optional variant; omit to load whichever is on disk.",
|
|
1315
|
+
inputSchema: { engine: MODEL_ENGINE, model: MODEL_VARIANT },
|
|
1316
|
+
}, async ({ engine, model }) => {
|
|
1317
|
+
const check = await requireBridge();
|
|
1318
|
+
if (check !== true)
|
|
1319
|
+
return check;
|
|
1320
|
+
return json(await bridge.send("modelLoad", { engine, model }));
|
|
1321
|
+
});
|
|
1322
|
+
server.registerTool("unload_model", {
|
|
1323
|
+
title: "Unload AI Model",
|
|
1324
|
+
description: "Free the loaded model from memory (weights stay on disk). May briefly stall the plugin while an in-flight load finishes (non-interruptible ORT/MLX load).",
|
|
1325
|
+
inputSchema: { engine: MODEL_ENGINE },
|
|
1326
|
+
}, async ({ engine }) => {
|
|
1327
|
+
const check = await requireBridge();
|
|
1328
|
+
if (check !== true)
|
|
1329
|
+
return check;
|
|
1330
|
+
return json(await bridge.send("modelUnload", { engine }));
|
|
1331
|
+
});
|
|
1332
|
+
server.registerTool("delete_model", {
|
|
1333
|
+
title: "Delete AI Model",
|
|
1334
|
+
description: "Delete downloaded model weights from disk. Unloads first automatically. DESTRUCTIVE — re-download to restore. magenta delete is variant-agnostic (removes both variants + shared resources).",
|
|
1335
|
+
inputSchema: { engine: MODEL_ENGINE, model: MODEL_VARIANT },
|
|
1336
|
+
}, async ({ engine, model }) => {
|
|
1337
|
+
const check = await requireBridge();
|
|
1338
|
+
if (check !== true)
|
|
1339
|
+
return check;
|
|
1340
|
+
return json(await bridge.send("modelDelete", { engine, model }));
|
|
1341
|
+
});
|
|
1342
|
+
server.registerTool("list_models", {
|
|
1343
|
+
title: "List AI Models",
|
|
1344
|
+
description: "List available model variants per engine with downloaded / loaded state. Omit engine to list all engines (notochord, aigen, magenta base + small).",
|
|
1345
|
+
inputSchema: { engine: MODEL_ENGINE.optional() },
|
|
1346
|
+
}, async ({ engine }) => {
|
|
1347
|
+
const check = await requireBridge();
|
|
1348
|
+
if (check !== true)
|
|
1349
|
+
return check;
|
|
1350
|
+
return json(await bridge.send("modelList", { engine }));
|
|
1351
|
+
});
|
|
485
1352
|
server.registerTool("generate_sample", {
|
|
486
1353
|
title: "Generate Sample (AI)",
|
|
487
1354
|
description: "Generate a new audio sample via the on-device AI engine (Stable Audio 3 Small SFX, ~3.2 GB ONNX). The plugin must have the model downloaded first (Settings → Local models). Returns the on-disk path to the generated WAV and optionally loads it into a cell.",
|
|
@@ -563,6 +1430,145 @@ server.registerTool("generate_kit", {
|
|
|
563
1430
|
return error(`Kit generation failed: ${e.message}`);
|
|
564
1431
|
}
|
|
565
1432
|
});
|
|
1433
|
+
// --- AI Audio Workbench (Phase A, dev-gated) ---------------------------------
|
|
1434
|
+
// Drives the Transform canvas headlessly: canvas state (mask regions / strength
|
|
1435
|
+
// curve / prompt / seed / sigma), sidecar renders, and the take shelf. Requires
|
|
1436
|
+
// experimentalMode + experimentalAIGen (update_settings) AND the SA3 Python
|
|
1437
|
+
// sidecar running on 127.0.0.1:8093 (packages/sa3-workbench-sidecar/README.md).
|
|
1438
|
+
const WORKBENCH_MASK_REGION = z.object({
|
|
1439
|
+
startSec: z.number().min(0).describe("Region start in seconds (source time)."),
|
|
1440
|
+
endSec: z.number().min(0).describe("Region end in seconds (must be > startSec)."),
|
|
1441
|
+
lowHz: z.number().min(0).optional()
|
|
1442
|
+
.describe("Optional band low edge in Hz (canvas v2 2D mask). Omit BOTH lowHz/highHz for a full-band region (1D back-compat)."),
|
|
1443
|
+
highHz: z.number().min(0).optional()
|
|
1444
|
+
.describe("Optional band high edge in Hz (must be > lowHz). lowHz<=20 && highHz>=source nyquist is treated as full band by the sidecar."),
|
|
1445
|
+
});
|
|
1446
|
+
const WORKBENCH_CURVE_POINT = z.object({
|
|
1447
|
+
t: z.number().describe("Normalized time 0..1."),
|
|
1448
|
+
v: z.number().describe("Value 0..1 (σ at this point)."),
|
|
1449
|
+
tension: z.number().optional().describe("Curve tension (CurveCanvas semantics, default 0)."),
|
|
1450
|
+
});
|
|
1451
|
+
const WORKBENCH_STATE_FIELDS = {
|
|
1452
|
+
sourceCellId: z.number().int().min(1).max(8).nullable().optional()
|
|
1453
|
+
.describe("Cell whose sample is the transform source (null to clear)."),
|
|
1454
|
+
prompt: z.string().optional().describe("Text prompt for the render."),
|
|
1455
|
+
seed: z.number().int().min(0).optional().describe("Render seed."),
|
|
1456
|
+
seedLocked: z.boolean().optional().describe("Lock seed across renders (iterate at fixed identity)."),
|
|
1457
|
+
sigma: z.number().min(0.02).max(1).optional()
|
|
1458
|
+
.describe("Deviation σ / init noise level. a2a: 0.25-0.35 preserves melody, 0.45-0.5 variation; inpaint regime ~0.85."),
|
|
1459
|
+
steps: z.number().int().min(1).max(32).optional().describe("Denoise steps (8 is the validated recipe)."),
|
|
1460
|
+
maskRegions: z.array(WORKBENCH_MASK_REGION).optional()
|
|
1461
|
+
.describe("Transform-lane mask. Empty = pure a2a re-render of the whole source; regions = inpaint (regenerate inside, keep outside). Regions may carry an optional lowHz/highHz band."),
|
|
1462
|
+
strengthCurve: z.array(WORKBENCH_CURVE_POINT).optional()
|
|
1463
|
+
.describe("Strength-lane breakpoints (stored with full provenance; per-region σ application is experimental)."),
|
|
1464
|
+
activeSourcePath: z.string().optional()
|
|
1465
|
+
.describe("Render-chain head: absolute WAV path used as the transform source ('' = the source cell's sample). Managed automatically — each successful render advances it to the new take's wavPath; set explicitly only to point the chain at a prior take. Walk it back with workbench_undo / workbench_reset_chain."),
|
|
1466
|
+
sketchPath: z.string().optional()
|
|
1467
|
+
.describe("SKETCH mode: absolute path to a painted-gesture WAV ('' = no sketch). Normally written by the UI (workbenchSaveSketch saves into ai-cache/workbench/); settable directly for headless tests. When non-empty, renders feed the model source + sketch*sketchGain as init audio AND use that MIX as the preservation reference for masks/paste-back — the gesture survives outside masked regions."),
|
|
1468
|
+
sketchGain: z.number().min(0).max(1).optional()
|
|
1469
|
+
.describe("SKETCH mix gain 0..1 (default 0.5) applied to the sketch before summing with the source. If the mix peaks above 0.99 the sidecar scales the whole mix down to 0.99."),
|
|
1470
|
+
};
|
|
1471
|
+
server.registerTool("workbench_get_state", {
|
|
1472
|
+
title: "Workbench: Get State",
|
|
1473
|
+
description: "Read the AI Audio Workbench canvas state AND the take shelf: { state: { sourceCellId, prompt, seed, seedLocked, sigma, steps, maskRegions, strengthCurve, activeSourcePath, sketchPath, sketchGain }, takes: [...] }.",
|
|
1474
|
+
inputSchema: {},
|
|
1475
|
+
}, async () => {
|
|
1476
|
+
const check = await requireBridge();
|
|
1477
|
+
if (check !== true)
|
|
1478
|
+
return check;
|
|
1479
|
+
return json(await bridge.send("workbenchGetState"));
|
|
1480
|
+
});
|
|
1481
|
+
server.registerTool("workbench_set_state", {
|
|
1482
|
+
title: "Workbench: Set State",
|
|
1483
|
+
description: "Partial-merge the Workbench canvas state (only the keys you pass change). Also pushes the merged state into an open Workbench UI. Returns the full merged state.",
|
|
1484
|
+
inputSchema: WORKBENCH_STATE_FIELDS,
|
|
1485
|
+
}, async (args) => {
|
|
1486
|
+
const check = await requireBridge();
|
|
1487
|
+
if (check !== true)
|
|
1488
|
+
return check;
|
|
1489
|
+
return json(await bridge.send("workbenchSetState", args));
|
|
1490
|
+
});
|
|
1491
|
+
server.registerTool("workbench_render", {
|
|
1492
|
+
title: "Workbench: Render",
|
|
1493
|
+
description: "Render the Workbench transform via the SA3 sidecar and append the result to the take shelf. Renders the CURRENT canvas state merged with any overrides passed here (overrides do not persist). Blocks until done (up to ~3 min: pipeline rebuilds + multi-region inpaints render sequentially). Returns the take { id, wavPath, prompt, seed, sigma, maskRegions, createdAt, sourceCellId, durationSeconds, error? }. Source resolution: sourcePath override > activeSourcePath (chain head) > state.sourceCellId's sample file. A successful render ADVANCES the chain (activeSourcePath = new take's wavPath) — step back with workbench_undo or workbench_reset_chain.",
|
|
1494
|
+
inputSchema: {
|
|
1495
|
+
...WORKBENCH_STATE_FIELDS,
|
|
1496
|
+
sourcePath: z.string().optional()
|
|
1497
|
+
.describe("Absolute path to a source WAV — bypasses sourceCellId resolution."),
|
|
1498
|
+
},
|
|
1499
|
+
}, async (args) => {
|
|
1500
|
+
const check = await requireBridge();
|
|
1501
|
+
if (check !== true)
|
|
1502
|
+
return check;
|
|
1503
|
+
try {
|
|
1504
|
+
return json(await bridge.send("workbenchRender", args));
|
|
1505
|
+
}
|
|
1506
|
+
catch (e) {
|
|
1507
|
+
return error(`Workbench render failed: ${e.message}`);
|
|
1508
|
+
}
|
|
1509
|
+
});
|
|
1510
|
+
server.registerTool("workbench_list_takes", {
|
|
1511
|
+
title: "Workbench: List Takes",
|
|
1512
|
+
description: "List all Workbench takes (renders) with full provenance, oldest first.",
|
|
1513
|
+
inputSchema: {},
|
|
1514
|
+
}, async () => {
|
|
1515
|
+
const check = await requireBridge();
|
|
1516
|
+
if (check !== true)
|
|
1517
|
+
return check;
|
|
1518
|
+
return json(await bridge.send("workbenchListTakes"));
|
|
1519
|
+
});
|
|
1520
|
+
server.registerTool("workbench_load_take", {
|
|
1521
|
+
title: "Workbench: Load Take",
|
|
1522
|
+
description: "Load a rendered take's WAV into a cell (same native path as load_sample).",
|
|
1523
|
+
inputSchema: {
|
|
1524
|
+
takeId: z.string().describe("Take id from workbench_render / workbench_list_takes."),
|
|
1525
|
+
cellId: z.number().int().min(1).max(8).describe("Target cell."),
|
|
1526
|
+
},
|
|
1527
|
+
}, async ({ takeId, cellId }) => {
|
|
1528
|
+
const check = await requireBridge();
|
|
1529
|
+
if (check !== true)
|
|
1530
|
+
return check;
|
|
1531
|
+
try {
|
|
1532
|
+
return json(await bridge.send("workbenchLoadTake", { takeId, cellId }));
|
|
1533
|
+
}
|
|
1534
|
+
catch (e) {
|
|
1535
|
+
return error(`Failed to load take: ${e.message}`);
|
|
1536
|
+
}
|
|
1537
|
+
});
|
|
1538
|
+
server.registerTool("workbench_undo", {
|
|
1539
|
+
title: "Workbench: Undo (pop chain)",
|
|
1540
|
+
description: "Pop the Workbench render chain one step: restores the previous chain head (activeSourcePath) so the next render re-transforms the prior source. No-op at the chain root (cell sample). Takes are kept. Returns the full { state, takes }.",
|
|
1541
|
+
inputSchema: {},
|
|
1542
|
+
}, async () => {
|
|
1543
|
+
const check = await requireBridge();
|
|
1544
|
+
if (check !== true)
|
|
1545
|
+
return check;
|
|
1546
|
+
return json(await bridge.send("workbenchUndo"));
|
|
1547
|
+
});
|
|
1548
|
+
server.registerTool("workbench_reset_chain", {
|
|
1549
|
+
title: "Workbench: Reset Chain",
|
|
1550
|
+
description: "Clear the Workbench render chain: empties the undo stack and resets the source back to the cell sample (activeSourcePath = ''). Takes are kept. Returns the full { state, takes }.",
|
|
1551
|
+
inputSchema: {},
|
|
1552
|
+
}, async () => {
|
|
1553
|
+
const check = await requireBridge();
|
|
1554
|
+
if (check !== true)
|
|
1555
|
+
return check;
|
|
1556
|
+
return json(await bridge.send("workbenchResetChain"));
|
|
1557
|
+
});
|
|
1558
|
+
server.registerTool("workbench_sidecar_status", {
|
|
1559
|
+
title: "Workbench: Sidecar Status",
|
|
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).",
|
|
1561
|
+
inputSchema: {},
|
|
1562
|
+
outputSchema: {
|
|
1563
|
+
connected: z.boolean().optional(),
|
|
1564
|
+
loaded: z.boolean().optional(),
|
|
1565
|
+
},
|
|
1566
|
+
}, async () => {
|
|
1567
|
+
const check = await requireBridge();
|
|
1568
|
+
if (check !== true)
|
|
1569
|
+
return check;
|
|
1570
|
+
return structuredFromBridge(await bridge.send("workbenchSidecarStatus"));
|
|
1571
|
+
});
|
|
566
1572
|
server.registerTool("trigger_midi", {
|
|
567
1573
|
title: "Trigger MIDI",
|
|
568
1574
|
description: "Send a MIDI note to a specific cell or globally. " +
|
|
@@ -626,13 +1632,13 @@ server.registerTool("add_modulation", {
|
|
|
626
1632
|
.min(1)
|
|
627
1633
|
.max(8)
|
|
628
1634
|
.optional()
|
|
629
|
-
.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."),
|
|
630
1636
|
lfoIndex: z
|
|
631
1637
|
.number()
|
|
632
1638
|
.min(0)
|
|
633
1639
|
.max(5)
|
|
634
1640
|
.optional()
|
|
635
|
-
.describe("LFO generator index
|
|
1641
|
+
.describe("Required for LFO: generator index 0-5. Parameter IDs remain 1-based (lfo1_... through lfo6_...)."),
|
|
636
1642
|
strength: z
|
|
637
1643
|
.number()
|
|
638
1644
|
.min(0)
|
|
@@ -657,9 +1663,9 @@ server.registerTool("add_modulation", {
|
|
|
657
1663
|
const index = await getParameterIndex();
|
|
658
1664
|
const param = index.get(paramId);
|
|
659
1665
|
if (!param)
|
|
660
|
-
return error(
|
|
1666
|
+
return error(formatUnknownParamError(paramId, index, { modulatableOnly: true }));
|
|
661
1667
|
if (!param.isModulatable)
|
|
662
|
-
return error(
|
|
1668
|
+
return error(formatNotModulatableError(paramId, index));
|
|
663
1669
|
const check = await requireBridge();
|
|
664
1670
|
if (check !== true)
|
|
665
1671
|
return check;
|
|
@@ -675,12 +1681,12 @@ server.registerTool("add_modulation", {
|
|
|
675
1681
|
}
|
|
676
1682
|
else if (type === "envelope") {
|
|
677
1683
|
if (!cellId)
|
|
678
|
-
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.");
|
|
679
1685
|
await bridge.send("addEnvelope", paramId, cellId, strength, bipolar, effectEnableParams, pitch ?? false, pitchDuck ?? false);
|
|
680
1686
|
}
|
|
681
1687
|
else if (type === "vary") {
|
|
682
1688
|
if (!cellId)
|
|
683
|
-
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.");
|
|
684
1690
|
await bridge.send("addVary", paramId, cellId, strength, bipolar, effectEnableParams);
|
|
685
1691
|
}
|
|
686
1692
|
return text(`Added ${type} modulation to ${paramId} (strength: ${strength}%).`);
|
|
@@ -753,12 +1759,17 @@ server.registerTool("update_pattern", {
|
|
|
753
1759
|
});
|
|
754
1760
|
server.registerTool("randomize", {
|
|
755
1761
|
title: "Randomize",
|
|
756
|
-
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.",
|
|
757
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).
|
|
758
1769
|
target: z
|
|
759
|
-
.enum(["full", "samples", "sequences"])
|
|
1770
|
+
.enum(["full", "samples", "sequences", "synth", "lfos", "mods"])
|
|
760
1771
|
.default("full")
|
|
761
|
-
.describe("What to randomize."),
|
|
1772
|
+
.describe("What to randomize. 'synth' = the built-in drum-synth machines (PAM window must be open)."),
|
|
762
1773
|
cellIds: z
|
|
763
1774
|
.array(z.number().min(1).max(8))
|
|
764
1775
|
.optional()
|
|
@@ -784,6 +1795,21 @@ server.registerTool("randomize", {
|
|
|
784
1795
|
const check = await requireBridge();
|
|
785
1796
|
if (check !== true)
|
|
786
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
|
+
}
|
|
787
1813
|
await bridge.send("randomize", target, cellIds ?? [], genreHint ?? "", bpmHint ?? -1, mutationAmount ?? -1);
|
|
788
1814
|
return text(`Randomized ${target}${cellIds ? ` for cells ${cellIds.join(", ")}` : ""}.`);
|
|
789
1815
|
});
|
|
@@ -798,7 +1824,7 @@ server.registerTool("randomize_samples", {
|
|
|
798
1824
|
genreHint: z
|
|
799
1825
|
.string()
|
|
800
1826
|
.optional()
|
|
801
|
-
.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."),
|
|
802
1828
|
bpmHint: z
|
|
803
1829
|
.number()
|
|
804
1830
|
.min(20)
|
|
@@ -886,13 +1912,148 @@ server.registerTool("get_transport", {
|
|
|
886
1912
|
title: "Get Transport",
|
|
887
1913
|
description: "Get current transport state (BPM, playing, position).",
|
|
888
1914
|
inputSchema: {},
|
|
1915
|
+
outputSchema: {
|
|
1916
|
+
bpm: z.number().optional(),
|
|
1917
|
+
isPlaying: z.boolean().optional(),
|
|
1918
|
+
ppqPosition: z.number().optional(),
|
|
1919
|
+
},
|
|
889
1920
|
annotations: { readOnlyHint: true },
|
|
890
1921
|
}, async () => {
|
|
891
1922
|
const check = await requireBridge();
|
|
892
1923
|
if (check !== true)
|
|
893
1924
|
return check;
|
|
894
1925
|
const transport = await bridge.send("getTransport");
|
|
895
|
-
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 });
|
|
896
2057
|
});
|
|
897
2058
|
server.registerTool("new_preset", {
|
|
898
2059
|
title: "New Preset",
|
|
@@ -913,7 +2074,11 @@ server.registerTool("save_preset", {
|
|
|
913
2074
|
"Writes to the user preset folder by default; pass `path` for a specific location. " +
|
|
914
2075
|
"Use `update_preset_metadata` to edit an existing preset without re-saving plugin state.",
|
|
915
2076
|
inputSchema: {
|
|
916
|
-
|
|
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)."),
|
|
917
2082
|
author: z.string().optional().describe("Author name."),
|
|
918
2083
|
category: z
|
|
919
2084
|
.string()
|
|
@@ -938,26 +2103,56 @@ server.registerTool("save_preset", {
|
|
|
938
2103
|
const check = await requireBridge();
|
|
939
2104
|
if (check !== true)
|
|
940
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
|
+
}
|
|
941
2139
|
const metadata = { name, factory: false };
|
|
942
2140
|
if (path !== undefined)
|
|
943
2141
|
metadata.path = path;
|
|
944
|
-
for (const [k, v] of Object.entries(
|
|
2142
|
+
for (const [k, v] of Object.entries(extras)) {
|
|
945
2143
|
if (v !== undefined)
|
|
946
2144
|
metadata[k] = v;
|
|
947
2145
|
}
|
|
948
2146
|
// Match UI behavior: auto-save current state to variation slot 0 if no variations exist,
|
|
949
2147
|
// otherwise update the current active variation slot.
|
|
950
|
-
const state = (await bridge.send("getState"));
|
|
951
2148
|
const variationString = buildVariationString(state);
|
|
952
2149
|
const presetRelPath = path || `User/${name}.preset`;
|
|
953
|
-
|
|
954
|
-
//
|
|
955
|
-
//
|
|
956
|
-
//
|
|
957
|
-
const
|
|
958
|
-
|
|
959
|
-
: presetRelPath;
|
|
960
|
-
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);
|
|
961
2156
|
const hasVariations = existingVariations.some((v) => v !== null);
|
|
962
2157
|
if (!hasVariations) {
|
|
963
2158
|
existingVariations[0] = variationString;
|
|
@@ -971,6 +2166,8 @@ server.registerTool("save_preset", {
|
|
|
971
2166
|
}
|
|
972
2167
|
metadata.variations = existingVariations;
|
|
973
2168
|
const result = (await bridge.send("savePreset", metadata));
|
|
2169
|
+
lastSavedPresetName = name;
|
|
2170
|
+
lastSavedPresetPath = result?.path || readPath;
|
|
974
2171
|
return text(`Saved preset: ${name}\nPath: ${result?.path || "(unknown path)"}`);
|
|
975
2172
|
});
|
|
976
2173
|
server.registerTool("save_variation", {
|
|
@@ -1018,11 +2215,15 @@ server.registerTool("save_variation", {
|
|
|
1018
2215
|
// Strip "Presets/" prefix if present — resolvePresetPath already prepends
|
|
1019
2216
|
// the presets base directory, so "Presets/User/X" would double up to
|
|
1020
2217
|
// ".../Presets/Presets/User/X" and silently fail to read back variations.
|
|
1021
|
-
diskPath =
|
|
2218
|
+
diskPath = normalizePresetLookupPath(activePath);
|
|
2219
|
+
}
|
|
2220
|
+
if (!presetName && lastSavedPresetName) {
|
|
2221
|
+
presetName = lastSavedPresetName;
|
|
2222
|
+
diskPath = lastSavedPresetPath;
|
|
1022
2223
|
}
|
|
1023
2224
|
if (!presetName) {
|
|
1024
|
-
return
|
|
1025
|
-
"
|
|
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'.");
|
|
1026
2227
|
}
|
|
1027
2228
|
if (!diskPath) {
|
|
1028
2229
|
diskPath = `User/${presetName}.preset`;
|
|
@@ -1033,11 +2234,9 @@ server.registerTool("save_variation", {
|
|
|
1033
2234
|
try {
|
|
1034
2235
|
const fullPath = await resolvePresetPath(diskPath);
|
|
1035
2236
|
const raw = await readFile(fullPath, "utf-8");
|
|
1036
|
-
const
|
|
1037
|
-
existingMeta =
|
|
1038
|
-
|
|
1039
|
-
variations = [...m.variations];
|
|
1040
|
-
}
|
|
2237
|
+
const parsed = parsePresetFile(raw);
|
|
2238
|
+
existingMeta = parsed.metadata;
|
|
2239
|
+
variations = variationsFromParsedPreset(parsed);
|
|
1041
2240
|
}
|
|
1042
2241
|
catch {
|
|
1043
2242
|
// New preset or unreadable — start fresh
|
|
@@ -1063,6 +2262,8 @@ server.registerTool("save_variation", {
|
|
|
1063
2262
|
resultSuffix = "\n(Note: active preset is a factory preset — saved as new user preset)";
|
|
1064
2263
|
}
|
|
1065
2264
|
const result = (await bridge.send("savePreset", metadata));
|
|
2265
|
+
lastSavedPresetName = presetName;
|
|
2266
|
+
lastSavedPresetPath = result?.path || diskPath;
|
|
1066
2267
|
return text(`Saved variation ${index} for "${presetName}"\n` +
|
|
1067
2268
|
`Path: ${result?.path || "(unknown path)"}\n` +
|
|
1068
2269
|
`Filled slots: ${variations.filter((v) => v !== null).length}/8` +
|
|
@@ -1180,30 +2381,141 @@ server.registerTool("reverse_sample", {
|
|
|
1180
2381
|
await bridge.send("reverseSampleForCell", cellId);
|
|
1181
2382
|
return text(`Reversed sample in cell ${cellId}.`);
|
|
1182
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
|
+
});
|
|
1183
2390
|
server.registerTool("connection_status", {
|
|
1184
2391
|
title: "Connection Status",
|
|
1185
2392
|
description: "Check if the MCP server can connect to a running PAM plugin instance. " +
|
|
1186
2393
|
"Returns connection details or instructions for enabling the bridge.",
|
|
1187
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
|
+
},
|
|
1188
2401
|
annotations: { readOnlyHint: true },
|
|
1189
2402
|
}, async () => {
|
|
1190
2403
|
const instances = await bridge.discover();
|
|
1191
2404
|
if (instances.length === 0) {
|
|
1192
|
-
return
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
"
|
|
1196
|
-
|
|
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
|
+
});
|
|
1197
2412
|
}
|
|
1198
2413
|
const connected = bridge.isConnected() || (await bridge.connect());
|
|
1199
|
-
|
|
2414
|
+
if (connected)
|
|
2415
|
+
syncProductHint();
|
|
2416
|
+
return structured({
|
|
1200
2417
|
instances,
|
|
1201
2418
|
connected,
|
|
2419
|
+
connectedTo: bridge.getConnectedInstance(),
|
|
1202
2420
|
note: connected
|
|
1203
|
-
?
|
|
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."
|
|
1204
2424
|
: "Found instances but could not connect. The plugin may have shut down.",
|
|
1205
2425
|
});
|
|
1206
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
|
+
}
|
|
1207
2519
|
server.registerTool("list_samples", {
|
|
1208
2520
|
title: "List Samples",
|
|
1209
2521
|
description: "Browse sample library directories. IMPORTANT: Do NOT guess paths — always call " +
|
|
@@ -1235,12 +2547,25 @@ server.registerTool("list_samples", {
|
|
|
1235
2547
|
.max(200)
|
|
1236
2548
|
.default(50)
|
|
1237
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')."),
|
|
1238
2556
|
},
|
|
1239
2557
|
annotations: { readOnlyHint: true },
|
|
1240
|
-
}, async ({ path: dirPath, audioOnly, sort, offset, limit }) => {
|
|
2558
|
+
}, async ({ path: dirPath, audioOnly, sort, offset, limit, search }) => {
|
|
1241
2559
|
const check = await requireBridge();
|
|
1242
2560
|
if (check !== true)
|
|
1243
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
|
+
}
|
|
1244
2569
|
const options = JSON.stringify({ audioOnly, sort });
|
|
1245
2570
|
const result = await bridge.send("listDirectory", dirPath, options);
|
|
1246
2571
|
if (!Array.isArray(result) || result.length === 0) {
|
|
@@ -1344,86 +2669,686 @@ server.registerTool("set_visual", {
|
|
|
1344
2669
|
if (typeof result === "object" && result.path) {
|
|
1345
2670
|
return text(`Saved visual: ${result.path} (${result.mime}, ${result.size} bytes)`);
|
|
1346
2671
|
}
|
|
1347
|
-
return text("Visual saved.");
|
|
2672
|
+
return text("Visual saved.");
|
|
2673
|
+
});
|
|
2674
|
+
server.registerTool("list_visuals", {
|
|
2675
|
+
title: "List Visuals",
|
|
2676
|
+
description: "List all visual/thumbnail files in PAM's media library. " +
|
|
2677
|
+
"Returns name, relative path, MIME type, size, and modification time for each visual.",
|
|
2678
|
+
inputSchema: {
|
|
2679
|
+
scope: z
|
|
2680
|
+
.enum(["user", "factory", "all"])
|
|
2681
|
+
.optional()
|
|
2682
|
+
.default("user")
|
|
2683
|
+
.describe("Which library to list: 'user' (default), 'factory', or 'all'."),
|
|
2684
|
+
},
|
|
2685
|
+
}, async ({ scope }) => {
|
|
2686
|
+
const check = await requireBridge();
|
|
2687
|
+
if (check !== true)
|
|
2688
|
+
return check;
|
|
2689
|
+
const result = await bridge.send("listVisuals", scope);
|
|
2690
|
+
if (Array.isArray(result)) {
|
|
2691
|
+
if (result.length === 0)
|
|
2692
|
+
return text("No visuals found.");
|
|
2693
|
+
const lines = result.map((v) => `- ${v.name} (${v.mime}, ${v.size} bytes) → ${v.path}`);
|
|
2694
|
+
return text(`Found ${result.length} visual(s):\n${lines.join("\n")}`);
|
|
2695
|
+
}
|
|
2696
|
+
return json(result);
|
|
2697
|
+
});
|
|
2698
|
+
server.registerTool("delete_visual", {
|
|
2699
|
+
title: "Delete Visual",
|
|
2700
|
+
description: "Delete a visual file from PAM's user media library. " +
|
|
2701
|
+
"Only files in the user media folder can be deleted (not factory visuals).",
|
|
2702
|
+
inputSchema: {
|
|
2703
|
+
path: z.string().describe("Relative path to the visual (as returned by list_visuals or set_visual)."),
|
|
2704
|
+
},
|
|
2705
|
+
annotations: { destructiveHint: true },
|
|
2706
|
+
}, async ({ path: relativePath }) => {
|
|
2707
|
+
const check = await requireBridge();
|
|
2708
|
+
if (check !== true)
|
|
2709
|
+
return check;
|
|
2710
|
+
const result = await bridge.send("deleteVisual", relativePath);
|
|
2711
|
+
if (typeof result === "string" && result.includes("error"))
|
|
2712
|
+
return error(`Failed to delete visual: ${relativePath}`);
|
|
2713
|
+
return text(`Deleted visual: ${relativePath}`);
|
|
2714
|
+
});
|
|
2715
|
+
server.registerTool("get_visual_data_url", {
|
|
2716
|
+
title: "Get Visual Data URL",
|
|
2717
|
+
description: "Read a visual file from PAM's media library and return it as a base64 data URL. " +
|
|
2718
|
+
"Useful for previewing or inspecting visual content.",
|
|
2719
|
+
inputSchema: {
|
|
2720
|
+
path: z.string().describe("Relative path to the visual (as returned by list_visuals or set_visual)."),
|
|
2721
|
+
maxBytes: z
|
|
2722
|
+
.number()
|
|
2723
|
+
.optional()
|
|
2724
|
+
.default(5242880)
|
|
2725
|
+
.describe("Maximum file size in bytes (default 5MB). Files exceeding this are rejected."),
|
|
2726
|
+
},
|
|
2727
|
+
}, async ({ path: relativePath, maxBytes }) => {
|
|
2728
|
+
const check = await requireBridge();
|
|
2729
|
+
if (check !== true)
|
|
2730
|
+
return check;
|
|
2731
|
+
const result = (await bridge.send("getVisualDataUrl", relativePath, maxBytes));
|
|
2732
|
+
return text(`Visual: ${result.path} (${result.mime}, ${result.size} bytes)\nData URL: ${result.dataUrl}`);
|
|
2733
|
+
});
|
|
2734
|
+
server.registerTool("import_visual_folder", {
|
|
2735
|
+
title: "Import Visual Folder",
|
|
2736
|
+
description: "Bulk-import all valid visual files from a folder into PAM's user media library. " +
|
|
2737
|
+
"The import runs asynchronously; this tool returns immediately once the import is queued.",
|
|
2738
|
+
inputSchema: {
|
|
2739
|
+
folderPath: z.string().describe("Absolute path to the folder containing visual files to import."),
|
|
2740
|
+
operationId: z
|
|
2741
|
+
.string()
|
|
2742
|
+
.optional()
|
|
2743
|
+
.describe("Optional operation ID for tracking import progress."),
|
|
2744
|
+
},
|
|
2745
|
+
}, async ({ folderPath, operationId }) => {
|
|
2746
|
+
const check = await requireBridge();
|
|
2747
|
+
if (check !== true)
|
|
2748
|
+
return check;
|
|
2749
|
+
const result = (await bridge.send("importVisualFolder", folderPath, operationId ?? ""));
|
|
2750
|
+
return text(`Import queued for: ${result.folderPath}` +
|
|
2751
|
+
(result.operationId ? ` (operationId: ${result.operationId})` : ""));
|
|
2752
|
+
});
|
|
2753
|
+
// ============================================================
|
|
2754
|
+
// VX video subsystem — layers, FX, audio-reactive bindings, settings
|
|
2755
|
+
// ============================================================
|
|
2756
|
+
//
|
|
2757
|
+
// The video scene lives in PAM's WebView (R3F/WebGL). These tools forward semantic
|
|
2758
|
+
// commands into the live video stores and read back a full-scene snapshot. PAM's
|
|
2759
|
+
// window must be OPEN for commands to apply (no headless renderer). Audio-reactive
|
|
2760
|
+
// *visual params* (visualWaveDisplace, etc.) are regular plugin params — drive those
|
|
2761
|
+
// with set_parameters; everything else (layers/FX/bindings/output FX) goes here.
|
|
2762
|
+
// Per-layer FX types (FxRegistry). Some only apply to drawing/text (trails, beam).
|
|
2763
|
+
const VIDEO_LAYER_FX = "hueRotate, glow, audioReact, vibration, chromaticAberration, pixelate, rgbShift, " +
|
|
2764
|
+
"mirror, trails(drawing/text), beam(drawing/text), shake, rolls, barrelBlur, edge, " +
|
|
2765
|
+
"halftone, patches, brightnessContrast, lines, feedback, warp, datamosh, slicer, " +
|
|
2766
|
+
"polarSlitScan, cellVoronoi, spectralZones, paletteLut, asciiDither, pixelSort, " +
|
|
2767
|
+
"cameraShake, blur, cymatics, lineJitter, crt, vhs";
|
|
2768
|
+
// Output/global FX chain ids (videoStore, DEFAULT_VIDEO_FX_CONFIG).
|
|
2769
|
+
const VIDEO_OUTPUT_FX = "lines, barrelBlur, brightnessContrast, edge, glow, shake, rgbShift, halftone, mirror, " +
|
|
2770
|
+
"patches, pixelate, rolls, feedback, warp, datamosh, slicer, positionXY, speed, " +
|
|
2771
|
+
"cellVoronoi, polarSlitScan, spectralZones, audioReact";
|
|
2772
|
+
// Binding source vocabulary (sourceKey string form OR a ModSource object).
|
|
2773
|
+
const VIDEO_BIND_SOURCES = "Pass `source` as a sourceKey string: " +
|
|
2774
|
+
"`g:<id>` global (level, bandLow, bandMid, bandHigh, transient, transientLow, " +
|
|
2775
|
+
"transientMid, transientHigh, bpm, spectralCentroid, spectralFlux, stereoWidth, " +
|
|
2776
|
+
"varyActivity, phraseLevel, phraseBass, looperBus, masterOnsetLow/Mid/High, " +
|
|
2777
|
+
"masterStereoWidth, masterPanScalar, masterFullMix, masterCompressorGR, " +
|
|
2778
|
+
"sidechainLevel, sidechainEnv); " +
|
|
2779
|
+
"`t:<phase>` transport (beat, bar, phrase); " +
|
|
2780
|
+
"`c:<cell>:<field>` per-cell (level, envFollow, pulse, onset, spectrumPeak, " +
|
|
2781
|
+
"spectrumCentroid, pitch, postFaderL/R, samplerRMSL/R, synthRMS, beatPosition, " +
|
|
2782
|
+
"activeRetriggers, grainActivity, grainPos, grainSpread, grainPitch, " +
|
|
2783
|
+
"grainPitchSpread, grainReverseRatio); " +
|
|
2784
|
+
"`l:<track>:level` looper; `lf:<lfo>:rate|phase` LFO; " +
|
|
2785
|
+
"`p:<paramId>` any DAW param (e.g. p:macro_1). Or a structured object e.g. " +
|
|
2786
|
+
"{kind:'cell',cellIndex:1,field:'onset'}.";
|
|
2787
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2788
|
+
async function getVideoStateRaw() {
|
|
2789
|
+
const r = await bridge.send("getVideoState");
|
|
2790
|
+
return (typeof r === "string" ? JSON.parse(r) : r) ?? {};
|
|
2791
|
+
}
|
|
2792
|
+
const sendVideoCommand = (command) => bridge.send("videoCommand", command);
|
|
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) {
|
|
2800
|
+
const deadline = Date.now() + timeoutMs;
|
|
2801
|
+
while (Date.now() < deadline) {
|
|
2802
|
+
await sleep(120);
|
|
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
|
+
}
|
|
2811
|
+
const found = pick(s).find((id) => !beforeIds.has(id));
|
|
2812
|
+
if (found)
|
|
2813
|
+
return found;
|
|
2814
|
+
}
|
|
2815
|
+
return null;
|
|
2816
|
+
}
|
|
2817
|
+
server.registerTool("get_video_state", {
|
|
2818
|
+
title: "Get Video State",
|
|
2819
|
+
description: "Read the full VX video scene: layers (in render order, top-first) with their " +
|
|
2820
|
+
"per-layer FX chains, the output/global FX chain, audio-reactive bindings, " +
|
|
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.",
|
|
2829
|
+
inputSchema: {},
|
|
2830
|
+
annotations: { readOnlyHint: true },
|
|
2831
|
+
}, async () => {
|
|
2832
|
+
const check = await requireBridge();
|
|
2833
|
+
if (check !== true)
|
|
2834
|
+
return check;
|
|
2835
|
+
const state = await getVideoStateRaw();
|
|
2836
|
+
return json(state);
|
|
2837
|
+
});
|
|
2838
|
+
server.registerTool("capture_ui", {
|
|
2839
|
+
title: "Capture UI Screenshot",
|
|
2840
|
+
description: "Render PAM's plugin UI (#app_root) to a PNG via html2canvas and return the saved " +
|
|
2841
|
+
"file path (read it to view the image). Optionally compose the view first: " +
|
|
2842
|
+
"gridSize (1|4|8), mainView (CellGrid|CtrlAll|Envelope|LFO|Vary|Mixer|SongMode|Video|" +
|
|
2843
|
+
"PresetBrowser; omit/null = cell grid), instrumentView ('synth'|'sampler', applied to " +
|
|
2844
|
+
"all cells). REQUIRES PAM's window OPEN — html2canvas needs the live DOM, there is no " +
|
|
2845
|
+
"headless renderer (same constraint as the video tools). Beats screen-capture: " +
|
|
2846
|
+
"deterministic, no cursor/screen control, exact UI bitmap.",
|
|
2847
|
+
inputSchema: {
|
|
2848
|
+
gridSize: z.number().optional().describe("Cell grid size to set before capture: 1, 4, or 8."),
|
|
2849
|
+
mainView: z
|
|
2850
|
+
.string()
|
|
2851
|
+
.optional()
|
|
2852
|
+
.describe("Main view to switch to before capture (CellGrid, Mixer, LFO, …). Omit for the cell grid."),
|
|
2853
|
+
instrumentView: z
|
|
2854
|
+
.enum(["synth", "sampler"])
|
|
2855
|
+
.optional()
|
|
2856
|
+
.describe("Switch all cells to the synth or sampler tab before capture."),
|
|
2857
|
+
settleMs: z.number().optional().describe("Delay (ms) after view changes before rasterizing. Default 350."),
|
|
2858
|
+
timeoutMs: z.number().optional().default(8000).describe("Max ms to wait for the WebView to return the image."),
|
|
2859
|
+
},
|
|
2860
|
+
annotations: { readOnlyHint: true },
|
|
2861
|
+
}, async ({ gridSize, mainView, instrumentView, settleMs, timeoutMs }) => {
|
|
2862
|
+
const check = await requireBridge();
|
|
2863
|
+
if (check !== true)
|
|
2864
|
+
return check;
|
|
2865
|
+
const requestId = `ui-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
|
2866
|
+
const setup = { requestId };
|
|
2867
|
+
if (typeof gridSize === "number")
|
|
2868
|
+
setup.gridSize = gridSize;
|
|
2869
|
+
if (mainView !== undefined)
|
|
2870
|
+
setup.mainView = mainView;
|
|
2871
|
+
if (instrumentView !== undefined)
|
|
2872
|
+
setup.instrumentView = instrumentView;
|
|
2873
|
+
if (typeof settleMs === "number")
|
|
2874
|
+
setup.settleMs = settleMs;
|
|
2875
|
+
// Forward is fire-and-forget; poll the C++ cache until the WebView posts the PNG back.
|
|
2876
|
+
await bridge.send("captureUi", setup);
|
|
2877
|
+
const deadline = Date.now() + (timeoutMs ?? 8000);
|
|
2878
|
+
for (;;) {
|
|
2879
|
+
const res = (await bridge.send("getUiCapture", { requestId }));
|
|
2880
|
+
if (res.ready && res.requestId === requestId && res.path) {
|
|
2881
|
+
return text(`Saved UI screenshot: ${res.path}`);
|
|
2882
|
+
}
|
|
2883
|
+
if (Date.now() > deadline) {
|
|
2884
|
+
return error("capture_ui timed out waiting for the WebView to return the image. Is PAM's window " +
|
|
2885
|
+
"open and active? html2canvas needs the live DOM — a closed/suspended editor cannot render.");
|
|
2886
|
+
}
|
|
2887
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
2888
|
+
}
|
|
2889
|
+
});
|
|
2890
|
+
server.registerTool("video_command", {
|
|
2891
|
+
title: "Video Command (advanced)",
|
|
2892
|
+
description: "Escape hatch to the full video command router — pass any { op, ...args } object. " +
|
|
2893
|
+
"Use the dedicated tools (add_video_layer, set_video_fx, bind_video_param, …) when " +
|
|
2894
|
+
"one fits; this reaches everything else. Ops: " +
|
|
2895
|
+
"addLayer{kind,name?,insertAt?}, removeLayer{layerId}, updateLayer{layerId,patch}, " +
|
|
2896
|
+
"moveLayer{layerId,toIndex}, duplicateLayer{layerId}, renameLayer{layerId,name}, " +
|
|
2897
|
+
"setLayerEnabled{layerId,enabled?}, toggleSolo{layerId}, selectLayer{layerId}, " +
|
|
2898
|
+
"resetLayer{layerId}, applyLayout{preset}, randomizeLayer{layerId}; " +
|
|
2899
|
+
"addLayerFx{layerId,fxType,params?,beforeFxId?}, removeLayerFx{layerId,fxId}, " +
|
|
2900
|
+
"removeAllLayerFx{layerId}, setLayerFxParam{layerId,fxId,param,value|params}, " +
|
|
2901
|
+
"setLayerFxEnabled{layerId,fxId,enabled?}, reorderLayerFx{layerId,fxIds}, " +
|
|
2902
|
+
"resetLayerFx{layerId,fxId}; bind{layerId,fxId?,paramKey,source,...}, " +
|
|
2903
|
+
"unbind{layerId,fxId?,paramKey}, setBindingField{layerId,fxId?,paramKey,field,value}, " +
|
|
2904
|
+
"fireImpulse{layerId,fxId?,paramKey}, removeBindingsForLayer{layerId}, clearBindings; " +
|
|
2905
|
+
"addOutputFx{fxId}, removeOutputFx{instanceId}, setOutputFxParam{instanceId|fxId,param,value}, " +
|
|
2906
|
+
"setOutputFxEnabled{instanceId,enabled}, setOutputFxStrength{instanceId,strength}; " +
|
|
2907
|
+
"addModRoute{route}, removeModRoute{id}, updateModRoute{id,updates}, clearModRoutes, " +
|
|
2908
|
+
"setModEnabled{enabled}; setModel{sourcePath?,visible?,blendMode?,audioReactive?,autoRotate?,transform?}, " +
|
|
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). " +
|
|
2919
|
+
"Layer fx target a per-layer chain; output fx target the global chain (ids differ). " +
|
|
2920
|
+
VIDEO_BIND_SOURCES,
|
|
2921
|
+
inputSchema: {
|
|
2922
|
+
command: z
|
|
2923
|
+
.record(z.string(), z.any())
|
|
2924
|
+
.describe("The command object. Must include a string `op` field."),
|
|
2925
|
+
},
|
|
2926
|
+
}, async ({ command }) => {
|
|
2927
|
+
const check = await requireBridge();
|
|
2928
|
+
if (check !== true)
|
|
2929
|
+
return check;
|
|
2930
|
+
const result = await sendVideoCommand(command);
|
|
2931
|
+
return json(result);
|
|
2932
|
+
});
|
|
2933
|
+
server.registerTool("add_video_layer", {
|
|
2934
|
+
title: "Add Video Layer",
|
|
2935
|
+
description: "Add a layer to the VX scene and return its id. Kinds: video, image, camera, " +
|
|
2936
|
+
"text, drawing, model3d, adjustment (adjustment = full-canvas FX only; master is a " +
|
|
2937
|
+
"managed singleton and cannot be added). After adding, set its source/props with " +
|
|
2938
|
+
"update_video_layer and stack FX with add_video_fx.",
|
|
2939
|
+
inputSchema: {
|
|
2940
|
+
kind: z
|
|
2941
|
+
.enum(["video", "image", "camera", "text", "drawing", "model3d", "adjustment"])
|
|
2942
|
+
.describe("Layer kind."),
|
|
2943
|
+
name: z.string().optional().describe("Display name (auto-generated if omitted)."),
|
|
2944
|
+
insertAt: z
|
|
2945
|
+
.number()
|
|
2946
|
+
.int()
|
|
2947
|
+
.optional()
|
|
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)."),
|
|
2958
|
+
},
|
|
2959
|
+
}, async ({ kind, name, insertAt, sourcePath, maxBytes }) => {
|
|
2960
|
+
const check = await requireBridge();
|
|
2961
|
+
if (check !== true)
|
|
2962
|
+
return check;
|
|
2963
|
+
const before = await getVideoStateRaw();
|
|
2964
|
+
const beforeIds = new Set((before.layers ?? []).map((l) => l.id));
|
|
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");
|
|
2968
|
+
return text(id
|
|
2969
|
+
? `Added ${kind} layer: ${id}`
|
|
2970
|
+
: `Added ${kind} layer (window may be closed; call get_video_state to confirm).`);
|
|
2971
|
+
});
|
|
2972
|
+
server.registerTool("update_video_layer", {
|
|
2973
|
+
title: "Update Video Layer",
|
|
2974
|
+
description: "Update a layer's common props and/or kind-specific fields. transform is merged " +
|
|
2975
|
+
"(partial) against the current transform; other fields replace. Common: opacity " +
|
|
2976
|
+
"(0..1), blendMode (normal|add|multiply|screen), enabled, name, transform " +
|
|
2977
|
+
"{x,y(0..1),scale,rotation(rad),anchor}. Kind-specific via `fields`: text layers " +
|
|
2978
|
+
"{text,fontSize,fontWeight,color,align,lineHeight}; image/video/model3d {sourcePath,fit}; " +
|
|
2979
|
+
"camera {deviceId,mirror,fit}; drawing {paintColor,paintWidth}; model3d " +
|
|
2980
|
+
"{lightingMode,lightIntensity,rotateRateX/Y/Z,...}. Use get_video_state to see a " +
|
|
2981
|
+
"layer's current fields.",
|
|
2982
|
+
inputSchema: {
|
|
2983
|
+
layerId: z.string().describe("Target layer id (from get_video_state)."),
|
|
2984
|
+
opacity: z.number().min(0).max(1).optional(),
|
|
2985
|
+
blendMode: z.enum(["normal", "add", "multiply", "screen"]).optional(),
|
|
2986
|
+
enabled: z.boolean().optional(),
|
|
2987
|
+
name: z.string().optional(),
|
|
2988
|
+
transform: z
|
|
2989
|
+
.record(z.string(), z.any())
|
|
2990
|
+
.optional()
|
|
2991
|
+
.describe("Partial transform: {x?,y?,scale?,rotation?,anchor?}. Merged."),
|
|
2992
|
+
fields: z
|
|
2993
|
+
.record(z.string(), z.any())
|
|
2994
|
+
.optional()
|
|
2995
|
+
.describe("Kind-specific fields (text, sourcePath, fit, fontSize, color, …)."),
|
|
2996
|
+
},
|
|
2997
|
+
}, async ({ layerId, opacity, blendMode, enabled, name, transform, fields }) => {
|
|
2998
|
+
const check = await requireBridge();
|
|
2999
|
+
if (check !== true)
|
|
3000
|
+
return check;
|
|
3001
|
+
const patch = { ...(fields ?? {}) };
|
|
3002
|
+
if (opacity !== undefined)
|
|
3003
|
+
patch.opacity = opacity;
|
|
3004
|
+
if (blendMode !== undefined)
|
|
3005
|
+
patch.blendMode = blendMode;
|
|
3006
|
+
if (enabled !== undefined)
|
|
3007
|
+
patch.enabled = enabled;
|
|
3008
|
+
if (name !== undefined)
|
|
3009
|
+
patch.name = name;
|
|
3010
|
+
if (transform !== undefined)
|
|
3011
|
+
patch.transform = transform;
|
|
3012
|
+
const result = await sendVideoCommand({ op: "updateLayer", layerId, patch });
|
|
3013
|
+
return json(result);
|
|
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
|
+
});
|
|
3032
|
+
server.registerTool("remove_video_layer", {
|
|
3033
|
+
title: "Remove Video Layer",
|
|
3034
|
+
description: "Remove a layer (and its FX + bindings). The master layer cannot be removed.",
|
|
3035
|
+
inputSchema: { layerId: z.string().describe("Layer id to remove.") },
|
|
3036
|
+
annotations: { destructiveHint: true },
|
|
3037
|
+
}, async ({ layerId }) => {
|
|
3038
|
+
const check = await requireBridge();
|
|
3039
|
+
if (check !== true)
|
|
3040
|
+
return check;
|
|
3041
|
+
const result = await sendVideoCommand({ op: "removeLayer", layerId });
|
|
3042
|
+
return json(result);
|
|
3043
|
+
});
|
|
3044
|
+
server.registerTool("apply_video_layout", {
|
|
3045
|
+
title: "Apply Video Layout",
|
|
3046
|
+
description: "Arrange the spatial layers with a layout preset: centered (stacked), grid, " +
|
|
3047
|
+
"columns, rows, or focus (one large + thumbnail strip). adjustment and master " +
|
|
3048
|
+
"layers are untouched.",
|
|
3049
|
+
inputSchema: {
|
|
3050
|
+
preset: z.enum(["centered", "grid", "columns", "rows", "focus"]).describe("Layout preset."),
|
|
3051
|
+
},
|
|
3052
|
+
}, async ({ preset }) => {
|
|
3053
|
+
const check = await requireBridge();
|
|
3054
|
+
if (check !== true)
|
|
3055
|
+
return check;
|
|
3056
|
+
const result = await sendVideoCommand({ op: "applyLayout", preset });
|
|
3057
|
+
return json(result);
|
|
3058
|
+
});
|
|
3059
|
+
server.registerTool("add_video_fx", {
|
|
3060
|
+
title: "Add Video FX",
|
|
3061
|
+
description: "Append an FX to a layer's chain (target='layer', needs layerId) or to the output/" +
|
|
3062
|
+
"global chain (target='output'), and return the new id. Per-layer FX: " +
|
|
3063
|
+
VIDEO_LAYER_FX +
|
|
3064
|
+
". Output FX: " +
|
|
3065
|
+
VIDEO_OUTPUT_FX +
|
|
3066
|
+
". FX params seed from registry defaults; override via `params`. Use set_video_fx " +
|
|
3067
|
+
"to tweak afterwards.",
|
|
3068
|
+
inputSchema: {
|
|
3069
|
+
target: z.enum(["layer", "output"]).default("layer").describe("Which chain."),
|
|
3070
|
+
layerId: z.string().optional().describe("Required when target='layer'."),
|
|
3071
|
+
fxType: z.string().describe("FX id (see the per-layer / output id lists)."),
|
|
3072
|
+
params: z
|
|
3073
|
+
.record(z.string(), z.number())
|
|
3074
|
+
.optional()
|
|
3075
|
+
.describe("Optional initial param overrides {paramKey: value}."),
|
|
3076
|
+
beforeFxId: z
|
|
3077
|
+
.string()
|
|
3078
|
+
.optional()
|
|
3079
|
+
.describe("Insert before this fx id instead of appending (layer target only)."),
|
|
3080
|
+
},
|
|
3081
|
+
}, async ({ target, layerId, fxType, params, beforeFxId }) => {
|
|
3082
|
+
const check = await requireBridge();
|
|
3083
|
+
if (check !== true)
|
|
3084
|
+
return check;
|
|
3085
|
+
if (target === "output") {
|
|
3086
|
+
const before = await getVideoStateRaw();
|
|
3087
|
+
const beforeIds = new Set((before.outputFx ?? []).map((f) => f.instanceId));
|
|
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");
|
|
3091
|
+
// Apply param overrides now that the instance exists.
|
|
3092
|
+
if (params && id)
|
|
3093
|
+
for (const [param, value] of Object.entries(params))
|
|
3094
|
+
await sendVideoCommand({ op: "setOutputFxParam", instanceId: id, param, value });
|
|
3095
|
+
return text(id ? `Added output FX ${fxType}: ${id}` : `Added output FX ${fxType} (confirm via get_video_state).`);
|
|
3096
|
+
}
|
|
3097
|
+
if (!layerId)
|
|
3098
|
+
return error("add_video_fx: layerId is required when target='layer'.");
|
|
3099
|
+
const before = await getVideoStateRaw();
|
|
3100
|
+
const layer = (before.layers ?? []).find((l) => l.id === layerId);
|
|
3101
|
+
const beforeIds = new Set((layer?.fx ?? []).map((f) => f.id));
|
|
3102
|
+
const requestId = mintVideoRequestId();
|
|
3103
|
+
await sendVideoCommand({ op: "addLayerFx", layerId, fxType, params, beforeFxId, requestId });
|
|
3104
|
+
const id = await resolveNewId(requestId, beforeIds, (s) => {
|
|
3105
|
+
const l = (s.layers ?? []).find((x) => x.id === layerId);
|
|
3106
|
+
return (l?.fx ?? []).map((f) => f.id);
|
|
3107
|
+
}, "fxId");
|
|
3108
|
+
return text(id ? `Added ${fxType} to layer ${layerId}: ${id}` : `Added ${fxType} (confirm via get_video_state).`);
|
|
3109
|
+
});
|
|
3110
|
+
server.registerTool("set_video_fx", {
|
|
3111
|
+
title: "Set Video FX",
|
|
3112
|
+
description: "Set FX params / enabled / strength on a per-layer FX (target='layer', needs " +
|
|
3113
|
+
"layerId + fxId) or an output FX (target='output', needs instanceId, or fxId to " +
|
|
3114
|
+
"auto-create). Pass a single {param,value}, multiple {params}, `enabled`, and/or " +
|
|
3115
|
+
"`strength` (output only). Get current ids/params from get_video_state.",
|
|
3116
|
+
inputSchema: {
|
|
3117
|
+
target: z.enum(["layer", "output"]).default("layer"),
|
|
3118
|
+
layerId: z.string().optional().describe("target='layer': owning layer id."),
|
|
3119
|
+
fxId: z.string().optional().describe("Layer fx id, or output fxId (auto-creates the output fx)."),
|
|
3120
|
+
instanceId: z.string().optional().describe("target='output': the output fx instance id."),
|
|
3121
|
+
param: z.string().optional().describe("Single param key to set."),
|
|
3122
|
+
value: z.number().optional().describe("Value for `param`."),
|
|
3123
|
+
params: z.record(z.string(), z.number()).optional().describe("Multiple params {key: value}."),
|
|
3124
|
+
enabled: z.boolean().optional().describe("Enable/bypass this FX."),
|
|
3125
|
+
strength: z.number().optional().describe("Output FX wet strength (output target only)."),
|
|
3126
|
+
},
|
|
3127
|
+
}, async ({ target, layerId, fxId, instanceId, param, value, params, enabled, strength }) => {
|
|
3128
|
+
const check = await requireBridge();
|
|
3129
|
+
if (check !== true)
|
|
3130
|
+
return check;
|
|
3131
|
+
const results = [];
|
|
3132
|
+
if (target === "layer") {
|
|
3133
|
+
if (!layerId || !fxId)
|
|
3134
|
+
return error("set_video_fx: layerId and fxId required for target='layer'.");
|
|
3135
|
+
if (param !== undefined && value !== undefined)
|
|
3136
|
+
results.push(await sendVideoCommand({ op: "setLayerFxParam", layerId, fxId, param, value }));
|
|
3137
|
+
if (params)
|
|
3138
|
+
results.push(await sendVideoCommand({ op: "setLayerFxParam", layerId, fxId, params }));
|
|
3139
|
+
if (enabled !== undefined)
|
|
3140
|
+
results.push(await sendVideoCommand({ op: "setLayerFxEnabled", layerId, fxId, enabled }));
|
|
3141
|
+
}
|
|
3142
|
+
else {
|
|
3143
|
+
if (param !== undefined && value !== undefined)
|
|
3144
|
+
results.push(await sendVideoCommand({ op: "setOutputFxParam", instanceId, fxId, param, value }));
|
|
3145
|
+
if (params)
|
|
3146
|
+
for (const [p, v] of Object.entries(params))
|
|
3147
|
+
results.push(await sendVideoCommand({ op: "setOutputFxParam", instanceId, fxId, param: p, value: v }));
|
|
3148
|
+
if (enabled !== undefined && instanceId)
|
|
3149
|
+
results.push(await sendVideoCommand({ op: "setOutputFxEnabled", instanceId, enabled }));
|
|
3150
|
+
if (strength !== undefined && instanceId)
|
|
3151
|
+
results.push(await sendVideoCommand({ op: "setOutputFxStrength", instanceId, strength }));
|
|
3152
|
+
}
|
|
3153
|
+
if (results.length === 0)
|
|
3154
|
+
return error("set_video_fx: nothing to set (provide param/value, params, enabled, or strength).");
|
|
3155
|
+
return json({ applied: results.length });
|
|
1348
3156
|
});
|
|
1349
|
-
server.registerTool("
|
|
1350
|
-
title: "
|
|
1351
|
-
description: "
|
|
1352
|
-
"
|
|
3157
|
+
server.registerTool("remove_video_fx", {
|
|
3158
|
+
title: "Remove Video FX",
|
|
3159
|
+
description: "Remove an FX from a layer chain (target='layer' + layerId + fxId) or the output " +
|
|
3160
|
+
"chain (target='output' + instanceId).",
|
|
1353
3161
|
inputSchema: {
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
.describe("Which library to list: 'user' (default), 'factory', or 'all'."),
|
|
3162
|
+
target: z.enum(["layer", "output"]).default("layer"),
|
|
3163
|
+
layerId: z.string().optional(),
|
|
3164
|
+
fxId: z.string().optional().describe("target='layer': fx id on the layer."),
|
|
3165
|
+
instanceId: z.string().optional().describe("target='output': output fx instance id."),
|
|
1359
3166
|
},
|
|
1360
|
-
|
|
3167
|
+
annotations: { destructiveHint: true },
|
|
3168
|
+
}, async ({ target, layerId, fxId, instanceId }) => {
|
|
1361
3169
|
const check = await requireBridge();
|
|
1362
3170
|
if (check !== true)
|
|
1363
3171
|
return check;
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
const lines = result.map((v) => `- ${v.name} (${v.mime}, ${v.size} bytes) → ${v.path}`);
|
|
1369
|
-
return text(`Found ${result.length} visual(s):\n${lines.join("\n")}`);
|
|
3172
|
+
if (target === "layer") {
|
|
3173
|
+
if (!layerId || !fxId)
|
|
3174
|
+
return error("remove_video_fx: layerId and fxId required for target='layer'.");
|
|
3175
|
+
return json(await sendVideoCommand({ op: "removeLayerFx", layerId, fxId }));
|
|
1370
3176
|
}
|
|
1371
|
-
|
|
3177
|
+
if (!instanceId)
|
|
3178
|
+
return error("remove_video_fx: instanceId required for target='output'.");
|
|
3179
|
+
return json(await sendVideoCommand({ op: "removeOutputFx", instanceId }));
|
|
1372
3180
|
});
|
|
1373
|
-
server.registerTool("
|
|
1374
|
-
title: "
|
|
1375
|
-
description: "
|
|
1376
|
-
"
|
|
3181
|
+
server.registerTool("bind_video_param", {
|
|
3182
|
+
title: "Bind Video Param (audio-reactive)",
|
|
3183
|
+
description: "Bind a per-layer FX param or layer field to an audio/transport source so it reacts " +
|
|
3184
|
+
"to the music. Omit fxId (or pass '') to target a layer field: transform.x, " +
|
|
3185
|
+
"transform.y, transform.scale, transform.rotation, opacity, hue, brightness, " +
|
|
3186
|
+
"contrast, saturation, playbackRate, fontSize, pulseAmount, or model3d fields. " +
|
|
3187
|
+
"Pass fxId to target an FX param (param key from that FX). " +
|
|
3188
|
+
VIDEO_BIND_SOURCES +
|
|
3189
|
+
" amount is -1..1; curve linear|exp|expInverse|bipolar; mode additive|replace|" +
|
|
3190
|
+
"multiply|centered; impulse off|pulse|latch (one-shot on threshold crossing). " +
|
|
3191
|
+
"attack/release default per-source if omitted.",
|
|
1377
3192
|
inputSchema: {
|
|
1378
|
-
|
|
3193
|
+
layerId: z.string().describe("Target layer id."),
|
|
3194
|
+
paramKey: z.string().describe("FX param key, or layer field (e.g. transform.scale, opacity)."),
|
|
3195
|
+
source: z
|
|
3196
|
+
.union([z.string(), z.record(z.string(), z.any())])
|
|
3197
|
+
.describe("sourceKey string (e.g. 'g:bandLow', 'c:1:onset', 't:beat') or ModSource object."),
|
|
3198
|
+
fxId: z.string().optional().describe("FX instance id; omit/'' for a layer-field target."),
|
|
3199
|
+
amount: z.number().min(-1).max(1).optional().describe("Modulation depth (-1..1), default 1."),
|
|
3200
|
+
curve: z.enum(["linear", "exp", "expInverse", "bipolar"]).optional(),
|
|
3201
|
+
mode: z.enum(["additive", "replace", "multiply", "centered"]).optional(),
|
|
3202
|
+
attackMs: z.number().min(0).optional(),
|
|
3203
|
+
releaseMs: z.number().min(0).optional(),
|
|
3204
|
+
threshold: z.number().min(0).max(1).optional().describe("Gate / impulse trigger level."),
|
|
3205
|
+
impulse: z.enum(["off", "pulse", "latch"]).optional(),
|
|
1379
3206
|
},
|
|
1380
|
-
|
|
1381
|
-
}, async ({ path: relativePath }) => {
|
|
3207
|
+
}, async ({ layerId, paramKey, source, fxId, amount, curve, mode, attackMs, releaseMs, threshold, impulse }) => {
|
|
1382
3208
|
const check = await requireBridge();
|
|
1383
3209
|
if (check !== true)
|
|
1384
3210
|
return check;
|
|
1385
|
-
const result = await
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
3211
|
+
const result = await sendVideoCommand({
|
|
3212
|
+
op: "bind",
|
|
3213
|
+
layerId,
|
|
3214
|
+
fxId: fxId ?? "",
|
|
3215
|
+
paramKey,
|
|
3216
|
+
source,
|
|
3217
|
+
amount,
|
|
3218
|
+
curve,
|
|
3219
|
+
mode,
|
|
3220
|
+
attackMs,
|
|
3221
|
+
releaseMs,
|
|
3222
|
+
threshold,
|
|
3223
|
+
impulse,
|
|
3224
|
+
});
|
|
3225
|
+
return json(result);
|
|
1389
3226
|
});
|
|
1390
|
-
server.registerTool("
|
|
1391
|
-
title: "
|
|
1392
|
-
description: "
|
|
1393
|
-
"
|
|
3227
|
+
server.registerTool("set_video_settings", {
|
|
3228
|
+
title: "Set Video Settings",
|
|
3229
|
+
description: "Set global VX render/sync settings: syncMode (off|beat-sync|beat-match), loopBars, " +
|
|
3230
|
+
"aspectRatio (auto|16:9|4:3|1:1|stretch), renderQuality (low|medium|high), " +
|
|
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 " +
|
|
3234
|
+
"scene (preserves the master layer).",
|
|
1394
3235
|
inputSchema: {
|
|
1395
|
-
|
|
1396
|
-
|
|
3236
|
+
syncMode: z.enum(["off", "beat-sync", "beat-match"]).optional(),
|
|
3237
|
+
loopBars: z.number().optional(),
|
|
3238
|
+
aspectRatio: z.enum(["auto", "16:9", "4:3", "1:1", "stretch"]).optional(),
|
|
3239
|
+
renderQuality: z.enum(["low", "medium", "high"]).optional(),
|
|
3240
|
+
designAspect: z.number().nullable().optional(),
|
|
3241
|
+
letterboxColor: z.string().optional(),
|
|
3242
|
+
canvasColor: z.string().optional(),
|
|
3243
|
+
modEnabled: z.boolean().optional().describe("Mod-matrix master enable."),
|
|
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
|
|
1397
3253
|
.number()
|
|
1398
3254
|
.optional()
|
|
1399
|
-
.
|
|
1400
|
-
|
|
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)."),
|
|
3257
|
+
resetWorkspace: z.boolean().optional().describe("If true, clear the whole scene first."),
|
|
1401
3258
|
},
|
|
1402
|
-
}, async ({
|
|
3259
|
+
}, async ({ syncMode, loopBars, aspectRatio, renderQuality, designAspect, letterboxColor, canvasColor, modEnabled, vxVariationFollowAudio, vxBarSyncEnabled, vxBarSyncIntervalBars, activeCell, resetWorkspace }) => {
|
|
1403
3260
|
const check = await requireBridge();
|
|
1404
3261
|
if (check !== true)
|
|
1405
3262
|
return check;
|
|
1406
|
-
|
|
1407
|
-
|
|
3263
|
+
if (resetWorkspace)
|
|
3264
|
+
await sendVideoCommand({ op: "resetWorkspace" });
|
|
3265
|
+
if (activeCell !== undefined)
|
|
3266
|
+
await sendVideoCommand({ op: "setActiveCell", cellId: activeCell });
|
|
3267
|
+
if (modEnabled !== undefined)
|
|
3268
|
+
await sendVideoCommand({ op: "setModEnabled", enabled: modEnabled });
|
|
3269
|
+
const settings = {};
|
|
3270
|
+
if (syncMode !== undefined)
|
|
3271
|
+
settings.syncMode = syncMode;
|
|
3272
|
+
if (loopBars !== undefined)
|
|
3273
|
+
settings.loopBars = loopBars;
|
|
3274
|
+
if (aspectRatio !== undefined)
|
|
3275
|
+
settings.aspectRatio = aspectRatio;
|
|
3276
|
+
if (renderQuality !== undefined)
|
|
3277
|
+
settings.renderQuality = renderQuality;
|
|
3278
|
+
if (designAspect !== undefined)
|
|
3279
|
+
settings.designAspect = designAspect;
|
|
3280
|
+
if (letterboxColor !== undefined)
|
|
3281
|
+
settings.letterboxColor = letterboxColor;
|
|
3282
|
+
if (canvasColor !== undefined)
|
|
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;
|
|
3290
|
+
if (Object.keys(settings).length > 0)
|
|
3291
|
+
await sendVideoCommand({ op: "setSettings", ...settings });
|
|
3292
|
+
return text("Video settings updated.");
|
|
1408
3293
|
});
|
|
1409
|
-
server.registerTool("
|
|
1410
|
-
title: "
|
|
1411
|
-
description: "
|
|
1412
|
-
"
|
|
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.",
|
|
1413
3305
|
inputSchema: {
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
.
|
|
3306
|
+
op: z.enum(["save", "load", "clear", "fill"]).describe("Slot operation."),
|
|
3307
|
+
index: z
|
|
3308
|
+
.number()
|
|
3309
|
+
.int()
|
|
3310
|
+
.min(0)
|
|
3311
|
+
.max(7)
|
|
1417
3312
|
.optional()
|
|
1418
|
-
.describe("
|
|
3313
|
+
.describe("Slot index 0-7 (required for save/load/clear; ignored for fill)."),
|
|
1419
3314
|
},
|
|
1420
|
-
}, async ({
|
|
3315
|
+
}, async ({ op, index }) => {
|
|
1421
3316
|
const check = await requireBridge();
|
|
1422
3317
|
if (check !== true)
|
|
1423
3318
|
return check;
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
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 ?? "?")}.`);
|
|
1427
3352
|
});
|
|
1428
3353
|
// ============================================================
|
|
1429
3354
|
// Composite cell-operation tools
|
|
@@ -1446,6 +3371,10 @@ server.registerTool("reset_cell", {
|
|
|
1446
3371
|
const check = await requireBridge();
|
|
1447
3372
|
if (check !== true)
|
|
1448
3373
|
return check;
|
|
3374
|
+
const manifest = await loadManifestForProductGuard();
|
|
3375
|
+
const cellCheck = validateCellIdForManifest(cellId, manifest);
|
|
3376
|
+
if (cellCheck !== true)
|
|
3377
|
+
return cellCheck;
|
|
1449
3378
|
const [cellParams, envelopes] = await Promise.all([
|
|
1450
3379
|
getParametersByCell(`cell${cellId}`),
|
|
1451
3380
|
bridge.send("getState", "envelopes"),
|
|
@@ -1479,6 +3408,13 @@ server.registerTool("copy_cell", {
|
|
|
1479
3408
|
const check = await requireBridge();
|
|
1480
3409
|
if (check !== true)
|
|
1481
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;
|
|
1482
3418
|
// Full state needed: cell params live as flat top-level cellN_* keys, no section.
|
|
1483
3419
|
const state = (await bridge.send("getState"));
|
|
1484
3420
|
const srcPrefix = `cell${sourceCellId}_`;
|
|
@@ -1579,6 +3515,10 @@ server.registerTool("clear_modulations", {
|
|
|
1579
3515
|
const check = await requireBridge();
|
|
1580
3516
|
if (check !== true)
|
|
1581
3517
|
return check;
|
|
3518
|
+
const manifest = await loadManifestForProductGuard();
|
|
3519
|
+
const cellCheck = validateCellIdForManifest(cellId, manifest);
|
|
3520
|
+
if (cellCheck !== true)
|
|
3521
|
+
return cellCheck;
|
|
1582
3522
|
const [lfosRaw, varyRaw, envsRaw] = await Promise.all([
|
|
1583
3523
|
bridge.send("getState", "lfos"),
|
|
1584
3524
|
bridge.send("getState", "vary"),
|
|
@@ -1611,7 +3551,7 @@ server.registerTool("clear_modulations", {
|
|
|
1611
3551
|
});
|
|
1612
3552
|
server.registerTool("reset_patterns", {
|
|
1613
3553
|
title: "Reset Patterns",
|
|
1614
|
-
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.",
|
|
1615
3555
|
inputSchema: {
|
|
1616
3556
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
1617
3557
|
},
|
|
@@ -1620,6 +3560,15 @@ server.registerTool("reset_patterns", {
|
|
|
1620
3560
|
const check = await requireBridge();
|
|
1621
3561
|
if (check !== true)
|
|
1622
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
|
+
}
|
|
1623
3572
|
const delta = { __forceRender__: true };
|
|
1624
3573
|
for (const suffix of PATTERN_SUFFIXES) {
|
|
1625
3574
|
delta[`cell${cellId}_${suffix}`] = [];
|
|
@@ -1646,6 +3595,10 @@ server.registerTool("update_effect_config", {
|
|
|
1646
3595
|
const check = await requireBridge();
|
|
1647
3596
|
if (check !== true)
|
|
1648
3597
|
return check;
|
|
3598
|
+
const manifest = await loadManifestForProductGuard();
|
|
3599
|
+
const cellCheck = validateCellIdForManifest(cellId, manifest);
|
|
3600
|
+
if (cellCheck !== true)
|
|
3601
|
+
return cellCheck;
|
|
1649
3602
|
const delta = {};
|
|
1650
3603
|
for (const [key, value] of Object.entries(config)) {
|
|
1651
3604
|
if (value !== undefined)
|
|
@@ -1664,7 +3617,8 @@ server.registerTool("update_cell_config", {
|
|
|
1664
3617
|
title: "Update Cell Config",
|
|
1665
3618
|
description: "Update cell-level configuration properties like polyphony, MIDI settings, varispeed, and modulation latch. " +
|
|
1666
3619
|
"These are KV-only properties that don't appear in the parameter manifest. " +
|
|
1667
|
-
"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.",
|
|
1668
3622
|
inputSchema: {
|
|
1669
3623
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
1670
3624
|
isPoly: z.union([z.literal(0), z.literal(1)]).optional().describe("Polyphonic mode (0=mono, 1=poly)."),
|
|
@@ -1674,7 +3628,18 @@ server.registerTool("update_cell_config", {
|
|
|
1674
3628
|
modLatch: z.union([z.literal(0), z.literal(1)]).optional().describe("Latch slice/pitch/stretch modulation at trigger."),
|
|
1675
3629
|
varispeed: z.union([z.literal(0), z.literal(1)]).optional().describe("Varispeed mode (pitch = speed) vs independent pitch shift."),
|
|
1676
3630
|
armed: z.union([z.literal(0), z.literal(1)]).optional().describe("Whether cell accepts recorded MIDI."),
|
|
1677
|
-
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)."),
|
|
1678
3643
|
scaleName: z.string().optional().describe("Scale name for pitch sequencer quantization (e.g. 'chromatic', 'major', 'minor')."),
|
|
1679
3644
|
scaleRoot: z.number().min(0).max(11).optional().describe("Root note of the scale (0=C, 1=C#, ..., 11=B)."),
|
|
1680
3645
|
pitchMode: z.number().int().min(0).max(3).optional().describe("Pitch engine: 0=Varispeed, 1=Formant, 2=Pitch Shift Quantize, 3=Varispeed Quantize."),
|
|
@@ -1695,6 +3660,10 @@ server.registerTool("update_cell_config", {
|
|
|
1695
3660
|
const check = await requireBridge();
|
|
1696
3661
|
if (check !== true)
|
|
1697
3662
|
return check;
|
|
3663
|
+
const manifest = await loadManifestForProductGuard();
|
|
3664
|
+
const cellCheck = validateCellIdForManifest(cellId, manifest);
|
|
3665
|
+
if (cellCheck !== true)
|
|
3666
|
+
return cellCheck;
|
|
1698
3667
|
const cellConfig = {};
|
|
1699
3668
|
const flatDelta = {};
|
|
1700
3669
|
for (const [key, value] of Object.entries(config)) {
|
|
@@ -1704,11 +3673,23 @@ server.registerTool("update_cell_config", {
|
|
|
1704
3673
|
if (Object.keys(cellConfig).length === 0) {
|
|
1705
3674
|
return error("At least one config property must be provided.");
|
|
1706
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
|
+
}
|
|
1707
3683
|
// Auto-enable compressor when setting sidechain source
|
|
1708
3684
|
if (cellConfig.sidechainFrom !== undefined && cellConfig.sidechainFrom !== null) {
|
|
1709
3685
|
flatDelta[`cell${cellId}_compressorEnabled`] = 1;
|
|
1710
3686
|
}
|
|
1711
|
-
|
|
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) {
|
|
1712
3693
|
const next = (cellModeChangeSeq[cellId] ?? Date.now()) + 1;
|
|
1713
3694
|
cellModeChangeSeq[cellId] = next;
|
|
1714
3695
|
cellConfig.modeChangeSeq = next;
|
|
@@ -1720,11 +3701,50 @@ server.registerTool("update_cell_config", {
|
|
|
1720
3701
|
.join(", ");
|
|
1721
3702
|
return text(`Updated cell ${cellId} config: ${summary}`);
|
|
1722
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
|
+
});
|
|
1723
3742
|
server.registerTool("set_master_effects", {
|
|
1724
3743
|
title: "Set Master Effects",
|
|
1725
3744
|
description: "Configure the master bus Punch compressor, Limiter, and Tape colour. " +
|
|
1726
3745
|
"The Punch compressor is a musical bus compressor — use it for glue and pump. " +
|
|
1727
|
-
"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). " +
|
|
1728
3748
|
"The Tape is a vintage-tape coloration stage (ADAA tanh/asymmetric saturation + HF compression + wow/flutter), " +
|
|
1729
3749
|
"the single biggest lever for lo-fi / dusty / 'pushed' master character (hip-hop, lo-fi, Memphis, dub). " +
|
|
1730
3750
|
"Pass enabled flags to toggle each on/off. Pass parameters to shape the sound. " +
|
|
@@ -1732,7 +3752,7 @@ server.registerTool("set_master_effects", {
|
|
|
1732
3752
|
"medium release (80-200ms), ratio 4-8, autoMakeup true. " +
|
|
1733
3753
|
"For lo-fi/Memphis/dusty glue: tapeEnabled=1, tapeMode=1 (Lo-Fi), masterTapeDrive 0.55-0.7, " +
|
|
1734
3754
|
"masterTapeTone -0.4..-0.1 (darker), masterTapeWear 0.5-0.7 (wow/flutter). " +
|
|
1735
|
-
"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.",
|
|
1736
3756
|
inputSchema: {
|
|
1737
3757
|
compressorEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable Punch compressor (0=off, 1=on)."),
|
|
1738
3758
|
limiterEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable master Limiter (0=off, 1=on)."),
|
|
@@ -1747,13 +3767,46 @@ server.registerTool("set_master_effects", {
|
|
|
1747
3767
|
masterCompCeiling: z.number().min(-6).max(0).optional().describe("Output ceiling in dB (default -0.05)."),
|
|
1748
3768
|
masterCompInputGain: z.number().min(-24).max(24).optional().describe("Input gain in dB (default 0)."),
|
|
1749
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)."),
|
|
1750
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')."),
|
|
1751
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."),
|
|
1752
3774
|
masterTapeMix: z.number().min(0).max(1).optional().describe("Tape dry/wet (0-1, default 1 = fully wet)."),
|
|
1753
|
-
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."),
|
|
1754
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."),
|
|
1755
3808
|
},
|
|
1756
|
-
}, async ({ compressorEnabled, limiterEnabled, tapeEnabled, ...effectParams }) => {
|
|
3809
|
+
}, async ({ compressorEnabled, limiterEnabled, tapeEnabled, masterEqBells, ...effectParams }) => {
|
|
1757
3810
|
const check = await requireBridge();
|
|
1758
3811
|
if (check !== true)
|
|
1759
3812
|
return check;
|
|
@@ -1765,8 +3818,28 @@ server.registerTool("set_master_effects", {
|
|
|
1765
3818
|
delta.masterLimiterEnabled = limiterEnabled;
|
|
1766
3819
|
if (tapeEnabled !== undefined)
|
|
1767
3820
|
delta.masterTapeEnabled = tapeEnabled;
|
|
1768
|
-
//
|
|
1769
|
-
|
|
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.
|
|
1770
3843
|
for (const [key, value] of Object.entries(effectParams)) {
|
|
1771
3844
|
if (value !== undefined)
|
|
1772
3845
|
delta[key] = value;
|
|
@@ -1774,8 +3847,10 @@ server.registerTool("set_master_effects", {
|
|
|
1774
3847
|
if (Object.keys(delta).length === 0) {
|
|
1775
3848
|
return error("At least one parameter must be provided.");
|
|
1776
3849
|
}
|
|
1777
|
-
//
|
|
1778
|
-
|
|
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);
|
|
1779
3854
|
const summary = Object.entries(delta)
|
|
1780
3855
|
.map(([k, v]) => `${k}=${v}`)
|
|
1781
3856
|
.join(", ");
|
|
@@ -1813,8 +3888,8 @@ server.registerTool("set_drum_synth", {
|
|
|
1813
3888
|
"sample for a pure synth voice, or layer over a sample. Each engine reinterprets the 8 generic params (p1..p8); pass " +
|
|
1814
3889
|
"only the ones you want to nuance — unspecified params seed to the engine's musical default. Per-engine p-labels: " +
|
|
1815
3890
|
DRUM_SYNTH_ENGINES.map((e) => `${e.name}=[${e.params.map((p) => p[0]).join(",")}]`).join("; ") + ". " +
|
|
1816
|
-
"NOTE: requires a PAM build with drum synth (manifest.drumSynth.enabled)
|
|
1817
|
-
"On
|
|
3891
|
+
"NOTE: requires a PAM build with drum synth (manifest.drumSynth.enabled) — a standard PAM feature, no settings flag. " +
|
|
3892
|
+
"On products without it, this tool returns a product error instead of writing inert synth keys.",
|
|
1818
3893
|
inputSchema: {
|
|
1819
3894
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
1820
3895
|
engine: z.enum(DRUM_SYNTH_ENGINE_NAMES).describe("Engine name. 'Off' disables the synth voice (sample-only cell)."),
|
|
@@ -1832,6 +3907,13 @@ server.registerTool("set_drum_synth", {
|
|
|
1832
3907
|
const check = await requireBridge();
|
|
1833
3908
|
if (check !== true)
|
|
1834
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
|
+
}
|
|
1835
3917
|
const engineId = DRUM_SYNTH_ENGINE_NAMES.indexOf(engine);
|
|
1836
3918
|
if (engineId < 0)
|
|
1837
3919
|
return error(`Unknown drum synth engine: ${engine}`);
|
|
@@ -1858,6 +3940,44 @@ server.registerTool("set_drum_synth", {
|
|
|
1858
3940
|
const labels = engineId > 0 ? DRUM_SYNTH_ENGINES[engineId - 1].params.map((p) => p[0]).join(",") : "—";
|
|
1859
3941
|
return text(`Cell ${cellId} drum synth → ${engine}${engineId > 0 ? ` (p1..pN = ${labels})` : ""}.`);
|
|
1860
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
|
+
});
|
|
1861
3981
|
server.registerTool("add_param_seq", {
|
|
1862
3982
|
title: "Add Parameter Sequencer",
|
|
1863
3983
|
description: "Add a parameter sequencer (step modulation) targeting any modulatable parameter. " +
|
|
@@ -1880,8 +4000,11 @@ server.registerTool("add_param_seq", {
|
|
|
1880
4000
|
if (check !== true)
|
|
1881
4001
|
return check;
|
|
1882
4002
|
const index = await getParameterIndex();
|
|
1883
|
-
|
|
1884
|
-
|
|
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));
|
|
1885
4008
|
const existing = await bridge.send("getState", "paramSeqs");
|
|
1886
4009
|
const paramSeqs = (Array.isArray(existing) ? existing : []).filter((seq) => seq?.paramId !== paramId);
|
|
1887
4010
|
const newSeq = {
|
|
@@ -1984,56 +4107,131 @@ server.registerTool("gain_sample", {
|
|
|
1984
4107
|
await bridge.send("gainSampleForCell", { cellId, gainDb });
|
|
1985
4108
|
return text(`Applied ${gainDb > 0 ? "+" : ""}${gainDb} dB to the sample in cell ${cellId}.`);
|
|
1986
4109
|
});
|
|
4110
|
+
// Poll the offline-render status flag until the export completes or times out.
|
|
4111
|
+
async function pollExportStatus(maxWaitMs = 180_000, pollMs = 500) {
|
|
4112
|
+
const t0 = Date.now();
|
|
4113
|
+
while (Date.now() - t0 < maxWaitMs) {
|
|
4114
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
4115
|
+
const status = (await bridge.send("getExportStatus"));
|
|
4116
|
+
if (status.complete) {
|
|
4117
|
+
lastExportLoudness = status.loudness ?? null;
|
|
4118
|
+
return status.success ? "success" : "failed";
|
|
4119
|
+
}
|
|
4120
|
+
}
|
|
4121
|
+
return "timeout";
|
|
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
|
+
}
|
|
1987
4140
|
server.registerTool("export_audio", {
|
|
1988
4141
|
title: "Export Audio",
|
|
1989
|
-
description: "Render and export audio from the current preset to a WAV file. " +
|
|
1990
|
-
"
|
|
1991
|
-
"
|
|
4142
|
+
description: "Render and export audio from the current preset to a WAV file in PAM's Exports folder. " +
|
|
4143
|
+
"Waits for completion. Default mode renders the looping preset for `bars` bars at the current BPM. " +
|
|
4144
|
+
"Set `mode` to bounce stems or the whole Song Mode arrangement.\n\n" +
|
|
4145
|
+
"Modes: 'main' (full mix, default) · 'individual' (one cell solo, needs cellId) · " +
|
|
4146
|
+
"'all-cells' / 'selected-cells' (per-cell stems, the latter needs selectedCellIds) · " +
|
|
4147
|
+
"'per-bus' (per output bus) · 'song-mode' (the Song Mode timeline).\n\n" +
|
|
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.",
|
|
1992
4151
|
inputSchema: {
|
|
1993
|
-
bars: z.number().min(1).max(128).default(4).describe("
|
|
4152
|
+
bars: z.number().min(1).max(128).default(4).describe("Bars to render (ignored for 'song-mode', which derives length from the timeline)."),
|
|
1994
4153
|
cellId: z
|
|
1995
4154
|
.number()
|
|
1996
4155
|
.min(1)
|
|
1997
4156
|
.max(8)
|
|
1998
4157
|
.optional()
|
|
1999
|
-
.describe("Render only a specific cell in solo. Omit for full mix."),
|
|
4158
|
+
.describe("Render only a specific cell in solo (implies mode 'individual'). Omit for full mix."),
|
|
4159
|
+
mode: z
|
|
4160
|
+
.enum(["main", "individual", "all-cells", "selected-cells", "per-bus", "song-mode"])
|
|
4161
|
+
.optional()
|
|
4162
|
+
.describe("Export mode. Defaults to 'main' (or 'individual' when cellId is set)."),
|
|
4163
|
+
selectedCellIds: z
|
|
4164
|
+
.array(z.number().min(1).max(8))
|
|
4165
|
+
.optional()
|
|
4166
|
+
.describe("For mode 'selected-cells': cells to bounce as stems."),
|
|
2000
4167
|
bitDepth: z
|
|
2001
4168
|
.number()
|
|
2002
4169
|
.default(24)
|
|
2003
4170
|
.describe("Bit depth (16 or 24)."),
|
|
2004
4171
|
},
|
|
2005
|
-
}, async ({ bars, cellId, bitDepth }) => {
|
|
4172
|
+
}, async ({ bars, cellId, mode, selectedCellIds, bitDepth }) => {
|
|
2006
4173
|
const check = await requireBridge();
|
|
2007
4174
|
if (check !== true)
|
|
2008
4175
|
return check;
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
const bpm = result.bpm;
|
|
2021
|
-
// Poll for completion
|
|
2022
|
-
const maxWaitMs = 120_000;
|
|
2023
|
-
const pollMs = 500;
|
|
2024
|
-
const t0 = Date.now();
|
|
2025
|
-
while (Date.now() - t0 < maxWaitMs) {
|
|
2026
|
-
await new Promise((r) => setTimeout(r, pollMs));
|
|
2027
|
-
const status = (await bridge.send("getExportStatus"));
|
|
2028
|
-
if (status.complete) {
|
|
2029
|
-
if (status.success) {
|
|
2030
|
-
return text(`Audio exported successfully.\nFile: ${outputPath}\n` +
|
|
2031
|
-
`Duration: ${durationSecs.toFixed(1)}s (${bars} bars at ${bpm} BPM, ${bitDepth}-bit)`);
|
|
2032
|
-
}
|
|
2033
|
-
return error(`Audio export failed.`);
|
|
4176
|
+
// Resolve the effective mode. A bare cellId means "individual".
|
|
4177
|
+
const effectiveMode = mode ?? (cellId ? "individual" : "main");
|
|
4178
|
+
// Fast path: main / individual go through the MCP-friendly bridge branch,
|
|
4179
|
+
// which constructs options server-side and returns the output path + duration.
|
|
4180
|
+
if (effectiveMode === "main" || (effectiveMode === "individual" && cellId)) {
|
|
4181
|
+
const options = { bars, bitDepth };
|
|
4182
|
+
if (cellId)
|
|
4183
|
+
options.cellId = cellId;
|
|
4184
|
+
const startResult = await bridge.send("exportAudio", options);
|
|
4185
|
+
if (typeof startResult === "string") {
|
|
4186
|
+
return text(`Audio export started (${bars} bars, ${bitDepth}-bit).`);
|
|
2034
4187
|
}
|
|
4188
|
+
const result = startResult;
|
|
4189
|
+
const outputPath = result.outputPath;
|
|
4190
|
+
const durationSecs = result.durationInSeconds;
|
|
4191
|
+
const bpm = result.bpm;
|
|
4192
|
+
const outcome = await pollExportStatus();
|
|
4193
|
+
if (outcome === "success")
|
|
4194
|
+
return text(`Audio exported successfully.\nFile: ${outputPath}\n` +
|
|
4195
|
+
`Duration: ${durationSecs.toFixed(1)}s (${bars} bars at ${bpm} BPM, ${bitDepth}-bit)` +
|
|
4196
|
+
loudnessReportLine());
|
|
4197
|
+
if (outcome === "failed")
|
|
4198
|
+
return error("Audio export failed.");
|
|
4199
|
+
return text(`Export timed out. The render may still be running.\nExpected output: ${outputPath}`);
|
|
2035
4200
|
}
|
|
2036
|
-
|
|
4201
|
+
// Stem / song-mode modes: build full pass-through options (filename + mode +
|
|
4202
|
+
// durationInSeconds) so the C++ export handler runs the requested mode directly.
|
|
4203
|
+
if (effectiveMode === "selected-cells" && (!selectedCellIds || selectedCellIds.length === 0))
|
|
4204
|
+
return error("mode 'selected-cells' requires selectedCellIds.");
|
|
4205
|
+
if (effectiveMode === "individual" && !cellId)
|
|
4206
|
+
return error("mode 'individual' requires cellId.");
|
|
4207
|
+
const transport = (await bridge.send("getTransport"));
|
|
4208
|
+
const bpm = typeof transport.bpm === "number" && transport.bpm > 0 ? transport.bpm : 120;
|
|
4209
|
+
// 4/4 assumption for the placeholder duration; 'song-mode' recomputes its own length from the timeline.
|
|
4210
|
+
const durationInSeconds = (bars * 4 * 60) / bpm;
|
|
4211
|
+
const filename = `pam_export_${effectiveMode.replace(/[^a-z0-9]/gi, "_")}_${Date.now()}`;
|
|
4212
|
+
const options = {
|
|
4213
|
+
filename,
|
|
4214
|
+
mode: effectiveMode,
|
|
4215
|
+
durationInSeconds,
|
|
4216
|
+
bitDepth,
|
|
4217
|
+
};
|
|
4218
|
+
if (cellId)
|
|
4219
|
+
options.selectedCellId = cellId;
|
|
4220
|
+
if (selectedCellIds)
|
|
4221
|
+
options.selectedCellIds = selectedCellIds;
|
|
4222
|
+
await bridge.send("exportAudio", options);
|
|
4223
|
+
const outcome = await pollExportStatus();
|
|
4224
|
+
if (outcome === "success")
|
|
4225
|
+
return text(`Export complete (mode '${effectiveMode}').\n` +
|
|
4226
|
+
`File(s) written to PAM's Exports folder, base name: ${filename}.wav` +
|
|
4227
|
+
loudnessReportLine() +
|
|
4228
|
+
(effectiveMode === "song-mode"
|
|
4229
|
+
? "\n\nReminder: the offline song-mode bounce captures only the first section's state. " +
|
|
4230
|
+
"Use export_song for a full track with section changes."
|
|
4231
|
+
: ""));
|
|
4232
|
+
if (outcome === "failed")
|
|
4233
|
+
return error(`Export failed (mode '${effectiveMode}').`);
|
|
4234
|
+
return text(`Export timed out (mode '${effectiveMode}'). The render may still be running.`);
|
|
2037
4235
|
});
|
|
2038
4236
|
// ============================================================
|
|
2039
4237
|
// RESOURCES
|
|
@@ -2145,6 +4343,24 @@ Use \`update_pattern\` with \`type: "slice"\` to sequence slice changes over tim
|
|
|
2145
4343
|
- Different patternSpeed creates rate variation: slice lane at 2x speed = double-time slice changes
|
|
2146
4344
|
- Combine with \`prob\` field (0-1) on events for controlled randomness
|
|
2147
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
|
+
|
|
2148
4364
|
## Sidechain Compression — The Pump
|
|
2149
4365
|
|
|
2150
4366
|
### Setup via MCP
|
|
@@ -2426,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,
|
|
|
2426
4642
|
],
|
|
2427
4643
|
}));
|
|
2428
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
|
+
// ============================================================
|
|
2429
4725
|
// TOOLS — Looper (state deltas)
|
|
2430
4726
|
// ============================================================
|
|
2431
4727
|
// applyStateDelta-equivalent options: full update, dispatch graph, sync KV.
|
|
@@ -3037,11 +5333,27 @@ server.registerTool("update_modulation", {
|
|
|
3037
5333
|
// ============================================================
|
|
3038
5334
|
server.registerTool("song_mode", {
|
|
3039
5335
|
title: "Song Mode",
|
|
3040
|
-
description: "Control PAM's song mode (timeline of variation/preset
|
|
3041
|
-
"'
|
|
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.",
|
|
3042
5351
|
inputSchema: {
|
|
3043
5352
|
op: z
|
|
3044
5353
|
.enum([
|
|
5354
|
+
"setArrangement",
|
|
5355
|
+
"get",
|
|
5356
|
+
"assignVx",
|
|
3045
5357
|
"setSequence",
|
|
3046
5358
|
"clear",
|
|
3047
5359
|
"toggle",
|
|
@@ -3051,6 +5363,62 @@ server.registerTool("song_mode", {
|
|
|
3051
5363
|
"setPlayhead",
|
|
3052
5364
|
])
|
|
3053
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)."),
|
|
3054
5422
|
steps: z
|
|
3055
5423
|
.array(z.object({
|
|
3056
5424
|
variationIndex: z.number().min(0).max(7).describe("Variation slot 0-7."),
|
|
@@ -3064,8 +5432,8 @@ server.registerTool("song_mode", {
|
|
|
3064
5432
|
probability: z.number().min(0).max(1).optional().describe("Trigger probability (0-1)."),
|
|
3065
5433
|
}))
|
|
3066
5434
|
.optional()
|
|
3067
|
-
.describe("For 'setSequence': step list."),
|
|
3068
|
-
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."),
|
|
3069
5437
|
start: z
|
|
3070
5438
|
.number()
|
|
3071
5439
|
.nullable()
|
|
@@ -3089,6 +5457,65 @@ server.registerTool("song_mode", {
|
|
|
3089
5457
|
if (check !== true)
|
|
3090
5458
|
return check;
|
|
3091
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
|
+
}
|
|
3092
5519
|
case "setSequence":
|
|
3093
5520
|
if (!args.steps)
|
|
3094
5521
|
return error("'setSequence' requires steps");
|
|
@@ -3126,6 +5553,178 @@ server.registerTool("song_mode", {
|
|
|
3126
5553
|
}
|
|
3127
5554
|
return text("No-op");
|
|
3128
5555
|
});
|
|
5556
|
+
server.registerTool("export_song", {
|
|
5557
|
+
title: "Export Song (Full Track)",
|
|
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).",
|
|
5576
|
+
inputSchema: {
|
|
5577
|
+
strategy: z.enum(["live", "offline"]).default("live").describe("Capture strategy."),
|
|
5578
|
+
bars: z
|
|
5579
|
+
.number()
|
|
5580
|
+
.min(1)
|
|
5581
|
+
.max(64)
|
|
5582
|
+
.describe("Total arrangement length in bars (the full timeline). Required for 'live'."),
|
|
5583
|
+
cellId: z
|
|
5584
|
+
.number()
|
|
5585
|
+
.min(1)
|
|
5586
|
+
.max(8)
|
|
5587
|
+
.optional()
|
|
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)."),
|
|
5597
|
+
tailBars: z
|
|
5598
|
+
.number()
|
|
5599
|
+
.min(0)
|
|
5600
|
+
.max(8)
|
|
5601
|
+
.default(1)
|
|
5602
|
+
.describe("For legacy cell-capture 'live': extra bars captured after the arrangement end for tails."),
|
|
5603
|
+
},
|
|
5604
|
+
}, async ({ strategy, bars, cellId, includeVideo, name, normalize, encoderId, bitDepth, tailBars }) => {
|
|
5605
|
+
const check = await requireBridge();
|
|
5606
|
+
if (check !== true)
|
|
5607
|
+
return check;
|
|
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) {
|
|
5612
|
+
const filename = `pam_song_${Date.now()}`;
|
|
5613
|
+
const transport = (await bridge.send("getTransport"));
|
|
5614
|
+
const bpm = typeof transport.bpm === "number" && transport.bpm > 0 ? transport.bpm : 120;
|
|
5615
|
+
await bridge.send("exportAudio", {
|
|
5616
|
+
filename,
|
|
5617
|
+
mode: "song-mode",
|
|
5618
|
+
// Estimate only — exportSongMode computes the real length from the arrangement.
|
|
5619
|
+
durationInSeconds: (bars * 4 * 60) / bpm,
|
|
5620
|
+
bitDepth,
|
|
5621
|
+
});
|
|
5622
|
+
const outcome = await pollExportStatus();
|
|
5623
|
+
if (outcome === "success")
|
|
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.");
|
|
5628
|
+
if (outcome === "failed")
|
|
5629
|
+
return error("Offline song bounce failed (or was cancelled). Is Song Mode enabled with a non-empty sequence?");
|
|
5630
|
+
return text("Offline song bounce timed out. The render may still be running.");
|
|
5631
|
+
}
|
|
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 --------
|
|
5695
|
+
const transport = (await bridge.send("getTransport"));
|
|
5696
|
+
const bpm = typeof transport.bpm === "number" && transport.bpm > 0 ? transport.bpm : 120;
|
|
5697
|
+
const totalBars = bars + (tailBars ?? 1);
|
|
5698
|
+
if (totalBars > 64)
|
|
5699
|
+
return error(`Total capture (${totalBars} bars incl. tail) exceeds the 64-bar live cap. Bounce in sections.`);
|
|
5700
|
+
const captureSeconds = (totalBars * 4 * 60) / bpm;
|
|
5701
|
+
// Reset playhead to the top so the arrangement starts at section 1, enable Song
|
|
5702
|
+
// Mode, then roll the transport and capture the full master mix.
|
|
5703
|
+
await bridge.send("fullStopTransport");
|
|
5704
|
+
await bridge.send("toggleSongMode", true);
|
|
5705
|
+
await bridge.send("setSongModePlayhead", 0);
|
|
5706
|
+
await bridge.send("startTransport");
|
|
5707
|
+
await bridge.send("resampleGraphToCell", JSON.stringify({ cellId, bars: totalBars, live: true, captureSource: 2, cellOnly: false }));
|
|
5708
|
+
// Wait out the arrangement (plus a small safety margin), then stop + finalize.
|
|
5709
|
+
await new Promise((r) => setTimeout(r, captureSeconds * 1000 + 1500));
|
|
5710
|
+
await bridge.send("stopLiveResample", true);
|
|
5711
|
+
await bridge.send("stopTransport");
|
|
5712
|
+
// Read the captured clip path back off the destination cell.
|
|
5713
|
+
let capturedPath = "(unknown — check PAM's Resamples folder)";
|
|
5714
|
+
try {
|
|
5715
|
+
const state = (await bridge.send("getState"));
|
|
5716
|
+
const cells = state.cells;
|
|
5717
|
+
const fp = cells?.[String(cellId)]?.filePath;
|
|
5718
|
+
if (typeof fp === "string" && fp.length > 0)
|
|
5719
|
+
capturedPath = fp;
|
|
5720
|
+
}
|
|
5721
|
+
catch {
|
|
5722
|
+
// fall through with the placeholder
|
|
5723
|
+
}
|
|
5724
|
+
return text(`Live song bounce complete (${bars} bars + ${tailBars ?? 1} tail @ ${bpm} BPM, ${bitDepth}-bit).\n` +
|
|
5725
|
+
`Captured into cell ${cellId}.\nClip: ${capturedPath}\n` +
|
|
5726
|
+
"Resolve the absolute path against the Resamples location from get_default_locations, then verify with get_audio_info.");
|
|
5727
|
+
});
|
|
3129
5728
|
// ============================================================
|
|
3130
5729
|
// TOOLS — Metronome
|
|
3131
5730
|
// ============================================================
|
|
@@ -3290,8 +5889,12 @@ server.registerTool("export_slice", {
|
|
|
3290
5889
|
// ============================================================
|
|
3291
5890
|
server.registerTool("live_resample", {
|
|
3292
5891
|
title: "Live Resample",
|
|
3293
|
-
description: "Capture PAM's audio graph output into a cell as a new sample. Ops: 'start' / 'stop'. " +
|
|
3294
|
-
"Transport
|
|
5892
|
+
description: "Capture PAM's audio graph output into a cell as a new sample, in real time. Ops: 'start' / 'stop'. " +
|
|
5893
|
+
"Transport MUST be playing for the capture to produce audio. Because the capture runs live, " +
|
|
5894
|
+
"Song Mode section changes, modulation, and variation switches are all captured faithfully — " +
|
|
5895
|
+
"this is the building block for full-track / Song Mode bounces (see export_song).\n\n" +
|
|
5896
|
+
"`source` selects what to record: 'cell' (the destination cell's own output), 'input' (PAM's audio input), " +
|
|
5897
|
+
"or 'fullmix' (the whole master mix — use this to bounce the entire arrangement).",
|
|
3295
5898
|
inputSchema: {
|
|
3296
5899
|
op: z.enum(["start", "stop"]).describe("Operation."),
|
|
3297
5900
|
cellId: z
|
|
@@ -3299,13 +5902,21 @@ server.registerTool("live_resample", {
|
|
|
3299
5902
|
.min(1)
|
|
3300
5903
|
.max(8)
|
|
3301
5904
|
.optional()
|
|
3302
|
-
.describe("For 'start': destination cell."),
|
|
5905
|
+
.describe("For 'start': destination cell the captured clip lands in. Use an empty/scratch cell for a full-mix bounce."),
|
|
3303
5906
|
bars: z
|
|
3304
5907
|
.number()
|
|
3305
5908
|
.min(0.125)
|
|
3306
5909
|
.max(64)
|
|
3307
5910
|
.optional()
|
|
3308
|
-
.describe("For 'start': capture length in bars."),
|
|
5911
|
+
.describe("For 'start': capture length in bars (max 64; the capture also stops on the 'stop' op)."),
|
|
5912
|
+
source: z
|
|
5913
|
+
.enum(["cell", "input", "fullmix"])
|
|
5914
|
+
.default("cell")
|
|
5915
|
+
.describe("For 'start': capture source. 'fullmix' records the entire master output (the whole song)."),
|
|
5916
|
+
normalize: z
|
|
5917
|
+
.boolean()
|
|
5918
|
+
.default(false)
|
|
5919
|
+
.describe("For 'start': peak-normalize the captured clip after recording."),
|
|
3309
5920
|
finalize: z
|
|
3310
5921
|
.boolean()
|
|
3311
5922
|
.default(true)
|
|
@@ -3318,8 +5929,19 @@ server.registerTool("live_resample", {
|
|
|
3318
5929
|
if (args.op === "start") {
|
|
3319
5930
|
if (typeof args.cellId !== "number" || typeof args.bars !== "number")
|
|
3320
5931
|
return error("'start' requires cellId + bars");
|
|
3321
|
-
|
|
3322
|
-
|
|
5932
|
+
const source = args.source ?? "cell";
|
|
5933
|
+
const captureSource = source === "fullmix" ? 2 : source === "input" ? 1 : 0;
|
|
5934
|
+
await bridge.send("resampleGraphToCell", JSON.stringify({
|
|
5935
|
+
cellId: args.cellId,
|
|
5936
|
+
bars: args.bars,
|
|
5937
|
+
live: true,
|
|
5938
|
+
captureSource,
|
|
5939
|
+
// For a full-mix / input bounce the destination cell must NOT be soloed,
|
|
5940
|
+
// or we'd only capture that one (empty) cell.
|
|
5941
|
+
cellOnly: source === "cell",
|
|
5942
|
+
normalize: args.normalize ?? false,
|
|
5943
|
+
}));
|
|
5944
|
+
return text(`Started live resample → cell ${args.cellId} (${args.bars} bars, source '${source}').`);
|
|
3323
5945
|
}
|
|
3324
5946
|
await bridge.send("stopLiveResample", args.finalize ?? true);
|
|
3325
5947
|
return text(`Stopped live resample (finalize=${args.finalize ?? true}).`);
|
|
@@ -3359,6 +5981,13 @@ function isPathUnderWhitelist(p) {
|
|
|
3359
5981
|
// accepts entries observed here so an agent can't instantiate arbitrary
|
|
3360
5982
|
// binaries.
|
|
3361
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);
|
|
3362
5991
|
// Hosted-plugin tools target PAM cells. Cap to the manifest's cell count
|
|
3363
5992
|
// (falls back to 8 — PAM's shipping max) rather than the C++ slot
|
|
3364
5993
|
// reservation of 32.
|
|
@@ -3405,12 +6034,12 @@ server.registerTool("list_plugins", {
|
|
|
3405
6034
|
filter.isInstrument = args.isInstrument;
|
|
3406
6035
|
if (args.nameContains)
|
|
3407
6036
|
filter.nameContains = args.nameContains;
|
|
3408
|
-
const raw =
|
|
6037
|
+
const raw = await bridge.send("listPlugins", filter);
|
|
3409
6038
|
// Record every fileOrIdentifier so load_plugin can validate that the
|
|
3410
6039
|
// caller-supplied path was discovered by PAM's own scanner.
|
|
3411
6040
|
try {
|
|
3412
|
-
const parsed = JSON.parse(raw);
|
|
3413
|
-
if (Array.isArray(parsed
|
|
6041
|
+
const parsed = (typeof raw === "string" ? JSON.parse(raw) : raw);
|
|
6042
|
+
if (Array.isArray(parsed?.plugins)) {
|
|
3414
6043
|
for (const p of parsed.plugins) {
|
|
3415
6044
|
if (typeof p.fileOrIdentifier === "string") {
|
|
3416
6045
|
knownPluginIdentifiers.add(p.fileOrIdentifier);
|
|
@@ -3421,7 +6050,7 @@ server.registerTool("list_plugins", {
|
|
|
3421
6050
|
catch {
|
|
3422
6051
|
// C++ payload not JSON — fall through; load_plugin will reject unknowns.
|
|
3423
6052
|
}
|
|
3424
|
-
return text(raw);
|
|
6053
|
+
return text(bridgeResultText(raw));
|
|
3425
6054
|
});
|
|
3426
6055
|
server.registerTool("scan_plugins", {
|
|
3427
6056
|
title: "Scan Plugins",
|
|
@@ -3477,12 +6106,21 @@ server.registerTool("get_plugin_scan_status", {
|
|
|
3477
6106
|
"`fraction` (0..1), `elapsedMs`, `isStall` (true when the in-flight plugin hasn't returned in 2s), " +
|
|
3478
6107
|
"and `updatedAtMs`. Poll while a scan is running.",
|
|
3479
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
|
+
},
|
|
3480
6118
|
}, async () => {
|
|
3481
6119
|
const check = await requireBridge();
|
|
3482
6120
|
if (check !== true)
|
|
3483
6121
|
return check;
|
|
3484
|
-
const raw =
|
|
3485
|
-
return
|
|
6122
|
+
const raw = await bridge.send("getPluginScanStatus");
|
|
6123
|
+
return structuredFromBridge(raw);
|
|
3486
6124
|
});
|
|
3487
6125
|
server.registerTool("list_loaded_plugins", {
|
|
3488
6126
|
title: "List Loaded Plugins",
|
|
@@ -3495,8 +6133,8 @@ server.registerTool("list_loaded_plugins", {
|
|
|
3495
6133
|
const check = await requireBridge();
|
|
3496
6134
|
if (check !== true)
|
|
3497
6135
|
return check;
|
|
3498
|
-
const raw =
|
|
3499
|
-
return text(raw);
|
|
6136
|
+
const raw = await bridge.send("listLoadedPlugins");
|
|
6137
|
+
return text(bridgeResultText(raw));
|
|
3500
6138
|
});
|
|
3501
6139
|
server.registerTool("load_plugin", {
|
|
3502
6140
|
title: "Load Plugin Into Cell",
|
|
@@ -3507,6 +6145,10 @@ server.registerTool("load_plugin", {
|
|
|
3507
6145
|
cellId: z.number().int().min(1).describe("Target cell (1-based)."),
|
|
3508
6146
|
path: z.string().describe("Plugin fileOrIdentifier from list_plugins."),
|
|
3509
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."),
|
|
3510
6152
|
},
|
|
3511
6153
|
}, async (args) => {
|
|
3512
6154
|
const check = await requireBridge();
|
|
@@ -3523,6 +6165,8 @@ server.registerTool("load_plugin", {
|
|
|
3523
6165
|
cellId: clamped,
|
|
3524
6166
|
path: args.path,
|
|
3525
6167
|
format: args.format,
|
|
6168
|
+
...(args.uniqueId ? { uniqueId: args.uniqueId } : {}),
|
|
6169
|
+
...(args.name ? { name: args.name } : {}),
|
|
3526
6170
|
}));
|
|
3527
6171
|
return text(raw);
|
|
3528
6172
|
});
|
|
@@ -3592,14 +6236,15 @@ server.registerTool("update_settings", {
|
|
|
3592
6236
|
title: "Update Plugin Settings",
|
|
3593
6237
|
description: "Merge a partial settings delta into PAM's plugin settings (the Settings panel state) and apply it live. " +
|
|
3594
6238
|
"Returns the full merged settings object so you can discover available keys. This is the only way to flip " +
|
|
3595
|
-
"experimental-feature gates from an agent. Common boolean keys: experimentalMode,
|
|
3596
|
-
"experimentalPluginHost, experimentalMcpBridge. To enable the
|
|
3597
|
-
"experimentalMode:true AND the specific sub-flag (e.g.
|
|
6239
|
+
"experimental-feature gates from an agent. Common boolean keys: experimentalMode, " +
|
|
6240
|
+
"experimentalPluginHost, experimentalMcpBridge. To enable the plugin host, set BOTH " +
|
|
6241
|
+
"experimentalMode:true AND the specific sub-flag (e.g. experimentalPluginHost:true) in one call. " +
|
|
6242
|
+
"The drum synth needs no flag — it is a standard feature on PAM. Other keys " +
|
|
3598
6243
|
"include UI/library preferences — call get_state or inspect the returned object for the current set.",
|
|
3599
6244
|
inputSchema: {
|
|
3600
6245
|
settings: z
|
|
3601
6246
|
.record(z.string(), z.any())
|
|
3602
|
-
.describe("Partial settings object to merge. Example: { experimentalMode: true,
|
|
6247
|
+
.describe("Partial settings object to merge. Example: { experimentalMode: true, experimentalPluginHost: true }"),
|
|
3603
6248
|
},
|
|
3604
6249
|
}, async ({ settings }) => {
|
|
3605
6250
|
const check = await requireBridge();
|
|
@@ -3704,16 +6349,16 @@ server.registerTool("set_midi_io", {
|
|
|
3704
6349
|
server.registerTool("set_midi_mapping", {
|
|
3705
6350
|
title: "Set MIDI Action Mapping",
|
|
3706
6351
|
description: "Configure the global MIDI-controller mappings that trigger PAM actions: variation switch, preset change, " +
|
|
3707
|
-
"looper record, live resample, and looper-variation switch. Set the enable flag,
|
|
3708
|
-
"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.",
|
|
3709
6354
|
inputSchema: {
|
|
3710
6355
|
target: z
|
|
3711
|
-
.enum(["variation", "preset", "looper", "resample", "looperVariation"])
|
|
3712
|
-
.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)."),
|
|
3713
6358
|
field: z
|
|
3714
6359
|
.enum(["enabled", "channel", "ccNumber", "source", "barSync"])
|
|
3715
6360
|
.describe("Field to set. 'source' is preset-only; 'barSync' is variation/looperVariation-only; " +
|
|
3716
|
-
"looper/resample have no source/barSync."),
|
|
6361
|
+
"looper/resample/vxRecord have no source/barSync."),
|
|
3717
6362
|
value: z
|
|
3718
6363
|
.union([z.number(), z.boolean()])
|
|
3719
6364
|
.describe("boolean for enabled/barSync; number for channel (1-16), ccNumber (0-127), or source."),
|
|
@@ -3878,13 +6523,309 @@ server.registerTool("remove_plugin_from_blocklist", {
|
|
|
3878
6523
|
return text(`Removed ${fileOrIdentifier} from the plugin blocklist.`);
|
|
3879
6524
|
});
|
|
3880
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
|
+
// ============================================================
|
|
3881
6819
|
// Start
|
|
3882
6820
|
// ============================================================
|
|
3883
6821
|
async function main() {
|
|
3884
6822
|
if (await handleCliArgs())
|
|
3885
6823
|
return;
|
|
3886
6824
|
// Try to connect to plugin on startup (non-blocking)
|
|
3887
|
-
void bridge.connect()
|
|
6825
|
+
void bridge.connect().then((ok) => {
|
|
6826
|
+
if (ok)
|
|
6827
|
+
syncProductHint();
|
|
6828
|
+
});
|
|
3888
6829
|
const transport = new StdioServerTransport();
|
|
3889
6830
|
await server.connect(transport);
|
|
3890
6831
|
// Log to stderr (stdout is the MCP JSON-RPC channel)
|