@map-audio/pam-mcp-server 1.18.1 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/dist/drumSynth.d.ts +96 -0
- package/dist/drumSynth.js +193 -0
- package/dist/drumSynth.js.map +1 -0
- package/dist/drumSynthEngines.json +1360 -0
- package/dist/generated/variationSnapshot.d.ts +31 -0
- package/dist/generated/variationSnapshot.js +62 -0
- package/dist/generated/variationSnapshot.js.map +1 -0
- package/dist/manifest-cell1.json +297 -2
- package/dist/manifest-mfx.json +687 -8
- package/dist/manifest.d.ts +12 -0
- package/dist/manifest.js +54 -5
- package/dist/manifest.js.map +1 -1
- package/dist/manifest.json +1509 -35
- package/dist/mixAssist.js +5 -0
- package/dist/mixAssist.js.map +1 -1
- package/dist/server.js +480 -113
- package/dist/server.js.map +1 -1
- package/dist/variationSessionKeys.json +110 -0
- package/package.json +4 -2
package/dist/server.js
CHANGED
|
@@ -3,12 +3,14 @@ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mc
|
|
|
3
3
|
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
|
+
import { buildVariationSnapshotBase } from "./generated/variationSnapshot.js";
|
|
6
7
|
import { readFile } from "node:fs/promises";
|
|
7
8
|
import { existsSync, readFileSync } from "node:fs";
|
|
8
9
|
import { inflateSync } from "node:zlib";
|
|
9
10
|
import { basename, dirname, resolve } from "node:path";
|
|
10
11
|
import { fileURLToPath } from "node:url";
|
|
11
|
-
import {
|
|
12
|
+
import { createDrumSynthCatalog, } from "./drumSynth.js";
|
|
13
|
+
import { loadManifest, cellEffectNames, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, setActiveProductHint, walkPresets, } from "./manifest.js";
|
|
12
14
|
import { PluginBridge } from "./bridge.js";
|
|
13
15
|
import { buildMixMeasurementFromState, currentFlatFromState, parseMixAnalysisPayload, suggestMix, } from "./mixAssist.js";
|
|
14
16
|
import { normalizePresetLookupPath, selectSaveVariationSourcePath, } from "./presetSave.js";
|
|
@@ -207,42 +209,93 @@ async function readExistingVariations(presetPath) {
|
|
|
207
209
|
return Array(PRESET_VARIATION_SLOTS).fill(null);
|
|
208
210
|
}
|
|
209
211
|
}
|
|
210
|
-
|
|
212
|
+
const VARIATION_CONTRACT = (() => {
|
|
213
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
214
|
+
const candidates = [
|
|
215
|
+
resolve(here, "..", "..", "..", "shared", "variationSessionKeys.json"), // repo checkout
|
|
216
|
+
resolve(here, "variationSessionKeys.json"), // published dist/
|
|
217
|
+
];
|
|
218
|
+
for (const file of candidates) {
|
|
219
|
+
if (!existsSync(file))
|
|
220
|
+
continue;
|
|
221
|
+
const contract = JSON.parse(readFileSync(file, "utf8"));
|
|
222
|
+
if (Array.isArray(contract.all) && contract.all.length > 0)
|
|
223
|
+
return contract;
|
|
224
|
+
}
|
|
225
|
+
throw new Error("pam-mcp: variationSessionKeys.json not found. Run `node scripts/generate-variation-session-keys.mjs` " +
|
|
226
|
+
"(repo) or rebuild the package — saving a variation without it would bake session state into the slot.");
|
|
227
|
+
})();
|
|
228
|
+
// `transport` is live host position, reported in state but never variation content.
|
|
211
229
|
const VARIATION_EXCLUDED_PARAMS = [
|
|
212
|
-
"
|
|
213
|
-
"interfaceCache",
|
|
214
|
-
"songMode",
|
|
215
|
-
"volume",
|
|
216
|
-
"hqMode",
|
|
217
|
-
"previewSamplePath",
|
|
218
|
-
"previewCellId",
|
|
219
|
-
"transport",
|
|
220
|
-
"snapshotRateHz",
|
|
221
|
-
"looperCrossfade",
|
|
222
|
-
"looperLength",
|
|
223
|
-
"looperVolume",
|
|
224
|
-
"looperFilter",
|
|
225
|
-
"loopers",
|
|
226
|
-
"looperPlaybackBars",
|
|
227
|
-
"looperTrack1Level",
|
|
228
|
-
"looperTrack2Level",
|
|
229
|
-
"looperTrack3Level",
|
|
230
|
-
"looperTrack4Level",
|
|
231
|
-
"looperTrack5Level",
|
|
232
|
-
"looperTrack6Level",
|
|
233
|
-
"looperTrack7Level",
|
|
234
|
-
"looperTrack8Level",
|
|
230
|
+
...new Set([...(VARIATION_CONTRACT.all ?? []), "transport"]),
|
|
235
231
|
];
|
|
236
232
|
/**
|
|
237
|
-
* Build a variation snapshot string from plugin state
|
|
233
|
+
* Build a variation snapshot string from plugin state.
|
|
234
|
+
*
|
|
235
|
+
* The exclusion set comes from the generated contract, so this matches what a
|
|
236
|
+
* UI save strips. It does NOT match a UI save in one respect: interop.ts awaits
|
|
237
|
+
* `refreshLoadedPluginStateBlobs()` first and stamps each hosted plugin's
|
|
238
|
+
* identity and bypass from `pluginHostStore` — data that lives in the WebView,
|
|
239
|
+
* not in the headless bridge's state. `hostedPluginCells` reports the cells
|
|
240
|
+
* where that difference bites so callers are told rather than silently handed a
|
|
241
|
+
* lossy slot.
|
|
242
|
+
*
|
|
243
|
+
* This path is the FALLBACK. A PAM build carrying the `getSaveSnapshot` action
|
|
244
|
+
* hands back the native canonical snapshot (hosted-plugin state included) and
|
|
245
|
+
* never reaches here.
|
|
238
246
|
*/
|
|
239
247
|
function buildVariationString(state) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
248
|
+
return JSON.stringify(buildVariationSnapshotBase(state, {
|
|
249
|
+
sessionKeys: VARIATION_EXCLUDED_PARAMS,
|
|
250
|
+
visualParamIds: VARIATION_CONTRACT.visualParamIds ?? [],
|
|
251
|
+
lfoGeneratorCount: VARIATION_CONTRACT.lfoGeneratorCount ?? 6,
|
|
252
|
+
}));
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* The canonical save-time state — engine state PLUS every live hosted plugin's
|
|
256
|
+
* identity, bypass and state blob (PluginProcessor::buildCanonicalSaveSnapshot).
|
|
257
|
+
*
|
|
258
|
+
* `getState` returns the live map, which carries no hosted-plugin blob, so a
|
|
259
|
+
* variation built from it silently dropped the hosted patch (A1). Falls back to
|
|
260
|
+
* `getState` on a PAM build that predates the action, and reports the fallback
|
|
261
|
+
* so the save result can say the snapshot is lossy.
|
|
262
|
+
*/
|
|
263
|
+
async function fetchSaveSnapshot() {
|
|
264
|
+
try {
|
|
265
|
+
const snap = await bridge.send("getSaveSnapshot");
|
|
266
|
+
if (snap && typeof snap === "object") {
|
|
267
|
+
return { state: snap, canonical: true };
|
|
268
|
+
}
|
|
243
269
|
}
|
|
244
|
-
|
|
245
|
-
|
|
270
|
+
catch {
|
|
271
|
+
// Older PAM: action not registered. Fall through.
|
|
272
|
+
}
|
|
273
|
+
const state = (await bridge.send("getState"));
|
|
274
|
+
return { state, canonical: false };
|
|
275
|
+
}
|
|
276
|
+
/** Cells whose hosted plugin this snapshot cannot fully capture. */
|
|
277
|
+
function hostedPluginCells(state) {
|
|
278
|
+
const cells = new Set();
|
|
279
|
+
for (const key of Object.keys(state)) {
|
|
280
|
+
const m = /^cell(\d+)_plugin(?:Fx)?Path$/.exec(key);
|
|
281
|
+
if (m && state[key])
|
|
282
|
+
cells.add(Number(m[1]));
|
|
283
|
+
}
|
|
284
|
+
return [...cells].sort((a, b) => a - b);
|
|
285
|
+
}
|
|
286
|
+
/** Appended to a save result when the snapshot is known to be lossy. */
|
|
287
|
+
function hostedPluginSaveWarning(state, canonical = false) {
|
|
288
|
+
// The canonical snapshot stamps hosted-plugin state on the message thread, so
|
|
289
|
+
// there is nothing to warn about.
|
|
290
|
+
if (canonical)
|
|
291
|
+
return "";
|
|
292
|
+
const cells = hostedPluginCells(state);
|
|
293
|
+
if (!cells.length)
|
|
294
|
+
return "";
|
|
295
|
+
return (`\n\n⚠️ Cell${cells.length > 1 ? "s" : ""} ${cells.join(", ")} host${cells.length > 1 ? "" : "s"} a plugin. ` +
|
|
296
|
+
"This save captured the plugin's last-known state, not its live one — the bridge cannot refresh a " +
|
|
297
|
+
"hosted plugin's state blob (that runs in the plugin UI). Re-save from the PAM window to capture " +
|
|
298
|
+
"current hosted-plugin settings.");
|
|
246
299
|
}
|
|
247
300
|
function formatParam(p) {
|
|
248
301
|
let desc = `${p.paramId}: ${p.displayName}`;
|
|
@@ -263,7 +316,14 @@ function formatParam(p) {
|
|
|
263
316
|
return desc;
|
|
264
317
|
}
|
|
265
318
|
const FULL_STATE_RESPONSE_LIMIT_CHARS = 24_000;
|
|
266
|
-
|
|
319
|
+
// Cell effects are derived per product from the manifest (`cellEffectNames`) —
|
|
320
|
+
// this constant is only the fallback for a manifest that failed to load. The
|
|
321
|
+
// hardcoded eight it used to be is why get_design_overview under-reported:
|
|
322
|
+
// limiter, qverb, eq8, panner, gain, vocoder (and MFX's wavefold/resonator)
|
|
323
|
+
// were enabled in state but absent from `enabledEffects`.
|
|
324
|
+
const CELL_EFFECTS_FALLBACK = [
|
|
325
|
+
"filter", "delay", "crusher", "flanger", "eq", "fdn", "gate", "compressor",
|
|
326
|
+
];
|
|
267
327
|
const PATTERN_LANES = {
|
|
268
328
|
note: "midiSequence",
|
|
269
329
|
pitch: "pitchSequence",
|
|
@@ -480,7 +540,8 @@ const server = new McpServer({
|
|
|
480
540
|
"paths from list_samples. Transport must be playing for sequencer output. " +
|
|
481
541
|
"To enable experimental features (plugin host, AI engines) call update_settings with both " +
|
|
482
542
|
"experimentalMode:true AND the specific sub-flag (e.g. experimentalPluginHost:true). " +
|
|
483
|
-
"The
|
|
543
|
+
"The standard Drum Synth UI follows drumSynthEnabled (default true); this preference does not mute synth audio. " +
|
|
544
|
+
"set_mfx_routing is the MFX cable patchbay and errors on PAM/Cell1; set_audio_device is standalone-only. " +
|
|
484
545
|
"Tools added in server version 1.7.0 — update_settings, configure_transport, " +
|
|
485
546
|
"get_audio_device_setup, set_midi_io, set_midi_mapping, delete_preset, " +
|
|
486
547
|
"set_preset_factory_status, get_sample_usage, repair_preset_samples, preview_sample, " +
|
|
@@ -644,6 +705,8 @@ const TOOL_ANNOTATIONS = {
|
|
|
644
705
|
export_audio: W,
|
|
645
706
|
// Settings / devices
|
|
646
707
|
update_settings: WI,
|
|
708
|
+
set_audio_device: WI,
|
|
709
|
+
set_mfx_routing: WI,
|
|
647
710
|
set_midi_io: WI,
|
|
648
711
|
set_midi_mapping: WI,
|
|
649
712
|
// Plugin host
|
|
@@ -1132,6 +1195,9 @@ server.registerTool("get_design_overview", {
|
|
|
1132
1195
|
]);
|
|
1133
1196
|
const state = isRecord(stateRaw) ? stateRaw : {};
|
|
1134
1197
|
const transport = isRecord(transportRaw) ? transportRaw : {};
|
|
1198
|
+
// Product-aware: MFX carries wavefold/resonator, PAM/Cell1 do not.
|
|
1199
|
+
const derivedEffects = cellEffectNames(manifest);
|
|
1200
|
+
const effectNames = derivedEffects.length ? derivedEffects : CELL_EFFECTS_FALLBACK;
|
|
1135
1201
|
const manifestCellIds = [...new Set(manifest.parameters
|
|
1136
1202
|
.map((p) => p.cell)
|
|
1137
1203
|
.filter((cell) => typeof cell === "string" && /^cell\d+$/.test(cell))
|
|
@@ -1162,8 +1228,9 @@ server.registerTool("get_design_overview", {
|
|
|
1162
1228
|
name: stemName(samplePath),
|
|
1163
1229
|
path: typeof samplePath === "string" ? samplePath : undefined,
|
|
1164
1230
|
},
|
|
1165
|
-
|
|
1166
|
-
|
|
1231
|
+
// Both halves: the enum an agent writes back, and the name it reasons about.
|
|
1232
|
+
synthEngine: describeSynthEngine(state[`${prefix}synthEngine`]),
|
|
1233
|
+
enabledEffects: effectNames.filter((effectName) => state[`${prefix}${effectName}Enabled`] === 1 || state[`${prefix}${effectName}Enabled`] === true),
|
|
1167
1234
|
patternEvents: Object.fromEntries(Object.entries(PATTERN_LANES).map(([lane, suffix]) => [
|
|
1168
1235
|
lane,
|
|
1169
1236
|
countEvents(state[`${prefix}${suffix}`]),
|
|
@@ -1653,11 +1720,11 @@ server.registerTool("add_modulation", {
|
|
|
1653
1720
|
.boolean()
|
|
1654
1721
|
.optional()
|
|
1655
1722
|
.describe("Envelope only: route from the cell's pitch-tracked source instead of amp. " +
|
|
1656
|
-
"false (default) = AMP; true & pitchDuck=false =
|
|
1723
|
+
"false (default) = AMP; true & pitchDuck=false = PITCH (comb); true & pitchDuck=true = DUCK."),
|
|
1657
1724
|
pitchDuck: z
|
|
1658
1725
|
.boolean()
|
|
1659
1726
|
.optional()
|
|
1660
|
-
.describe("Envelope only: when pitch=true, switch from
|
|
1727
|
+
.describe("Envelope only: when pitch=true, switch from PITCH (comb) to DUCK semantics (notch). Ignored if pitch=false."),
|
|
1661
1728
|
},
|
|
1662
1729
|
}, async ({ type, paramId, cellId, lfoIndex, strength, bipolar, pitch, pitchDuck }) => {
|
|
1663
1730
|
const index = await getParameterIndex();
|
|
@@ -2103,7 +2170,7 @@ server.registerTool("save_preset", {
|
|
|
2103
2170
|
const check = await requireBridge();
|
|
2104
2171
|
if (check !== true)
|
|
2105
2172
|
return check;
|
|
2106
|
-
const state =
|
|
2173
|
+
const { state, canonical } = await fetchSaveSnapshot();
|
|
2107
2174
|
// Bare save (no name): the UI Save button's semantics — overwrite the
|
|
2108
2175
|
// ACTIVE user preset in place, preserving its on-disk metadata. Mirrors
|
|
2109
2176
|
// the in-plugin assistant executor (error strings included — the model's
|
|
@@ -2168,7 +2235,8 @@ server.registerTool("save_preset", {
|
|
|
2168
2235
|
const result = (await bridge.send("savePreset", metadata));
|
|
2169
2236
|
lastSavedPresetName = name;
|
|
2170
2237
|
lastSavedPresetPath = result?.path || readPath;
|
|
2171
|
-
return text(`Saved preset: ${name}\nPath: ${result?.path || "(unknown path)"}`
|
|
2238
|
+
return text(`Saved preset: ${name}\nPath: ${result?.path || "(unknown path)"}` +
|
|
2239
|
+
hostedPluginSaveWarning(state, canonical));
|
|
2172
2240
|
});
|
|
2173
2241
|
server.registerTool("save_variation", {
|
|
2174
2242
|
title: "Save Variation",
|
|
@@ -2201,8 +2269,8 @@ server.registerTool("save_variation", {
|
|
|
2201
2269
|
const check = await requireBridge();
|
|
2202
2270
|
if (check !== true)
|
|
2203
2271
|
return check;
|
|
2204
|
-
// 1.
|
|
2205
|
-
const state =
|
|
2272
|
+
// 1. Canonical save snapshot (engine + hosted-plugin state), then shape it
|
|
2273
|
+
const { state, canonical } = await fetchSaveSnapshot();
|
|
2206
2274
|
const variationString = buildVariationString(state);
|
|
2207
2275
|
// 2. Determine preset name and disk path — needed to read existing variations and metadata
|
|
2208
2276
|
let presetName = name;
|
|
@@ -2267,7 +2335,8 @@ server.registerTool("save_variation", {
|
|
|
2267
2335
|
return text(`Saved variation ${index} for "${presetName}"\n` +
|
|
2268
2336
|
`Path: ${result?.path || "(unknown path)"}\n` +
|
|
2269
2337
|
`Filled slots: ${variations.filter((v) => v !== null).length}/8` +
|
|
2270
|
-
resultSuffix
|
|
2338
|
+
resultSuffix +
|
|
2339
|
+
hostedPluginSaveWarning(state, canonical));
|
|
2271
2340
|
});
|
|
2272
2341
|
server.registerTool("update_preset_metadata", {
|
|
2273
2342
|
title: "Update Preset Metadata",
|
|
@@ -2896,6 +2965,10 @@ server.registerTool("video_command", {
|
|
|
2896
2965
|
"moveLayer{layerId,toIndex}, duplicateLayer{layerId}, renameLayer{layerId,name}, " +
|
|
2897
2966
|
"setLayerEnabled{layerId,enabled?}, toggleSolo{layerId}, selectLayer{layerId}, " +
|
|
2898
2967
|
"resetLayer{layerId}, applyLayout{preset}, randomizeLayer{layerId}; " +
|
|
2968
|
+
"setShaderSource{layerId,source} (GLSL fragment for a 'shader' layer; auto-parses " +
|
|
2969
|
+
"'// @param uP_x 0..1 = 0.5 \"Label\"' rows into sliders/bind targets; read " +
|
|
2970
|
+
"layers[].shaderCompile in get_video_state for the compile ok/infoLog and repair), " +
|
|
2971
|
+
"setShaderParam{layerId,paramKey,value}; " +
|
|
2899
2972
|
"addLayerFx{layerId,fxType,params?,beforeFxId?}, removeLayerFx{layerId,fxId}, " +
|
|
2900
2973
|
"removeAllLayerFx{layerId}, setLayerFxParam{layerId,fxId,param,value|params}, " +
|
|
2901
2974
|
"setLayerFxEnabled{layerId,fxId,enabled?}, reorderLayerFx{layerId,fxIds}, " +
|
|
@@ -2938,8 +3011,8 @@ server.registerTool("add_video_layer", {
|
|
|
2938
3011
|
"update_video_layer and stack FX with add_video_fx.",
|
|
2939
3012
|
inputSchema: {
|
|
2940
3013
|
kind: z
|
|
2941
|
-
.enum(["video", "image", "camera", "text", "drawing", "model3d", "adjustment"])
|
|
2942
|
-
.describe("Layer kind."),
|
|
3014
|
+
.enum(["video", "image", "camera", "text", "drawing", "model3d", "shader", "adjustment"])
|
|
3015
|
+
.describe("Layer kind. 'shader' is a user-GLSL fragment layer — write its source with video_command {op:'setShaderSource'}."),
|
|
2943
3016
|
name: z.string().optional().describe("Display name (auto-generated if omitted)."),
|
|
2944
3017
|
insertAt: z
|
|
2945
3018
|
.number()
|
|
@@ -3578,7 +3651,8 @@ server.registerTool("reset_patterns", {
|
|
|
3578
3651
|
});
|
|
3579
3652
|
server.registerTool("update_effect_config", {
|
|
3580
3653
|
title: "Update Effect Config",
|
|
3581
|
-
description: "Toggle cell effects on/off (filter, delay, crusher, flanger, eq, fdn, gate, compressor). " +
|
|
3654
|
+
description: "Toggle cell effects on/off (filter, delay, crusher, flanger, eq, eq8, fdn, gate, compressor, panner, gain, vocoder, limiter, qverb, wavefold, resonator). " +
|
|
3655
|
+
"wavefold and resonator exist on MFX only. " +
|
|
3582
3656
|
"Uses the lighter KV path that does not rebuild the audio graph. Pass 1 to enable, 0 to disable.",
|
|
3583
3657
|
inputSchema: {
|
|
3584
3658
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
@@ -3590,6 +3664,20 @@ server.registerTool("update_effect_config", {
|
|
|
3590
3664
|
fdnEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3591
3665
|
gateEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3592
3666
|
compressorEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3667
|
+
// The page-2 effects. These ship on PAM/Cell1 too, so leaving them out of the
|
|
3668
|
+
// schema made them unreachable over the bridge even though the handler is
|
|
3669
|
+
// generic (it just builds `cell{N}_{key}`) and the state vars exist in
|
|
3670
|
+
// build.js alongside the eight above.
|
|
3671
|
+
eq8Enabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3672
|
+
pannerEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3673
|
+
gainEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3674
|
+
vocoderEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3675
|
+
limiterEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3676
|
+
qverbEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3677
|
+
// MFX-only page-2 signature effects; harmless no-ops on PAM/Cell1, whose
|
|
3678
|
+
// manifests carry no cellN_wavefoldEnabled / cellN_resonatorEnabled key.
|
|
3679
|
+
wavefoldEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3680
|
+
resonatorEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3593
3681
|
},
|
|
3594
3682
|
}, async ({ cellId, ...config }) => {
|
|
3595
3683
|
const check = await requireBridge();
|
|
@@ -3613,6 +3701,88 @@ server.registerTool("update_effect_config", {
|
|
|
3613
3701
|
.join(", ");
|
|
3614
3702
|
return text(`Updated effect config for cell ${cellId}: ${summary}`);
|
|
3615
3703
|
});
|
|
3704
|
+
// MFX cable patchbay. `mfx_routing` is a non-numeric state variable, so it was
|
|
3705
|
+
// unreachable through set_parameters (numbers only) — an agent could shape an MFX
|
|
3706
|
+
// cell but never patch one to another, which is the whole product.
|
|
3707
|
+
//
|
|
3708
|
+
// Validation mirrors parseRoutingEdges (interface/src/dsp/mfxRouting.ts): the
|
|
3709
|
+
// plugin re-validates on receipt, so this pass exists to fail loudly here rather
|
|
3710
|
+
// than have a typo silently drop a cable.
|
|
3711
|
+
const MFX_ROUTING_SOURCE_HINT = "'input', 'sidechain', or 'cellN'. 'output' is a destination only.";
|
|
3712
|
+
const MFX_ROUTING_SINK_HINT = "'output' or 'cellN'. 'input'/'sidechain' are sources only.";
|
|
3713
|
+
function mfxPortCell(port) {
|
|
3714
|
+
const m = /^cell([1-9]\d*)$/.exec(port); // canonical form only — 'cell01' is not a port
|
|
3715
|
+
return m ? Number(m[1]) : null;
|
|
3716
|
+
}
|
|
3717
|
+
function validateMfxEdges(edges, maxCells) {
|
|
3718
|
+
const seen = new Set();
|
|
3719
|
+
const out = [];
|
|
3720
|
+
const inRange = (port) => {
|
|
3721
|
+
const c = mfxPortCell(port);
|
|
3722
|
+
return c !== null && c >= 1 && c <= maxCells;
|
|
3723
|
+
};
|
|
3724
|
+
for (const { from, to } of edges) {
|
|
3725
|
+
if (from !== "input" && from !== "sidechain" && !inRange(from)) {
|
|
3726
|
+
return { ok: false, reason: `invalid source port '${from}' — expected ${MFX_ROUTING_SOURCE_HINT}` };
|
|
3727
|
+
}
|
|
3728
|
+
if (to !== "output" && !inRange(to)) {
|
|
3729
|
+
return { ok: false, reason: `invalid destination port '${to}' — expected ${MFX_ROUTING_SINK_HINT}` };
|
|
3730
|
+
}
|
|
3731
|
+
const key = `${from}->${to}`;
|
|
3732
|
+
if (seen.has(key))
|
|
3733
|
+
continue; // dedupe, matching the plugin
|
|
3734
|
+
seen.add(key);
|
|
3735
|
+
out.push({ from, to });
|
|
3736
|
+
}
|
|
3737
|
+
return { ok: true, edges: out };
|
|
3738
|
+
}
|
|
3739
|
+
server.registerTool("set_mfx_routing", {
|
|
3740
|
+
title: "Set MFX Cable Routing",
|
|
3741
|
+
description: "Read or replace the MFX cable patch — the graph that decides which cell feeds which. MFX-only; on PAM/Cell1 " +
|
|
3742
|
+
"this returns a product error.\n\n" +
|
|
3743
|
+
"Ports: 'input' (the host's audio in) and 'sidechain' are sources; 'output' is the destination; 'cellN' is both. " +
|
|
3744
|
+
"A self-edge cellN→cellN is a feedback loop, and input→output is a direct dry path. Duplicate cables are dropped.\n\n" +
|
|
3745
|
+
"A plain serial chain on 4 cells is input→cell1, cell1→cell2, cell2→cell3, cell3→cell4, cell4→output. " +
|
|
3746
|
+
"Parallel is input→each cell plus each cell→output.\n\n" +
|
|
3747
|
+
"'set' REPLACES the whole patch, so send the full cable list. Per-cable levels live in mfx_edge_gains and are " +
|
|
3748
|
+
"left alone; a new cable takes the graph's own default (unity forward, 0.5 on a back or self edge). " +
|
|
3749
|
+
"Re-patching rebuilds the audio graph, like reordering FX.",
|
|
3750
|
+
inputSchema: {
|
|
3751
|
+
op: z.enum(["get", "set"]).describe("'get' reads the current cables; 'set' replaces them."),
|
|
3752
|
+
edges: z
|
|
3753
|
+
.array(z.object({
|
|
3754
|
+
from: z.string().describe(`Source port: ${MFX_ROUTING_SOURCE_HINT}`),
|
|
3755
|
+
to: z.string().describe(`Destination port: ${MFX_ROUTING_SINK_HINT}`),
|
|
3756
|
+
}))
|
|
3757
|
+
.optional()
|
|
3758
|
+
.describe("Full cable list for 'set'. An empty array clears the patch (silence)."),
|
|
3759
|
+
},
|
|
3760
|
+
annotations: { destructiveHint: false, idempotentHint: true },
|
|
3761
|
+
}, async ({ op, edges }) => {
|
|
3762
|
+
const check = await requireBridge();
|
|
3763
|
+
if (check !== true)
|
|
3764
|
+
return check;
|
|
3765
|
+
const manifest = await loadManifestForProductGuard();
|
|
3766
|
+
if (manifest && !isMfxManifest(manifest)) {
|
|
3767
|
+
return error(`set_mfx_routing is MFX-only. ${manifest.name ?? "This product"} has no cable patchbay — ` +
|
|
3768
|
+
"its cells run in a fixed per-cell chain.");
|
|
3769
|
+
}
|
|
3770
|
+
if (op === "get") {
|
|
3771
|
+
const raw = await bridge.send("getState", "mfx_routing");
|
|
3772
|
+
return json(isRecord(raw) ? raw.mfx_routing ?? raw : raw);
|
|
3773
|
+
}
|
|
3774
|
+
if (!edges)
|
|
3775
|
+
return error("op 'set' requires an `edges` array (pass [] to clear the patch).");
|
|
3776
|
+
const maxCells = manifestMaxCellId(manifest) ?? 4;
|
|
3777
|
+
const validated = validateMfxEdges(edges, maxCells);
|
|
3778
|
+
if (!validated.ok) {
|
|
3779
|
+
return error(`${validated.reason}. This product exposes cells 1..${maxCells}.`);
|
|
3780
|
+
}
|
|
3781
|
+
// mfx_routing is a GRAPH_RENDER state var — a re-patch must rebuild the graph.
|
|
3782
|
+
await applyKv({ mfx_routing: validated.edges }, KV_OPTS_STANDARD);
|
|
3783
|
+
const summary = validated.edges.map((e) => `${e.from}→${e.to}`).join(", ");
|
|
3784
|
+
return text(`MFX routing set: ${validated.edges.length} cable(s)${summary ? ` — ${summary}` : " (patch cleared)"}.`);
|
|
3785
|
+
});
|
|
3616
3786
|
server.registerTool("update_cell_config", {
|
|
3617
3787
|
title: "Update Cell Config",
|
|
3618
3788
|
description: "Update cell-level configuration properties like polyphony, MIDI settings, varispeed, and modulation latch. " +
|
|
@@ -3640,7 +3810,24 @@ server.registerTool("update_cell_config", {
|
|
|
3640
3810
|
.max(2)
|
|
3641
3811
|
.optional()
|
|
3642
3812
|
.describe("Slice playback length when sliceKeymapMode=1: 0=Boundary (stop at the next slice edge), 1=Sample End (play to end of sample), 2=Note Off (sustain until key release; requires the cell to be in Gate mode)."),
|
|
3643
|
-
scaleName: z
|
|
3813
|
+
scaleName: z
|
|
3814
|
+
.enum([
|
|
3815
|
+
"chromatic",
|
|
3816
|
+
"major",
|
|
3817
|
+
"minor",
|
|
3818
|
+
"pentatonicMajor",
|
|
3819
|
+
"pentatonicMinor",
|
|
3820
|
+
"dorian",
|
|
3821
|
+
"mixolydian",
|
|
3822
|
+
"blues",
|
|
3823
|
+
"harmonicMinor",
|
|
3824
|
+
"phrygian",
|
|
3825
|
+
"lydian",
|
|
3826
|
+
"locrian",
|
|
3827
|
+
])
|
|
3828
|
+
.optional()
|
|
3829
|
+
.describe("Scale name for pitch sequencer / note quantization ('chromatic' = no filter). " +
|
|
3830
|
+
"An out-of-vocabulary string would silently play as chromatic while junk persists into the preset, so the name is validated."),
|
|
3644
3831
|
scaleRoot: z.number().min(0).max(11).optional().describe("Root note of the scale (0=C, 1=C#, ..., 11=B)."),
|
|
3645
3832
|
pitchMode: z.number().int().min(0).max(3).optional().describe("Pitch engine: 0=Varispeed, 1=Formant, 2=Pitch Shift Quantize, 3=Varispeed Quantize."),
|
|
3646
3833
|
stretchMode: z.number().int().min(0).max(3).optional().describe("Stretch engine: 0=OLA, 1=Cloud, 2=Phase Vocoder, 3=GrainRate."),
|
|
@@ -3664,22 +3851,35 @@ server.registerTool("update_cell_config", {
|
|
|
3664
3851
|
const cellCheck = validateCellIdForManifest(cellId, manifest);
|
|
3665
3852
|
if (cellCheck !== true)
|
|
3666
3853
|
return cellCheck;
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3854
|
+
// These four are DAW params (cell{N}_quantScale, …), not cell-config keys —
|
|
3855
|
+
// nothing consumes them on the cells{} config channel (the sampler reads
|
|
3856
|
+
// the param signal inputs), so a cells{} write was a silent no-op that
|
|
3857
|
+
// also persisted junk keys into the preset. Route them to flat param keys,
|
|
3858
|
+
// which applyKv maps onto the JUCE params (updateParameters: true).
|
|
3859
|
+
const PARAM_ROUTED_KEYS = new Set(["quantScale", "quantRoot", "spread", "chaos"]);
|
|
3860
|
+
const providedKeys = Object.entries(config)
|
|
3861
|
+
.filter(([, value]) => value !== undefined)
|
|
3862
|
+
.map(([key]) => key);
|
|
3863
|
+
if (providedKeys.length === 0) {
|
|
3674
3864
|
return error("At least one config property must be provided.");
|
|
3675
3865
|
}
|
|
3676
3866
|
if (manifest && isMfxManifest(manifest)) {
|
|
3677
|
-
const unsupported =
|
|
3867
|
+
const unsupported = providedKeys.filter((key) => !MFX_CELL_CONFIG_KEYS.has(key));
|
|
3678
3868
|
if (unsupported.length > 0) {
|
|
3679
3869
|
return error("update_cell_config in MFX only accepts sidechainFrom. " +
|
|
3680
3870
|
`Sampler/MIDI/instrument config keys are PAM/Cell1-only: ${unsupported.join(", ")}.`);
|
|
3681
3871
|
}
|
|
3682
3872
|
}
|
|
3873
|
+
const cellConfig = {};
|
|
3874
|
+
const flatDelta = {};
|
|
3875
|
+
for (const [key, value] of Object.entries(config)) {
|
|
3876
|
+
if (value === undefined)
|
|
3877
|
+
continue;
|
|
3878
|
+
if (PARAM_ROUTED_KEYS.has(key))
|
|
3879
|
+
flatDelta[`cell${cellId}_${key}`] = value;
|
|
3880
|
+
else
|
|
3881
|
+
cellConfig[key] = value;
|
|
3882
|
+
}
|
|
3683
3883
|
// Auto-enable compressor when setting sidechain source
|
|
3684
3884
|
if (cellConfig.sidechainFrom !== undefined && cellConfig.sidechainFrom !== null) {
|
|
3685
3885
|
flatDelta[`cell${cellId}_compressorEnabled`] = 1;
|
|
@@ -3694,11 +3894,14 @@ server.registerTool("update_cell_config", {
|
|
|
3694
3894
|
cellModeChangeSeq[cellId] = next;
|
|
3695
3895
|
cellConfig.modeChangeSeq = next;
|
|
3696
3896
|
}
|
|
3697
|
-
const delta = {
|
|
3897
|
+
const delta = { ...flatDelta };
|
|
3898
|
+
if (Object.keys(cellConfig).length > 0)
|
|
3899
|
+
delta.cells = { [cellId]: cellConfig };
|
|
3698
3900
|
await applyKv(delta, KV_OPTS_CELL_CONFIG);
|
|
3699
|
-
const summary =
|
|
3700
|
-
.map(([k, v]) => `${k}=${v}`)
|
|
3701
|
-
.
|
|
3901
|
+
const summary = [
|
|
3902
|
+
...Object.entries(cellConfig).map(([k, v]) => `${k}=${v}`),
|
|
3903
|
+
...Object.entries(flatDelta).map(([k, v]) => `${k}=${v}`),
|
|
3904
|
+
].join(", ");
|
|
3702
3905
|
return text(`Updated cell ${cellId} config: ${summary}`);
|
|
3703
3906
|
});
|
|
3704
3907
|
server.registerTool("set_cell_mute", {
|
|
@@ -3752,7 +3955,8 @@ server.registerTool("set_master_effects", {
|
|
|
3752
3955
|
"medium release (80-200ms), ratio 4-8, autoMakeup true. " +
|
|
3753
3956
|
"For lo-fi/Memphis/dusty glue: tapeEnabled=1, tapeMode=1 (Lo-Fi), masterTapeDrive 0.55-0.7, " +
|
|
3754
3957
|
"masterTapeTone -0.4..-0.1 (darker), masterTapeWear 0.5-0.7 (wow/flutter). " +
|
|
3755
|
-
"
|
|
3958
|
+
"Squeeze (masterSqueeze*) is the 3-band up/down density stage between Punch and Tape — for dense modern-electronic masters set masterSqueezeEnabled=1 with masterSqueezeDepth 0.2-0.4; it lifts quiet detail and limits loud bands per band (a character stage, not glue — Punch stays the glue). " +
|
|
3959
|
+
"NOTE: the Tape and Squeeze stages require a PAM build that ships them (flat state keys, not manifest params). On older builds these keys are ignored.",
|
|
3756
3960
|
inputSchema: {
|
|
3757
3961
|
compressorEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable Punch compressor (0=off, 1=on)."),
|
|
3758
3962
|
limiterEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable master Limiter (0=off, 1=on)."),
|
|
@@ -3792,6 +3996,15 @@ server.registerTool("set_master_effects", {
|
|
|
3792
3996
|
masterIronDrive: z.number().min(0).max(12).optional().describe("Iron drive dB (default 0). Level-compensated — drive adds colour, not loudness."),
|
|
3793
3997
|
masterIronAmount: z.number().min(0).max(1).optional().describe("Transformer (magnetizing-branch) depth 0..1 — LF-concentrated saturation, the 'iron' signature (default 0.5)."),
|
|
3794
3998
|
masterIronMode: z.union([z.literal(0), z.literal(1)]).optional().describe("0=Console (amp-forward, subtle), 1=Transformer (heavier LF iron)."),
|
|
3999
|
+
masterSqueezeEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable Squeeze — a 3-band upward+downward compressor (post-Punch, pre-Tape; zero latency). The character (88/2500 Hz splits, thresholds, ratios) is baked; Depth is the main lever."),
|
|
4000
|
+
masterSqueezeDepth: z.number().min(0).max(1).optional().describe("Squeeze depth 0..1 (default 0.3): scales the applied per-band gain toward the full character — a density control, NOT a dry/wet. 0.15-0.3 densifies a master; 0.6+ is the aggressive pumped sound."),
|
|
4001
|
+
masterSqueezeTime: z.number().min(0.1).max(10).optional().describe("Squeeze time multiplier on all band attack+release (0.1 fast .. 10 slow, default 1)."),
|
|
4002
|
+
masterSqueezeTone: z.number().min(-1).max(1).optional().describe("Squeeze tone: bipolar low<->high band tilt, ±6 dB at the extremes (default 0)."),
|
|
4003
|
+
masterSqueezeInGain: z.number().min(-12).max(12).optional().describe("Squeeze input gain dB (default 0) — drives program into the fixed thresholds (more In = more of everything)."),
|
|
4004
|
+
masterSqueezeOutTrim: z.number().min(-12).max(12).optional().describe("Squeeze output trim dB (default 0)."),
|
|
4005
|
+
masterSqueezeUpAmount: z.number().min(0).max(1.5).optional().describe("Squeeze upward-branch trim 0..1.5 (default 1). 0 disables the upward lift (downward-only multiband)."),
|
|
4006
|
+
masterSqueezeDownAmount: z.number().min(0).max(1.5).optional().describe("Squeeze downward-branch trim 0..1.5 (default 1). 0 disables the downward limiting (pure upward lift)."),
|
|
4007
|
+
masterSqueezeUpCapDb: z.number().min(0).max(24).optional().describe("Squeeze upward max-boost clamp dB (default 12) — bounds how far quiet material (tails, noise floor) is lifted."),
|
|
3795
4008
|
masterLoudnessAutoReset: z.union([z.literal(0), z.literal(1)]).optional().describe("Loudness meter auto-reset on host transport start (default 1). 0 keeps measurements running across transport stops."),
|
|
3796
4009
|
masterEqBells: z
|
|
3797
4010
|
.array(z.object({
|
|
@@ -3857,38 +4070,50 @@ server.registerTool("set_master_effects", {
|
|
|
3857
4070
|
return text(`Updated master effects: ${summary}`);
|
|
3858
4071
|
});
|
|
3859
4072
|
// Built-in per-cell drum synth ("machines"). Engine enum index = drumsynth::Engine
|
|
3860
|
-
// (Off = 0).
|
|
3861
|
-
//
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
4073
|
+
// (Off = 0). The roster, the curated defaults and the engine-switch rule all come
|
|
4074
|
+
// from shared/drumSynthEngines.json — the registry build.js codegens the C++ cook
|
|
4075
|
+
// and the manifest from, and interface/src/dsp/modules/drumSynth.ts derives its
|
|
4076
|
+
// own tables from. The hand-copied table this replaced had drifted on Kick,
|
|
4077
|
+
// Cowbell and 808 (issue #999); the switch rule it shipped with carried knob
|
|
4078
|
+
// positions where the plugin carries engine units.
|
|
4079
|
+
const DRUM_SYNTH_REGISTRY = (() => {
|
|
4080
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
4081
|
+
const candidates = [
|
|
4082
|
+
resolve(here, "..", "..", "..", "shared", "drumSynthEngines.json"), // repo checkout
|
|
4083
|
+
resolve(here, "drumSynthEngines.json"), // published dist/
|
|
4084
|
+
];
|
|
4085
|
+
for (const file of candidates) {
|
|
4086
|
+
if (!existsSync(file))
|
|
4087
|
+
continue;
|
|
4088
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
4089
|
+
if (parsed.engines && Object.keys(parsed.engines).length > 0)
|
|
4090
|
+
return parsed;
|
|
4091
|
+
}
|
|
4092
|
+
throw new Error("pam-mcp: drumSynthEngines.json not found. Rebuild the package (scripts/build-postprocess.mjs " +
|
|
4093
|
+
"vendors it into dist/) — seeding a drum engine without it would write defaults that disagree " +
|
|
4094
|
+
"with the plugin.");
|
|
4095
|
+
})();
|
|
4096
|
+
const drumSynth = createDrumSynthCatalog(DRUM_SYNTH_REGISTRY);
|
|
4097
|
+
const DRUM_SYNTH_ENGINE_NAMES = drumSynth.engineNames;
|
|
4098
|
+
/** State carries the bare enum; agents reason about the name. Report both. */
|
|
4099
|
+
function describeSynthEngine(raw) {
|
|
4100
|
+
const id = Math.round(Number(raw ?? 0));
|
|
4101
|
+
return { id, name: DRUM_SYNTH_ENGINE_NAMES[id] ?? "Off" };
|
|
4102
|
+
}
|
|
3882
4103
|
server.registerTool("set_drum_synth", {
|
|
3883
4104
|
title: "Set Drum Synth (built-in machine)",
|
|
3884
4105
|
description: "Select and shape a cell's built-in drum-synth 'machine' — a real-time synthesized voice that sums into the cell " +
|
|
3885
|
-
|
|
3886
|
-
"
|
|
4106
|
+
`alongside (or instead of) its sample. ${DRUM_SYNTH_ENGINE_NAMES.length} engines: ${DRUM_SYNTH_ENGINE_NAMES.join(", ")}. ` +
|
|
4107
|
+
"The 808 engine is a tuned long-glide sub that plays as a chromatic " +
|
|
3887
4108
|
"bassline (drive it with a pitch lane + pitch-quantize) — ideal for 808 sub-bass, trap, Memphis. Use a cell with NO " +
|
|
3888
4109
|
"sample for a pure synth voice, or layer over a sample. Each engine reinterprets the 8 generic params (p1..p8); pass " +
|
|
3889
|
-
"only the ones you want to nuance
|
|
3890
|
-
|
|
3891
|
-
"
|
|
4110
|
+
"only the ones you want to nuance. First activation seeds every slot from the engine's musical defaults. On a switch " +
|
|
4111
|
+
"between two active engines, only slots you had moved off their default travel, and they carry the engine VALUE " +
|
|
4112
|
+
"(re-mapped through the destination's own range), so Kick Decay 0.41 becomes Stab Decay 0.336 — the same 198 ms. " +
|
|
4113
|
+
"Untouched slots, and slots whose label or unit has no counterpart, take the destination's default. Per-engine p-labels: " +
|
|
4114
|
+
drumSynth.engines.map((e) => `${e.name}=[${e.params.map((p) => p.label).join(",")}]`).join("; ") + ". " +
|
|
4115
|
+
"NOTE: requires a PAM build with drum synth (manifest.drumSynth.enabled). This headless tool is product-gated, " +
|
|
4116
|
+
"not gated by the UI-only drumSynthEnabled preference. " +
|
|
3892
4117
|
"On products without it, this tool returns a product error instead of writing inert synth keys.",
|
|
3893
4118
|
inputSchema: {
|
|
3894
4119
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
@@ -3917,14 +4142,11 @@ server.registerTool("set_drum_synth", {
|
|
|
3917
4142
|
const engineId = DRUM_SYNTH_ENGINE_NAMES.indexOf(engine);
|
|
3918
4143
|
if (engineId < 0)
|
|
3919
4144
|
return error(`Unknown drum synth engine: ${engine}`);
|
|
3920
|
-
const
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
desc.params.forEach(([, def], i) => {
|
|
3926
|
-
delta[`cell${cellId}_synth_p${i + 1}`] = def;
|
|
3927
|
-
});
|
|
4145
|
+
const stateRaw = await bridge.send("getState");
|
|
4146
|
+
const state = isRecord(stateRaw) ? stateRaw : {};
|
|
4147
|
+
const delta = drumSynth.buildEngineSwitchDelta(cellId, engineId, state);
|
|
4148
|
+
const desc = drumSynth.getEngine(engineId);
|
|
4149
|
+
if (desc) {
|
|
3928
4150
|
for (const [key, value] of Object.entries(overrides)) {
|
|
3929
4151
|
if (value === undefined)
|
|
3930
4152
|
continue;
|
|
@@ -3937,7 +4159,7 @@ server.registerTool("set_drum_synth", {
|
|
|
3937
4159
|
delta[`cell${cellId}_synthVolume`] = volume;
|
|
3938
4160
|
// synthEngine is a graph-render key → needs a graph dispatch (KV_OPTS_STANDARD).
|
|
3939
4161
|
await applyKv(delta, KV_OPTS_STANDARD);
|
|
3940
|
-
const labels =
|
|
4162
|
+
const labels = desc ? desc.params.map((p) => p.label).join(",") : "—";
|
|
3941
4163
|
return text(`Cell ${cellId} drum synth → ${engine}${engineId > 0 ? ` (p1..pN = ${labels})` : ""}.`);
|
|
3942
4164
|
});
|
|
3943
4165
|
server.registerTool("set_drum_synth_wave", {
|
|
@@ -4482,7 +4704,7 @@ Variations exclude: global volume, looper state.
|
|
|
4482
4704
|
|
|
4483
4705
|
## Master Effects — The Final Stage
|
|
4484
4706
|
|
|
4485
|
-
Signal chain: Cell outputs → Bus Mix → **Punch Compressor** → **Limiter** → Volume Fader → Output
|
|
4707
|
+
Signal chain: Cell outputs → Bus Mix → **EQ** → **Punch Compressor** → **Squeeze** (3-band up/down) → **Tape** → **Iron** → **Clipper** → **Limiter** → Volume Fader → Output
|
|
4486
4708
|
|
|
4487
4709
|
### Punch Compressor (Musical Bus Comp)
|
|
4488
4710
|
\`\`\`
|
|
@@ -4603,7 +4825,8 @@ update_cell_config: cellId=5, scaleName="pentatonicMinor", scaleRoot=0
|
|
|
4603
4825
|
\`\`\`
|
|
4604
4826
|
\`scaleRoot\`: 0=C, 1=C#, 2=D, 3=D#, 4=E, 5=F, 6=F#, 7=G, 8=G#, 9=A, 10=A#, 11=B
|
|
4605
4827
|
|
|
4606
|
-
Available scales: \`chromatic\` (default/none), \`major\`, \`minor\`, \`pentatonicMajor\`, \`pentatonicMinor\`, \`dorian\`, \`mixolydian\`, \`blues\`, \`harmonicMinor\`, \`phrygian\`
|
|
4828
|
+
Available scales: \`chromatic\` (default/none), \`major\`, \`minor\`, \`pentatonicMajor\`, \`pentatonicMinor\`, \`dorian\`, \`mixolydian\`, \`blues\`, \`harmonicMinor\`, \`phrygian\`, \`lydian\`, \`locrian\`
|
|
4829
|
+
(The sampler pitch-quantize lane \`cell{N}_quantScale\` is a separate, deliberately frozen 10-scale DAW param — no lydian/locrian there.)
|
|
4607
4830
|
|
|
4608
4831
|
### Playback Modes
|
|
4609
4832
|
\`cell{N}_mode\`: 0=Trigger (one-shot), 1=Gate (plays while held, default), 2=Loop (continuous)
|
|
@@ -4646,6 +4869,9 @@ 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,
|
|
|
4646
4869
|
// commands in Claude Code). Each stages the same flows the
|
|
4647
4870
|
// README and tool descriptions teach, with exact tool names.
|
|
4648
4871
|
// ============================================================
|
|
4872
|
+
// Mirror of RANDOMIZER_GENRES (shared/randomizer/types.ts) — the published
|
|
4873
|
+
// package can't import repo-relative shared modules, so the copy is pinned by
|
|
4874
|
+
// interface/test/randomizerGenre.test.ts (source-text parity check).
|
|
4649
4875
|
const GENRE_SUGGESTIONS = ["boom_bap", "trap", "drill", "house", "techno", "dnb"];
|
|
4650
4876
|
const completeGenre = (value) => GENRE_SUGGESTIONS.filter((g) => g.startsWith((value ?? "").toLowerCase()));
|
|
4651
4877
|
// completable() tags the schema OBJECT with a symbol, so ordering is delicate:
|
|
@@ -4833,7 +5059,7 @@ server.registerTool("set_looper_track", {
|
|
|
4833
5059
|
if (typeof rest.sourceCell === "number")
|
|
4834
5060
|
trackDelta.sourceCell = rest.sourceCell;
|
|
4835
5061
|
if (typeof rest.autoSelect === "boolean")
|
|
4836
|
-
trackDelta.autoSelect = rest.autoSelect
|
|
5062
|
+
trackDelta.autoSelect = rest.autoSelect;
|
|
4837
5063
|
if (typeof rest.recordedBpm === "number")
|
|
4838
5064
|
trackDelta.recordedBpm = rest.recordedBpm;
|
|
4839
5065
|
if (Object.keys(trackDelta).length === 0)
|
|
@@ -4914,6 +5140,10 @@ server.registerTool("start_looper_recording", {
|
|
|
4914
5140
|
const check = await requireBridge();
|
|
4915
5141
|
if (check !== true)
|
|
4916
5142
|
return check;
|
|
5143
|
+
// Arm in UI state too — the plugin's live recording overlay and progress
|
|
5144
|
+
// mount off loopers[id].armed, so a bridge-only record left the timeline
|
|
5145
|
+
// blank while recording ran fine underneath.
|
|
5146
|
+
await applyLoopersDelta(Object.fromEntries(armedCellIds.map((cellId) => [cellId, { armed: 1 }])));
|
|
4917
5147
|
const meta = trackMetadata ??
|
|
4918
5148
|
armedCellIds.map((cellId) => ({
|
|
4919
5149
|
cellId,
|
|
@@ -5129,7 +5359,9 @@ server.registerTool("looper_variation_sync", {
|
|
|
5129
5359
|
server.registerTool("queue_variation", {
|
|
5130
5360
|
title: "Queue Variation Switch",
|
|
5131
5361
|
description: "Schedule a global variation switch on the next bar boundary. " +
|
|
5132
|
-
"For an immediate switch, use load_variation instead."
|
|
5362
|
+
"For an immediate switch, use load_variation instead. Optionally morph " +
|
|
5363
|
+
"into the new section over the bars before the boundary (song mode / an " +
|
|
5364
|
+
"already-engaged queued-flip window only; a from-idle flip hard-switches).",
|
|
5133
5365
|
inputSchema: {
|
|
5134
5366
|
index: z.number().min(0).max(7).describe("Variation slot 0-7."),
|
|
5135
5367
|
intervalBars: z.number().min(0.125).max(64).describe("Bars before switching."),
|
|
@@ -5138,13 +5370,48 @@ server.registerTool("queue_variation", {
|
|
|
5138
5370
|
.boolean()
|
|
5139
5371
|
.default(true)
|
|
5140
5372
|
.describe("Reset pattern phase on switch."),
|
|
5373
|
+
morphBars: z
|
|
5374
|
+
.number()
|
|
5375
|
+
.min(0.25)
|
|
5376
|
+
.max(8)
|
|
5377
|
+
.optional()
|
|
5378
|
+
.describe("Pre-anchor morph window in bars for this flip (PAM >= 1.19.3). Omit = hard switch."),
|
|
5379
|
+
morphMode: z
|
|
5380
|
+
.enum(["auto", "param", "mix", "seq", "all"])
|
|
5381
|
+
.optional()
|
|
5382
|
+
.describe("Morph style: 'param' glides continuous params (default); 'mix' equal-power blends the two sections; 'seq' adds a staged pattern dissolve to the glide (old notes lose trigger probability as new notes gain it); 'all' runs glide + dissolve + blend together; 'auto' lets the engine pick — mix when the sections differ in graph topology (samples, synth engines, routing) or when nothing can glide, param otherwise (PAM >= 1.19.4; seq/all on older builds run param)."),
|
|
5383
|
+
morphCurve: z
|
|
5384
|
+
.enum(["linear", "ease", "exp", "log"])
|
|
5385
|
+
.optional()
|
|
5386
|
+
.describe("Morph easing; default ease."),
|
|
5387
|
+
transition: z
|
|
5388
|
+
.object({
|
|
5389
|
+
patternPolicy: z.enum(["dissolve", "wipe", "cells"]).optional(),
|
|
5390
|
+
mixLaw: z.enum(["equalPower", "equalGain", "correlation"]).optional(),
|
|
5391
|
+
tail: z.enum(["cut", "fade", "ring"]).optional(),
|
|
5392
|
+
tailMaxBars: z.number().min(0.25).max(8).optional(),
|
|
5393
|
+
})
|
|
5394
|
+
.optional()
|
|
5395
|
+
.describe("Advanced transition settings for this flip (PAM >= 1.20.0): pattern policy for a " +
|
|
5396
|
+
"seq/all morph, blend gain law for a mix/all morph, and the outgoing section's " +
|
|
5397
|
+
"tail. Omitted fields keep the defaults (dissolve, equalPower, fade)."),
|
|
5141
5398
|
},
|
|
5142
|
-
}, async ({ index, intervalBars, fadeMs, resetPhase }) => {
|
|
5399
|
+
}, async ({ index, intervalBars, fadeMs, resetPhase, morphBars, morphMode, morphCurve, transition }) => {
|
|
5143
5400
|
const check = await requireBridge();
|
|
5144
5401
|
if (check !== true)
|
|
5145
5402
|
return check;
|
|
5146
|
-
await bridge.send("queueVariationOnBars", {
|
|
5147
|
-
|
|
5403
|
+
await bridge.send("queueVariationOnBars", {
|
|
5404
|
+
index,
|
|
5405
|
+
intervalBars,
|
|
5406
|
+
fadeMs,
|
|
5407
|
+
resetPhase,
|
|
5408
|
+
...(morphBars !== undefined && { morphBars }),
|
|
5409
|
+
...(morphMode !== undefined && { morphMode }),
|
|
5410
|
+
...(morphCurve !== undefined && { morphCurve }),
|
|
5411
|
+
...(transition !== undefined && { transition }),
|
|
5412
|
+
});
|
|
5413
|
+
const morphNote = morphBars !== undefined ? ` (morph ${morphBars} bars, ${morphMode ?? "param"})` : "";
|
|
5414
|
+
return text(`Queued variation ${index} on next ${intervalBars}-bar boundary${morphNote}.`);
|
|
5148
5415
|
});
|
|
5149
5416
|
server.registerTool("cancel_queued_variation", {
|
|
5150
5417
|
title: "Cancel Queued Variation",
|
|
@@ -5298,16 +5565,17 @@ server.registerTool("midi_recording", {
|
|
|
5298
5565
|
// ============================================================
|
|
5299
5566
|
server.registerTool("update_modulation", {
|
|
5300
5567
|
title: "Update Modulation",
|
|
5301
|
-
description: "Apply a delta to a modulation source: LFO, Envelope, Vary, Macro, ParamSeq config,
|
|
5568
|
+
description: "Apply a delta to a modulation source: LFO, Envelope, Vary, Macro, ParamSeq config, ParamSeq sequence data, " +
|
|
5569
|
+
"or the Free Sequencer master pattern. " +
|
|
5302
5570
|
"The payload is type-specific; see modulation update flows in interop.ts (applyLfoUpdate / updateEnvelope / etc.). " +
|
|
5303
5571
|
"Use add_modulation to first attach a source to a target — then this tool to edit it.",
|
|
5304
5572
|
inputSchema: {
|
|
5305
5573
|
type: z
|
|
5306
|
-
.enum(["lfo", "envelope", "vary", "macro", "paramSeq", "paramSeqSequence"])
|
|
5574
|
+
.enum(["lfo", "envelope", "vary", "macro", "paramSeq", "paramSeqSequence", "freeSeqSequence"])
|
|
5307
5575
|
.describe("Which modulation system to update."),
|
|
5308
5576
|
payload: z
|
|
5309
5577
|
.record(z.string(), z.unknown())
|
|
5310
|
-
.describe("Update payload object. Shape depends on type: lfo expects { index, rate?, depth?, shape?, customCurve?, target? }, envelope expects matrix entry edits, paramSeqSequence expects { id, steps[] }, etc."),
|
|
5578
|
+
.describe("Update payload object. Shape depends on type: lfo expects { index, rate?, depth?, shape?, customCurve?, target? }, envelope expects matrix entry edits, paramSeqSequence expects { id, steps[] }, freeSeqSequence expects { sequence: number[] } (the master step contour every free-mode ParamSeq follows), etc."),
|
|
5311
5579
|
},
|
|
5312
5580
|
}, async ({ type, payload }) => {
|
|
5313
5581
|
const check = await requireBridge();
|
|
@@ -5324,13 +5592,55 @@ server.registerTool("update_modulation", {
|
|
|
5324
5592
|
? "applyMacroUpdate"
|
|
5325
5593
|
: type === "paramSeq"
|
|
5326
5594
|
? "applyParamSeqUpdate"
|
|
5327
|
-
: "
|
|
5595
|
+
: type === "paramSeqSequence"
|
|
5596
|
+
? "applyParamSeqSequenceUpdate"
|
|
5597
|
+
: "applyFreeSeqSequenceUpdate";
|
|
5328
5598
|
await bridge.send(action, data);
|
|
5329
5599
|
return text(`Applied ${type} update.`);
|
|
5330
5600
|
});
|
|
5331
5601
|
// ============================================================
|
|
5332
5602
|
// TOOLS — Song mode
|
|
5333
5603
|
// ============================================================
|
|
5604
|
+
// Advanced transition spec (ADR-001, PAM >= 1.19.6; engine families live from
|
|
5605
|
+
// PAM >= 1.20.0). Honest contract: it persists, round-trips, and reads back
|
|
5606
|
+
// RESOLVED. Pattern policy (dissolve/wipe/cells), mix law (equal power / equal
|
|
5607
|
+
// gain / correlation), tail (cut/fade/ring) and phase run on song VARIATION
|
|
5608
|
+
// sections and queued bar-sync flips; preset destinations coerce to a param
|
|
5609
|
+
// glide, and offline bounces coerce ring→fade and blend→hard switch (each
|
|
5610
|
+
// reported, never silent). Wire enums are append-only.
|
|
5611
|
+
const morphCurveEnum = z.enum(["linear", "ease", "exp", "log"]);
|
|
5612
|
+
const transitionSpecSchema = z.object({
|
|
5613
|
+
mode: z.enum(["auto", "param", "mix", "seq", "all"]).optional(),
|
|
5614
|
+
window: z
|
|
5615
|
+
.object({
|
|
5616
|
+
bars: z.number().min(0).max(8).optional(),
|
|
5617
|
+
paramCurve: morphCurveEnum.optional(),
|
|
5618
|
+
patternCurve: morphCurveEnum.optional(),
|
|
5619
|
+
mixCurve: morphCurveEnum.optional(),
|
|
5620
|
+
})
|
|
5621
|
+
.optional(),
|
|
5622
|
+
pattern: z.object({ policy: z.enum(["dissolve", "wipe", "cells"]).optional() }).optional(),
|
|
5623
|
+
audio: z
|
|
5624
|
+
.object({
|
|
5625
|
+
mixLaw: z.enum(["equalPower", "equalGain", "correlation"]).optional(),
|
|
5626
|
+
tail: z.enum(["cut", "fade", "ring"]).optional(),
|
|
5627
|
+
tailMaxBars: z.number().min(0.25).max(8).optional(),
|
|
5628
|
+
})
|
|
5629
|
+
.optional(),
|
|
5630
|
+
phase: z.enum(["inherit", "reset", "continue"]).optional(),
|
|
5631
|
+
});
|
|
5632
|
+
const TRANSITION_SPEC_HONESTY = "Advanced transition spec (PAM >= 1.19.6; live engine from PAM >= 1.20.0): pattern.policy " +
|
|
5633
|
+
"('dissolve' prob-morphs events, 'wipe' replaces them at a moving frontier, 'cells' hands " +
|
|
5634
|
+
"whole cells over in ascending order), audio.mixLaw ('equalPower' default, 'equalGain' for " +
|
|
5635
|
+
"correlated sections, 'correlation' auto-adapts), audio.tail ('fade' default, 'cut', 'ring' " +
|
|
5636
|
+
"lets voices/FX decay up to tailMaxBars), phase ('reset'/'continue' overrides the song's " +
|
|
5637
|
+
"Reset Phase per cable), window.patternCurve/mixCurve (per-domain easing; patternCurve " +
|
|
5638
|
+
"defaults linear). Runs on variation sections + queued flips, and on preset destinations " +
|
|
5639
|
+
"when the lanePresetSections setting is on (PAM >= 1.20.1 — the dual-lane preset section " +
|
|
5640
|
+
"pipeline; with it off, preset destinations coerce to a param glide). Offline bounces " +
|
|
5641
|
+
"coerce ring→fade, blend→hard switch. 'get' reads back each cable's resolvedTransition " +
|
|
5642
|
+
"(effective values after inheritance and capability coercion) and lastMorphResolution " +
|
|
5643
|
+
"(the engine's latest AUTO decision + reason).";
|
|
5334
5644
|
server.registerTool("song_mode", {
|
|
5335
5645
|
title: "Song Mode",
|
|
5336
5646
|
description: "Control PAM's song mode (timeline of variation/preset sections). Ops: 'setArrangement', " +
|
|
@@ -5342,9 +5652,13 @@ server.registerTool("song_mode", {
|
|
|
5342
5652
|
"VX (visual) variation slot via `vxVariationIndex` (omit = visuals follow the section's " +
|
|
5343
5653
|
"audio slot 1:1; see the vx_variation tool), optionally inside a specific VX scene via " +
|
|
5344
5654
|
"`vxScenePath` (the .vxpreset loads before the section plays, so sections can pull visuals " +
|
|
5345
|
-
"from different scenes).
|
|
5346
|
-
"
|
|
5347
|
-
"
|
|
5655
|
+
"from different scenes). A section can also carry `morphBars`/`morphCurve`/`morphMode` to glide the " +
|
|
5656
|
+
"incoming variation's continuous params over the bars before its boundary (PAM >= 1.19.3), and an " +
|
|
5657
|
+
"advanced `transition` spec (PAM >= 1.19.6 — authored metadata; see the field description). " +
|
|
5658
|
+
"'get' reads the arrangement back (nodes, compiled steps with the " +
|
|
5659
|
+
"VX scene+slot each recalls, total bars, and each cable's `resolvedTransition` — the effective " +
|
|
5660
|
+
"transition after inheritance and capability coercion, e.g. preset destinations report param); " +
|
|
5661
|
+
"'assignVx' re-pins one section's VX slot (and optional scene) after authoring.\n\n" +
|
|
5348
5662
|
"'setSequence' is the older HEADLESS raw-step path (works with the window closed): steps " +
|
|
5349
5663
|
"land in the C++ player but carry no grid nodes, so the UI can't edit them and VX can only " +
|
|
5350
5664
|
"follow 1:1 by audio slot.",
|
|
@@ -5365,13 +5679,28 @@ server.registerTool("song_mode", {
|
|
|
5365
5679
|
.describe("Operation."),
|
|
5366
5680
|
sections: z
|
|
5367
5681
|
.array(z.object({
|
|
5368
|
-
variationIndex: z.number().min(0).max(7).describe("Audio variation slot 0-7 this section plays."),
|
|
5682
|
+
variationIndex: z.number().min(0).max(7).describe("Audio variation slot 0-7 this section plays. With presetPath set, this selects the variation INSIDE that preset's own bank — the two compose, they are not mutually exclusive."),
|
|
5369
5683
|
bars: z.number().positive().optional().describe("Section length in bars (default: auto from longest pattern)."),
|
|
5370
5684
|
repeat: z.number().int().min(1).optional().describe("Times to play before advancing (default 1)."),
|
|
5371
5685
|
muted: z.boolean().optional().describe("Pass through without an audio trigger."),
|
|
5372
5686
|
probability: z.number().min(0).max(1).optional().describe("Trigger probability (0-1)."),
|
|
5373
|
-
presetPath: z.string().optional().describe("
|
|
5687
|
+
presetPath: z.string().optional().describe("Cross-preset section: at this section's boundary the song switches to THIS preset file, playing its variations[variationIndex] slot. With lanePresetSections on (PAM >= 1.20.1) it transitions exactly like a variation section (prepared off the audible path, full morph family); otherwise the legacy in-place load applies."),
|
|
5374
5688
|
label: z.string().optional().describe("Section label shown on the grid node (e.g. 'Intro', 'Hook')."),
|
|
5689
|
+
morphBars: z
|
|
5690
|
+
.number()
|
|
5691
|
+
.min(0)
|
|
5692
|
+
.max(8)
|
|
5693
|
+
.optional()
|
|
5694
|
+
.describe("Pre-anchor morph window in bars for the transition INTO this section — continuous params glide to the new values, landing at the boundary (PAM >= 1.19.3). 0 = explicit hard switch (never morph, ignores the song's default); omit to inherit the default. Values in (0, 0.25) clamp up to 0.25."),
|
|
5695
|
+
morphCurve: z
|
|
5696
|
+
.enum(["linear", "ease", "exp", "log"])
|
|
5697
|
+
.optional()
|
|
5698
|
+
.describe("Morph glide easing; default ease. Ignored without morphBars."),
|
|
5699
|
+
morphMode: z
|
|
5700
|
+
.enum(["auto", "param", "mix", "seq", "all"])
|
|
5701
|
+
.optional()
|
|
5702
|
+
.describe("Morph realization; default auto — the engine picks mix when the sections differ in graph topology (samples, synth engines, routing) or when nothing can glide, param otherwise (PAM >= 1.19.4; older builds run param). 'param' glides continuous params; 'mix' DJ-blends stems; 'seq' adds a staged pattern dissolve to the glide; 'all' runs glide + dissolve + blend (seq/all on older builds run param). Ignored without morphBars."),
|
|
5703
|
+
transition: transitionSpecSchema.optional().describe(TRANSITION_SPEC_HONESTY),
|
|
5375
5704
|
vxVariationIndex: z
|
|
5376
5705
|
.number()
|
|
5377
5706
|
.int()
|
|
@@ -5399,9 +5728,28 @@ server.registerTool("song_mode", {
|
|
|
5399
5728
|
.max(1)
|
|
5400
5729
|
.optional()
|
|
5401
5730
|
.describe("Branch probability (0-1). Sibling routes normalize to 100%."),
|
|
5731
|
+
morphBars: z
|
|
5732
|
+
.number()
|
|
5733
|
+
.min(0)
|
|
5734
|
+
.max(8)
|
|
5735
|
+
.optional()
|
|
5736
|
+
.describe("Per-cable morph window in bars — overrides the destination section's morphBars for THIS cable only (branching arrangements can give each incoming cable its own window). 0 = hard switch; omit to use the section value / song default."),
|
|
5737
|
+
morphCurve: z
|
|
5738
|
+
.enum(["linear", "ease", "exp", "log"])
|
|
5739
|
+
.optional()
|
|
5740
|
+
.describe("Per-cable morph easing; overrides the destination section's morphCurve."),
|
|
5741
|
+
morphMode: z
|
|
5742
|
+
.enum(["auto", "param", "mix", "seq", "all"])
|
|
5743
|
+
.optional()
|
|
5744
|
+
.describe("Per-cable morph realization; overrides the destination section's morphMode. Same modes as the section field, incl. 'seq' (pattern dissolve) and 'all' (glide + dissolve + blend)."),
|
|
5745
|
+
transition: transitionSpecSchema.optional().describe("Per-cable " + TRANSITION_SPEC_HONESTY),
|
|
5402
5746
|
}))
|
|
5403
5747
|
.optional()
|
|
5404
|
-
.describe("For 'setArrangement': optional cable graph. Multiple cables may leave or enter a section."),
|
|
5748
|
+
.describe("For 'setArrangement': optional cable graph. Multiple cables may leave or enter a section. Each cable may carry its own morphBars/morphCurve/morphMode/transition."),
|
|
5749
|
+
defaultTransition: transitionSpecSchema
|
|
5750
|
+
.nullable()
|
|
5751
|
+
.optional()
|
|
5752
|
+
.describe("For 'setArrangement': song-level advanced transition defaults inherited by cables without their own `transition`; null clears. " + TRANSITION_SPEC_HONESTY),
|
|
5405
5753
|
loop: z.boolean().optional().describe("For 'setArrangement': loop the whole arrangement."),
|
|
5406
5754
|
nodeId: z.string().optional().describe("For 'assignVx': grid node id (from op 'get')."),
|
|
5407
5755
|
stepIndex: z.number().int().min(0).optional().describe("For 'assignVx': compiled step index (alternative to nodeId)."),
|
|
@@ -5464,6 +5812,7 @@ server.registerTool("song_mode", {
|
|
|
5464
5812
|
op: "setSongArrangement",
|
|
5465
5813
|
sections: args.sections,
|
|
5466
5814
|
...(args.connections !== undefined && { connections: args.connections }),
|
|
5815
|
+
...(args.defaultTransition !== undefined && { defaultTransition: args.defaultTransition }),
|
|
5467
5816
|
...(args.loop !== undefined && { loop: args.loop }),
|
|
5468
5817
|
...(args.enabled !== undefined && { enabled: args.enabled }),
|
|
5469
5818
|
...(args.crossfadeMs !== undefined && { crossfadeMs: args.crossfadeMs }),
|
|
@@ -6239,7 +6588,7 @@ server.registerTool("update_settings", {
|
|
|
6239
6588
|
"experimental-feature gates from an agent. Common boolean keys: experimentalMode, " +
|
|
6240
6589
|
"experimentalPluginHost, experimentalMcpBridge. To enable the plugin host, set BOTH " +
|
|
6241
6590
|
"experimentalMode:true AND the specific sub-flag (e.g. experimentalPluginHost:true) in one call. " +
|
|
6242
|
-
"
|
|
6591
|
+
"Set drumSynthEnabled:false to hide its UI without muting loaded synths. Other keys " +
|
|
6243
6592
|
"include UI/library preferences — call get_state or inspect the returned object for the current set.",
|
|
6244
6593
|
inputSchema: {
|
|
6245
6594
|
settings: z
|
|
@@ -6316,6 +6665,24 @@ server.registerTool("get_audio_device_setup", {
|
|
|
6316
6665
|
const setup = await bridge.send("getAudioDeviceSetup");
|
|
6317
6666
|
return json(setup);
|
|
6318
6667
|
});
|
|
6668
|
+
server.registerTool("set_audio_device", {
|
|
6669
|
+
title: "Set Audio Output Device",
|
|
6670
|
+
description: "Switch the active audio OUTPUT device by name (standalone only — plugin-hosted builds let the DAW own the " +
|
|
6671
|
+
"device, and the call errors there). Use the exact name from get_audio_device_setup's device list. " +
|
|
6672
|
+
"The usual reason to call this headlessly is routing PAM into a loopback device (BlackHole, Soundflower) " +
|
|
6673
|
+
"so a capture run records audio. MIDI devices go through set_midi_io.",
|
|
6674
|
+
inputSchema: {
|
|
6675
|
+
deviceName: z
|
|
6676
|
+
.string()
|
|
6677
|
+
.describe("Output device name exactly as listed by get_audio_device_setup."),
|
|
6678
|
+
},
|
|
6679
|
+
}, async ({ deviceName }) => {
|
|
6680
|
+
const check = await requireBridge();
|
|
6681
|
+
if (check !== true)
|
|
6682
|
+
return check;
|
|
6683
|
+
await bridge.send("setAudioDevice", [deviceName]);
|
|
6684
|
+
return text(`Audio output device set to '${deviceName}'.`);
|
|
6685
|
+
});
|
|
6319
6686
|
server.registerTool("set_midi_io", {
|
|
6320
6687
|
title: "Set MIDI I/O Devices",
|
|
6321
6688
|
description: "Select MIDI input/output devices (standalone). Use get_audio_device_setup to discover device IDs first. " +
|