@map-audio/pam-mcp-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -0
- package/dist/agent.d.ts +11 -0
- package/dist/agent.js +201 -0
- package/dist/agent.js.map +1 -0
- package/dist/bridge.d.ts +21 -0
- package/dist/bridge.js +147 -0
- package/dist/bridge.js.map +1 -0
- package/dist/manifest.d.ts +41 -0
- package/dist/manifest.js +135 -0
- package/dist/manifest.js.map +1 -0
- package/dist/manifest.json +16344 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +2181 -0
- package/dist/server.js.map +1 -0
- package/package.json +59 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,2181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { basename } from "node:path";
|
|
7
|
+
import { loadManifest, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, walkPresets, } from "./manifest.js";
|
|
8
|
+
import { PluginBridge } from "./bridge.js";
|
|
9
|
+
// --- State ---
|
|
10
|
+
const bridge = new PluginBridge();
|
|
11
|
+
// --- Helpers ---
|
|
12
|
+
function text(s) {
|
|
13
|
+
return { content: [{ type: "text", text: s }] };
|
|
14
|
+
}
|
|
15
|
+
function json(obj) {
|
|
16
|
+
return text(JSON.stringify(obj, null, 2));
|
|
17
|
+
}
|
|
18
|
+
function error(msg) {
|
|
19
|
+
return { content: [{ type: "text", text: msg }], isError: true };
|
|
20
|
+
}
|
|
21
|
+
async function requireBridge() {
|
|
22
|
+
if (bridge.isConnected())
|
|
23
|
+
return true;
|
|
24
|
+
const ok = await bridge.connect();
|
|
25
|
+
if (!ok) {
|
|
26
|
+
return error("Not connected to PAM plugin. Start PAM (standalone or in a DAW) with MCP bridge enabled. " +
|
|
27
|
+
"The bridge writes its port to ~/.pam-mcp-bridge.json on startup.");
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Parse the PAM binary preset format: <metadataSize as text int><metadata JSON><data JSON>
|
|
33
|
+
*/
|
|
34
|
+
function parsePresetFile(raw) {
|
|
35
|
+
const sizeMatch = raw.match(/^(\d+)/);
|
|
36
|
+
if (!sizeMatch)
|
|
37
|
+
throw new Error("invalid format (no metadata size prefix)");
|
|
38
|
+
const prefixLen = sizeMatch[1].length;
|
|
39
|
+
const metadataSize = parseInt(sizeMatch[1], 10);
|
|
40
|
+
if (metadataSize <= 0 || prefixLen + metadataSize > raw.length) {
|
|
41
|
+
throw new Error(`invalid format (metadata size ${metadataSize} out of bounds for file length ${raw.length})`);
|
|
42
|
+
}
|
|
43
|
+
const metadataStr = raw.slice(prefixLen, prefixLen + metadataSize);
|
|
44
|
+
const dataStr = raw.slice(prefixLen + metadataSize);
|
|
45
|
+
return { metadata: JSON.parse(metadataStr), data: JSON.parse(dataStr) };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Read existing variations array from a preset file on disk.
|
|
49
|
+
* Returns Array(8) of strings or nulls.
|
|
50
|
+
*/
|
|
51
|
+
async function readExistingVariations(presetPath) {
|
|
52
|
+
try {
|
|
53
|
+
const fullPath = await resolvePresetPath(presetPath);
|
|
54
|
+
const raw = await readFile(fullPath, "utf-8");
|
|
55
|
+
const { metadata } = parsePresetFile(raw);
|
|
56
|
+
if (metadata.variations && Array.isArray(metadata.variations) && metadata.variations.length === 8) {
|
|
57
|
+
return [...metadata.variations];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Preset doesn't exist yet or can't be read — start fresh
|
|
62
|
+
}
|
|
63
|
+
return Array(8).fill(null);
|
|
64
|
+
}
|
|
65
|
+
// Mirrors interop.ts VARIATION_EXCLUDED_PARAMS (activePresetPath is excluded separately below, matching interop.ts)
|
|
66
|
+
const VARIATION_EXCLUDED_PARAMS = [
|
|
67
|
+
"variations",
|
|
68
|
+
"interfaceCache",
|
|
69
|
+
"songMode",
|
|
70
|
+
"volume",
|
|
71
|
+
"looperCrossfade",
|
|
72
|
+
"looperLength",
|
|
73
|
+
"looperVolume",
|
|
74
|
+
"looperFilter",
|
|
75
|
+
"loopers",
|
|
76
|
+
"looperPlaybackBars",
|
|
77
|
+
"looperTrack1Level",
|
|
78
|
+
"looperTrack2Level",
|
|
79
|
+
"looperTrack3Level",
|
|
80
|
+
"looperTrack4Level",
|
|
81
|
+
"looperTrack5Level",
|
|
82
|
+
"looperTrack6Level",
|
|
83
|
+
"looperTrack7Level",
|
|
84
|
+
"looperTrack8Level",
|
|
85
|
+
];
|
|
86
|
+
/**
|
|
87
|
+
* Build a variation snapshot string from plugin state (matching interop.ts logic).
|
|
88
|
+
*/
|
|
89
|
+
function buildVariationString(state) {
|
|
90
|
+
const stateForVariation = { ...state };
|
|
91
|
+
for (const param of VARIATION_EXCLUDED_PARAMS) {
|
|
92
|
+
delete stateForVariation[param];
|
|
93
|
+
}
|
|
94
|
+
delete stateForVariation.activePresetPath;
|
|
95
|
+
return JSON.stringify(stateForVariation);
|
|
96
|
+
}
|
|
97
|
+
function formatParam(p) {
|
|
98
|
+
let desc = `${p.paramId}: ${p.displayName}`;
|
|
99
|
+
desc += ` [${p.min}..${p.max}]`;
|
|
100
|
+
if (p.suffix)
|
|
101
|
+
desc += ` ${p.suffix}`;
|
|
102
|
+
if (p.defaultValue !== undefined)
|
|
103
|
+
desc += ` (default: ${p.defaultValue})`;
|
|
104
|
+
if (p.strRepr)
|
|
105
|
+
desc += ` values: ${p.strRepr.join(", ")}`;
|
|
106
|
+
if (p.cell)
|
|
107
|
+
desc += ` (cell: ${p.cell})`;
|
|
108
|
+
if (p.effect)
|
|
109
|
+
desc += ` (effect: ${p.effect})`;
|
|
110
|
+
desc += p.isPluginParameter ? ` [plugin]` : ` [state]`;
|
|
111
|
+
if (p.tooltip)
|
|
112
|
+
desc += ` — ${p.tooltip}`;
|
|
113
|
+
return desc;
|
|
114
|
+
}
|
|
115
|
+
// --- Server ---
|
|
116
|
+
const server = new McpServer({
|
|
117
|
+
name: "pam",
|
|
118
|
+
version: "1.0.0",
|
|
119
|
+
}, {
|
|
120
|
+
instructions: "PAM is an audio sampler plugin. When browsing samples, ALWAYS call get_default_locations " +
|
|
121
|
+
"first to discover the user's actual library paths — never guess paths like ~/Music/PAM. " +
|
|
122
|
+
"The sample library lives under ~/Library/Application Support/, NOT ~/Music/. " +
|
|
123
|
+
"Use the returned paths with list_samples. When loading samples, use load_sample with " +
|
|
124
|
+
"paths from list_samples. Transport must be playing for sequencer output.",
|
|
125
|
+
});
|
|
126
|
+
// ============================================================
|
|
127
|
+
// TOOLS — Offline (work without plugin running)
|
|
128
|
+
// ============================================================
|
|
129
|
+
server.registerTool("get_parameter_info", {
|
|
130
|
+
title: "Get Parameter Info",
|
|
131
|
+
description: "Look up PAM parameter definitions from the manifest. Returns ranges, defaults, " +
|
|
132
|
+
"display names, and tooltips. Use this to understand what parameters are available " +
|
|
133
|
+
"before setting values.",
|
|
134
|
+
inputSchema: {
|
|
135
|
+
paramId: z
|
|
136
|
+
.string()
|
|
137
|
+
.optional()
|
|
138
|
+
.describe("Specific parameter ID (e.g. 'cell1_filterCutoff'). Omit for all."),
|
|
139
|
+
cell: z
|
|
140
|
+
.string()
|
|
141
|
+
.optional()
|
|
142
|
+
.describe("Filter by cell (e.g. 'cell1'). Returns all params for that cell."),
|
|
143
|
+
effect: z
|
|
144
|
+
.string()
|
|
145
|
+
.optional()
|
|
146
|
+
.describe("Filter by effect (e.g. 'filter', 'delay', 'compressor')."),
|
|
147
|
+
search: z
|
|
148
|
+
.string()
|
|
149
|
+
.optional()
|
|
150
|
+
.describe("Search term to filter parameters by name or ID."),
|
|
151
|
+
type: z
|
|
152
|
+
.enum(["plugin", "state"])
|
|
153
|
+
.optional()
|
|
154
|
+
.describe("Filter by parameter type: 'plugin' (DAW-automatable) or 'state' (internal)."),
|
|
155
|
+
},
|
|
156
|
+
annotations: { readOnlyHint: true },
|
|
157
|
+
}, async ({ paramId, cell, effect, search, type }) => {
|
|
158
|
+
const index = await getParameterIndex();
|
|
159
|
+
if (paramId) {
|
|
160
|
+
const p = index.get(paramId);
|
|
161
|
+
if (!p)
|
|
162
|
+
return error(`Unknown parameter: ${paramId}`);
|
|
163
|
+
return json(p);
|
|
164
|
+
}
|
|
165
|
+
let params;
|
|
166
|
+
if (cell) {
|
|
167
|
+
params = await getParametersByCell(cell);
|
|
168
|
+
}
|
|
169
|
+
else if (effect) {
|
|
170
|
+
const manifest = await loadManifest();
|
|
171
|
+
params = manifest.parameters.filter((p) => p.effect === effect);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
const manifest = await loadManifest();
|
|
175
|
+
params = manifest.parameters;
|
|
176
|
+
}
|
|
177
|
+
if (type) {
|
|
178
|
+
params = params.filter((p) => type === "plugin" ? p.isPluginParameter : !p.isPluginParameter);
|
|
179
|
+
}
|
|
180
|
+
if (search) {
|
|
181
|
+
const s = search.toLowerCase();
|
|
182
|
+
params = params.filter((p) => p.paramId.toLowerCase().includes(s) ||
|
|
183
|
+
p.displayName.toLowerCase().includes(s) ||
|
|
184
|
+
p.name.toLowerCase().includes(s) ||
|
|
185
|
+
(p.tooltip && p.tooltip.toLowerCase().includes(s)));
|
|
186
|
+
}
|
|
187
|
+
if (params.length === 0)
|
|
188
|
+
return text("No parameters found matching criteria.");
|
|
189
|
+
if (params.length > 50) {
|
|
190
|
+
return text(`${params.length} parameters found. Showing summary:\n\n` +
|
|
191
|
+
params.map(formatParam).join("\n"));
|
|
192
|
+
}
|
|
193
|
+
return json(params);
|
|
194
|
+
});
|
|
195
|
+
server.registerTool("list_presets", {
|
|
196
|
+
title: "List Presets",
|
|
197
|
+
description: "List available PAM presets (factory and user).",
|
|
198
|
+
inputSchema: {
|
|
199
|
+
search: z.string().optional().describe("Filter presets by name."),
|
|
200
|
+
},
|
|
201
|
+
annotations: { readOnlyHint: true },
|
|
202
|
+
}, async ({ search }) => {
|
|
203
|
+
const dirs = await findPresetDirs();
|
|
204
|
+
if (dirs.length === 0)
|
|
205
|
+
return text("No preset directories found.");
|
|
206
|
+
let allPresets = [];
|
|
207
|
+
for (const dir of dirs) {
|
|
208
|
+
const presets = await walkPresets(dir);
|
|
209
|
+
allPresets.push(...presets);
|
|
210
|
+
}
|
|
211
|
+
if (search) {
|
|
212
|
+
const s = search.toLowerCase();
|
|
213
|
+
allPresets = allPresets.filter((p) => p.name.toLowerCase().includes(s));
|
|
214
|
+
}
|
|
215
|
+
if (allPresets.length === 0)
|
|
216
|
+
return text("No presets found.");
|
|
217
|
+
return json(allPresets.map((p) => ({ name: p.name, path: p.relativePath })));
|
|
218
|
+
});
|
|
219
|
+
server.registerTool("read_preset", {
|
|
220
|
+
title: "Read Preset",
|
|
221
|
+
description: "Read a PAM preset file and return its state. Useful for understanding " +
|
|
222
|
+
"what parameters a preset uses, what samples it references, etc.",
|
|
223
|
+
inputSchema: {
|
|
224
|
+
path: z.string().describe("Preset file path (absolute or relative to preset dirs)."),
|
|
225
|
+
summary: z
|
|
226
|
+
.boolean()
|
|
227
|
+
.optional()
|
|
228
|
+
.describe("If true, return only metadata + parameter summary instead of full state."),
|
|
229
|
+
},
|
|
230
|
+
annotations: { readOnlyHint: true },
|
|
231
|
+
}, async ({ path: presetPath, summary }) => {
|
|
232
|
+
const fullPath = await resolvePresetPath(presetPath);
|
|
233
|
+
try {
|
|
234
|
+
const raw = await readFile(fullPath, "utf-8");
|
|
235
|
+
const { metadata, data } = parsePresetFile(raw);
|
|
236
|
+
if (summary) {
|
|
237
|
+
const cells = {};
|
|
238
|
+
if (data.cells) {
|
|
239
|
+
for (const [id, cell] of Object.entries(data.cells)) {
|
|
240
|
+
cells[id] = cell.filePath || "(empty)";
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return json({
|
|
244
|
+
name: metadata.name || basename(fullPath, ".preset"),
|
|
245
|
+
author: metadata.author,
|
|
246
|
+
category: metadata.category,
|
|
247
|
+
subcategory: metadata.subcategory,
|
|
248
|
+
bpm: data.bpm || metadata.bpm,
|
|
249
|
+
tags: metadata.tags,
|
|
250
|
+
description: metadata.description,
|
|
251
|
+
cells,
|
|
252
|
+
hasLFOs: (data.lfos || []).filter((l) => l.enabled).length,
|
|
253
|
+
hasEnvelopes: (data.envelopes || []).filter((e) => e.enabled).length,
|
|
254
|
+
hasMacros: (data.macros || []).filter((m) => m.enabled).length,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return json({ metadata, ...data });
|
|
258
|
+
}
|
|
259
|
+
catch (e) {
|
|
260
|
+
return error(`Failed to read preset: ${e.message}`);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
server.registerTool("get_manifest_summary", {
|
|
264
|
+
title: "Get Manifest Summary",
|
|
265
|
+
description: "Get a high-level summary of PAM's capabilities: number of cells, effects, " +
|
|
266
|
+
"modulators, and parameter counts.",
|
|
267
|
+
inputSchema: {},
|
|
268
|
+
annotations: { readOnlyHint: true },
|
|
269
|
+
}, async () => {
|
|
270
|
+
const manifest = await loadManifest();
|
|
271
|
+
const params = manifest.parameters;
|
|
272
|
+
const cells = new Set(params.filter((p) => p.cell).map((p) => p.cell));
|
|
273
|
+
const effects = new Set(params.filter((p) => p.effect).map((p) => p.effect));
|
|
274
|
+
const global = params.filter((p) => !p.cell);
|
|
275
|
+
const modulatable = params.filter((p) => p.isModulatable);
|
|
276
|
+
return json({
|
|
277
|
+
product: manifest.name,
|
|
278
|
+
company: manifest.company,
|
|
279
|
+
totalParameters: params.length,
|
|
280
|
+
cells: [...cells],
|
|
281
|
+
effects: [...effects],
|
|
282
|
+
globalParameters: global.length,
|
|
283
|
+
modulatableParameters: modulatable.length,
|
|
284
|
+
pluginParameters: params.filter((p) => p.isPluginParameter).length,
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
// Standard full update — graph dispatch + KV sync + parameter updates.
|
|
288
|
+
const KV_OPTS_STANDARD = {
|
|
289
|
+
shouldDispatch: true,
|
|
290
|
+
syncKVResources: true,
|
|
291
|
+
syncConfigResources: true,
|
|
292
|
+
resyncKVAfterDispatch: true,
|
|
293
|
+
updateParameters: true,
|
|
294
|
+
broadcastFollowers: true,
|
|
295
|
+
};
|
|
296
|
+
// Modulation update — same as standard but no follower broadcast.
|
|
297
|
+
const KV_OPTS_MOD = {
|
|
298
|
+
shouldDispatch: true,
|
|
299
|
+
syncKVResources: true,
|
|
300
|
+
syncConfigResources: true,
|
|
301
|
+
resyncKVAfterDispatch: true,
|
|
302
|
+
updateParameters: true,
|
|
303
|
+
broadcastFollowers: false,
|
|
304
|
+
};
|
|
305
|
+
// Pattern/sequencer update — KV-only, no graph rebuild, no param updates.
|
|
306
|
+
const KV_OPTS_PATTERN = {
|
|
307
|
+
shouldDispatch: true,
|
|
308
|
+
syncKVResources: true,
|
|
309
|
+
syncConfigResources: false,
|
|
310
|
+
skipFileLoadingIfUnchanged: true,
|
|
311
|
+
resyncKVAfterDispatch: false,
|
|
312
|
+
updateParameters: false,
|
|
313
|
+
broadcastFollowers: false,
|
|
314
|
+
};
|
|
315
|
+
// Effect config toggle — lighter KV path, no graph dispatch.
|
|
316
|
+
const KV_OPTS_EFFECT_CONFIG = {
|
|
317
|
+
shouldDispatch: false,
|
|
318
|
+
syncKVResources: true,
|
|
319
|
+
syncConfigResources: true,
|
|
320
|
+
skipFileLoadingIfUnchanged: true,
|
|
321
|
+
resyncKVAfterDispatch: false,
|
|
322
|
+
updateParameters: true,
|
|
323
|
+
broadcastFollowers: true,
|
|
324
|
+
};
|
|
325
|
+
// Cell config update — KV-only, syncs cell config resources, no graph dispatch.
|
|
326
|
+
// Matches interop.ts updateCellConfig() options.
|
|
327
|
+
const KV_OPTS_CELL_CONFIG = {
|
|
328
|
+
shouldDispatch: false,
|
|
329
|
+
syncKVResources: true,
|
|
330
|
+
syncConfigResources: true,
|
|
331
|
+
skipFileLoadingIfUnchanged: true,
|
|
332
|
+
resyncKVAfterDispatch: false,
|
|
333
|
+
updateParameters: true,
|
|
334
|
+
broadcastFollowers: false,
|
|
335
|
+
};
|
|
336
|
+
async function applyKv(delta, options, directNodeOps) {
|
|
337
|
+
const payload = { delta, options };
|
|
338
|
+
if (directNodeOps)
|
|
339
|
+
payload.directNodeOps = directNodeOps;
|
|
340
|
+
return bridge.send("applyUnifiedStateKvUpdate", payload);
|
|
341
|
+
}
|
|
342
|
+
server.registerTool("get_state", {
|
|
343
|
+
title: "Get Plugin State",
|
|
344
|
+
description: "Read the current full state of the running PAM plugin. " +
|
|
345
|
+
"Returns all parameter values, cell configs, modulation, and sequences.",
|
|
346
|
+
inputSchema: {
|
|
347
|
+
section: z
|
|
348
|
+
.enum(["full", "cells", "lfos", "macros", "envelopes", "vary", "paramSeqs"])
|
|
349
|
+
.optional()
|
|
350
|
+
.describe("Return only a specific section of state. Defaults to 'full'. Use get_transport for transport data."),
|
|
351
|
+
},
|
|
352
|
+
annotations: { readOnlyHint: true },
|
|
353
|
+
}, async ({ section }) => {
|
|
354
|
+
const check = await requireBridge();
|
|
355
|
+
if (check !== true)
|
|
356
|
+
return check;
|
|
357
|
+
if (section && section !== "full") {
|
|
358
|
+
const result = await bridge.send("getState", section);
|
|
359
|
+
return json(result ?? `Section '${section}' not found in state.`);
|
|
360
|
+
}
|
|
361
|
+
const state = await bridge.send("getState");
|
|
362
|
+
return json(state);
|
|
363
|
+
});
|
|
364
|
+
server.registerTool("set_parameters", {
|
|
365
|
+
title: "Set Parameters",
|
|
366
|
+
description: "Set one or more PAM parameters. Values are validated against the manifest. " +
|
|
367
|
+
"Use get_parameter_info first to discover valid parameter IDs and ranges.",
|
|
368
|
+
inputSchema: {
|
|
369
|
+
parameters: z
|
|
370
|
+
.record(z.string(), z.number())
|
|
371
|
+
.describe("Map of paramId to value. Example: { 'cell1_filterCutoff': 5000, 'cell1_filterQ': 0.7 }"),
|
|
372
|
+
},
|
|
373
|
+
annotations: { destructiveHint: false, idempotentHint: true },
|
|
374
|
+
}, async ({ parameters }) => {
|
|
375
|
+
// Validate against manifest
|
|
376
|
+
const index = await getParameterIndex();
|
|
377
|
+
const errors = [];
|
|
378
|
+
for (const [paramId, value] of Object.entries(parameters)) {
|
|
379
|
+
const p = index.get(paramId);
|
|
380
|
+
if (!p) {
|
|
381
|
+
errors.push(`Unknown parameter: ${paramId}`);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (value < p.min || value > p.max) {
|
|
385
|
+
errors.push(`${paramId}: ${value} out of range [${p.min}..${p.max}]`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (errors.length > 0)
|
|
389
|
+
return error(errors.join("\n"));
|
|
390
|
+
const check = await requireBridge();
|
|
391
|
+
if (check !== true)
|
|
392
|
+
return check;
|
|
393
|
+
// Auto-enable macros when setting macro_* parameters (matches UI ensureMacroEnabled)
|
|
394
|
+
const macroParams = Object.keys(parameters).filter((k) => k.startsWith("macro_"));
|
|
395
|
+
const delta = { ...parameters };
|
|
396
|
+
if (macroParams.length > 0) {
|
|
397
|
+
const macrosRaw = await bridge.send("getState", "macros");
|
|
398
|
+
const macros = Array.isArray(macrosRaw) ? [...macrosRaw] : [];
|
|
399
|
+
for (const macroId of macroParams) {
|
|
400
|
+
const idx = macros.findIndex((m) => m.paramId === macroId);
|
|
401
|
+
if (idx !== -1) {
|
|
402
|
+
if (macros[idx].enabled !== 1)
|
|
403
|
+
macros[idx] = { ...macros[idx], enabled: 1 };
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
macros.push({ paramId: macroId, enabled: 1, strength: 0.5, bipolar: 0, mode: 0 });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
delta.macros = macros;
|
|
410
|
+
}
|
|
411
|
+
await bridge.send("applyUnifiedStateKvUpdate", {
|
|
412
|
+
delta,
|
|
413
|
+
options: {
|
|
414
|
+
shouldDispatch: true,
|
|
415
|
+
syncKVResources: true,
|
|
416
|
+
syncConfigResources: true,
|
|
417
|
+
resyncKVAfterDispatch: true,
|
|
418
|
+
updateParameters: true,
|
|
419
|
+
broadcastFollowers: true,
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
return text(`Set ${Object.keys(parameters).length} parameter(s): ${Object.entries(parameters).map(([k, v]) => `${k}=${v}`).join(", ")}`);
|
|
423
|
+
});
|
|
424
|
+
server.registerTool("load_sample", {
|
|
425
|
+
title: "Load Sample",
|
|
426
|
+
description: "Load an audio file (wav, mp3, aif, flac) into a PAM cell.",
|
|
427
|
+
inputSchema: {
|
|
428
|
+
filePath: z.string().describe("Path to the audio file (absolute, or relative as returned by list_samples)."),
|
|
429
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
430
|
+
},
|
|
431
|
+
}, async ({ filePath, cellId }) => {
|
|
432
|
+
const check = await requireBridge();
|
|
433
|
+
if (check !== true)
|
|
434
|
+
return check;
|
|
435
|
+
await bridge.send("loadSampleToCell", filePath, cellId);
|
|
436
|
+
return text(`Loaded ${basename(filePath)} into cell ${cellId}.`);
|
|
437
|
+
});
|
|
438
|
+
server.registerTool("trigger_midi", {
|
|
439
|
+
title: "Trigger MIDI",
|
|
440
|
+
description: "Send a MIDI note to a specific cell or globally. " +
|
|
441
|
+
"When cellId is omitted, the note is routed through the same MIDI-mapping " +
|
|
442
|
+
"logic the DAW uses — cells with matching note + channel mappings receive it. " +
|
|
443
|
+
"Use for previewing sounds or triggering one-shots.",
|
|
444
|
+
inputSchema: {
|
|
445
|
+
cellId: z.number().min(1).max(8).optional().describe("Target cell (1-8). Omit to route globally via MIDI mappings."),
|
|
446
|
+
note: z.number().min(0).max(127).default(60).describe("MIDI note number (0-127)."),
|
|
447
|
+
noteOn: z.boolean().default(true).describe("true for note on, false for note off."),
|
|
448
|
+
channel: z.number().min(1).max(16).default(1).describe("MIDI channel (1-16)."),
|
|
449
|
+
},
|
|
450
|
+
}, async ({ cellId, note, noteOn, channel }) => {
|
|
451
|
+
const check = await requireBridge();
|
|
452
|
+
if (check !== true)
|
|
453
|
+
return check;
|
|
454
|
+
if (cellId) {
|
|
455
|
+
await bridge.send("cellMidi", cellId, note, channel, noteOn);
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
await bridge.send("midi", note, channel, noteOn);
|
|
459
|
+
}
|
|
460
|
+
return text(`MIDI ${noteOn ? "ON" : "OFF"} note=${note} ${cellId ? `cell=${cellId}` : "global"}`);
|
|
461
|
+
});
|
|
462
|
+
server.registerTool("stop_cell", {
|
|
463
|
+
title: "Stop Cell",
|
|
464
|
+
description: "Stop playback of a specific cell.",
|
|
465
|
+
inputSchema: {
|
|
466
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
467
|
+
},
|
|
468
|
+
}, async ({ cellId }) => {
|
|
469
|
+
const check = await requireBridge();
|
|
470
|
+
if (check !== true)
|
|
471
|
+
return check;
|
|
472
|
+
await bridge.send("stopCell", cellId);
|
|
473
|
+
return text(`Stopped cell ${cellId}.`);
|
|
474
|
+
});
|
|
475
|
+
server.registerTool("load_preset", {
|
|
476
|
+
title: "Load Preset",
|
|
477
|
+
description: "Load a preset into the running PAM plugin.",
|
|
478
|
+
inputSchema: {
|
|
479
|
+
path: z.string().describe("Preset file path (absolute or relative to preset dirs)."),
|
|
480
|
+
},
|
|
481
|
+
}, async ({ path: presetPath }) => {
|
|
482
|
+
const check = await requireBridge();
|
|
483
|
+
if (check !== true)
|
|
484
|
+
return check;
|
|
485
|
+
const fullPath = await resolvePresetPath(presetPath);
|
|
486
|
+
await bridge.send("loadPreset", fullPath);
|
|
487
|
+
return text(`Loaded preset: ${basename(fullPath, ".preset")}`);
|
|
488
|
+
});
|
|
489
|
+
server.registerTool("add_modulation", {
|
|
490
|
+
title: "Add Modulation",
|
|
491
|
+
description: "Add a modulation link (LFO, envelope follower, or vary) to a parameter. " +
|
|
492
|
+
"The parameter must be modulatable (check with get_parameter_info).",
|
|
493
|
+
inputSchema: {
|
|
494
|
+
type: z.enum(["lfo", "envelope", "vary"]).describe("Modulation type."),
|
|
495
|
+
paramId: z.string().describe("Target parameter ID (e.g. 'cell1_filterCutoff')."),
|
|
496
|
+
cellId: z
|
|
497
|
+
.number()
|
|
498
|
+
.min(1)
|
|
499
|
+
.max(8)
|
|
500
|
+
.optional()
|
|
501
|
+
.describe("Cell ID for envelope/vary sources (1-8)."),
|
|
502
|
+
lfoIndex: z
|
|
503
|
+
.number()
|
|
504
|
+
.min(0)
|
|
505
|
+
.max(5)
|
|
506
|
+
.optional()
|
|
507
|
+
.describe("LFO generator index (0-5). Required for LFO type."),
|
|
508
|
+
strength: z
|
|
509
|
+
.number()
|
|
510
|
+
.min(0)
|
|
511
|
+
.max(100)
|
|
512
|
+
.default(50)
|
|
513
|
+
.describe("Modulation depth (0-100)."),
|
|
514
|
+
bipolar: z
|
|
515
|
+
.boolean()
|
|
516
|
+
.default(false)
|
|
517
|
+
.describe("Bipolar modulation (modulates both directions)."),
|
|
518
|
+
},
|
|
519
|
+
}, async ({ type, paramId, cellId, lfoIndex, strength, bipolar }) => {
|
|
520
|
+
const index = await getParameterIndex();
|
|
521
|
+
const param = index.get(paramId);
|
|
522
|
+
if (!param)
|
|
523
|
+
return error(`Unknown parameter: ${paramId}`);
|
|
524
|
+
if (!param.isModulatable)
|
|
525
|
+
return error(`Parameter '${paramId}' is not modulatable.`);
|
|
526
|
+
const check = await requireBridge();
|
|
527
|
+
if (check !== true)
|
|
528
|
+
return check;
|
|
529
|
+
// Compute effect-enabled params to auto-enable disabled effects (mirrors UI behavior)
|
|
530
|
+
const effectEnableParams = {};
|
|
531
|
+
if (param.effect && param.cell) {
|
|
532
|
+
effectEnableParams[`${param.cell}_${param.effect}Enabled`] = 1;
|
|
533
|
+
}
|
|
534
|
+
if (type === "lfo") {
|
|
535
|
+
if (lfoIndex === undefined)
|
|
536
|
+
return error("lfoIndex is required for LFO modulation.");
|
|
537
|
+
await bridge.send("addLFO", paramId, lfoIndex, strength, bipolar, effectEnableParams);
|
|
538
|
+
}
|
|
539
|
+
else if (type === "envelope") {
|
|
540
|
+
if (!cellId)
|
|
541
|
+
return error("cellId is required for envelope modulation.");
|
|
542
|
+
await bridge.send("addEnvelope", paramId, cellId, strength, bipolar, effectEnableParams);
|
|
543
|
+
}
|
|
544
|
+
else if (type === "vary") {
|
|
545
|
+
if (!cellId)
|
|
546
|
+
return error("cellId is required for vary modulation.");
|
|
547
|
+
await bridge.send("addVary", paramId, cellId, strength, bipolar, effectEnableParams);
|
|
548
|
+
}
|
|
549
|
+
return text(`Added ${type} modulation to ${paramId} (strength: ${strength}%).`);
|
|
550
|
+
});
|
|
551
|
+
server.registerTool("update_pattern", {
|
|
552
|
+
title: "Update Pattern",
|
|
553
|
+
description: "Write a note, pitch, slice, or stretch sequence to a cell's pattern sequencer.",
|
|
554
|
+
inputSchema: {
|
|
555
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
556
|
+
type: z
|
|
557
|
+
.enum(["note", "pitch", "slice", "stretch"])
|
|
558
|
+
.describe("Pattern lane type."),
|
|
559
|
+
sequence: z
|
|
560
|
+
.array(z.any())
|
|
561
|
+
.describe("Array of sequence events. For 'note': [{time, note, duration, velocity, prob}]. " +
|
|
562
|
+
"For 'pitch'/'slice'/'stretch': [{time, value, duration}]."),
|
|
563
|
+
},
|
|
564
|
+
}, async ({ cellId, type, sequence }) => {
|
|
565
|
+
const check = await requireBridge();
|
|
566
|
+
if (check !== true)
|
|
567
|
+
return check;
|
|
568
|
+
// Map pattern type to state key suffix
|
|
569
|
+
const keyMap = {
|
|
570
|
+
note: "midiSequence",
|
|
571
|
+
midi: "midiSequence",
|
|
572
|
+
pitch: "pitchSequence",
|
|
573
|
+
slice: "sliceSequence",
|
|
574
|
+
stretch: "stretchSequence",
|
|
575
|
+
};
|
|
576
|
+
const suffix = keyMap[type];
|
|
577
|
+
if (!suffix)
|
|
578
|
+
return error(`Unknown pattern type: ${type}`);
|
|
579
|
+
// For note sequences, fill in required defaults that PAM's sequencer expects
|
|
580
|
+
let normalizedSequence = sequence;
|
|
581
|
+
if (suffix === "midiSequence") {
|
|
582
|
+
// Default MIDI note for each cell (C2=48 + cellIndex)
|
|
583
|
+
const defaultNote = 47 + cellId;
|
|
584
|
+
normalizedSequence = sequence.map((evt) => ({
|
|
585
|
+
channel: 1,
|
|
586
|
+
prob: 1,
|
|
587
|
+
randVel: 0,
|
|
588
|
+
retrigger: 0,
|
|
589
|
+
retriggerDelay: 0.125,
|
|
590
|
+
retriggerProb: 1,
|
|
591
|
+
retriggerVelocity: 1,
|
|
592
|
+
duration: 0.25,
|
|
593
|
+
note: defaultNote,
|
|
594
|
+
time: 0,
|
|
595
|
+
velocity: 127,
|
|
596
|
+
...evt,
|
|
597
|
+
// Ensure 'value' mirrors velocity if not provided
|
|
598
|
+
value: evt.value ?? evt.velocity ?? 127,
|
|
599
|
+
}));
|
|
600
|
+
}
|
|
601
|
+
const stateKey = `cell${cellId}_${suffix}`;
|
|
602
|
+
await bridge.send("applyUnifiedStateKvUpdate", {
|
|
603
|
+
delta: { [stateKey]: normalizedSequence, __forceRender__: true },
|
|
604
|
+
options: {
|
|
605
|
+
shouldDispatch: true,
|
|
606
|
+
syncKVResources: true,
|
|
607
|
+
syncConfigResources: false,
|
|
608
|
+
skipFileLoadingIfUnchanged: true,
|
|
609
|
+
resyncKVAfterDispatch: false,
|
|
610
|
+
updateParameters: false,
|
|
611
|
+
broadcastFollowers: false,
|
|
612
|
+
},
|
|
613
|
+
directNodeOps: [{ key: stateKey, property: "sequence", value: normalizedSequence }],
|
|
614
|
+
});
|
|
615
|
+
return text(`Updated ${type} pattern for cell ${cellId} (${normalizedSequence.length} events).`);
|
|
616
|
+
});
|
|
617
|
+
server.registerTool("randomize", {
|
|
618
|
+
title: "Randomize",
|
|
619
|
+
description: "Trigger PAM's randomization engine to generate random sounds and patterns.",
|
|
620
|
+
inputSchema: {
|
|
621
|
+
target: z
|
|
622
|
+
.enum(["full", "samples", "sequences"])
|
|
623
|
+
.default("full")
|
|
624
|
+
.describe("What to randomize."),
|
|
625
|
+
cellIds: z
|
|
626
|
+
.array(z.number().min(1).max(8))
|
|
627
|
+
.optional()
|
|
628
|
+
.describe("Specific cells to randomize. Omit for all."),
|
|
629
|
+
genreHint: z
|
|
630
|
+
.string()
|
|
631
|
+
.optional()
|
|
632
|
+
.describe("Genre hint to guide sample selection (e.g. 'techno', 'hip-hop')."),
|
|
633
|
+
bpmHint: z
|
|
634
|
+
.number()
|
|
635
|
+
.min(20)
|
|
636
|
+
.max(999)
|
|
637
|
+
.optional()
|
|
638
|
+
.describe("BPM hint to influence pattern generation."),
|
|
639
|
+
mutationAmount: z
|
|
640
|
+
.number()
|
|
641
|
+
.min(0)
|
|
642
|
+
.max(1)
|
|
643
|
+
.optional()
|
|
644
|
+
.describe("Mutation amount for pattern randomization (0-1). Lower = subtler changes."),
|
|
645
|
+
},
|
|
646
|
+
}, async ({ target, cellIds, genreHint, bpmHint, mutationAmount }) => {
|
|
647
|
+
const check = await requireBridge();
|
|
648
|
+
if (check !== true)
|
|
649
|
+
return check;
|
|
650
|
+
await bridge.send("randomize", target, cellIds ?? [], genreHint ?? "", bpmHint ?? -1, mutationAmount ?? -1);
|
|
651
|
+
return text(`Randomized ${target}${cellIds ? ` for cells ${cellIds.join(", ")}` : ""}.`);
|
|
652
|
+
});
|
|
653
|
+
server.registerTool("randomize_samples", {
|
|
654
|
+
title: "Randomize Samples",
|
|
655
|
+
description: "Use PAM's native sample randomization engine to pick coherent samples based on genre, BPM, and kit role. More musically coherent than manually browsing and loading samples. Supports folder filtering to constrain selection to specific sample packs.",
|
|
656
|
+
inputSchema: {
|
|
657
|
+
cellIds: z
|
|
658
|
+
.array(z.number().min(1).max(8))
|
|
659
|
+
.optional()
|
|
660
|
+
.describe("Specific cells to randomize. Omit for all cells."),
|
|
661
|
+
genreHint: z
|
|
662
|
+
.string()
|
|
663
|
+
.optional()
|
|
664
|
+
.describe("Genre hint to guide sample selection. Supported: techno, house, trap, boom_bap, drill, dnb, hip-hop, ambient, industrial, breaks."),
|
|
665
|
+
bpmHint: z
|
|
666
|
+
.number()
|
|
667
|
+
.min(20)
|
|
668
|
+
.max(999)
|
|
669
|
+
.optional()
|
|
670
|
+
.describe("BPM hint — samples near this tempo are preferred."),
|
|
671
|
+
excludedFolders: z
|
|
672
|
+
.array(z.string())
|
|
673
|
+
.optional()
|
|
674
|
+
.describe("Folder names to exclude from sample selection (e.g. ['Loops', 'Vocals'])."),
|
|
675
|
+
allowedSamplePaths: z
|
|
676
|
+
.array(z.string())
|
|
677
|
+
.optional()
|
|
678
|
+
.describe("If provided, only pick samples from these paths. Overrides default library scan."),
|
|
679
|
+
},
|
|
680
|
+
}, async ({ cellIds, genreHint, bpmHint, excludedFolders, allowedSamplePaths }) => {
|
|
681
|
+
const check = await requireBridge();
|
|
682
|
+
if (check !== true)
|
|
683
|
+
return check;
|
|
684
|
+
await bridge.send("randomizeSamples", cellIds ?? [], genreHint ?? "", bpmHint ?? -1, excludedFolders ?? [], allowedSamplePaths ?? []);
|
|
685
|
+
return text(`Randomized samples${cellIds ? ` for cells ${cellIds.join(", ")}` : " for all cells"}${genreHint ? ` (genre: ${genreHint})` : ""}.`);
|
|
686
|
+
});
|
|
687
|
+
server.registerTool("randomize_patterns", {
|
|
688
|
+
title: "Randomize Patterns",
|
|
689
|
+
description: "Use PAM's native pattern generation engine to create musically coherent note patterns based on genre and BPM. Analyzes loaded samples to determine roles (kick, snare, hat, etc.) and generates appropriate rhythms.",
|
|
690
|
+
inputSchema: {
|
|
691
|
+
cellIds: z
|
|
692
|
+
.array(z.number().min(1).max(8))
|
|
693
|
+
.optional()
|
|
694
|
+
.describe("Specific cells to randomize patterns for. Omit for all cells."),
|
|
695
|
+
genreHint: z
|
|
696
|
+
.string()
|
|
697
|
+
.optional()
|
|
698
|
+
.describe("Genre hint to guide pattern generation (e.g. 'techno', 'hip-hop', 'dnb')."),
|
|
699
|
+
bpmHint: z
|
|
700
|
+
.number()
|
|
701
|
+
.min(20)
|
|
702
|
+
.max(999)
|
|
703
|
+
.optional()
|
|
704
|
+
.describe("BPM hint to influence pattern timing and density."),
|
|
705
|
+
mutationAmount: z
|
|
706
|
+
.number()
|
|
707
|
+
.min(0)
|
|
708
|
+
.max(1)
|
|
709
|
+
.optional()
|
|
710
|
+
.describe("How much to vary from typical patterns (0 = conventional, 1 = experimental). Default ~0.5."),
|
|
711
|
+
},
|
|
712
|
+
}, async ({ cellIds, genreHint, bpmHint, mutationAmount }) => {
|
|
713
|
+
const check = await requireBridge();
|
|
714
|
+
if (check !== true)
|
|
715
|
+
return check;
|
|
716
|
+
await bridge.send("randomize", "sequences", cellIds ?? [], genreHint ?? "", bpmHint ?? -1, mutationAmount ?? -1);
|
|
717
|
+
return text(`Randomized patterns${cellIds ? ` for cells ${cellIds.join(", ")}` : " for all cells"}${genreHint ? ` (genre: ${genreHint})` : ""}.`);
|
|
718
|
+
});
|
|
719
|
+
server.registerTool("load_variation", {
|
|
720
|
+
title: "Load Variation",
|
|
721
|
+
description: "Switch to a saved variation slot (0-7). Loads the stored state for that variation including parameters, effects, patterns, and modulation. The preset must have variations saved via save_variation first.",
|
|
722
|
+
inputSchema: {
|
|
723
|
+
index: z
|
|
724
|
+
.number()
|
|
725
|
+
.int()
|
|
726
|
+
.min(0)
|
|
727
|
+
.max(7)
|
|
728
|
+
.describe("Variation slot index (0-7). PAM UI shows these as slots 1-8."),
|
|
729
|
+
},
|
|
730
|
+
}, async ({ index }) => {
|
|
731
|
+
const check = await requireBridge();
|
|
732
|
+
if (check !== true)
|
|
733
|
+
return check;
|
|
734
|
+
await bridge.send("loadVariation", index);
|
|
735
|
+
return text(`Loaded variation ${index} (UI slot ${index + 1}).`);
|
|
736
|
+
});
|
|
737
|
+
server.registerTool("full_stop_transport", {
|
|
738
|
+
title: "Full Stop Transport",
|
|
739
|
+
description: "Stop transport and reset playhead position to zero. Unlike stop_transport (which pauses), this resets the position so playback starts from the beginning.",
|
|
740
|
+
inputSchema: {},
|
|
741
|
+
}, async () => {
|
|
742
|
+
const check = await requireBridge();
|
|
743
|
+
if (check !== true)
|
|
744
|
+
return check;
|
|
745
|
+
await bridge.send("fullStopTransport");
|
|
746
|
+
return text("Transport stopped and position reset to zero.");
|
|
747
|
+
});
|
|
748
|
+
server.registerTool("get_transport", {
|
|
749
|
+
title: "Get Transport",
|
|
750
|
+
description: "Get current transport state (BPM, playing, position).",
|
|
751
|
+
inputSchema: {},
|
|
752
|
+
annotations: { readOnlyHint: true },
|
|
753
|
+
}, async () => {
|
|
754
|
+
const check = await requireBridge();
|
|
755
|
+
if (check !== true)
|
|
756
|
+
return check;
|
|
757
|
+
const transport = await bridge.send("getTransport");
|
|
758
|
+
return json(transport);
|
|
759
|
+
});
|
|
760
|
+
server.registerTool("new_preset", {
|
|
761
|
+
title: "New Preset",
|
|
762
|
+
description: "Reset PAM to its initial/default state — equivalent to creating a new empty preset. " +
|
|
763
|
+
"All cells, modulation, sequences, and effects are cleared.",
|
|
764
|
+
inputSchema: {},
|
|
765
|
+
annotations: { destructiveHint: true },
|
|
766
|
+
}, async () => {
|
|
767
|
+
const check = await requireBridge();
|
|
768
|
+
if (check !== true)
|
|
769
|
+
return check;
|
|
770
|
+
await bridge.send("resetToInitialState");
|
|
771
|
+
return text("Reset to initial state (new preset).");
|
|
772
|
+
});
|
|
773
|
+
server.registerTool("save_preset", {
|
|
774
|
+
title: "Save Preset",
|
|
775
|
+
description: "Save the current PAM state as a new preset file with metadata. " +
|
|
776
|
+
"Writes to the user preset folder by default; pass `path` for a specific location. " +
|
|
777
|
+
"Use `update_preset_metadata` to edit an existing preset without re-saving plugin state.",
|
|
778
|
+
inputSchema: {
|
|
779
|
+
name: z.string().describe("Preset name (used for filename if `path` omitted)."),
|
|
780
|
+
author: z.string().optional().describe("Author name."),
|
|
781
|
+
category: z
|
|
782
|
+
.string()
|
|
783
|
+
.optional()
|
|
784
|
+
.describe("Category (e.g. 'Bass', 'Drums', 'Atmosphere', 'FX')."),
|
|
785
|
+
subcategory: z.string().optional().describe("Subcategory."),
|
|
786
|
+
description: z.string().optional().describe("Short description of the preset."),
|
|
787
|
+
tags: z.array(z.string()).optional().describe("Tags for searchability."),
|
|
788
|
+
bpm: z.number().optional().describe("BPM the preset was designed for."),
|
|
789
|
+
image: z
|
|
790
|
+
.string()
|
|
791
|
+
.optional()
|
|
792
|
+
.describe("Relative path to a visual/thumbnail saved via set_visual."),
|
|
793
|
+
path: z
|
|
794
|
+
.string()
|
|
795
|
+
.optional()
|
|
796
|
+
.describe("Relative preset path (e.g. 'Presets/User/MyFolder/MyPreset.preset'). " +
|
|
797
|
+
"Factory paths are rejected. Defaults to user preset folder + name + '.preset'."),
|
|
798
|
+
},
|
|
799
|
+
annotations: { destructiveHint: false },
|
|
800
|
+
}, async ({ name, path, ...rest }) => {
|
|
801
|
+
const check = await requireBridge();
|
|
802
|
+
if (check !== true)
|
|
803
|
+
return check;
|
|
804
|
+
const metadata = { name, factory: false };
|
|
805
|
+
if (path !== undefined)
|
|
806
|
+
metadata.path = path;
|
|
807
|
+
for (const [k, v] of Object.entries(rest)) {
|
|
808
|
+
if (v !== undefined)
|
|
809
|
+
metadata[k] = v;
|
|
810
|
+
}
|
|
811
|
+
// Match UI behavior: auto-save current state to variation slot 0 if no variations exist,
|
|
812
|
+
// otherwise update the current active variation slot.
|
|
813
|
+
const state = (await bridge.send("getState"));
|
|
814
|
+
const variationString = buildVariationString(state);
|
|
815
|
+
const presetRelPath = path || `User/${name}.preset`;
|
|
816
|
+
const existingVariations = await readExistingVariations(presetRelPath);
|
|
817
|
+
const hasVariations = existingVariations.some((v) => v !== null);
|
|
818
|
+
if (!hasVariations) {
|
|
819
|
+
existingVariations[0] = variationString;
|
|
820
|
+
metadata.currentVariation = 0;
|
|
821
|
+
}
|
|
822
|
+
else {
|
|
823
|
+
const currentSlot = state.currentVariation ?? 0;
|
|
824
|
+
if (currentSlot >= 0 && currentSlot < existingVariations.length) {
|
|
825
|
+
existingVariations[currentSlot] = variationString;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
metadata.variations = existingVariations;
|
|
829
|
+
const result = (await bridge.send("savePreset", metadata));
|
|
830
|
+
return text(`Saved preset: ${name}\nPath: ${result?.path || "(unknown path)"}`);
|
|
831
|
+
});
|
|
832
|
+
server.registerTool("save_variation", {
|
|
833
|
+
title: "Save Variation",
|
|
834
|
+
description: "Capture the current plugin state and save it to a variation slot (0-7). " +
|
|
835
|
+
"Use this to build presets with multiple variations — e.g., a build-up from minimal to full. " +
|
|
836
|
+
"Configure the plugin state first (load samples, set parameters, enable effects, add modulation), " +
|
|
837
|
+
"then call save_variation to snapshot it into a slot. The preset must have been saved at least once " +
|
|
838
|
+
"via save_preset before calling this, or pass a name to create it.",
|
|
839
|
+
inputSchema: {
|
|
840
|
+
index: z
|
|
841
|
+
.number()
|
|
842
|
+
.int()
|
|
843
|
+
.min(0)
|
|
844
|
+
.max(7)
|
|
845
|
+
.describe("Variation slot index (0-7)."),
|
|
846
|
+
name: z
|
|
847
|
+
.string()
|
|
848
|
+
.optional()
|
|
849
|
+
.describe("Preset name. Required if the preset hasn't been saved yet. " +
|
|
850
|
+
"If omitted, re-saves the currently active preset."),
|
|
851
|
+
author: z.string().optional().describe("Author name (default: from existing preset)."),
|
|
852
|
+
category: z.string().optional().describe("Category."),
|
|
853
|
+
subcategory: z.string().optional().describe("Subcategory."),
|
|
854
|
+
description: z.string().optional().describe("Short description."),
|
|
855
|
+
tags: z.array(z.string()).optional().describe("Tags."),
|
|
856
|
+
bpm: z.number().optional().describe("BPM."),
|
|
857
|
+
},
|
|
858
|
+
annotations: { destructiveHint: false },
|
|
859
|
+
}, async ({ index, name, ...rest }) => {
|
|
860
|
+
const check = await requireBridge();
|
|
861
|
+
if (check !== true)
|
|
862
|
+
return check;
|
|
863
|
+
// 1. Get current full state and build variation snapshot
|
|
864
|
+
const state = (await bridge.send("getState"));
|
|
865
|
+
const variationString = buildVariationString(state);
|
|
866
|
+
// 2. Determine preset name and disk path — needed to read existing variations and metadata
|
|
867
|
+
let presetName = name;
|
|
868
|
+
let diskPath;
|
|
869
|
+
const activePath = state.activePresetPath;
|
|
870
|
+
if (!presetName && activePath) {
|
|
871
|
+
const filename = activePath.split("/").pop() || "";
|
|
872
|
+
presetName = filename.replace(/\.preset$/, "");
|
|
873
|
+
// Use active path for disk lookup (preserves subfolder structure).
|
|
874
|
+
// Strip "Presets/" prefix if present — resolvePresetPath already prepends
|
|
875
|
+
// the presets base directory, so "Presets/User/X" would double up to
|
|
876
|
+
// ".../Presets/Presets/User/X" and silently fail to read back variations.
|
|
877
|
+
diskPath = activePath.startsWith("Presets/") ? activePath.slice("Presets/".length) : activePath;
|
|
878
|
+
}
|
|
879
|
+
if (!presetName) {
|
|
880
|
+
return text("Error: No preset name provided and no active preset loaded. " +
|
|
881
|
+
"Save the preset first with save_preset, or pass a name.");
|
|
882
|
+
}
|
|
883
|
+
if (!diskPath) {
|
|
884
|
+
diskPath = `User/${presetName}.preset`;
|
|
885
|
+
}
|
|
886
|
+
// 3. Read existing preset metadata and variations from disk
|
|
887
|
+
let existingMeta = {};
|
|
888
|
+
let variations = Array(8).fill(null);
|
|
889
|
+
try {
|
|
890
|
+
const fullPath = await resolvePresetPath(diskPath);
|
|
891
|
+
const raw = await readFile(fullPath, "utf-8");
|
|
892
|
+
const { metadata: m } = parsePresetFile(raw);
|
|
893
|
+
existingMeta = m;
|
|
894
|
+
if (m.variations && Array.isArray(m.variations) && m.variations.length === 8) {
|
|
895
|
+
variations = [...m.variations];
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
catch {
|
|
899
|
+
// New preset or unreadable — start fresh
|
|
900
|
+
}
|
|
901
|
+
// 4. Update the target slot
|
|
902
|
+
variations[index] = variationString;
|
|
903
|
+
// 5. Build metadata: merge existing (preserves author/tags/etc.) then override with caller args
|
|
904
|
+
const metadata = {
|
|
905
|
+
...existingMeta,
|
|
906
|
+
name: presetName,
|
|
907
|
+
factory: false,
|
|
908
|
+
variations,
|
|
909
|
+
currentVariation: index,
|
|
910
|
+
...Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined)),
|
|
911
|
+
};
|
|
912
|
+
// Write to the correct subfolder path
|
|
913
|
+
let resultSuffix = "";
|
|
914
|
+
if (activePath && !activePath.startsWith("Factory/")) {
|
|
915
|
+
metadata.path = activePath;
|
|
916
|
+
}
|
|
917
|
+
else if (activePath?.startsWith("Factory/")) {
|
|
918
|
+
// Cannot write back to factory location; savePreset will create User/{name}.preset
|
|
919
|
+
resultSuffix = "\n(Note: active preset is a factory preset — saved as new user preset)";
|
|
920
|
+
}
|
|
921
|
+
const result = (await bridge.send("savePreset", metadata));
|
|
922
|
+
return text(`Saved variation ${index} for "${presetName}"\n` +
|
|
923
|
+
`Path: ${result?.path || "(unknown path)"}\n` +
|
|
924
|
+
`Filled slots: ${variations.filter((v) => v !== null).length}/8` +
|
|
925
|
+
resultSuffix);
|
|
926
|
+
});
|
|
927
|
+
server.registerTool("update_preset_metadata", {
|
|
928
|
+
title: "Update Preset Metadata",
|
|
929
|
+
description: "Update metadata fields on an existing preset file without touching its DSP state or " +
|
|
930
|
+
"the currently loaded plugin. Merges the provided fields into the preset's metadata on " +
|
|
931
|
+
"disk. Returns {success, newPath?} on success, or {conflict, existingPath} if a rename " +
|
|
932
|
+
"would collide with another preset. Factory presets cannot be modified.",
|
|
933
|
+
inputSchema: {
|
|
934
|
+
path: z
|
|
935
|
+
.string()
|
|
936
|
+
.describe("Relative preset path (e.g. 'Presets/User/MyPreset.preset'), as returned by list_presets or save_preset."),
|
|
937
|
+
name: z.string().optional().describe("New preset name (renames the file on disk)."),
|
|
938
|
+
author: z.string().optional(),
|
|
939
|
+
category: z.string().optional(),
|
|
940
|
+
subcategory: z.string().optional(),
|
|
941
|
+
description: z.string().optional(),
|
|
942
|
+
tags: z.array(z.string()).optional(),
|
|
943
|
+
bpm: z.number().optional(),
|
|
944
|
+
image: z
|
|
945
|
+
.string()
|
|
946
|
+
.optional()
|
|
947
|
+
.describe("Relative path to a visual/thumbnail saved via set_visual."),
|
|
948
|
+
},
|
|
949
|
+
}, async ({ path: presetPath, ...updates }) => {
|
|
950
|
+
const check = await requireBridge();
|
|
951
|
+
if (check !== true)
|
|
952
|
+
return check;
|
|
953
|
+
const delta = {};
|
|
954
|
+
for (const [k, v] of Object.entries(updates)) {
|
|
955
|
+
if (v !== undefined)
|
|
956
|
+
delta[k] = v;
|
|
957
|
+
}
|
|
958
|
+
if (Object.keys(delta).length === 0) {
|
|
959
|
+
return error("At least one metadata field must be provided.");
|
|
960
|
+
}
|
|
961
|
+
const fullPath = await resolvePresetPath(presetPath);
|
|
962
|
+
const raw = await bridge.send("updatePresetMetadata", fullPath, delta);
|
|
963
|
+
// A well-formed response is the parsed object; a string indicates an argument-level
|
|
964
|
+
// error returned by the bridge envelope (e.g. "error: expected array").
|
|
965
|
+
if (typeof raw === "string") {
|
|
966
|
+
return error(`Bridge error: ${raw}`);
|
|
967
|
+
}
|
|
968
|
+
const parsed = (raw ?? {});
|
|
969
|
+
if (parsed.conflict) {
|
|
970
|
+
return error(`Rename would collide with an existing preset at ${parsed.existingPath}. Pick a different name.`);
|
|
971
|
+
}
|
|
972
|
+
if (parsed.success !== true) {
|
|
973
|
+
return error(`Failed to update preset metadata: ${parsed.error || JSON.stringify(parsed)}`);
|
|
974
|
+
}
|
|
975
|
+
const changed = Object.keys(delta).join(", ");
|
|
976
|
+
return text(`Updated preset metadata (${changed}).\nPath: ${parsed.newPath || presetPath}`);
|
|
977
|
+
});
|
|
978
|
+
server.registerTool("start_transport", {
|
|
979
|
+
title: "Start Transport",
|
|
980
|
+
description: "Start PAM's internal transport (begins sequencer playback).",
|
|
981
|
+
inputSchema: {},
|
|
982
|
+
}, async () => {
|
|
983
|
+
const check = await requireBridge();
|
|
984
|
+
if (check !== true)
|
|
985
|
+
return check;
|
|
986
|
+
await bridge.send("startTransport");
|
|
987
|
+
return text("Transport started.");
|
|
988
|
+
});
|
|
989
|
+
server.registerTool("stop_transport", {
|
|
990
|
+
title: "Stop Transport",
|
|
991
|
+
description: "Stop PAM's internal transport (stops sequencer playback).",
|
|
992
|
+
inputSchema: {},
|
|
993
|
+
}, async () => {
|
|
994
|
+
const check = await requireBridge();
|
|
995
|
+
if (check !== true)
|
|
996
|
+
return check;
|
|
997
|
+
await bridge.send("stopTransport");
|
|
998
|
+
return text("Transport stopped.");
|
|
999
|
+
});
|
|
1000
|
+
server.registerTool("set_bpm", {
|
|
1001
|
+
title: "Set BPM",
|
|
1002
|
+
description: "Set PAM's internal transport BPM.",
|
|
1003
|
+
inputSchema: {
|
|
1004
|
+
bpm: z.number().min(20).max(999).describe("BPM value (20-999)."),
|
|
1005
|
+
},
|
|
1006
|
+
}, async ({ bpm }) => {
|
|
1007
|
+
const check = await requireBridge();
|
|
1008
|
+
if (check !== true)
|
|
1009
|
+
return check;
|
|
1010
|
+
await bridge.send("updateTransportBpm", bpm);
|
|
1011
|
+
return text(`BPM set to ${bpm}.`);
|
|
1012
|
+
});
|
|
1013
|
+
server.registerTool("trim_sample", {
|
|
1014
|
+
title: "Trim Sample",
|
|
1015
|
+
description: "Trim a cell's sample to its start/end markers (non-destructive crop).",
|
|
1016
|
+
inputSchema: {
|
|
1017
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
1018
|
+
},
|
|
1019
|
+
}, async ({ cellId }) => {
|
|
1020
|
+
const check = await requireBridge();
|
|
1021
|
+
if (check !== true)
|
|
1022
|
+
return check;
|
|
1023
|
+
await bridge.send("trimSampleForCell", cellId);
|
|
1024
|
+
return text(`Trimmed sample in cell ${cellId}.`);
|
|
1025
|
+
});
|
|
1026
|
+
server.registerTool("reverse_sample", {
|
|
1027
|
+
title: "Reverse Sample",
|
|
1028
|
+
description: "Reverse a cell's loaded sample.",
|
|
1029
|
+
inputSchema: {
|
|
1030
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
1031
|
+
},
|
|
1032
|
+
}, async ({ cellId }) => {
|
|
1033
|
+
const check = await requireBridge();
|
|
1034
|
+
if (check !== true)
|
|
1035
|
+
return check;
|
|
1036
|
+
await bridge.send("reverseSampleForCell", cellId);
|
|
1037
|
+
return text(`Reversed sample in cell ${cellId}.`);
|
|
1038
|
+
});
|
|
1039
|
+
server.registerTool("connection_status", {
|
|
1040
|
+
title: "Connection Status",
|
|
1041
|
+
description: "Check if the MCP server can connect to a running PAM plugin instance. " +
|
|
1042
|
+
"Returns connection details or instructions for enabling the bridge.",
|
|
1043
|
+
inputSchema: {},
|
|
1044
|
+
annotations: { readOnlyHint: true },
|
|
1045
|
+
}, async () => {
|
|
1046
|
+
const instances = await bridge.discover();
|
|
1047
|
+
if (instances.length === 0) {
|
|
1048
|
+
return text("No running PAM instances found.\n\n" +
|
|
1049
|
+
"To use live tools, PAM needs the MCP bridge enabled:\n" +
|
|
1050
|
+
"1. Start PAM (standalone or in a DAW)\n" +
|
|
1051
|
+
"2. The bridge will write its port to ~/.pam-mcp-bridge.json\n\n" +
|
|
1052
|
+
"Offline tools (get_parameter_info, list_presets, read_preset) work without a connection.");
|
|
1053
|
+
}
|
|
1054
|
+
const connected = bridge.isConnected() || (await bridge.connect());
|
|
1055
|
+
return json({
|
|
1056
|
+
instances,
|
|
1057
|
+
connected,
|
|
1058
|
+
note: connected
|
|
1059
|
+
? "Connected to PAM. All tools available."
|
|
1060
|
+
: "Found instances but could not connect. The plugin may have shut down.",
|
|
1061
|
+
});
|
|
1062
|
+
});
|
|
1063
|
+
server.registerTool("list_samples", {
|
|
1064
|
+
title: "List Samples",
|
|
1065
|
+
description: "Browse sample library directories. IMPORTANT: Do NOT guess paths — always call " +
|
|
1066
|
+
"get_default_locations first to discover the actual library paths, then use those " +
|
|
1067
|
+
"paths here. Paths vary per user and platform.",
|
|
1068
|
+
inputSchema: {
|
|
1069
|
+
path: z
|
|
1070
|
+
.string()
|
|
1071
|
+
.describe("Directory path to list. MUST be a path returned by get_default_locations " +
|
|
1072
|
+
"or a subdirectory of one. Do not guess paths like ~/Music/PAM."),
|
|
1073
|
+
audioOnly: z
|
|
1074
|
+
.boolean()
|
|
1075
|
+
.default(true)
|
|
1076
|
+
.describe("If true, only show audio files and directories (default: true)."),
|
|
1077
|
+
sort: z
|
|
1078
|
+
.enum(["name", "modified", "size"])
|
|
1079
|
+
.default("name")
|
|
1080
|
+
.describe("Sort order for results."),
|
|
1081
|
+
offset: z
|
|
1082
|
+
.number()
|
|
1083
|
+
.int()
|
|
1084
|
+
.min(0)
|
|
1085
|
+
.default(0)
|
|
1086
|
+
.describe("Number of entries to skip (for pagination). Default: 0."),
|
|
1087
|
+
limit: z
|
|
1088
|
+
.number()
|
|
1089
|
+
.int()
|
|
1090
|
+
.min(1)
|
|
1091
|
+
.max(200)
|
|
1092
|
+
.default(50)
|
|
1093
|
+
.describe("Maximum number of entries to return (1-200). Default: 50."),
|
|
1094
|
+
},
|
|
1095
|
+
annotations: { readOnlyHint: true },
|
|
1096
|
+
}, async ({ path: dirPath, audioOnly, sort, offset, limit }) => {
|
|
1097
|
+
const check = await requireBridge();
|
|
1098
|
+
if (check !== true)
|
|
1099
|
+
return check;
|
|
1100
|
+
const options = JSON.stringify({ audioOnly, sort });
|
|
1101
|
+
const result = await bridge.send("listDirectory", dirPath, options);
|
|
1102
|
+
if (!Array.isArray(result) || result.length === 0) {
|
|
1103
|
+
return text("No entries found in directory.");
|
|
1104
|
+
}
|
|
1105
|
+
const total = result.length;
|
|
1106
|
+
const page = result.slice(offset, offset + limit);
|
|
1107
|
+
const hasMore = offset + limit < total;
|
|
1108
|
+
return json({
|
|
1109
|
+
entries: page,
|
|
1110
|
+
total,
|
|
1111
|
+
offset,
|
|
1112
|
+
limit,
|
|
1113
|
+
hasMore,
|
|
1114
|
+
...(hasMore ? { nextOffset: offset + limit } : {}),
|
|
1115
|
+
});
|
|
1116
|
+
});
|
|
1117
|
+
server.registerTool("get_default_locations", {
|
|
1118
|
+
title: "Get Default Locations",
|
|
1119
|
+
description: "Get PAM's known library paths (samples, media, presets). " +
|
|
1120
|
+
"Use these paths with list_samples to browse the sample library.",
|
|
1121
|
+
inputSchema: {},
|
|
1122
|
+
annotations: { readOnlyHint: true },
|
|
1123
|
+
}, async () => {
|
|
1124
|
+
const check = await requireBridge();
|
|
1125
|
+
if (check !== true)
|
|
1126
|
+
return check;
|
|
1127
|
+
const result = await bridge.send("getDefaultLocations");
|
|
1128
|
+
return json(result);
|
|
1129
|
+
});
|
|
1130
|
+
server.registerTool("get_audio_info", {
|
|
1131
|
+
title: "Get Audio File Info",
|
|
1132
|
+
description: "Get detailed information about an audio file: sample rate, channels, bit depth, " +
|
|
1133
|
+
"duration, format name, and metadata tags. Works with WAV, AIFF, FLAC, MP3, OGG, M4A. " +
|
|
1134
|
+
"Useful for analyzing exported renders or checking samples before loading.",
|
|
1135
|
+
inputSchema: {
|
|
1136
|
+
filePath: z.string().describe("Absolute path to the audio file."),
|
|
1137
|
+
},
|
|
1138
|
+
annotations: { readOnlyHint: true },
|
|
1139
|
+
}, async ({ filePath }) => {
|
|
1140
|
+
const check = await requireBridge();
|
|
1141
|
+
if (check !== true)
|
|
1142
|
+
return check;
|
|
1143
|
+
const result = await bridge.send("getAudioFileInfo", filePath);
|
|
1144
|
+
return json(result);
|
|
1145
|
+
});
|
|
1146
|
+
server.registerTool("export_preset", {
|
|
1147
|
+
title: "Export Preset",
|
|
1148
|
+
description: "Export a preset as a zip file bundled with all referenced samples. " +
|
|
1149
|
+
"Creates a shareable preset package.",
|
|
1150
|
+
inputSchema: {
|
|
1151
|
+
presetPath: z
|
|
1152
|
+
.string()
|
|
1153
|
+
.describe("Preset path (relative to PAM library, as returned by list_presets or save_preset)."),
|
|
1154
|
+
outputFolderName: z
|
|
1155
|
+
.string()
|
|
1156
|
+
.describe("Name for the output zip folder (without extension)."),
|
|
1157
|
+
},
|
|
1158
|
+
}, async ({ presetPath, outputFolderName }) => {
|
|
1159
|
+
const check = await requireBridge();
|
|
1160
|
+
if (check !== true)
|
|
1161
|
+
return check;
|
|
1162
|
+
await bridge.send("exportPresetWithSamples", presetPath, outputFolderName);
|
|
1163
|
+
return text(`Exported preset to: ${outputFolderName}`);
|
|
1164
|
+
});
|
|
1165
|
+
server.registerTool("import_preset", {
|
|
1166
|
+
title: "Import Preset",
|
|
1167
|
+
description: "Import a preset from a zip file or folder into PAM's preset library.",
|
|
1168
|
+
inputSchema: {
|
|
1169
|
+
path: z.string().describe("Absolute path to the preset zip file or folder to import."),
|
|
1170
|
+
},
|
|
1171
|
+
}, async ({ path: presetPath }) => {
|
|
1172
|
+
const check = await requireBridge();
|
|
1173
|
+
if (check !== true)
|
|
1174
|
+
return check;
|
|
1175
|
+
const result = await bridge.send("importPreset", presetPath);
|
|
1176
|
+
return json(result);
|
|
1177
|
+
});
|
|
1178
|
+
server.registerTool("set_visual", {
|
|
1179
|
+
title: "Set Visual",
|
|
1180
|
+
description: "Save an image file as a preset visual/thumbnail in PAM's media library. " +
|
|
1181
|
+
"Accepts an absolute file path to an image (png, jpg, gif, webp).",
|
|
1182
|
+
inputSchema: {
|
|
1183
|
+
filePath: z.string().describe("Absolute path to the image file."),
|
|
1184
|
+
name: z.string().optional().describe("Preferred filename for the saved visual."),
|
|
1185
|
+
},
|
|
1186
|
+
}, async ({ filePath, name }) => {
|
|
1187
|
+
const check = await requireBridge();
|
|
1188
|
+
if (check !== true)
|
|
1189
|
+
return check;
|
|
1190
|
+
// Ensure the preferred name preserves the source file's extension so the
|
|
1191
|
+
// C++ side writes a file with a valid extension (needed for MIME detection).
|
|
1192
|
+
let preferredName = name ?? "";
|
|
1193
|
+
if (preferredName) {
|
|
1194
|
+
const srcExt = filePath.includes(".") ? filePath.slice(filePath.lastIndexOf(".")) : "";
|
|
1195
|
+
if (srcExt && !preferredName.includes(".")) {
|
|
1196
|
+
preferredName += srcExt;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
const result = (await bridge.send("saveVisual", filePath, preferredName));
|
|
1200
|
+
if (typeof result === "object" && result.path) {
|
|
1201
|
+
return text(`Saved visual: ${result.path} (${result.mime}, ${result.size} bytes)`);
|
|
1202
|
+
}
|
|
1203
|
+
return text("Visual saved.");
|
|
1204
|
+
});
|
|
1205
|
+
server.registerTool("list_visuals", {
|
|
1206
|
+
title: "List Visuals",
|
|
1207
|
+
description: "List all visual/thumbnail files in PAM's media library. " +
|
|
1208
|
+
"Returns name, relative path, MIME type, size, and modification time for each visual.",
|
|
1209
|
+
inputSchema: {
|
|
1210
|
+
scope: z
|
|
1211
|
+
.enum(["user", "factory", "all"])
|
|
1212
|
+
.optional()
|
|
1213
|
+
.default("user")
|
|
1214
|
+
.describe("Which library to list: 'user' (default), 'factory', or 'all'."),
|
|
1215
|
+
},
|
|
1216
|
+
}, async ({ scope }) => {
|
|
1217
|
+
const check = await requireBridge();
|
|
1218
|
+
if (check !== true)
|
|
1219
|
+
return check;
|
|
1220
|
+
const result = await bridge.send("listVisuals", scope);
|
|
1221
|
+
if (Array.isArray(result)) {
|
|
1222
|
+
if (result.length === 0)
|
|
1223
|
+
return text("No visuals found.");
|
|
1224
|
+
const lines = result.map((v) => `- ${v.name} (${v.mime}, ${v.size} bytes) → ${v.path}`);
|
|
1225
|
+
return text(`Found ${result.length} visual(s):\n${lines.join("\n")}`);
|
|
1226
|
+
}
|
|
1227
|
+
return json(result);
|
|
1228
|
+
});
|
|
1229
|
+
server.registerTool("delete_visual", {
|
|
1230
|
+
title: "Delete Visual",
|
|
1231
|
+
description: "Delete a visual file from PAM's user media library. " +
|
|
1232
|
+
"Only files in the user media folder can be deleted (not factory visuals).",
|
|
1233
|
+
inputSchema: {
|
|
1234
|
+
path: z.string().describe("Relative path to the visual (as returned by list_visuals or set_visual)."),
|
|
1235
|
+
},
|
|
1236
|
+
annotations: { destructiveHint: true },
|
|
1237
|
+
}, async ({ path: relativePath }) => {
|
|
1238
|
+
const check = await requireBridge();
|
|
1239
|
+
if (check !== true)
|
|
1240
|
+
return check;
|
|
1241
|
+
const result = await bridge.send("deleteVisual", relativePath);
|
|
1242
|
+
if (typeof result === "string" && result.includes("error"))
|
|
1243
|
+
return error(`Failed to delete visual: ${relativePath}`);
|
|
1244
|
+
return text(`Deleted visual: ${relativePath}`);
|
|
1245
|
+
});
|
|
1246
|
+
server.registerTool("get_visual_data_url", {
|
|
1247
|
+
title: "Get Visual Data URL",
|
|
1248
|
+
description: "Read a visual file from PAM's media library and return it as a base64 data URL. " +
|
|
1249
|
+
"Useful for previewing or inspecting visual content.",
|
|
1250
|
+
inputSchema: {
|
|
1251
|
+
path: z.string().describe("Relative path to the visual (as returned by list_visuals or set_visual)."),
|
|
1252
|
+
maxBytes: z
|
|
1253
|
+
.number()
|
|
1254
|
+
.optional()
|
|
1255
|
+
.default(5242880)
|
|
1256
|
+
.describe("Maximum file size in bytes (default 5MB). Files exceeding this are rejected."),
|
|
1257
|
+
},
|
|
1258
|
+
}, async ({ path: relativePath, maxBytes }) => {
|
|
1259
|
+
const check = await requireBridge();
|
|
1260
|
+
if (check !== true)
|
|
1261
|
+
return check;
|
|
1262
|
+
const result = (await bridge.send("getVisualDataUrl", relativePath, maxBytes));
|
|
1263
|
+
return text(`Visual: ${result.path} (${result.mime}, ${result.size} bytes)\nData URL: ${result.dataUrl}`);
|
|
1264
|
+
});
|
|
1265
|
+
server.registerTool("import_visual_folder", {
|
|
1266
|
+
title: "Import Visual Folder",
|
|
1267
|
+
description: "Bulk-import all valid visual files from a folder into PAM's user media library. " +
|
|
1268
|
+
"The import runs asynchronously; this tool returns immediately once the import is queued.",
|
|
1269
|
+
inputSchema: {
|
|
1270
|
+
folderPath: z.string().describe("Absolute path to the folder containing visual files to import."),
|
|
1271
|
+
operationId: z
|
|
1272
|
+
.string()
|
|
1273
|
+
.optional()
|
|
1274
|
+
.describe("Optional operation ID for tracking import progress."),
|
|
1275
|
+
},
|
|
1276
|
+
}, async ({ folderPath, operationId }) => {
|
|
1277
|
+
const check = await requireBridge();
|
|
1278
|
+
if (check !== true)
|
|
1279
|
+
return check;
|
|
1280
|
+
const result = (await bridge.send("importVisualFolder", folderPath, operationId ?? ""));
|
|
1281
|
+
return text(`Import queued for: ${result.folderPath}` +
|
|
1282
|
+
(result.operationId ? ` (operationId: ${result.operationId})` : ""));
|
|
1283
|
+
});
|
|
1284
|
+
// ============================================================
|
|
1285
|
+
// Composite cell-operation tools
|
|
1286
|
+
// ============================================================
|
|
1287
|
+
const PATTERN_SUFFIXES = [
|
|
1288
|
+
"midiSequence",
|
|
1289
|
+
"pitchSequence",
|
|
1290
|
+
"sliceSequence",
|
|
1291
|
+
"stretchSequence",
|
|
1292
|
+
];
|
|
1293
|
+
server.registerTool("reset_cell", {
|
|
1294
|
+
title: "Reset Cell",
|
|
1295
|
+
description: "Reset all parameters of a single cell to their default values and remove " +
|
|
1296
|
+
"any envelopes targeting this cell. Does not touch other cells.",
|
|
1297
|
+
inputSchema: {
|
|
1298
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
1299
|
+
},
|
|
1300
|
+
annotations: { destructiveHint: true },
|
|
1301
|
+
}, async ({ cellId }) => {
|
|
1302
|
+
const check = await requireBridge();
|
|
1303
|
+
if (check !== true)
|
|
1304
|
+
return check;
|
|
1305
|
+
const [cellParams, envelopes] = await Promise.all([
|
|
1306
|
+
getParametersByCell(`cell${cellId}`),
|
|
1307
|
+
bridge.send("getState", "envelopes"),
|
|
1308
|
+
]);
|
|
1309
|
+
const delta = {};
|
|
1310
|
+
for (const p of cellParams) {
|
|
1311
|
+
if (p.defaultValue !== undefined)
|
|
1312
|
+
delta[p.paramId] = p.defaultValue;
|
|
1313
|
+
}
|
|
1314
|
+
const paramCount = Object.keys(delta).length;
|
|
1315
|
+
const envArray = Array.isArray(envelopes) ? envelopes : [];
|
|
1316
|
+
const filteredEnvs = envArray.filter((env) => env?.cellId !== cellId);
|
|
1317
|
+
const removedEnvs = envArray.length - filteredEnvs.length;
|
|
1318
|
+
if (removedEnvs > 0)
|
|
1319
|
+
delta.envelopes = filteredEnvs;
|
|
1320
|
+
await applyKv(delta, KV_OPTS_STANDARD);
|
|
1321
|
+
return text(`Reset cell ${cellId} to defaults (${paramCount} params, removed ${removedEnvs} envelopes).`);
|
|
1322
|
+
});
|
|
1323
|
+
server.registerTool("copy_cell", {
|
|
1324
|
+
title: "Copy Cell",
|
|
1325
|
+
description: "Copy all parameters and modulation from one cell to another. " +
|
|
1326
|
+
"Overwrites the target cell's settings.",
|
|
1327
|
+
inputSchema: {
|
|
1328
|
+
sourceCellId: z.number().min(1).max(8).describe("Source cell (1-8)."),
|
|
1329
|
+
targetCellId: z.number().min(1).max(8).describe("Target cell (1-8)."),
|
|
1330
|
+
},
|
|
1331
|
+
annotations: { destructiveHint: true },
|
|
1332
|
+
}, async ({ sourceCellId, targetCellId }) => {
|
|
1333
|
+
if (sourceCellId === targetCellId)
|
|
1334
|
+
return error("Source and target must differ.");
|
|
1335
|
+
const check = await requireBridge();
|
|
1336
|
+
if (check !== true)
|
|
1337
|
+
return check;
|
|
1338
|
+
// Full state needed: cell params live as flat top-level cellN_* keys, no section.
|
|
1339
|
+
const state = (await bridge.send("getState"));
|
|
1340
|
+
const srcPrefix = `cell${sourceCellId}_`;
|
|
1341
|
+
const dstPrefix = `cell${targetCellId}_`;
|
|
1342
|
+
const delta = {};
|
|
1343
|
+
for (const [key, value] of Object.entries(state)) {
|
|
1344
|
+
if (key.startsWith(srcPrefix)) {
|
|
1345
|
+
delta[dstPrefix + key.slice(srcPrefix.length)] = value;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
// Remap envelopes
|
|
1349
|
+
const envelopes = state.envelopes || [];
|
|
1350
|
+
if (envelopes.length > 0) {
|
|
1351
|
+
const kept = envelopes.filter((env) => env?.cellId !== targetCellId);
|
|
1352
|
+
const remapped = envelopes
|
|
1353
|
+
.filter((env) => env?.cellId === sourceCellId)
|
|
1354
|
+
.map((env) => {
|
|
1355
|
+
const pid = typeof env.paramId === "string" && env.paramId.startsWith(srcPrefix)
|
|
1356
|
+
? dstPrefix + env.paramId.slice(srcPrefix.length)
|
|
1357
|
+
: env.paramId;
|
|
1358
|
+
return { ...env, cellId: targetCellId, paramId: pid };
|
|
1359
|
+
});
|
|
1360
|
+
delta.envelopes = [...kept, ...remapped];
|
|
1361
|
+
}
|
|
1362
|
+
// Remap LFO links
|
|
1363
|
+
const lfos = state.lfos || [];
|
|
1364
|
+
if (lfos.length > 0) {
|
|
1365
|
+
const kept = lfos.filter((lfo) => !(typeof lfo?.paramId === "string" && lfo.paramId.startsWith(dstPrefix)));
|
|
1366
|
+
const remapped = lfos
|
|
1367
|
+
.filter((lfo) => typeof lfo?.paramId === "string" && lfo.paramId.startsWith(srcPrefix))
|
|
1368
|
+
.map((lfo) => ({
|
|
1369
|
+
...lfo,
|
|
1370
|
+
paramId: dstPrefix + lfo.paramId.slice(srcPrefix.length),
|
|
1371
|
+
}));
|
|
1372
|
+
if (remapped.length > 0)
|
|
1373
|
+
delta.lfos = [...kept, ...remapped];
|
|
1374
|
+
}
|
|
1375
|
+
// Remap Vary links
|
|
1376
|
+
const vary = state.vary || [];
|
|
1377
|
+
if (vary.length > 0) {
|
|
1378
|
+
const kept = vary.filter((v) => {
|
|
1379
|
+
const targetsTarget = typeof v?.paramId === "string" && v.paramId.startsWith(dstPrefix);
|
|
1380
|
+
const sourcesTarget = v?.cellId === targetCellId;
|
|
1381
|
+
return !(targetsTarget || sourcesTarget);
|
|
1382
|
+
});
|
|
1383
|
+
const remapped = vary
|
|
1384
|
+
.filter((v) => {
|
|
1385
|
+
const targetsSource = typeof v?.paramId === "string" && v.paramId.startsWith(srcPrefix);
|
|
1386
|
+
const sourcesSource = v?.cellId === sourceCellId;
|
|
1387
|
+
return targetsSource || sourcesSource;
|
|
1388
|
+
})
|
|
1389
|
+
.map((v) => ({
|
|
1390
|
+
...v,
|
|
1391
|
+
paramId: typeof v.paramId === "string" && v.paramId.startsWith(srcPrefix)
|
|
1392
|
+
? dstPrefix + v.paramId.slice(srcPrefix.length)
|
|
1393
|
+
: v.paramId,
|
|
1394
|
+
cellId: v.cellId === sourceCellId ? targetCellId : v.cellId,
|
|
1395
|
+
}));
|
|
1396
|
+
if (remapped.length > 0)
|
|
1397
|
+
delta.vary = [...kept, ...remapped];
|
|
1398
|
+
}
|
|
1399
|
+
// Remap ParamSeq links
|
|
1400
|
+
const paramSeqs = state.paramSeqs || [];
|
|
1401
|
+
if (paramSeqs.length > 0) {
|
|
1402
|
+
const kept = paramSeqs.filter((ps) => {
|
|
1403
|
+
const targetsTarget = typeof ps?.paramId === "string" && ps.paramId.startsWith(dstPrefix);
|
|
1404
|
+
const sourcesTarget = ps?.cellId === targetCellId;
|
|
1405
|
+
return !(targetsTarget || sourcesTarget);
|
|
1406
|
+
});
|
|
1407
|
+
const remapped = paramSeqs
|
|
1408
|
+
.filter((ps) => {
|
|
1409
|
+
const targetsSource = typeof ps?.paramId === "string" && ps.paramId.startsWith(srcPrefix);
|
|
1410
|
+
const sourcesSource = ps?.cellId === sourceCellId;
|
|
1411
|
+
return targetsSource || sourcesSource;
|
|
1412
|
+
})
|
|
1413
|
+
.map((ps) => ({
|
|
1414
|
+
...ps,
|
|
1415
|
+
paramId: typeof ps.paramId === "string" && ps.paramId.startsWith(srcPrefix)
|
|
1416
|
+
? dstPrefix + ps.paramId.slice(srcPrefix.length)
|
|
1417
|
+
: ps.paramId,
|
|
1418
|
+
cellId: ps.cellId === sourceCellId ? targetCellId : ps.cellId,
|
|
1419
|
+
}));
|
|
1420
|
+
if (remapped.length > 0)
|
|
1421
|
+
delta.paramSeqs = [...kept, ...remapped];
|
|
1422
|
+
}
|
|
1423
|
+
await applyKv(delta, KV_OPTS_STANDARD);
|
|
1424
|
+
return text(`Copied cell ${sourceCellId} → cell ${targetCellId} (${Object.keys(delta).length} keys).`);
|
|
1425
|
+
});
|
|
1426
|
+
server.registerTool("clear_modulations", {
|
|
1427
|
+
title: "Clear Modulations",
|
|
1428
|
+
description: "Remove all LFO, envelope, and vary modulation links targeting a cell's parameters " +
|
|
1429
|
+
"(or originating from that cell, for envelopes/vary). Useful for starting fresh on a cell.",
|
|
1430
|
+
inputSchema: {
|
|
1431
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
1432
|
+
},
|
|
1433
|
+
annotations: { destructiveHint: true },
|
|
1434
|
+
}, async ({ cellId }) => {
|
|
1435
|
+
const check = await requireBridge();
|
|
1436
|
+
if (check !== true)
|
|
1437
|
+
return check;
|
|
1438
|
+
const [lfosRaw, varyRaw, envsRaw] = await Promise.all([
|
|
1439
|
+
bridge.send("getState", "lfos"),
|
|
1440
|
+
bridge.send("getState", "vary"),
|
|
1441
|
+
bridge.send("getState", "envelopes"),
|
|
1442
|
+
]);
|
|
1443
|
+
const lfos = Array.isArray(lfosRaw) ? lfosRaw : [];
|
|
1444
|
+
const vary = Array.isArray(varyRaw) ? varyRaw : [];
|
|
1445
|
+
const envelopes = Array.isArray(envsRaw) ? envsRaw : [];
|
|
1446
|
+
const prefix = `cell${cellId}_`;
|
|
1447
|
+
const targetsCell = (link) => typeof link?.paramId === "string" && link.paramId.startsWith(prefix);
|
|
1448
|
+
const sourcedFromCell = (link) => link?.cellId === cellId;
|
|
1449
|
+
const filteredLfos = lfos.filter((l) => !targetsCell(l));
|
|
1450
|
+
const filteredVary = vary.filter((v) => !targetsCell(v) && !sourcedFromCell(v));
|
|
1451
|
+
const filteredEnvs = envelopes.filter((e) => !targetsCell(e) && !sourcedFromCell(e));
|
|
1452
|
+
const delta = {};
|
|
1453
|
+
if (filteredLfos.length !== lfos.length)
|
|
1454
|
+
delta.lfos = filteredLfos;
|
|
1455
|
+
if (filteredVary.length !== vary.length)
|
|
1456
|
+
delta.vary = filteredVary;
|
|
1457
|
+
if (filteredEnvs.length !== envelopes.length)
|
|
1458
|
+
delta.envelopes = filteredEnvs;
|
|
1459
|
+
if (Object.keys(delta).length === 0) {
|
|
1460
|
+
return text(`No modulations found for cell ${cellId}.`);
|
|
1461
|
+
}
|
|
1462
|
+
await applyKv(delta, KV_OPTS_MOD);
|
|
1463
|
+
const removed = (lfos.length - filteredLfos.length) +
|
|
1464
|
+
(vary.length - filteredVary.length) +
|
|
1465
|
+
(envelopes.length - filteredEnvs.length);
|
|
1466
|
+
return text(`Cleared ${removed} modulation link(s) for cell ${cellId}.`);
|
|
1467
|
+
});
|
|
1468
|
+
server.registerTool("reset_patterns", {
|
|
1469
|
+
title: "Reset Patterns",
|
|
1470
|
+
description: "Clear all sequencer lanes (note, pitch, slice, stretch) for a single cell.",
|
|
1471
|
+
inputSchema: {
|
|
1472
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
1473
|
+
},
|
|
1474
|
+
annotations: { destructiveHint: true },
|
|
1475
|
+
}, async ({ cellId }) => {
|
|
1476
|
+
const check = await requireBridge();
|
|
1477
|
+
if (check !== true)
|
|
1478
|
+
return check;
|
|
1479
|
+
const delta = { __forceRender__: true };
|
|
1480
|
+
for (const suffix of PATTERN_SUFFIXES) {
|
|
1481
|
+
delta[`cell${cellId}_${suffix}`] = [];
|
|
1482
|
+
}
|
|
1483
|
+
await applyKv(delta, KV_OPTS_PATTERN);
|
|
1484
|
+
return text(`Cleared all patterns for cell ${cellId}.`);
|
|
1485
|
+
});
|
|
1486
|
+
server.registerTool("update_effect_config", {
|
|
1487
|
+
title: "Update Effect Config",
|
|
1488
|
+
description: "Toggle cell effects on/off (filter, delay, crusher, flanger, eq, fdn, gate, compressor). " +
|
|
1489
|
+
"Uses the lighter KV path that does not rebuild the audio graph. Pass 1 to enable, 0 to disable.",
|
|
1490
|
+
inputSchema: {
|
|
1491
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
1492
|
+
filterEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
1493
|
+
delayEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
1494
|
+
crusherEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
1495
|
+
flangerEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
1496
|
+
eqEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
1497
|
+
fdnEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
1498
|
+
gateEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
1499
|
+
compressorEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
1500
|
+
},
|
|
1501
|
+
}, async ({ cellId, ...config }) => {
|
|
1502
|
+
const check = await requireBridge();
|
|
1503
|
+
if (check !== true)
|
|
1504
|
+
return check;
|
|
1505
|
+
const delta = {};
|
|
1506
|
+
for (const [key, value] of Object.entries(config)) {
|
|
1507
|
+
if (value !== undefined)
|
|
1508
|
+
delta[`cell${cellId}_${key}`] = value;
|
|
1509
|
+
}
|
|
1510
|
+
if (Object.keys(delta).length === 0) {
|
|
1511
|
+
return error("At least one effect flag must be provided.");
|
|
1512
|
+
}
|
|
1513
|
+
await applyKv(delta, KV_OPTS_EFFECT_CONFIG);
|
|
1514
|
+
const summary = Object.entries(delta)
|
|
1515
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
1516
|
+
.join(", ");
|
|
1517
|
+
return text(`Updated effect config for cell ${cellId}: ${summary}`);
|
|
1518
|
+
});
|
|
1519
|
+
server.registerTool("update_cell_config", {
|
|
1520
|
+
title: "Update Cell Config",
|
|
1521
|
+
description: "Update cell-level configuration properties like polyphony, MIDI settings, varispeed, and modulation latch. " +
|
|
1522
|
+
"These are KV-only properties that don't appear in the parameter manifest. " +
|
|
1523
|
+
"Uses a lightweight KV path without graph rebuild.",
|
|
1524
|
+
inputSchema: {
|
|
1525
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
1526
|
+
isPoly: z.union([z.literal(0), z.literal(1)]).optional().describe("Polyphonic mode (0=mono, 1=poly)."),
|
|
1527
|
+
midiNote: z.number().min(0).max(127).optional().describe("MIDI trigger note for the cell."),
|
|
1528
|
+
midiChannel: z.number().min(1).max(16).optional().describe("MIDI channel filter (1-16)."),
|
|
1529
|
+
midiIsolate: z.union([z.literal(0), z.literal(1)]).optional().describe("Isolate internal MIDI within cell."),
|
|
1530
|
+
modLatch: z.union([z.literal(0), z.literal(1)]).optional().describe("Latch slice/pitch/stretch modulation at trigger."),
|
|
1531
|
+
varispeed: z.union([z.literal(0), z.literal(1)]).optional().describe("Varispeed mode (pitch = speed) vs independent pitch shift."),
|
|
1532
|
+
armed: z.union([z.literal(0), z.literal(1)]).optional().describe("Whether cell accepts recorded MIDI."),
|
|
1533
|
+
polyRootNote: z.number().min(0).max(127).optional().describe("Root note for poly pitch calculation."),
|
|
1534
|
+
scaleName: z.string().optional().describe("Scale name for pitch sequencer quantization (e.g. 'chromatic', 'major', 'minor')."),
|
|
1535
|
+
scaleRoot: z.number().min(0).max(11).optional().describe("Root note of the scale (0=C, 1=C#, ..., 11=B)."),
|
|
1536
|
+
sidechainFrom: z
|
|
1537
|
+
.union([z.number().min(0).max(8), z.null()])
|
|
1538
|
+
.optional()
|
|
1539
|
+
.describe("Sidechain compressor source. null=no sidechain, 0=external audio input, 1-8=cell output. " +
|
|
1540
|
+
"Auto-enables the cell's compressor when set to a non-null value."),
|
|
1541
|
+
},
|
|
1542
|
+
}, async ({ cellId, ...config }) => {
|
|
1543
|
+
const check = await requireBridge();
|
|
1544
|
+
if (check !== true)
|
|
1545
|
+
return check;
|
|
1546
|
+
const cellConfig = {};
|
|
1547
|
+
const flatDelta = {};
|
|
1548
|
+
for (const [key, value] of Object.entries(config)) {
|
|
1549
|
+
if (value !== undefined)
|
|
1550
|
+
cellConfig[key] = value;
|
|
1551
|
+
}
|
|
1552
|
+
if (Object.keys(cellConfig).length === 0) {
|
|
1553
|
+
return error("At least one config property must be provided.");
|
|
1554
|
+
}
|
|
1555
|
+
// Auto-enable compressor when setting sidechain source
|
|
1556
|
+
if (cellConfig.sidechainFrom !== undefined && cellConfig.sidechainFrom !== null) {
|
|
1557
|
+
flatDelta[`cell${cellId}_compressorEnabled`] = 1;
|
|
1558
|
+
}
|
|
1559
|
+
const delta = { cells: { [cellId]: cellConfig }, ...flatDelta };
|
|
1560
|
+
await applyKv(delta, KV_OPTS_CELL_CONFIG);
|
|
1561
|
+
const summary = Object.entries(cellConfig)
|
|
1562
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
1563
|
+
.join(", ");
|
|
1564
|
+
return text(`Updated cell ${cellId} config: ${summary}`);
|
|
1565
|
+
});
|
|
1566
|
+
server.registerTool("set_master_effects", {
|
|
1567
|
+
title: "Set Master Effects",
|
|
1568
|
+
description: "Configure the master bus Punch compressor and Limiter. " +
|
|
1569
|
+
"The Punch compressor is a musical bus compressor — use it for glue and pump. " +
|
|
1570
|
+
"The Limiter is a 3-stage brick-wall limiter with tanh saturation. " +
|
|
1571
|
+
"Pass enabled flags to toggle them on/off. Pass compressor parameters to shape the sound. " +
|
|
1572
|
+
"For pumpy beats: enable punch, set threshold around -18 to -12 dB, fast attack (0.1-0.5ms), " +
|
|
1573
|
+
"medium release (80-200ms), ratio 4-8, autoMakeup true.",
|
|
1574
|
+
inputSchema: {
|
|
1575
|
+
compressorEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable Punch compressor (0=off, 1=on)."),
|
|
1576
|
+
limiterEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable master Limiter (0=off, 1=on)."),
|
|
1577
|
+
masterCompThreshold: z.number().min(-60).max(0).optional().describe("Compressor threshold in dB (default -24)."),
|
|
1578
|
+
masterCompRatio: z.number().min(1).max(20).optional().describe("Compressor ratio (default 4)."),
|
|
1579
|
+
masterCompAttack: z.number().min(0.01).max(100).optional().describe("Attack in ms (default 0.2)."),
|
|
1580
|
+
masterCompReleaseA: z.number().min(0.05).max(2000).optional().describe("Release A in ms (default 115)."),
|
|
1581
|
+
masterCompReleaseB: z.number().min(0.05).max(2000).optional().describe("Release B in ms (default 158)."),
|
|
1582
|
+
masterCompMakeupGain: z.number().min(0).max(24).optional().describe("Makeup gain in dB (default 0)."),
|
|
1583
|
+
masterCompAutoMakeup: z.boolean().optional().describe("Auto makeup gain (default true)."),
|
|
1584
|
+
masterCompCeiling: z.number().min(-6).max(0).optional().describe("Output ceiling in dB (default -0.05)."),
|
|
1585
|
+
masterCompInputGain: z.number().min(-24).max(24).optional().describe("Input gain in dB (default 0)."),
|
|
1586
|
+
masterCompKnee: z.number().min(0).max(30).optional().describe("Knee width in dB (default 0)."),
|
|
1587
|
+
},
|
|
1588
|
+
}, async ({ compressorEnabled, limiterEnabled, ...compParams }) => {
|
|
1589
|
+
const check = await requireBridge();
|
|
1590
|
+
if (check !== true)
|
|
1591
|
+
return check;
|
|
1592
|
+
const delta = {};
|
|
1593
|
+
// Enable/disable flags go as flat state keys matching interop.ts updateMasterConfig
|
|
1594
|
+
if (compressorEnabled !== undefined)
|
|
1595
|
+
delta.masterCompressorEnabled = compressorEnabled;
|
|
1596
|
+
if (limiterEnabled !== undefined)
|
|
1597
|
+
delta.masterLimiterEnabled = limiterEnabled;
|
|
1598
|
+
// Compressor parameters are flat state keys read by the DSP graph
|
|
1599
|
+
for (const [key, value] of Object.entries(compParams)) {
|
|
1600
|
+
if (value !== undefined)
|
|
1601
|
+
delta[key] = value;
|
|
1602
|
+
}
|
|
1603
|
+
if (Object.keys(delta).length === 0) {
|
|
1604
|
+
return error("At least one parameter must be provided.");
|
|
1605
|
+
}
|
|
1606
|
+
// Use standard opts with graph dispatch so the DSP graph picks up new compressor params
|
|
1607
|
+
await applyKv(delta, KV_OPTS_STANDARD);
|
|
1608
|
+
const summary = Object.entries(delta)
|
|
1609
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
1610
|
+
.join(", ");
|
|
1611
|
+
return text(`Updated master effects: ${summary}`);
|
|
1612
|
+
});
|
|
1613
|
+
server.registerTool("add_param_seq", {
|
|
1614
|
+
title: "Add Parameter Sequencer",
|
|
1615
|
+
description: "Add a parameter sequencer (step modulation) targeting any modulatable parameter. " +
|
|
1616
|
+
"Generates rhythmic value changes synced to transport. Steps are normalized 0-1 values. " +
|
|
1617
|
+
"If a paramSeq already exists for this paramId, it is replaced.",
|
|
1618
|
+
inputSchema: {
|
|
1619
|
+
paramId: z.string().describe("Target parameter ID (e.g. 'cell1_filterCutoff')."),
|
|
1620
|
+
cellId: z.number().min(1).max(8).describe("Cell ID (1-8) — used for routing."),
|
|
1621
|
+
sequence: z
|
|
1622
|
+
.array(z.number().min(0).max(1))
|
|
1623
|
+
.optional()
|
|
1624
|
+
.describe("Step values (0-1). Defaults to 16 steps of 0.5."),
|
|
1625
|
+
strength: z.number().min(0).max(1).default(0.5).describe("Modulation depth (0-1)."),
|
|
1626
|
+
bipolar: z.union([z.literal(0), z.literal(1)]).default(0).describe("0 = unipolar, 1 = bipolar."),
|
|
1627
|
+
length: z.number().min(0.0625).max(16).default(1).describe("Pattern length in bars."),
|
|
1628
|
+
speed: z.number().min(0.0625).max(16).default(1).describe("Pattern speed multiplier."),
|
|
1629
|
+
},
|
|
1630
|
+
}, async ({ paramId, cellId, sequence, strength, bipolar, length, speed }) => {
|
|
1631
|
+
const check = await requireBridge();
|
|
1632
|
+
if (check !== true)
|
|
1633
|
+
return check;
|
|
1634
|
+
const index = await getParameterIndex();
|
|
1635
|
+
if (!index.get(paramId))
|
|
1636
|
+
return error(`Unknown parameter: ${paramId}`);
|
|
1637
|
+
const existing = await bridge.send("getState", "paramSeqs");
|
|
1638
|
+
const paramSeqs = (Array.isArray(existing) ? existing : []).filter((seq) => seq?.paramId !== paramId);
|
|
1639
|
+
const newSeq = {
|
|
1640
|
+
paramId,
|
|
1641
|
+
cellId,
|
|
1642
|
+
sequence: sequence ?? Array(16).fill(0.5),
|
|
1643
|
+
length,
|
|
1644
|
+
speed,
|
|
1645
|
+
enabled: 1,
|
|
1646
|
+
strength,
|
|
1647
|
+
bipolar,
|
|
1648
|
+
boost: 0,
|
|
1649
|
+
interpolate: 0,
|
|
1650
|
+
freeMode: 0,
|
|
1651
|
+
};
|
|
1652
|
+
paramSeqs.push(newSeq);
|
|
1653
|
+
await applyKv({ paramSeqs }, KV_OPTS_MOD);
|
|
1654
|
+
return text(`Added paramSeq for ${paramId} (${newSeq.sequence.length} steps, strength ${strength}).`);
|
|
1655
|
+
});
|
|
1656
|
+
server.registerTool("update_param_seq", {
|
|
1657
|
+
title: "Update Parameter Sequencer",
|
|
1658
|
+
description: "Update an existing parameter sequencer's step values or properties. " +
|
|
1659
|
+
"Use add_param_seq first if no paramSeq exists for this paramId.",
|
|
1660
|
+
inputSchema: {
|
|
1661
|
+
paramId: z.string().describe("Target parameter ID."),
|
|
1662
|
+
sequence: z.array(z.number().min(0).max(1)).optional().describe("New step values (0-1)."),
|
|
1663
|
+
strength: z.number().min(0).max(1).optional(),
|
|
1664
|
+
length: z.number().min(0.0625).max(16).optional(),
|
|
1665
|
+
speed: z.number().min(0.0625).max(16).optional(),
|
|
1666
|
+
bipolar: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
1667
|
+
enabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
1668
|
+
},
|
|
1669
|
+
}, async ({ paramId, ...updates }) => {
|
|
1670
|
+
const check = await requireBridge();
|
|
1671
|
+
if (check !== true)
|
|
1672
|
+
return check;
|
|
1673
|
+
const cleanUpdates = {};
|
|
1674
|
+
for (const [k, v] of Object.entries(updates)) {
|
|
1675
|
+
if (v !== undefined)
|
|
1676
|
+
cleanUpdates[k] = v;
|
|
1677
|
+
}
|
|
1678
|
+
if (Object.keys(cleanUpdates).length === 0) {
|
|
1679
|
+
return error("At least one field must be provided.");
|
|
1680
|
+
}
|
|
1681
|
+
const existing = await bridge.send("getState", "paramSeqs");
|
|
1682
|
+
const paramSeqs = Array.isArray(existing) ? existing : [];
|
|
1683
|
+
const idx = paramSeqs.findIndex((seq) => seq?.paramId === paramId);
|
|
1684
|
+
if (idx === -1) {
|
|
1685
|
+
return error(`No paramSeq found for ${paramId}. Use add_param_seq first.`);
|
|
1686
|
+
}
|
|
1687
|
+
paramSeqs[idx] = { ...paramSeqs[idx], ...cleanUpdates };
|
|
1688
|
+
await applyKv({ paramSeqs }, KV_OPTS_MOD);
|
|
1689
|
+
return text(`Updated paramSeq ${paramId}: ${Object.keys(cleanUpdates).join(", ")}`);
|
|
1690
|
+
});
|
|
1691
|
+
server.registerTool("remove_param_seq", {
|
|
1692
|
+
title: "Remove Parameter Sequencer",
|
|
1693
|
+
description: "Remove a parameter sequencer for the given paramId.",
|
|
1694
|
+
inputSchema: {
|
|
1695
|
+
paramId: z.string().describe("Target parameter ID."),
|
|
1696
|
+
},
|
|
1697
|
+
annotations: { destructiveHint: true },
|
|
1698
|
+
}, async ({ paramId }) => {
|
|
1699
|
+
const check = await requireBridge();
|
|
1700
|
+
if (check !== true)
|
|
1701
|
+
return check;
|
|
1702
|
+
const existing = await bridge.send("getState", "paramSeqs");
|
|
1703
|
+
const paramSeqs = (Array.isArray(existing) ? existing : []).filter((seq) => seq?.paramId !== paramId);
|
|
1704
|
+
await applyKv({ paramSeqs }, KV_OPTS_MOD);
|
|
1705
|
+
return text(`Removed paramSeq for ${paramId}.`);
|
|
1706
|
+
});
|
|
1707
|
+
server.registerTool("normalize_sample", {
|
|
1708
|
+
title: "Normalize Sample",
|
|
1709
|
+
description: "Normalize a cell's sample to peak amplitude (non-destructive).",
|
|
1710
|
+
inputSchema: {
|
|
1711
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
1712
|
+
},
|
|
1713
|
+
}, async ({ cellId }) => {
|
|
1714
|
+
const check = await requireBridge();
|
|
1715
|
+
if (check !== true)
|
|
1716
|
+
return check;
|
|
1717
|
+
await bridge.send("normalizeSampleForCell", cellId);
|
|
1718
|
+
return text(`Normalized sample in cell ${cellId}.`);
|
|
1719
|
+
});
|
|
1720
|
+
server.registerTool("export_audio", {
|
|
1721
|
+
title: "Export Audio",
|
|
1722
|
+
description: "Render and export audio from the current preset to a WAV file. " +
|
|
1723
|
+
"Uses the current BPM to calculate duration. Waits for completion and returns the output file path. " +
|
|
1724
|
+
"Use get_audio_info afterwards to analyze the exported file.",
|
|
1725
|
+
inputSchema: {
|
|
1726
|
+
bars: z.number().min(1).max(128).default(4).describe("Number of bars to render."),
|
|
1727
|
+
cellId: z
|
|
1728
|
+
.number()
|
|
1729
|
+
.min(1)
|
|
1730
|
+
.max(8)
|
|
1731
|
+
.optional()
|
|
1732
|
+
.describe("Render only a specific cell in solo. Omit for full mix."),
|
|
1733
|
+
bitDepth: z
|
|
1734
|
+
.number()
|
|
1735
|
+
.default(24)
|
|
1736
|
+
.describe("Bit depth (16 or 24)."),
|
|
1737
|
+
},
|
|
1738
|
+
}, async ({ bars, cellId, bitDepth }) => {
|
|
1739
|
+
const check = await requireBridge();
|
|
1740
|
+
if (check !== true)
|
|
1741
|
+
return check;
|
|
1742
|
+
const options = { bars, bitDepth };
|
|
1743
|
+
if (cellId)
|
|
1744
|
+
options.cellId = cellId;
|
|
1745
|
+
const startResult = await bridge.send("exportAudio", options);
|
|
1746
|
+
// If bridge returned old-style "ok", it was a pass-through with full options
|
|
1747
|
+
if (typeof startResult === "string") {
|
|
1748
|
+
return text(`Audio export started (${bars} bars, ${bitDepth}-bit).`);
|
|
1749
|
+
}
|
|
1750
|
+
const result = startResult;
|
|
1751
|
+
const outputPath = result.outputPath;
|
|
1752
|
+
const durationSecs = result.durationInSeconds;
|
|
1753
|
+
const bpm = result.bpm;
|
|
1754
|
+
// Poll for completion
|
|
1755
|
+
const maxWaitMs = 120_000;
|
|
1756
|
+
const pollMs = 500;
|
|
1757
|
+
const t0 = Date.now();
|
|
1758
|
+
while (Date.now() - t0 < maxWaitMs) {
|
|
1759
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
1760
|
+
const status = (await bridge.send("getExportStatus"));
|
|
1761
|
+
if (status.complete) {
|
|
1762
|
+
if (status.success) {
|
|
1763
|
+
return text(`Audio exported successfully.\nFile: ${outputPath}\n` +
|
|
1764
|
+
`Duration: ${durationSecs.toFixed(1)}s (${bars} bars at ${bpm} BPM, ${bitDepth}-bit)`);
|
|
1765
|
+
}
|
|
1766
|
+
return error(`Audio export failed.`);
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
return text(`Export timed out after ${maxWaitMs / 1000}s. The render may still be running.\nExpected output: ${outputPath}`);
|
|
1770
|
+
});
|
|
1771
|
+
// ============================================================
|
|
1772
|
+
// RESOURCES
|
|
1773
|
+
// ============================================================
|
|
1774
|
+
server.registerResource("parameter-manifest", "pam://parameters", {
|
|
1775
|
+
title: "PAM Parameter Manifest",
|
|
1776
|
+
description: "Complete parameter definitions with ranges, defaults, and metadata.",
|
|
1777
|
+
mimeType: "application/json",
|
|
1778
|
+
}, async (uri) => {
|
|
1779
|
+
const manifest = await loadManifest();
|
|
1780
|
+
return {
|
|
1781
|
+
contents: [
|
|
1782
|
+
{
|
|
1783
|
+
uri: uri.href,
|
|
1784
|
+
text: JSON.stringify(manifest.parameters, null, 2),
|
|
1785
|
+
},
|
|
1786
|
+
],
|
|
1787
|
+
};
|
|
1788
|
+
});
|
|
1789
|
+
server.registerResource("parameter-summary", "pam://parameters/summary", {
|
|
1790
|
+
title: "PAM Parameter Summary",
|
|
1791
|
+
description: "Human-readable parameter list with ranges and descriptions.",
|
|
1792
|
+
mimeType: "text/plain",
|
|
1793
|
+
}, async (uri) => {
|
|
1794
|
+
const manifest = await loadManifest();
|
|
1795
|
+
const lines = manifest.parameters.map(formatParam);
|
|
1796
|
+
return {
|
|
1797
|
+
contents: [{ uri: uri.href, text: lines.join("\n") }],
|
|
1798
|
+
};
|
|
1799
|
+
});
|
|
1800
|
+
server.registerResource("cell-parameters", new ResourceTemplate("pam://cell/{cellId}/parameters", {
|
|
1801
|
+
list: async () => ({
|
|
1802
|
+
resources: Array.from({ length: 8 }, (_, i) => ({
|
|
1803
|
+
uri: `pam://cell/cell${i + 1}/parameters`,
|
|
1804
|
+
name: `Cell ${i + 1} Parameters`,
|
|
1805
|
+
})),
|
|
1806
|
+
}),
|
|
1807
|
+
}), {
|
|
1808
|
+
title: "Cell Parameters",
|
|
1809
|
+
description: "Parameters for a specific cell.",
|
|
1810
|
+
mimeType: "application/json",
|
|
1811
|
+
}, async (uri, { cellId }) => {
|
|
1812
|
+
const id = Array.isArray(cellId) ? cellId[0] : cellId;
|
|
1813
|
+
const params = await getParametersByCell(id);
|
|
1814
|
+
return {
|
|
1815
|
+
contents: [{ uri: uri.href, text: JSON.stringify(params, null, 2) }],
|
|
1816
|
+
};
|
|
1817
|
+
});
|
|
1818
|
+
server.registerResource("global-parameters", "pam://parameters/global", {
|
|
1819
|
+
title: "Global Parameters",
|
|
1820
|
+
description: "Non-cell-specific parameters (volume, crossfade, looper, etc.).",
|
|
1821
|
+
mimeType: "application/json",
|
|
1822
|
+
}, async (uri) => {
|
|
1823
|
+
const params = await getGlobalParameters();
|
|
1824
|
+
return {
|
|
1825
|
+
contents: [{ uri: uri.href, text: JSON.stringify(params, null, 2) }],
|
|
1826
|
+
};
|
|
1827
|
+
});
|
|
1828
|
+
server.registerResource("beat-making-guide", "pam://guide/beat-making", {
|
|
1829
|
+
title: "PAM Beat-Making Guide",
|
|
1830
|
+
description: "Comprehensive guide for creating beats with PAM. Covers cell roles, slicer for loops, " +
|
|
1831
|
+
"sidechain pumping, poly/arp patterns, macros, variations, gain staging, and master effects.",
|
|
1832
|
+
mimeType: "text/markdown",
|
|
1833
|
+
}, async () => ({
|
|
1834
|
+
contents: [
|
|
1835
|
+
{
|
|
1836
|
+
uri: "pam://guide/beat-making",
|
|
1837
|
+
text: `# PAM Beat-Making Guide
|
|
1838
|
+
|
|
1839
|
+
## Cell Architecture — 8 Cells, Each a Full Instrument
|
|
1840
|
+
|
|
1841
|
+
Each cell is a sampler with its own FX chain, sequencer, and modulation. Typical beat layout:
|
|
1842
|
+
- **Cell 1**: Kick (mono, simple pattern, sidechain source)
|
|
1843
|
+
- **Cell 2**: Snare/Clap (mono)
|
|
1844
|
+
- **Cell 3**: Hi-hats (mono or poly for open/closed interplay)
|
|
1845
|
+
- **Cell 4**: Percussion/Shaker (mono)
|
|
1846
|
+
- **Cell 5-6**: Melodic loops or one-shots (poly for chords/arps)
|
|
1847
|
+
- **Cell 7-8**: FX, textures, or additional layers
|
|
1848
|
+
|
|
1849
|
+
## Slicer — Chopping Longer Loops
|
|
1850
|
+
|
|
1851
|
+
The slicer divides a sample into segments. Essential for drum loops, vocal chops, melodic phrases.
|
|
1852
|
+
|
|
1853
|
+
### Setting Up Slices
|
|
1854
|
+
Currently slice boundaries are set when loading a sample (auto-sliced to 8 by default) or via the UI.
|
|
1855
|
+
The \`cell{N}_slice\` parameter (0-64) selects which slice plays: 0=OFF (plays full sample), 1-64=slice index.
|
|
1856
|
+
|
|
1857
|
+
### Slice Sequencer Lane
|
|
1858
|
+
Use \`update_pattern\` with \`type: "slice"\` to sequence slice changes over time:
|
|
1859
|
+
\`\`\`json
|
|
1860
|
+
{
|
|
1861
|
+
"cellId": 1,
|
|
1862
|
+
"type": "slice",
|
|
1863
|
+
"sequence": [
|
|
1864
|
+
{ "time": 0.0, "value": 1, "duration": 1.0 },
|
|
1865
|
+
{ "time": 1.0, "value": 3, "duration": 0.5 },
|
|
1866
|
+
{ "time": 1.5, "value": 5, "duration": 0.5 },
|
|
1867
|
+
{ "time": 2.0, "value": 2, "duration": 2.0 }
|
|
1868
|
+
]
|
|
1869
|
+
}
|
|
1870
|
+
\`\`\`
|
|
1871
|
+
- \`value\`: 1-based slice index (integer)
|
|
1872
|
+
- \`time\`: position in beats (quarter notes)
|
|
1873
|
+
- \`duration\`: how long this slice plays in beats
|
|
1874
|
+
- Pattern loops based on \`cell{N}_patternLengthSlice\` (bars) and \`cell{N}_patternSpeedSlice\`
|
|
1875
|
+
|
|
1876
|
+
### Slice + Pattern Length for Interest
|
|
1877
|
+
- Different patternLength per lane creates polyrhythms: e.g., MIDI pattern = 4 bars, slice pattern = 3 bars
|
|
1878
|
+
- Different patternSpeed creates rate variation: slice lane at 2x speed = double-time slice changes
|
|
1879
|
+
- Combine with \`prob\` field (0-1) on events for controlled randomness
|
|
1880
|
+
|
|
1881
|
+
## Sidechain Compression — The Pump
|
|
1882
|
+
|
|
1883
|
+
### Setup via MCP
|
|
1884
|
+
\`\`\`
|
|
1885
|
+
update_cell_config: cellId=2, sidechainFrom=1
|
|
1886
|
+
\`\`\`
|
|
1887
|
+
This routes cell 1's output as the detector signal for cell 2's compressor and auto-enables it.
|
|
1888
|
+
|
|
1889
|
+
### Compressor Settings for Pump
|
|
1890
|
+
\`\`\`
|
|
1891
|
+
set_parameters: {
|
|
1892
|
+
"cell2_compressorThreshold": -18,
|
|
1893
|
+
"cell2_compressorAttack": 0.3,
|
|
1894
|
+
"cell2_compressorRelease": 150
|
|
1895
|
+
}
|
|
1896
|
+
\`\`\`
|
|
1897
|
+
- **Threshold** (-60 to 0 dB): Lower = more compression. -18 to -12 for noticeable pump.
|
|
1898
|
+
- **Attack** (0.1-50 ms): 0.1-0.5ms for fast clamping (tight pump), 5-15ms for transient pass-through.
|
|
1899
|
+
- **Release** (1-2000 ms): 80-200ms for rhythmic pumping. Match to BPM for groove.
|
|
1900
|
+
- **Makeup** (\`compressorRatio\` param, 0-20 dB): Compensates volume lost to compression. Despite the paramId name, this is makeup gain, not a ratio.
|
|
1901
|
+
|
|
1902
|
+
### Typical Sidechain Routing
|
|
1903
|
+
- Cell 1 (kick) → sidechain cells 2-8 (everything ducks to the kick)
|
|
1904
|
+
- To sidechain multiple cells, call update_cell_config for each target cell
|
|
1905
|
+
|
|
1906
|
+
## Polyphonic & Arpeggiated Patterns
|
|
1907
|
+
|
|
1908
|
+
### Enabling Poly Mode
|
|
1909
|
+
\`\`\`
|
|
1910
|
+
update_cell_config: cellId=5, isPoly=1, polyRootNote=60
|
|
1911
|
+
\`\`\`
|
|
1912
|
+
- \`isPoly=1\`: enables multi-voice playback (up to 24 voices)
|
|
1913
|
+
- \`polyRootNote\`: the MIDI note that plays the sample at original pitch
|
|
1914
|
+
- Notes above/below polyRootNote transpose the sample chromatically
|
|
1915
|
+
|
|
1916
|
+
### Writing Chords (simultaneous notes)
|
|
1917
|
+
Multiple events at the same \`time\` = chord:
|
|
1918
|
+
\`\`\`json
|
|
1919
|
+
{
|
|
1920
|
+
"cellId": 5, "type": "note",
|
|
1921
|
+
"sequence": [
|
|
1922
|
+
{ "note": 60, "velocity": 100, "time": 0.0, "duration": 2.0 },
|
|
1923
|
+
{ "note": 64, "velocity": 90, "time": 0.0, "duration": 2.0 },
|
|
1924
|
+
{ "note": 67, "velocity": 85, "time": 0.0, "duration": 2.0 },
|
|
1925
|
+
{ "note": 60, "velocity": 100, "time": 2.0, "duration": 2.0 },
|
|
1926
|
+
{ "note": 63, "velocity": 90, "time": 2.0, "duration": 2.0 },
|
|
1927
|
+
{ "note": 67, "velocity": 85, "time": 2.0, "duration": 2.0 }
|
|
1928
|
+
]
|
|
1929
|
+
}
|
|
1930
|
+
\`\`\`
|
|
1931
|
+
|
|
1932
|
+
### Writing Arpeggios (sequential notes)
|
|
1933
|
+
Short durations at successive time positions:
|
|
1934
|
+
\`\`\`json
|
|
1935
|
+
{
|
|
1936
|
+
"cellId": 5, "type": "note",
|
|
1937
|
+
"sequence": [
|
|
1938
|
+
{ "note": 60, "velocity": 100, "time": 0.0, "duration": 0.25 },
|
|
1939
|
+
{ "note": 64, "velocity": 90, "time": 0.25, "duration": 0.25 },
|
|
1940
|
+
{ "note": 67, "velocity": 85, "time": 0.5, "duration": 0.25 },
|
|
1941
|
+
{ "note": 72, "velocity": 80, "time": 0.75, "duration": 0.25 }
|
|
1942
|
+
]
|
|
1943
|
+
}
|
|
1944
|
+
\`\`\`
|
|
1945
|
+
|
|
1946
|
+
### Built-in Arpeggiator (alternative to manual)
|
|
1947
|
+
\`\`\`
|
|
1948
|
+
set_parameters: { "cell5_arpeg": 12, "cell5_arpegMode": 0 }
|
|
1949
|
+
\`\`\`
|
|
1950
|
+
- \`arpeg\`: semitone range (-24 to 24), 0=off
|
|
1951
|
+
- \`arpegMode\`: 0=Up, 1=Down, 2=Up/Down, 3=Random, 4=Triad, 5=Major, 6=Minor, 7=PentaMaj, 8=PentaMin, 9=Microtonal
|
|
1952
|
+
|
|
1953
|
+
### Velocity Dynamics
|
|
1954
|
+
Use velocity variation for humanization:
|
|
1955
|
+
- Kick: 110-127 (consistent, strong)
|
|
1956
|
+
- Hi-hats: alternate 80/110 for groove, ghost notes at 40-60
|
|
1957
|
+
- Snare: 100-127, occasional ghost at 50-70
|
|
1958
|
+
|
|
1959
|
+
## Macros — Global Control Across All Cells
|
|
1960
|
+
|
|
1961
|
+
Macros apply a single value to the same parameter across all 8 cells simultaneously.
|
|
1962
|
+
|
|
1963
|
+
### Using Macros
|
|
1964
|
+
\`\`\`
|
|
1965
|
+
set_parameters: { "macro_filterCutoff": 2000 }
|
|
1966
|
+
\`\`\`
|
|
1967
|
+
This sets filterCutoff on ALL cells (unless excluded). Macro parameters mirror cell parameters with \`macro_\` prefix.
|
|
1968
|
+
|
|
1969
|
+
### Key Macros for Beat-Making
|
|
1970
|
+
- \`macro_filterCutoff\`: sweep all filters for builds/drops
|
|
1971
|
+
- \`macro_volume\`: global volume rides
|
|
1972
|
+
- \`macro_stretch\`: time-stretch all cells
|
|
1973
|
+
- \`macro_patternSpeed\`: change all pattern speeds (halftime/doubletime)
|
|
1974
|
+
- \`macro_gateTime\`: rhythmic gating across all cells
|
|
1975
|
+
- \`macro_delayMix\` / \`macro_fdnMix\`: add space to everything
|
|
1976
|
+
|
|
1977
|
+
### Modulating Macros with LFOs
|
|
1978
|
+
\`\`\`
|
|
1979
|
+
add_modulation: type="lfo", paramId="macro_filterCutoff", lfoIndex=0, strength=60
|
|
1980
|
+
\`\`\`
|
|
1981
|
+
This auto-enables the macro and creates an LFO sweep across all cell filters.
|
|
1982
|
+
|
|
1983
|
+
## Variations — 8 Snapshots per Preset
|
|
1984
|
+
|
|
1985
|
+
Variations capture the entire plugin state (except global volume and looper). Use them for:
|
|
1986
|
+
- **Build-ups**: Variation 0 = minimal, Variation 7 = full energy
|
|
1987
|
+
- **Drops**: strip back to kick + bass, then recall full arrangement
|
|
1988
|
+
- **A/B sections**: verse vs chorus
|
|
1989
|
+
|
|
1990
|
+
### Workflow
|
|
1991
|
+
1. Configure the sound (load samples, set patterns, FX, modulation)
|
|
1992
|
+
2. \`save_preset\` (auto-saves to variation slot 0)
|
|
1993
|
+
3. Modify for next section
|
|
1994
|
+
4. \`save_variation\` index=1 (snapshot current state)
|
|
1995
|
+
5. Repeat for each arrangement section
|
|
1996
|
+
|
|
1997
|
+
Variations include: samples, patterns, FX settings, modulation, macros, cell configs.
|
|
1998
|
+
Variations exclude: global volume, looper state.
|
|
1999
|
+
|
|
2000
|
+
## Master Effects — The Final Stage
|
|
2001
|
+
|
|
2002
|
+
Signal chain: Cell outputs → Bus Mix → **Punch Compressor** → **Limiter** → Volume Fader → Output
|
|
2003
|
+
|
|
2004
|
+
### Punch Compressor (Musical Bus Comp)
|
|
2005
|
+
\`\`\`
|
|
2006
|
+
set_master_effects: {
|
|
2007
|
+
compressorEnabled: 1,
|
|
2008
|
+
masterCompThreshold: -18,
|
|
2009
|
+
masterCompRatio: 4,
|
|
2010
|
+
masterCompAttack: 0.2,
|
|
2011
|
+
masterCompReleaseA: 115,
|
|
2012
|
+
masterCompAutoMakeup: true,
|
|
2013
|
+
masterCompCeiling: -0.3
|
|
2014
|
+
}
|
|
2015
|
+
\`\`\`
|
|
2016
|
+
|
|
2017
|
+
**For pumpy beats:**
|
|
2018
|
+
- Threshold: -18 to -12 dB (aggressive)
|
|
2019
|
+
- Ratio: 4-8
|
|
2020
|
+
- Attack: 0.1-0.5ms (catch transients) or 5-10ms (let transients punch through)
|
|
2021
|
+
- Release: 80-200ms (match to tempo — 60000/BPM * 0.5 for half-note pump)
|
|
2022
|
+
- AutoMakeup: true (compensates gain automatically)
|
|
2023
|
+
- Ceiling: -0.3 to -0.1 dB (headroom)
|
|
2024
|
+
|
|
2025
|
+
### Master Limiter (Brick Wall)
|
|
2026
|
+
\`\`\`
|
|
2027
|
+
set_master_effects: { limiterEnabled: 1 }
|
|
2028
|
+
\`\`\`
|
|
2029
|
+
3-stage limiter with tanh saturation. Hardcoded settings — just enable it for loudness safety.
|
|
2030
|
+
Always enable the limiter for beat-making to prevent clipping.
|
|
2031
|
+
|
|
2032
|
+
### Gain Staging Best Practices
|
|
2033
|
+
1. **Cell volumes**: Keep individual cells around -6 to 0 dB. Kick slightly louder.
|
|
2034
|
+
2. **Before master comp**: Sum should peak around -6 to -3 dB
|
|
2035
|
+
3. **Punch compressor**: Adds glue and pump, autoMakeup compensates
|
|
2036
|
+
4. **Limiter**: Catches peaks, adds density via soft saturation
|
|
2037
|
+
5. **Output volume**: Final trim, usually 0 dB
|
|
2038
|
+
|
|
2039
|
+
\`\`\`
|
|
2040
|
+
set_parameters: {
|
|
2041
|
+
"cell1_volume": -3,
|
|
2042
|
+
"cell2_volume": -6,
|
|
2043
|
+
"cell3_volume": -9,
|
|
2044
|
+
"cell4_volume": -9,
|
|
2045
|
+
"volume": 0
|
|
2046
|
+
}
|
|
2047
|
+
\`\`\`
|
|
2048
|
+
|
|
2049
|
+
## Creating Interesting Patterns (Not Boring!)
|
|
2050
|
+
|
|
2051
|
+
### Polyrhythmic Pattern Lengths
|
|
2052
|
+
Set different \`patternLength\` per cell for evolving loops:
|
|
2053
|
+
\`\`\`
|
|
2054
|
+
set_parameters: {
|
|
2055
|
+
"cell1_patternLength": 4,
|
|
2056
|
+
"cell2_patternLength": 4,
|
|
2057
|
+
"cell3_patternLength": 3,
|
|
2058
|
+
"cell4_patternLength": 5
|
|
2059
|
+
}
|
|
2060
|
+
\`\`\`
|
|
2061
|
+
Cells 3 and 4 will phase against the 4-bar kick/snare, creating variation over 60 bars.
|
|
2062
|
+
|
|
2063
|
+
### Speed Variation
|
|
2064
|
+
Speed index 9 = 1x. Use different speeds per cell:
|
|
2065
|
+
\`\`\`
|
|
2066
|
+
set_parameters: {
|
|
2067
|
+
"cell3_patternSpeed": 13,
|
|
2068
|
+
"cell4_patternSpeed": 7
|
|
2069
|
+
}
|
|
2070
|
+
\`\`\`
|
|
2071
|
+
Speed 13 = 2x (double-time hats), Speed 7 = 1/3 (triplet feel).
|
|
2072
|
+
|
|
2073
|
+
Speed values: 0=1/32, 1=1/16, 2=1/12, 3=1/8, 4=1/6, 5=1/5, 6=1/4, 7=1/3, 8=1/2, 9=1x, 10=1.33, 11=1.5, 12=1.66, 13=2x, 14=3x, 15=4x, 16=5x, 17=6x, 18=8x, 19=12x
|
|
2074
|
+
|
|
2075
|
+
### Swing
|
|
2076
|
+
\`\`\`
|
|
2077
|
+
set_parameters: { "cell3_patternSwing": 55 }
|
|
2078
|
+
\`\`\`
|
|
2079
|
+
Range 0-100. ~55-65 for classic hip-hop swing. Apply per-cell for groove.
|
|
2080
|
+
|
|
2081
|
+
### Probability
|
|
2082
|
+
Add \`prob\` field to MIDI events (0-1) for controlled randomness:
|
|
2083
|
+
- 0.5 = 50% chance of firing (ghost notes, hat variations)
|
|
2084
|
+
- 0.8 = mostly plays (subtle variation)
|
|
2085
|
+
- 1.0 = always plays (essential hits)
|
|
2086
|
+
|
|
2087
|
+
### Retrigger (Fills & Rolls)
|
|
2088
|
+
\`\`\`json
|
|
2089
|
+
{ "note": 48, "velocity": 127, "time": 14.0, "duration": 2.0, "retrigger": 4, "retriggerDelay": 0.125 }
|
|
2090
|
+
\`\`\`
|
|
2091
|
+
Creates a roll of 5 hits (1 original + 4 retriggers) at 1/32 intervals. Great for fills.
|
|
2092
|
+
|
|
2093
|
+
### Pattern Direction
|
|
2094
|
+
\`cell{N}_patternDirection\`: 0=Forward, 1=Backward, 2=Ping-Pong, 3=Random Cycle, 4=Random Step
|
|
2095
|
+
|
|
2096
|
+
## Quick Recipes
|
|
2097
|
+
|
|
2098
|
+
### Pumpy Lo-Fi Beat
|
|
2099
|
+
1. Load kick to cell 1, snare to cell 2, hat loop to cell 3, keys to cell 5
|
|
2100
|
+
2. Set cell 5 to poly: \`update_cell_config(5, isPoly=1, polyRootNote=60)\`
|
|
2101
|
+
3. Write a chord progression in cell 5
|
|
2102
|
+
4. Sidechain cells 2-5 from cell 1: \`update_cell_config(2, sidechainFrom=1)\` etc.
|
|
2103
|
+
5. Enable Punch: \`set_master_effects(compressorEnabled=1, masterCompThreshold=-15, masterCompRatio=6)\`
|
|
2104
|
+
6. Enable Limiter: \`set_master_effects(limiterEnabled=1)\`
|
|
2105
|
+
7. Add LFO to macro filter: \`add_modulation(type="lfo", paramId="macro_filterCutoff", lfoIndex=0, strength=40)\`
|
|
2106
|
+
|
|
2107
|
+
### Polyrhythmic Percussion
|
|
2108
|
+
1. Load different perc samples to cells 3-6
|
|
2109
|
+
2. Set different pattern lengths: 3, 5, 7, 4 bars
|
|
2110
|
+
3. Set different speeds: 1x, 1.33x, 1/2, 2x
|
|
2111
|
+
4. Add swing: 55 on cell 3, 0 on cell 4 (straight), 65 on cell 5
|
|
2112
|
+
5. Use prob=0.6 on secondary hits for organic feel
|
|
2113
|
+
|
|
2114
|
+
## Scales & Musical Options
|
|
2115
|
+
|
|
2116
|
+
### Scale Quantization (for pitch sequencer and poly mode)
|
|
2117
|
+
Set via \`update_cell_config\`:
|
|
2118
|
+
\`\`\`
|
|
2119
|
+
update_cell_config: cellId=5, scaleName="pentatonicMinor", scaleRoot=0
|
|
2120
|
+
\`\`\`
|
|
2121
|
+
\`scaleRoot\`: 0=C, 1=C#, 2=D, 3=D#, 4=E, 5=F, 6=F#, 7=G, 8=G#, 9=A, 10=A#, 11=B
|
|
2122
|
+
|
|
2123
|
+
Available scales: \`chromatic\` (default/none), \`major\`, \`minor\`, \`pentatonicMajor\`, \`pentatonicMinor\`, \`dorian\`, \`mixolydian\`, \`blues\`, \`harmonicMinor\`, \`phrygian\`
|
|
2124
|
+
|
|
2125
|
+
### Playback Modes
|
|
2126
|
+
\`cell{N}_mode\`: 0=Trigger (one-shot), 1=Gate (plays while held, default), 2=Loop (continuous)
|
|
2127
|
+
- Use Trigger for drums/one-shots
|
|
2128
|
+
- Use Gate for melodic/sustained sounds with note duration control
|
|
2129
|
+
- Use Loop for ambient textures and pads
|
|
2130
|
+
|
|
2131
|
+
### Choke Groups (\`cutFrom\`)
|
|
2132
|
+
\`cell{N}_cutFrom\`: 0=off, 1-8=source cell. When source cell plays, this cell is silenced.
|
|
2133
|
+
Classic use: open hi-hat (cell 3) choked by closed hi-hat (cell 4):
|
|
2134
|
+
\`\`\`
|
|
2135
|
+
set_parameters: { "cell3_cutFrom": 4 }
|
|
2136
|
+
\`\`\`
|
|
2137
|
+
|
|
2138
|
+
### Gate Programs
|
|
2139
|
+
\`cell{N}_gateProgram\`: 0=Gate (uniform chop), 1=PingPong (L/R), 2-16=Scatter presets (rhythmic stutter patterns).
|
|
2140
|
+
\`cell{N}_gateMix\`: 0-100%. \`cell{N}_gateTime\` + \`cell{N}_gateSyncEnabled\`=1 for tempo-synced gating.
|
|
2141
|
+
|
|
2142
|
+
### Filter Types
|
|
2143
|
+
\`cell{N}_filterType\`: 0=Lowpass, 1=Highpass, 2=Bandpass, 3=Peak, 4=Notch
|
|
2144
|
+
|
|
2145
|
+
### Sync Time Divisions
|
|
2146
|
+
All sync params use the same division vocabulary:
|
|
2147
|
+
\`1/32T\`, \`1/32\`, \`1/32D\`, \`1/16T\`, \`1/16\`, \`1/16D\`, \`1/8T\`, \`1/8\`, \`1/8D\`, \`1/4T\`, \`1/4\`, \`1/4D\`, \`1/2T\`, \`1/2\`, \`1/2D\`, \`1\`
|
|
2148
|
+
(T=triplet, D=dotted). Enable sync first (\`cell{N}_{param}SyncEnabled\`=1), then set the division.
|
|
2149
|
+
|
|
2150
|
+
Applies to: delay (\`delayTimeSync\`), gate (\`gateTimeSync\`), retrigger (\`retriggerDelaySync\`), flanger (\`flangerFreqSync\`), stretch (\`stretchSync\` uses bar values: 1/8, 1/4, 1/2, 1, 2, 3, 4).
|
|
2151
|
+
|
|
2152
|
+
### Pattern Speed Reference
|
|
2153
|
+
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, 8=1/2, **9=1x**, 10=1.33, 11=1.5, 12=1.66, 13=2x, 14=3x, 15=4x, 16=5x, 17=6x, 18=8x, 19=12x
|
|
2154
|
+
|
|
2155
|
+
### Pattern Direction
|
|
2156
|
+
\`cell{N}_patternDirection\`: 0=Forward, 1=Backward, 2=Ping-Pong, 3=Random Cycle (shuffle each loop), 4=Random Step (each step independent)
|
|
2157
|
+
`,
|
|
2158
|
+
},
|
|
2159
|
+
],
|
|
2160
|
+
}));
|
|
2161
|
+
// ============================================================
|
|
2162
|
+
// Start
|
|
2163
|
+
// ============================================================
|
|
2164
|
+
async function main() {
|
|
2165
|
+
// Try to connect to plugin on startup (non-blocking)
|
|
2166
|
+
void bridge.connect();
|
|
2167
|
+
const transport = new StdioServerTransport();
|
|
2168
|
+
await server.connect(transport);
|
|
2169
|
+
// Log to stderr (stdout is the MCP JSON-RPC channel)
|
|
2170
|
+
console.error("PAM MCP server started");
|
|
2171
|
+
}
|
|
2172
|
+
main().catch((e) => {
|
|
2173
|
+
console.error("Fatal:", e);
|
|
2174
|
+
process.exit(1);
|
|
2175
|
+
});
|
|
2176
|
+
process.on("SIGINT", async () => {
|
|
2177
|
+
bridge.disconnect();
|
|
2178
|
+
await server.close();
|
|
2179
|
+
process.exit(0);
|
|
2180
|
+
});
|
|
2181
|
+
//# sourceMappingURL=server.js.map
|