@map-audio/pam-mcp-server 1.21.0 → 1.25.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/LICENSE +21 -0
- package/README.md +33 -7
- package/dist/audioAnalysis.d.ts +35 -0
- package/dist/audioAnalysis.js +277 -0
- package/dist/audioAnalysis.js.map +1 -0
- package/dist/bridge.d.ts +6 -0
- package/dist/bridge.js +151 -168
- package/dist/bridge.js.map +1 -1
- package/dist/corpusFingerprints.d.ts +17 -0
- package/dist/corpusFingerprints.js +10 -0
- package/dist/corpusFingerprints.js.map +1 -0
- package/dist/corpusRanges.d.ts +28 -0
- package/dist/corpusRanges.js +1029 -0
- package/dist/corpusRanges.js.map +1 -0
- package/dist/drumSynthEngines.json +376 -0
- package/dist/fingerprint.d.ts +70 -0
- package/dist/fingerprint.js +240 -0
- package/dist/fingerprint.js.map +1 -0
- package/dist/generated/variationSnapshot.d.ts +8 -4
- package/dist/generated/variationSnapshot.js +6 -4
- package/dist/generated/variationSnapshot.js.map +1 -1
- package/dist/generativePlayerPresets.json +70 -0
- package/dist/generativePlayers.json +134 -0
- package/dist/manifest-cell1.json +4972 -249
- package/dist/manifest-mfx.json +11571 -1800
- package/dist/manifest.d.ts +15 -3
- package/dist/manifest.js +24 -15
- package/dist/manifest.js.map +1 -1
- package/dist/manifest.json +25034 -499
- package/dist/mfxCombos.d.ts +45 -0
- package/dist/mfxCombos.js +499 -0
- package/dist/mfxCombos.js.map +1 -0
- package/dist/midiFile.d.ts +21 -0
- package/dist/midiFile.js +108 -0
- package/dist/midiFile.js.map +1 -0
- package/dist/mixAssist.d.ts +6 -0
- package/dist/mixAssist.js +96 -11
- package/dist/mixAssist.js.map +1 -1
- package/dist/players.d.ts +89 -0
- package/dist/players.js +235 -0
- package/dist/players.js.map +1 -0
- package/dist/presetSave.d.ts +5 -0
- package/dist/presetSave.js +22 -0
- package/dist/presetSave.js.map +1 -1
- package/dist/productProfile.d.ts +9 -0
- package/dist/productProfile.js +20 -0
- package/dist/productProfile.js.map +1 -0
- package/dist/sampleSlicing.d.ts +12 -0
- package/dist/sampleSlicing.js +78 -0
- package/dist/sampleSlicing.js.map +1 -0
- package/dist/server.js +2536 -483
- package/dist/server.js.map +1 -1
- package/dist/variationSessionKeys.json +34 -1
- package/package.json +11 -5
package/dist/server.js
CHANGED
|
@@ -4,27 +4,40 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
4
4
|
import { completable } from "@modelcontextprotocol/sdk/server/completable.js";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { buildVariationSnapshotBase } from "./generated/variationSnapshot.js";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { CORPUS_META, CORPUS_RANGES } from "./corpusRanges.js";
|
|
8
|
+
import { CORPUS_FINGERPRINTS, CORPUS_NOVELTY_BASELINE, CORPUS_VARIATION_BASELINE } from "./corpusFingerprints.js";
|
|
9
|
+
import { buildFingerprint, scoreNovelty, similarity as fingerprintSimilarity } from "./fingerprint.js";
|
|
10
|
+
import { analyzeMotion, decodeWav } from "./audioAnalysis.js";
|
|
11
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
12
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
9
14
|
import { inflateSync } from "node:zlib";
|
|
10
|
-
import { basename, dirname, resolve } from "node:path";
|
|
15
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
11
16
|
import { fileURLToPath } from "node:url";
|
|
12
17
|
import { createDrumSynthCatalog, } from "./drumSynth.js";
|
|
13
|
-
import { loadManifest, cellEffectNames, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, setActiveProductHint, walkPresets, } from "./manifest.js";
|
|
18
|
+
import { loadManifest, cellEffectNames, cellEffectOrder, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, setActiveProductHint, walkPresets, } from "./manifest.js";
|
|
14
19
|
import { PluginBridge } from "./bridge.js";
|
|
15
20
|
import { buildMixMeasurementFromState, currentFlatFromState, parseMixAnalysisPayload, suggestMix, } from "./mixAssist.js";
|
|
16
|
-
import { normalizePresetLookupPath, selectSaveVariationSourcePath, } from "./presetSave.js";
|
|
21
|
+
import { normalizePresetLookupPath, parsePresetFile, selectSaveVariationSourcePath, } from "./presetSave.js";
|
|
17
22
|
import { STOP_SEQUENCES, buildGenerationPrompt, parseFunctionCalls, renderContextLine, renderFunctionCall, renderFunctionResponse, truncateAtFabricatedResponse, withContext, } from "./fgemma-format.js";
|
|
23
|
+
import { mcpProductProfile } from "./productProfile.js";
|
|
24
|
+
import { MFX_COMBO_COUNT, MFX_COMBO_MANUAL_STRENGTH, buildMfxComboTarget, isMfxComboTargetParameter, mfxComboTargetValueFromCurrent, parseMfxComboSlots, reanchorMfxComboTarget, resolveMfxCombos, setMfxComboTargetStop, } from "./mfxCombos.js";
|
|
25
|
+
import { GENERATIVE_PLAYERS, LISTEN_MODES, MODULATABLE_PLAYER_FIELDS, NO_POLY_ARM, PATTERN_MODES, PLAYER_GENRES, PLAYER_NAMES, PLAYER_SLOT_FIELDS, cellPlayerType, describeCellPlayer, describePlayerSpec, getPlayerSpec, isMelodicPlayer, playerDefaultsFor, playerDensityDisplay, playerGenreConfig, playerMutateDisplay, playerPresetsFor, playerSlotDisplay, presetConfig, randomPlayerSeed, resolvePlayerPreset, resolvePlayerSpec, resolveSlotValue, rolePlayerConfigFor, roleSpreadSummary, snapPlayerValue, } from "./players.js";
|
|
26
|
+
import { buildMidiFile, patternSpeedMultiplier, tilePatternEvents } from "./midiFile.js";
|
|
27
|
+
import { MAX_SLICES, detectOnsetBoundaries, equalBoundaries, normalizeBoundaries } from "./sampleSlicing.js";
|
|
18
28
|
// --- State ---
|
|
19
29
|
// Variation slot count. Must stay in sync with PRESET_VARIATION_SLOTS in
|
|
20
30
|
// interface/src/stores/Preset.ts. Defined locally because the MCP package
|
|
21
31
|
// can't import from the interface workspace without infra changes.
|
|
22
32
|
const PRESET_VARIATION_SLOTS = 8;
|
|
33
|
+
const SERVER_PRODUCT = mcpProductProfile();
|
|
34
|
+
const IS_MFX_SERVER = SERVER_PRODUCT.id === "mfx";
|
|
23
35
|
const bridge = new PluginBridge();
|
|
24
36
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
25
37
|
const cellModeChangeSeq = {};
|
|
26
38
|
let lastSavedPresetName;
|
|
27
39
|
let lastSavedPresetPath;
|
|
40
|
+
let activeLiveResample;
|
|
28
41
|
// --- Helpers ---
|
|
29
42
|
function text(s) {
|
|
30
43
|
return { content: [{ type: "text", text: s }] };
|
|
@@ -102,6 +115,9 @@ function validateCellIdForManifest(cellId, manifest) {
|
|
|
102
115
|
return true;
|
|
103
116
|
}
|
|
104
117
|
function readPackageVersion() {
|
|
118
|
+
const override = IS_MFX_SERVER ? process.env.MFX_MCP_SERVER_VERSION?.trim() : undefined;
|
|
119
|
+
if (override)
|
|
120
|
+
return override;
|
|
105
121
|
try {
|
|
106
122
|
const raw = readFileSync(resolve(PACKAGE_ROOT, "package.json"), "utf-8");
|
|
107
123
|
const pkg = JSON.parse(raw);
|
|
@@ -122,9 +138,9 @@ async function handleCliArgs() {
|
|
|
122
138
|
}
|
|
123
139
|
if (args.has("--help") || args.has("-h")) {
|
|
124
140
|
const version = SERVER_VERSION;
|
|
125
|
-
console.log(
|
|
126
|
-
|
|
127
|
-
|
|
141
|
+
console.log(`${SERVER_PRODUCT.command} ${version}\n\n` +
|
|
142
|
+
`MCP stdio server for ${SERVER_PRODUCT.name}. Configure your MCP client to launch this command; ` +
|
|
143
|
+
`it reads the local bridge registry to find a running ${SERVER_PRODUCT.name} instance.`);
|
|
128
144
|
return true;
|
|
129
145
|
}
|
|
130
146
|
return false;
|
|
@@ -134,36 +150,55 @@ async function handleCliArgs() {
|
|
|
134
150
|
function syncProductHint() {
|
|
135
151
|
setActiveProductHint(bridge.getConnectedInstance()?.product);
|
|
136
152
|
}
|
|
153
|
+
function resolveServerMfxCombos(state, manifest) {
|
|
154
|
+
const instance = bridge.getConnectedInstance();
|
|
155
|
+
const cacheKey = instance?.instanceId ?? (instance ? `${instance.product}:${instance.port}` : manifest.bundleId);
|
|
156
|
+
return resolveMfxCombos(state, manifest, cacheKey);
|
|
157
|
+
}
|
|
137
158
|
async function requireBridge() {
|
|
138
159
|
if (bridge.isConnected())
|
|
139
160
|
return true;
|
|
140
161
|
const ok = await bridge.connect();
|
|
141
162
|
if (!ok) {
|
|
142
163
|
const pin = process.env.PAM_BRIDGE_PRODUCT;
|
|
143
|
-
return error(
|
|
144
|
-
|
|
164
|
+
return error(`Not connected to ${SERVER_PRODUCT.name}. Start ${SERVER_PRODUCT.name} (standalone or in a DAW) with MCP bridge enabled. ` +
|
|
165
|
+
(IS_MFX_SERVER
|
|
166
|
+
? "The launcher finds MFX through the local bridge registry; the plugin records its actual port on startup."
|
|
167
|
+
: "The bridge writes its port to ~/.pam-mcp-bridge.json on startup.") +
|
|
145
168
|
(pin
|
|
146
|
-
?
|
|
169
|
+
? IS_MFX_SERVER
|
|
170
|
+
? " The MFX launcher is product-pinned, but no matching instance was found."
|
|
171
|
+
: ` Note: PAM_BRIDGE_PRODUCT pins this server to '${pin}' — no matching instance was found (connection_status lists what is running).`
|
|
147
172
|
: ""));
|
|
148
173
|
}
|
|
149
174
|
syncProductHint();
|
|
150
175
|
return true;
|
|
151
176
|
}
|
|
152
177
|
/**
|
|
153
|
-
*
|
|
178
|
+
* requireBridge() plus product-aware cell bounds, for tools that ADDRESS a cell.
|
|
179
|
+
*
|
|
180
|
+
* The zod schemas cap cellId at 8 because they are built before we know which
|
|
181
|
+
* product is on the other end. Cell1 has 1 cell and MFX has 4, so without this
|
|
182
|
+
* an agent aims at cell 7 and gets a bridge-level failure or a silent no-op
|
|
183
|
+
* instead of being told the cell does not exist. Read filters (get_state,
|
|
184
|
+
* get_design_overview) deliberately stay unguarded — returning only the cells
|
|
185
|
+
* that exist is the right answer there. Manifests are cached, so this is
|
|
186
|
+
* effectively free after the first call.
|
|
154
187
|
*/
|
|
155
|
-
function
|
|
156
|
-
const
|
|
157
|
-
if (
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
188
|
+
async function requireBridgeForCells(...cellIds) {
|
|
189
|
+
const check = await requireBridge();
|
|
190
|
+
if (check !== true)
|
|
191
|
+
return check;
|
|
192
|
+
const ids = cellIds.flat().filter((c) => typeof c === "number");
|
|
193
|
+
if (ids.length === 0)
|
|
194
|
+
return true;
|
|
195
|
+
const manifest = await loadManifestForProductGuard();
|
|
196
|
+
for (const id of new Set(ids)) {
|
|
197
|
+
const cellCheck = validateCellIdForManifest(id, manifest);
|
|
198
|
+
if (cellCheck !== true)
|
|
199
|
+
return cellCheck;
|
|
200
|
+
}
|
|
201
|
+
return true;
|
|
167
202
|
}
|
|
168
203
|
// PAM ≥ 1.4.61 stores variations ONLY in the data block, zlib+base64-compressed with a
|
|
169
204
|
// "PAMZ1:" marker (see helpers/VariationCodec.h). Decompress one entry to plain JSON;
|
|
@@ -194,20 +229,17 @@ function variationsFromParsedPreset(parsed) {
|
|
|
194
229
|
return Array(PRESET_VARIATION_SLOTS).fill(null);
|
|
195
230
|
return src.map(decompressVariationEntry);
|
|
196
231
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
* Returns Array(8) of plain-JSON strings or nulls.
|
|
200
|
-
*/
|
|
201
|
-
async function readExistingVariations(presetPath) {
|
|
232
|
+
async function readPresetForSave(presetPath, allowMissing = false) {
|
|
233
|
+
let fullPath;
|
|
202
234
|
try {
|
|
203
|
-
|
|
204
|
-
const raw = await readFile(fullPath, "utf-8");
|
|
205
|
-
return variationsFromParsedPreset(parsePresetFile(raw));
|
|
235
|
+
fullPath = await resolvePresetPath(presetPath);
|
|
206
236
|
}
|
|
207
|
-
catch {
|
|
208
|
-
|
|
209
|
-
|
|
237
|
+
catch (error) {
|
|
238
|
+
if (allowMissing && error.code === "ENOENT")
|
|
239
|
+
return null;
|
|
240
|
+
throw error;
|
|
210
241
|
}
|
|
242
|
+
return parsePresetFile(await readFile(fullPath));
|
|
211
243
|
}
|
|
212
244
|
const VARIATION_CONTRACT = (() => {
|
|
213
245
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -247,6 +279,7 @@ const VARIATION_EXCLUDED_PARAMS = [
|
|
|
247
279
|
function buildVariationString(state) {
|
|
248
280
|
return JSON.stringify(buildVariationSnapshotBase(state, {
|
|
249
281
|
sessionKeys: VARIATION_EXCLUDED_PARAMS,
|
|
282
|
+
transientKeys: VARIATION_CONTRACT.transientDispatchKeys ?? [],
|
|
250
283
|
visualParamIds: VARIATION_CONTRACT.visualParamIds ?? [],
|
|
251
284
|
lfoGeneratorCount: VARIATION_CONTRACT.lfoGeneratorCount ?? 6,
|
|
252
285
|
}));
|
|
@@ -261,17 +294,23 @@ function buildVariationString(state) {
|
|
|
261
294
|
* so the save result can say the snapshot is lossy.
|
|
262
295
|
*/
|
|
263
296
|
async function fetchSaveSnapshot() {
|
|
297
|
+
let state;
|
|
298
|
+
let canonical = false;
|
|
264
299
|
try {
|
|
265
300
|
const snap = await bridge.send("getSaveSnapshot");
|
|
266
301
|
if (snap && typeof snap === "object") {
|
|
267
|
-
|
|
302
|
+
state = snap;
|
|
303
|
+
canonical = true;
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
state = (await bridge.send("getState"));
|
|
268
307
|
}
|
|
269
308
|
}
|
|
270
309
|
catch {
|
|
271
310
|
// Older PAM: action not registered. Fall through.
|
|
311
|
+
state = (await bridge.send("getState"));
|
|
272
312
|
}
|
|
273
|
-
|
|
274
|
-
return { state, canonical: false };
|
|
313
|
+
return { state, canonical };
|
|
275
314
|
}
|
|
276
315
|
/** Cells whose hosted plugin this snapshot cannot fully capture. */
|
|
277
316
|
function hostedPluginCells(state) {
|
|
@@ -316,14 +355,9 @@ function formatParam(p) {
|
|
|
316
355
|
return desc;
|
|
317
356
|
}
|
|
318
357
|
const FULL_STATE_RESPONSE_LIMIT_CHARS = 24_000;
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
// limiter, qverb, eq8, panner, gain, vocoder (and MFX's wavefold/resonator)
|
|
323
|
-
// were enabled in state but absent from `enabledEffects`.
|
|
324
|
-
const CELL_EFFECTS_FALLBACK = [
|
|
325
|
-
"filter", "delay", "crusher", "flanger", "eq", "fdn", "gate", "compressor",
|
|
326
|
-
];
|
|
358
|
+
// Preset listings grow with every installed pack; cap the unfiltered dump so it
|
|
359
|
+
// can't crowd out the agent's context (`search` narrows it).
|
|
360
|
+
const LIST_PRESETS_MAX = 400;
|
|
327
361
|
const PATTERN_LANES = {
|
|
328
362
|
note: "midiSequence",
|
|
329
363
|
pitch: "pitchSequence",
|
|
@@ -339,6 +373,10 @@ const LFO_DIVISIONS = [
|
|
|
339
373
|
"1/32D", "1/32", "1/32T",
|
|
340
374
|
"1/64D", "1/64", "1/64T",
|
|
341
375
|
];
|
|
376
|
+
// Index/enum params. derive-corpus-ranges.mjs skips them (a percentile over an
|
|
377
|
+
// index means nothing), so design_context must not read their missing corpus
|
|
378
|
+
// row as "no preset ever used this". Keep in sync with ENUMISH in that script.
|
|
379
|
+
const CORPUS_ENUM_PARAMS = /^(mode|filterType|arpegMode|gateProgram|synthEngine|outputMode|outputIndex|crossfadeAssign|grainWindow|grainArp|fmSource|fmPolarity|gainClipMode|patternDirection|quantRoot|quantScale|.*SyncEnabled|.*Enabled)$/;
|
|
342
380
|
function levenshtein(a, b) {
|
|
343
381
|
const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
344
382
|
const curr = Array(b.length + 1).fill(0);
|
|
@@ -526,55 +564,18 @@ function stemName(path) {
|
|
|
526
564
|
}
|
|
527
565
|
// --- Server ---
|
|
528
566
|
const server = new McpServer({
|
|
529
|
-
name:
|
|
530
|
-
title:
|
|
567
|
+
name: SERVER_PRODUCT.id,
|
|
568
|
+
title: SERVER_PRODUCT.serverTitle,
|
|
531
569
|
version: SERVER_VERSION,
|
|
532
570
|
}, {
|
|
533
|
-
instructions:
|
|
534
|
-
"
|
|
535
|
-
|
|
536
|
-
"
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
"To enable experimental features (plugin host, AI engines) call update_settings with both " +
|
|
542
|
-
"experimentalMode:true AND the specific sub-flag (e.g. experimentalPluginHost:true). " +
|
|
543
|
-
"The standard Drum Synth UI follows drumSynthEnabled (default true); this preference does not mute synth audio. " +
|
|
544
|
-
"set_mfx_routing is the MFX cable patchbay and errors on PAM/Cell1; set_audio_device is standalone-only. " +
|
|
545
|
-
"Tools added in server version 1.7.0 — update_settings, configure_transport, " +
|
|
546
|
-
"get_audio_device_setup, set_midi_io, set_midi_mapping, delete_preset, " +
|
|
547
|
-
"set_preset_factory_status, get_sample_usage, repair_preset_samples, preview_sample, " +
|
|
548
|
-
"set_plugin_params, skip_current_plugin_scan, remove_plugin_from_blocklist — require PAM ≥ 1.7.0. " +
|
|
549
|
-
"VX video tools (get_video_state, video_command, add_video_layer, update_video_layer, " +
|
|
550
|
-
"remove_video_layer, apply_video_layout, add_video_fx, set_video_fx, remove_video_fx, " +
|
|
551
|
-
"bind_video_param, set_video_settings, set_video_layer_source) require PAM ≥ 1.8.0 AND PAM's window to be OPEN " +
|
|
552
|
-
"(the video engine renders in the WebView; there is no headless renderer). " +
|
|
553
|
-
"capture_ui, set_visual, set_drum_synth_wave, and randomize with target 'synth' are " +
|
|
554
|
-
"WebView-routed too and need the window open for the same reason. For " +
|
|
555
|
-
"audio-reactive control, note the visual audio-react params (visualWaveDisplace, etc.) " +
|
|
556
|
-
"are regular plugin params — drive them with set_parameters; layers/FX/bindings go through " +
|
|
557
|
-
"the video tools. When building a scene, call get_video_state first to learn layer/fx ids. " +
|
|
558
|
-
"AI model lifecycle tools (download_model, get_model_status, load_model, unload_model, " +
|
|
559
|
-
"delete_model, list_models; engines aigen|magenta|notochord) require PAM ≥ 1.9.0 plus the " +
|
|
560
|
-
"matching experimental flags; load/unload/delete can block for seconds — poll " +
|
|
561
|
-
"get_model_status rather than tight-looping. " +
|
|
562
|
-
"Earlier tool sets (looper, song mode, modulation edits, MIDI recording, sample analysis, " +
|
|
563
|
-
"live resample, variation scheduling, AI generation) require PAM ≥ 1.5.0. " +
|
|
564
|
-
"SONG WORKFLOW: author sections as variation slots (save_variation), arrange them with " +
|
|
565
|
-
"song_mode (op setArrangement authors real Song Mode grid nodes and can pin a VX variation " +
|
|
566
|
-
"slot per section via vxVariationIndex; op setSequence is the older headless raw-step path), " +
|
|
567
|
-
"manage visual variation slots with vx_variation (save/load/clear/fill — the VX siblings of " +
|
|
568
|
-
"save_variation), then bounce with export_song (includeVideo:true films the VX scene with " +
|
|
569
|
-
"variation follow live). song_mode setArrangement/get/assignVx, vx_variation, and " +
|
|
570
|
-
"export_song includeVideo are WebView-routed (window open, PAM ≥ 1.5.7). " +
|
|
571
|
-
"If a tool fails with \"PAM build does not support 'X'\", the connected plugin is older than " +
|
|
572
|
-
"this MCP server; the user must update PAM (updating the npm package alone is not enough). " +
|
|
573
|
-
"Tool annotations are accurate: readOnlyHint tools never mutate the plugin, destructiveHint " +
|
|
574
|
-
"tools discard user state (confirm intent before calling them). When several product " +
|
|
575
|
-
"instances run at once (PAM + MFX), select_instance chooses which one the live tools drive " +
|
|
576
|
-
"(PAM_BRIDGE_PRODUCT pins it at launch). Guided workflow prompts are available: build-a-beat, " +
|
|
577
|
-
"make-a-song, design-a-preset, mix-and-master, troubleshoot-bridge.",
|
|
571
|
+
instructions: IS_MFX_SERVER
|
|
572
|
+
? "MFX is a four-cell modular FX router: cells are patched with cables and each cell runs its own effect chain. " +
|
|
573
|
+
"Use cells 1-4. Treat tool annotations as authoritative and use select_instance when several products are running."
|
|
574
|
+
: "PAM is an eight-cell sampler. " +
|
|
575
|
+
"Sequencer output requires a running transport. UI, video, and visual tools require the plugin " +
|
|
576
|
+
"window open; experimental plugin-host and AI tools require experimentalMode plus the matching " +
|
|
577
|
+
"feature flag. Treat tool annotations as authoritative and use select_instance when several " +
|
|
578
|
+
"products are running. A 'build does not support' error means update the plugin, not only this server.",
|
|
578
579
|
});
|
|
579
580
|
// --- tool annotation policy ---------------------------------------------------
|
|
580
581
|
// Single audit surface for MCP tool annotations. Every tool MUST have an entry;
|
|
@@ -598,19 +599,24 @@ const TOOL_ANNOTATIONS = {
|
|
|
598
599
|
read_preset: RO,
|
|
599
600
|
get_manifest_summary: RO,
|
|
600
601
|
design_context: RO,
|
|
602
|
+
preset_novelty: RO,
|
|
603
|
+
variation_distinctness: RO,
|
|
604
|
+
analyze_motion: W,
|
|
601
605
|
get_state: RO,
|
|
602
606
|
get_design_overview: RO,
|
|
603
607
|
get_transport: RO,
|
|
604
608
|
get_master_analysis: RO,
|
|
605
|
-
suggest_mix:
|
|
609
|
+
suggest_mix: W,
|
|
606
610
|
connection_status: RO,
|
|
611
|
+
situation: RO,
|
|
607
612
|
list_samples: RO,
|
|
608
613
|
get_default_locations: RO,
|
|
609
614
|
get_audio_info: RO,
|
|
610
615
|
get_video_state: RO,
|
|
611
|
-
capture_ui:
|
|
616
|
+
capture_ui: WI,
|
|
612
617
|
get_sample_peaks: RO,
|
|
613
618
|
get_audio_device_setup: RO,
|
|
619
|
+
get_memory_report: RO,
|
|
614
620
|
get_sample_usage: RO,
|
|
615
621
|
get_model_status: RO,
|
|
616
622
|
list_models: RO,
|
|
@@ -640,6 +646,11 @@ const TOOL_ANNOTATIONS = {
|
|
|
640
646
|
remove_param_seq: DI,
|
|
641
647
|
set_drum_synth: WI,
|
|
642
648
|
set_drum_synth_wave: WI,
|
|
649
|
+
list_players: RO,
|
|
650
|
+
set_player: WI,
|
|
651
|
+
set_slices: WI,
|
|
652
|
+
set_free_sequencer: WI,
|
|
653
|
+
export_midi: W,
|
|
643
654
|
stop_cell: WI,
|
|
644
655
|
reset_cell: DI,
|
|
645
656
|
copy_cell: DI, // overwrites the destination cell
|
|
@@ -659,6 +670,7 @@ const TOOL_ANNOTATIONS = {
|
|
|
659
670
|
midi_recording: W,
|
|
660
671
|
// Presets / variations
|
|
661
672
|
load_preset: DI, // replaces the working state (unsaved edits are lost)
|
|
673
|
+
get_preset_load_status: RO,
|
|
662
674
|
save_preset: WI,
|
|
663
675
|
new_preset: DI,
|
|
664
676
|
import_preset: WI,
|
|
@@ -698,7 +710,7 @@ const TOOL_ANNOTATIONS = {
|
|
|
698
710
|
looper_settings: WI,
|
|
699
711
|
looper_variation: W,
|
|
700
712
|
looper_variation_sync: WI,
|
|
701
|
-
live_resample:
|
|
713
|
+
live_resample: D, // finalize replaces the destination cell; finalize:false discards the take
|
|
702
714
|
// Song mode / export
|
|
703
715
|
song_mode: W,
|
|
704
716
|
export_song: W,
|
|
@@ -707,6 +719,9 @@ const TOOL_ANNOTATIONS = {
|
|
|
707
719
|
update_settings: WI,
|
|
708
720
|
set_audio_device: WI,
|
|
709
721
|
set_mfx_routing: WI,
|
|
722
|
+
set_mfx_edge_gain: WI,
|
|
723
|
+
configure_mfx_combo: WI,
|
|
724
|
+
set_effect_order: WI,
|
|
710
725
|
set_midi_io: WI,
|
|
711
726
|
set_midi_mapping: WI,
|
|
712
727
|
// Plugin host
|
|
@@ -746,7 +761,7 @@ const TOOL_ANNOTATIONS = {
|
|
|
746
761
|
set_video_fx: WI,
|
|
747
762
|
remove_video_fx: DI,
|
|
748
763
|
bind_video_param: WI,
|
|
749
|
-
set_video_settings:
|
|
764
|
+
set_video_settings: D, // resetWorkspace:true clears the scene
|
|
750
765
|
vx_variation: W,
|
|
751
766
|
// Connection management
|
|
752
767
|
select_instance: WI,
|
|
@@ -755,18 +770,231 @@ const TOOL_ANNOTATIONS = {
|
|
|
755
770
|
ask_assistant: D, // executes arbitrary model-chosen tool calls
|
|
756
771
|
};
|
|
757
772
|
const internalTools = new Map();
|
|
773
|
+
// The shared implementation contains sampler, song, video, AI, hosted-
|
|
774
|
+
// instrument, and MFX operations. A dedicated MFX launcher must not advertise
|
|
775
|
+
// sampler workflows merely because their handlers live in this file: tool
|
|
776
|
+
// discovery is part of the product contract, not an implementation inventory.
|
|
777
|
+
const MFX_TOOL_NAMES = new Set([
|
|
778
|
+
"get_parameter_info",
|
|
779
|
+
"list_presets",
|
|
780
|
+
"read_preset",
|
|
781
|
+
"get_manifest_summary",
|
|
782
|
+
"design_context",
|
|
783
|
+
"variation_distinctness",
|
|
784
|
+
"analyze_motion",
|
|
785
|
+
"get_state",
|
|
786
|
+
"get_design_overview",
|
|
787
|
+
"get_transport",
|
|
788
|
+
"connection_status",
|
|
789
|
+
"situation",
|
|
790
|
+
"get_default_locations",
|
|
791
|
+
"capture_ui",
|
|
792
|
+
"get_memory_report",
|
|
793
|
+
"set_parameters",
|
|
794
|
+
"add_modulation",
|
|
795
|
+
"update_modulation",
|
|
796
|
+
"clear_modulations",
|
|
797
|
+
"update_effect_config",
|
|
798
|
+
"update_cell_config",
|
|
799
|
+
"set_cell_mute",
|
|
800
|
+
"set_master_effects",
|
|
801
|
+
"add_param_seq",
|
|
802
|
+
"update_param_seq",
|
|
803
|
+
"remove_param_seq",
|
|
804
|
+
"set_free_sequencer",
|
|
805
|
+
"stop_cell",
|
|
806
|
+
"reset_cell",
|
|
807
|
+
"copy_cell",
|
|
808
|
+
"start_transport",
|
|
809
|
+
"stop_transport",
|
|
810
|
+
"full_stop_transport",
|
|
811
|
+
"set_bpm",
|
|
812
|
+
"set_metronome",
|
|
813
|
+
"configure_transport",
|
|
814
|
+
"load_preset",
|
|
815
|
+
"get_preset_load_status",
|
|
816
|
+
"save_preset",
|
|
817
|
+
"new_preset",
|
|
818
|
+
"import_preset",
|
|
819
|
+
"export_preset",
|
|
820
|
+
"update_preset_metadata",
|
|
821
|
+
"delete_preset",
|
|
822
|
+
"set_preset_factory_status",
|
|
823
|
+
"load_variation",
|
|
824
|
+
"save_variation",
|
|
825
|
+
"queue_variation",
|
|
826
|
+
"cancel_queued_variation",
|
|
827
|
+
"update_variations",
|
|
828
|
+
"variation_hold",
|
|
829
|
+
"export_audio",
|
|
830
|
+
"update_settings",
|
|
831
|
+
"get_audio_device_setup",
|
|
832
|
+
"set_audio_device",
|
|
833
|
+
"set_mfx_routing",
|
|
834
|
+
"set_mfx_edge_gain",
|
|
835
|
+
"configure_mfx_combo",
|
|
836
|
+
"set_effect_order",
|
|
837
|
+
"set_midi_io",
|
|
838
|
+
"set_midi_mapping",
|
|
839
|
+
"select_instance",
|
|
840
|
+
]);
|
|
841
|
+
function mfxSurfaceText(value) {
|
|
842
|
+
return value
|
|
843
|
+
// MFX has its own version line, so a PAM version gate names a build MFX never reaches.
|
|
844
|
+
.replace(/PAM ?(?:≥|>=) ?\d+(?:\.\d+)*/g, "a current MFX build")
|
|
845
|
+
.replaceAll("a PAM build", "an MFX build")
|
|
846
|
+
.replaceAll(" saved via set_visual", "")
|
|
847
|
+
.replaceAll(" Check corr in get_master_analysis after widening.", "")
|
|
848
|
+
.replaceAll(" (same constraint as the video tools)", "")
|
|
849
|
+
.replaceAll("so the preset_novelty scale does", "so a whole-preset novelty scale does")
|
|
850
|
+
.replaceAll("PAM/Cell1-only", "sampler-product-only")
|
|
851
|
+
.replaceAll("PAM/Cell1", "sampler products")
|
|
852
|
+
.replaceAll("PAM-only", "sampler-product-only")
|
|
853
|
+
.replaceAll("PAM's", "MFX's")
|
|
854
|
+
.replaceAll("PAM", "MFX");
|
|
855
|
+
}
|
|
856
|
+
const MFX_MAX_CELL = 4;
|
|
857
|
+
// Zod wrappers are immutable, so narrowing a leaf means rebuilding the
|
|
858
|
+
// optional/default/array/union chain around it and re-attaching the description.
|
|
859
|
+
function rebuildSchema(schema, narrowLeaf) {
|
|
860
|
+
const def = schema._zod?.def;
|
|
861
|
+
const keepText = (next) => (schema.description ? next.describe(schema.description) : next);
|
|
862
|
+
const inner = () => rebuildSchema(def.innerType, narrowLeaf);
|
|
863
|
+
switch (def?.type) {
|
|
864
|
+
case "optional":
|
|
865
|
+
return keepText(inner().optional());
|
|
866
|
+
case "nullable":
|
|
867
|
+
return keepText(inner().nullable());
|
|
868
|
+
case "default":
|
|
869
|
+
return keepText(inner().default(def.defaultValue));
|
|
870
|
+
case "array":
|
|
871
|
+
return keepText(z.array(rebuildSchema(def.element, narrowLeaf)));
|
|
872
|
+
case "union":
|
|
873
|
+
return keepText(z.union(def.options.map((option) => rebuildSchema(option, narrowLeaf))));
|
|
874
|
+
default:
|
|
875
|
+
return keepText(narrowLeaf(schema));
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
function schemaKind(schema) {
|
|
879
|
+
return schema._zod?.def?.type;
|
|
880
|
+
}
|
|
881
|
+
const capCellId = (leaf) => schemaKind(leaf) === "number" ? leaf.max(MFX_MAX_CELL) : leaf;
|
|
882
|
+
const dropEnumMembers = (drop) => (leaf) => {
|
|
883
|
+
if (schemaKind(leaf) !== "enum")
|
|
884
|
+
return leaf;
|
|
885
|
+
const entries = leaf._zod.def.entries;
|
|
886
|
+
const kept = Object.values(entries).filter((value) => !drop.includes(value));
|
|
887
|
+
return z.enum(kept);
|
|
888
|
+
};
|
|
889
|
+
// MFX ships four cells and the shared schemas advertise PAM's eight. The runtime
|
|
890
|
+
// guard is manifest-driven; this is the advertised half of the same contract.
|
|
891
|
+
const MFX_CELL_FIELDS = new Set([
|
|
892
|
+
"cellId",
|
|
893
|
+
"sourceCellId",
|
|
894
|
+
"targetCellId",
|
|
895
|
+
"cellIds",
|
|
896
|
+
"selectedCellIds",
|
|
897
|
+
"sidechainFrom",
|
|
898
|
+
]);
|
|
899
|
+
// Enum members MFX has no surface for: Vary is removed from its modulation stack,
|
|
900
|
+
// and neither song_mode nor a looper tool is registered on this launcher.
|
|
901
|
+
const MFX_ENUM_DROPS = {
|
|
902
|
+
add_modulation: { type: ["vary"] },
|
|
903
|
+
get_state: { section: ["vary", "loopers", "songMode"] },
|
|
904
|
+
export_audio: { mode: ["song-mode"] },
|
|
905
|
+
};
|
|
906
|
+
const MFX_KEPT_FIELDS = {
|
|
907
|
+
update_cell_config: ["cellId", "sidechainFrom"],
|
|
908
|
+
};
|
|
909
|
+
const MFX_DROPPED_FIELDS = {
|
|
910
|
+
select_instance: ["product"],
|
|
911
|
+
// MFX cells are effect slots — there is no synth/sampler tab to switch.
|
|
912
|
+
capture_ui: ["instrumentView"],
|
|
913
|
+
};
|
|
914
|
+
const MFX_TOOL_DESCRIPTIONS = {
|
|
915
|
+
select_instance: "Choose which running MFX instance the live tools drive. Match by instanceId or port from connection_status. The launcher refuses non-MFX instances.",
|
|
916
|
+
update_cell_config: "Set one MFX cell's sidechain source. A non-null source also enables that cell's compressor.",
|
|
917
|
+
capture_ui: "Render MFX's plugin UI (#app_root) to a PNG via html2canvas and return the saved file path (read it to " +
|
|
918
|
+
"view the image). Optionally compose the view first: gridSize (1 or 4), mainView (CellGrid|MfxRouting|" +
|
|
919
|
+
"CtrlAll|Envelope|LFO|ParamSeq|Mixer|PresetBrowser; omit/null = cell grid). REQUIRES MFX's window OPEN — " +
|
|
920
|
+
"html2canvas needs the live DOM, there is no headless renderer. Beats screen-capture: deterministic, no " +
|
|
921
|
+
"cursor/screen control, exact UI bitmap. View arguments switch the live editor and the previous view is " +
|
|
922
|
+
"not restored.",
|
|
923
|
+
};
|
|
924
|
+
const MFX_FIELD_DESCRIPTIONS = {
|
|
925
|
+
capture_ui: { gridSize: "Cell grid size to set before capture: 1 or 4." },
|
|
926
|
+
export_audio: { bars: "Bars to render." },
|
|
927
|
+
add_modulation: {
|
|
928
|
+
cellId: "Required for envelope: source cell driving the follower (1-4). This is not the target cell; target is inferred from paramId.",
|
|
929
|
+
},
|
|
930
|
+
};
|
|
931
|
+
// Text whose subject is a tool this launcher does not register.
|
|
932
|
+
function mfxDescriptionEdits(name, value) {
|
|
933
|
+
if (name === "export_audio")
|
|
934
|
+
return value
|
|
935
|
+
.replace("Set `mode` to bounce stems or the whole Song Mode arrangement.", "Set `mode` to bounce per-cell or per-bus stems.")
|
|
936
|
+
.replace(" · 'song-mode' (the Song Mode timeline)", "")
|
|
937
|
+
.replace(/\n\nNOTE on 'song-mode':[\s\S]*$/, "");
|
|
938
|
+
if (name === "analyze_motion")
|
|
939
|
+
return value.replace("Fills the gap left by get_master_analysis (loudness) and get_mix_analysis (aggregate spectrum + onset " +
|
|
940
|
+
"density), neither of which can tell a modulated preset from a static one.", "No other tool on this surface can tell a modulated preset from a static one.");
|
|
941
|
+
if (name === "set_master_effects")
|
|
942
|
+
return value.replace("Check results with get_master_analysis (keep tp <= -1 dBTP, PSR >= 8 in the loudest section). ", "Aim for tp <= -1 dBTP and PSR >= 8 in the loudest section; no loudness analysis tool is registered on this surface. ");
|
|
943
|
+
if (name === "set_parameters")
|
|
944
|
+
return (value +
|
|
945
|
+
" Writing mfx_combo1, mfx_combo2 or mfx_combo3 also fans the value out to that combo's resolved target " +
|
|
946
|
+
"parameters, unless one of those targets is written in the same call.");
|
|
947
|
+
return value;
|
|
948
|
+
}
|
|
949
|
+
function mfxToolConfig(name, config) {
|
|
950
|
+
const source = config;
|
|
951
|
+
const kept = MFX_KEPT_FIELDS[name];
|
|
952
|
+
const droppedFields = MFX_DROPPED_FIELDS[name] ?? [];
|
|
953
|
+
const enumDrops = MFX_ENUM_DROPS[name] ?? {};
|
|
954
|
+
const fieldText = MFX_FIELD_DESCRIPTIONS[name] ?? {};
|
|
955
|
+
const inputSchema = source.inputSchema
|
|
956
|
+
? Object.fromEntries(Object.entries(source.inputSchema)
|
|
957
|
+
.filter(([key]) => (!kept || kept.includes(key)) && !droppedFields.includes(key))
|
|
958
|
+
.map(([key, schema]) => {
|
|
959
|
+
let narrowed = schema;
|
|
960
|
+
if (enumDrops[key])
|
|
961
|
+
narrowed = rebuildSchema(narrowed, dropEnumMembers(enumDrops[key]));
|
|
962
|
+
if (MFX_CELL_FIELDS.has(key))
|
|
963
|
+
narrowed = rebuildSchema(narrowed, capCellId);
|
|
964
|
+
const described = fieldText[key] ??
|
|
965
|
+
(narrowed.description
|
|
966
|
+
? mfxSurfaceText(MFX_CELL_FIELDS.has(key)
|
|
967
|
+
? narrowed.description.replaceAll("1-8", "1-4").replaceAll("1..8", "1..4")
|
|
968
|
+
: narrowed.description)
|
|
969
|
+
: undefined);
|
|
970
|
+
return [key, described ? narrowed.describe(described) : narrowed];
|
|
971
|
+
}))
|
|
972
|
+
: undefined;
|
|
973
|
+
const description = MFX_TOOL_DESCRIPTIONS[name] ??
|
|
974
|
+
(source.description ? mfxSurfaceText(mfxDescriptionEdits(name, source.description)) : undefined);
|
|
975
|
+
return {
|
|
976
|
+
...source,
|
|
977
|
+
...(description ? { description } : {}),
|
|
978
|
+
...(inputSchema ? { inputSchema } : {}),
|
|
979
|
+
};
|
|
980
|
+
}
|
|
758
981
|
{
|
|
759
982
|
const origRegisterTool = server.registerTool.bind(server);
|
|
760
983
|
server.registerTool = ((name, config, handler) => {
|
|
761
|
-
|
|
984
|
+
// Registration handles are never retained by this module; omitted product
|
|
985
|
+
// tools therefore have no runtime handle to return.
|
|
986
|
+
if (IS_MFX_SERVER && !MFX_TOOL_NAMES.has(name))
|
|
987
|
+
return undefined;
|
|
988
|
+
const productConfig = (IS_MFX_SERVER ? mfxToolConfig(name, config) : config);
|
|
989
|
+
internalTools.set(name, { shape: productConfig.inputSchema, handler });
|
|
762
990
|
// Merge the central annotation policy. Inline annotations at the call site
|
|
763
991
|
// win per key; openWorldHint is false server-wide (local plugin only).
|
|
764
|
-
const cfg =
|
|
992
|
+
const cfg = productConfig;
|
|
765
993
|
if (!TOOL_ANNOTATIONS[name]) {
|
|
766
|
-
console.error(`[
|
|
994
|
+
console.error(`[${SERVER_PRODUCT.command}] tool '${name}' has no TOOL_ANNOTATIONS entry — classify it (check-tool-surface fails on this)`);
|
|
767
995
|
}
|
|
768
996
|
cfg.annotations = { openWorldHint: false, ...TOOL_ANNOTATIONS[name], ...cfg.annotations };
|
|
769
|
-
return origRegisterTool(name,
|
|
997
|
+
return origRegisterTool(name, productConfig, handler);
|
|
770
998
|
});
|
|
771
999
|
}
|
|
772
1000
|
async function callInternalTool(name, args) {
|
|
@@ -872,7 +1100,7 @@ server.registerTool("get_parameter_info", {
|
|
|
872
1100
|
});
|
|
873
1101
|
server.registerTool("list_presets", {
|
|
874
1102
|
title: "List Presets",
|
|
875
|
-
description: "List available PAM presets (factory and user).",
|
|
1103
|
+
description: "List available PAM presets (factory and user) as an array of {name, path}. Above 400 matches it returns {presets, total, truncated, hint} instead — narrow with `search` rather than paging.",
|
|
876
1104
|
inputSchema: {
|
|
877
1105
|
search: z.string().optional().describe("Filter presets by name."),
|
|
878
1106
|
},
|
|
@@ -892,7 +1120,20 @@ server.registerTool("list_presets", {
|
|
|
892
1120
|
}
|
|
893
1121
|
if (allPresets.length === 0)
|
|
894
1122
|
return text("No presets found.");
|
|
895
|
-
|
|
1123
|
+
// A pack-heavy library runs to thousands of entries and would swamp the
|
|
1124
|
+
// agent's context. `search` is the narrowing control — it is already in the
|
|
1125
|
+
// FunctionGemma toolpack schema, so this stays an output-side cap and the
|
|
1126
|
+
// inputSchema does not drift. The plain array shape is preserved unless the
|
|
1127
|
+
// cap actually bites, so normal libraries see exactly what they saw before.
|
|
1128
|
+
const entries = allPresets.map((p) => ({ name: p.name, path: p.relativePath }));
|
|
1129
|
+
if (entries.length <= LIST_PRESETS_MAX)
|
|
1130
|
+
return json(entries);
|
|
1131
|
+
return json({
|
|
1132
|
+
presets: entries.slice(0, LIST_PRESETS_MAX),
|
|
1133
|
+
total: entries.length,
|
|
1134
|
+
truncated: entries.length - LIST_PRESETS_MAX,
|
|
1135
|
+
hint: "Pass `search` to narrow the list — only the first entries are shown.",
|
|
1136
|
+
});
|
|
896
1137
|
});
|
|
897
1138
|
server.registerTool("read_preset", {
|
|
898
1139
|
title: "Read Preset",
|
|
@@ -974,8 +1215,11 @@ server.registerTool("get_manifest_summary", {
|
|
|
974
1215
|
});
|
|
975
1216
|
server.registerTool("design_context", {
|
|
976
1217
|
title: "Design Context",
|
|
977
|
-
description: "Compact parameter-design cheat sheet for agents:
|
|
978
|
-
"
|
|
1218
|
+
description: "Compact parameter-design cheat sheet for agents: the product's global controls and full per-cell effect vocabulary, " +
|
|
1219
|
+
"value bands measured from the " +
|
|
1220
|
+
"shipped factory presets, an underexplored-parameter list, macro targets, unit traps, LFO/delay division " +
|
|
1221
|
+
"tables, FX chain order, MIDI cell note map, and variation slot semantics. The bands describe where " +
|
|
1222
|
+
"shipped presets sit — they are not limits, and the distinctive end of a range is usually outside them.",
|
|
979
1223
|
inputSchema: {
|
|
980
1224
|
cellId: z
|
|
981
1225
|
.number()
|
|
@@ -984,13 +1228,73 @@ server.registerTool("design_context", {
|
|
|
984
1228
|
.max(8)
|
|
985
1229
|
.default(1)
|
|
986
1230
|
.describe("Cell number to use for per-cell example param IDs. Defaults to cell 1."),
|
|
1231
|
+
groups: z
|
|
1232
|
+
.array(z.string())
|
|
1233
|
+
.optional()
|
|
1234
|
+
.describe("Narrow keyPerCellParams to these groups (e.g. ['delay','vocoder']). " +
|
|
1235
|
+
"Omit it to get the full per-cell vocabulary."),
|
|
987
1236
|
},
|
|
988
1237
|
annotations: { readOnlyHint: true },
|
|
989
|
-
}, async ({ cellId }) => {
|
|
1238
|
+
}, async ({ cellId, groups: groupFilter }) => {
|
|
990
1239
|
const manifest = await loadManifest();
|
|
1240
|
+
const cellCheck = validateCellIdForManifest(cellId, manifest);
|
|
1241
|
+
if (cellCheck !== true)
|
|
1242
|
+
return cellCheck;
|
|
991
1243
|
const params = manifest.parameters;
|
|
1244
|
+
const defaultEffectOrder = cellEffectOrder(manifest);
|
|
992
1245
|
const byId = new Map(params.map((p) => [p.paramId, p]));
|
|
993
1246
|
const example = (suffix) => byId.get(`cell${cellId}_${suffix}`)?.paramId ?? `cell${cellId}_${suffix}`;
|
|
1247
|
+
if (IS_MFX_SERVER) {
|
|
1248
|
+
const requestedGroups = groupFilter?.length ? new Set(groupFilter) : null;
|
|
1249
|
+
const effectGroups = Object.fromEntries(cellEffectNames(manifest).filter((effect) => !requestedGroups || requestedGroups.has(effect)).map((effect) => [
|
|
1250
|
+
effect,
|
|
1251
|
+
params
|
|
1252
|
+
.filter((parameter) => parameter.cell === `cell${cellId}` && parameter.effect === effect)
|
|
1253
|
+
.map((parameter) => parameter.paramId),
|
|
1254
|
+
]));
|
|
1255
|
+
return json({
|
|
1256
|
+
product: manifest.name,
|
|
1257
|
+
architecture: "Four effect cells connected by a replaceable cable graph.",
|
|
1258
|
+
cells: [1, 2, 3, 4],
|
|
1259
|
+
routing: {
|
|
1260
|
+
tool: "set_mfx_routing",
|
|
1261
|
+
gainTool: "set_mfx_edge_gain",
|
|
1262
|
+
ports: {
|
|
1263
|
+
sources: ["input", "sidechain", "cell1..cell4", "split_lo", "split_mid", "split_hi"],
|
|
1264
|
+
destinations: ["cell1..cell4", "split", "output"],
|
|
1265
|
+
},
|
|
1266
|
+
serialExample: [
|
|
1267
|
+
{ from: "input", to: "cell1" },
|
|
1268
|
+
{ from: "cell1", to: "cell2" },
|
|
1269
|
+
{ from: "cell2", to: "cell3" },
|
|
1270
|
+
{ from: "cell3", to: "cell4" },
|
|
1271
|
+
{ from: "cell4", to: "output" },
|
|
1272
|
+
],
|
|
1273
|
+
splitParameters: ["mfx_split_lo_hz", "mfx_split_hi_hz"],
|
|
1274
|
+
},
|
|
1275
|
+
combos: {
|
|
1276
|
+
tool: "configure_mfx_combo",
|
|
1277
|
+
parameters: ["mfx_combo1", "mfx_combo2", "mfx_combo3"],
|
|
1278
|
+
roles: ["Morph", "Color", "Motion"],
|
|
1279
|
+
note: "The tool resolves automatic assignments, edits targets and labels, locks assignments, and fans combo values out while the MFX window is closed.",
|
|
1280
|
+
},
|
|
1281
|
+
globalParameters: params
|
|
1282
|
+
.filter((parameter) => !parameter.cell && parameter.paramId.startsWith("mfx_"))
|
|
1283
|
+
.map((parameter) => parameter.paramId),
|
|
1284
|
+
effectOrder: {
|
|
1285
|
+
tool: "set_effect_order",
|
|
1286
|
+
paramId: `cell${cellId}_effectOrder`,
|
|
1287
|
+
defaultOrder: defaultEffectOrder,
|
|
1288
|
+
},
|
|
1289
|
+
keyPerCellParams: effectGroups,
|
|
1290
|
+
parameterRanges: "Call get_parameter_info with a paramId above for its exact range, default, unit, and enum labels.",
|
|
1291
|
+
modulation: {
|
|
1292
|
+
tools: ["add_modulation", "update_modulation", "clear_modulations", "add_param_seq"],
|
|
1293
|
+
note: "Target the cell effect parameters above or the three combo parameters.",
|
|
1294
|
+
},
|
|
1295
|
+
variationSlots: "API indices are 0..7.",
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
994
1298
|
const macroTargetsByEffect = {};
|
|
995
1299
|
for (const p of params.filter((param) => param.paramId.startsWith("macro_") && param.isModulatable)) {
|
|
996
1300
|
const key = p.effect ?? "global";
|
|
@@ -1014,39 +1318,131 @@ server.registerTool("design_context", {
|
|
|
1014
1318
|
["1/32", 18, 0.818],
|
|
1015
1319
|
["1/64", 21, 0.955],
|
|
1016
1320
|
].map(([division, index, value]) => ({ division, index, lfoRate: value }));
|
|
1321
|
+
// Terse corpus band for one param suffix. Reports where the shipped
|
|
1322
|
+
// presets actually sit AND how much of the legal span they leave unused —
|
|
1323
|
+
// the second number is the one that matters when the brief is "make it
|
|
1324
|
+
// sound like nothing we've shipped".
|
|
1325
|
+
const band = (suffix) => {
|
|
1326
|
+
const r = CORPUS_RANGES[suffix];
|
|
1327
|
+
if (!r)
|
|
1328
|
+
return undefined;
|
|
1329
|
+
const headroom = r.spanUsedPct < 60 ? ` — corpus only visits ${r.spanUsedPct}% of the legal span` : "";
|
|
1330
|
+
return `typical ${r.p10}..${r.p90} (median ${r.p50}); corpus ${r.lo}..${r.hi}; legal ${r.legal[0]}..${r.legal[1]}; default ${r.def}${headroom}`;
|
|
1331
|
+
};
|
|
1332
|
+
// Attach bands to a group's param IDs so one lookup answers both "what is
|
|
1333
|
+
// the ID" and "what value is musical".
|
|
1334
|
+
const group = (...suffixes) => Object.fromEntries(suffixes.map((s) => [example(s), band(s) ?? "no corpus data — use get_parameter_info for the legal range"]));
|
|
1335
|
+
const wanted = groupFilter?.length ? new Set(groupFilter) : null;
|
|
1336
|
+
const pick = (all) => wanted ? Object.fromEntries(Object.entries(all).filter(([k]) => wanted.has(k))) : all;
|
|
1337
|
+
// Params the corpus barely explores. Presented as opportunity, not as a
|
|
1338
|
+
// warning: an agent that only ever writes values inside the dense part of
|
|
1339
|
+
// the distribution reproduces the average shipped preset by construction.
|
|
1340
|
+
const underexplored = Object.entries(CORPUS_RANGES)
|
|
1341
|
+
.filter(([, r]) => r.spanUsedPct < 55 || r.distinct <= 8)
|
|
1342
|
+
.sort((a, b) => a[1].spanUsedPct - b[1].spanUsedPct)
|
|
1343
|
+
.map(([k, r]) => `${k}: corpus uses ${r.lo}..${r.hi} of ${r.legal[0]}..${r.legal[1]} (${r.distinct} distinct values)`);
|
|
1344
|
+
// Params with no corpus row at all — no shipped preset has ever moved them.
|
|
1345
|
+
// Enable flags, sync toggles and the per-band EQ8 grid are excluded because
|
|
1346
|
+
// they are switches and a 49-row template, not sound-design dials.
|
|
1347
|
+
const neverUsed = params
|
|
1348
|
+
.filter((p) => p.paramId.startsWith(`cell${cellId}_`))
|
|
1349
|
+
.map((p) => p.paramId.slice(`cell${cellId}_`.length))
|
|
1350
|
+
.filter((s) => !CORPUS_RANGES[s] &&
|
|
1351
|
+
// Enum/index params carry no corpus row because a percentile over an
|
|
1352
|
+
// index is meaningless — their absence is not evidence of disuse.
|
|
1353
|
+
!CORPUS_ENUM_PARAMS.test(s) &&
|
|
1354
|
+
!s.startsWith("eq8_") &&
|
|
1355
|
+
!s.startsWith("magenta") &&
|
|
1356
|
+
!/^synth_p[4-8]$/.test(s))
|
|
1357
|
+
.sort();
|
|
1017
1358
|
return json({
|
|
1018
1359
|
product: manifest.name,
|
|
1019
1360
|
exampleCell: cellId,
|
|
1020
1361
|
macroTargetsByEffect,
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1362
|
+
// Ordered roughly by how much each group changes the character of a
|
|
1363
|
+
// sound. The sampler group is first because startPos/endPos/pitchShift
|
|
1364
|
+
// are the three most actively-designed params in the whole factory
|
|
1365
|
+
// corpus — chopping and re-tuning the source moves a preset further than
|
|
1366
|
+
// any amount of downstream FX.
|
|
1367
|
+
keyPerCellParams: pick({
|
|
1368
|
+
sampler: group("startPos", "endPos", "cutFrom", "fadeIn", "fadeOut", "mode"),
|
|
1369
|
+
pitch: group("pitchShift", "stretch", "arpeg", "arpegMode"),
|
|
1370
|
+
envelope: group("envAttack", "envGain", "envRelease"),
|
|
1371
|
+
retrigger: group("retrigger", "retriggerDelay"),
|
|
1372
|
+
groove: group("patternSwing"),
|
|
1373
|
+
mixer: group("volume", "pan", "gainGain"),
|
|
1374
|
+
filter: group("filterCutoff", "filterQ", "filterType"),
|
|
1375
|
+
eq: group("eqLowGain", "eqLowMidFreq", "eqMidGain", "eqMidHighFreq", "eqHighGain"),
|
|
1376
|
+
delay: group("delayDelayTime", "delayFeedback", "delayTilt", "delayMix"),
|
|
1377
|
+
flanger: group("flangerMix", "flangerTime", "flangerFeedback", "flangerFreq", "flangerSyncEnabled"),
|
|
1378
|
+
crusher: group("crusherBitReduction", "crusherRateReduction", "crusherDrive", "crusherMix"),
|
|
1379
|
+
fdn: group("fdnSize", "fdnFeedback", "fdnModDepth", "fdnMix"),
|
|
1380
|
+
gate: group("gateTime", "gateProgram", "gateMix"),
|
|
1381
|
+
compressor: group("compressorThreshold", "compressorRatio", "compressorAttack", "compressorRelease"),
|
|
1382
|
+
panner: group("pannerBalance", "pannerWidth"),
|
|
1383
|
+
clip: group("gainClip", "gainClipMode"),
|
|
1384
|
+
drumSynth: group("synthEngine", "synthVolume", "synthPitch", "synthAttack", "synthWaveFm", "synth_p1", "synth_p2", "synth_p3"),
|
|
1385
|
+
// Everything below is a real per-cell engine that no shipped factory
|
|
1386
|
+
// preset has ever touched. They are the least-explored territory in the
|
|
1387
|
+
// instrument, which makes them the first place to look when the brief
|
|
1388
|
+
// is "something we haven't heard".
|
|
1389
|
+
granular: group("stretchGrainMs", "stretchCrossfadeFrac", "stretchSyncEnabled", "grainDensity", "grainWindow", "grainArp", "grainScan", "grainFeedback", "grainTone"),
|
|
1390
|
+
vocoder: group("vocoderMix", "vocoderWave", "vocoderNote", "vocoderFormant", "vocoderSpeed", "vocoderWidth"),
|
|
1391
|
+
resonator: group("envResonatorMix", "envResonatorResonance", "envResonatorDampHz", "envResonatorDetune", "envResonatorGlideMs"),
|
|
1392
|
+
crossModulation: group("fmSource", "fmAmount", "fmPolarity", "spread", "chaos", "formantShift", "vcaDepth"),
|
|
1393
|
+
patternShape: group("patternDirection", "patternLength", "patternSpeed"),
|
|
1394
|
+
pitchQuantize: group("quantRoot", "quantScale", "quantStrength"),
|
|
1395
|
+
retriggerShape: group("retriggerSyncEnabled", "retriggerDecayDb", "retriggerRamp"),
|
|
1396
|
+
slicing: group("slice"),
|
|
1397
|
+
}),
|
|
1031
1398
|
unitNotes: {
|
|
1032
|
-
|
|
1399
|
+
sampleWindow: "cellN_startPos / cellN_endPos are 0..100 percent of the sample (defaults 0 and 100). Trimming endPos is how the corpus builds chops — a quarter of shipped sounding cells end below 69%. cellN_cutFrom (0..8) picks the slice source.",
|
|
1400
|
+
pitchShift: "cellN_pitchShift is -24..24 SEMITONES, not a ratio. Pair with update_cell_config quantRoot/quantScale to keep a pitched cell in key.",
|
|
1401
|
+
stretch: "cellN_stretch is 0..1 with 0.25 = unstretched. Below 0.25 smears toward granular freeze, above plays faster. This is PAM's signature control — a value away from 0.25 is audible immediately.",
|
|
1402
|
+
envelope: "cellN_envAttack 1..100 and cellN_envRelease 1..5000 are ms; cellN_envGain is -24..24 dB. A long envRelease on a short one-shot is how a tail is built without reverb.",
|
|
1403
|
+
retrigger: "cellN_retrigger is a repeat COUNT (0..10); cellN_retriggerDelay is a normalized 0..1 division, not seconds.",
|
|
1404
|
+
arpeg: "cellN_arpeg is a -24..24 semitone step and cellN_arpegMode selects the pattern (0=Up 1=Down 2=Up/Down 3=Random 4=Triad 5=Major 6=Minor 7=Penta Maj ...). Non-zero arpeg turns a one-shot cell into a melodic line.",
|
|
1405
|
+
pan: "cellN_pan is -1..1, so -0.15 is about 15% left; use cellN_pannerBalance for -100..100% (needs pannerEnabled).",
|
|
1033
1406
|
percentages: "Most mix/feedback/depth controls are 0..100 percent.",
|
|
1034
1407
|
filterCutoff: "Use real Hz values; move at least 1.5 octaves for obviously audible modulation.",
|
|
1035
1408
|
delayDelayTime: "With delaySyncEnabled=1, normalized 0..1 maps to musical divisions; do not use cellN_delayTime.",
|
|
1409
|
+
flanger: "cellN_flangerTime and cellN_flangerFreq are normalized 0..1, not ms/Hz. flangerFeedback is where the character lives — the corpus median is 70 and the top quartile is at 95+.",
|
|
1410
|
+
eqGains: "cellN_eq{Low,Mid,High}Gain are -60..12 dB. The -60 floor makes the EQ a band KILL, not just a tilt.",
|
|
1036
1411
|
lfoRate: "LFO IDs are 1-based: lfo1_... through lfo6_.... Sync ON uses index/22.",
|
|
1412
|
+
lfoFeel: "lfoN_jitter and lfoN_offset (both 0..100, both default 0) are what stop an LFO sounding like a metronome — jitter randomizes the period, offset moves the starting phase so two LFOs at the same rate stop moving in lockstep. Only 8% and 6% of shipped LFOs set them.",
|
|
1413
|
+
granular: "The granular engine runs under cellN_stretch. cellN_stretchGrainMs (1..2000) is grain size — small values buzz, 200+ smears into a pad; cellN_grainDensity (1..32) is grains in flight; cellN_grainScan (-100..100) moves the read head independently of playback, so a negative scan plays a sample backwards through itself; cellN_grainFeedback (0..100) recirculates grains; cellN_grainTone (-100..100) tilts them; cellN_grainWindow (0..4) is the grain envelope shape.",
|
|
1414
|
+
vocoder: "Per-cell vocoder: cellN_vocoderMix (0..100) blends it in, cellN_vocoderNote (24..96) is the carrier MIDI note, cellN_vocoderWave (0..100) morphs the carrier, cellN_vocoderFormant (-12..12) shifts formants against pitch, cellN_vocoderSpeed (0.25..4) is band tracking rate. Any cell can vocode any source — it does not need a voice sample.",
|
|
1415
|
+
resonator: "cellN_envResonator* is the tuned comb resonator: mix (0..100), resonance (0..100), dampHz (200..20000), detune (-24..24 semitones), glideMs (0..500). High resonance turns a percussive hit into a pitched tone that follows the cell — see rules/tunedcomb.md for PRE/POST routing.",
|
|
1416
|
+
crossModulation: "cellN_fmSource (0..8) picks ANOTHER cell as an FM modulator and cellN_fmAmount (0..100) sets depth, so cells can modulate each other rather than each sitting in its own lane. cellN_chaos (0..100) and cellN_spread (0..100) add instability and stereo scatter; cellN_formantShift (-12..12) moves formants without pitch; cellN_vcaDepth (0..100) sets how hard the amp envelope bites.",
|
|
1417
|
+
patternShape: "cellN_patternDirection (0..4), cellN_patternLength (1..32) and cellN_patternSpeed (0..19) reshape a lane without rewriting a single note. Odd lengths against a 16-step grid (7, 11, 13) produce phasing polymeter, and the pitch/slice/stretch lanes have their own patternLength*/patternSpeed* variants so lanes can run at different lengths against each other.",
|
|
1418
|
+
pitchQuantize: "cellN_quantRoot (0..11), cellN_quantScale (0..9) and cellN_quantStrength (0..100) force a pitched cell into key. Set these before writing a pitch lane so transposition stays musical.",
|
|
1419
|
+
clip: "cellN_gainClip (0..12 dB) with cellN_gainClipMode (0..3) is a per-cell clipper — a different distortion character from the crusher's drive.",
|
|
1420
|
+
slice: "cellN_slice (0..64) selects the active slice; drive it from a slice lane for stutter edits without touching the sample.",
|
|
1421
|
+
},
|
|
1422
|
+
// Derived from the shipped factory presets, not hand-written. Regenerate
|
|
1423
|
+
// with `node scripts/derive-corpus-ranges.mjs` after changing the corpus.
|
|
1424
|
+
corpusBands: {
|
|
1425
|
+
source: `${CORPUS_META.presets} factory presets, ${CORPUS_META.soundingCells} sounding cells (${CORPUS_META.source})`,
|
|
1426
|
+
reading: "'typical' is p10..p90 — where the shipped presets sit. It is a description of the house average, NOT a limit. The interesting end of a range is usually outside it, and 'corpus' shows how far the shipped presets actually went.",
|
|
1427
|
+
underexplored,
|
|
1428
|
+
neverUsed: {
|
|
1429
|
+
note: "No shipped factory preset has ever moved these. That makes them the cheapest route to a sound the library does not already contain — reach here before adding another filter sweep.",
|
|
1430
|
+
params: neverUsed,
|
|
1431
|
+
},
|
|
1037
1432
|
},
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1433
|
+
eq8: {
|
|
1434
|
+
note: "Every cell also has a full 8-band parametric EQ, separate from the 3-band eq group above. It is a per-band grid rather than a fixed param list.",
|
|
1435
|
+
template: `cell${cellId}_eq8_b{1..8}_{freq,gain,q,type,slope,enabled}`,
|
|
1436
|
+
bandRanges: "freq 10..30000 Hz, gain -30..30 dB, q 0.025..40, type 0..5, slope 0..2, enabled 0/1",
|
|
1437
|
+
outputGain: example("eq8_outputGain"),
|
|
1438
|
+
idea: "A q of 40 at a chosen freq is a resonant whistle, not an EQ move — eight of them tuned to a chord turn any noise source into a pitched pad.",
|
|
1044
1439
|
},
|
|
1045
1440
|
lfoSyncTable: {
|
|
1046
1441
|
formula: "set lfoN_lfoSyncEnabled=1 and lfoN_lfoRate=index/22",
|
|
1047
1442
|
quick: lfoQuick,
|
|
1048
1443
|
fullDivisions: LFO_DIVISIONS,
|
|
1049
1444
|
shapes: ["0=Sine", "1=Saw", "2=Tri", "3=Square", "4=Random", "5=Wander", "6=Custom"],
|
|
1445
|
+
shapeNote: "Shapes 4 (Random) and 5 (Wander) are stepped/drifting rather than periodic — they read as performance rather than automation. Half of shipped LFOs are shape 0 (Sine).",
|
|
1050
1446
|
},
|
|
1051
1447
|
delaySyncDivisions: [
|
|
1052
1448
|
"1/32T", "1/32", "1/32D", "1/16T", "1/16", "1/16D",
|
|
@@ -1056,12 +1452,297 @@ server.registerTool("design_context", {
|
|
|
1056
1452
|
filterTypes: filterType?.strRepr?.map((label, index) => `${index}=${label}`) ?? [
|
|
1057
1453
|
"0=Lowpass", "1=Highpass", "2=Bandpass", "3=Peak", "4=Notch",
|
|
1058
1454
|
],
|
|
1455
|
+
effectOrder: {
|
|
1456
|
+
paramId: `cell${cellId}_effectOrder`,
|
|
1457
|
+
tool: "set_effect_order",
|
|
1458
|
+
note: "Per-cell FX chain order is a JSON string array, NOT a numeric param — set_parameters cannot reach it. Order is audible: filter-into-delay smears the resonance, delay-into-filter darkens the repeats.",
|
|
1459
|
+
defaultOrder: defaultEffectOrder,
|
|
1460
|
+
},
|
|
1059
1461
|
midiNoteMap: Object.fromEntries(Array.from({ length: 8 }, (_, i) => {
|
|
1060
1462
|
const id = i + 1;
|
|
1061
1463
|
return [`cell${id}`, 47 + id];
|
|
1062
1464
|
})),
|
|
1063
1465
|
variationSlots: "API is 0-based (0..7); PAM UI labels slots 1..8. Save/load variation index 0 = UI slot 1.",
|
|
1064
|
-
|
|
1466
|
+
generativePlayers: "set_player turns a cell into a live generator (list_players for the roster: EUCLID, KIT, TURING, WALK, MARKOV, " +
|
|
1467
|
+
"RATCHET, PHASE, RIFF, MASK). cell{N}_playerDensity/Mutate/P1..P5 are modulatable params with macro_player* twins; " +
|
|
1468
|
+
"write them through set_player, never set_parameters alone, so the cell config stays in step.",
|
|
1469
|
+
modulationGuidance: "Primary movement should target per-cell params for distinct audible changes. Use macro_* for performance-level fan-out across cells. " +
|
|
1470
|
+
"Shipped presets that modulate at all carry a mean of 8.2 sources; the top third carry 8-46. Four sources is the corpus MEDIAN, i.e. the unremarkable middle — not a target.",
|
|
1471
|
+
});
|
|
1472
|
+
});
|
|
1473
|
+
server.registerTool("preset_novelty", {
|
|
1474
|
+
title: "Score Preset Novelty",
|
|
1475
|
+
description: "Score a preset against the shipped factory corpus and name what it duplicates. Answers the question " +
|
|
1476
|
+
"nothing else in the pipeline could: 'does this already exist?'\n\n" +
|
|
1477
|
+
"Compares structure, not audio — which params are off their defaults, the FX set and chain orders, the " +
|
|
1478
|
+
"modulation topology, per-cell onset patterns, transpositions, and sample sources. No render, no audio, " +
|
|
1479
|
+
"and (for source 'file') no plugin connection needed.\n\n" +
|
|
1480
|
+
"novelty is 1 - (closest match). Read it against percentileVsCorpus, NOT as an absolute: every PAM preset " +
|
|
1481
|
+
"shares an instrument, so a perfectly good one still scores around " +
|
|
1482
|
+
`${CORPUS_NOVELTY_BASELINE.p50}. The closest pair that actually shipped scores ${CORPUS_NOVELTY_BASELINE.floor} — ` +
|
|
1483
|
+
"below that, a preset is more derivative than anything in the library.\n\n" +
|
|
1484
|
+
"duplicatedDimensions is the actionable part: it names which dimensions to change.",
|
|
1485
|
+
inputSchema: {
|
|
1486
|
+
source: z
|
|
1487
|
+
.enum(["live", "file"])
|
|
1488
|
+
.default("live")
|
|
1489
|
+
.describe("'live' fingerprints the running plugin's current state; 'file' reads a .preset from disk."),
|
|
1490
|
+
path: z.string().optional().describe("Preset path for source 'file' (absolute or relative to the preset dirs)."),
|
|
1491
|
+
topN: z.number().int().min(1).max(20).default(5).describe("How many nearest neighbours to return."),
|
|
1492
|
+
},
|
|
1493
|
+
annotations: { readOnlyHint: true },
|
|
1494
|
+
}, async ({ source, path: presetPath, topN }) => {
|
|
1495
|
+
const manifest = await loadManifest();
|
|
1496
|
+
const defaults = {};
|
|
1497
|
+
for (const p of manifest.parameters) {
|
|
1498
|
+
const m = /^cell1_(.+)$/.exec(p.paramId);
|
|
1499
|
+
if (m)
|
|
1500
|
+
defaults[m[1]] = p.defaultValue;
|
|
1501
|
+
}
|
|
1502
|
+
const effectNames = cellEffectNames(manifest);
|
|
1503
|
+
let state;
|
|
1504
|
+
let label = "live state";
|
|
1505
|
+
if (source === "file") {
|
|
1506
|
+
if (!presetPath)
|
|
1507
|
+
return error("source 'file' requires a `path`.");
|
|
1508
|
+
try {
|
|
1509
|
+
const full = await resolvePresetPath(presetPath);
|
|
1510
|
+
const { metadata, data } = parsePresetFile(await readFile(full, "utf-8"));
|
|
1511
|
+
state = data;
|
|
1512
|
+
label = metadata.name || basename(full, ".preset");
|
|
1513
|
+
}
|
|
1514
|
+
catch (e) {
|
|
1515
|
+
return error(`Failed to read preset: ${e.message}`);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
else {
|
|
1519
|
+
const check = await requireBridge();
|
|
1520
|
+
if (check !== true)
|
|
1521
|
+
return check;
|
|
1522
|
+
state = (await bridge.send("getState"));
|
|
1523
|
+
}
|
|
1524
|
+
const candidate = buildFingerprint(state, defaults, effectNames, label);
|
|
1525
|
+
if (candidate.cells === 0) {
|
|
1526
|
+
return error("No cell carries any notes, so there is nothing to fingerprint. Write note patterns first (update_pattern), then re-score.");
|
|
1527
|
+
}
|
|
1528
|
+
// Re-scoring a preset that already shipped would otherwise match itself at
|
|
1529
|
+
// 1.0 and report "more derivative than anything in the library", which is
|
|
1530
|
+
// true but useless. Drop the identity match and say so.
|
|
1531
|
+
const selfIndex = CORPUS_FINGERPRINTS.findIndex((f) => f.name === candidate.name && fingerprintSimilarity(candidate, f).overall > 0.999);
|
|
1532
|
+
const corpus = selfIndex >= 0 ? CORPUS_FINGERPRINTS.filter((_, i) => i !== selfIndex) : CORPUS_FINGERPRINTS;
|
|
1533
|
+
const report = scoreNovelty(candidate, corpus, topN, CORPUS_NOVELTY_BASELINE);
|
|
1534
|
+
return json({
|
|
1535
|
+
subject: label,
|
|
1536
|
+
alreadyInCorpus: selfIndex >= 0 || undefined,
|
|
1537
|
+
...report,
|
|
1538
|
+
corpusBaseline: {
|
|
1539
|
+
...CORPUS_NOVELTY_BASELINE,
|
|
1540
|
+
note: `Leave-one-out novelty of the ${CORPUS_FINGERPRINTS.length} shipped presets against each other. p50 is a typical preset; below floor is more derivative than any shipped pair.`,
|
|
1541
|
+
},
|
|
1542
|
+
candidateProfile: {
|
|
1543
|
+
soundingCells: candidate.cells,
|
|
1544
|
+
paramsMoved: candidate.params.length,
|
|
1545
|
+
fx: candidate.fx,
|
|
1546
|
+
distinctChainOrders: candidate.chains.length,
|
|
1547
|
+
modulationSources: candidate.mod.lfo + candidate.mod.env + candidate.mod.vary + candidate.mod.seq,
|
|
1548
|
+
modulationTargets: candidate.mod.targets.length,
|
|
1549
|
+
transpositions: candidate.pitch.length,
|
|
1550
|
+
sampleSources: candidate.sources.length,
|
|
1551
|
+
},
|
|
1552
|
+
});
|
|
1553
|
+
});
|
|
1554
|
+
server.registerTool("analyze_motion", {
|
|
1555
|
+
title: "Analyze Preset Motion",
|
|
1556
|
+
description: "Measure whether a preset actually MOVES over time, rather than whether it is mixed. Fills the gap left by " +
|
|
1557
|
+
"get_master_analysis (loudness) and get_mix_analysis (aggregate spectrum + onset density), neither of which " +
|
|
1558
|
+
"can tell a modulated preset from a static one.\n\n" +
|
|
1559
|
+
"Reports spectral flux, brightness wander, per-bar level and brightness variation, and level movement WITHIN " +
|
|
1560
|
+
"a bar — the last one is what catches a bar-synced gate, tremolo or sidechain pump, whose bars are all " +
|
|
1561
|
+
"identical to each other.\n\n" +
|
|
1562
|
+
"Pass wavPath to analyze an existing render or live_resample capture. Otherwise the tool runs an offline " +
|
|
1563
|
+
"export first and leaves that render in PAM's Exports folder as pam_motion_<epoch>.wav. Digital silence is " +
|
|
1564
|
+
"reported as a RENDER FAILURE with the workaround, not as 'no motion' — offline export is known to produce " +
|
|
1565
|
+
"silence in the standalone build.",
|
|
1566
|
+
inputSchema: {
|
|
1567
|
+
wavPath: z
|
|
1568
|
+
.string()
|
|
1569
|
+
.optional()
|
|
1570
|
+
.describe("Absolute path to an existing WAV. Skips rendering — use this for a live_resample capture."),
|
|
1571
|
+
bars: z.number().int().min(1).max(32).default(4).describe("Bars to render when no wavPath is given."),
|
|
1572
|
+
bpm: z
|
|
1573
|
+
.number()
|
|
1574
|
+
.optional()
|
|
1575
|
+
.describe("Tempo for the per-bar windows. Read from the transport when omitted."),
|
|
1576
|
+
},
|
|
1577
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
1578
|
+
}, async ({ wavPath, bars, bpm }) => {
|
|
1579
|
+
let path = wavPath;
|
|
1580
|
+
let effectiveBpm = bpm;
|
|
1581
|
+
if (!path) {
|
|
1582
|
+
const check = await requireBridge();
|
|
1583
|
+
if (check !== true)
|
|
1584
|
+
return check;
|
|
1585
|
+
const transport = (await bridge.send("getTransport"));
|
|
1586
|
+
effectiveBpm ??= typeof transport?.bpm === "number" ? transport.bpm : 120;
|
|
1587
|
+
const filename = `pam_motion_${Date.now()}`;
|
|
1588
|
+
await bridge.send("exportAudio", {
|
|
1589
|
+
filename,
|
|
1590
|
+
mode: "main",
|
|
1591
|
+
durationInSeconds: (bars * 4 * 60) / (effectiveBpm || 120),
|
|
1592
|
+
bitDepth: 24,
|
|
1593
|
+
});
|
|
1594
|
+
const outcome = await pollExportStatus();
|
|
1595
|
+
if (outcome !== "success")
|
|
1596
|
+
return error(`Render failed before analysis could run (export ${outcome}).`);
|
|
1597
|
+
const locations = (await bridge.send("getDefaultLocations"));
|
|
1598
|
+
const exportDir = [locations?.exports, locations?.exportsLocation, locations?.Exports].find((v) => typeof v === "string" && v.length > 0);
|
|
1599
|
+
if (!exportDir) {
|
|
1600
|
+
return error(`Rendered ${filename}.wav but could not resolve PAM's Exports folder from get_default_locations. ` +
|
|
1601
|
+
"Re-run with an explicit wavPath.");
|
|
1602
|
+
}
|
|
1603
|
+
path = resolve(exportDir, `${filename}.wav`);
|
|
1604
|
+
}
|
|
1605
|
+
else if (effectiveBpm === undefined) {
|
|
1606
|
+
const check = await requireBridge();
|
|
1607
|
+
if (check === true) {
|
|
1608
|
+
const transport = (await bridge.send("getTransport"));
|
|
1609
|
+
if (typeof transport?.bpm === "number")
|
|
1610
|
+
effectiveBpm = transport.bpm;
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
let metrics;
|
|
1614
|
+
try {
|
|
1615
|
+
metrics = analyzeMotion(decodeWav(await readFile(path)), effectiveBpm);
|
|
1616
|
+
}
|
|
1617
|
+
catch (e) {
|
|
1618
|
+
return error(`Could not analyze ${path}: ${e.message}`);
|
|
1619
|
+
}
|
|
1620
|
+
return json({
|
|
1621
|
+
file: path,
|
|
1622
|
+
bpm: effectiveBpm ?? null,
|
|
1623
|
+
...metrics,
|
|
1624
|
+
reading: "static:true means nothing measurably changes — brightness, per-bar level and within-bar level are all flat. " +
|
|
1625
|
+
"centroidVariability and perBarCentroidVariability catch slow filter/timbre motion; intraBarRmsVariability " +
|
|
1626
|
+
"catches per-hit and per-beat movers (gate, tremolo, sidechain duck). A preset can pass the modulation-count " +
|
|
1627
|
+
"check and still fail here when depths are too small to hear.",
|
|
1628
|
+
});
|
|
1629
|
+
});
|
|
1630
|
+
// What to change when a pair of variations is identical on a given dimension.
|
|
1631
|
+
// The dimension name alone is not actionable; this maps it to a move.
|
|
1632
|
+
const VARIATION_FIX_HINTS = {
|
|
1633
|
+
groove: "rewrite the note patterns — different onsets, not just different density",
|
|
1634
|
+
params: "change the sound, not the mix: filter type, chop window (startPos/endPos), stretch, or a different FX",
|
|
1635
|
+
mod: "change what MOVES — retarget the front modulator or swap the mover type",
|
|
1636
|
+
pitch: "transpose or re-voice the melodic parts",
|
|
1637
|
+
fx: "enable or bypass a different effect on at least one cell",
|
|
1638
|
+
chains: "reorder one cell's FX chain (set_effect_order)",
|
|
1639
|
+
sources: "swap a sample on at least one cell",
|
|
1640
|
+
};
|
|
1641
|
+
server.registerTool("variation_distinctness", {
|
|
1642
|
+
title: "Check Variation Distinctness",
|
|
1643
|
+
description: "Measure whether a preset's variation slots are actually different, and name what is identical about the " +
|
|
1644
|
+
"pairs that are not. Structural comparison of the saved slot snapshots — no render, no transport, no " +
|
|
1645
|
+
"audition loop, and no risk of the load_variation race.\n\n" +
|
|
1646
|
+
`Read scores against the shipped corpus, where sibling variation pairs sit at a median of ` +
|
|
1647
|
+
`${CORPUS_VARIATION_BASELINE.p50} similarity (p75 ${CORPUS_VARIATION_BASELINE.p75}, p90 ${CORPUS_VARIATION_BASELINE.p90}). ` +
|
|
1648
|
+
`Siblings are far more alike than two different presets by construction, so the preset_novelty scale does ` +
|
|
1649
|
+
`not apply. ${CORPUS_VARIATION_BASELINE.nearIdenticalShare}% of shipped pairs are already near-identical — ` +
|
|
1650
|
+
"this gate exists to stop adding to that number.\n\n" +
|
|
1651
|
+
"Each failing pair reports the dimensions it duplicates plus the move that would fix it.",
|
|
1652
|
+
inputSchema: {
|
|
1653
|
+
source: z
|
|
1654
|
+
.enum(["live", "file"])
|
|
1655
|
+
.default("live")
|
|
1656
|
+
.describe("'live' reads the running plugin's variation slots; 'file' reads them from a .preset."),
|
|
1657
|
+
path: z.string().optional().describe("Preset path for source 'file'."),
|
|
1658
|
+
threshold: z
|
|
1659
|
+
.number()
|
|
1660
|
+
.min(0.5)
|
|
1661
|
+
.max(1)
|
|
1662
|
+
.default(0.9)
|
|
1663
|
+
.describe(`Similarity above which a pair fails. Default 0.9 — worse than 75% of shipped sibling pairs (p75 ${CORPUS_VARIATION_BASELINE.p75}).`),
|
|
1664
|
+
},
|
|
1665
|
+
annotations: { readOnlyHint: true },
|
|
1666
|
+
}, async ({ source, path: presetPath, threshold }) => {
|
|
1667
|
+
const manifest = await loadManifest();
|
|
1668
|
+
const defaults = {};
|
|
1669
|
+
for (const p of manifest.parameters) {
|
|
1670
|
+
const m = /^cell1_(.+)$/.exec(p.paramId);
|
|
1671
|
+
if (m)
|
|
1672
|
+
defaults[m[1]] = p.defaultValue;
|
|
1673
|
+
}
|
|
1674
|
+
const effectNames = cellEffectNames(manifest);
|
|
1675
|
+
let slots;
|
|
1676
|
+
let label;
|
|
1677
|
+
if (source === "file") {
|
|
1678
|
+
if (!presetPath)
|
|
1679
|
+
return error("source 'file' requires a `path`.");
|
|
1680
|
+
try {
|
|
1681
|
+
const full = await resolvePresetPath(presetPath);
|
|
1682
|
+
const parsed = parsePresetFile(await readFile(full, "utf-8"));
|
|
1683
|
+
slots = variationsFromParsedPreset(parsed);
|
|
1684
|
+
label = parsed.metadata.name || basename(full, ".preset");
|
|
1685
|
+
}
|
|
1686
|
+
catch (e) {
|
|
1687
|
+
return error(`Failed to read preset: ${e.message}`);
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
else {
|
|
1691
|
+
const check = await requireBridge();
|
|
1692
|
+
if (check !== true)
|
|
1693
|
+
return check;
|
|
1694
|
+
const state = (await bridge.send("getState"));
|
|
1695
|
+
const raw = Array.isArray(state.variations) ? state.variations : [];
|
|
1696
|
+
slots = Array.from({ length: PRESET_VARIATION_SLOTS }, (_, i) => typeof raw[i] === "string" ? decompressVariationEntry(raw[i]) : null);
|
|
1697
|
+
label = "live state";
|
|
1698
|
+
}
|
|
1699
|
+
const filled = [];
|
|
1700
|
+
for (const [i, slot] of slots.entries()) {
|
|
1701
|
+
if (typeof slot !== "string" || slot.length < 10)
|
|
1702
|
+
continue;
|
|
1703
|
+
try {
|
|
1704
|
+
filled.push({ slot: i, fp: buildFingerprint(JSON.parse(slot), defaults, effectNames, `slot ${i}`) });
|
|
1705
|
+
}
|
|
1706
|
+
catch {
|
|
1707
|
+
/* unreadable slot — reported via the occupancy count below */
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
if (filled.length < 2) {
|
|
1711
|
+
return error(`Only ${filled.length} readable variation slot(s) — need at least 2 to compare. ` +
|
|
1712
|
+
"Save more slots with save_variation (API indices are 0-based; UI labels them 1-8).");
|
|
1713
|
+
}
|
|
1714
|
+
const pairs = [];
|
|
1715
|
+
for (let i = 0; i < filled.length; i += 1) {
|
|
1716
|
+
for (let j = i + 1; j < filled.length; j += 1) {
|
|
1717
|
+
const sim = fingerprintSimilarity(filled[i].fp, filled[j].fp);
|
|
1718
|
+
const identical = Object.entries(sim.byDimension)
|
|
1719
|
+
.filter(([, v]) => v >= 0.95)
|
|
1720
|
+
.sort((a, b) => b[1] - a[1])
|
|
1721
|
+
.map(([k]) => k);
|
|
1722
|
+
pairs.push({
|
|
1723
|
+
pair: `slot ${filled[i].slot} vs slot ${filled[j].slot}`,
|
|
1724
|
+
similarity: Math.round(sim.overall * 1000) / 1000,
|
|
1725
|
+
pass: sim.overall < threshold,
|
|
1726
|
+
identicalDimensions: identical,
|
|
1727
|
+
fix: sim.overall < threshold ? undefined : identical.map((d) => VARIATION_FIX_HINTS[d] ?? d),
|
|
1728
|
+
});
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
const failing = pairs.filter((p) => !p.pass).sort((a, b) => b.similarity - a.similarity);
|
|
1732
|
+
return json({
|
|
1733
|
+
subject: label,
|
|
1734
|
+
filledSlots: filled.map((f) => f.slot),
|
|
1735
|
+
threshold,
|
|
1736
|
+
pass: failing.length === 0,
|
|
1737
|
+
verdict: failing.length === 0
|
|
1738
|
+
? `All ${pairs.length} slot pairs differ by more than the ${threshold} threshold.`
|
|
1739
|
+
: `${failing.length} of ${pairs.length} slot pairs are too similar. Give one slot in each a real change — density alone is not a variation.`,
|
|
1740
|
+
failingPairs: failing,
|
|
1741
|
+
allPairs: pairs,
|
|
1742
|
+
corpusBaseline: CORPUS_VARIATION_BASELINE,
|
|
1743
|
+
// Shipped variations move these dimensions and leave those alone — useful
|
|
1744
|
+
// when deciding WHICH dimension to change.
|
|
1745
|
+
corpusHabit: "Shipped variations mostly change modulation and groove; they almost never change samples or FX chain order. Those two are therefore the least-used lever available to you.",
|
|
1065
1746
|
});
|
|
1066
1747
|
});
|
|
1067
1748
|
// Standard full update — graph dispatch + KV sync + parameter updates.
|
|
@@ -1195,9 +1876,8 @@ server.registerTool("get_design_overview", {
|
|
|
1195
1876
|
]);
|
|
1196
1877
|
const state = isRecord(stateRaw) ? stateRaw : {};
|
|
1197
1878
|
const transport = isRecord(transportRaw) ? transportRaw : {};
|
|
1198
|
-
//
|
|
1199
|
-
const
|
|
1200
|
-
const effectNames = derivedEffects.length ? derivedEffects : CELL_EFFECTS_FALLBACK;
|
|
1879
|
+
// The manifest is authoritative; every current product carries both character effects.
|
|
1880
|
+
const effectNames = cellEffectNames(manifest);
|
|
1201
1881
|
const manifestCellIds = [...new Set(manifest.parameters
|
|
1202
1882
|
.map((p) => p.cell)
|
|
1203
1883
|
.filter((cell) => typeof cell === "string" && /^cell\d+$/.test(cell))
|
|
@@ -1210,6 +1890,49 @@ server.registerTool("get_design_overview", {
|
|
|
1210
1890
|
vary: Array.isArray(state.vary) ? state.vary : [],
|
|
1211
1891
|
paramSeq: Array.isArray(state.paramSeqs) ? state.paramSeqs : [],
|
|
1212
1892
|
};
|
|
1893
|
+
if (IS_MFX_SERVER) {
|
|
1894
|
+
const cells = ids.map((id) => {
|
|
1895
|
+
const prefix = `cell${id}_`;
|
|
1896
|
+
const targetPrefix = new Set([prefix]);
|
|
1897
|
+
const storedOrder = state[`${prefix}effectOrder`];
|
|
1898
|
+
const rawCell = cellsObj[String(id)];
|
|
1899
|
+
const cellConfig = isRecord(rawCell) ? rawCell : {};
|
|
1900
|
+
return {
|
|
1901
|
+
cellId: id,
|
|
1902
|
+
muted: state[`${prefix}mute`] === 1 || state[`${prefix}mute`] === true,
|
|
1903
|
+
sidechainFrom: cellConfig.sidechainFrom ?? state[`${prefix}sidechainFrom`],
|
|
1904
|
+
effectOrder: Array.isArray(storedOrder) ? storedOrder : cellEffectOrder(manifest),
|
|
1905
|
+
enabledEffects: effectNames.filter((effectName) => state[`${prefix}${effectName}Enabled`] === 1 || state[`${prefix}${effectName}Enabled`] === true),
|
|
1906
|
+
modulation: Object.fromEntries(Object.entries(modArrays).map(([kind, arr]) => [
|
|
1907
|
+
kind,
|
|
1908
|
+
arr.filter((entry) => paramTargetsCell(entry, targetPrefix)).length,
|
|
1909
|
+
])),
|
|
1910
|
+
mixer: {
|
|
1911
|
+
volume: state[`${prefix}volume`],
|
|
1912
|
+
pan: state[`${prefix}pan`],
|
|
1913
|
+
gain: state[`${prefix}gainGain`],
|
|
1914
|
+
},
|
|
1915
|
+
};
|
|
1916
|
+
});
|
|
1917
|
+
return json({
|
|
1918
|
+
product: manifest.name,
|
|
1919
|
+
bpm: state.bpm ?? transport.bpm,
|
|
1920
|
+
playing: transport.playing,
|
|
1921
|
+
mix: state.mfx_mix,
|
|
1922
|
+
routing: state.mfx_routing,
|
|
1923
|
+
edgeGains: state.mfx_edge_gains,
|
|
1924
|
+
split: { lowHz: state.mfx_split_lo_hz, highHz: state.mfx_split_hi_hz },
|
|
1925
|
+
combos: resolveServerMfxCombos(state, manifest).map((combo) => ({
|
|
1926
|
+
...combo,
|
|
1927
|
+
value: state[`mfx_combo${combo.index}`],
|
|
1928
|
+
})),
|
|
1929
|
+
totals: {
|
|
1930
|
+
enabledEffects: cells.reduce((sum, cell) => sum + cell.enabledEffects.length, 0),
|
|
1931
|
+
modulationLinks: cells.reduce((sum, cell) => sum + Object.values(cell.modulation).reduce((subtotal, count) => subtotal + count, 0), 0),
|
|
1932
|
+
},
|
|
1933
|
+
cells,
|
|
1934
|
+
});
|
|
1935
|
+
}
|
|
1213
1936
|
const cells = ids.map((id) => {
|
|
1214
1937
|
const prefix = `cell${id}_`;
|
|
1215
1938
|
const rawCellState = cellsObj[String(id)];
|
|
@@ -1224,12 +1947,14 @@ server.registerTool("get_design_overview", {
|
|
|
1224
1947
|
]));
|
|
1225
1948
|
return {
|
|
1226
1949
|
cellId: id,
|
|
1950
|
+
muted: state[`${prefix}mute`] === 1 || state[`${prefix}mute`] === true,
|
|
1227
1951
|
sample: {
|
|
1228
1952
|
name: stemName(samplePath),
|
|
1229
1953
|
path: typeof samplePath === "string" ? samplePath : undefined,
|
|
1230
1954
|
},
|
|
1231
1955
|
// Both halves: the enum an agent writes back, and the name it reasons about.
|
|
1232
1956
|
synthEngine: describeSynthEngine(state[`${prefix}synthEngine`]),
|
|
1957
|
+
player: describeCellPlayer(id, cellState, state),
|
|
1233
1958
|
enabledEffects: effectNames.filter((effectName) => state[`${prefix}${effectName}Enabled`] === 1 || state[`${prefix}${effectName}Enabled`] === true),
|
|
1234
1959
|
patternEvents: Object.fromEntries(Object.entries(PATTERN_LANES).map(([lane, suffix]) => [
|
|
1235
1960
|
lane,
|
|
@@ -1254,6 +1979,7 @@ server.registerTool("get_design_overview", {
|
|
|
1254
1979
|
},
|
|
1255
1980
|
totals: {
|
|
1256
1981
|
cellsWithSamples: cells.filter((c) => c.sample.path).length,
|
|
1982
|
+
playersRunning: cells.filter((c) => c.player).length,
|
|
1257
1983
|
activePatternEvents: cells.reduce((sum, c) => sum + Object.values(c.patternEvents).reduce((a, b) => a + b, 0), 0),
|
|
1258
1984
|
modulationLinks: cells.reduce((sum, c) => sum + Object.values(c.modulation).reduce((a, b) => a + b, 0), 0),
|
|
1259
1985
|
},
|
|
@@ -1264,7 +1990,8 @@ server.registerTool("set_parameters", {
|
|
|
1264
1990
|
title: "Set Parameters",
|
|
1265
1991
|
description: "Set one or more PAM parameters. Values are validated against the manifest. " +
|
|
1266
1992
|
"Effect enable flags such as cell1_filterEnabled are settable here too, so params + enables can land in one call " +
|
|
1267
|
-
"when a graph dispatch is acceptable. Use get_parameter_info or design_context to discover valid IDs and ranges."
|
|
1993
|
+
"when a graph dispatch is acceptable. Use get_parameter_info or design_context to discover valid IDs and ranges. " +
|
|
1994
|
+
"Writing a macro_* id also enables that macro, matching the UI.",
|
|
1268
1995
|
inputSchema: {
|
|
1269
1996
|
parameters: z
|
|
1270
1997
|
.record(z.string(), z.number())
|
|
@@ -1308,6 +2035,36 @@ server.registerTool("set_parameters", {
|
|
|
1308
2035
|
}
|
|
1309
2036
|
delta.macros = macros;
|
|
1310
2037
|
}
|
|
2038
|
+
let comboTargetCount = 0;
|
|
2039
|
+
if (isMfxManifest(await loadManifest())) {
|
|
2040
|
+
const comboValues = new Map();
|
|
2041
|
+
for (let comboIndex = 1; comboIndex <= MFX_COMBO_COUNT; comboIndex++) {
|
|
2042
|
+
const value = parameters[`mfx_combo${comboIndex}`];
|
|
2043
|
+
if (typeof value === "number")
|
|
2044
|
+
comboValues.set(comboIndex, value);
|
|
2045
|
+
}
|
|
2046
|
+
if (comboValues.size > 0) {
|
|
2047
|
+
const stateRaw = await bridge.send("getState");
|
|
2048
|
+
const state = isRecord(stateRaw) ? stateRaw : {};
|
|
2049
|
+
const manifest = await loadManifest();
|
|
2050
|
+
for (const combo of resolveServerMfxCombos(state, manifest)) {
|
|
2051
|
+
const value = comboValues.get(combo.index);
|
|
2052
|
+
if (value === undefined)
|
|
2053
|
+
continue;
|
|
2054
|
+
if (combo.targets.some((target) => Object.hasOwn(parameters, target.id)))
|
|
2055
|
+
continue;
|
|
2056
|
+
for (const target of combo.targets) {
|
|
2057
|
+
const info = index.get(target.id);
|
|
2058
|
+
if (!info)
|
|
2059
|
+
continue;
|
|
2060
|
+
const previousComboValue = state[`mfx_combo${combo.index}`];
|
|
2061
|
+
const currentTargetValue = state[target.id];
|
|
2062
|
+
delta[target.id] = mfxComboTargetValueFromCurrent(target, typeof previousComboValue === "number" ? previousComboValue : 0, value, info, typeof currentTargetValue === "number" ? currentTargetValue : info.defaultValue);
|
|
2063
|
+
comboTargetCount++;
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
1311
2068
|
await bridge.send("applyUnifiedStateKvUpdate", {
|
|
1312
2069
|
delta,
|
|
1313
2070
|
options: {
|
|
@@ -1319,17 +2076,18 @@ server.registerTool("set_parameters", {
|
|
|
1319
2076
|
broadcastFollowers: true,
|
|
1320
2077
|
},
|
|
1321
2078
|
});
|
|
1322
|
-
return text(`Set ${Object.keys(parameters).length} parameter(s): ${Object.entries(parameters).map(([k, v]) => `${k}=${v}`).join(", ")}`
|
|
2079
|
+
return text(`Set ${Object.keys(parameters).length} parameter(s): ${Object.entries(parameters).map(([k, v]) => `${k}=${v}`).join(", ")}` +
|
|
2080
|
+
(comboTargetCount > 0 ? `; fanned out ${comboTargetCount} MFX combo target write(s)` : ""));
|
|
1323
2081
|
});
|
|
1324
2082
|
server.registerTool("load_sample", {
|
|
1325
2083
|
title: "Load Sample",
|
|
1326
|
-
description: "Load an audio file (wav, mp3, aif, flac) into a PAM cell.",
|
|
2084
|
+
description: "Load an audio file (wav, mp3, aif, flac) into a PAM cell, replacing whatever the cell currently holds. Take filePath from list_samples or a get_default_locations root.",
|
|
1327
2085
|
inputSchema: {
|
|
1328
2086
|
filePath: z.string().describe("Path to the audio file (absolute, or relative as returned by list_samples)."),
|
|
1329
2087
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
1330
2088
|
},
|
|
1331
2089
|
}, async ({ filePath, cellId }) => {
|
|
1332
|
-
const check = await
|
|
2090
|
+
const check = await requireBridgeForCells(cellId);
|
|
1333
2091
|
if (check !== true)
|
|
1334
2092
|
return check;
|
|
1335
2093
|
await bridge.send("loadSampleToCell", filePath, cellId);
|
|
@@ -1341,7 +2099,7 @@ server.registerTool("load_sample", {
|
|
|
1341
2099
|
// + experimental<Engine>) — the bridge returns a clear error otherwise.
|
|
1342
2100
|
const MODEL_ENGINE = z
|
|
1343
2101
|
.enum(["notochord", "aigen", "magenta"])
|
|
1344
|
-
.describe("Which AI engine. notochord = MIDI co-player (~18 MB blob); aigen = Stable Audio 3 sample generation (
|
|
2102
|
+
.describe("Which AI engine. notochord = MIDI co-player (~18 MB blob); aigen = Stable Audio 3 sample generation (2.71 GB); magenta = MagentaRT realtime audio (Apple Silicon, MLX).");
|
|
1345
2103
|
const MODEL_VARIANT = z
|
|
1346
2104
|
.enum(["base", "small"])
|
|
1347
2105
|
.optional()
|
|
@@ -1418,7 +2176,7 @@ server.registerTool("list_models", {
|
|
|
1418
2176
|
});
|
|
1419
2177
|
server.registerTool("generate_sample", {
|
|
1420
2178
|
title: "Generate Sample (AI)",
|
|
1421
|
-
description: "Generate a new audio sample via the on-device AI engine (Stable Audio 3 Small SFX,
|
|
2179
|
+
description: "Generate a new audio sample via the on-device AI engine (Stable Audio 3 Small SFX, 2.71 GB). The plugin must have the model downloaded first (Settings → Local models). Returns the on-disk path to the generated WAV and optionally loads it into a cell.",
|
|
1422
2180
|
inputSchema: {
|
|
1423
2181
|
category: z
|
|
1424
2182
|
.enum(["kick", "snare", "hat", "perc", "bass", "synth", "pad", "fx", "loop"])
|
|
@@ -1502,6 +2260,7 @@ server.registerTool("generate_kit", {
|
|
|
1502
2260
|
// curve / prompt / seed / sigma), sidecar renders, and the take shelf. Requires
|
|
1503
2261
|
// experimentalMode + experimentalAIGen (update_settings) AND the SA3 Python
|
|
1504
2262
|
// sidecar running on 127.0.0.1:8093 (packages/sa3-workbench-sidecar/README.md).
|
|
2263
|
+
const WORKBENCH_PREREQ = "Requires experimentalMode + experimentalAIGen (update_settings) and the SA3 sidecar on 127.0.0.1:8093; check workbench_sidecar_status first. ";
|
|
1505
2264
|
const WORKBENCH_MASK_REGION = z.object({
|
|
1506
2265
|
startSec: z.number().min(0).describe("Region start in seconds (source time)."),
|
|
1507
2266
|
endSec: z.number().min(0).describe("Region end in seconds (must be > startSec)."),
|
|
@@ -1537,7 +2296,8 @@ const WORKBENCH_STATE_FIELDS = {
|
|
|
1537
2296
|
};
|
|
1538
2297
|
server.registerTool("workbench_get_state", {
|
|
1539
2298
|
title: "Workbench: Get State",
|
|
1540
|
-
description:
|
|
2299
|
+
description: WORKBENCH_PREREQ +
|
|
2300
|
+
"Read the AI Audio Workbench canvas state AND the take shelf: { state: { sourceCellId, prompt, seed, seedLocked, sigma, steps, maskRegions, strengthCurve, activeSourcePath, sketchPath, sketchGain }, takes: [...] }.",
|
|
1541
2301
|
inputSchema: {},
|
|
1542
2302
|
}, async () => {
|
|
1543
2303
|
const check = await requireBridge();
|
|
@@ -1547,7 +2307,8 @@ server.registerTool("workbench_get_state", {
|
|
|
1547
2307
|
});
|
|
1548
2308
|
server.registerTool("workbench_set_state", {
|
|
1549
2309
|
title: "Workbench: Set State",
|
|
1550
|
-
description:
|
|
2310
|
+
description: WORKBENCH_PREREQ +
|
|
2311
|
+
"Partial-merge the Workbench canvas state (only the keys you pass change). Also pushes the merged state into an open Workbench UI. Returns the full merged state.",
|
|
1551
2312
|
inputSchema: WORKBENCH_STATE_FIELDS,
|
|
1552
2313
|
}, async (args) => {
|
|
1553
2314
|
const check = await requireBridge();
|
|
@@ -1557,7 +2318,8 @@ server.registerTool("workbench_set_state", {
|
|
|
1557
2318
|
});
|
|
1558
2319
|
server.registerTool("workbench_render", {
|
|
1559
2320
|
title: "Workbench: Render",
|
|
1560
|
-
description:
|
|
2321
|
+
description: WORKBENCH_PREREQ +
|
|
2322
|
+
"Render the Workbench transform via the SA3 sidecar and append the result to the take shelf. Renders the CURRENT canvas state merged with any overrides passed here (overrides do not persist). Blocks until done (up to ~3 min: pipeline rebuilds + multi-region inpaints render sequentially). Returns the take { id, wavPath, prompt, seed, sigma, maskRegions, createdAt, sourceCellId, durationSeconds, error? }. Source resolution: sourcePath override > activeSourcePath (chain head) > state.sourceCellId's sample file. A successful render ADVANCES the chain (activeSourcePath = new take's wavPath) — step back with workbench_undo or workbench_reset_chain.",
|
|
1561
2323
|
inputSchema: {
|
|
1562
2324
|
...WORKBENCH_STATE_FIELDS,
|
|
1563
2325
|
sourcePath: z.string().optional()
|
|
@@ -1576,7 +2338,7 @@ server.registerTool("workbench_render", {
|
|
|
1576
2338
|
});
|
|
1577
2339
|
server.registerTool("workbench_list_takes", {
|
|
1578
2340
|
title: "Workbench: List Takes",
|
|
1579
|
-
description: "List all Workbench takes (renders) with full provenance, oldest first.",
|
|
2341
|
+
description: WORKBENCH_PREREQ + "List all Workbench takes (renders) with full provenance, oldest first.",
|
|
1580
2342
|
inputSchema: {},
|
|
1581
2343
|
}, async () => {
|
|
1582
2344
|
const check = await requireBridge();
|
|
@@ -1586,13 +2348,13 @@ server.registerTool("workbench_list_takes", {
|
|
|
1586
2348
|
});
|
|
1587
2349
|
server.registerTool("workbench_load_take", {
|
|
1588
2350
|
title: "Workbench: Load Take",
|
|
1589
|
-
description: "Load a rendered take's WAV into a cell (same native path as load_sample).",
|
|
2351
|
+
description: WORKBENCH_PREREQ + "Load a rendered take's WAV into a cell (same native path as load_sample).",
|
|
1590
2352
|
inputSchema: {
|
|
1591
2353
|
takeId: z.string().describe("Take id from workbench_render / workbench_list_takes."),
|
|
1592
2354
|
cellId: z.number().int().min(1).max(8).describe("Target cell."),
|
|
1593
2355
|
},
|
|
1594
2356
|
}, async ({ takeId, cellId }) => {
|
|
1595
|
-
const check = await
|
|
2357
|
+
const check = await requireBridgeForCells(cellId);
|
|
1596
2358
|
if (check !== true)
|
|
1597
2359
|
return check;
|
|
1598
2360
|
try {
|
|
@@ -1604,7 +2366,8 @@ server.registerTool("workbench_load_take", {
|
|
|
1604
2366
|
});
|
|
1605
2367
|
server.registerTool("workbench_undo", {
|
|
1606
2368
|
title: "Workbench: Undo (pop chain)",
|
|
1607
|
-
description:
|
|
2369
|
+
description: WORKBENCH_PREREQ +
|
|
2370
|
+
"Pop the Workbench render chain one step: restores the previous chain head (activeSourcePath) so the next render re-transforms the prior source. No-op at the chain root (cell sample). Takes are kept. Returns the full { state, takes }.",
|
|
1608
2371
|
inputSchema: {},
|
|
1609
2372
|
}, async () => {
|
|
1610
2373
|
const check = await requireBridge();
|
|
@@ -1614,7 +2377,8 @@ server.registerTool("workbench_undo", {
|
|
|
1614
2377
|
});
|
|
1615
2378
|
server.registerTool("workbench_reset_chain", {
|
|
1616
2379
|
title: "Workbench: Reset Chain",
|
|
1617
|
-
description:
|
|
2380
|
+
description: WORKBENCH_PREREQ +
|
|
2381
|
+
"Clear the Workbench render chain: empties the undo stack and resets the source back to the cell sample (activeSourcePath = ''). Takes are kept. Returns the full { state, takes }.",
|
|
1618
2382
|
inputSchema: {},
|
|
1619
2383
|
}, async () => {
|
|
1620
2384
|
const check = await requireBridge();
|
|
@@ -1649,7 +2413,7 @@ server.registerTool("trigger_midi", {
|
|
|
1649
2413
|
channel: z.number().min(1).max(16).default(1).describe("MIDI channel (1-16)."),
|
|
1650
2414
|
},
|
|
1651
2415
|
}, async ({ cellId, note, noteOn, channel }) => {
|
|
1652
|
-
const check = await
|
|
2416
|
+
const check = await requireBridgeForCells(cellId);
|
|
1653
2417
|
if (check !== true)
|
|
1654
2418
|
return check;
|
|
1655
2419
|
if (cellId) {
|
|
@@ -1667,7 +2431,7 @@ server.registerTool("stop_cell", {
|
|
|
1667
2431
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
1668
2432
|
},
|
|
1669
2433
|
}, async ({ cellId }) => {
|
|
1670
|
-
const check = await
|
|
2434
|
+
const check = await requireBridgeForCells(cellId);
|
|
1671
2435
|
if (check !== true)
|
|
1672
2436
|
return check;
|
|
1673
2437
|
await bridge.send("stopCell", cellId);
|
|
@@ -1675,17 +2439,31 @@ server.registerTool("stop_cell", {
|
|
|
1675
2439
|
});
|
|
1676
2440
|
server.registerTool("load_preset", {
|
|
1677
2441
|
title: "Load Preset",
|
|
1678
|
-
description: "
|
|
2442
|
+
description: "Queue a preset load through PAM's native load/morph lifecycle. Returns requestId and status:'queued'. " +
|
|
2443
|
+
"Poll get_preset_load_status until status:'succeeded' before editing, randomizing, or saving; failure, cancellation, and supersession are explicit. " +
|
|
2444
|
+
"Replaces working state and the variation bank; save_preset first if unsaved edits matter. Requires a plugin build with tracked preset loading.",
|
|
1679
2445
|
inputSchema: {
|
|
1680
2446
|
path: z.string().describe("Preset file path (absolute or relative to preset dirs)."),
|
|
2447
|
+
immediate: z.boolean().optional().describe("true bypasses the preset morph; false/omitted follows the native preset morph settings."),
|
|
1681
2448
|
},
|
|
1682
|
-
}, async ({ path: presetPath }) => {
|
|
2449
|
+
}, async ({ path: presetPath, immediate }) => {
|
|
1683
2450
|
const check = await requireBridge();
|
|
1684
2451
|
if (check !== true)
|
|
1685
2452
|
return check;
|
|
1686
2453
|
const fullPath = await resolvePresetPath(presetPath);
|
|
1687
|
-
await bridge.send("
|
|
1688
|
-
|
|
2454
|
+
return json(await bridge.send("loadPresetTracked", fullPath, immediate === true));
|
|
2455
|
+
});
|
|
2456
|
+
server.registerTool("get_preset_load_status", {
|
|
2457
|
+
title: "Get Preset Load Status",
|
|
2458
|
+
description: "Read the outcome of a load_preset request without requiring the plugin window. " +
|
|
2459
|
+
"Statuses: queued, morphing, succeeded, failed, cancelled, superseded. Only succeeded confirms the world committed, including same-path reloads. " +
|
|
2460
|
+
"PAM retains the latest 64 requests per instance; unknown/expired IDs return an error. Keep the same selected instance while polling.",
|
|
2461
|
+
inputSchema: { requestId: z.string().min(1).describe("requestId returned by load_preset.") },
|
|
2462
|
+
}, async ({ requestId }) => {
|
|
2463
|
+
const check = await requireBridge();
|
|
2464
|
+
if (check !== true)
|
|
2465
|
+
return check;
|
|
2466
|
+
return json(await bridge.send("getPresetLoadStatus", requestId));
|
|
1689
2467
|
});
|
|
1690
2468
|
server.registerTool("add_modulation", {
|
|
1691
2469
|
title: "Add Modulation",
|
|
@@ -1733,7 +2511,7 @@ server.registerTool("add_modulation", {
|
|
|
1733
2511
|
return error(formatUnknownParamError(paramId, index, { modulatableOnly: true }));
|
|
1734
2512
|
if (!param.isModulatable)
|
|
1735
2513
|
return error(formatNotModulatableError(paramId, index));
|
|
1736
|
-
const check = await
|
|
2514
|
+
const check = await requireBridgeForCells(cellId);
|
|
1737
2515
|
if (check !== true)
|
|
1738
2516
|
return check;
|
|
1739
2517
|
// Compute effect-enabled params to auto-enable disabled effects (mirrors UI behavior)
|
|
@@ -1772,7 +2550,7 @@ server.registerTool("update_pattern", {
|
|
|
1772
2550
|
"For 'pitch'/'slice'/'stretch': [{time, value, duration}]."),
|
|
1773
2551
|
},
|
|
1774
2552
|
}, async ({ cellId, type, sequence }) => {
|
|
1775
|
-
const check = await
|
|
2553
|
+
const check = await requireBridgeForCells(cellId);
|
|
1776
2554
|
if (check !== true)
|
|
1777
2555
|
return check;
|
|
1778
2556
|
// Map pattern type to state key suffix
|
|
@@ -1829,7 +2607,10 @@ server.registerTool("randomize", {
|
|
|
1829
2607
|
description: "Trigger PAM's randomization engine to generate random sounds and patterns. " +
|
|
1830
2608
|
"target:'synth' runs the musical, engine-aware drum-synth randomizer (picks an engine + " +
|
|
1831
2609
|
"shapes its params per cell) — it is routed through the WebView, so it requires PAM's window " +
|
|
1832
|
-
"to be OPEN (samples/sequences/full run natively and do not).
|
|
2610
|
+
"to be OPEN (samples/sequences/full run natively and do not). 'lfos'/'mods' are global and require an open window. " +
|
|
2611
|
+
"Returns queued, not completed: poll get_state for the requested changes before another randomize, edit, or save. " +
|
|
2612
|
+
"Concurrent requests can coalesce; a preset/variation load can discard pending work. " +
|
|
2613
|
+
"Use target:'full' to chain sample selection and pattern generation in the native engine.",
|
|
1833
2614
|
inputSchema: {
|
|
1834
2615
|
// 'lfos'/'mods' added for the assistant surface (1.15.0) — WebView-routed
|
|
1835
2616
|
// like 'synth'. The describe() text is pinned (exported toolpack bytes).
|
|
@@ -1840,7 +2621,7 @@ server.registerTool("randomize", {
|
|
|
1840
2621
|
cellIds: z
|
|
1841
2622
|
.array(z.number().min(1).max(8))
|
|
1842
2623
|
.optional()
|
|
1843
|
-
.describe("Specific cells to randomize. Omit for all."),
|
|
2624
|
+
.describe("Specific cells to randomize. Omit for all. Not supported for global 'lfos'/'mods'."),
|
|
1844
2625
|
genreHint: z
|
|
1845
2626
|
.string()
|
|
1846
2627
|
.optional()
|
|
@@ -1857,32 +2638,44 @@ server.registerTool("randomize", {
|
|
|
1857
2638
|
.max(1)
|
|
1858
2639
|
.optional()
|
|
1859
2640
|
.describe("Mutation amount for pattern randomization (0-1). Lower = subtler changes."),
|
|
2641
|
+
density: z
|
|
2642
|
+
.number()
|
|
2643
|
+
.min(-1)
|
|
2644
|
+
.max(1)
|
|
2645
|
+
.optional()
|
|
2646
|
+
.describe("Pattern density for 'sequences'/'full' (-1 = sparse, 0 = neutral, 1 = dense). Omit for the genre and role default."),
|
|
2647
|
+
syncopation: z
|
|
2648
|
+
.number()
|
|
2649
|
+
.min(-1)
|
|
2650
|
+
.max(1)
|
|
2651
|
+
.optional()
|
|
2652
|
+
.describe("Rhythmic placement for 'sequences'/'full' (-1 = strong beats, 0 = neutral, 1 = weaker offbeats). Omit for the genre and role default."),
|
|
1860
2653
|
},
|
|
1861
|
-
}, async ({ target, cellIds, genreHint, bpmHint, mutationAmount }) => {
|
|
1862
|
-
|
|
2654
|
+
}, async ({ target, cellIds, genreHint, bpmHint, mutationAmount, density, syncopation }) => {
|
|
2655
|
+
if ((target === "lfos" || target === "mods") && cellIds !== undefined)
|
|
2656
|
+
return error(`target:'${target}' randomizes global modulation; omit cellIds or edit individual links with update_modulation.`);
|
|
2657
|
+
const check = await requireBridgeForCells(cellIds);
|
|
1863
2658
|
if (check !== true)
|
|
1864
2659
|
return check;
|
|
1865
2660
|
if (target === "synth") {
|
|
1866
2661
|
// Routed to the canonical WebView randomizer (single source of truth) — needs the
|
|
1867
2662
|
// window open. Empty array → all non-excluded cells.
|
|
1868
2663
|
await bridge.send("randomizeDrumSynth", cellIds ?? []);
|
|
1869
|
-
return
|
|
1870
|
-
`(Drum-synth randomize runs in the WebView — if nothing changed, ensure PAM's window is open.)`);
|
|
2664
|
+
return json({ status: "queued", target, cellIds, note: "Open PAM's window and poll get_design_overview for synth changes before editing or saving." });
|
|
1871
2665
|
}
|
|
1872
2666
|
if (target === "lfos" || target === "mods") {
|
|
1873
|
-
// The data-driven modulation randomizer lives in WebView JS
|
|
1874
|
-
// (interop.ts randomizeLfos/randomizeEnvelopes/randomizeVary) — routed
|
|
1875
|
-
// like 'synth'. Needs PAM ≥ 1.15.0 and the window open.
|
|
1876
2667
|
await bridge.send("randomizeModulations", target, mutationAmount ?? -1);
|
|
1877
|
-
return
|
|
1878
|
-
`(Runs in the WebView — if nothing changed, ensure PAM's window is open.)`);
|
|
2668
|
+
return json({ status: "queued", target, note: "Open PAM's window and poll get_state for modulation changes before editing or saving." });
|
|
1879
2669
|
}
|
|
1880
|
-
await bridge.send("randomize", target, cellIds ?? [], genreHint ?? "", bpmHint ?? -1, mutationAmount ?? -1);
|
|
1881
|
-
return
|
|
2670
|
+
await bridge.send("randomize", target, cellIds ?? [], genreHint ?? "", bpmHint ?? -1, mutationAmount ?? -1, density ?? null, syncopation ?? null);
|
|
2671
|
+
return json({ status: "queued", target, cellIds, note: "Poll get_state for changed sample paths and/or sequences before another randomize, edit, or save. An acknowledgment does not prove the request applied." });
|
|
1882
2672
|
});
|
|
1883
2673
|
server.registerTool("randomize_samples", {
|
|
1884
2674
|
title: "Randomize Samples",
|
|
1885
|
-
description: "Use PAM's native sample randomization engine to pick coherent samples based on genre, BPM, and kit role.
|
|
2675
|
+
description: "Use PAM's native sample randomization engine to pick coherent samples based on genre, BPM, and kit role. Supports folder filtering. " +
|
|
2676
|
+
"Returns queued: poll get_state section:'cells' for sample changes before generating patterns, editing, or saving. " +
|
|
2677
|
+
"Serialize requests because concurrent requests can coalesce; a preset/variation load can discard pending work. " +
|
|
2678
|
+
"For samples plus patterns, prefer randomize target:'full'.",
|
|
1886
2679
|
inputSchema: {
|
|
1887
2680
|
cellIds: z
|
|
1888
2681
|
.array(z.number().min(1).max(8))
|
|
@@ -1891,7 +2684,7 @@ server.registerTool("randomize_samples", {
|
|
|
1891
2684
|
genreHint: z
|
|
1892
2685
|
.string()
|
|
1893
2686
|
.optional()
|
|
1894
|
-
.describe("Genre hint to guide sample selection. Supported: boom_bap (aliases hip-hop, lofi), trap, drill, house, techno, dnb (aliases jungle, breakbeat, breaks). Other values fall back to neutral role-based selection; omit to auto-detect from the library."),
|
|
2687
|
+
.describe("Genre hint to guide sample selection. Supported: boom_bap (aliases hip-hop, lofi), trap, drill, house, techno, dnb (aliases jungle, breakbeat, breaks), idm (aliases braindance, electronica, breakcore). Other values fall back to neutral role-based selection; omit to auto-detect from the library."),
|
|
1895
2688
|
bpmHint: z
|
|
1896
2689
|
.number()
|
|
1897
2690
|
.min(20)
|
|
@@ -1906,17 +2699,58 @@ server.registerTool("randomize_samples", {
|
|
|
1906
2699
|
.array(z.string())
|
|
1907
2700
|
.optional()
|
|
1908
2701
|
.describe("If provided, only pick samples from these paths. Overrides default library scan."),
|
|
2702
|
+
diversify: z
|
|
2703
|
+
.number()
|
|
2704
|
+
.min(0)
|
|
2705
|
+
.max(1)
|
|
2706
|
+
.optional()
|
|
2707
|
+
.describe("Anti-centroid mode (0 = default behaviour, 1 = maximum spread). The native engine scores FOLDER " +
|
|
2708
|
+
"COHERENCE, which is correct for a kit-from-one-pack and is also why repeated randomizes keep landing " +
|
|
2709
|
+
"on the same few well-stocked folders. Above 0 this builds a cross-folder pool first and restricts the " +
|
|
2710
|
+
"engine to it, keeping role assignment while removing its ability to converge. Ignored when " +
|
|
2711
|
+
"allowedSamplePaths is given."),
|
|
2712
|
+
seed: z
|
|
2713
|
+
.number()
|
|
2714
|
+
.int()
|
|
2715
|
+
.min(0)
|
|
2716
|
+
.optional()
|
|
2717
|
+
.describe("Seed for diversify, so a diverged kit can be reproduced. Defaults to 0."),
|
|
1909
2718
|
},
|
|
1910
|
-
}, async ({ cellIds, genreHint, bpmHint, excludedFolders, allowedSamplePaths }) => {
|
|
1911
|
-
const check = await
|
|
1912
|
-
if (check !== true)
|
|
1913
|
-
return check;
|
|
1914
|
-
|
|
1915
|
-
|
|
2719
|
+
}, async ({ cellIds, genreHint, bpmHint, excludedFolders, allowedSamplePaths, diversify, seed }) => {
|
|
2720
|
+
const check = await requireBridgeForCells(cellIds);
|
|
2721
|
+
if (check !== true)
|
|
2722
|
+
return check;
|
|
2723
|
+
let pool = allowedSamplePaths;
|
|
2724
|
+
let diversifyNote = "";
|
|
2725
|
+
if (!pool && diversify && diversify > 0) {
|
|
2726
|
+
const locations = (await bridge.send("getDefaultLocations"));
|
|
2727
|
+
const roots = Object.entries(locations ?? {})
|
|
2728
|
+
.filter(([k, v]) => typeof v === "string" && v.length > 0 && /sample/i.test(k))
|
|
2729
|
+
.map(([, v]) => v);
|
|
2730
|
+
if (roots.length) {
|
|
2731
|
+
const byFolder = await collectLibraryByFolder(roots);
|
|
2732
|
+
const spread = spreadAcrossFolders(byFolder, diversify, seed ?? 0);
|
|
2733
|
+
if (spread.length >= 4) {
|
|
2734
|
+
pool = spread;
|
|
2735
|
+
diversifyNote = ` Diversified: ${spread.length} samples drawn across ${byFolder.size} folders (diversify ${diversify}, seed ${seed ?? 0}).`;
|
|
2736
|
+
}
|
|
2737
|
+
else {
|
|
2738
|
+
diversifyNote = " Diversify found too few samples to build a pool; fell back to the normal library scan.";
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
else {
|
|
2742
|
+
diversifyNote = " Diversify could not resolve a sample library root; fell back to the normal library scan.";
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
await bridge.send("randomizeSamples", cellIds ?? [], genreHint ?? "", bpmHint ?? -1, excludedFolders ?? [], pool ?? []);
|
|
2746
|
+
return json({ status: "queued", target: "samples", cellIds, genreHint,
|
|
2747
|
+
note: "Poll get_state section:'cells' for changed sample paths before another randomize, patterns, edit, or save." + diversifyNote });
|
|
1916
2748
|
});
|
|
1917
2749
|
server.registerTool("randomize_patterns", {
|
|
1918
2750
|
title: "Randomize Patterns",
|
|
1919
|
-
description: "Use PAM's native pattern generation engine to create
|
|
2751
|
+
description: "Use PAM's native pattern generation engine to create note patterns based on loaded sample roles, genre, and BPM. " +
|
|
2752
|
+
"Identical to randomize target:'sequences'. Returns queued: poll get_state for changed cellN_midiSequence values " +
|
|
2753
|
+
"before another randomize, edit, or save. Wait for sample loading/randomization first; concurrent requests can coalesce.",
|
|
1920
2754
|
inputSchema: {
|
|
1921
2755
|
cellIds: z
|
|
1922
2756
|
.array(z.number().min(1).max(8))
|
|
@@ -1937,18 +2771,34 @@ server.registerTool("randomize_patterns", {
|
|
|
1937
2771
|
.min(0)
|
|
1938
2772
|
.max(1)
|
|
1939
2773
|
.optional()
|
|
1940
|
-
.describe("How much to vary from typical patterns (0 = conventional, 1 = experimental). Default ~0.5."
|
|
2774
|
+
.describe("How much to vary from typical patterns (0 = conventional, 1 = experimental). Default ~0.5. " +
|
|
2775
|
+
"This is the pattern-side divergence lever: the generator otherwise produces the genre's most typical " +
|
|
2776
|
+
"rhythm, which is exactly what makes a batch of presets feel the same; 0.7-1.0 buys rhythmic " +
|
|
2777
|
+
"distinctness, low values keep a stock groove."),
|
|
2778
|
+
density: z
|
|
2779
|
+
.number()
|
|
2780
|
+
.min(-1)
|
|
2781
|
+
.max(1)
|
|
2782
|
+
.optional()
|
|
2783
|
+
.describe("Pattern density (-1 = sparse, 0 = neutral, 1 = dense). Omit for the genre and role default."),
|
|
2784
|
+
syncopation: z
|
|
2785
|
+
.number()
|
|
2786
|
+
.min(-1)
|
|
2787
|
+
.max(1)
|
|
2788
|
+
.optional()
|
|
2789
|
+
.describe("Rhythmic placement (-1 = strong beats, 0 = neutral, 1 = weaker offbeats). Omit for the genre and role default."),
|
|
1941
2790
|
},
|
|
1942
|
-
}, async ({ cellIds, genreHint, bpmHint, mutationAmount }) => {
|
|
1943
|
-
const check = await
|
|
2791
|
+
}, async ({ cellIds, genreHint, bpmHint, mutationAmount, density, syncopation }) => {
|
|
2792
|
+
const check = await requireBridgeForCells(cellIds);
|
|
1944
2793
|
if (check !== true)
|
|
1945
2794
|
return check;
|
|
1946
|
-
await bridge.send("randomize", "sequences", cellIds ?? [], genreHint ?? "", bpmHint ?? -1, mutationAmount ?? -1);
|
|
1947
|
-
return
|
|
2795
|
+
await bridge.send("randomize", "sequences", cellIds ?? [], genreHint ?? "", bpmHint ?? -1, mutationAmount ?? -1, density ?? null, syncopation ?? null);
|
|
2796
|
+
return json({ status: "queued", target: "sequences", cellIds, genreHint,
|
|
2797
|
+
note: "Poll get_state for changed cellN_midiSequence values before another randomize, edit, or save." });
|
|
1948
2798
|
});
|
|
1949
2799
|
server.registerTool("load_variation", {
|
|
1950
2800
|
title: "Load Variation",
|
|
1951
|
-
description: "
|
|
2801
|
+
description: "Request a switch to a saved variation slot (0-7), including parameters, effects, patterns, and modulation. Empty slots are rejected. Verify currentVariation and a slot-specific value with get_state before editing or saving.",
|
|
1952
2802
|
inputSchema: {
|
|
1953
2803
|
index: z
|
|
1954
2804
|
.number()
|
|
@@ -1962,7 +2812,7 @@ server.registerTool("load_variation", {
|
|
|
1962
2812
|
if (check !== true)
|
|
1963
2813
|
return check;
|
|
1964
2814
|
await bridge.send("loadVariation", index);
|
|
1965
|
-
return text(`
|
|
2815
|
+
return text(`Requested variation ${index} (UI slot ${index + 1}); verify currentVariation and the intended state with get_state before editing or saving.`);
|
|
1966
2816
|
});
|
|
1967
2817
|
server.registerTool("full_stop_transport", {
|
|
1968
2818
|
title: "Full Stop Transport",
|
|
@@ -2016,7 +2866,7 @@ server.registerTool("get_mix_analysis", {
|
|
|
2016
2866
|
"true/sample peak (tp/sp), PSR/PLR crest, stereo correlation, onset density, and a 25-band Bark " +
|
|
2017
2867
|
"spectrum per cell, plus the full-mix master block. Pass refresh:true to run a new analysis " +
|
|
2018
2868
|
"(renders each sounding cell soloed from bar 0 — takes a few seconds; audio does NOT need to be " +
|
|
2019
|
-
"playing). Cached result otherwise. Requires PAM
|
|
2869
|
+
"playing). Cached result otherwise. Requires a PAM build with Mix Assist support.",
|
|
2020
2870
|
inputSchema: {
|
|
2021
2871
|
refresh: z.boolean().optional().describe("Run a fresh analysis instead of returning the cached one."),
|
|
2022
2872
|
durationSeconds: z.number().min(5).max(30).optional().describe("Analysis render length (default 8 bars at the current BPM, clamped 5-30 s)."),
|
|
@@ -2034,14 +2884,32 @@ server.registerTool("get_mix_analysis", {
|
|
|
2034
2884
|
while (Date.now() - t0 < 120_000) {
|
|
2035
2885
|
await new Promise((r) => setTimeout(r, 500));
|
|
2036
2886
|
const status = (await bridge.send("getMixAnalysis"));
|
|
2037
|
-
if (status.status !== "running")
|
|
2038
|
-
|
|
2887
|
+
if (status.status !== "running") {
|
|
2888
|
+
const silent = silentMixAnalysisMessage(status);
|
|
2889
|
+
return silent ? error(silent) : json(status);
|
|
2890
|
+
}
|
|
2039
2891
|
}
|
|
2040
2892
|
return error("Mix analysis timed out after 120 s.");
|
|
2041
2893
|
}
|
|
2042
2894
|
const result = await bridge.send("getMixAnalysis");
|
|
2043
|
-
|
|
2895
|
+
const silent = silentMixAnalysisMessage(result);
|
|
2896
|
+
return silent ? error(silent) : json(result);
|
|
2044
2897
|
});
|
|
2898
|
+
function silentMixAnalysisMessage(raw) {
|
|
2899
|
+
if (!isRecord(raw))
|
|
2900
|
+
return null;
|
|
2901
|
+
const analysis = parseMixAnalysisPayload(raw.analysis);
|
|
2902
|
+
if (!analysis || analysis.cells.length === 0)
|
|
2903
|
+
return null;
|
|
2904
|
+
const stems = [...analysis.cells, analysis.master];
|
|
2905
|
+
const silent = stems.every((stem) => stem.barkDb.length > 0 &&
|
|
2906
|
+
stem.onsets === 0 &&
|
|
2907
|
+
(stem.lufsI === undefined || stem.lufsI <= -119) &&
|
|
2908
|
+
stem.barkDb.every((band) => band <= -119));
|
|
2909
|
+
return silent
|
|
2910
|
+
? "Mix analysis rendered digital silence. No balance or EQ inference is safe from this result; use live playback measurement or retry in a plugin host."
|
|
2911
|
+
: null;
|
|
2912
|
+
}
|
|
2045
2913
|
// Epoch of the last OFFLINE analysis suggest_mix applied moves from — the
|
|
2046
2914
|
// stacking guard's memory (live payloads refresh their epoch, so they never
|
|
2047
2915
|
// stay consumed). Session-scoped by design: a server restart just re-allows a
|
|
@@ -2053,7 +2921,7 @@ server.registerTool("suggest_mix", {
|
|
|
2053
2921
|
"partial-loudness fader balance, pan spread, bounded masking EQ cuts, and optional compressor " +
|
|
2054
2922
|
"settings. Returns absolute moves with per-move rationale and guard notes; apply:true lands them " +
|
|
2055
2923
|
"as one state update. Run get_mix_analysis with refresh:true first if the mix changed. " +
|
|
2056
|
-
"Requires PAM
|
|
2924
|
+
"Requires a PAM build with Mix Assist support.",
|
|
2057
2925
|
inputSchema: {
|
|
2058
2926
|
scopes: z
|
|
2059
2927
|
.array(z.enum(["balance", "pan", "eq", "dynamics"]))
|
|
@@ -2071,6 +2939,9 @@ server.registerTool("suggest_mix", {
|
|
|
2071
2939
|
const analysis = parseMixAnalysisPayload(r.analysis);
|
|
2072
2940
|
if (!analysis)
|
|
2073
2941
|
return error("Cached mix analysis is malformed — re-run get_mix_analysis with refresh:true.");
|
|
2942
|
+
const silent = silentMixAnalysisMessage(r);
|
|
2943
|
+
if (silent)
|
|
2944
|
+
return error(silent);
|
|
2074
2945
|
// Stacking guard (mirrors the panel's consumed-epoch gate): the engine's
|
|
2075
2946
|
// deltas come from the analysis but land on the LIVE params, so suggesting
|
|
2076
2947
|
// again from an analysis that was already applied doubles the correction.
|
|
@@ -2137,7 +3008,8 @@ server.registerTool("new_preset", {
|
|
|
2137
3008
|
});
|
|
2138
3009
|
server.registerTool("save_preset", {
|
|
2139
3010
|
title: "Save Preset",
|
|
2140
|
-
description: "Save the current PAM state as a
|
|
3011
|
+
description: "Save the current PAM state as a preset file with metadata. Omit `name` to re-save the active user preset in place " +
|
|
3012
|
+
"(factory presets are refused). Every save also snapshots the current state into variation slot 0 if the bank is empty, otherwise into the active slot. " +
|
|
2141
3013
|
"Writes to the user preset folder by default; pass `path` for a specific location. " +
|
|
2142
3014
|
"Use `update_preset_metadata` to edit an existing preset without re-saving plugin state.",
|
|
2143
3015
|
inputSchema: {
|
|
@@ -2188,19 +3060,12 @@ server.registerTool("save_preset", {
|
|
|
2188
3060
|
return error(`Could not derive a name from the active preset path (${activePath}) — pass name explicitly.`);
|
|
2189
3061
|
if (path === undefined)
|
|
2190
3062
|
path = activePath;
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
continue;
|
|
2198
|
-
if (extras[k] === undefined)
|
|
2199
|
-
extras[k] = v;
|
|
2200
|
-
}
|
|
2201
|
-
}
|
|
2202
|
-
catch {
|
|
2203
|
-
// unreadable/new — save fresh with whatever the caller passed
|
|
3063
|
+
const parsed = await readPresetForSave(readPath);
|
|
3064
|
+
for (const [k, v] of Object.entries(parsed.metadata)) {
|
|
3065
|
+
if (["name", "factory", "path", "variations", "currentVariation"].includes(k))
|
|
3066
|
+
continue;
|
|
3067
|
+
if (extras[k] === undefined)
|
|
3068
|
+
extras[k] = v;
|
|
2204
3069
|
}
|
|
2205
3070
|
}
|
|
2206
3071
|
const metadata = { name, factory: false };
|
|
@@ -2219,7 +3084,11 @@ server.registerTool("save_preset", {
|
|
|
2219
3084
|
// the new destination starts from eight empty slots because that file does
|
|
2220
3085
|
// not exist yet, which used to collapse every named save to one slot.
|
|
2221
3086
|
const variationSourcePath = selectSaveVariationSourcePath(state.activePresetPath, presetRelPath);
|
|
2222
|
-
const
|
|
3087
|
+
const hasActivePreset = typeof state.activePresetPath === "string"
|
|
3088
|
+
&& state.activePresetPath.length > 0 && state.activePresetPath !== "Init";
|
|
3089
|
+
const source = await readPresetForSave(variationSourcePath, !hasActivePreset);
|
|
3090
|
+
const existingVariations = source
|
|
3091
|
+
? variationsFromParsedPreset(source) : Array(PRESET_VARIATION_SLOTS).fill(null);
|
|
2223
3092
|
const hasVariations = existingVariations.some((v) => v !== null);
|
|
2224
3093
|
if (!hasVariations) {
|
|
2225
3094
|
existingVariations[0] = variationString;
|
|
@@ -2269,49 +3138,25 @@ server.registerTool("save_variation", {
|
|
|
2269
3138
|
const check = await requireBridge();
|
|
2270
3139
|
if (check !== true)
|
|
2271
3140
|
return check;
|
|
2272
|
-
// 1. Canonical save snapshot (engine + hosted-plugin state), then shape it
|
|
2273
3141
|
const { state, canonical } = await fetchSaveSnapshot();
|
|
2274
3142
|
const variationString = buildVariationString(state);
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
let diskPath;
|
|
2278
|
-
const activePath = state.activePresetPath;
|
|
2279
|
-
if (!presetName && activePath) {
|
|
2280
|
-
const filename = activePath.split("/").pop() || "";
|
|
2281
|
-
presetName = filename.replace(/\.preset$/, "");
|
|
2282
|
-
// Use active path for disk lookup (preserves subfolder structure).
|
|
2283
|
-
// Strip "Presets/" prefix if present — resolvePresetPath already prepends
|
|
2284
|
-
// the presets base directory, so "Presets/User/X" would double up to
|
|
2285
|
-
// ".../Presets/Presets/User/X" and silently fail to read back variations.
|
|
2286
|
-
diskPath = normalizePresetLookupPath(activePath);
|
|
2287
|
-
}
|
|
2288
|
-
if (!presetName && lastSavedPresetName) {
|
|
2289
|
-
presetName = lastSavedPresetName;
|
|
2290
|
-
diskPath = lastSavedPresetPath;
|
|
3143
|
+
if (!name && state.activePresetPath === "Init") {
|
|
3144
|
+
return error("No active preset. Call save_preset with a name first, or pass a name to save_variation.");
|
|
2291
3145
|
}
|
|
3146
|
+
const activePath = typeof state.activePresetPath === "string" && state.activePresetPath !== "Init"
|
|
3147
|
+
? normalizePresetLookupPath(state.activePresetPath) : "";
|
|
3148
|
+
const presetName = name || (activePath.split("/").pop() || "").replace(/\.preset$/, "") || lastSavedPresetName;
|
|
2292
3149
|
if (!presetName) {
|
|
2293
|
-
return error("No preset name
|
|
2294
|
-
|
|
2295
|
-
}
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
try {
|
|
2303
|
-
const fullPath = await resolvePresetPath(diskPath);
|
|
2304
|
-
const raw = await readFile(fullPath, "utf-8");
|
|
2305
|
-
const parsed = parsePresetFile(raw);
|
|
2306
|
-
existingMeta = parsed.metadata;
|
|
2307
|
-
variations = variationsFromParsedPreset(parsed);
|
|
2308
|
-
}
|
|
2309
|
-
catch {
|
|
2310
|
-
// New preset or unreadable — start fresh
|
|
2311
|
-
}
|
|
2312
|
-
// 4. Update the target slot
|
|
3150
|
+
return error("No active preset. Call save_preset with a name first, or pass a name to save_variation.");
|
|
3151
|
+
}
|
|
3152
|
+
const diskPath = name ? `User/${presetName}.preset`
|
|
3153
|
+
: activePath || lastSavedPresetPath || `User/${presetName}.preset`;
|
|
3154
|
+
const sourcePath = selectSaveVariationSourcePath(activePath, diskPath);
|
|
3155
|
+
const parsed = await readPresetForSave(sourcePath, !!name && !activePath);
|
|
3156
|
+
const existingMeta = parsed?.metadata ?? {};
|
|
3157
|
+
const variations = parsed
|
|
3158
|
+
? variationsFromParsedPreset(parsed) : Array(PRESET_VARIATION_SLOTS).fill(null);
|
|
2313
3159
|
variations[index] = variationString;
|
|
2314
|
-
// 5. Build metadata: merge existing (preserves author/tags/etc.) then override with caller args
|
|
2315
3160
|
const metadata = {
|
|
2316
3161
|
...existingMeta,
|
|
2317
3162
|
name: presetName,
|
|
@@ -2320,12 +3165,13 @@ server.registerTool("save_variation", {
|
|
|
2320
3165
|
currentVariation: index,
|
|
2321
3166
|
...Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined)),
|
|
2322
3167
|
};
|
|
2323
|
-
|
|
3168
|
+
delete metadata.path;
|
|
2324
3169
|
let resultSuffix = "";
|
|
2325
|
-
|
|
2326
|
-
|
|
3170
|
+
const destinationPath = normalizePresetLookupPath(diskPath);
|
|
3171
|
+
if (!name && !destinationPath.startsWith("Factory/")) {
|
|
3172
|
+
metadata.path = destinationPath;
|
|
2327
3173
|
}
|
|
2328
|
-
else if (
|
|
3174
|
+
else if (sourcePath.startsWith("Factory/")) {
|
|
2329
3175
|
// Cannot write back to factory location; savePreset will create User/{name}.preset
|
|
2330
3176
|
resultSuffix = "\n(Note: active preset is a factory preset — saved as new user preset)";
|
|
2331
3177
|
}
|
|
@@ -2426,12 +3272,12 @@ server.registerTool("set_bpm", {
|
|
|
2426
3272
|
});
|
|
2427
3273
|
server.registerTool("trim_sample", {
|
|
2428
3274
|
title: "Trim Sample",
|
|
2429
|
-
description: "Trim a cell's sample to its start/end markers
|
|
3275
|
+
description: "Trim a cell's sample to its start/end markers: writes a new WAV into Samples/Resamples/Trimmed/ and points the cell at it. The original file is untouched, but a preset saved afterwards references the trimmed copy, and calling it again crops the trimmed file further.",
|
|
2430
3276
|
inputSchema: {
|
|
2431
3277
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
2432
3278
|
},
|
|
2433
3279
|
}, async ({ cellId }) => {
|
|
2434
|
-
const check = await
|
|
3280
|
+
const check = await requireBridgeForCells(cellId);
|
|
2435
3281
|
if (check !== true)
|
|
2436
3282
|
return check;
|
|
2437
3283
|
await bridge.send("trimSampleForCell", cellId);
|
|
@@ -2439,12 +3285,12 @@ server.registerTool("trim_sample", {
|
|
|
2439
3285
|
});
|
|
2440
3286
|
server.registerTool("reverse_sample", {
|
|
2441
3287
|
title: "Reverse Sample",
|
|
2442
|
-
description: "Reverse a cell's loaded sample.",
|
|
3288
|
+
description: "Reverse a cell's loaded sample. Writes <name>_reversed.<ext> next to the source file and points the cell at it; calling it again on an already-reversed sample restores the original instead of reversing twice.",
|
|
2443
3289
|
inputSchema: {
|
|
2444
3290
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
2445
3291
|
},
|
|
2446
3292
|
}, async ({ cellId }) => {
|
|
2447
|
-
const check = await
|
|
3293
|
+
const check = await requireBridgeForCells(cellId);
|
|
2448
3294
|
if (check !== true)
|
|
2449
3295
|
return check;
|
|
2450
3296
|
await bridge.send("reverseSampleForCell", cellId);
|
|
@@ -2469,13 +3315,16 @@ server.registerTool("connection_status", {
|
|
|
2469
3315
|
},
|
|
2470
3316
|
annotations: { readOnlyHint: true },
|
|
2471
3317
|
}, async () => {
|
|
2472
|
-
const
|
|
3318
|
+
const discoveredInstances = await bridge.discover();
|
|
3319
|
+
const instances = IS_MFX_SERVER
|
|
3320
|
+
? discoveredInstances.filter((instance) => instance.product?.trim().toLowerCase() === "mfx")
|
|
3321
|
+
: discoveredInstances;
|
|
2473
3322
|
if (instances.length === 0) {
|
|
2474
3323
|
return structured({
|
|
2475
3324
|
instances: [],
|
|
2476
3325
|
connected: false,
|
|
2477
|
-
note:
|
|
2478
|
-
|
|
3326
|
+
note: `No running ${SERVER_PRODUCT.name} instances found. To use live tools, ${SERVER_PRODUCT.name} needs the MCP bridge enabled: ` +
|
|
3327
|
+
`start ${SERVER_PRODUCT.name} (standalone or in a DAW) and enable it in Settings → Experimental. ` +
|
|
2479
3328
|
"Offline tools (get_parameter_info, list_presets, read_preset) work without a connection.",
|
|
2480
3329
|
});
|
|
2481
3330
|
}
|
|
@@ -2488,11 +3337,117 @@ server.registerTool("connection_status", {
|
|
|
2488
3337
|
connectedTo: bridge.getConnectedInstance(),
|
|
2489
3338
|
note: connected
|
|
2490
3339
|
? instances.length > 1
|
|
2491
|
-
?
|
|
2492
|
-
:
|
|
3340
|
+
? `Connected. Multiple ${SERVER_PRODUCT.name} instances are running; use select_instance to choose; reconnects stay pinned to that instance.`
|
|
3341
|
+
: `Connected to ${SERVER_PRODUCT.name}. Tool availability also depends on this plugin build, open windows, and feature settings.`
|
|
2493
3342
|
: "Found instances but could not connect. The plugin may have shut down.",
|
|
2494
3343
|
});
|
|
2495
3344
|
});
|
|
3345
|
+
server.registerTool("situation", {
|
|
3346
|
+
title: "Situation",
|
|
3347
|
+
description: "One-call orientation snapshot: discovered instances, the connected instance's product/pid/executable " +
|
|
3348
|
+
"(plus build mtime and a dev-build heuristic when resolvable), server and manifest identity, active preset, " +
|
|
3349
|
+
"transport, and per-cell occupancy counts. Start a session here — it collapses the connection/product/build " +
|
|
3350
|
+
"preflight and the first design reads into one round trip. Deeper reads: get_design_overview, design_context.",
|
|
3351
|
+
inputSchema: {},
|
|
3352
|
+
annotations: { readOnlyHint: true },
|
|
3353
|
+
}, async () => {
|
|
3354
|
+
const instances = await bridge.discover().catch(() => []);
|
|
3355
|
+
const connected = bridge.isConnected() || (await bridge.connect().catch(() => false)) === true;
|
|
3356
|
+
if (connected)
|
|
3357
|
+
syncProductHint();
|
|
3358
|
+
const instance = connected ? bridge.getConnectedInstance() : null;
|
|
3359
|
+
let build;
|
|
3360
|
+
if (instance?.pid) {
|
|
3361
|
+
try {
|
|
3362
|
+
const executable = execFileSync("ps", ["-o", "comm=", "-p", String(instance.pid)], {
|
|
3363
|
+
encoding: "utf8",
|
|
3364
|
+
}).trim();
|
|
3365
|
+
if (executable) {
|
|
3366
|
+
build = {
|
|
3367
|
+
executable,
|
|
3368
|
+
looksLikeDevBuild: executable.includes("/build/") && !executable.startsWith("/Applications/"),
|
|
3369
|
+
};
|
|
3370
|
+
try {
|
|
3371
|
+
build.executableMtime = statSync(executable).mtime.toISOString();
|
|
3372
|
+
}
|
|
3373
|
+
catch {
|
|
3374
|
+
/* executable path unreadable from this process */
|
|
3375
|
+
}
|
|
3376
|
+
const projectRoot = process.env.PAM_PROJECT_ROOT;
|
|
3377
|
+
if (projectRoot && typeof build.executableMtime === "string") {
|
|
3378
|
+
try {
|
|
3379
|
+
const headIso = execFileSync("git", ["-C", resolve(projectRoot), "log", "-1", "--format=%cI"], { encoding: "utf8" }).trim();
|
|
3380
|
+
build.repoHeadCommitTime = headIso;
|
|
3381
|
+
build.binaryOlderThanHeadCommit =
|
|
3382
|
+
new Date(build.executableMtime).getTime() < new Date(headIso).getTime();
|
|
3383
|
+
}
|
|
3384
|
+
catch {
|
|
3385
|
+
/* PAM_PROJECT_ROOT is not a git checkout */
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
3390
|
+
catch {
|
|
3391
|
+
/* ps unavailable (Windows) or pid already gone */
|
|
3392
|
+
}
|
|
3393
|
+
}
|
|
3394
|
+
let live;
|
|
3395
|
+
let manifestName;
|
|
3396
|
+
if (connected) {
|
|
3397
|
+
const [stateRaw, transportRaw, manifest] = await Promise.all([
|
|
3398
|
+
bridge.send("getState").catch(() => undefined),
|
|
3399
|
+
bridge.send("getTransport").catch(() => undefined),
|
|
3400
|
+
loadManifest().catch(() => null),
|
|
3401
|
+
]);
|
|
3402
|
+
manifestName = manifest?.name;
|
|
3403
|
+
const state = isRecord(stateRaw) ? stateRaw : {};
|
|
3404
|
+
const transport = isRecord(transportRaw) ? transportRaw : {};
|
|
3405
|
+
if (manifest) {
|
|
3406
|
+
const effectNames = cellEffectNames(manifest);
|
|
3407
|
+
const ids = [...new Set(manifest.parameters
|
|
3408
|
+
.map((p) => p.cell)
|
|
3409
|
+
.filter((cell) => typeof cell === "string" && /^cell\d+$/.test(cell))
|
|
3410
|
+
.map((cell) => Number(cell.replace("cell", ""))))].sort((a, b) => a - b);
|
|
3411
|
+
const cellsObj = isRecord(state.cells) ? state.cells : {};
|
|
3412
|
+
const cells = ids.map((id) => {
|
|
3413
|
+
const prefix = `cell${id}_`;
|
|
3414
|
+
const rawCellState = cellsObj[String(id)];
|
|
3415
|
+
const cellState = isRecord(rawCellState) ? rawCellState : {};
|
|
3416
|
+
const samplePath = cellState.filePath ??
|
|
3417
|
+
cellState.samplePath ??
|
|
3418
|
+
state[`${prefix}filePath`] ??
|
|
3419
|
+
state[`${prefix}samplePath`];
|
|
3420
|
+
const synth = IS_MFX_SERVER ? undefined : describeSynthEngine(state[`${prefix}synthEngine`]);
|
|
3421
|
+
return {
|
|
3422
|
+
cellId: id,
|
|
3423
|
+
sample: stemName(samplePath),
|
|
3424
|
+
synthEngine: synth && synth.id > 0 ? synth.name : undefined,
|
|
3425
|
+
muted: state[`${prefix}mute`] === 1 || state[`${prefix}mute`] === true,
|
|
3426
|
+
enabledEffects: effectNames.filter((n) => state[`${prefix}${n}Enabled`] === 1 || state[`${prefix}${n}Enabled`] === true).length,
|
|
3427
|
+
};
|
|
3428
|
+
});
|
|
3429
|
+
live = {
|
|
3430
|
+
activePreset: state.activePresetPath,
|
|
3431
|
+
bpm: state.bpm ?? transport.bpm,
|
|
3432
|
+
playing: transport.playing,
|
|
3433
|
+
cells,
|
|
3434
|
+
...(IS_MFX_SERVER ? { mix: state.mfx_mix, routing: state.mfx_routing } : {}),
|
|
3435
|
+
};
|
|
3436
|
+
}
|
|
3437
|
+
}
|
|
3438
|
+
return json({
|
|
3439
|
+
server: { name: SERVER_PRODUCT.name, version: SERVER_VERSION },
|
|
3440
|
+
instances,
|
|
3441
|
+
connected,
|
|
3442
|
+
instance,
|
|
3443
|
+
manifest: manifestName,
|
|
3444
|
+
build,
|
|
3445
|
+
live,
|
|
3446
|
+
note: connected
|
|
3447
|
+
? undefined
|
|
3448
|
+
: "Not connected. connection_status has enable instructions; offline tools still work.",
|
|
3449
|
+
});
|
|
3450
|
+
});
|
|
2496
3451
|
server.registerTool("select_instance", {
|
|
2497
3452
|
title: "Select Plugin Instance",
|
|
2498
3453
|
description: "Choose which running plugin instance (PAM / CELL1 / MFX) the live tools drive when more than " +
|
|
@@ -2514,9 +3469,16 @@ server.registerTool("select_instance", {
|
|
|
2514
3469
|
if (!product && !instanceId && port === undefined) {
|
|
2515
3470
|
return error("Pass at least one of product, instanceId, or port. Call connection_status to list running instances.");
|
|
2516
3471
|
}
|
|
2517
|
-
|
|
3472
|
+
if (IS_MFX_SERVER && product && product.trim().toLowerCase() !== "mfx") {
|
|
3473
|
+
return error("The mfx-mcp launcher can select MFX instances only.");
|
|
3474
|
+
}
|
|
3475
|
+
const targetProduct = IS_MFX_SERVER ? "MFX" : product;
|
|
3476
|
+
const ok = await bridge.connect({ product: targetProduct, instanceId, port });
|
|
2518
3477
|
if (!ok) {
|
|
2519
|
-
const
|
|
3478
|
+
const discovered = await bridge.discover();
|
|
3479
|
+
const instances = IS_MFX_SERVER
|
|
3480
|
+
? discovered.filter((instance) => instance.product?.trim().toLowerCase() === "mfx")
|
|
3481
|
+
: discovered;
|
|
2520
3482
|
const listing = instances.length
|
|
2521
3483
|
? ` Running instances: ${instances
|
|
2522
3484
|
.map((i) => `${i.product} (port ${i.port}, id ${i.instanceId})`)
|
|
@@ -2573,6 +3535,69 @@ async function searchSampleLibrary(rootPath, term, audioOnly) {
|
|
|
2573
3535
|
}
|
|
2574
3536
|
return matches;
|
|
2575
3537
|
}
|
|
3538
|
+
// Walk the library and return audio files grouped by their parent folder.
|
|
3539
|
+
// Bounded like searchSampleLibrary — this runs inside a tool call, not a job.
|
|
3540
|
+
async function collectLibraryByFolder(roots, maxDirReads = 120) {
|
|
3541
|
+
const byFolder = new Map();
|
|
3542
|
+
const queue = roots.map((path) => ({ path, depth: 0 }));
|
|
3543
|
+
const options = JSON.stringify({ audioOnly: true, sort: "name" });
|
|
3544
|
+
let dirReads = 0;
|
|
3545
|
+
while (queue.length && dirReads < maxDirReads) {
|
|
3546
|
+
const { path, depth } = queue.shift();
|
|
3547
|
+
dirReads += 1;
|
|
3548
|
+
let entries = [];
|
|
3549
|
+
try {
|
|
3550
|
+
const res = await bridge.send("listDirectory", path, options);
|
|
3551
|
+
if (Array.isArray(res))
|
|
3552
|
+
entries = res;
|
|
3553
|
+
}
|
|
3554
|
+
catch {
|
|
3555
|
+
continue;
|
|
3556
|
+
}
|
|
3557
|
+
for (const e of entries) {
|
|
3558
|
+
if (typeof e.path !== "string")
|
|
3559
|
+
continue;
|
|
3560
|
+
if (e.isDir) {
|
|
3561
|
+
if (depth < 3)
|
|
3562
|
+
queue.push({ path: e.path, depth: depth + 1 });
|
|
3563
|
+
}
|
|
3564
|
+
else {
|
|
3565
|
+
const list = byFolder.get(path) ?? [];
|
|
3566
|
+
list.push(e.path);
|
|
3567
|
+
byFolder.set(path, list);
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
return byFolder;
|
|
3572
|
+
}
|
|
3573
|
+
// Build a sample pool that deliberately SPANS folders instead of collapsing
|
|
3574
|
+
// into one pack.
|
|
3575
|
+
//
|
|
3576
|
+
// WHY: randomize_samples' native engine scores folder coherence, which is the
|
|
3577
|
+
// right default (a kit from one pack sounds like a kit) and the wrong one when
|
|
3578
|
+
// the brief is "something we haven't heard" — coherence pulls every result
|
|
3579
|
+
// toward the same few well-stocked folders. Restricting the engine to a
|
|
3580
|
+
// cross-folder pool keeps its role assignment while removing its ability to
|
|
3581
|
+
// converge. Deterministic given the same seed, so a diverged kit can be
|
|
3582
|
+
// reproduced.
|
|
3583
|
+
function spreadAcrossFolders(byFolder, diversify, seed) {
|
|
3584
|
+
const folders = [...byFolder.keys()].sort();
|
|
3585
|
+
if (!folders.length)
|
|
3586
|
+
return [];
|
|
3587
|
+
// At diversify 1 every folder contributes a single file (maximum spread); at
|
|
3588
|
+
// low values a few folders contribute many (close to normal behaviour).
|
|
3589
|
+
const perFolder = Math.max(1, Math.round(12 - 11 * diversify));
|
|
3590
|
+
const folderCount = Math.max(1, Math.min(folders.length, Math.round(2 + (folders.length - 2) * diversify)));
|
|
3591
|
+
const start = folders.length ? seed % folders.length : 0;
|
|
3592
|
+
const pool = [];
|
|
3593
|
+
for (let i = 0; i < folderCount; i += 1) {
|
|
3594
|
+
const files = byFolder.get(folders[(start + i) % folders.length]) ?? [];
|
|
3595
|
+
for (let j = 0; j < Math.min(perFolder, files.length); j += 1) {
|
|
3596
|
+
pool.push(files[(seed + j * 7) % files.length]);
|
|
3597
|
+
}
|
|
3598
|
+
}
|
|
3599
|
+
return [...new Set(pool)];
|
|
3600
|
+
}
|
|
2576
3601
|
// Search-result payloads feed the assistant's 256-token continuation round —
|
|
2577
3602
|
// trim to that budget instead of letting the listing collapse to an opaque
|
|
2578
3603
|
// truncation string (executor parity: assistantExecutor.ts fitListing).
|
|
@@ -2587,14 +3612,12 @@ function fitListing(base, itemsKey, items, moreKey, total) {
|
|
|
2587
3612
|
}
|
|
2588
3613
|
server.registerTool("list_samples", {
|
|
2589
3614
|
title: "List Samples",
|
|
2590
|
-
description: "Browse sample library directories.
|
|
2591
|
-
"get_default_locations
|
|
2592
|
-
"paths here. Paths vary per user and platform.",
|
|
3615
|
+
description: "Browse sample library directories. Library paths vary per user and platform, so take them from " +
|
|
3616
|
+
"get_default_locations rather than guessing.",
|
|
2593
3617
|
inputSchema: {
|
|
2594
3618
|
path: z
|
|
2595
3619
|
.string()
|
|
2596
|
-
.describe("Directory path to list
|
|
2597
|
-
"or a subdirectory of one. Do not guess paths like ~/Music/PAM."),
|
|
3620
|
+
.describe("Directory path to list: a path from get_default_locations, or a subdirectory of one."),
|
|
2598
3621
|
audioOnly: z
|
|
2599
3622
|
.boolean()
|
|
2600
3623
|
.default(true)
|
|
@@ -2683,8 +3706,9 @@ server.registerTool("get_audio_info", {
|
|
|
2683
3706
|
});
|
|
2684
3707
|
server.registerTool("export_preset", {
|
|
2685
3708
|
title: "Export Preset",
|
|
2686
|
-
description: "Export a preset as a zip file bundled with all referenced samples
|
|
2687
|
-
"
|
|
3709
|
+
description: "Export a preset as a zip file bundled with all referenced samples, written to PAM's Exports folder as " +
|
|
3710
|
+
"<outputFolderName>.zip (get_default_locations resolves that folder). An existing folder or zip of that " +
|
|
3711
|
+
"name is replaced, and presets belonging to a preset pack are refused.",
|
|
2688
3712
|
inputSchema: {
|
|
2689
3713
|
presetPath: z
|
|
2690
3714
|
.string()
|
|
@@ -2702,7 +3726,10 @@ server.registerTool("export_preset", {
|
|
|
2702
3726
|
});
|
|
2703
3727
|
server.registerTool("import_preset", {
|
|
2704
3728
|
title: "Import Preset",
|
|
2705
|
-
description: "Import a preset from a zip file or folder into PAM's preset library."
|
|
3729
|
+
description: "Import a preset from a zip file or folder into PAM's preset library. Returns the relative path of the " +
|
|
3730
|
+
"first imported preset, or an empty string on failure. Referenced samples are copied into the user sample " +
|
|
3731
|
+
"folder and the preset's paths are rewritten. A name already in the library is never overwritten: the " +
|
|
3732
|
+
"import lands as <name>_1, <name>_2 and so on.",
|
|
2706
3733
|
inputSchema: {
|
|
2707
3734
|
path: z.string().describe("Absolute path to the preset zip file or folder to import."),
|
|
2708
3735
|
},
|
|
@@ -2912,7 +3939,7 @@ server.registerTool("capture_ui", {
|
|
|
2912
3939
|
"PresetBrowser; omit/null = cell grid), instrumentView ('synth'|'sampler', applied to " +
|
|
2913
3940
|
"all cells). REQUIRES PAM's window OPEN — html2canvas needs the live DOM, there is no " +
|
|
2914
3941
|
"headless renderer (same constraint as the video tools). Beats screen-capture: " +
|
|
2915
|
-
"deterministic, no cursor/screen control, exact UI bitmap.",
|
|
3942
|
+
"deterministic, no cursor/screen control, exact UI bitmap. View arguments switch the live editor and the previous view is not restored.",
|
|
2916
3943
|
inputSchema: {
|
|
2917
3944
|
gridSize: z.number().optional().describe("Cell grid size to set before capture: 1, 4, or 8."),
|
|
2918
3945
|
mainView: z
|
|
@@ -2926,7 +3953,7 @@ server.registerTool("capture_ui", {
|
|
|
2926
3953
|
settleMs: z.number().optional().describe("Delay (ms) after view changes before rasterizing. Default 350."),
|
|
2927
3954
|
timeoutMs: z.number().optional().default(8000).describe("Max ms to wait for the WebView to return the image."),
|
|
2928
3955
|
},
|
|
2929
|
-
annotations: { readOnlyHint: true },
|
|
3956
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
2930
3957
|
}, async ({ gridSize, mainView, instrumentView, settleMs, timeoutMs }) => {
|
|
2931
3958
|
const check = await requireBridge();
|
|
2932
3959
|
if (check !== true)
|
|
@@ -3306,13 +4333,23 @@ server.registerTool("set_video_settings", {
|
|
|
3306
4333
|
"loads on the next bar boundary while playing). resetWorkspace clears the " +
|
|
3307
4334
|
"scene (preserves the master layer).",
|
|
3308
4335
|
inputSchema: {
|
|
3309
|
-
syncMode: z
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
4336
|
+
syncMode: z
|
|
4337
|
+
.enum(["off", "beat-sync", "beat-match"])
|
|
4338
|
+
.optional()
|
|
4339
|
+
.describe("Clock source: 'off' free-runs, 'beat-sync' locks playback to the bar grid, 'beat-match' retimes clips to the transport."),
|
|
4340
|
+
loopBars: z.number().min(0).optional().describe("Loop length in bars for the beat-sync and beat-match modes."),
|
|
4341
|
+
aspectRatio: z
|
|
4342
|
+
.enum(["auto", "16:9", "4:3", "1:1", "stretch"])
|
|
4343
|
+
.optional()
|
|
4344
|
+
.describe("How a layer is fitted to the canvas. 'auto' keeps the source ratio, 'stretch' fills it."),
|
|
4345
|
+
renderQuality: z.enum(["low", "medium", "high"]).optional().describe("VX render resolution/effort tier."),
|
|
4346
|
+
designAspect: z
|
|
4347
|
+
.number()
|
|
4348
|
+
.nullable()
|
|
4349
|
+
.optional()
|
|
4350
|
+
.describe("Canvas design ratio as width/height (e.g. 1.7777 for 16:9). null clears it and follows the window."),
|
|
4351
|
+
letterboxColor: z.string().describe("#hex fill for the bars outside the design aspect.").optional(),
|
|
4352
|
+
canvasColor: z.string().describe("#hex background behind every VX layer.").optional(),
|
|
3316
4353
|
modEnabled: z.boolean().optional().describe("Mod-matrix master enable."),
|
|
3317
4354
|
vxVariationFollowAudio: z
|
|
3318
4355
|
.boolean()
|
|
@@ -3434,8 +4471,9 @@ const PATTERN_SUFFIXES = [
|
|
|
3434
4471
|
];
|
|
3435
4472
|
server.registerTool("reset_cell", {
|
|
3436
4473
|
title: "Reset Cell",
|
|
3437
|
-
description: "Reset
|
|
3438
|
-
"
|
|
4474
|
+
description: "Reset a single cell's manifest parameters to their defaults and remove the envelopes whose SOURCE is this cell. " +
|
|
4475
|
+
"Untouched: envelopes targeting this cell from another source (clear_modulations), pattern lanes (reset_patterns), " +
|
|
4476
|
+
"the cell config block (update_cell_config) and the loaded sample. Does not touch other cells.",
|
|
3439
4477
|
inputSchema: {
|
|
3440
4478
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
3441
4479
|
},
|
|
@@ -3468,8 +4506,9 @@ server.registerTool("reset_cell", {
|
|
|
3468
4506
|
});
|
|
3469
4507
|
server.registerTool("copy_cell", {
|
|
3470
4508
|
title: "Copy Cell",
|
|
3471
|
-
description: "Copy
|
|
3472
|
-
"
|
|
4509
|
+
description: "Copy a cell's manifest parameters and remap its LFO, envelope and vary links onto another cell, overwriting the target's. " +
|
|
4510
|
+
"Not copied: the loaded sample, parameter sequencers, and the cell config block (polyphony, MIDI note/channel, scale, " +
|
|
4511
|
+
"sidechain source, pitch/stretch engine, slice keymap) — re-apply those with load_sample and update_cell_config.",
|
|
3473
4512
|
inputSchema: {
|
|
3474
4513
|
sourceCellId: z.number().min(1).max(8).describe("Source cell (1-8)."),
|
|
3475
4514
|
targetCellId: z.number().min(1).max(8).describe("Target cell (1-8)."),
|
|
@@ -3579,7 +4618,7 @@ server.registerTool("copy_cell", {
|
|
|
3579
4618
|
server.registerTool("clear_modulations", {
|
|
3580
4619
|
title: "Clear Modulations",
|
|
3581
4620
|
description: "Remove all LFO, envelope, and vary modulation links targeting a cell's parameters " +
|
|
3582
|
-
"(or originating from that cell, for envelopes/vary).
|
|
4621
|
+
"(or originating from that cell, for envelopes/vary). Parameter sequencers survive — use remove_param_seq for those.",
|
|
3583
4622
|
inputSchema: {
|
|
3584
4623
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
3585
4624
|
},
|
|
@@ -3651,8 +4690,8 @@ server.registerTool("reset_patterns", {
|
|
|
3651
4690
|
});
|
|
3652
4691
|
server.registerTool("update_effect_config", {
|
|
3653
4692
|
title: "Update Effect Config",
|
|
3654
|
-
description: "Toggle cell effects on/off (filter, delay, crusher, flanger, eq, eq8, fdn, gate, compressor, panner, gain, vocoder, limiter, qverb, wavefold, resonator). " +
|
|
3655
|
-
"
|
|
4693
|
+
description: "Toggle cell effects on/off (filter, delay, crusher, flanger, chorus, phaser, eq, eq8, tablefilter, fdn, gate, compressor, panner, gain, vocoder, ringmod, vinylbrake, transient, repeat, pitchshift, limiter, qverb, shimmer, wavefold, resonator). " +
|
|
4694
|
+
"Wavefold and Resonator ship in every product. Exact availability comes from the selected product manifest. " +
|
|
3656
4695
|
"Uses the lighter KV path that does not rebuild the audio graph. Pass 1 to enable, 0 to disable.",
|
|
3657
4696
|
inputSchema: {
|
|
3658
4697
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
@@ -3664,7 +4703,7 @@ server.registerTool("update_effect_config", {
|
|
|
3664
4703
|
fdnEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3665
4704
|
gateEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3666
4705
|
compressorEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3667
|
-
//
|
|
4706
|
+
// Shared extended effects. These ship on PAM/Cell1 too, so leaving them out of the
|
|
3668
4707
|
// schema made them unreachable over the bridge even though the handler is
|
|
3669
4708
|
// generic (it just builds `cell{N}_{key}`) and the state vars exist in
|
|
3670
4709
|
// build.js alongside the eight above.
|
|
@@ -3672,10 +4711,19 @@ server.registerTool("update_effect_config", {
|
|
|
3672
4711
|
pannerEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3673
4712
|
gainEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3674
4713
|
vocoderEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4714
|
+
ringmodEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4715
|
+
vinylbrakeEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4716
|
+
transientEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4717
|
+
repeatEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4718
|
+
pitchshiftEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3675
4719
|
limiterEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3676
4720
|
qverbEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3677
|
-
|
|
3678
|
-
|
|
4721
|
+
chorusEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4722
|
+
phaserEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4723
|
+
shimmerEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4724
|
+
tablefilterEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4725
|
+
// Product-specific extended effects. The handler rejects flags absent from
|
|
4726
|
+
// the connected product instead of relying on the bridge to ignore them.
|
|
3679
4727
|
wavefoldEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3680
4728
|
resonatorEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3681
4729
|
},
|
|
@@ -3687,10 +4735,20 @@ server.registerTool("update_effect_config", {
|
|
|
3687
4735
|
const cellCheck = validateCellIdForManifest(cellId, manifest);
|
|
3688
4736
|
if (cellCheck !== true)
|
|
3689
4737
|
return cellCheck;
|
|
4738
|
+
if (!manifest) {
|
|
4739
|
+
return error("The selected product manifest is unavailable; effect availability cannot be validated safely.");
|
|
4740
|
+
}
|
|
4741
|
+
const availableEffects = new Set(cellEffectNames(manifest));
|
|
3690
4742
|
const delta = {};
|
|
3691
4743
|
for (const [key, value] of Object.entries(config)) {
|
|
3692
|
-
if (value
|
|
3693
|
-
|
|
4744
|
+
if (value === undefined)
|
|
4745
|
+
continue;
|
|
4746
|
+
const effect = key.endsWith("Enabled") ? key.slice(0, -"Enabled".length) : key;
|
|
4747
|
+
if (!availableEffects.has(effect)) {
|
|
4748
|
+
return error(`${effect} is not available in ${manifest.name ?? "the selected product"}. ` +
|
|
4749
|
+
`Available effects: ${[...availableEffects].join(", ")}.`);
|
|
4750
|
+
}
|
|
4751
|
+
delta[`cell${cellId}_${key}`] = value;
|
|
3694
4752
|
}
|
|
3695
4753
|
if (Object.keys(delta).length === 0) {
|
|
3696
4754
|
return error("At least one effect flag must be provided.");
|
|
@@ -3705,11 +4763,16 @@ server.registerTool("update_effect_config", {
|
|
|
3705
4763
|
// unreachable through set_parameters (numbers only) — an agent could shape an MFX
|
|
3706
4764
|
// cell but never patch one to another, which is the whole product.
|
|
3707
4765
|
//
|
|
3708
|
-
// Validation mirrors parseRoutingEdges (interface/src/dsp/mfxRouting.ts): the
|
|
4766
|
+
// Validation mirrors parseRoutingEdges (interface/src/dsp/products/mfx/mfxRouting.ts): the
|
|
3709
4767
|
// plugin re-validates on receipt, so this pass exists to fail loudly here rather
|
|
3710
4768
|
// than have a typo silently drop a cable.
|
|
3711
|
-
const MFX_ROUTING_SOURCE_HINT = "'input', 'sidechain',
|
|
3712
|
-
|
|
4769
|
+
const MFX_ROUTING_SOURCE_HINT = "'input', 'sidechain', 'split_lo' / 'split_mid' / 'split_hi', or 'cellN'. " +
|
|
4770
|
+
"'output' and 'split' are destinations only.";
|
|
4771
|
+
const MFX_ROUTING_SINK_HINT = "'output', 'split', or 'cellN'. 'input', 'sidechain' and the three split bands are sources only.";
|
|
4772
|
+
// SPLIT — the three-band crossover patch node. One sink, three band sources.
|
|
4773
|
+
const MFX_SPLIT_SINK = "split";
|
|
4774
|
+
const MFX_SPLIT_BANDS = ["split_lo", "split_mid", "split_hi"];
|
|
4775
|
+
const isMfxSplitBand = (port) => MFX_SPLIT_BANDS.includes(port);
|
|
3713
4776
|
function mfxPortCell(port) {
|
|
3714
4777
|
const m = /^cell([1-9]\d*)$/.exec(port); // canonical form only — 'cell01' is not a port
|
|
3715
4778
|
return m ? Number(m[1]) : null;
|
|
@@ -3722,12 +4785,23 @@ function validateMfxEdges(edges, maxCells) {
|
|
|
3722
4785
|
return c !== null && c >= 1 && c <= maxCells;
|
|
3723
4786
|
};
|
|
3724
4787
|
for (const { from, to } of edges) {
|
|
3725
|
-
if (from !== "input" && from !== "sidechain" && !inRange(from)) {
|
|
4788
|
+
if (from !== "input" && from !== "sidechain" && !isMfxSplitBand(from) && !inRange(from)) {
|
|
3726
4789
|
return { ok: false, reason: `invalid source port '${from}' — expected ${MFX_ROUTING_SOURCE_HINT}` };
|
|
3727
4790
|
}
|
|
3728
|
-
if (to !== "output" && !inRange(to)) {
|
|
4791
|
+
if (to !== "output" && to !== MFX_SPLIT_SINK && !inRange(to)) {
|
|
3729
4792
|
return { ok: false, reason: `invalid destination port '${to}' — expected ${MFX_ROUTING_SINK_HINT}` };
|
|
3730
4793
|
}
|
|
4794
|
+
// Only a source ENDPOINT may feed the crossover. Mirrors parseRoutingEdges: SPLIT sits
|
|
4795
|
+
// outside the cell evaluation order, so a cell→split cable would put it inside a cycle
|
|
4796
|
+
// the plugin's resolver cannot see. The plugin drops such a cable silently; failing here
|
|
4797
|
+
// is what tells the agent why its patch came back short.
|
|
4798
|
+
if (to === MFX_SPLIT_SINK && from !== "input" && from !== "sidechain") {
|
|
4799
|
+
return {
|
|
4800
|
+
ok: false,
|
|
4801
|
+
reason: `'${from}' cannot feed '${MFX_SPLIT_SINK}' — only 'input' and 'sidechain' can. ` +
|
|
4802
|
+
"Split what arrives, then patch the bands.",
|
|
4803
|
+
};
|
|
4804
|
+
}
|
|
3731
4805
|
const key = `${from}->${to}`;
|
|
3732
4806
|
if (seen.has(key))
|
|
3733
4807
|
continue; // dedupe, matching the plugin
|
|
@@ -3742,6 +4816,11 @@ server.registerTool("set_mfx_routing", {
|
|
|
3742
4816
|
"this returns a product error.\n\n" +
|
|
3743
4817
|
"Ports: 'input' (the host's audio in) and 'sidechain' are sources; 'output' is the destination; 'cellN' is both. " +
|
|
3744
4818
|
"A self-edge cellN→cellN is a feedback loop, and input→output is a direct dry path. Duplicate cables are dropped.\n\n" +
|
|
4819
|
+
"SPLIT is a three-band crossover node: cable 'input' (or 'sidechain') to 'split', then patch 'split_lo', " +
|
|
4820
|
+
"'split_mid' and 'split_hi' to different cells. Each band then has its own full FX rack, its own modulation " +
|
|
4821
|
+
"and its own sidechain key — which is how you build a multiband gate or a multiband distortion here. Only " +
|
|
4822
|
+
"'input' and 'sidechain' may feed 'split'; the bands are sources only. Its two corner frequencies are the " +
|
|
4823
|
+
"ordinary parameters mfx_split_lo_hz and mfx_split_hi_hz (set_parameters).\n\n" +
|
|
3745
4824
|
"A plain serial chain on 4 cells is input→cell1, cell1→cell2, cell2→cell3, cell3→cell4, cell4→output. " +
|
|
3746
4825
|
"Parallel is input→each cell plus each cell→output.\n\n" +
|
|
3747
4826
|
"'set' REPLACES the whole patch, so send the full cable list. Per-cable levels live in mfx_edge_gains and are " +
|
|
@@ -3783,6 +4862,258 @@ server.registerTool("set_mfx_routing", {
|
|
|
3783
4862
|
const summary = validated.edges.map((e) => `${e.from}→${e.to}`).join(", ");
|
|
3784
4863
|
return text(`MFX routing set: ${validated.edges.length} cable(s)${summary ? ` — ${summary}` : " (patch cleared)"}.`);
|
|
3785
4864
|
});
|
|
4865
|
+
server.registerTool("set_mfx_edge_gain", {
|
|
4866
|
+
title: "Set MFX Cable Gain",
|
|
4867
|
+
description: "Read or set the gain for one cable in the current MFX patch. Gain is linear: 0 is silent, 1 is unity, " +
|
|
4868
|
+
"and 1.5 is the maximum. The cable must already exist. Other cable gains are preserved.",
|
|
4869
|
+
inputSchema: {
|
|
4870
|
+
op: z.enum(["get", "set"]).describe("'get' returns every cable gain; 'set' writes one."),
|
|
4871
|
+
from: z.string().optional().describe(`Cable source for 'set': ${MFX_ROUTING_SOURCE_HINT}`),
|
|
4872
|
+
to: z.string().optional().describe(`Cable destination for 'set': ${MFX_ROUTING_SINK_HINT}`),
|
|
4873
|
+
gain: z.number().min(0).max(1.5).optional().describe("Linear gain for 'set'."),
|
|
4874
|
+
},
|
|
4875
|
+
}, async ({ op, from, to, gain }) => {
|
|
4876
|
+
const check = await requireBridge();
|
|
4877
|
+
if (check !== true)
|
|
4878
|
+
return check;
|
|
4879
|
+
const manifest = await loadManifestForProductGuard();
|
|
4880
|
+
if (!manifest || !isMfxManifest(manifest))
|
|
4881
|
+
return error("set_mfx_edge_gain is MFX-only.");
|
|
4882
|
+
const stateRaw = await bridge.send("getState");
|
|
4883
|
+
const state = isRecord(stateRaw) ? stateRaw : {};
|
|
4884
|
+
const gains = isRecord(state.mfx_edge_gains) ? { ...state.mfx_edge_gains } : {};
|
|
4885
|
+
if (op === "get")
|
|
4886
|
+
return json(gains);
|
|
4887
|
+
if (!from || !to || typeof gain !== "number")
|
|
4888
|
+
return error("op 'set' requires from, to, and gain.");
|
|
4889
|
+
const validated = validateMfxEdges([{ from, to }], manifestMaxCellId(manifest) ?? 4);
|
|
4890
|
+
if (!validated.ok)
|
|
4891
|
+
return error(validated.reason);
|
|
4892
|
+
const edges = Array.isArray(state.mfx_routing) ? state.mfx_routing : [];
|
|
4893
|
+
const exists = edges.some((edge) => isRecord(edge) && edge.from === from && edge.to === to);
|
|
4894
|
+
if (!exists)
|
|
4895
|
+
return error(`Cable ${from}→${to} is not in the current patch. Add it with set_mfx_routing first.`);
|
|
4896
|
+
const key = `${from}__${to}`;
|
|
4897
|
+
gains[key] = gain;
|
|
4898
|
+
await applyKv({ mfx_edge_gains: gains }, KV_OPTS_EFFECT_CONFIG);
|
|
4899
|
+
return text(`MFX cable gain set: ${from}→${to} = ${gain}.`);
|
|
4900
|
+
});
|
|
4901
|
+
const MFX_COMBO_TARGET_INPUT = z.object({
|
|
4902
|
+
paramId: z.string(),
|
|
4903
|
+
from: z.number().optional(),
|
|
4904
|
+
mid: z.number().optional(),
|
|
4905
|
+
to: z.number().optional(),
|
|
4906
|
+
});
|
|
4907
|
+
server.registerTool("configure_mfx_combo", {
|
|
4908
|
+
title: "Configure MFX Combo",
|
|
4909
|
+
description: "Read, edit, or move one of MFX's three performance combos. Automatic assignments follow the audible patch. " +
|
|
4910
|
+
"Manual target edits materialize and lock the assignment. setValue writes the combo parameter and its real targets " +
|
|
4911
|
+
"in one native state update, so it works with the MFX window closed.",
|
|
4912
|
+
inputSchema: {
|
|
4913
|
+
op: z.enum([
|
|
4914
|
+
"get", "setValue", "rename", "lock", "unlock", "rescan", "setTargets", "addTarget",
|
|
4915
|
+
"removeTarget", "clearTargets", "invertTarget", "setTargetStop", "anchor",
|
|
4916
|
+
]).describe("get reads the combo; setValue writes its value and every resolved target; rename relabels it (blank restores the role label); " +
|
|
4917
|
+
"lock freezes the current automatic assignment as manual; unlock and rescan return it to automatic; " +
|
|
4918
|
+
"setTargets/addTarget/removeTarget/clearTargets edit the manual target list; invertTarget swaps a target's from/to stops; " +
|
|
4919
|
+
"setTargetStop sets one target's stop value; anchor re-derives every target's stops from the current parameter values."),
|
|
4920
|
+
index: z.number().int().min(1).max(3).default(1),
|
|
4921
|
+
value: z.number().optional().describe("Combo value (−100 to 100) for setValue, or a value in the target parameter's units for setTargetStop."),
|
|
4922
|
+
name: z.string().max(24).optional().describe("New label for rename; blank restores the role label."),
|
|
4923
|
+
paramId: z.string().optional().describe("Target parameter for add/remove/invert/setTargetStop."),
|
|
4924
|
+
stop: z.enum(["from", "mid", "to"]).optional().describe("Target stop edited by setTargetStop."),
|
|
4925
|
+
targets: z.array(MFX_COMBO_TARGET_INPUT).max(6).optional().describe("Complete target list for setTargets."),
|
|
4926
|
+
},
|
|
4927
|
+
}, async ({ op, index, value, name, paramId, stop, targets }) => {
|
|
4928
|
+
const check = await requireBridge();
|
|
4929
|
+
if (check !== true)
|
|
4930
|
+
return check;
|
|
4931
|
+
const manifest = await loadManifest();
|
|
4932
|
+
if (!isMfxManifest(manifest))
|
|
4933
|
+
return error("configure_mfx_combo is MFX-only.");
|
|
4934
|
+
const stateRaw = await bridge.send("getState");
|
|
4935
|
+
const state = isRecord(stateRaw) ? stateRaw : {};
|
|
4936
|
+
const resolved = resolveServerMfxCombos(state, manifest);
|
|
4937
|
+
const combo = resolved[index - 1];
|
|
4938
|
+
const slots = parseMfxComboSlots(state.mfx_combos);
|
|
4939
|
+
const stored = slots[String(index)] ?? {};
|
|
4940
|
+
const parameterIndex = new Map(manifest.parameters.map((parameter) => [parameter.paramId, parameter]));
|
|
4941
|
+
if (op === "get")
|
|
4942
|
+
return json({ combos: resolved.map((entry) => ({ ...entry, value: state[`mfx_combo${entry.index}`] })) });
|
|
4943
|
+
if (op === "setValue") {
|
|
4944
|
+
if (typeof value !== "number")
|
|
4945
|
+
return error("setValue requires value.");
|
|
4946
|
+
if (value < -100 || value > 100)
|
|
4947
|
+
return error("Combo value must be between -100 and 100.");
|
|
4948
|
+
const delta = { [`mfx_combo${index}`]: value };
|
|
4949
|
+
for (const target of combo.targets) {
|
|
4950
|
+
const info = parameterIndex.get(target.id);
|
|
4951
|
+
if (!info)
|
|
4952
|
+
continue;
|
|
4953
|
+
const previousComboValue = state[`mfx_combo${index}`];
|
|
4954
|
+
const currentTargetValue = state[target.id];
|
|
4955
|
+
delta[target.id] = mfxComboTargetValueFromCurrent(target, typeof previousComboValue === "number" ? previousComboValue : 0, value, info, typeof currentTargetValue === "number" ? currentTargetValue : info.defaultValue);
|
|
4956
|
+
}
|
|
4957
|
+
await applyKv(delta, KV_OPTS_STANDARD);
|
|
4958
|
+
return json({ combo: index, value, targetsWritten: combo.targets.map((target) => target.id) });
|
|
4959
|
+
}
|
|
4960
|
+
const materialized = () => ({ ...stored, targets: [...combo.targets] });
|
|
4961
|
+
let next = stored;
|
|
4962
|
+
if (op === "rename") {
|
|
4963
|
+
if (name === undefined)
|
|
4964
|
+
return error("rename requires name.");
|
|
4965
|
+
next = { ...stored };
|
|
4966
|
+
const trimmed = name.trim().slice(0, 24);
|
|
4967
|
+
if (trimmed)
|
|
4968
|
+
next.name = trimmed;
|
|
4969
|
+
else
|
|
4970
|
+
delete next.name;
|
|
4971
|
+
}
|
|
4972
|
+
else if (op === "lock") {
|
|
4973
|
+
next = { ...materialized(), locked: 1 };
|
|
4974
|
+
}
|
|
4975
|
+
else if (op === "unlock" || op === "rescan") {
|
|
4976
|
+
next = stored.name ? { name: stored.name } : null;
|
|
4977
|
+
}
|
|
4978
|
+
else {
|
|
4979
|
+
let edited = materialized();
|
|
4980
|
+
if (op === "setTargets") {
|
|
4981
|
+
if (!targets)
|
|
4982
|
+
return error("setTargets requires targets.");
|
|
4983
|
+
const built = [];
|
|
4984
|
+
for (const target of targets) {
|
|
4985
|
+
const info = parameterIndex.get(target.paramId);
|
|
4986
|
+
if (!info || !isMfxComboTargetParameter(info)) {
|
|
4987
|
+
return error(`'${target.paramId}' is not an available MFX combo target.`);
|
|
4988
|
+
}
|
|
4989
|
+
if (built.some((candidate) => candidate.id === target.paramId)) {
|
|
4990
|
+
return error(`'${target.paramId}' appears more than once in targets.`);
|
|
4991
|
+
}
|
|
4992
|
+
const current = state[target.paramId];
|
|
4993
|
+
let builtTarget = buildMfxComboTarget(target.paramId, info, MFX_COMBO_MANUAL_STRENGTH, 1, typeof current === "number" ? current : info.defaultValue);
|
|
4994
|
+
for (const targetStop of ["from", "mid", "to"]) {
|
|
4995
|
+
const targetValue = target[targetStop];
|
|
4996
|
+
if (typeof targetValue === "number")
|
|
4997
|
+
builtTarget = setMfxComboTargetStop(builtTarget, info, targetStop, targetValue);
|
|
4998
|
+
}
|
|
4999
|
+
built.push(builtTarget);
|
|
5000
|
+
}
|
|
5001
|
+
edited.targets = built;
|
|
5002
|
+
}
|
|
5003
|
+
else if (op === "clearTargets") {
|
|
5004
|
+
edited.targets = [];
|
|
5005
|
+
}
|
|
5006
|
+
else if (op === "anchor") {
|
|
5007
|
+
edited.targets = edited.targets.map((target) => {
|
|
5008
|
+
const info = parameterIndex.get(target.id);
|
|
5009
|
+
const current = state[target.id];
|
|
5010
|
+
return info && typeof current === "number" ? reanchorMfxComboTarget(target, info, current) : target;
|
|
5011
|
+
});
|
|
5012
|
+
}
|
|
5013
|
+
else {
|
|
5014
|
+
if (!paramId)
|
|
5015
|
+
return error(`${op} requires paramId.`);
|
|
5016
|
+
const info = parameterIndex.get(paramId);
|
|
5017
|
+
if (!info || !isMfxComboTargetParameter(info)) {
|
|
5018
|
+
return error(`'${paramId}' is not an available MFX combo target.`);
|
|
5019
|
+
}
|
|
5020
|
+
if (op === "addTarget") {
|
|
5021
|
+
if (!edited.targets.some((target) => target.id === paramId)) {
|
|
5022
|
+
if (edited.targets.length >= 6)
|
|
5023
|
+
return error("A combo can drive at most 6 targets.");
|
|
5024
|
+
const current = state[paramId];
|
|
5025
|
+
edited.targets.push(buildMfxComboTarget(paramId, info, MFX_COMBO_MANUAL_STRENGTH, 1, typeof current === "number" ? current : info.defaultValue));
|
|
5026
|
+
}
|
|
5027
|
+
}
|
|
5028
|
+
else if (op === "removeTarget") {
|
|
5029
|
+
edited.targets = edited.targets.filter((target) => target.id !== paramId);
|
|
5030
|
+
}
|
|
5031
|
+
else if (op === "invertTarget") {
|
|
5032
|
+
edited.targets = edited.targets.map((target) => target.id === paramId
|
|
5033
|
+
? { id: target.id, from: target.to, mid: target.mid, to: target.from }
|
|
5034
|
+
: target);
|
|
5035
|
+
}
|
|
5036
|
+
else if (op === "setTargetStop") {
|
|
5037
|
+
if (!stop || typeof value !== "number")
|
|
5038
|
+
return error("setTargetStop requires paramId, stop, and value.");
|
|
5039
|
+
if (!edited.targets.some((target) => target.id === paramId)) {
|
|
5040
|
+
return error(`'${paramId}' is not assigned to combo ${index}.`);
|
|
5041
|
+
}
|
|
5042
|
+
edited.targets = edited.targets.map((target) => target.id === paramId
|
|
5043
|
+
? setMfxComboTargetStop(target, info, stop, value)
|
|
5044
|
+
: target);
|
|
5045
|
+
}
|
|
5046
|
+
}
|
|
5047
|
+
next = { ...edited, locked: 1 };
|
|
5048
|
+
}
|
|
5049
|
+
const update = {
|
|
5050
|
+
name: next?.name ?? null,
|
|
5051
|
+
locked: next?.locked === 1 ? 1 : 0,
|
|
5052
|
+
targets: Array.isArray(next?.targets) ? next.targets : null,
|
|
5053
|
+
};
|
|
5054
|
+
await applyKv({ mfx_combos: { ...slots, [String(index)]: update } }, KV_OPTS_EFFECT_CONFIG);
|
|
5055
|
+
return json({ combo: index, slot: update });
|
|
5056
|
+
});
|
|
5057
|
+
server.registerTool("set_effect_order", {
|
|
5058
|
+
title: "Set Cell FX Chain Order",
|
|
5059
|
+
description: "Read or replace one cell's FX chain order. cellN_effectOrder is a JSON string array, not a numeric " +
|
|
5060
|
+
"parameter, so set_parameters cannot reach it — this tool owns it (the set_mfx_routing precedent).\n\n" +
|
|
5061
|
+
"The available stages and their fresh-cell order are product-specific and come from the selected manifest; " +
|
|
5062
|
+
"design_context reports that default order. 'set' REPLACES the chain, so send every listed stage " +
|
|
5063
|
+
"in the order you want it to process; the array must be a permutation, not a subset (use the per-effect " +
|
|
5064
|
+
"*Enabled flags via update_effect_config to switch stages off).\n\n" +
|
|
5065
|
+
"Order is audible and is a cheap way to make two otherwise identical patches sound different: filter before " +
|
|
5066
|
+
"delay smears the resonance into the repeats, delay before filter darkens the repeats instead. Crusher " +
|
|
5067
|
+
"before filter grinds then tames; filter before crusher grinds only what survived. Reordering rebuilds the " +
|
|
5068
|
+
"cell's graph.",
|
|
5069
|
+
inputSchema: {
|
|
5070
|
+
cellId: z.number().int().min(1).max(8).describe("Cell to read or reorder."),
|
|
5071
|
+
op: z.enum(["get", "set"]).default("set").describe("'get' reads the current chain; 'set' replaces it."),
|
|
5072
|
+
order: z
|
|
5073
|
+
.array(z.string().min(1))
|
|
5074
|
+
.optional()
|
|
5075
|
+
.describe("Full product-specific chain order for 'set' — a permutation of every stage reported by design_context."),
|
|
5076
|
+
},
|
|
5077
|
+
annotations: { destructiveHint: false, idempotentHint: true },
|
|
5078
|
+
}, async ({ cellId, op, order }) => {
|
|
5079
|
+
const check = await requireBridgeForCells(cellId);
|
|
5080
|
+
if (check !== true)
|
|
5081
|
+
return check;
|
|
5082
|
+
const key = `cell${cellId}_effectOrder`;
|
|
5083
|
+
if (op === "get") {
|
|
5084
|
+
const raw = await bridge.send("getState", key);
|
|
5085
|
+
return json(isRecord(raw) ? raw[key] ?? raw : raw);
|
|
5086
|
+
}
|
|
5087
|
+
if (!order)
|
|
5088
|
+
return error("op 'set' requires an `order` array.");
|
|
5089
|
+
const manifest = await loadManifestForProductGuard();
|
|
5090
|
+
if (!manifest) {
|
|
5091
|
+
return error("The selected product manifest is unavailable; its complete FX chain cannot be validated safely.");
|
|
5092
|
+
}
|
|
5093
|
+
const expectedOrder = cellEffectOrder(manifest);
|
|
5094
|
+
if (expectedOrder.length === 0) {
|
|
5095
|
+
return error(`${manifest.name ?? "The selected product"} manifest has no valid cell1_effectOrder default.`);
|
|
5096
|
+
}
|
|
5097
|
+
const seen = new Set(order);
|
|
5098
|
+
if (seen.size !== order.length) {
|
|
5099
|
+
const dupes = order.filter((s, i) => order.indexOf(s) !== i);
|
|
5100
|
+
return error(`Duplicate stage(s) in order: ${[...new Set(dupes)].join(", ")}.`);
|
|
5101
|
+
}
|
|
5102
|
+
// A subset would silently drop stages from the chain rather than bypass
|
|
5103
|
+
// them, so it is rejected here instead of at the plugin.
|
|
5104
|
+
const expected = new Set(expectedOrder);
|
|
5105
|
+
const unknown = order.filter((stage) => !expected.has(stage));
|
|
5106
|
+
const missing = expectedOrder.filter((stage) => !seen.has(stage));
|
|
5107
|
+
if (unknown.length || missing.length) {
|
|
5108
|
+
return error(`order must be a full ${manifest.name ?? "product"} permutation` +
|
|
5109
|
+
`${unknown.length ? ` — unknown: ${[...new Set(unknown)].join(", ")}` : ""}` +
|
|
5110
|
+
`${missing.length ? ` — missing: ${missing.join(", ")}` : ""}. ` +
|
|
5111
|
+
"To bypass a stage set its *Enabled flag via update_effect_config; leaving it out of the chain is not the same thing.");
|
|
5112
|
+
}
|
|
5113
|
+
// effectOrder rebuilds the cell's FX graph, like an MFX re-patch.
|
|
5114
|
+
await applyKv({ [key]: order }, KV_OPTS_STANDARD);
|
|
5115
|
+
return text(`Cell ${cellId} FX chain: ${order.join(" → ")}.`);
|
|
5116
|
+
});
|
|
3786
5117
|
server.registerTool("update_cell_config", {
|
|
3787
5118
|
title: "Update Cell Config",
|
|
3788
5119
|
description: "Update cell-level configuration properties like polyphony, MIDI settings, varispeed, and modulation latch. " +
|
|
@@ -3830,8 +5161,8 @@ server.registerTool("update_cell_config", {
|
|
|
3830
5161
|
"An out-of-vocabulary string would silently play as chromatic while junk persists into the preset, so the name is validated."),
|
|
3831
5162
|
scaleRoot: z.number().min(0).max(11).optional().describe("Root note of the scale (0=C, 1=C#, ..., 11=B)."),
|
|
3832
5163
|
pitchMode: z.number().int().min(0).max(3).optional().describe("Pitch engine: 0=Varispeed, 1=Formant, 2=Pitch Shift Quantize, 3=Varispeed Quantize."),
|
|
3833
|
-
stretchMode: z.number().int().min(0).max(3).optional().describe("Stretch engine: 0=OLA, 1=Cloud, 2=
|
|
3834
|
-
quantScale: z.number().int().min(0).max(9).optional().describe("Pitch quantize scale when pitchMode=2 (Pitch Shift Quantize) or 3 (Varispeed Quantize): 0=Chromatic, 1=Major, 2=Minor, 3=Penta Maj, 4=Penta Min, 5=Dorian, 6=Mixolydian, 7=Blues, 8=Harm Min, 9=Phrygian. Order locked to QuantScale in modules/
|
|
5164
|
+
stretchMode: z.number().int().min(0).max(3).optional().describe("Stretch engine: 0=Default (OLA), 1=Cloud, 2=Spectral, 3=Beats."),
|
|
5165
|
+
quantScale: z.number().int().min(0).max(9).optional().describe("Pitch quantize scale when pitchMode=2 (Pitch Shift Quantize) or 3 (Varispeed Quantize): 0=Chromatic, 1=Major, 2=Minor, 3=Penta Maj, 4=Penta Min, 5=Dorian, 6=Mixolydian, 7=Blues, 8=Harm Min, 9=Phrygian. Order locked to QuantScale in modules/dsp-core/Scale.h."),
|
|
3835
5166
|
quantRoot: z.number().int().min(0).max(11).optional().describe("Pitch quantize root when pitchMode=2 (Pitch Shift Quantize) or 3 (Varispeed Quantize): 0=C, 1=C#, ..., 11=B."),
|
|
3836
5167
|
grainDensity: z.number().int().min(1).max(24).optional().describe("Cloud stretch grain density when stretchMode=1."),
|
|
3837
5168
|
grainWindow: z.number().int().min(0).max(4).optional().describe("Cloud stretch grain envelope when stretchMode=1: 0=Hann, 1=Tukey, 2=Welch, 3=Punch, 4=Swell."),
|
|
@@ -3909,7 +5240,7 @@ server.registerTool("set_cell_mute", {
|
|
|
3909
5240
|
description: "Mute, unmute, solo, or unsolo a cell in the mixer — the real channel mute/solo, cutting the whole channel " +
|
|
3910
5241
|
"(source + all effect tails) rather than pulling the volume fader down. Soloing a cell silences every other " +
|
|
3911
5242
|
"non-soloed cell until unsoloed. cell{N}_mute / cell{N}_solo are live-polled state variables (no graph rebuild). " +
|
|
3912
|
-
"Prefer this over
|
|
5243
|
+
"Prefer this over pulling cell{N}_volume down: a fader move leaves effect tails audible, a channel mute cuts them.",
|
|
3913
5244
|
inputSchema: {
|
|
3914
5245
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
3915
5246
|
mute: z.boolean().optional().describe("Mute (true) or unmute (false) the cell's channel."),
|
|
@@ -3950,11 +5281,7 @@ server.registerTool("set_master_effects", {
|
|
|
3950
5281
|
"Check results with get_master_analysis (keep tp <= -1 dBTP, PSR >= 8 in the loudest section). " +
|
|
3951
5282
|
"The Tape is a vintage-tape coloration stage (ADAA tanh/asymmetric saturation + HF compression + wow/flutter), " +
|
|
3952
5283
|
"the single biggest lever for lo-fi / dusty / 'pushed' master character (hip-hop, lo-fi, Memphis, dub). " +
|
|
3953
|
-
"Pass enabled flags to toggle each on/off. Pass parameters to shape the sound. " +
|
|
3954
|
-
"For pumpy beats: enable punch, set threshold around -18 to -12 dB, fast attack (0.1-0.5ms), " +
|
|
3955
|
-
"medium release (80-200ms), ratio 4-8, autoMakeup true. " +
|
|
3956
|
-
"For lo-fi/Memphis/dusty glue: tapeEnabled=1, tapeMode=1 (Lo-Fi), masterTapeDrive 0.55-0.7, " +
|
|
3957
|
-
"masterTapeTone -0.4..-0.1 (darker), masterTapeWear 0.5-0.7 (wow/flutter). " +
|
|
5284
|
+
"Pass enabled flags to toggle each on/off. Pass parameters to shape the sound; genre recipes live in the beat-making-guide resource. " +
|
|
3958
5285
|
"Squeeze (masterSqueeze*) is the 3-band up/down density stage between Punch and Tape — for dense modern-electronic masters set masterSqueezeEnabled=1 with masterSqueezeDepth 0.2-0.4; it lifts quiet detail and limits loud bands per band (a character stage, not glue — Punch stays the glue). " +
|
|
3959
5286
|
"NOTE: the Tape and Squeeze stages require a PAM build that ships them (flat state keys, not manifest params). On older builds these keys are ignored.",
|
|
3960
5287
|
inputSchema: {
|
|
@@ -3988,7 +5315,7 @@ server.registerTool("set_master_effects", {
|
|
|
3988
5315
|
masterEqHsGain: z.number().min(-12).max(12).optional().describe("High-shelf gain dB (default 0)."),
|
|
3989
5316
|
masterWidth: z.number().min(0).max(2).optional().describe("Stereo width (0=mono, 1=unity, 2=double side; default 1). Check corr in get_master_analysis after widening."),
|
|
3990
5317
|
masterBassMonoFreq: z.number().min(0).max(300).optional().describe("Elliptical bass-mono corner Hz; < 20 = off. Engage (~100-120) when corrLow < +0.7."),
|
|
3991
|
-
masterClipEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the oversampled soft clipper (post-Tape, pre-Limiter).
|
|
5318
|
+
masterClipEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the oversampled soft clipper (post-Tape, pre-Limiter). Its 23 samples of latency are reported whether it is on or off, so toggling it never moves the host's compensation."),
|
|
3992
5319
|
masterClipDrive: z.number().min(0).max(12).optional().describe("Clipper drive dB (default 0). Staging guidance: the clipper shaves the sharpest 2-3 dB so the limiter works 1-2 dB."),
|
|
3993
5320
|
masterClipMix: z.number().min(0).max(1).optional().describe("Clipper parallel blend (1 = fully clipped)."),
|
|
3994
5321
|
masterClipMode: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]).optional().describe("Clipper transfer: 0=Smooth (knee-tanh), 1=Silicon diode (hard knee), 2=Germanium diode (earlier, softer knee), 3=LED (higher knee). Diode modes are exact antiparallel-pair solves, ADAA at the 2x rate. Default 0."),
|
|
@@ -4169,7 +5496,7 @@ server.registerTool("set_drum_synth_wave", {
|
|
|
4169
5496
|
"centre, tension −1..+1 with 0 = linear). PAM bakes them into a DC-removed, peak-normalized single cycle " +
|
|
4170
5497
|
"that the Wave engine's Draw param (p8) crossfades against the factory bank. Only the Wave engine reads it — " +
|
|
4171
5498
|
"select 'Wave' via set_drum_synth and raise Draw (p8) to hear it. Runs in the WebView (the curve transform " +
|
|
4172
|
-
"is single-sourced there), so PAM's window must be OPEN
|
|
5499
|
+
"is single-sourced there), so PAM's window must be OPEN with drum-synth wave editing support.",
|
|
4173
5500
|
inputSchema: {
|
|
4174
5501
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
4175
5502
|
points: z
|
|
@@ -4200,6 +5527,394 @@ server.registerTool("set_drum_synth_wave", {
|
|
|
4200
5527
|
return text(`Set cell ${cellId} Wave-engine custom waveform (${points.length} breakpoints). ` +
|
|
4201
5528
|
`Select the Wave engine and raise Draw (p8) to hear it. (Runs in the WebView — ensure PAM's window is open.)`);
|
|
4202
5529
|
});
|
|
5530
|
+
// Generative Players (per-cell PLAYER mode). Arming semantics mirror
|
|
5531
|
+
// interface/src/interop.ts setCellPlayer; the registry is shared/generativePlayers.json.
|
|
5532
|
+
const PLAYER_REF_HELP = `one of ${PLAYER_NAMES.join(", ")} (or its id 1..${GENERATIVE_PLAYERS.length}), ` +
|
|
5533
|
+
"'role' for the cell's role default, or 'off'";
|
|
5534
|
+
function playerProductError(manifest) {
|
|
5535
|
+
if (manifest && manifest.generativePatterns?.enabled !== true) {
|
|
5536
|
+
return error(`Generative players are PAM-only. ${manifest.name ?? "This product"}'s manifest exposes none.`);
|
|
5537
|
+
}
|
|
5538
|
+
return null;
|
|
5539
|
+
}
|
|
5540
|
+
const playerSlotArg = (n) => z
|
|
5541
|
+
.union([z.number().min(0).max(1), z.string()])
|
|
5542
|
+
.optional()
|
|
5543
|
+
.describe(`P${n} slot: 0..1 (snapped to the slot's detents) or a position label such as 'Techno' or 'Triad'. ` +
|
|
5544
|
+
"Its meaning is per player — list_players names it.");
|
|
5545
|
+
server.registerTool("list_players", {
|
|
5546
|
+
title: "List Generative Players",
|
|
5547
|
+
description: "Catalogue of PAM's generative Players — per-cell generators that replace a cell's drawn pattern with a " +
|
|
5548
|
+
"seeded, transport-synced cycle (EUCLID, KIT, TURING, WALK, MARKOV, RATCHET, PHASE, RIFF, MASK). Returns each " +
|
|
5549
|
+
"player's blurb, defaults, P-slot vocabulary (detented positions or 0..1 knobs) and curated preset names, plus " +
|
|
5550
|
+
"the role spread (which player each cell arms by default). Offline — no bridge needed. Arm one with set_player.",
|
|
5551
|
+
inputSchema: {
|
|
5552
|
+
player: z.string().optional().describe("Return one player's full detail (name or id). Omit for the whole roster."),
|
|
5553
|
+
presets: z
|
|
5554
|
+
.boolean()
|
|
5555
|
+
.optional()
|
|
5556
|
+
.describe("Include each curated preset's values, not only its name. Default false."),
|
|
5557
|
+
},
|
|
5558
|
+
annotations: { readOnlyHint: true },
|
|
5559
|
+
}, async ({ player, presets }) => {
|
|
5560
|
+
const manifest = await loadManifestForProductGuard();
|
|
5561
|
+
const available = manifest ? manifest.generativePatterns?.enabled === true : true;
|
|
5562
|
+
const spec = player ? resolvePlayerSpec(player) : undefined;
|
|
5563
|
+
if (player && !spec)
|
|
5564
|
+
return error(`Unknown player '${player}' — ${PLAYER_REF_HELP}.`);
|
|
5565
|
+
const specs = spec ? [spec] : GENERATIVE_PLAYERS;
|
|
5566
|
+
return json({
|
|
5567
|
+
available,
|
|
5568
|
+
...(available ? {} : { note: `${manifest?.name ?? "This product"} exposes no players; the catalogue is informational.` }),
|
|
5569
|
+
players: specs.map((entry) => ({
|
|
5570
|
+
...describePlayerSpec(entry, true),
|
|
5571
|
+
...(presets ? { presetValues: playerPresetsFor(entry.id) } : {}),
|
|
5572
|
+
})),
|
|
5573
|
+
roleSpread: roleSpreadSummary(),
|
|
5574
|
+
notes: [
|
|
5575
|
+
"A player replaces what the cell PLAYS, never the drawn pattern: set_player 'off' restores the grid exactly.",
|
|
5576
|
+
"Sequencer output needs the transport running (start_transport).",
|
|
5577
|
+
"Melodic players arm the cell poly with the root on its trigger note; 'off' restores both.",
|
|
5578
|
+
"KIT follows its Genre slot — pass genre to set_player so it matches the track.",
|
|
5579
|
+
"patternMode 'keep' layers the player over the drawn pattern instead of masking it.",
|
|
5580
|
+
],
|
|
5581
|
+
});
|
|
5582
|
+
});
|
|
5583
|
+
server.registerTool("set_player", {
|
|
5584
|
+
title: "Set Generative Player",
|
|
5585
|
+
description: "Arm, edit or switch off a cell's generative Player (PAM-only). Passing `player` arms that generator with the " +
|
|
5586
|
+
"roster's own voicing (or a curated `preset`), the track genre on genre-aware players, and a reversible poly " +
|
|
5587
|
+
"arm on melodic ones; omit `player` to edit the running player's values in place. Values land in both the cell " +
|
|
5588
|
+
"config and the cell{N}_player* automation params, like the plugin UI. 'off' restores the drawn pattern and the " +
|
|
5589
|
+
"cell's pre-arm polyphony. The player sounds only while the transport runs. The UI shows a running player " +
|
|
5590
|
+
"unprompted; to arm from the UI as well, update_settings { experimentalMode: true, experimentalGenerativePatterns: true }.",
|
|
5591
|
+
inputSchema: {
|
|
5592
|
+
cellIds: z
|
|
5593
|
+
.array(z.number().int().min(1).max(8))
|
|
5594
|
+
.min(1)
|
|
5595
|
+
.describe("Cells to write (1-8). Several cells take the same write, each snapped to its own player."),
|
|
5596
|
+
player: z
|
|
5597
|
+
.union([z.string(), z.number().int().min(0)])
|
|
5598
|
+
.optional()
|
|
5599
|
+
.describe(`Player to arm: ${PLAYER_REF_HELP}. Omit to edit the running player.`),
|
|
5600
|
+
preset: z.string().optional().describe("Curated preset name for the armed or running player — list_players lists them."),
|
|
5601
|
+
genre: z
|
|
5602
|
+
.enum(PLAYER_GENRES)
|
|
5603
|
+
.optional()
|
|
5604
|
+
.describe("Genre for genre-aware players (KIT's Genre slot). Defaults to the roster default when arming."),
|
|
5605
|
+
density: z.number().min(0).max(1).optional().describe("How much plays, 0..1. Audible from the next step."),
|
|
5606
|
+
mutate: z.number().min(0).max(1).optional().describe("Cycle-to-cycle evolution, 0..1 (below 0.02 locks the loop)."),
|
|
5607
|
+
p1: playerSlotArg(1),
|
|
5608
|
+
p2: playerSlotArg(2),
|
|
5609
|
+
p3: playerSlotArg(3),
|
|
5610
|
+
p4: playerSlotArg(4),
|
|
5611
|
+
p5: playerSlotArg(5),
|
|
5612
|
+
seed: z
|
|
5613
|
+
.union([z.number().int().min(0), z.literal("random")])
|
|
5614
|
+
.optional()
|
|
5615
|
+
.describe("Player seed (uint31) or 'random' to reroll — the DICE gesture."),
|
|
5616
|
+
patternMode: z
|
|
5617
|
+
.enum(PATTERN_MODES)
|
|
5618
|
+
.optional()
|
|
5619
|
+
.describe("'mask' (default): the player replaces the drawn pattern. 'keep': the drawn pattern keeps sounding and the player layers over it."),
|
|
5620
|
+
listenMode: z
|
|
5621
|
+
.enum(LISTEN_MODES)
|
|
5622
|
+
.optional()
|
|
5623
|
+
.describe("Ensemble listening: react to another cell's last completed cycle — complement its gaps, answer it, or shade velocity harmonically."),
|
|
5624
|
+
listenSource: z.number().int().min(0).max(8).optional().describe("Listening source: 0 = all other cells, 1-8 = one cell (never itself)."),
|
|
5625
|
+
listenAmount: z.number().min(0).max(1).optional().describe("Listening strength 0..1."),
|
|
5626
|
+
poly: z
|
|
5627
|
+
.boolean()
|
|
5628
|
+
.optional()
|
|
5629
|
+
.describe("Override the automatic poly arm: true forces poly, false keeps the cell mono even for a melodic player."),
|
|
5630
|
+
},
|
|
5631
|
+
}, async ({ cellIds, player, preset, genre, density, mutate, p1, p2, p3, p4, p5, seed, patternMode, listenMode, listenSource, listenAmount, poly }) => {
|
|
5632
|
+
const check = await requireBridgeForCells(cellIds);
|
|
5633
|
+
if (check !== true)
|
|
5634
|
+
return check;
|
|
5635
|
+
const manifest = await loadManifestForProductGuard();
|
|
5636
|
+
const gate = playerProductError(manifest);
|
|
5637
|
+
if (gate)
|
|
5638
|
+
return gate;
|
|
5639
|
+
const playerRef = typeof player === "string" ? player.trim().toLowerCase() : player;
|
|
5640
|
+
const wantsOff = playerRef === "off" || playerRef === 0;
|
|
5641
|
+
const wantsRole = playerRef === "role";
|
|
5642
|
+
const explicitSpec = player !== undefined && !wantsOff && !wantsRole ? resolvePlayerSpec(player) : undefined;
|
|
5643
|
+
if (player !== undefined && !wantsOff && !wantsRole && !explicitSpec) {
|
|
5644
|
+
return error(`Unknown player '${player}' — ${PLAYER_REF_HELP}.`);
|
|
5645
|
+
}
|
|
5646
|
+
const stateRaw = await bridge.send("getState");
|
|
5647
|
+
const state = isRecord(stateRaw) ? stateRaw : {};
|
|
5648
|
+
const cellsObj = isRecord(state.cells) ? state.cells : {};
|
|
5649
|
+
const slotInputs = [p1, p2, p3, p4, p5];
|
|
5650
|
+
const flatDelta = {};
|
|
5651
|
+
const cellsDelta = {};
|
|
5652
|
+
const lines = [];
|
|
5653
|
+
for (const cellId of new Set(cellIds)) {
|
|
5654
|
+
const rawCell = cellsObj[String(cellId)];
|
|
5655
|
+
const cell = isRecord(rawCell) ? rawCell : {};
|
|
5656
|
+
const currentType = cellPlayerType(cell);
|
|
5657
|
+
const recorded = (key) => {
|
|
5658
|
+
const value = Number(cell[key]);
|
|
5659
|
+
return Number.isFinite(value) && value !== NO_POLY_ARM ? Math.round(value) : undefined;
|
|
5660
|
+
};
|
|
5661
|
+
if (wantsOff) {
|
|
5662
|
+
if (currentType <= 0) {
|
|
5663
|
+
lines.push(`Cell ${cellId}: no player running.`);
|
|
5664
|
+
continue;
|
|
5665
|
+
}
|
|
5666
|
+
const config = { playerType: 0, playerLastType: currentType };
|
|
5667
|
+
const prePoly = recorded("playerPrePoly");
|
|
5668
|
+
const preRoot = recorded("playerPreRoot");
|
|
5669
|
+
if (prePoly !== undefined)
|
|
5670
|
+
config.isPoly = prePoly;
|
|
5671
|
+
if (preRoot !== undefined)
|
|
5672
|
+
config.polyRootNote = preRoot;
|
|
5673
|
+
config.playerPrePoly = NO_POLY_ARM;
|
|
5674
|
+
config.playerPreRoot = NO_POLY_ARM;
|
|
5675
|
+
cellsDelta[String(cellId)] = config;
|
|
5676
|
+
lines.push(`Cell ${cellId}: ${getPlayerSpec(currentType)?.name ?? "player"} off — drawn pattern restored` +
|
|
5677
|
+
`${prePoly !== undefined || preRoot !== undefined ? ", pre-arm polyphony restored" : ""}.`);
|
|
5678
|
+
continue;
|
|
5679
|
+
}
|
|
5680
|
+
const roleConfig = wantsRole ? rolePlayerConfigFor(cellId) : undefined;
|
|
5681
|
+
const spec = explicitSpec ?? (roleConfig ? getPlayerSpec(roleConfig.playerType) : getPlayerSpec(currentType));
|
|
5682
|
+
if (!spec)
|
|
5683
|
+
return error(`Cell ${cellId} runs no player. Pass player to arm one — ${PLAYER_REF_HELP}.`);
|
|
5684
|
+
const arming = explicitSpec !== undefined || roleConfig !== undefined;
|
|
5685
|
+
const config = {};
|
|
5686
|
+
if (arming) {
|
|
5687
|
+
Object.assign(config, playerDefaultsFor(spec.id), playerGenreConfig(spec, genre));
|
|
5688
|
+
if (roleConfig?.playerP1 !== undefined)
|
|
5689
|
+
config.playerP1 = roleConfig.playerP1;
|
|
5690
|
+
if (roleConfig?.playerP5 !== undefined)
|
|
5691
|
+
config.playerP5 = roleConfig.playerP5;
|
|
5692
|
+
config.playerLastType = spec.id;
|
|
5693
|
+
}
|
|
5694
|
+
else if (genre) {
|
|
5695
|
+
Object.assign(config, playerGenreConfig(spec, genre));
|
|
5696
|
+
}
|
|
5697
|
+
if (preset) {
|
|
5698
|
+
const found = resolvePlayerPreset(spec, preset);
|
|
5699
|
+
if (!found) {
|
|
5700
|
+
const names = playerPresetsFor(spec.id).map((entry) => entry.name);
|
|
5701
|
+
return error(`${spec.name} has no preset '${preset}' — one of: ${names.join(", ")}.`);
|
|
5702
|
+
}
|
|
5703
|
+
Object.assign(config, presetConfig(found));
|
|
5704
|
+
}
|
|
5705
|
+
if (density !== undefined)
|
|
5706
|
+
config.playerDensity = density;
|
|
5707
|
+
if (mutate !== undefined)
|
|
5708
|
+
config.playerMutate = mutate;
|
|
5709
|
+
for (let i = 0; i < slotInputs.length; i++) {
|
|
5710
|
+
const input = slotInputs[i];
|
|
5711
|
+
if (input === undefined)
|
|
5712
|
+
continue;
|
|
5713
|
+
const slot = spec.slots[i];
|
|
5714
|
+
if (!slot)
|
|
5715
|
+
return error(`${spec.name} has no P${i + 1} slot (it has ${spec.slots.length}).`);
|
|
5716
|
+
const resolved = resolveSlotValue(slot, input);
|
|
5717
|
+
if (typeof resolved === "string")
|
|
5718
|
+
return error(`Cell ${cellId} ${spec.name} P${i + 1}: ${resolved}`);
|
|
5719
|
+
config[PLAYER_SLOT_FIELDS[i]] = resolved;
|
|
5720
|
+
}
|
|
5721
|
+
PLAYER_SLOT_FIELDS.forEach((field, i) => {
|
|
5722
|
+
if (config[field] !== undefined)
|
|
5723
|
+
config[field] = snapPlayerValue(spec.slots[i]?.steps, config[field]);
|
|
5724
|
+
});
|
|
5725
|
+
if (seed !== undefined)
|
|
5726
|
+
config.playerSeed = seed === "random" ? randomPlayerSeed() : Math.round(seed);
|
|
5727
|
+
if (patternMode)
|
|
5728
|
+
config.playerPatternMode = PATTERN_MODES.indexOf(patternMode);
|
|
5729
|
+
if (listenMode)
|
|
5730
|
+
config.playerListenMode = LISTEN_MODES.indexOf(listenMode);
|
|
5731
|
+
if (listenSource !== undefined)
|
|
5732
|
+
config.playerListenSource = listenSource;
|
|
5733
|
+
if (listenAmount !== undefined)
|
|
5734
|
+
config.playerListenAmount = listenAmount;
|
|
5735
|
+
// Reversible poly arm: record the pre-arm values once, never the armed state.
|
|
5736
|
+
const autoPoly = arming && poly !== false && (isMelodicPlayer(spec) || roleConfig?.isPoly === 1);
|
|
5737
|
+
let polyNote = "";
|
|
5738
|
+
if (autoPoly) {
|
|
5739
|
+
if (recorded("playerPrePoly") === undefined && recorded("playerPreRoot") === undefined) {
|
|
5740
|
+
config.playerPrePoly = Math.round(Number(cell.isPoly ?? 0)) || 0;
|
|
5741
|
+
config.playerPreRoot = Math.round(Number(cell.polyRootNote ?? 48)) || 48;
|
|
5742
|
+
}
|
|
5743
|
+
config.isPoly = 1;
|
|
5744
|
+
config.polyRootNote = Math.round(Number(cell.midiNote ?? 47 + cellId)) || 47 + cellId;
|
|
5745
|
+
polyNote = `; poly armed (root ${config.polyRootNote})`;
|
|
5746
|
+
}
|
|
5747
|
+
else if (poly !== undefined) {
|
|
5748
|
+
config.isPoly = poly ? 1 : 0;
|
|
5749
|
+
polyNote = `; poly ${poly ? "on" : "off"}`;
|
|
5750
|
+
}
|
|
5751
|
+
if (arming)
|
|
5752
|
+
config.playerType = spec.id;
|
|
5753
|
+
if (Object.keys(config).length === 0)
|
|
5754
|
+
return error("Nothing to write — pass player, preset, or at least one value.");
|
|
5755
|
+
cellsDelta[String(cellId)] = config;
|
|
5756
|
+
for (const field of MODULATABLE_PLAYER_FIELDS) {
|
|
5757
|
+
if (typeof config[field] === "number")
|
|
5758
|
+
flatDelta[`cell${cellId}_${field}`] = config[field];
|
|
5759
|
+
}
|
|
5760
|
+
const parts = [];
|
|
5761
|
+
if (config.playerDensity !== undefined)
|
|
5762
|
+
parts.push(`density ${playerDensityDisplay(config.playerDensity)}`);
|
|
5763
|
+
if (config.playerMutate !== undefined)
|
|
5764
|
+
parts.push(`mutate ${playerMutateDisplay(config.playerMutate)}`);
|
|
5765
|
+
spec.slots.forEach((slot, i) => {
|
|
5766
|
+
const value = config[PLAYER_SLOT_FIELDS[i]];
|
|
5767
|
+
if (value !== undefined)
|
|
5768
|
+
parts.push(`${slot.label}=${playerSlotDisplay(slot, value)}`);
|
|
5769
|
+
});
|
|
5770
|
+
if (config.playerSeed !== undefined)
|
|
5771
|
+
parts.push(`seed ${config.playerSeed}`);
|
|
5772
|
+
if (patternMode)
|
|
5773
|
+
parts.push(`pattern ${patternMode}`);
|
|
5774
|
+
if (listenMode)
|
|
5775
|
+
parts.push(`listen ${listenMode}`);
|
|
5776
|
+
lines.push(`Cell ${cellId}: ${spec.name} ${arming ? "armed" : "updated"} — ${parts.join(", ")}${polyNote}.`);
|
|
5777
|
+
}
|
|
5778
|
+
if (Object.keys(cellsDelta).length === 0)
|
|
5779
|
+
return text(lines.join("\n"));
|
|
5780
|
+
await applyKv({ ...flatDelta, cells: cellsDelta }, KV_OPTS_CELL_CONFIG);
|
|
5781
|
+
return text(`${lines.join("\n")}\n` +
|
|
5782
|
+
"Players sound only while the transport runs; get_design_overview reports each cell's player.");
|
|
5783
|
+
});
|
|
5784
|
+
/** A cell's sample as a readable file: absolute as given, else relative to a library root. */
|
|
5785
|
+
async function resolveCellSampleFile(filePath) {
|
|
5786
|
+
const expanded = expandHome(filePath);
|
|
5787
|
+
if (isAbsolute(expanded))
|
|
5788
|
+
return existsSync(expanded) ? expanded : undefined;
|
|
5789
|
+
const locations = await bridge.send("getDefaultLocations").catch(() => undefined);
|
|
5790
|
+
const roots = Array.isArray(locations)
|
|
5791
|
+
? locations.filter((entry) => isRecord(entry) && typeof entry.path === "string")
|
|
5792
|
+
: [];
|
|
5793
|
+
const normalized = filePath.replaceAll("\\", "/");
|
|
5794
|
+
const candidates = [];
|
|
5795
|
+
for (const root of roots) {
|
|
5796
|
+
const stripped = normalized.replace(/^Samples\/(User|Factory)\//, "");
|
|
5797
|
+
if (/user/i.test(root.name) && /^Samples\/User\//.test(normalized))
|
|
5798
|
+
candidates.push(join(root.path, stripped));
|
|
5799
|
+
if (/factory/i.test(root.name) && /^Samples\/Factory\//.test(normalized))
|
|
5800
|
+
candidates.push(join(root.path, stripped));
|
|
5801
|
+
candidates.push(join(root.path, normalized), join(root.path, stripped));
|
|
5802
|
+
}
|
|
5803
|
+
return candidates.find((candidate) => existsSync(candidate));
|
|
5804
|
+
}
|
|
5805
|
+
server.registerTool("set_slices", {
|
|
5806
|
+
title: "Set Sample Slices",
|
|
5807
|
+
description: "Slice the sample loaded in a cell: explicit `boundaries` (0..1 of the file), `count` equal slices, or " +
|
|
5808
|
+
"`transients` detected from the audio (WAV files, offline). Writes cell{N}_sliceBoundaries / _sliceCount — " +
|
|
5809
|
+
"what the slice lane, Slice Keymap, preview_slice and export_slice all read — and switches the Slice knob on. " +
|
|
5810
|
+
"`sequence:true` also chops the loop into the slice + note lanes so it plays back in order at native rate " +
|
|
5811
|
+
"(the Slice → Sequence gesture: modLatch on, both lanes at 1× over the loop's length in beats).",
|
|
5812
|
+
inputSchema: {
|
|
5813
|
+
cellId: z.number().int().min(1).max(8).describe("Cell number (1-8); it must have a sample loaded."),
|
|
5814
|
+
boundaries: z
|
|
5815
|
+
.array(z.number().min(0).max(1))
|
|
5816
|
+
.min(2)
|
|
5817
|
+
.max(MAX_SLICES + 1)
|
|
5818
|
+
.optional()
|
|
5819
|
+
.describe("Slice boundaries as 0..1 positions in the file, ascending; 0 and 1 are added when missing."),
|
|
5820
|
+
count: z.number().int().min(1).max(MAX_SLICES).optional().describe(`Equal slices, 1..${MAX_SLICES}.`),
|
|
5821
|
+
transients: z.boolean().optional().describe("Detect onsets in the sample and slice there (WAV only)."),
|
|
5822
|
+
sensitivity: z.number().min(0).max(1).optional().describe("Transient sensitivity 0..1 (default 0.5; higher finds more onsets)."),
|
|
5823
|
+
maxSlices: z.number().int().min(2).max(MAX_SLICES).optional().describe("Cap on transient slices (default 32)."),
|
|
5824
|
+
sequence: z.boolean().optional().describe("Also write the slice + note lanes so the slices play in order (loops)."),
|
|
5825
|
+
},
|
|
5826
|
+
}, async ({ cellId, boundaries, count, transients, sensitivity, maxSlices, sequence }) => {
|
|
5827
|
+
const check = await requireBridgeForCells(cellId);
|
|
5828
|
+
if (check !== true)
|
|
5829
|
+
return check;
|
|
5830
|
+
const manifest = await loadManifestForProductGuard();
|
|
5831
|
+
if (manifest && isMfxManifest(manifest))
|
|
5832
|
+
return error("set_slices needs a sampler cell; MFX cells carry no sample.");
|
|
5833
|
+
const modes = [boundaries !== undefined, count !== undefined, transients === true].filter(Boolean).length;
|
|
5834
|
+
if (modes !== 1)
|
|
5835
|
+
return error("Pass exactly one of boundaries, count, or transients:true.");
|
|
5836
|
+
const stateRaw = await bridge.send("getState");
|
|
5837
|
+
const state = isRecord(stateRaw) ? stateRaw : {};
|
|
5838
|
+
const cellsObj = isRecord(state.cells) ? state.cells : {};
|
|
5839
|
+
const cell = isRecord(cellsObj[String(cellId)]) ? cellsObj[String(cellId)] : {};
|
|
5840
|
+
const filePath = [cell.filePath, cell.samplePath, state[`cell${cellId}_filePath`]].find((v) => typeof v === "string" && v.length > 0);
|
|
5841
|
+
if (!filePath)
|
|
5842
|
+
return error(`Cell ${cellId} has no sample loaded — load_sample first.`);
|
|
5843
|
+
let resolved;
|
|
5844
|
+
let wavDuration;
|
|
5845
|
+
if (boundaries)
|
|
5846
|
+
resolved = normalizeBoundaries(boundaries);
|
|
5847
|
+
else if (count !== undefined)
|
|
5848
|
+
resolved = equalBoundaries(count);
|
|
5849
|
+
else {
|
|
5850
|
+
const file = await resolveCellSampleFile(filePath);
|
|
5851
|
+
if (!file)
|
|
5852
|
+
return error(`Could not find '${filePath}' on disk to analyse — pass count or boundaries instead.`);
|
|
5853
|
+
let wav;
|
|
5854
|
+
try {
|
|
5855
|
+
wav = decodeWav(await readFile(file));
|
|
5856
|
+
}
|
|
5857
|
+
catch (e) {
|
|
5858
|
+
return error(`Transient slicing reads WAV files only (${e.message}) — pass count or boundaries for ${basename(file)}.`);
|
|
5859
|
+
}
|
|
5860
|
+
wavDuration = wav.durationSeconds;
|
|
5861
|
+
resolved = detectOnsetBoundaries(wav, sensitivity ?? 0.5, maxSlices ?? 32);
|
|
5862
|
+
if (resolved.length < 3)
|
|
5863
|
+
return error("No transients found above the threshold — raise sensitivity or pass count.");
|
|
5864
|
+
}
|
|
5865
|
+
const sliceCount = resolved.length - 1;
|
|
5866
|
+
const currentKnob = Number(state[`cell${cellId}_slice`]) || 0;
|
|
5867
|
+
const delta = {
|
|
5868
|
+
[`cell${cellId}_sliceBoundaries`]: resolved,
|
|
5869
|
+
[`cell${cellId}_sliceCount`]: sliceCount,
|
|
5870
|
+
};
|
|
5871
|
+
if (currentKnob < 1 || currentKnob > sliceCount)
|
|
5872
|
+
delta[`cell${cellId}_slice`] = 1;
|
|
5873
|
+
await applyKv(delta, KV_OPTS_CELL_CONFIG);
|
|
5874
|
+
let sequenceNote = "";
|
|
5875
|
+
if (sequence) {
|
|
5876
|
+
const transportRaw = await bridge.send("getTransport").catch(() => undefined);
|
|
5877
|
+
const bpm = Number(isRecord(transportRaw) ? transportRaw.bpm : undefined) || Number(state.bpm) || 120;
|
|
5878
|
+
let duration = wavDuration;
|
|
5879
|
+
if (duration === undefined) {
|
|
5880
|
+
const info = await bridge.send("getAudioFileInfo", filePath).catch(() => undefined);
|
|
5881
|
+
const parsed = typeof info === "string" ? JSON.parse(info) : info;
|
|
5882
|
+
duration = [parsed?.duration, parsed?.durationSeconds, parsed?.lengthSeconds, parsed?.length]
|
|
5883
|
+
.map(Number)
|
|
5884
|
+
.find((v) => Number.isFinite(v) && v > 0);
|
|
5885
|
+
}
|
|
5886
|
+
const loopBeats = duration ? (duration * bpm) / 60 : Number(state[`cell${cellId}_patternLengthSlice`]) || 16;
|
|
5887
|
+
const patternLength = Math.max(1, Math.min(32, duration ? Math.ceil(loopBeats) : loopBeats));
|
|
5888
|
+
const note = typeof cell.midiNote === "number" ? cell.midiNote : 48 + cellId - 1;
|
|
5889
|
+
const channel = typeof cell.midiChannel === "number" ? cell.midiChannel : 1;
|
|
5890
|
+
const sliceEvents = [];
|
|
5891
|
+
const noteEvents = [];
|
|
5892
|
+
for (let i = 0; i < sliceCount; i++) {
|
|
5893
|
+
const time = resolved[i] * loopBeats;
|
|
5894
|
+
const dur = (resolved[i + 1] - resolved[i]) * loopBeats;
|
|
5895
|
+
if (dur <= 0)
|
|
5896
|
+
continue;
|
|
5897
|
+
sliceEvents.push({ note, channel, time, duration: dur, velocity: 100, value: i + 1 });
|
|
5898
|
+
noteEvents.push({ note, channel, time, duration: dur, velocity: 100 });
|
|
5899
|
+
}
|
|
5900
|
+
await applyKv({
|
|
5901
|
+
[`cell${cellId}_sliceSequence`]: sliceEvents,
|
|
5902
|
+
[`cell${cellId}_midiSequence`]: noteEvents,
|
|
5903
|
+
[`cell${cellId}_patternLengthSlice`]: patternLength,
|
|
5904
|
+
[`cell${cellId}_patternLength`]: patternLength,
|
|
5905
|
+
[`cell${cellId}_patternSpeedSlice`]: 9,
|
|
5906
|
+
[`cell${cellId}_patternSpeed`]: 9,
|
|
5907
|
+
[`cell${cellId}_patternDirection`]: 0,
|
|
5908
|
+
[`cell${cellId}_slice`]: 1,
|
|
5909
|
+
cells: { [cellId]: { modLatch: 1 } },
|
|
5910
|
+
__forceRender__: true,
|
|
5911
|
+
}, KV_OPTS_PATTERN);
|
|
5912
|
+
sequenceNote = ` Chopped into the slice + note lanes over ${patternLength} beats at ${bpm} BPM; start_transport to hear it.`;
|
|
5913
|
+
}
|
|
5914
|
+
const positions = resolved.slice(1, -1).map((b) => `${Math.round(b * 1000) / 10}%`);
|
|
5915
|
+
return text(`Cell ${cellId}: ${sliceCount} slice${sliceCount === 1 ? "" : "s"}` +
|
|
5916
|
+
`${positions.length ? ` (cuts at ${positions.join(", ")})` : ""}.${sequenceNote}`);
|
|
5917
|
+
});
|
|
4203
5918
|
server.registerTool("add_param_seq", {
|
|
4204
5919
|
title: "Add Parameter Sequencer",
|
|
4205
5920
|
description: "Add a parameter sequencer (step modulation) targeting any modulatable parameter. " +
|
|
@@ -4216,9 +5931,17 @@ server.registerTool("add_param_seq", {
|
|
|
4216
5931
|
bipolar: z.union([z.literal(0), z.literal(1)]).default(0).describe("0 = unipolar, 1 = bipolar."),
|
|
4217
5932
|
length: z.number().min(0.0625).max(16).default(1).describe("Pattern length in bars."),
|
|
4218
5933
|
speed: z.number().min(0.0625).max(16).default(1).describe("Pattern speed multiplier."),
|
|
5934
|
+
freeMode: z
|
|
5935
|
+
.union([z.literal(0), z.literal(1)])
|
|
5936
|
+
.default(0)
|
|
5937
|
+
.describe("0 = locked to the source cell's clock and its own steps; 1 = mapped to the Free Sequencer (its clock AND its master steps, set_free_sequencer)."),
|
|
5938
|
+
stepMode: z
|
|
5939
|
+
.union([z.literal(0), z.literal(1)])
|
|
5940
|
+
.default(0)
|
|
5941
|
+
.describe("0 = Clock; 1 = Gate: advance one step per trigger of the source cell (cell-locked only; MFX has no Gate)."),
|
|
4219
5942
|
},
|
|
4220
|
-
}, async ({ paramId, cellId, sequence, strength, bipolar, length, speed }) => {
|
|
4221
|
-
const check = await
|
|
5943
|
+
}, async ({ paramId, cellId, sequence, strength, bipolar, length, speed, freeMode, stepMode }) => {
|
|
5944
|
+
const check = await requireBridgeForCells(cellId);
|
|
4222
5945
|
if (check !== true)
|
|
4223
5946
|
return check;
|
|
4224
5947
|
const index = await getParameterIndex();
|
|
@@ -4240,7 +5963,8 @@ server.registerTool("add_param_seq", {
|
|
|
4240
5963
|
bipolar,
|
|
4241
5964
|
boost: 0,
|
|
4242
5965
|
interpolate: 0,
|
|
4243
|
-
freeMode
|
|
5966
|
+
freeMode,
|
|
5967
|
+
stepMode,
|
|
4244
5968
|
};
|
|
4245
5969
|
paramSeqs.push(newSeq);
|
|
4246
5970
|
await applyKv({ paramSeqs }, KV_OPTS_MOD);
|
|
@@ -4253,11 +5977,19 @@ server.registerTool("update_param_seq", {
|
|
|
4253
5977
|
inputSchema: {
|
|
4254
5978
|
paramId: z.string().describe("Target parameter ID."),
|
|
4255
5979
|
sequence: z.array(z.number().min(0).max(1)).optional().describe("New step values (0-1)."),
|
|
4256
|
-
strength: z.number().min(0).max(1).optional(),
|
|
4257
|
-
length: z.number().min(0.0625).max(16).optional(),
|
|
4258
|
-
speed: z.number().min(0.0625).max(16).optional(),
|
|
4259
|
-
bipolar: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4260
|
-
enabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
5980
|
+
strength: z.number().min(0).max(1).optional().describe("Modulation depth (0-1)."),
|
|
5981
|
+
length: z.number().min(0.0625).max(16).optional().describe("Pattern length in bars."),
|
|
5982
|
+
speed: z.number().min(0.0625).max(16).optional().describe("Pattern speed multiplier."),
|
|
5983
|
+
bipolar: z.union([z.literal(0), z.literal(1)]).optional().describe("0 = unipolar, 1 = bipolar."),
|
|
5984
|
+
enabled: z.union([z.literal(0), z.literal(1)]).optional().describe("0 = bypass the sequencer, 1 = run it."),
|
|
5985
|
+
freeMode: z
|
|
5986
|
+
.union([z.literal(0), z.literal(1)])
|
|
5987
|
+
.optional()
|
|
5988
|
+
.describe("0 = cell-locked with its own steps; 1 = mapped to the Free Sequencer master contour (set_free_sequencer)."),
|
|
5989
|
+
stepMode: z
|
|
5990
|
+
.union([z.literal(0), z.literal(1)])
|
|
5991
|
+
.optional()
|
|
5992
|
+
.describe("0 = Clock; 1 = Gate (one step per source-cell trigger; falls back to Clock when free or on MFX)."),
|
|
4261
5993
|
},
|
|
4262
5994
|
}, async ({ paramId, ...updates }) => {
|
|
4263
5995
|
const check = await requireBridge();
|
|
@@ -4281,6 +6013,105 @@ server.registerTool("update_param_seq", {
|
|
|
4281
6013
|
await applyKv({ paramSeqs }, KV_OPTS_MOD);
|
|
4282
6014
|
return text(`Updated paramSeq ${paramId}: ${Object.keys(cleanUpdates).join(", ")}`);
|
|
4283
6015
|
});
|
|
6016
|
+
server.registerTool("set_free_sequencer", {
|
|
6017
|
+
title: "Set Free Sequencer",
|
|
6018
|
+
description: "Write the Free Sequencer's master step contour (top-level `freeSeqSequence`, 2..64 steps of 0..1). Every " +
|
|
6019
|
+
"parameter sequencer mapped to it (freeMode:1 via add_param_seq / update_param_seq) follows these steps AND its " +
|
|
6020
|
+
"clock live, each with its own strength, polarity and smoothing; cell-locked and Gate-mode sequencers keep " +
|
|
6021
|
+
"their own steps. An empty contour restores the legacy per-sequencer behaviour.",
|
|
6022
|
+
inputSchema: {
|
|
6023
|
+
sequence: z.array(z.number().min(0).max(1)).min(2).max(64).describe("Master step values, 0..1 each."),
|
|
6024
|
+
},
|
|
6025
|
+
}, async ({ sequence }) => {
|
|
6026
|
+
const check = await requireBridge();
|
|
6027
|
+
if (check !== true)
|
|
6028
|
+
return check;
|
|
6029
|
+
await bridge.send("applyFreeSeqSequenceUpdate", { sequence });
|
|
6030
|
+
return text(`Free Sequencer: ${sequence.length} steps written. Map a paramSeq with freeMode:1 to follow it.`);
|
|
6031
|
+
});
|
|
6032
|
+
server.registerTool("export_midi", {
|
|
6033
|
+
title: "Export MIDI",
|
|
6034
|
+
description: "Write the cells' drawn note patterns to a Standard MIDI File (type 1, 480 PPQ, tempo from the transport): " +
|
|
6035
|
+
"one track per cell on MIDI channel = cell number, each pattern tiled across `bars`. Pure file write — no " +
|
|
6036
|
+
"render, nothing changes in PAM. Generated Player cycles are not drawn patterns; FREEZE them first (in the UI) " +
|
|
6037
|
+
"to export what a player is playing. Default location is PAM's Exports folder.",
|
|
6038
|
+
inputSchema: {
|
|
6039
|
+
filename: z.string().optional().describe("Base name without extension (default pam_midi_<epoch>)."),
|
|
6040
|
+
cellIds: z.array(z.number().int().min(1).max(8)).optional().describe("Cells to include (default: every cell with notes)."),
|
|
6041
|
+
bars: z.number().min(1).max(64).optional().describe("Length in 4/4 bars (default: the longest included pattern cycle, at least 1)."),
|
|
6042
|
+
mode: z.enum(["combined", "stems"]).optional().describe("'combined' (default): one multi-track file. 'stems': one file per cell."),
|
|
6043
|
+
outputDir: z.string().optional().describe("Directory to write into (default: the Exports location from get_default_locations)."),
|
|
6044
|
+
},
|
|
6045
|
+
}, async ({ filename, cellIds, bars, mode, outputDir }) => {
|
|
6046
|
+
const check = await requireBridgeForCells(cellIds);
|
|
6047
|
+
if (check !== true)
|
|
6048
|
+
return check;
|
|
6049
|
+
const manifest = await loadManifestForProductGuard();
|
|
6050
|
+
if (manifest && isMfxManifest(manifest))
|
|
6051
|
+
return error("export_midi needs sampler cells with note patterns; MFX has none.");
|
|
6052
|
+
const [stateRaw, transportRaw, locations] = await Promise.all([
|
|
6053
|
+
bridge.send("getState"),
|
|
6054
|
+
bridge.send("getTransport").catch(() => undefined),
|
|
6055
|
+
bridge.send("getDefaultLocations").catch(() => undefined),
|
|
6056
|
+
]);
|
|
6057
|
+
const state = isRecord(stateRaw) ? stateRaw : {};
|
|
6058
|
+
const bpm = Number(isRecord(transportRaw) ? transportRaw.bpm : undefined) || Number(state.bpm) || 120;
|
|
6059
|
+
let dir = outputDir ? expandHome(outputDir) : undefined;
|
|
6060
|
+
if (!dir) {
|
|
6061
|
+
if (Array.isArray(locations)) {
|
|
6062
|
+
const exportsEntry = locations.find((entry) => isRecord(entry) && /export/i.test(String(entry.name)));
|
|
6063
|
+
if (isRecord(exportsEntry) && typeof exportsEntry.path === "string")
|
|
6064
|
+
dir = exportsEntry.path;
|
|
6065
|
+
}
|
|
6066
|
+
else if (isRecord(locations)) {
|
|
6067
|
+
dir = [locations.exports, locations.exportsLocation, locations.Exports].find((v) => typeof v === "string" && v.length > 0);
|
|
6068
|
+
}
|
|
6069
|
+
}
|
|
6070
|
+
if (!dir)
|
|
6071
|
+
return error("Could not resolve PAM's Exports folder from get_default_locations — pass outputDir.");
|
|
6072
|
+
const maxCell = manifestMaxCellId(manifest) ?? 8;
|
|
6073
|
+
const ids = (cellIds?.length ? cellIds : Array.from({ length: maxCell }, (_, i) => i + 1)).filter((id) => id <= maxCell);
|
|
6074
|
+
const cells = ids
|
|
6075
|
+
.map((id) => ({
|
|
6076
|
+
id,
|
|
6077
|
+
sequence: state[`cell${id}_midiSequence`],
|
|
6078
|
+
patternLength: state[`cell${id}_patternLength`],
|
|
6079
|
+
speed: state[`cell${id}_patternSpeed`],
|
|
6080
|
+
}))
|
|
6081
|
+
.filter((cell) => Array.isArray(cell.sequence) && cell.sequence.length > 0);
|
|
6082
|
+
if (cells.length === 0)
|
|
6083
|
+
return error("No note patterns to export — the selected cells carry no notes.");
|
|
6084
|
+
const cycleBars = cells.map((cell) => {
|
|
6085
|
+
const length = Number(cell.patternLength);
|
|
6086
|
+
const speedIndex = Math.round(Number(cell.speed));
|
|
6087
|
+
const mul = Number.isFinite(speedIndex) ? patternSpeedMultiplier(speedIndex) : 1;
|
|
6088
|
+
return (Number.isFinite(length) && length > 0 ? length : 4) / mul / 4;
|
|
6089
|
+
});
|
|
6090
|
+
const totalBars = bars ?? Math.max(1, Math.ceil(Math.max(...cycleBars)));
|
|
6091
|
+
const totalBeats = totalBars * 4;
|
|
6092
|
+
const tracks = cells.map((cell) => ({
|
|
6093
|
+
name: `Cell ${cell.id}`,
|
|
6094
|
+
events: tilePatternEvents(cell.sequence, cell.patternLength, cell.speed, totalBeats, Math.min(16, cell.id)),
|
|
6095
|
+
}));
|
|
6096
|
+
const base = (filename?.trim() || `pam_midi_${Date.now()}`).replace(/\.mid$/i, "").replace(/[\\/:]/g, "_");
|
|
6097
|
+
await mkdir(dir, { recursive: true });
|
|
6098
|
+
const written = [];
|
|
6099
|
+
if (mode === "stems") {
|
|
6100
|
+
for (const track of tracks) {
|
|
6101
|
+
const path = join(dir, `${base}_cell${track.name.slice(5)}.mid`);
|
|
6102
|
+
await writeFile(path, buildMidiFile(bpm, [track], track.name));
|
|
6103
|
+
written.push(path);
|
|
6104
|
+
}
|
|
6105
|
+
}
|
|
6106
|
+
else {
|
|
6107
|
+
const path = join(dir, `${base}.mid`);
|
|
6108
|
+
await writeFile(path, buildMidiFile(bpm, tracks, base));
|
|
6109
|
+
written.push(path);
|
|
6110
|
+
}
|
|
6111
|
+
const noteCount = tracks.reduce((sum, track) => sum + track.events.length, 0);
|
|
6112
|
+
return text(`Exported ${noteCount} notes from ${tracks.length} cell${tracks.length === 1 ? "" : "s"} over ${totalBars} bar${totalBars === 1 ? "" : "s"} at ${bpm} BPM.\n` +
|
|
6113
|
+
written.map((path) => `File: ${path}`).join("\n"));
|
|
6114
|
+
});
|
|
4284
6115
|
server.registerTool("remove_param_seq", {
|
|
4285
6116
|
title: "Remove Parameter Sequencer",
|
|
4286
6117
|
description: "Remove a parameter sequencer for the given paramId.",
|
|
@@ -4304,7 +6135,7 @@ server.registerTool("normalize_sample", {
|
|
|
4304
6135
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
4305
6136
|
},
|
|
4306
6137
|
}, async ({ cellId }) => {
|
|
4307
|
-
const check = await
|
|
6138
|
+
const check = await requireBridgeForCells(cellId);
|
|
4308
6139
|
if (check !== true)
|
|
4309
6140
|
return check;
|
|
4310
6141
|
await bridge.send("normalizeSampleForCell", cellId);
|
|
@@ -4323,7 +6154,7 @@ server.registerTool("gain_sample", {
|
|
|
4323
6154
|
.describe("Gain in decibels (e.g. 3 = +3 dB louder, -6 = 6 dB quieter)."),
|
|
4324
6155
|
},
|
|
4325
6156
|
}, async ({ cellId, gainDb }) => {
|
|
4326
|
-
const check = await
|
|
6157
|
+
const check = await requireBridgeForCells(cellId);
|
|
4327
6158
|
if (check !== true)
|
|
4328
6159
|
return check;
|
|
4329
6160
|
await bridge.send("gainSampleForCell", { cellId, gainDb });
|
|
@@ -4369,7 +6200,9 @@ server.registerTool("export_audio", {
|
|
|
4369
6200
|
"'per-bus' (per output bus) · 'song-mode' (the Song Mode timeline).\n\n" +
|
|
4370
6201
|
"NOTE on 'song-mode': the offline bounce applies EVERY section on its anchor (PAM ≥ 1.5.7 with " +
|
|
4371
6202
|
"offline song export; older builds render only the first section). Wall-clock sources " +
|
|
4372
|
-
"(Magenta/Notochord) render silent offline — use the `export_song` tool's live strategy for those."
|
|
6203
|
+
"(Magenta/Notochord) render silent offline — use the `export_song` tool's live strategy for those. " +
|
|
6204
|
+
"It issues the same render as `export_song` with strategy 'offline'; prefer `export_song` for arrangements, " +
|
|
6205
|
+
"which is the only route to the live and audio+video strategies.",
|
|
4373
6206
|
inputSchema: {
|
|
4374
6207
|
bars: z.number().min(1).max(128).default(4).describe("Bars to render (ignored for 'song-mode', which derives length from the timeline)."),
|
|
4375
6208
|
cellId: z
|
|
@@ -4387,12 +6220,12 @@ server.registerTool("export_audio", {
|
|
|
4387
6220
|
.optional()
|
|
4388
6221
|
.describe("For mode 'selected-cells': cells to bounce as stems."),
|
|
4389
6222
|
bitDepth: z
|
|
4390
|
-
.
|
|
6223
|
+
.union([z.literal(16), z.literal(24)])
|
|
4391
6224
|
.default(24)
|
|
4392
|
-
.describe("Bit depth
|
|
6225
|
+
.describe("Bit depth: 16 or 24."),
|
|
4393
6226
|
},
|
|
4394
6227
|
}, async ({ bars, cellId, mode, selectedCellIds, bitDepth }) => {
|
|
4395
|
-
const check = await
|
|
6228
|
+
const check = await requireBridgeForCells(cellId, selectedCellIds);
|
|
4396
6229
|
if (check !== true)
|
|
4397
6230
|
return check;
|
|
4398
6231
|
// Resolve the effective mode. A bare cellId means "individual".
|
|
@@ -4430,7 +6263,7 @@ server.registerTool("export_audio", {
|
|
|
4430
6263
|
const bpm = typeof transport.bpm === "number" && transport.bpm > 0 ? transport.bpm : 120;
|
|
4431
6264
|
// 4/4 assumption for the placeholder duration; 'song-mode' recomputes its own length from the timeline.
|
|
4432
6265
|
const durationInSeconds = (bars * 4 * 60) / bpm;
|
|
4433
|
-
const filename =
|
|
6266
|
+
const filename = `${SERVER_PRODUCT.id}_export_${effectiveMode.replace(/[^a-z0-9]/gi, "_")}_${Date.now()}`;
|
|
4434
6267
|
const options = {
|
|
4435
6268
|
filename,
|
|
4436
6269
|
mode: effectiveMode,
|
|
@@ -4445,12 +6278,8 @@ server.registerTool("export_audio", {
|
|
|
4445
6278
|
const outcome = await pollExportStatus();
|
|
4446
6279
|
if (outcome === "success")
|
|
4447
6280
|
return text(`Export complete (mode '${effectiveMode}').\n` +
|
|
4448
|
-
`File(s) written to
|
|
4449
|
-
loudnessReportLine()
|
|
4450
|
-
(effectiveMode === "song-mode"
|
|
4451
|
-
? "\n\nReminder: the offline song-mode bounce captures only the first section's state. " +
|
|
4452
|
-
"Use export_song for a full track with section changes."
|
|
4453
|
-
: ""));
|
|
6281
|
+
`File(s) written to ${SERVER_PRODUCT.name}'s Exports folder, base name: ${filename}.wav` +
|
|
6282
|
+
loudnessReportLine());
|
|
4454
6283
|
if (outcome === "failed")
|
|
4455
6284
|
return error(`Export failed (mode '${effectiveMode}').`);
|
|
4456
6285
|
return text(`Export timed out (mode '${effectiveMode}'). The render may still be running.`);
|
|
@@ -4458,8 +6287,8 @@ server.registerTool("export_audio", {
|
|
|
4458
6287
|
// ============================================================
|
|
4459
6288
|
// RESOURCES
|
|
4460
6289
|
// ============================================================
|
|
4461
|
-
server.registerResource("parameter-manifest",
|
|
4462
|
-
title:
|
|
6290
|
+
server.registerResource("parameter-manifest", `${SERVER_PRODUCT.resourceScheme}://parameters`, {
|
|
6291
|
+
title: `${SERVER_PRODUCT.name} Parameter Manifest`,
|
|
4463
6292
|
description: "Complete parameter definitions with ranges, defaults, and metadata.",
|
|
4464
6293
|
mimeType: "application/json",
|
|
4465
6294
|
}, async (uri) => {
|
|
@@ -4473,8 +6302,8 @@ server.registerResource("parameter-manifest", "pam://parameters", {
|
|
|
4473
6302
|
],
|
|
4474
6303
|
};
|
|
4475
6304
|
});
|
|
4476
|
-
server.registerResource("parameter-summary",
|
|
4477
|
-
title:
|
|
6305
|
+
server.registerResource("parameter-summary", `${SERVER_PRODUCT.resourceScheme}://parameters/summary`, {
|
|
6306
|
+
title: `${SERVER_PRODUCT.name} Parameter Summary`,
|
|
4478
6307
|
description: "Human-readable parameter list with ranges and descriptions.",
|
|
4479
6308
|
mimeType: "text/plain",
|
|
4480
6309
|
}, async (uri) => {
|
|
@@ -4484,10 +6313,10 @@ server.registerResource("parameter-summary", "pam://parameters/summary", {
|
|
|
4484
6313
|
contents: [{ uri: uri.href, text: lines.join("\n") }],
|
|
4485
6314
|
};
|
|
4486
6315
|
});
|
|
4487
|
-
server.registerResource("cell-parameters", new ResourceTemplate(
|
|
6316
|
+
server.registerResource("cell-parameters", new ResourceTemplate(`${SERVER_PRODUCT.resourceScheme}://cell/{cellId}/parameters`, {
|
|
4488
6317
|
list: async () => ({
|
|
4489
|
-
resources: Array.from({ length:
|
|
4490
|
-
uri:
|
|
6318
|
+
resources: Array.from({ length: SERVER_PRODUCT.cellCount }, (_, i) => ({
|
|
6319
|
+
uri: `${SERVER_PRODUCT.resourceScheme}://cell/cell${i + 1}/parameters`,
|
|
4491
6320
|
name: `Cell ${i + 1} Parameters`,
|
|
4492
6321
|
})),
|
|
4493
6322
|
}),
|
|
@@ -4502,7 +6331,7 @@ server.registerResource("cell-parameters", new ResourceTemplate("pam://cell/{cel
|
|
|
4502
6331
|
contents: [{ uri: uri.href, text: JSON.stringify(params, null, 2) }],
|
|
4503
6332
|
};
|
|
4504
6333
|
});
|
|
4505
|
-
server.registerResource("global-parameters",
|
|
6334
|
+
server.registerResource("global-parameters", `${SERVER_PRODUCT.resourceScheme}://parameters/global`, {
|
|
4506
6335
|
title: "Global Parameters",
|
|
4507
6336
|
description: "Non-cell-specific parameters (volume, crossfade, looper, etc.).",
|
|
4508
6337
|
mimeType: "application/json",
|
|
@@ -4512,16 +6341,38 @@ server.registerResource("global-parameters", "pam://parameters/global", {
|
|
|
4512
6341
|
contents: [{ uri: uri.href, text: JSON.stringify(params, null, 2) }],
|
|
4513
6342
|
};
|
|
4514
6343
|
});
|
|
4515
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
"
|
|
4519
|
-
|
|
4520
|
-
}, async () => ({
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
6344
|
+
if (IS_MFX_SERVER) {
|
|
6345
|
+
server.registerResource("mfx-routing-guide", "mfx://guide/routing", {
|
|
6346
|
+
title: "MFX Routing Guide",
|
|
6347
|
+
description: "A concise workflow for cabling, ordering, shaping, and modulating an MFX patch.",
|
|
6348
|
+
mimeType: "text/markdown",
|
|
6349
|
+
}, async () => ({
|
|
6350
|
+
contents: [{
|
|
6351
|
+
uri: "mfx://guide/routing",
|
|
6352
|
+
text: `# MFX Routing Guide
|
|
6353
|
+
|
|
6354
|
+
MFX has four effects cells. Audio reaches a cell only when the cable patch routes a source into it.
|
|
6355
|
+
|
|
6356
|
+
1. Call \`set_mfx_routing\` with \`op: "get"\` before changing the patch.
|
|
6357
|
+
2. Use \`design_context\` to inspect the available effects, parameters, and default effect order.
|
|
6358
|
+
3. Replace the complete cable list with \`set_mfx_routing\` and \`op: "set"\`. A serial patch is input → cell1 → cell2 → cell3 → cell4 → output.
|
|
6359
|
+
4. Use \`set_effect_order\`, \`update_effect_config\`, and \`set_parameters\` to shape each cell.
|
|
6360
|
+
5. Add movement with the modulation tools, audition the result, and save it with \`save_preset\`.
|
|
6361
|
+
`,
|
|
6362
|
+
}],
|
|
6363
|
+
}));
|
|
6364
|
+
}
|
|
6365
|
+
else {
|
|
6366
|
+
server.registerResource("beat-making-guide", "pam://guide/beat-making", {
|
|
6367
|
+
title: "PAM Beat-Making Guide",
|
|
6368
|
+
description: "Comprehensive guide for creating beats with PAM. Covers cell roles, slicer for loops, " +
|
|
6369
|
+
"sidechain pumping, poly/arp patterns, macros, variations, gain staging, and master effects.",
|
|
6370
|
+
mimeType: "text/markdown",
|
|
6371
|
+
}, async () => ({
|
|
6372
|
+
contents: [
|
|
6373
|
+
{
|
|
6374
|
+
uri: "pam://guide/beat-making",
|
|
6375
|
+
text: `# PAM Beat-Making Guide
|
|
4525
6376
|
|
|
4526
6377
|
## Cell Architecture — 8 Cells, Each a Full Instrument
|
|
4527
6378
|
|
|
@@ -4861,9 +6712,10 @@ 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,
|
|
|
4861
6712
|
### Pattern Direction
|
|
4862
6713
|
\`cell{N}_patternDirection\`: 0=Forward, 1=Backward, 2=Ping-Pong, 3=Random Cycle (shuffle each loop), 4=Random Step (each step independent)
|
|
4863
6714
|
`,
|
|
4864
|
-
|
|
4865
|
-
|
|
4866
|
-
}));
|
|
6715
|
+
},
|
|
6716
|
+
],
|
|
6717
|
+
}));
|
|
6718
|
+
}
|
|
4867
6719
|
// ============================================================
|
|
4868
6720
|
// PROMPTS — guided workflows (client-invokable, e.g. slash
|
|
4869
6721
|
// commands in Claude Code). Each stages the same flows the
|
|
@@ -4872,7 +6724,7 @@ 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,
|
|
|
4872
6724
|
// Mirror of RANDOMIZER_GENRES (shared/randomizer/types.ts) — the published
|
|
4873
6725
|
// package can't import repo-relative shared modules, so the copy is pinned by
|
|
4874
6726
|
// interface/test/randomizerGenre.test.ts (source-text parity check).
|
|
4875
|
-
const GENRE_SUGGESTIONS = ["boom_bap", "trap", "drill", "house", "techno", "dnb"];
|
|
6727
|
+
const GENRE_SUGGESTIONS = ["boom_bap", "trap", "drill", "house", "techno", "dnb", "idm"];
|
|
4876
6728
|
const completeGenre = (value) => GENRE_SUGGESTIONS.filter((g) => g.startsWith((value ?? "").toLowerCase()));
|
|
4877
6729
|
// completable() tags the schema OBJECT with a symbol, so ordering is delicate:
|
|
4878
6730
|
// .describe()/.optional() clone schemas and drop the tag, SDK 1.29 unwraps
|
|
@@ -4880,7 +6732,7 @@ const completeGenre = (value) => GENRE_SUGGESTIONS.filter((g) => g.startsWith((v
|
|
|
4880
6732
|
// completer at request time — the tag must sit on both the inner type and the
|
|
4881
6733
|
// outer optional. Covered by the completion assertion in check-tool-surface's
|
|
4882
6734
|
// sibling smoke; revisit when the SDK unifies the two lookups.
|
|
4883
|
-
const genreArg = completable(completable(z.string().describe("Genre hint, e.g. techno, house, dnb, trap, boom_bap, drill."), completeGenre).optional(), completeGenre);
|
|
6735
|
+
const genreArg = completable(completable(z.string().describe("Genre hint, e.g. techno, house, dnb, trap, boom_bap, drill, idm."), completeGenre).optional(), completeGenre);
|
|
4884
6736
|
function promptText(text) {
|
|
4885
6737
|
return {
|
|
4886
6738
|
messages: [
|
|
@@ -4888,65 +6740,93 @@ function promptText(text) {
|
|
|
4888
6740
|
],
|
|
4889
6741
|
};
|
|
4890
6742
|
}
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
4898
|
-
}, ({
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
|
|
4902
|
-
|
|
4903
|
-
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
4922
|
-
},
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
|
|
4930
|
-
server.registerPrompt("
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
4944
|
-
|
|
4945
|
-
|
|
4946
|
-
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
|
|
6743
|
+
if (IS_MFX_SERVER) {
|
|
6744
|
+
server.registerPrompt("design-a-preset", {
|
|
6745
|
+
title: "Design an MFX Preset",
|
|
6746
|
+
description: "Turn a creative effects brief into a routed, modulated, saved MFX preset.",
|
|
6747
|
+
argsSchema: {
|
|
6748
|
+
brief: z.string().describe("Creative brief, e.g. 'wide rhythmic distortion with a filtered parallel tail'."),
|
|
6749
|
+
},
|
|
6750
|
+
}, ({ brief }) => promptText(`Design an MFX preset from this brief: "${brief}"\n\n` +
|
|
6751
|
+
`1. Call connection_status, design_context, and get_design_overview.\n` +
|
|
6752
|
+
`2. Read the current cable patch with set_mfx_routing op 'get', then replace the full patch only if the routing needs to change.\n` +
|
|
6753
|
+
`3. Shape cells 1-4 with set_effect_order, update_effect_config, and set_parameters.\n` +
|
|
6754
|
+
`4. Add movement with add_modulation or add_param_seq, then audition and refine.\n` +
|
|
6755
|
+
`5. Save the result with save_preset and useful metadata.`));
|
|
6756
|
+
server.registerPrompt("troubleshoot-bridge", {
|
|
6757
|
+
title: "Troubleshoot the MFX Bridge",
|
|
6758
|
+
description: "Diagnose why live tools cannot reach MFX.",
|
|
6759
|
+
argsSchema: {},
|
|
6760
|
+
}, () => promptText(`Diagnose the MFX MCP connection:\n\n` +
|
|
6761
|
+
`1. Run connection_status. If no instance appears, start MFX and enable Settings → Experimental → MCP Bridge.\n` +
|
|
6762
|
+
`2. MFX starts at the configured port and automatically takes the next available port when needed; do not assign a second fixed product port.\n` +
|
|
6763
|
+
`3. If an instance appears but cannot connect, it may have closed or be busy with a render; retry once.\n` +
|
|
6764
|
+
`4. If a live tool is unavailable, update the MFX plugin as well as the mfx-mcp package.`));
|
|
6765
|
+
}
|
|
6766
|
+
if (!IS_MFX_SERVER)
|
|
6767
|
+
server.registerPrompt("build-a-beat", {
|
|
6768
|
+
title: "Build a Beat",
|
|
6769
|
+
description: "Guided kit-building + pattern workflow: coherent samples, genre patterns, transport, refinement, save.",
|
|
6770
|
+
argsSchema: {
|
|
6771
|
+
genre: genreArg,
|
|
6772
|
+
bpm: z.string().optional().describe("Target tempo in BPM."),
|
|
6773
|
+
},
|
|
6774
|
+
}, ({ genre, bpm }) => promptText(`Build a beat in PAM${genre ? ` in the style of ${genre}` : ""}${bpm ? ` at ${bpm} BPM` : ""}. Work in stages, listening between them:\n\n` +
|
|
6775
|
+
`1. connection_status to confirm the bridge, then get_design_overview for the current state.\n` +
|
|
6776
|
+
`2. ${bpm ? `set_bpm to ${bpm}` : "set_bpm to the genre's tempo"}.\n` +
|
|
6777
|
+
`3. Build the kit and groove with randomize target:'full'${genre ? ` and genreHint '${genre}'` : " (pass a genreHint)"}; native chains samples before patterns. The reply is queued: poll get_state for changed cells and cellN_midiSequence before editing/saving. For specific sounds: get_default_locations → list_samples → load_sample, verify paths, then randomize_patterns and verify sequences.\n` +
|
|
6778
|
+
`4. start_transport — sequencer output needs the transport rolling.\n` +
|
|
6779
|
+
`5. Refine: update_pattern for individual lanes, set_parameters for levels/filters (ranges via design_context), update_effect_config to switch FX on, add_modulation for movement. For a groove that evolves, set_player (player 'role' across the kit, or KIT / EUCLID / RIFF per cell — list_players first${genre ? `, genre '${genre}'` : ""}).\n` +
|
|
6780
|
+
`6. save_preset once it works, and update_preset_metadata with genre tags and BPM.`));
|
|
6781
|
+
if (!IS_MFX_SERVER)
|
|
6782
|
+
server.registerPrompt("make-a-song", {
|
|
6783
|
+
title: "Make a Song",
|
|
6784
|
+
description: "Arrange the current preset into sections with variations + Song Mode, then bounce a full track.",
|
|
6785
|
+
argsSchema: {
|
|
6786
|
+
bars: z.string().optional().describe("Approximate song length in bars."),
|
|
6787
|
+
},
|
|
6788
|
+
}, ({ bars }) => promptText(`Turn the current PAM preset into an arranged song${bars ? ` of roughly ${bars} bars` : ""}:\n\n` +
|
|
6789
|
+
`1. Design 2-4 contrasting sections (intro / groove / peak / outro): shape the state for each, then save_variation into slots 0-7.\n` +
|
|
6790
|
+
`2. Arrange with song_mode op 'setArrangement' — an ordered sections list ({variationIndex, bars, repeat}, minimum 2 sections). Read it back with op 'get'.\n` +
|
|
6791
|
+
`3. Audition: start_transport with Song Mode driving the timeline; queue_variation switches sections live.\n` +
|
|
6792
|
+
`4. Bounce with export_song — strategy 'live' captures the ground-truth master mix (required for Magenta/Notochord/automation), strategy 'offline' is the deterministic multi-section bounce with no bar cap. includeVideo:true films the VX scene (window must be open).\n` +
|
|
6793
|
+
`5. The rendered file lands in PAM's Exports/Resamples locations — get_default_locations resolves them.`));
|
|
6794
|
+
if (!IS_MFX_SERVER)
|
|
6795
|
+
server.registerPrompt("design-a-preset", {
|
|
6796
|
+
title: "Design a Preset",
|
|
6797
|
+
description: "Sound-design workflow from a creative brief to a saved preset.",
|
|
6798
|
+
argsSchema: {
|
|
6799
|
+
brief: z.string().describe("Creative brief, e.g. 'dusty lo-fi kit with a wobbly sub'."),
|
|
6800
|
+
},
|
|
6801
|
+
}, ({ brief }) => promptText(`Design a PAM preset from this brief: "${brief}"\n\n` +
|
|
6802
|
+
`1. Read design_context (curated parameter cheat sheet) and get_design_overview first — avoid full get_state dumps.\n` +
|
|
6803
|
+
`2. Source sounds: randomize_samples with a genre hint, or get_default_locations → list_samples → load_sample. generate_sample creates bespoke hits (AI engines need update_settings experimental flags + a downloaded model).\n` +
|
|
6804
|
+
`3. Shape each cell: set_parameters (design_context teaches ranges and units), update_effect_config for FX chains, set_drum_synth for synthesized drum layers.\n` +
|
|
6805
|
+
`4. Add movement: add_modulation (LFO / envelope / vary), add_param_seq for stepped motion.\n` +
|
|
6806
|
+
`5. Write patterns with update_pattern, then start_transport to audition.\n` +
|
|
6807
|
+
`6. save_preset with a fitting name and update_preset_metadata (category, tags, BPM).`));
|
|
6808
|
+
if (!IS_MFX_SERVER)
|
|
6809
|
+
server.registerPrompt("mix-and-master", {
|
|
6810
|
+
title: "Mix and Master",
|
|
6811
|
+
description: "Balance the cells and shape the master bus using the analysis + suggestion tools.",
|
|
6812
|
+
argsSchema: {},
|
|
6813
|
+
}, () => promptText(`Balance and master the current PAM project:\n\n` +
|
|
6814
|
+
`1. get_mix_analysis with refresh:true (offline per-cell stem measurements — takes a few seconds) and get_master_analysis (live loudness snapshot).\n` +
|
|
6815
|
+
`2. suggest_mix for rule-based balance / pan / EQ / dynamics moves; apply the returned parameter moves with set_parameters and enable keys with update_effect_config.\n` +
|
|
6816
|
+
`3. Shape the master bus with set_master_effects (compressor, tape, limiter — design_context lists the keys).\n` +
|
|
6817
|
+
`4. Re-check get_master_analysis and iterate toward the loudness target.\n` +
|
|
6818
|
+
`5. Render with export_audio (mode 'main'; 'all-cells' for stems).`));
|
|
6819
|
+
if (!IS_MFX_SERVER)
|
|
6820
|
+
server.registerPrompt("troubleshoot-bridge", {
|
|
6821
|
+
title: "Troubleshoot the PAM Bridge",
|
|
6822
|
+
description: "Diagnose why live tools can't reach the plugin.",
|
|
6823
|
+
argsSchema: {},
|
|
6824
|
+
}, () => promptText(`Diagnose the PAM MCP connection:\n\n` +
|
|
6825
|
+
`1. connection_status. No instances → PAM isn't running or the bridge is off (PAM Settings → Experimental Mode → MCP Bridge, default port 9847). Multiple instances → select_instance picks which product the live tools drive.\n` +
|
|
6826
|
+
`2. Instances found but not connected → the plugin may have shut down, or its message thread is busy (heavy render); retry once.\n` +
|
|
6827
|
+
`3. Timeouts on WebView-routed tools (video tools, capture_ui, set_drum_synth_wave, randomize target 'synth') → the plugin window must be OPEN.\n` +
|
|
6828
|
+
`4. "PAM build does not support 'X'" errors → the plugin is older than this server; the user must update PAM (updating the npm package alone is not enough).\n` +
|
|
6829
|
+
`5. Offline tools always work without a plugin: get_parameter_info, design_context, list_presets, read_preset, get_manifest_summary.`));
|
|
4950
6830
|
// ============================================================
|
|
4951
6831
|
// TOOLS — Looper (state deltas)
|
|
4952
6832
|
// ============================================================
|
|
@@ -4969,13 +6849,13 @@ async function applyLoopersDelta(loopers, extra) {
|
|
|
4969
6849
|
server.registerTool("arm_looper_track", {
|
|
4970
6850
|
title: "Arm / Disarm Looper Track",
|
|
4971
6851
|
description: "Arm or disarm a looper track for recording. Arming alone does not start " +
|
|
4972
|
-
"recording — call start_looper_recording afterwards
|
|
6852
|
+
"recording — call start_looper_recording afterwards, or pass armedCellIds to start_looper_recording to arm and record in one call.",
|
|
4973
6853
|
inputSchema: {
|
|
4974
6854
|
cellId: z.number().min(1).max(8).describe("Looper track / cell (1-8)."),
|
|
4975
6855
|
armed: z.boolean().describe("true to arm, false to disarm."),
|
|
4976
6856
|
},
|
|
4977
6857
|
}, async ({ cellId, armed }) => {
|
|
4978
|
-
const check = await
|
|
6858
|
+
const check = await requireBridgeForCells(cellId);
|
|
4979
6859
|
if (check !== true)
|
|
4980
6860
|
return check;
|
|
4981
6861
|
await applyLoopersDelta({ [cellId]: { armed: armed ? 1 : 0 } });
|
|
@@ -4983,8 +6863,8 @@ server.registerTool("arm_looper_track", {
|
|
|
4983
6863
|
});
|
|
4984
6864
|
server.registerTool("set_looper_track", {
|
|
4985
6865
|
title: "Set Looper Track State",
|
|
4986
|
-
description: "Update one or more per-track looper fields (mute, overdub mode, reverse, " +
|
|
4987
|
-
"pitch, warp, capture source, level). Pass only the fields you want to change.",
|
|
6866
|
+
description: "Update one or more per-track looper fields (mute, solo, overdub mode, reverse, " +
|
|
6867
|
+
"pitch, warp, capture source, level, auto-select and recorded BPM). Pass only the fields you want to change.",
|
|
4988
6868
|
inputSchema: {
|
|
4989
6869
|
cellId: z.number().min(1).max(8).describe("Looper track / cell (1-8)."),
|
|
4990
6870
|
mute: z.boolean().optional().describe("Mute / unmute."),
|
|
@@ -5075,7 +6955,7 @@ server.registerTool("clear_looper_track", {
|
|
|
5075
6955
|
cellId: z.number().min(1).max(8).describe("Looper track / cell (1-8)."),
|
|
5076
6956
|
},
|
|
5077
6957
|
}, async ({ cellId }) => {
|
|
5078
|
-
const check = await
|
|
6958
|
+
const check = await requireBridgeForCells(cellId);
|
|
5079
6959
|
if (check !== true)
|
|
5080
6960
|
return check;
|
|
5081
6961
|
await applyLoopersDelta({
|
|
@@ -5103,7 +6983,7 @@ server.registerTool("set_looper_track_selection", {
|
|
|
5103
6983
|
}, async ({ cellId, start, end }) => {
|
|
5104
6984
|
if (end <= start)
|
|
5105
6985
|
return error("end must be > start");
|
|
5106
|
-
const check = await
|
|
6986
|
+
const check = await requireBridgeForCells(cellId);
|
|
5107
6987
|
if (check !== true)
|
|
5108
6988
|
return check;
|
|
5109
6989
|
await applyLoopersDelta({
|
|
@@ -5137,7 +7017,7 @@ server.registerTool("start_looper_recording", {
|
|
|
5137
7017
|
.describe("Per-track metadata. Defaults: replace mode, cell-output source. Pass existingClipPaths to enable overdub on a prior layer."),
|
|
5138
7018
|
},
|
|
5139
7019
|
}, async ({ loopBars, armedCellIds, trackMetadata }) => {
|
|
5140
|
-
const check = await
|
|
7020
|
+
const check = await requireBridgeForCells(armedCellIds);
|
|
5141
7021
|
if (check !== true)
|
|
5142
7022
|
return check;
|
|
5143
7023
|
// Arm in UI state too — the plugin's live recording overlay and progress
|
|
@@ -5211,7 +7091,7 @@ server.registerTool("request_looper_warp", {
|
|
|
5211
7091
|
cellId: z.number().min(1).max(8).describe("Looper track (1-8)."),
|
|
5212
7092
|
},
|
|
5213
7093
|
}, async ({ cellId }) => {
|
|
5214
|
-
const check = await
|
|
7094
|
+
const check = await requireBridgeForCells(cellId);
|
|
5215
7095
|
if (check !== true)
|
|
5216
7096
|
return check;
|
|
5217
7097
|
await bridge.send("requestLooperWarp", cellId);
|
|
@@ -5287,7 +7167,7 @@ server.registerTool("looper_variation", {
|
|
|
5287
7167
|
data: z
|
|
5288
7168
|
.record(z.string(), z.unknown())
|
|
5289
7169
|
.optional()
|
|
5290
|
-
.describe("For 'save': the looper variation snapshot (object).
|
|
7170
|
+
.describe("For 'save': the looper variation snapshot (object). Omit this field to clear the slot instead."),
|
|
5291
7171
|
},
|
|
5292
7172
|
}, async (args) => {
|
|
5293
7173
|
const check = await requireBridge();
|
|
@@ -5365,21 +7245,21 @@ server.registerTool("queue_variation", {
|
|
|
5365
7245
|
inputSchema: {
|
|
5366
7246
|
index: z.number().min(0).max(7).describe("Variation slot 0-7."),
|
|
5367
7247
|
intervalBars: z.number().min(0.125).max(64).describe("Bars before switching."),
|
|
5368
|
-
fadeMs: z.number().min(0).max(2000).
|
|
7248
|
+
fadeMs: z.number().min(0).max(2000).optional().describe("Crossfade duration in ms. Omit all transition options to inherit the native Bar Sync/Song Mode settings."),
|
|
5369
7249
|
resetPhase: z
|
|
5370
7250
|
.boolean()
|
|
5371
|
-
.
|
|
5372
|
-
.describe("Reset pattern phase on switch."),
|
|
7251
|
+
.optional()
|
|
7252
|
+
.describe("Reset pattern phase on switch. Omit to inherit native settings."),
|
|
5373
7253
|
morphBars: z
|
|
5374
7254
|
.number()
|
|
5375
7255
|
.min(0.25)
|
|
5376
7256
|
.max(8)
|
|
5377
7257
|
.optional()
|
|
5378
|
-
.describe("Pre-anchor morph window in bars for this flip
|
|
7258
|
+
.describe("Pre-anchor morph window in bars for this flip. Omit all transition options to inherit native settings."),
|
|
5379
7259
|
morphMode: z
|
|
5380
7260
|
.enum(["auto", "param", "mix", "seq", "all"])
|
|
5381
7261
|
.optional()
|
|
5382
|
-
.describe("Morph style: 'param' glides continuous params (default); 'mix' equal-power blends the two sections; 'seq' adds a staged pattern dissolve to the glide (old notes lose trigger probability as new notes gain it); 'all' runs glide + dissolve + blend together; 'auto' lets the engine pick — mix when the sections differ in graph topology (samples, synth engines, routing) or when nothing can glide, param otherwise
|
|
7262
|
+
.describe("Morph style: 'param' glides continuous params (default); 'mix' equal-power blends the two sections; 'seq' adds a staged pattern dissolve to the glide (old notes lose trigger probability as new notes gain it); 'all' runs glide + dissolve + blend together; 'auto' lets the engine pick — mix when the sections differ in graph topology (samples, synth engines, routing) or when nothing can glide, param otherwise."),
|
|
5383
7263
|
morphCurve: z
|
|
5384
7264
|
.enum(["linear", "ease", "exp", "log"])
|
|
5385
7265
|
.optional()
|
|
@@ -5392,7 +7272,7 @@ server.registerTool("queue_variation", {
|
|
|
5392
7272
|
tailMaxBars: z.number().min(0.25).max(8).optional(),
|
|
5393
7273
|
})
|
|
5394
7274
|
.optional()
|
|
5395
|
-
.describe("Advanced transition settings for this flip
|
|
7275
|
+
.describe("Advanced transition settings for this flip: pattern policy for a " +
|
|
5396
7276
|
"seq/all morph, blend gain law for a mix/all morph, and the outgoing section's " +
|
|
5397
7277
|
"tail. Omitted fields keep the defaults (dissolve, equalPower, fade)."),
|
|
5398
7278
|
},
|
|
@@ -5403,8 +7283,8 @@ server.registerTool("queue_variation", {
|
|
|
5403
7283
|
await bridge.send("queueVariationOnBars", {
|
|
5404
7284
|
index,
|
|
5405
7285
|
intervalBars,
|
|
5406
|
-
fadeMs,
|
|
5407
|
-
resetPhase,
|
|
7286
|
+
...(fadeMs !== undefined && { fadeMs }),
|
|
7287
|
+
...(resetPhase !== undefined && { resetPhase }),
|
|
5408
7288
|
...(morphBars !== undefined && { morphBars }),
|
|
5409
7289
|
...(morphMode !== undefined && { morphMode }),
|
|
5410
7290
|
...(morphCurve !== undefined && { morphCurve }),
|
|
@@ -5445,7 +7325,7 @@ server.registerTool("update_variations", {
|
|
|
5445
7325
|
server.registerTool("variation_hold", {
|
|
5446
7326
|
title: "Variation Hold",
|
|
5447
7327
|
description: "'Hold while note pressed' mode for variations. Use this to map MIDI notes to momentary " +
|
|
5448
|
-
"variation switches
|
|
7328
|
+
"variation switches. Hold mode reverts when the note releases; latch mode stays.",
|
|
5449
7329
|
inputSchema: {
|
|
5450
7330
|
enabled: z.boolean().optional().describe("Toggle hold mode on/off."),
|
|
5451
7331
|
allNotesOff: z
|
|
@@ -5567,7 +7447,6 @@ server.registerTool("update_modulation", {
|
|
|
5567
7447
|
title: "Update Modulation",
|
|
5568
7448
|
description: "Apply a delta to a modulation source: LFO, Envelope, Vary, Macro, ParamSeq config, ParamSeq sequence data, " +
|
|
5569
7449
|
"or the Free Sequencer master pattern. " +
|
|
5570
|
-
"The payload is type-specific; see modulation update flows in interop.ts (applyLfoUpdate / updateEnvelope / etc.). " +
|
|
5571
7450
|
"Use add_modulation to first attach a source to a target — then this tool to edit it.",
|
|
5572
7451
|
inputSchema: {
|
|
5573
7452
|
type: z
|
|
@@ -5575,7 +7454,11 @@ server.registerTool("update_modulation", {
|
|
|
5575
7454
|
.describe("Which modulation system to update."),
|
|
5576
7455
|
payload: z
|
|
5577
7456
|
.record(z.string(), z.unknown())
|
|
5578
|
-
.describe("Update payload object
|
|
7457
|
+
.describe("Update payload object, one property per call. lfo, envelope, vary, macro and paramSeq all take " +
|
|
7458
|
+
"{ index, property, value }, where index is the 0-based position in that state array (get_state " +
|
|
7459
|
+
"section 'lfos' / 'envelopes' / 'vary' / 'macros' / 'paramSeqs') and property is the field name to " +
|
|
7460
|
+
"overwrite. paramSeqSequence takes { index, sequence: number[] }. freeSeqSequence takes " +
|
|
7461
|
+
"{ sequence: number[] } — the master step contour every free-mode ParamSeq follows."),
|
|
5579
7462
|
},
|
|
5580
7463
|
}, async ({ type, payload }) => {
|
|
5581
7464
|
const check = await requireBridge();
|
|
@@ -5601,13 +7484,6 @@ server.registerTool("update_modulation", {
|
|
|
5601
7484
|
// ============================================================
|
|
5602
7485
|
// TOOLS — Song mode
|
|
5603
7486
|
// ============================================================
|
|
5604
|
-
// Advanced transition spec (ADR-001, PAM >= 1.19.6; engine families live from
|
|
5605
|
-
// PAM >= 1.20.0). Honest contract: it persists, round-trips, and reads back
|
|
5606
|
-
// RESOLVED. Pattern policy (dissolve/wipe/cells), mix law (equal power / equal
|
|
5607
|
-
// gain / correlation), tail (cut/fade/ring) and phase run on song VARIATION
|
|
5608
|
-
// sections and queued bar-sync flips; preset destinations coerce to a param
|
|
5609
|
-
// glide, and offline bounces coerce ring→fade and blend→hard switch (each
|
|
5610
|
-
// reported, never silent). Wire enums are append-only.
|
|
5611
7487
|
const morphCurveEnum = z.enum(["linear", "ease", "exp", "log"]);
|
|
5612
7488
|
const transitionSpecSchema = z.object({
|
|
5613
7489
|
mode: z.enum(["auto", "param", "mix", "seq", "all"]).optional(),
|
|
@@ -5617,6 +7493,10 @@ const transitionSpecSchema = z.object({
|
|
|
5617
7493
|
paramCurve: morphCurveEnum.optional(),
|
|
5618
7494
|
patternCurve: morphCurveEnum.optional(),
|
|
5619
7495
|
mixCurve: morphCurveEnum.optional(),
|
|
7496
|
+
staggerBy: z.enum(["off", "cell", "category"]).optional(),
|
|
7497
|
+
staggerSpread: z.number().min(0).max(0.9).optional(),
|
|
7498
|
+
staggerOrder: z.enum(["forward", "reverse"]).optional(),
|
|
7499
|
+
relations: z.enum(["off", "stepped"]).optional(),
|
|
5620
7500
|
})
|
|
5621
7501
|
.optional(),
|
|
5622
7502
|
pattern: z.object({ policy: z.enum(["dissolve", "wipe", "cells"]).optional() }).optional(),
|
|
@@ -5629,23 +7509,25 @@ const transitionSpecSchema = z.object({
|
|
|
5629
7509
|
.optional(),
|
|
5630
7510
|
phase: z.enum(["inherit", "reset", "continue"]).optional(),
|
|
5631
7511
|
});
|
|
5632
|
-
const TRANSITION_SPEC_HONESTY = "Advanced transition spec
|
|
7512
|
+
const TRANSITION_SPEC_HONESTY = "Advanced transition spec: pattern.policy " +
|
|
5633
7513
|
"('dissolve' prob-morphs events, 'wipe' replaces them at a moving frontier, 'cells' hands " +
|
|
5634
7514
|
"whole cells over in ascending order), audio.mixLaw ('equalPower' default, 'equalGain' for " +
|
|
5635
7515
|
"correlated sections, 'correlation' auto-adapts), audio.tail ('fade' default, 'cut', 'ring' " +
|
|
5636
7516
|
"lets voices/FX decay up to tailMaxBars), phase ('reset'/'continue' overrides the song's " +
|
|
5637
7517
|
"Reset Phase per cable), window.patternCurve/mixCurve (per-domain easing; patternCurve " +
|
|
5638
|
-
"defaults linear). Runs on variation sections + queued flips, and on preset destinations " +
|
|
5639
|
-
"when the lanePresetSections setting is on (
|
|
7518
|
+
"defaults linear). window.staggerBy (off/cell/category), staggerSpread (0..0.9), staggerOrder (forward/reverse), and relations (off/stepped) shape the parameter glide. Runs on variation sections + queued flips, and on preset destinations " +
|
|
7519
|
+
"when the lanePresetSections setting is on (the dual-lane preset section " +
|
|
5640
7520
|
"pipeline; with it off, preset destinations coerce to a param glide). Offline bounces " +
|
|
5641
7521
|
"coerce ring→fade, blend→hard switch. 'get' reads back each cable's resolvedTransition " +
|
|
5642
7522
|
"(effective values after inheritance and capability coercion) and lastMorphResolution " +
|
|
5643
|
-
"(the engine's latest AUTO decision + reason
|
|
7523
|
+
"(the engine's latest AUTO decision + reason; published only for an AUTO request, and " +
|
|
7524
|
+
"one per song rather than one per cable).";
|
|
5644
7525
|
server.registerTool("song_mode", {
|
|
5645
7526
|
title: "Song Mode",
|
|
5646
7527
|
description: "Control PAM's song mode (timeline of variation/preset sections). Ops: 'setArrangement', " +
|
|
5647
7528
|
"'get', 'assignVx', 'setSequence', 'clear', 'toggle', 'setLoopPoints', 'toggleLoop', " +
|
|
5648
|
-
"'setCrossfadeMs', 'setPlayhead'
|
|
7529
|
+
"'setCrossfadeMs', 'setPlayhead'. WebView-routed (window must be OPEN): setArrangement, get, assignVx. " +
|
|
7530
|
+
"Headless: setSequence, clear, toggle, setLoopPoints, toggleLoop, setCrossfadeMs, setPlayhead.\n\n" +
|
|
5649
7531
|
"PREFER 'setArrangement' (PAM ≥ 1.5.7, window open): it authors real Song Mode grid nodes " +
|
|
5650
7532
|
"from a `sections` list, with an optional weighted `connections` graph. The result is " +
|
|
5651
7533
|
"visible/editable in the UI, and each section can pin a " +
|
|
@@ -5653,8 +7535,11 @@ server.registerTool("song_mode", {
|
|
|
5653
7535
|
"audio slot 1:1; see the vx_variation tool), optionally inside a specific VX scene via " +
|
|
5654
7536
|
"`vxScenePath` (the .vxpreset loads before the section plays, so sections can pull visuals " +
|
|
5655
7537
|
"from different scenes). A section can also carry `morphBars`/`morphCurve`/`morphMode` to glide the " +
|
|
5656
|
-
"incoming variation's continuous params over the bars before its boundary
|
|
5657
|
-
"advanced `transition` spec (
|
|
7538
|
+
"incoming variation's continuous params over the bars before its boundary, and an " +
|
|
7539
|
+
"advanced `transition` spec (see the field description). " +
|
|
7540
|
+
"A `transitionPreset` id (or `defaultTransitionPreset` at the top level) stamps a whole named " +
|
|
7541
|
+
"transition in one field; op 'get' lists the available ids in `transitionPresets` and reports " +
|
|
7542
|
+
"each cable's derived `transitionPreset`. " +
|
|
5658
7543
|
"'get' reads the arrangement back (nodes, compiled steps with the " +
|
|
5659
7544
|
"VX scene+slot each recalls, total bars, and each cable's `resolvedTransition` — the effective " +
|
|
5660
7545
|
"transition after inheritance and capability coercion, e.g. preset destinations report param); " +
|
|
@@ -5684,14 +7569,14 @@ server.registerTool("song_mode", {
|
|
|
5684
7569
|
repeat: z.number().int().min(1).optional().describe("Times to play before advancing (default 1)."),
|
|
5685
7570
|
muted: z.boolean().optional().describe("Pass through without an audio trigger."),
|
|
5686
7571
|
probability: z.number().min(0).max(1).optional().describe("Trigger probability (0-1)."),
|
|
5687
|
-
presetPath: z.string().optional().describe("Cross-preset section: at this section's boundary the song switches to THIS preset file, playing its variations[variationIndex] slot. With lanePresetSections on
|
|
7572
|
+
presetPath: z.string().optional().describe("Cross-preset section: at this section's boundary the song switches to THIS preset file, playing its variations[variationIndex] slot. With lanePresetSections on it transitions exactly like a variation section (prepared off the audible path, full morph family); otherwise the legacy in-place load applies."),
|
|
5688
7573
|
label: z.string().optional().describe("Section label shown on the grid node (e.g. 'Intro', 'Hook')."),
|
|
5689
7574
|
morphBars: z
|
|
5690
7575
|
.number()
|
|
5691
7576
|
.min(0)
|
|
5692
7577
|
.max(8)
|
|
5693
7578
|
.optional()
|
|
5694
|
-
.describe("Pre-anchor morph window in bars for the transition INTO this section — continuous params glide to the new values, landing at the boundary
|
|
7579
|
+
.describe("Pre-anchor morph window in bars for the transition INTO this section — continuous params glide to the new values, landing at the boundary. 0 = explicit hard switch (never morph, ignores the song's default); omit to inherit the default. Values in (0, 0.25) clamp up to 0.25."),
|
|
5695
7580
|
morphCurve: z
|
|
5696
7581
|
.enum(["linear", "ease", "exp", "log"])
|
|
5697
7582
|
.optional()
|
|
@@ -5699,8 +7584,12 @@ server.registerTool("song_mode", {
|
|
|
5699
7584
|
morphMode: z
|
|
5700
7585
|
.enum(["auto", "param", "mix", "seq", "all"])
|
|
5701
7586
|
.optional()
|
|
5702
|
-
.describe("Morph realization; default auto — the engine picks mix when the sections differ in graph topology (samples, synth engines, routing) or when nothing can glide, param otherwise
|
|
7587
|
+
.describe("Morph realization; default auto — the engine picks mix when the sections differ in graph topology (samples, synth engines, routing) or when nothing can glide, param otherwise. 'param' glides continuous params; 'mix' DJ-blends stems; 'seq' adds a staged pattern dissolve to the glide; 'all' runs glide + dissolve + blend. Ignored without morphBars."),
|
|
5703
7588
|
transition: transitionSpecSchema.optional().describe(TRANSITION_SPEC_HONESTY),
|
|
7589
|
+
transitionPreset: z
|
|
7590
|
+
.string()
|
|
7591
|
+
.optional()
|
|
7592
|
+
.describe("Named transition preset id — read the list from op 'get' (`transitionPresets`: the factory set plus the user's saved ones). Stamps every transition field onto the incoming cable; morphBars/morphCurve/morphMode/transition on the same object override the stamp field by field. Unknown ids fail with the known list."),
|
|
5704
7593
|
vxVariationIndex: z
|
|
5705
7594
|
.number()
|
|
5706
7595
|
.int()
|
|
@@ -5743,6 +7632,10 @@ server.registerTool("song_mode", {
|
|
|
5743
7632
|
.optional()
|
|
5744
7633
|
.describe("Per-cable morph realization; overrides the destination section's morphMode. Same modes as the section field, incl. 'seq' (pattern dissolve) and 'all' (glide + dissolve + blend)."),
|
|
5745
7634
|
transition: transitionSpecSchema.optional().describe("Per-cable " + TRANSITION_SPEC_HONESTY),
|
|
7635
|
+
transitionPreset: z
|
|
7636
|
+
.string()
|
|
7637
|
+
.optional()
|
|
7638
|
+
.describe("Named transition preset id — read the list from op 'get' (`transitionPresets`: the factory set plus the user's saved ones). Stamps every transition field onto the incoming cable; morphBars/morphCurve/morphMode/transition on the same object override the stamp field by field. Unknown ids fail with the known list."),
|
|
5746
7639
|
}))
|
|
5747
7640
|
.optional()
|
|
5748
7641
|
.describe("For 'setArrangement': optional cable graph. Multiple cables may leave or enter a section. Each cable may carry its own morphBars/morphCurve/morphMode/transition."),
|
|
@@ -5750,6 +7643,11 @@ server.registerTool("song_mode", {
|
|
|
5750
7643
|
.nullable()
|
|
5751
7644
|
.optional()
|
|
5752
7645
|
.describe("For 'setArrangement': song-level advanced transition defaults inherited by cables without their own `transition`; null clears. " + TRANSITION_SPEC_HONESTY),
|
|
7646
|
+
defaultTransitionPreset: z
|
|
7647
|
+
.string()
|
|
7648
|
+
.nullable()
|
|
7649
|
+
.optional()
|
|
7650
|
+
.describe("For 'setArrangement': stamp a named preset as the song-level default transition (see transitionPreset)."),
|
|
5753
7651
|
loop: z.boolean().optional().describe("For 'setArrangement': loop the whole arrangement."),
|
|
5754
7652
|
nodeId: z.string().optional().describe("For 'assignVx': grid node id (from op 'get')."),
|
|
5755
7653
|
stepIndex: z.number().int().min(0).optional().describe("For 'assignVx': compiled step index (alternative to nodeId)."),
|
|
@@ -5808,29 +7706,37 @@ server.registerTool("song_mode", {
|
|
|
5808
7706
|
case "setArrangement": {
|
|
5809
7707
|
if (!args.sections || args.sections.length === 0)
|
|
5810
7708
|
return error("'setArrangement' requires sections");
|
|
7709
|
+
const requestId = mintVideoRequestId();
|
|
5811
7710
|
await sendVideoCommand({
|
|
5812
7711
|
op: "setSongArrangement",
|
|
7712
|
+
requestId,
|
|
5813
7713
|
sections: args.sections,
|
|
5814
7714
|
...(args.connections !== undefined && { connections: args.connections }),
|
|
5815
7715
|
...(args.defaultTransition !== undefined && { defaultTransition: args.defaultTransition }),
|
|
7716
|
+
...(args.defaultTransitionPreset !== undefined && { defaultTransitionPreset: args.defaultTransitionPreset }),
|
|
5816
7717
|
...(args.loop !== undefined && { loop: args.loop }),
|
|
5817
7718
|
...(args.enabled !== undefined && { enabled: args.enabled }),
|
|
5818
7719
|
...(args.crossfadeMs !== undefined && { crossfadeMs: args.crossfadeMs }),
|
|
5819
7720
|
});
|
|
5820
|
-
// The forward path is fire-and-forget — poll until the WebView's apply
|
|
5821
|
-
// republishes the snapshot (same pattern as resolveNewId).
|
|
5822
7721
|
const deadline = Date.now() + 3000;
|
|
5823
7722
|
let sm;
|
|
7723
|
+
let acknowledged = false;
|
|
5824
7724
|
for (;;) {
|
|
5825
7725
|
const state = await getVideoStateRaw();
|
|
5826
|
-
|
|
5827
|
-
|
|
5828
|
-
|
|
5829
|
-
|
|
5830
|
-
|
|
7726
|
+
const ack = state.lastCommandAck;
|
|
7727
|
+
if (ack?.requestId === requestId) {
|
|
7728
|
+
if (ack.ok !== true)
|
|
7729
|
+
return error(ack.error ?? "Song arrangement was rejected.");
|
|
7730
|
+
sm = state.songMode;
|
|
7731
|
+
acknowledged = true;
|
|
7732
|
+
break;
|
|
7733
|
+
}
|
|
7734
|
+
if (Date.now() > deadline)
|
|
5831
7735
|
break;
|
|
5832
7736
|
await sleep(150);
|
|
5833
7737
|
}
|
|
7738
|
+
if (!acknowledged)
|
|
7739
|
+
return error("Song arrangement was sent but its completion was not acknowledged. Open PAM's window and use a current plugin build; inspect song_mode op:'get' before retrying.");
|
|
5834
7740
|
if (!sm || sm.available !== true)
|
|
5835
7741
|
return error("Arrangement command sent, but no song-mode read-model came back — this needs PAM ≥ 1.5.7 " +
|
|
5836
7742
|
"with the window OPEN. On older builds fall back to op 'setSequence' (headless raw steps).");
|
|
@@ -5915,11 +7821,13 @@ server.registerTool("export_song", {
|
|
|
5915
7821
|
"• 'live' with cellId — legacy headless master-mix capture into an empty cell (works with the window " +
|
|
5916
7822
|
"closed on PAM ≥ 1.5.0). The captured WAV also lands in PAM's Resamples folder.\n" +
|
|
5917
7823
|
"• 'offline' — the DETERMINISTIC multi-section bounce (PAM ≥ 1.5.7 with offline song export): every " +
|
|
5918
|
-
"variation section applies sample-accurately on its anchor
|
|
7824
|
+
"variation section applies sample-accurately on its anchor and renders faster than realtime; `bars` is still required and capped at 64. " +
|
|
5919
7825
|
"Audio-only works headless (window closed); includeVideo runs the offline video pump with per-section VX " +
|
|
5920
7826
|
"recalls (window open, WebCodecs). Caveats: preset-path steps hold the previous section's sound (pending), " +
|
|
5921
7827
|
"and wall-clock sources (Magenta, Notochord, camera layers) cannot render offline — use 'live' for those.\n\n" +
|
|
5922
7828
|
"Live paths render in REAL TIME (a 90 s song takes ~90 s) and cap at 64 bars per capture. " +
|
|
7829
|
+
"This is the entry point for arrangement bounces: strategy 'offline' issues the same render as " +
|
|
7830
|
+
"`export_audio` with mode 'song-mode', and the live and audio+video strategies exist only here. " +
|
|
5923
7831
|
"Verify audio results with get_audio_info (resolve cell captures against the Resamples location " +
|
|
5924
7832
|
"from get_default_locations).",
|
|
5925
7833
|
inputSchema: {
|
|
@@ -5928,7 +7836,7 @@ server.registerTool("export_song", {
|
|
|
5928
7836
|
.number()
|
|
5929
7837
|
.min(1)
|
|
5930
7838
|
.max(64)
|
|
5931
|
-
.describe("Total arrangement length in bars (the full timeline). Required for
|
|
7839
|
+
.describe("Total arrangement length in bars (the full timeline), 1-64. Required for every strategy."),
|
|
5932
7840
|
cellId: z
|
|
5933
7841
|
.number()
|
|
5934
7842
|
.min(1)
|
|
@@ -5942,7 +7850,10 @@ server.registerTool("export_song", {
|
|
|
5942
7850
|
name: z.string().optional().describe("Output name for WebView Song Export renders (default 'Song')."),
|
|
5943
7851
|
normalize: z.boolean().optional().describe("Normalize the captured audio (WebView Song Export paths)."),
|
|
5944
7852
|
encoderId: z.string().optional().describe("Recording encoder preset for includeVideo (e.g. mp4-h264)."),
|
|
5945
|
-
bitDepth: z
|
|
7853
|
+
bitDepth: z
|
|
7854
|
+
.union([z.literal(16), z.literal(24)])
|
|
7855
|
+
.default(24)
|
|
7856
|
+
.describe("Bit depth: 16 or 24 (legacy cell-capture + offline paths)."),
|
|
5946
7857
|
tailBars: z
|
|
5947
7858
|
.number()
|
|
5948
7859
|
.min(0)
|
|
@@ -5951,7 +7862,7 @@ server.registerTool("export_song", {
|
|
|
5951
7862
|
.describe("For legacy cell-capture 'live': extra bars captured after the arrangement end for tails."),
|
|
5952
7863
|
},
|
|
5953
7864
|
}, async ({ strategy, bars, cellId, includeVideo, name, normalize, encoderId, bitDepth, tailBars }) => {
|
|
5954
|
-
const check = await
|
|
7865
|
+
const check = await requireBridgeForCells(cellId);
|
|
5955
7866
|
if (check !== true)
|
|
5956
7867
|
return check;
|
|
5957
7868
|
// -------- Offline audio-only: headless multi-section bounce --------
|
|
@@ -6095,9 +8006,10 @@ server.registerTool("set_metronome", {
|
|
|
6095
8006
|
// ============================================================
|
|
6096
8007
|
server.registerTool("analyze_sample_tempo", {
|
|
6097
8008
|
title: "Analyze Sample Tempo",
|
|
6098
|
-
description: "Run BPM detection and transient/slice analysis on an audio file. " +
|
|
6099
|
-
"
|
|
6100
|
-
"
|
|
8009
|
+
description: "Run BPM detection and transient/slice analysis on an audio file. The result lands in the plugin's " +
|
|
8010
|
+
"sample-metadata cache and the editor's sample library; the bridge does not capture it, so there is no " +
|
|
8011
|
+
"get_state key that reads it back. Run it before resample_sample_to_bpm so the stretch has a source BPM, " +
|
|
8012
|
+
"and use get_audio_info for a file's own format properties.",
|
|
6101
8013
|
inputSchema: {
|
|
6102
8014
|
filePath: z.string().describe("Path to the audio file."),
|
|
6103
8015
|
transientSensitivity: z
|
|
@@ -6156,11 +8068,11 @@ server.registerTool("preview_slice", {
|
|
|
6156
8068
|
title: "Preview Slice",
|
|
6157
8069
|
description: "Audition a single slice of a cell's sample.",
|
|
6158
8070
|
inputSchema: {
|
|
6159
|
-
cellId: z.number().min(1).max(8),
|
|
6160
|
-
sliceNumber: z.number().min(0).describe("Slice index."),
|
|
8071
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
8072
|
+
sliceNumber: z.number().min(0).describe("Slice index (0-based)."),
|
|
6161
8073
|
},
|
|
6162
8074
|
}, async ({ cellId, sliceNumber }) => {
|
|
6163
|
-
const check = await
|
|
8075
|
+
const check = await requireBridgeForCells(cellId);
|
|
6164
8076
|
if (check !== true)
|
|
6165
8077
|
return check;
|
|
6166
8078
|
await bridge.send("previewSlice", cellId, sliceNumber);
|
|
@@ -6171,7 +8083,7 @@ server.registerTool("resample_sample_to_bpm", {
|
|
|
6171
8083
|
description: "Time-stretch a cell's sample to the current project BPM (or a target BPM). " +
|
|
6172
8084
|
"Non-destructive — use restore_original_sample to revert.",
|
|
6173
8085
|
inputSchema: {
|
|
6174
|
-
cellId: z.number().min(1).max(8),
|
|
8086
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
6175
8087
|
sourceBpm: z
|
|
6176
8088
|
.number()
|
|
6177
8089
|
.min(20)
|
|
@@ -6196,10 +8108,10 @@ server.registerTool("restore_original_sample", {
|
|
|
6196
8108
|
title: "Restore Original Sample",
|
|
6197
8109
|
description: "Revert a cell's sample to its un-edited source (undoes resample / trim / etc.).",
|
|
6198
8110
|
inputSchema: {
|
|
6199
|
-
cellId: z.number().min(1).max(8),
|
|
8111
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
6200
8112
|
},
|
|
6201
8113
|
}, async ({ cellId }) => {
|
|
6202
|
-
const check = await
|
|
8114
|
+
const check = await requireBridgeForCells(cellId);
|
|
6203
8115
|
if (check !== true)
|
|
6204
8116
|
return check;
|
|
6205
8117
|
await bridge.send("restoreOriginalSample", { cellId });
|
|
@@ -6213,7 +8125,7 @@ server.registerTool("build_sample_chain", {
|
|
|
6213
8125
|
cellId: z.number().min(1).max(8).describe("Destination cell."),
|
|
6214
8126
|
},
|
|
6215
8127
|
}, async ({ filePaths, cellId }) => {
|
|
6216
|
-
const check = await
|
|
8128
|
+
const check = await requireBridgeForCells(cellId);
|
|
6217
8129
|
if (check !== true)
|
|
6218
8130
|
return check;
|
|
6219
8131
|
const result = await bridge.send("buildSampleChain", filePaths, cellId);
|
|
@@ -6223,11 +8135,11 @@ server.registerTool("export_slice", {
|
|
|
6223
8135
|
title: "Export Slice",
|
|
6224
8136
|
description: "Export a single slice of a cell's sample as a standalone WAV.",
|
|
6225
8137
|
inputSchema: {
|
|
6226
|
-
cellId: z.number().min(1).max(8),
|
|
6227
|
-
sliceIndex: z.number().min(0),
|
|
8138
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
8139
|
+
sliceIndex: z.number().min(0).describe("Slice index (0-based)."),
|
|
6228
8140
|
},
|
|
6229
8141
|
}, async ({ cellId, sliceIndex }) => {
|
|
6230
|
-
const check = await
|
|
8142
|
+
const check = await requireBridgeForCells(cellId);
|
|
6231
8143
|
if (check !== true)
|
|
6232
8144
|
return check;
|
|
6233
8145
|
await bridge.send("exportSliceForCell", { cellId, sliceIndex });
|
|
@@ -6241,7 +8153,8 @@ server.registerTool("live_resample", {
|
|
|
6241
8153
|
description: "Capture PAM's audio graph output into a cell as a new sample, in real time. Ops: 'start' / 'stop'. " +
|
|
6242
8154
|
"Transport MUST be playing for the capture to produce audio. Because the capture runs live, " +
|
|
6243
8155
|
"Song Mode section changes, modulation, and variation switches are all captured faithfully — " +
|
|
6244
|
-
"this is the building block for full-track / Song Mode bounces (see export_song)
|
|
8156
|
+
"this is the building block for full-track / Song Mode bounces (see export_song). On finalize the captured clip " +
|
|
8157
|
+
"REPLACES whatever sample the destination cell held — use an empty cell, or reload the preset before saving anything afterwards.\n\n" +
|
|
6245
8158
|
"`source` selects what to record: 'cell' (the destination cell's own output), 'input' (PAM's audio input), " +
|
|
6246
8159
|
"or 'fullmix' (the whole master mix — use this to bounce the entire arrangement).",
|
|
6247
8160
|
inputSchema: {
|
|
@@ -6272,14 +8185,21 @@ server.registerTool("live_resample", {
|
|
|
6272
8185
|
.describe("For 'stop': finalize the captured clip (false discards)."),
|
|
6273
8186
|
},
|
|
6274
8187
|
}, async (args) => {
|
|
6275
|
-
const check = await
|
|
8188
|
+
const check = await requireBridgeForCells(args.op === "start" ? args.cellId : undefined);
|
|
6276
8189
|
if (check !== true)
|
|
6277
8190
|
return check;
|
|
6278
8191
|
if (args.op === "start") {
|
|
8192
|
+
if (activeLiveResample) {
|
|
8193
|
+
return error(`A live resample started by this server is already targeting cell ${activeLiveResample.cellId}. Stop it first.`);
|
|
8194
|
+
}
|
|
6279
8195
|
if (typeof args.cellId !== "number" || typeof args.bars !== "number")
|
|
6280
8196
|
return error("'start' requires cellId + bars");
|
|
6281
8197
|
const source = args.source ?? "cell";
|
|
6282
8198
|
const captureSource = source === "fullmix" ? 2 : source === "input" ? 1 : 0;
|
|
8199
|
+
const beforeRaw = await bridge.send("getState");
|
|
8200
|
+
const beforeCells = isRecord(beforeRaw) && isRecord(beforeRaw.cells) ? beforeRaw.cells : {};
|
|
8201
|
+
const beforeCell = beforeCells[String(args.cellId)];
|
|
8202
|
+
const before = isRecord(beforeCell) ? beforeCell : {};
|
|
6283
8203
|
await bridge.send("resampleGraphToCell", JSON.stringify({
|
|
6284
8204
|
cellId: args.cellId,
|
|
6285
8205
|
bars: args.bars,
|
|
@@ -6290,10 +8210,48 @@ server.registerTool("live_resample", {
|
|
|
6290
8210
|
cellOnly: source === "cell",
|
|
6291
8211
|
normalize: args.normalize ?? false,
|
|
6292
8212
|
}));
|
|
8213
|
+
activeLiveResample = {
|
|
8214
|
+
cellId: args.cellId,
|
|
8215
|
+
previousPath: typeof before.filePath === "string" ? before.filePath : undefined,
|
|
8216
|
+
};
|
|
6293
8217
|
return text(`Started live resample → cell ${args.cellId} (${args.bars} bars, source '${source}').`);
|
|
6294
8218
|
}
|
|
6295
|
-
|
|
6296
|
-
|
|
8219
|
+
const finalize = args.finalize ?? true;
|
|
8220
|
+
await bridge.send("stopLiveResample", finalize);
|
|
8221
|
+
const capture = activeLiveResample;
|
|
8222
|
+
activeLiveResample = undefined;
|
|
8223
|
+
if (!finalize)
|
|
8224
|
+
return text("Stopped live resample and discarded the capture.");
|
|
8225
|
+
if (!capture)
|
|
8226
|
+
return error("Stopped live resample, but this server did not start it and cannot identify the destination cell.");
|
|
8227
|
+
let capturedPath;
|
|
8228
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
8229
|
+
const stateRaw = await bridge.send("getState");
|
|
8230
|
+
const state = isRecord(stateRaw) ? stateRaw : {};
|
|
8231
|
+
const stateCells = isRecord(state.cells) ? state.cells : {};
|
|
8232
|
+
const rawCell = stateCells[String(capture.cellId)];
|
|
8233
|
+
const cell = isRecord(rawCell) ? rawCell : {};
|
|
8234
|
+
if (typeof cell.filePath === "string" && cell.filePath && cell.filePath !== capture.previousPath) {
|
|
8235
|
+
capturedPath = cell.filePath;
|
|
8236
|
+
break;
|
|
8237
|
+
}
|
|
8238
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 250));
|
|
8239
|
+
}
|
|
8240
|
+
if (!capturedPath) {
|
|
8241
|
+
return error(`Capture finalized into cell ${capture.cellId}, but its new file path was not available after 5 seconds.`);
|
|
8242
|
+
}
|
|
8243
|
+
if (!isAbsolute(capturedPath)) {
|
|
8244
|
+
const locations = await bridge.send("getDefaultLocations");
|
|
8245
|
+
const userSamples = Array.isArray(locations)
|
|
8246
|
+
? locations.find((location) => isRecord(location) && location.name === "User Samples")
|
|
8247
|
+
: undefined;
|
|
8248
|
+
if (isRecord(userSamples) && typeof userSamples.path === "string") {
|
|
8249
|
+
const normalized = capturedPath.replaceAll("\\", "/");
|
|
8250
|
+
const userRelative = normalized.replace(/^Samples\/User\//, "");
|
|
8251
|
+
capturedPath = join(userSamples.path, userRelative);
|
|
8252
|
+
}
|
|
8253
|
+
}
|
|
8254
|
+
return text(`Stopped live resample and finalized cell ${capture.cellId}.\nFile: ${capturedPath}`);
|
|
6297
8255
|
});
|
|
6298
8256
|
// ============================================================
|
|
6299
8257
|
// TOOLS — Plugin host (scan/load/unload/param/bypass on hosted VST3/AU/CLAP)
|
|
@@ -6475,7 +8433,7 @@ server.registerTool("list_loaded_plugins", {
|
|
|
6475
8433
|
title: "List Loaded Plugins",
|
|
6476
8434
|
description: "List plugins currently hosted in PAM cells (instrument slot only — FX-side hosting is not exposed). " +
|
|
6477
8435
|
"Each entry: cellId, name, manufacturer, format, " +
|
|
6478
|
-
"path, isInstrument, bypassed, and the plugin's full param list (index, name, label, value, defaultValue). " +
|
|
8436
|
+
"path, isInstrument, bypassed, and the plugin's full param list (index, name, label, value, defaultValue, discrete, numSteps, and valueStrings for a discrete param). " +
|
|
6479
8437
|
"Use the param `index` with `set_plugin_param`.",
|
|
6480
8438
|
inputSchema: {},
|
|
6481
8439
|
}, async () => {
|
|
@@ -6517,7 +8475,8 @@ server.registerTool("load_plugin", {
|
|
|
6517
8475
|
...(args.uniqueId ? { uniqueId: args.uniqueId } : {}),
|
|
6518
8476
|
...(args.name ? { name: args.name } : {}),
|
|
6519
8477
|
}));
|
|
6520
|
-
|
|
8478
|
+
// Native answers a success with an object (`{ok: true, …}`), a failure with a string.
|
|
8479
|
+
return text(typeof raw === "string" ? raw : JSON.stringify(raw));
|
|
6521
8480
|
});
|
|
6522
8481
|
server.registerTool("unload_plugin", {
|
|
6523
8482
|
title: "Unload Plugin From Cell",
|
|
@@ -6540,10 +8499,10 @@ server.registerTool("set_plugin_param", {
|
|
|
6540
8499
|
description: "Set a parameter on a hosted plugin by cell + parameter index. Value is normalized 0..1. " +
|
|
6541
8500
|
"Use `list_loaded_plugins` to find param indices and names. " +
|
|
6542
8501
|
"This is the dedicated path for hosted plugin params — `set_parameters` won't reach them. " +
|
|
6543
|
-
"
|
|
8502
|
+
"It exists for a single instrument-slot value; use `set_plugin_params` for a batch or for the FX slot.",
|
|
6544
8503
|
inputSchema: {
|
|
6545
|
-
cellId: z.number().int().min(1),
|
|
6546
|
-
paramIndex: z.number().int().min(0),
|
|
8504
|
+
cellId: z.number().int().min(1).describe("Cell hosting the plugin."),
|
|
8505
|
+
paramIndex: z.number().int().min(0).describe("Parameter index from list_loaded_plugins."),
|
|
6547
8506
|
value: z.number().min(0).max(1).describe("Normalized 0..1."),
|
|
6548
8507
|
},
|
|
6549
8508
|
}, async (args) => {
|
|
@@ -6565,8 +8524,8 @@ server.registerTool("set_plugin_bypass", {
|
|
|
6565
8524
|
description: "Toggle bypass on the hosted plugin in a cell (instrument slot). Bypassed plugins are silenced; the cell sampler " +
|
|
6566
8525
|
"still plays in parallel.",
|
|
6567
8526
|
inputSchema: {
|
|
6568
|
-
cellId: z.number().int().min(1),
|
|
6569
|
-
bypassed: z.boolean(),
|
|
8527
|
+
cellId: z.number().int().min(1).describe("Cell hosting the plugin."),
|
|
8528
|
+
bypassed: z.boolean().describe("true silences the hosted plugin, false re-enables it."),
|
|
6570
8529
|
},
|
|
6571
8530
|
}, async (args) => {
|
|
6572
8531
|
const check = await requireBridge();
|
|
@@ -6665,6 +8624,22 @@ server.registerTool("get_audio_device_setup", {
|
|
|
6665
8624
|
const setup = await bridge.send("getAudioDeviceSetup");
|
|
6666
8625
|
return json(setup);
|
|
6667
8626
|
});
|
|
8627
|
+
server.registerTool("get_memory_report", {
|
|
8628
|
+
title: "Get Memory Report",
|
|
8629
|
+
description: "Report the instance's audio memory: vfsBytes (decoded samples resident in the engine's virtual file system), " +
|
|
8630
|
+
"vfsRetiredBytes (superseded buffers not yet reclaimed), vfsEntryCount, looperRingBytes (allocated looper " +
|
|
8631
|
+
"capture rings), and residentBytes (whole-process RSS; residentBytesAvailable is false when the platform " +
|
|
8632
|
+
"could not answer, in which case residentBytes is 0 rather than empty). Use it to check whether loading, " +
|
|
8633
|
+
"replacing or auditioning samples leaves memory flat or growing.",
|
|
8634
|
+
inputSchema: {},
|
|
8635
|
+
annotations: { readOnlyHint: true },
|
|
8636
|
+
}, async () => {
|
|
8637
|
+
const check = await requireBridge();
|
|
8638
|
+
if (check !== true)
|
|
8639
|
+
return check;
|
|
8640
|
+
const report = await bridge.send("getMemoryReport");
|
|
8641
|
+
return json(report);
|
|
8642
|
+
});
|
|
6668
8643
|
server.registerTool("set_audio_device", {
|
|
6669
8644
|
title: "Set Audio Output Device",
|
|
6670
8645
|
description: "Switch the active audio OUTPUT device by name (standalone only — plugin-hosted builds let the DAW own the " +
|
|
@@ -6771,7 +8746,9 @@ server.registerTool("get_sample_usage", {
|
|
|
6771
8746
|
description: "Cross-reference samples and presets. mode 'presetUsesSamples' lists the samples a preset references; " +
|
|
6772
8747
|
"mode 'presetsUsingSample' lists presets that reference a given sample (or any sample under a folder).",
|
|
6773
8748
|
inputSchema: {
|
|
6774
|
-
mode: z
|
|
8749
|
+
mode: z
|
|
8750
|
+
.enum(["presetUsesSamples", "presetsUsingSample"])
|
|
8751
|
+
.describe("Which direction to cross-reference: from a preset to its samples, or from a sample to the presets using it."),
|
|
6775
8752
|
path: z
|
|
6776
8753
|
.string()
|
|
6777
8754
|
.describe("Relative preset path (presetUsesSamples) or relative sample path (presetsUsingSample)."),
|
|
@@ -6796,7 +8773,9 @@ server.registerTool("repair_preset_samples", {
|
|
|
6796
8773
|
"library (optionally scoped to one preset). mode 'rescan' re-audits a preset for still-missing samples and " +
|
|
6797
8774
|
"reports them. Useful after moving a sample library or importing presets from another machine.",
|
|
6798
8775
|
inputSchema: {
|
|
6799
|
-
mode: z
|
|
8776
|
+
mode: z
|
|
8777
|
+
.enum(["fix", "rescan"])
|
|
8778
|
+
.describe("'fix' relinks moved samples by searching the library; 'rescan' only reports what is still missing."),
|
|
6800
8779
|
presetPath: z
|
|
6801
8780
|
.string()
|
|
6802
8781
|
.optional()
|
|
@@ -6840,7 +8819,9 @@ server.registerTool("preview_sample", {
|
|
|
6840
8819
|
server.registerTool("set_plugin_params", {
|
|
6841
8820
|
title: "Set Hosted Plugin Params (batch)",
|
|
6842
8821
|
description: "Set multiple hosted-plugin parameters in one call (more efficient than set_plugin_param per value). Values are " +
|
|
6843
|
-
"0..1 normalized. Use list_loaded_plugins to discover paramIndex values. role defaults to 'instrument'."
|
|
8822
|
+
"0..1 normalized. Use list_loaded_plugins to discover paramIndex values. role defaults to 'instrument'. " +
|
|
8823
|
+
"role:'effect' reaches the FX slot, but list_loaded_plugins reports instrument-slot plugins only, so " +
|
|
8824
|
+
"effect-slot param indices are not discoverable through MCP.",
|
|
6844
8825
|
inputSchema: {
|
|
6845
8826
|
cellId: z.number().int().min(1).describe("Cell number."),
|
|
6846
8827
|
role: z.enum(["instrument", "effect"]).optional().describe("Which hosted slot (default 'instrument')."),
|
|
@@ -6927,6 +8908,9 @@ const SET_VIEW_MAIN_VIEW = {
|
|
|
6927
8908
|
export: "Export",
|
|
6928
8909
|
presets: "PresetBrowser",
|
|
6929
8910
|
browser: "PresetBrowser",
|
|
8911
|
+
// MFX-only surfaces. The handler rejects them for other products.
|
|
8912
|
+
patch: "MfxRouting",
|
|
8913
|
+
patchbay: "MfxRouting",
|
|
6930
8914
|
};
|
|
6931
8915
|
// Dev-tree twin of the in-plugin assistant's set_view (toolpack-gated like
|
|
6932
8916
|
// ask_assistant — the published server deliberately excludes window/view
|
|
@@ -6946,12 +8930,13 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
|
|
|
6946
8930
|
view: z
|
|
6947
8931
|
.union([z.string(), z.number()])
|
|
6948
8932
|
.optional()
|
|
6949
|
-
.describe("Main view: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets."
|
|
8933
|
+
.describe("Main view: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets. " +
|
|
8934
|
+
"MFX also takes patch (the cable patchbay)."),
|
|
6950
8935
|
cellId: z.number().int().optional().describe("Cell to select/focus (1-8)."),
|
|
6951
8936
|
gridSize: z.number().optional().describe("How many pads the grid shows: 1, 4, or 8."),
|
|
6952
8937
|
},
|
|
6953
8938
|
}, async ({ view: viewArg, cellId, gridSize }) => {
|
|
6954
|
-
const check = await
|
|
8939
|
+
const check = await requireBridgeForCells(cellId);
|
|
6955
8940
|
if (check !== true)
|
|
6956
8941
|
return check;
|
|
6957
8942
|
let view = viewArg === undefined ? undefined : String(viewArg);
|
|
@@ -6967,7 +8952,12 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
|
|
|
6967
8952
|
if (typeof view === "string" && view.trim()) {
|
|
6968
8953
|
token = view.trim().toLowerCase();
|
|
6969
8954
|
if (!(token in SET_VIEW_MAIN_VIEW))
|
|
6970
|
-
return error(`Unknown view: ${view}. Views: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets.`);
|
|
8955
|
+
return error(`Unknown view: ${view}. Views: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets, patch.`);
|
|
8956
|
+
if (token === "patch" || token === "patchbay") {
|
|
8957
|
+
const manifest = await loadManifestForProductGuard();
|
|
8958
|
+
if (manifest && !isMfxManifest(manifest))
|
|
8959
|
+
return error(`${token} is available in MFX only.`);
|
|
8960
|
+
}
|
|
6971
8961
|
applied.view = token;
|
|
6972
8962
|
}
|
|
6973
8963
|
if (gridSize !== undefined) {
|
|
@@ -7081,9 +9071,12 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
|
|
|
7081
9071
|
playing: Boolean(transport.playing ?? transport.isPlaying),
|
|
7082
9072
|
preset,
|
|
7083
9073
|
sel: sel ?? null,
|
|
7084
|
-
|
|
7085
|
-
|
|
7086
|
-
|
|
9074
|
+
muted: (overview.cells ?? []).filter((c) => c.muted).map((c) => c.cellId),
|
|
9075
|
+
cells: (overview.cells ?? []).flatMap((c) => {
|
|
9076
|
+
const name = c.sample?.path || c.sample?.name
|
|
9077
|
+
|| (c.synthEngine && c.synthEngine.id > 0 ? `${c.synthEngine.name}(synth)` : undefined);
|
|
9078
|
+
return name ? [{ id: c.cellId, name }] : [];
|
|
9079
|
+
}),
|
|
7087
9080
|
};
|
|
7088
9081
|
}
|
|
7089
9082
|
}
|
|
@@ -7185,13 +9178,55 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
|
|
|
7185
9178
|
// ============================================================
|
|
7186
9179
|
// Start
|
|
7187
9180
|
// ============================================================
|
|
9181
|
+
// A stdio server that dies takes its whole tool surface with it, and the client
|
|
9182
|
+
// only ever reports "Connection closed". Nothing short of an explicit exit is
|
|
9183
|
+
// worth that, so stray async faults are logged to stderr and the server lives.
|
|
9184
|
+
// The rule must stay BOUNDED: a fault that repeats every tick turns "the server
|
|
9185
|
+
// lives" into a permanent 100% CPU spin, and each report re-formats a tsx
|
|
9186
|
+
// source-mapped stack. Five orphaned servers burned a core each for over a week.
|
|
9187
|
+
const FAULT_WINDOW_MS = 10_000;
|
|
9188
|
+
const FAULT_LIMIT = 100;
|
|
9189
|
+
let faultTimes = [];
|
|
9190
|
+
function surviveFault(label, value) {
|
|
9191
|
+
const now = Date.now();
|
|
9192
|
+
faultTimes = faultTimes.filter((t) => now - t < FAULT_WINDOW_MS);
|
|
9193
|
+
faultTimes.push(now);
|
|
9194
|
+
if (faultTimes.length > FAULT_LIMIT)
|
|
9195
|
+
process.exit(1);
|
|
9196
|
+
try {
|
|
9197
|
+
console.error(label, value);
|
|
9198
|
+
}
|
|
9199
|
+
catch {
|
|
9200
|
+
process.exit(1);
|
|
9201
|
+
}
|
|
9202
|
+
}
|
|
9203
|
+
// EPIPE on our own stdio is the dead-client signal, not a stray fault: the SDK
|
|
9204
|
+
// writes JSON-RPC to stdout unguarded, and routing that through the handler
|
|
9205
|
+
// below made the logger re-raise the fault it was reporting. Exit at the source.
|
|
9206
|
+
for (const s of [process.stdout, process.stderr]) {
|
|
9207
|
+
s.on("error", (e) => {
|
|
9208
|
+
if (e?.code === "EPIPE")
|
|
9209
|
+
process.exit(0);
|
|
9210
|
+
});
|
|
9211
|
+
}
|
|
9212
|
+
process.on("unhandledRejection", (reason) => {
|
|
9213
|
+
surviveFault("[pam-mcp] unhandled rejection (server continuing):", reason);
|
|
9214
|
+
});
|
|
9215
|
+
process.on("uncaughtException", (err) => {
|
|
9216
|
+
surviveFault("[pam-mcp] uncaught exception (server continuing):", err);
|
|
9217
|
+
});
|
|
7188
9218
|
async function main() {
|
|
7189
9219
|
if (await handleCliArgs())
|
|
7190
9220
|
return;
|
|
7191
9221
|
// Try to connect to plugin on startup (non-blocking)
|
|
7192
|
-
void bridge
|
|
9222
|
+
void bridge
|
|
9223
|
+
.connect()
|
|
9224
|
+
.then((ok) => {
|
|
7193
9225
|
if (ok)
|
|
7194
9226
|
syncProductHint();
|
|
9227
|
+
})
|
|
9228
|
+
.catch((e) => {
|
|
9229
|
+
console.error("[pam-mcp] startup bridge connect failed:", e);
|
|
7195
9230
|
});
|
|
7196
9231
|
const transport = new StdioServerTransport();
|
|
7197
9232
|
await server.connect(transport);
|
|
@@ -7202,9 +9237,27 @@ main().catch((e) => {
|
|
|
7202
9237
|
console.error("Fatal:", e);
|
|
7203
9238
|
process.exit(1);
|
|
7204
9239
|
});
|
|
7205
|
-
|
|
9240
|
+
// MCP clients terminate stdio servers with SIGTERM as often as SIGINT; without
|
|
9241
|
+
// both, the bridge socket is left for the OS to reap.
|
|
9242
|
+
let shuttingDown = false;
|
|
9243
|
+
const shutdown = async () => {
|
|
9244
|
+
if (shuttingDown)
|
|
9245
|
+
return;
|
|
9246
|
+
shuttingDown = true;
|
|
7206
9247
|
bridge.disconnect();
|
|
7207
|
-
|
|
9248
|
+
try {
|
|
9249
|
+
await server.close();
|
|
9250
|
+
}
|
|
9251
|
+
catch {
|
|
9252
|
+
// Already torn down — exiting anyway.
|
|
9253
|
+
}
|
|
7208
9254
|
process.exit(0);
|
|
7209
|
-
}
|
|
9255
|
+
};
|
|
9256
|
+
process.on("SIGINT", shutdown);
|
|
9257
|
+
process.on("SIGTERM", shutdown);
|
|
9258
|
+
// A client that dies without signalling leaves no signal at all — the process is
|
|
9259
|
+
// reparented to launchd and only stdin EOF says the session is gone. Without this
|
|
9260
|
+
// the server outlives every crashed client; ten-day-old orphans were measured.
|
|
9261
|
+
process.stdin.on("end", () => void shutdown());
|
|
9262
|
+
process.stdin.on("close", () => void shutdown());
|
|
7210
9263
|
//# sourceMappingURL=server.js.map
|