@map-audio/pam-mcp-server 1.0.0 → 1.7.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 +33 -5
- package/dist/bridge.d.ts +1 -0
- package/dist/bridge.js +132 -1
- package/dist/bridge.js.map +1 -1
- package/dist/manifest.json +58431 -328
- package/dist/server.js +1738 -17
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -3,11 +3,18 @@ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mc
|
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { readFile } from "node:fs/promises";
|
|
6
|
-
import { basename } from "node:path";
|
|
6
|
+
import { basename, dirname, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
7
8
|
import { loadManifest, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, walkPresets, } from "./manifest.js";
|
|
8
9
|
import { PluginBridge } from "./bridge.js";
|
|
9
10
|
// --- State ---
|
|
11
|
+
// Variation slot count. Must stay in sync with PRESET_VARIATION_SLOTS in
|
|
12
|
+
// interface/src/stores/Preset.ts. Defined locally because the MCP package
|
|
13
|
+
// can't import from the interface workspace without infra changes.
|
|
14
|
+
const PRESET_VARIATION_SLOTS = 8;
|
|
10
15
|
const bridge = new PluginBridge();
|
|
16
|
+
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
+
const cellModeChangeSeq = {};
|
|
11
18
|
// --- Helpers ---
|
|
12
19
|
function text(s) {
|
|
13
20
|
return { content: [{ type: "text", text: s }] };
|
|
@@ -18,6 +25,31 @@ function json(obj) {
|
|
|
18
25
|
function error(msg) {
|
|
19
26
|
return { content: [{ type: "text", text: msg }], isError: true };
|
|
20
27
|
}
|
|
28
|
+
async function readPackageVersion() {
|
|
29
|
+
try {
|
|
30
|
+
const raw = await readFile(resolve(PACKAGE_ROOT, "package.json"), "utf-8");
|
|
31
|
+
const pkg = JSON.parse(raw);
|
|
32
|
+
return typeof pkg.version === "string" ? pkg.version : "unknown";
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return "unknown";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function handleCliArgs() {
|
|
39
|
+
const args = new Set(process.argv.slice(2));
|
|
40
|
+
if (args.has("--version") || args.has("-v")) {
|
|
41
|
+
console.log(await readPackageVersion());
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
if (args.has("--help") || args.has("-h")) {
|
|
45
|
+
const version = await readPackageVersion();
|
|
46
|
+
console.log(`pam-mcp ${version}\n\n` +
|
|
47
|
+
"MCP stdio server for PAM. Configure your MCP client to launch this command; " +
|
|
48
|
+
"it reads ~/.pam-mcp-bridge.json to find a running PAM instance.");
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
21
53
|
async function requireBridge() {
|
|
22
54
|
if (bridge.isConnected())
|
|
23
55
|
return true;
|
|
@@ -53,14 +85,14 @@ async function readExistingVariations(presetPath) {
|
|
|
53
85
|
const fullPath = await resolvePresetPath(presetPath);
|
|
54
86
|
const raw = await readFile(fullPath, "utf-8");
|
|
55
87
|
const { metadata } = parsePresetFile(raw);
|
|
56
|
-
if (metadata.variations && Array.isArray(metadata.variations) && metadata.variations.length ===
|
|
88
|
+
if (metadata.variations && Array.isArray(metadata.variations) && metadata.variations.length === PRESET_VARIATION_SLOTS) {
|
|
57
89
|
return [...metadata.variations];
|
|
58
90
|
}
|
|
59
91
|
}
|
|
60
92
|
catch {
|
|
61
93
|
// Preset doesn't exist yet or can't be read — start fresh
|
|
62
94
|
}
|
|
63
|
-
return Array(
|
|
95
|
+
return Array(PRESET_VARIATION_SLOTS).fill(null);
|
|
64
96
|
}
|
|
65
97
|
// Mirrors interop.ts VARIATION_EXCLUDED_PARAMS (activePresetPath is excluded separately below, matching interop.ts)
|
|
66
98
|
const VARIATION_EXCLUDED_PARAMS = [
|
|
@@ -68,6 +100,11 @@ const VARIATION_EXCLUDED_PARAMS = [
|
|
|
68
100
|
"interfaceCache",
|
|
69
101
|
"songMode",
|
|
70
102
|
"volume",
|
|
103
|
+
"hqMode",
|
|
104
|
+
"previewSamplePath",
|
|
105
|
+
"previewCellId",
|
|
106
|
+
"transport",
|
|
107
|
+
"snapshotRateHz",
|
|
71
108
|
"looperCrossfade",
|
|
72
109
|
"looperLength",
|
|
73
110
|
"looperVolume",
|
|
@@ -115,13 +152,23 @@ function formatParam(p) {
|
|
|
115
152
|
// --- Server ---
|
|
116
153
|
const server = new McpServer({
|
|
117
154
|
name: "pam",
|
|
118
|
-
version: "1.
|
|
155
|
+
version: "1.7.0",
|
|
119
156
|
}, {
|
|
120
157
|
instructions: "PAM is an audio sampler plugin. When browsing samples, ALWAYS call get_default_locations " +
|
|
121
158
|
"first to discover the user's actual library paths — never guess paths like ~/Music/PAM. " +
|
|
122
159
|
"The sample library lives under ~/Library/Application Support/, NOT ~/Music/. " +
|
|
123
160
|
"Use the returned paths with list_samples. When loading samples, use load_sample with " +
|
|
124
|
-
"paths from list_samples. Transport must be playing for sequencer output."
|
|
161
|
+
"paths from list_samples. Transport must be playing for sequencer output. " +
|
|
162
|
+
"To enable experimental features (drum synth, plugin host) call update_settings with both " +
|
|
163
|
+
"experimentalMode:true AND the specific sub-flag (e.g. experimentalDrumSynth:true). " +
|
|
164
|
+
"Tools added in server version 1.7.0 — update_settings, configure_transport, " +
|
|
165
|
+
"get_audio_device_setup, set_midi_io, set_midi_mapping, delete_preset, " +
|
|
166
|
+
"set_preset_factory_status, get_sample_usage, repair_preset_samples, preview_sample, " +
|
|
167
|
+
"set_plugin_params, skip_current_plugin_scan, remove_plugin_from_blocklist — require PAM ≥ 1.7.0. " +
|
|
168
|
+
"Earlier tool sets (looper, song mode, modulation edits, MIDI recording, sample analysis, " +
|
|
169
|
+
"live resample, variation scheduling, AI generation) require PAM ≥ 1.4.50 / 1.5.0. " +
|
|
170
|
+
"If a tool fails with \"PAM build does not support 'X'\", the connected plugin is older than " +
|
|
171
|
+
"this MCP server; the user must update PAM (updating the npm package alone is not enough).",
|
|
125
172
|
});
|
|
126
173
|
// ============================================================
|
|
127
174
|
// TOOLS — Offline (work without plugin running)
|
|
@@ -345,7 +392,7 @@ server.registerTool("get_state", {
|
|
|
345
392
|
"Returns all parameter values, cell configs, modulation, and sequences.",
|
|
346
393
|
inputSchema: {
|
|
347
394
|
section: z
|
|
348
|
-
.enum(["full", "cells", "lfos", "macros", "envelopes", "vary", "paramSeqs"])
|
|
395
|
+
.enum(["full", "cells", "lfos", "macros", "envelopes", "vary", "paramSeqs", "loopers", "songMode"])
|
|
349
396
|
.optional()
|
|
350
397
|
.describe("Return only a specific section of state. Defaults to 'full'. Use get_transport for transport data."),
|
|
351
398
|
},
|
|
@@ -435,6 +482,87 @@ server.registerTool("load_sample", {
|
|
|
435
482
|
await bridge.send("loadSampleToCell", filePath, cellId);
|
|
436
483
|
return text(`Loaded ${basename(filePath)} into cell ${cellId}.`);
|
|
437
484
|
});
|
|
485
|
+
server.registerTool("generate_sample", {
|
|
486
|
+
title: "Generate Sample (AI)",
|
|
487
|
+
description: "Generate a new audio sample via the on-device AI engine (Stable Audio 3 Small SFX, ~3.2 GB ONNX). The plugin must have the model downloaded first (Settings → Local models). Returns the on-disk path to the generated WAV and optionally loads it into a cell.",
|
|
488
|
+
inputSchema: {
|
|
489
|
+
category: z
|
|
490
|
+
.enum(["kick", "snare", "hat", "perc", "bass", "synth", "pad", "fx", "loop"])
|
|
491
|
+
.describe("Sound category — drives prompt template defaults."),
|
|
492
|
+
prompt: z.string().describe("Text prompt describing the desired sound."),
|
|
493
|
+
durationSeconds: z
|
|
494
|
+
.number()
|
|
495
|
+
.min(0.1)
|
|
496
|
+
.max(47)
|
|
497
|
+
.describe("Output length in seconds (Stable Audio 3 Small SFX engine cap ≈ 47 s). For loops, pass bar-converted duration: bars * beatsPerBar * 60 / bpm."),
|
|
498
|
+
mode: z
|
|
499
|
+
.enum(["text", "inpaint", "continuation"])
|
|
500
|
+
.optional()
|
|
501
|
+
.default("text")
|
|
502
|
+
.describe("Generation mode. text = unconditional, inpaint = audio-to-audio with mask, continuation = extend existing audio."),
|
|
503
|
+
cellId: z
|
|
504
|
+
.number()
|
|
505
|
+
.int()
|
|
506
|
+
.min(1)
|
|
507
|
+
.max(8)
|
|
508
|
+
.optional()
|
|
509
|
+
.describe("If provided, load result directly into this cell."),
|
|
510
|
+
seed: z.number().int().optional().describe("Deterministic seed. Random if omitted."),
|
|
511
|
+
refAudioPath: z.string().optional().describe("Required for inpaint/continuation. Absolute path to a WAV."),
|
|
512
|
+
maskSpec: z
|
|
513
|
+
.string()
|
|
514
|
+
.optional()
|
|
515
|
+
.describe('Mask spec for inpaint mode: "tail:0.5" or "transient:0-0.05" or "full".'),
|
|
516
|
+
},
|
|
517
|
+
}, async (args) => {
|
|
518
|
+
const ok = await requireBridge();
|
|
519
|
+
if (ok !== true)
|
|
520
|
+
return ok;
|
|
521
|
+
try {
|
|
522
|
+
const result = await bridge.send("aiGenerate", args);
|
|
523
|
+
return json(result);
|
|
524
|
+
}
|
|
525
|
+
catch (e) {
|
|
526
|
+
return error(`AI generation failed: ${e.message}`);
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
server.registerTool("generate_kit", {
|
|
530
|
+
title: "Generate Kit (AI)",
|
|
531
|
+
description: "Generate multiple AI samples in parallel (Stable Audio 3 Small SFX) and load them into a list of cells. Useful for building a coherent drum / instrument kit from a single high-level prompt. Requires the on-device model to be downloaded (Settings → Local models).",
|
|
532
|
+
inputSchema: {
|
|
533
|
+
prompt: z.string().describe('High-level kit description (e.g. "dark trap drum kit", "lofi house").'),
|
|
534
|
+
cells: z
|
|
535
|
+
.array(z.object({
|
|
536
|
+
id: z.number().int().min(1).max(8),
|
|
537
|
+
role: z.enum(["kick", "snare", "hat", "perc", "bass", "synth", "pad", "fx", "loop"]),
|
|
538
|
+
}))
|
|
539
|
+
.min(1)
|
|
540
|
+
.max(8)
|
|
541
|
+
.describe("Cells to populate. Each gets one sample matching the role."),
|
|
542
|
+
},
|
|
543
|
+
}, async (args) => {
|
|
544
|
+
const ok = await requireBridge();
|
|
545
|
+
if (ok !== true)
|
|
546
|
+
return ok;
|
|
547
|
+
const { prompt, cells } = args;
|
|
548
|
+
// C++ handleAIGenerateKit expects { requests: [{ prompt, category, cellId, durationSeconds }] }.
|
|
549
|
+
// Derive a per-role prompt + sensible duration from the single kit prompt;
|
|
550
|
+
// cellId on each request drives the bridge's load-into-cell step. (mode omitted → text.)
|
|
551
|
+
const durationForRole = (role) => role === "loop" ? 4 : role === "bass" || role === "synth" || role === "pad" || role === "fx" ? 2 : 1.5;
|
|
552
|
+
const requests = cells.map((c) => ({
|
|
553
|
+
prompt: `${prompt}, ${c.role}`,
|
|
554
|
+
category: c.role,
|
|
555
|
+
cellId: c.id,
|
|
556
|
+
durationSeconds: durationForRole(c.role),
|
|
557
|
+
}));
|
|
558
|
+
try {
|
|
559
|
+
const results = await bridge.send("aiGenerateKit", { requests });
|
|
560
|
+
return json(results);
|
|
561
|
+
}
|
|
562
|
+
catch (e) {
|
|
563
|
+
return error(`Kit generation failed: ${e.message}`);
|
|
564
|
+
}
|
|
565
|
+
});
|
|
438
566
|
server.registerTool("trigger_midi", {
|
|
439
567
|
title: "Trigger MIDI",
|
|
440
568
|
description: "Send a MIDI note to a specific cell or globally. " +
|
|
@@ -515,8 +643,17 @@ server.registerTool("add_modulation", {
|
|
|
515
643
|
.boolean()
|
|
516
644
|
.default(false)
|
|
517
645
|
.describe("Bipolar modulation (modulates both directions)."),
|
|
646
|
+
pitch: z
|
|
647
|
+
.boolean()
|
|
648
|
+
.optional()
|
|
649
|
+
.describe("Envelope only: route from the cell's pitch-tracked source instead of amp. " +
|
|
650
|
+
"false (default) = AMP; true & pitchDuck=false = BOOST; true & pitchDuck=true = DUCK."),
|
|
651
|
+
pitchDuck: z
|
|
652
|
+
.boolean()
|
|
653
|
+
.optional()
|
|
654
|
+
.describe("Envelope only: when pitch=true, switch from BOOST to DUCK semantics (notch). Ignored if pitch=false."),
|
|
518
655
|
},
|
|
519
|
-
}, async ({ type, paramId, cellId, lfoIndex, strength, bipolar }) => {
|
|
656
|
+
}, async ({ type, paramId, cellId, lfoIndex, strength, bipolar, pitch, pitchDuck }) => {
|
|
520
657
|
const index = await getParameterIndex();
|
|
521
658
|
const param = index.get(paramId);
|
|
522
659
|
if (!param)
|
|
@@ -539,7 +676,7 @@ server.registerTool("add_modulation", {
|
|
|
539
676
|
else if (type === "envelope") {
|
|
540
677
|
if (!cellId)
|
|
541
678
|
return error("cellId is required for envelope modulation.");
|
|
542
|
-
await bridge.send("addEnvelope", paramId, cellId, strength, bipolar, effectEnableParams);
|
|
679
|
+
await bridge.send("addEnvelope", paramId, cellId, strength, bipolar, effectEnableParams, pitch ?? false, pitchDuck ?? false);
|
|
543
680
|
}
|
|
544
681
|
else if (type === "vary") {
|
|
545
682
|
if (!cellId)
|
|
@@ -813,7 +950,14 @@ server.registerTool("save_preset", {
|
|
|
813
950
|
const state = (await bridge.send("getState"));
|
|
814
951
|
const variationString = buildVariationString(state);
|
|
815
952
|
const presetRelPath = path || `User/${name}.preset`;
|
|
816
|
-
|
|
953
|
+
// Strip "Presets/" prefix if present — resolvePresetPath already prepends
|
|
954
|
+
// the presets base directory, so "Presets/User/X" would double up to
|
|
955
|
+
// ".../Presets/Presets/User/X" and silently fail to read back variations,
|
|
956
|
+
// wiping slots 1-7 on save. Mirrors save_variation's handling.
|
|
957
|
+
const readPath = presetRelPath.startsWith("Presets/")
|
|
958
|
+
? presetRelPath.slice("Presets/".length)
|
|
959
|
+
: presetRelPath;
|
|
960
|
+
const existingVariations = await readExistingVariations(readPath);
|
|
817
961
|
const hasVariations = existingVariations.some((v) => v !== null);
|
|
818
962
|
if (!hasVariations) {
|
|
819
963
|
existingVariations[0] = variationString;
|
|
@@ -885,13 +1029,13 @@ server.registerTool("save_variation", {
|
|
|
885
1029
|
}
|
|
886
1030
|
// 3. Read existing preset metadata and variations from disk
|
|
887
1031
|
let existingMeta = {};
|
|
888
|
-
let variations = Array(
|
|
1032
|
+
let variations = Array(PRESET_VARIATION_SLOTS).fill(null);
|
|
889
1033
|
try {
|
|
890
1034
|
const fullPath = await resolvePresetPath(diskPath);
|
|
891
1035
|
const raw = await readFile(fullPath, "utf-8");
|
|
892
1036
|
const { metadata: m } = parsePresetFile(raw);
|
|
893
1037
|
existingMeta = m;
|
|
894
|
-
if (m.variations && Array.isArray(m.variations) && m.variations.length ===
|
|
1038
|
+
if (m.variations && Array.isArray(m.variations) && m.variations.length === PRESET_VARIATION_SLOTS) {
|
|
895
1039
|
variations = [...m.variations];
|
|
896
1040
|
}
|
|
897
1041
|
}
|
|
@@ -1533,6 +1677,14 @@ server.registerTool("update_cell_config", {
|
|
|
1533
1677
|
polyRootNote: z.number().min(0).max(127).optional().describe("Root note for poly pitch calculation."),
|
|
1534
1678
|
scaleName: z.string().optional().describe("Scale name for pitch sequencer quantization (e.g. 'chromatic', 'major', 'minor')."),
|
|
1535
1679
|
scaleRoot: z.number().min(0).max(11).optional().describe("Root note of the scale (0=C, 1=C#, ..., 11=B)."),
|
|
1680
|
+
pitchMode: z.number().int().min(0).max(3).optional().describe("Pitch engine: 0=Varispeed, 1=Formant, 2=Pitch Shift Quantize, 3=Varispeed Quantize."),
|
|
1681
|
+
stretchMode: z.number().int().min(0).max(3).optional().describe("Stretch engine: 0=OLA, 1=Cloud, 2=Phase Vocoder, 3=GrainRate."),
|
|
1682
|
+
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/helpers/Scale.h."),
|
|
1683
|
+
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."),
|
|
1684
|
+
grainDensity: z.number().int().min(1).max(24).optional().describe("Cloud stretch grain density when stretchMode=1."),
|
|
1685
|
+
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."),
|
|
1686
|
+
spread: z.number().min(0).max(100).optional().describe("Cloud stretch grain spread (0..100) when stretchMode=1."),
|
|
1687
|
+
chaos: z.number().min(0).max(100).optional().describe("Cloud stretch grain chaos (0..100) when stretchMode=1."),
|
|
1536
1688
|
sidechainFrom: z
|
|
1537
1689
|
.union([z.number().min(0).max(8), z.null()])
|
|
1538
1690
|
.optional()
|
|
@@ -1556,6 +1708,11 @@ server.registerTool("update_cell_config", {
|
|
|
1556
1708
|
if (cellConfig.sidechainFrom !== undefined && cellConfig.sidechainFrom !== null) {
|
|
1557
1709
|
flatDelta[`cell${cellId}_compressorEnabled`] = 1;
|
|
1558
1710
|
}
|
|
1711
|
+
if (cellConfig.pitchMode !== undefined || cellConfig.stretchMode !== undefined) {
|
|
1712
|
+
const next = (cellModeChangeSeq[cellId] ?? Date.now()) + 1;
|
|
1713
|
+
cellModeChangeSeq[cellId] = next;
|
|
1714
|
+
cellConfig.modeChangeSeq = next;
|
|
1715
|
+
}
|
|
1559
1716
|
const delta = { cells: { [cellId]: cellConfig }, ...flatDelta };
|
|
1560
1717
|
await applyKv(delta, KV_OPTS_CELL_CONFIG);
|
|
1561
1718
|
const summary = Object.entries(cellConfig)
|
|
@@ -1565,15 +1722,21 @@ server.registerTool("update_cell_config", {
|
|
|
1565
1722
|
});
|
|
1566
1723
|
server.registerTool("set_master_effects", {
|
|
1567
1724
|
title: "Set Master Effects",
|
|
1568
|
-
description: "Configure the master bus Punch compressor and
|
|
1725
|
+
description: "Configure the master bus Punch compressor, Limiter, and Tape colour. " +
|
|
1569
1726
|
"The Punch compressor is a musical bus compressor — use it for glue and pump. " +
|
|
1570
1727
|
"The Limiter is a 3-stage brick-wall limiter with tanh saturation. " +
|
|
1571
|
-
"
|
|
1728
|
+
"The Tape is a vintage-tape coloration stage (ADAA tanh/asymmetric saturation + HF compression + wow/flutter), " +
|
|
1729
|
+
"the single biggest lever for lo-fi / dusty / 'pushed' master character (hip-hop, lo-fi, Memphis, dub). " +
|
|
1730
|
+
"Pass enabled flags to toggle each on/off. Pass parameters to shape the sound. " +
|
|
1572
1731
|
"For pumpy beats: enable punch, set threshold around -18 to -12 dB, fast attack (0.1-0.5ms), " +
|
|
1573
|
-
"medium release (80-200ms), ratio 4-8, autoMakeup true."
|
|
1732
|
+
"medium release (80-200ms), ratio 4-8, autoMakeup true. " +
|
|
1733
|
+
"For lo-fi/Memphis/dusty glue: tapeEnabled=1, tapeMode=1 (Lo-Fi), masterTapeDrive 0.55-0.7, " +
|
|
1734
|
+
"masterTapeTone -0.4..-0.1 (darker), masterTapeWear 0.5-0.7 (wow/flutter). " +
|
|
1735
|
+
"NOTE: the Tape requires a PAM build that ships it (manifest key masterTapeEnabled). On older builds these keys are ignored.",
|
|
1574
1736
|
inputSchema: {
|
|
1575
1737
|
compressorEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable Punch compressor (0=off, 1=on)."),
|
|
1576
1738
|
limiterEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable master Limiter (0=off, 1=on)."),
|
|
1739
|
+
tapeEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable master Tape colour/saturation (0=off, 1=on)."),
|
|
1577
1740
|
masterCompThreshold: z.number().min(-60).max(0).optional().describe("Compressor threshold in dB (default -24)."),
|
|
1578
1741
|
masterCompRatio: z.number().min(1).max(20).optional().describe("Compressor ratio (default 4)."),
|
|
1579
1742
|
masterCompAttack: z.number().min(0.01).max(100).optional().describe("Attack in ms (default 0.2)."),
|
|
@@ -1584,8 +1747,13 @@ server.registerTool("set_master_effects", {
|
|
|
1584
1747
|
masterCompCeiling: z.number().min(-6).max(0).optional().describe("Output ceiling in dB (default -0.05)."),
|
|
1585
1748
|
masterCompInputGain: z.number().min(-24).max(24).optional().describe("Input gain in dB (default 0)."),
|
|
1586
1749
|
masterCompKnee: z.number().min(0).max(30).optional().describe("Knee width in dB (default 0)."),
|
|
1750
|
+
masterTapeDrive: z.number().min(0).max(1).optional().describe("Tape drive / saturation amount (0-1, default 0.5). Higher = more harmonic grit + HF compression ('push it harder → it gets duller')."),
|
|
1751
|
+
masterTapeTone: z.number().min(-1).max(1).optional().describe("Tape tone tilt (-1 dark .. 0 flat .. +1 bright, default 0). Go negative for dusty/lo-fi."),
|
|
1752
|
+
masterTapeMix: z.number().min(0).max(1).optional().describe("Tape dry/wet (0-1, default 1 = fully wet)."),
|
|
1753
|
+
masterTapeMode: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional().describe("Tape character: 0=Clean (gentle tanh), 1=Lo-Fi (most degradation, dusty), 2=Amp (hard asymmetric drive). Default 0."),
|
|
1754
|
+
masterTapeWear: z.number().min(0).max(1).optional().describe("Tape wear: wow/flutter pitch drift + age (0-1, default 0.5). Raise for warble/sag."),
|
|
1587
1755
|
},
|
|
1588
|
-
}, async ({ compressorEnabled, limiterEnabled, ...
|
|
1756
|
+
}, async ({ compressorEnabled, limiterEnabled, tapeEnabled, ...effectParams }) => {
|
|
1589
1757
|
const check = await requireBridge();
|
|
1590
1758
|
if (check !== true)
|
|
1591
1759
|
return check;
|
|
@@ -1595,8 +1763,11 @@ server.registerTool("set_master_effects", {
|
|
|
1595
1763
|
delta.masterCompressorEnabled = compressorEnabled;
|
|
1596
1764
|
if (limiterEnabled !== undefined)
|
|
1597
1765
|
delta.masterLimiterEnabled = limiterEnabled;
|
|
1598
|
-
|
|
1599
|
-
|
|
1766
|
+
if (tapeEnabled !== undefined)
|
|
1767
|
+
delta.masterTapeEnabled = tapeEnabled;
|
|
1768
|
+
// Compressor + tape parameters are already the flat state keys read by the
|
|
1769
|
+
// DSP graph / native master node (masterComp*, masterTape*). Pass through.
|
|
1770
|
+
for (const [key, value] of Object.entries(effectParams)) {
|
|
1600
1771
|
if (value !== undefined)
|
|
1601
1772
|
delta[key] = value;
|
|
1602
1773
|
}
|
|
@@ -1610,6 +1781,83 @@ server.registerTool("set_master_effects", {
|
|
|
1610
1781
|
.join(", ");
|
|
1611
1782
|
return text(`Updated master effects: ${summary}`);
|
|
1612
1783
|
});
|
|
1784
|
+
// Built-in per-cell drum synth ("machines"). Engine enum index = drumsynth::Engine
|
|
1785
|
+
// (Off = 0). Per-engine param labels + musical defaults mirror DRUM_SYNTH_ENGINES
|
|
1786
|
+
// in interface/src/dsp/modules/drumSynth.ts — keep in lockstep (append-only).
|
|
1787
|
+
const DRUM_SYNTH_ENGINES = [
|
|
1788
|
+
{ name: "Kick", params: [["Tune", 0.25], ["Pitch", 0.5], ["P.Decay", 0.35], ["Decay", 0.5], ["Click", 0.3], ["Drive", 0.35], ["Tone", 0.6]] },
|
|
1789
|
+
{ name: "Snare", params: [["Tune", 0.4], ["Snap", 0.45], ["Body", 0.35], ["N.Decay", 0.45], ["N.Tone", 0.5], ["Detune", 0.3], ["Drive", 0.25]] },
|
|
1790
|
+
{ name: "HiHat", params: [["Tune", 0.5], ["Decay", 0.3], ["Tone", 0.5], ["Metal", 0.6], ["Noise", 0.2]] },
|
|
1791
|
+
{ name: "Clap", params: [["Tune", 0.45], ["Spread", 0.4], ["Decay", 0.4], ["Tone", 0.5], ["Drive", 0.2]] },
|
|
1792
|
+
{ name: "Tom", params: [["Tune", 0.4], ["Pitch", 0.4], ["Decay", 0.45], ["Tone", 0.55], ["Noise", 0.2], ["Drive", 0.2]] },
|
|
1793
|
+
{ name: "FM", params: [["Tune", 0.4], ["Ratio", 0.45], ["FM Amt", 0.4], ["Decay", 0.4], ["FM Dcy", 0.4], ["F.Back", 0.1], ["Drive", 0.2]] },
|
|
1794
|
+
{ name: "Cymbal", params: [["Tune", 0.5], ["Decay", 0.5], ["Tone", 0.6], ["Metal", 0.7], ["Sizzle", 0.4]] },
|
|
1795
|
+
{ name: "Cowbell", params: [["Tune", 0.5], ["Decay", 0.4], ["Ratio", 0.45], ["Tone", 0.5], ["Drive", 0.2]] },
|
|
1796
|
+
{ name: "808", params: [["Tune", 0.35], ["Glide", 0.25], ["P.Decay", 0.4], ["Decay", 0.55], ["Saturate", 0.2], ["Click", 0.25], ["Tone", 0.5]] },
|
|
1797
|
+
{ name: "Shaker", params: [["Tune", 0.5], ["Color", 0.3], ["Decay", 0.3], ["Attack", 0.4], ["Rattle", 0.0], ["Tone", 0.4]] },
|
|
1798
|
+
{ name: "Rim", params: [["Tune", 0.4], ["Ring", 0.5], ["Decay", 0.3], ["Mode", 0.4], ["Snap", 0.6], ["Drive", 0.2]] },
|
|
1799
|
+
{ name: "Conga", params: [["Tune", 0.45], ["Slap", 0.4], ["Decay", 0.4], ["Mode", 0.3], ["Tone", 0.55], ["Skin", 0.25], ["Drive", 0.2]] },
|
|
1800
|
+
{ name: "Bell", params: [["Tune", 0.5], ["Ratio", 0.4], ["FM Amt", 0.4], ["Decay", 0.5], ["Inharm", 0.3], ["Strike", 0.3], ["Tone", 0.3]] },
|
|
1801
|
+
{ name: "Pluck", params: [["Tune", 0.4], ["Sustain", 0.6], ["Damp", 0.5], ["Excite", 0.4], ["Tone", 0.6], ["Drive", 0.15], ["Decay", 0.5]] },
|
|
1802
|
+
{ name: "Zap", params: [["Tune", 0.3], ["Sweep", 0.7], ["S.Time", 0.35], ["Decay", 0.4], ["Timbre", 0.3], ["Noise", 0.1], ["Drive", 0.25], ["Tone", 0.6]] },
|
|
1803
|
+
{ name: "Wave", params: [["Tune", 0.35], ["Position", 0.2], ["P.Env", 0.4], ["Decay", 0.4], ["Bright", 0.6], ["Morph", 0.0], ["Drive", 0.2]] },
|
|
1804
|
+
{ name: "Chaos", params: [["Tune", 0.4], ["Chaos", 0.6], ["Decay", 0.4], ["Tone", 0.5], ["Drive", 0.3], ["Noise", 0.1]] },
|
|
1805
|
+
];
|
|
1806
|
+
const DRUM_SYNTH_ENGINE_NAMES = ["Off", ...DRUM_SYNTH_ENGINES.map((e) => e.name)];
|
|
1807
|
+
server.registerTool("set_drum_synth", {
|
|
1808
|
+
title: "Set Drum Synth (built-in machine)",
|
|
1809
|
+
description: "Select and shape a cell's built-in drum-synth 'machine' — a real-time synthesized voice that sums into the cell " +
|
|
1810
|
+
"alongside (or instead of) its sample. 18 engines: Off, Kick, Snare, HiHat, Clap, Tom, FM, Cymbal, Cowbell, 808, " +
|
|
1811
|
+
"Shaker, Rim, Conga, Bell, Pluck, Zap, Wave, Chaos. The 808 engine is a tuned long-glide sub that plays as a chromatic " +
|
|
1812
|
+
"bassline (drive it with a pitch lane + pitch-quantize) — ideal for 808 sub-bass, trap, Memphis. Use a cell with NO " +
|
|
1813
|
+
"sample for a pure synth voice, or layer over a sample. Each engine reinterprets the 8 generic params (p1..p8); pass " +
|
|
1814
|
+
"only the ones you want to nuance — unspecified params seed to the engine's musical default. Per-engine p-labels: " +
|
|
1815
|
+
DRUM_SYNTH_ENGINES.map((e) => `${e.name}=[${e.params.map((p) => p[0]).join(",")}]`).join("; ") + ". " +
|
|
1816
|
+
"NOTE: requires a PAM build with drum synth (manifest.drumSynth.enabled) AND Settings → Experimental → Drum Synth ON. " +
|
|
1817
|
+
"On builds without it, the cell{N}_synthEngine state key is ignored.",
|
|
1818
|
+
inputSchema: {
|
|
1819
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
1820
|
+
engine: z.enum(DRUM_SYNTH_ENGINE_NAMES).describe("Engine name. 'Off' disables the synth voice (sample-only cell)."),
|
|
1821
|
+
volume: z.number().min(-60).max(12).optional().describe("Synth output level in dB (default 0)."),
|
|
1822
|
+
p1: z.number().min(0).max(1).optional().describe("Param 1 (engine-specific; usually Tune). Overrides the engine default."),
|
|
1823
|
+
p2: z.number().min(0).max(1).optional().describe("Param 2 (engine-specific)."),
|
|
1824
|
+
p3: z.number().min(0).max(1).optional().describe("Param 3 (engine-specific)."),
|
|
1825
|
+
p4: z.number().min(0).max(1).optional().describe("Param 4 (engine-specific)."),
|
|
1826
|
+
p5: z.number().min(0).max(1).optional().describe("Param 5 (engine-specific)."),
|
|
1827
|
+
p6: z.number().min(0).max(1).optional().describe("Param 6 (engine-specific)."),
|
|
1828
|
+
p7: z.number().min(0).max(1).optional().describe("Param 7 (engine-specific)."),
|
|
1829
|
+
p8: z.number().min(0).max(1).optional().describe("Param 8 (engine-specific)."),
|
|
1830
|
+
},
|
|
1831
|
+
}, async ({ cellId, engine, volume, ...overrides }) => {
|
|
1832
|
+
const check = await requireBridge();
|
|
1833
|
+
if (check !== true)
|
|
1834
|
+
return check;
|
|
1835
|
+
const engineId = DRUM_SYNTH_ENGINE_NAMES.indexOf(engine);
|
|
1836
|
+
if (engineId < 0)
|
|
1837
|
+
return error(`Unknown drum synth engine: ${engine}`);
|
|
1838
|
+
const delta = { [`cell${cellId}_synthEngine`]: engineId };
|
|
1839
|
+
if (engineId > 0) {
|
|
1840
|
+
// Seed the engine's musical defaults so the voice sounds good immediately,
|
|
1841
|
+
// then apply explicit p-overrides on top (nuance only what you mean to).
|
|
1842
|
+
const desc = DRUM_SYNTH_ENGINES[engineId - 1];
|
|
1843
|
+
desc.params.forEach(([, def], i) => {
|
|
1844
|
+
delta[`cell${cellId}_synth_p${i + 1}`] = def;
|
|
1845
|
+
});
|
|
1846
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
1847
|
+
if (value === undefined)
|
|
1848
|
+
continue;
|
|
1849
|
+
const idx = Number(key.slice(1)); // p3 -> 3
|
|
1850
|
+
if (idx >= 1 && idx <= desc.params.length)
|
|
1851
|
+
delta[`cell${cellId}_synth_p${idx}`] = value;
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
if (volume !== undefined)
|
|
1855
|
+
delta[`cell${cellId}_synthVolume`] = volume;
|
|
1856
|
+
// synthEngine is a graph-render key → needs a graph dispatch (KV_OPTS_STANDARD).
|
|
1857
|
+
await applyKv(delta, KV_OPTS_STANDARD);
|
|
1858
|
+
const labels = engineId > 0 ? DRUM_SYNTH_ENGINES[engineId - 1].params.map((p) => p[0]).join(",") : "—";
|
|
1859
|
+
return text(`Cell ${cellId} drum synth → ${engine}${engineId > 0 ? ` (p1..pN = ${labels})` : ""}.`);
|
|
1860
|
+
});
|
|
1613
1861
|
server.registerTool("add_param_seq", {
|
|
1614
1862
|
title: "Add Parameter Sequencer",
|
|
1615
1863
|
description: "Add a parameter sequencer (step modulation) targeting any modulatable parameter. " +
|
|
@@ -1717,6 +1965,25 @@ server.registerTool("normalize_sample", {
|
|
|
1717
1965
|
await bridge.send("normalizeSampleForCell", cellId);
|
|
1718
1966
|
return text(`Normalized sample in cell ${cellId}.`);
|
|
1719
1967
|
});
|
|
1968
|
+
server.registerTool("gain_sample", {
|
|
1969
|
+
title: "Gain Sample",
|
|
1970
|
+
description: "Apply a gain in decibels to a cell's sample and render it into a new file (offline, destructive). " +
|
|
1971
|
+
"Positive values make it louder, negative quieter; boosting past 0 dBFS clips. The original library file is left unchanged, so the edit can be undone by reloading the source sample.",
|
|
1972
|
+
inputSchema: {
|
|
1973
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
1974
|
+
gainDb: z
|
|
1975
|
+
.number()
|
|
1976
|
+
.min(-96)
|
|
1977
|
+
.max(48)
|
|
1978
|
+
.describe("Gain in decibels (e.g. 3 = +3 dB louder, -6 = 6 dB quieter)."),
|
|
1979
|
+
},
|
|
1980
|
+
}, async ({ cellId, gainDb }) => {
|
|
1981
|
+
const check = await requireBridge();
|
|
1982
|
+
if (check !== true)
|
|
1983
|
+
return check;
|
|
1984
|
+
await bridge.send("gainSampleForCell", { cellId, gainDb });
|
|
1985
|
+
return text(`Applied ${gainDb > 0 ? "+" : ""}${gainDb} dB to the sample in cell ${cellId}.`);
|
|
1986
|
+
});
|
|
1720
1987
|
server.registerTool("export_audio", {
|
|
1721
1988
|
title: "Export Audio",
|
|
1722
1989
|
description: "Render and export audio from the current preset to a WAV file. " +
|
|
@@ -2159,9 +2426,1463 @@ 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,
|
|
|
2159
2426
|
],
|
|
2160
2427
|
}));
|
|
2161
2428
|
// ============================================================
|
|
2429
|
+
// TOOLS — Looper (state deltas)
|
|
2430
|
+
// ============================================================
|
|
2431
|
+
// applyStateDelta-equivalent options: full update, dispatch graph, sync KV.
|
|
2432
|
+
const KV_OPTS_LOOPER_STATE = {
|
|
2433
|
+
shouldDispatch: true,
|
|
2434
|
+
syncKVResources: true,
|
|
2435
|
+
syncConfigResources: true,
|
|
2436
|
+
resyncKVAfterDispatch: true,
|
|
2437
|
+
updateParameters: true,
|
|
2438
|
+
broadcastFollowers: true,
|
|
2439
|
+
};
|
|
2440
|
+
async function applyLoopersDelta(loopers, extra) {
|
|
2441
|
+
const delta = { loopers, ...(extra ?? {}) };
|
|
2442
|
+
await bridge.send("applyUnifiedStateKvUpdate", {
|
|
2443
|
+
delta,
|
|
2444
|
+
options: KV_OPTS_LOOPER_STATE,
|
|
2445
|
+
});
|
|
2446
|
+
}
|
|
2447
|
+
server.registerTool("arm_looper_track", {
|
|
2448
|
+
title: "Arm / Disarm Looper Track",
|
|
2449
|
+
description: "Arm or disarm a looper track for recording. Arming alone does not start " +
|
|
2450
|
+
"recording — call start_looper_recording afterwards (or use arm_and_record).",
|
|
2451
|
+
inputSchema: {
|
|
2452
|
+
cellId: z.number().min(1).max(8).describe("Looper track / cell (1-8)."),
|
|
2453
|
+
armed: z.boolean().describe("true to arm, false to disarm."),
|
|
2454
|
+
},
|
|
2455
|
+
}, async ({ cellId, armed }) => {
|
|
2456
|
+
const check = await requireBridge();
|
|
2457
|
+
if (check !== true)
|
|
2458
|
+
return check;
|
|
2459
|
+
await applyLoopersDelta({ [cellId]: { armed: armed ? 1 : 0 } });
|
|
2460
|
+
return text(`${armed ? "Armed" : "Disarmed"} looper track ${cellId}.`);
|
|
2461
|
+
});
|
|
2462
|
+
server.registerTool("set_looper_track", {
|
|
2463
|
+
title: "Set Looper Track State",
|
|
2464
|
+
description: "Update one or more per-track looper fields (mute, overdub mode, reverse, " +
|
|
2465
|
+
"pitch, warp, capture source, level). Pass only the fields you want to change.",
|
|
2466
|
+
inputSchema: {
|
|
2467
|
+
cellId: z.number().min(1).max(8).describe("Looper track / cell (1-8)."),
|
|
2468
|
+
mute: z.boolean().optional().describe("Mute / unmute."),
|
|
2469
|
+
solo: z.boolean().optional().describe("Solo / unsolo (toggle others off if true)."),
|
|
2470
|
+
overdubMode: z
|
|
2471
|
+
.enum(["replace", "overdub"])
|
|
2472
|
+
.optional()
|
|
2473
|
+
.describe("Overdub vs replace behavior on next recording."),
|
|
2474
|
+
reverse: z.boolean().optional().describe("Play recorded clip in reverse."),
|
|
2475
|
+
pitchSemitones: z
|
|
2476
|
+
.number()
|
|
2477
|
+
.min(-24)
|
|
2478
|
+
.max(24)
|
|
2479
|
+
.optional()
|
|
2480
|
+
.describe("Pitch shift in semitones (-24..+24)."),
|
|
2481
|
+
pitchMode: z
|
|
2482
|
+
.enum(["varispeed", "pitchshift"])
|
|
2483
|
+
.optional()
|
|
2484
|
+
.describe("'varispeed' (rate-coupled tape pitch) or 'pitchshift' (formant-preserving)."),
|
|
2485
|
+
warp: z.boolean().optional().describe("Enable tempo-warped playback."),
|
|
2486
|
+
warpMode: z
|
|
2487
|
+
.enum(["offline", "realtime"])
|
|
2488
|
+
.optional()
|
|
2489
|
+
.describe("Offline (pre-rendered) or realtime (Signalsmith) warp."),
|
|
2490
|
+
level: z
|
|
2491
|
+
.number()
|
|
2492
|
+
.min(0)
|
|
2493
|
+
.max(2)
|
|
2494
|
+
.optional()
|
|
2495
|
+
.describe("Per-track gain multiplier (0..2)."),
|
|
2496
|
+
sourceCell: z
|
|
2497
|
+
.number()
|
|
2498
|
+
.min(0)
|
|
2499
|
+
.max(8)
|
|
2500
|
+
.optional()
|
|
2501
|
+
.describe("Capture source: 0 = external input, 1-8 = cell output. (UI-side inverted vs captureSource.)"),
|
|
2502
|
+
autoSelect: z
|
|
2503
|
+
.boolean()
|
|
2504
|
+
.optional()
|
|
2505
|
+
.describe("Enable auto-select cycling of clip regions."),
|
|
2506
|
+
recordedBpm: z
|
|
2507
|
+
.number()
|
|
2508
|
+
.min(0)
|
|
2509
|
+
.max(999)
|
|
2510
|
+
.optional()
|
|
2511
|
+
.describe("Override the recorded BPM for the track (0 = unset)."),
|
|
2512
|
+
},
|
|
2513
|
+
}, async (args) => {
|
|
2514
|
+
const check = await requireBridge();
|
|
2515
|
+
if (check !== true)
|
|
2516
|
+
return check;
|
|
2517
|
+
const { cellId, mute, solo, ...rest } = args;
|
|
2518
|
+
const trackDelta = {};
|
|
2519
|
+
if (typeof mute === "boolean")
|
|
2520
|
+
trackDelta.mute = mute ? 1 : 0;
|
|
2521
|
+
if (typeof solo === "boolean")
|
|
2522
|
+
trackDelta.solo = solo ? 1 : 0;
|
|
2523
|
+
if (rest.overdubMode !== undefined)
|
|
2524
|
+
trackDelta.overdubMode = rest.overdubMode;
|
|
2525
|
+
if (typeof rest.reverse === "boolean")
|
|
2526
|
+
trackDelta.reverse = rest.reverse ? 1 : 0;
|
|
2527
|
+
if (typeof rest.pitchSemitones === "number")
|
|
2528
|
+
trackDelta.pitchSemitones = rest.pitchSemitones;
|
|
2529
|
+
if (rest.pitchMode !== undefined)
|
|
2530
|
+
trackDelta.pitchMode = rest.pitchMode;
|
|
2531
|
+
if (typeof rest.warp === "boolean")
|
|
2532
|
+
trackDelta.warpEnabled = rest.warp ? 1 : 0;
|
|
2533
|
+
if (rest.warpMode !== undefined)
|
|
2534
|
+
trackDelta.warpMode = rest.warpMode;
|
|
2535
|
+
if (typeof rest.level === "number")
|
|
2536
|
+
trackDelta.level = rest.level;
|
|
2537
|
+
if (typeof rest.sourceCell === "number")
|
|
2538
|
+
trackDelta.sourceCell = rest.sourceCell;
|
|
2539
|
+
if (typeof rest.autoSelect === "boolean")
|
|
2540
|
+
trackDelta.autoSelect = rest.autoSelect ? 1 : 0;
|
|
2541
|
+
if (typeof rest.recordedBpm === "number")
|
|
2542
|
+
trackDelta.recordedBpm = rest.recordedBpm;
|
|
2543
|
+
if (Object.keys(trackDelta).length === 0)
|
|
2544
|
+
return text(`No fields to update for looper track ${cellId}.`);
|
|
2545
|
+
await applyLoopersDelta({ [cellId]: trackDelta });
|
|
2546
|
+
return text(`Updated looper track ${cellId}: ${JSON.stringify(trackDelta)}`);
|
|
2547
|
+
});
|
|
2548
|
+
server.registerTool("clear_looper_track", {
|
|
2549
|
+
title: "Clear Looper Track",
|
|
2550
|
+
description: "Clear the recorded clip and any selection state for a looper track. " +
|
|
2551
|
+
"Does NOT delete the audio file from disk (the C++ side handles file cleanup separately).",
|
|
2552
|
+
inputSchema: {
|
|
2553
|
+
cellId: z.number().min(1).max(8).describe("Looper track / cell (1-8)."),
|
|
2554
|
+
},
|
|
2555
|
+
}, async ({ cellId }) => {
|
|
2556
|
+
const check = await requireBridge();
|
|
2557
|
+
if (check !== true)
|
|
2558
|
+
return check;
|
|
2559
|
+
await applyLoopersDelta({
|
|
2560
|
+
[cellId]: {
|
|
2561
|
+
clipPath: "",
|
|
2562
|
+
clipPaths: [],
|
|
2563
|
+
lastRecordedBars: 0,
|
|
2564
|
+
startPPQ: 0,
|
|
2565
|
+
warpedClipPath: "",
|
|
2566
|
+
recordedBpm: 0,
|
|
2567
|
+
selectionStart: 0,
|
|
2568
|
+
selectionEnd: 1,
|
|
2569
|
+
},
|
|
2570
|
+
});
|
|
2571
|
+
return text(`Cleared looper track ${cellId} clip + selection.`);
|
|
2572
|
+
});
|
|
2573
|
+
server.registerTool("set_looper_track_selection", {
|
|
2574
|
+
title: "Set Looper Track Selection",
|
|
2575
|
+
description: "Set the in/out selection (in loop-normalized 0..1 coordinates) for a looper track.",
|
|
2576
|
+
inputSchema: {
|
|
2577
|
+
cellId: z.number().min(1).max(8).describe("Looper track / cell (1-8)."),
|
|
2578
|
+
start: z.number().min(0).max(1).describe("Start (0..1)."),
|
|
2579
|
+
end: z.number().min(0).max(1).describe("End (0..1, must be > start)."),
|
|
2580
|
+
},
|
|
2581
|
+
}, async ({ cellId, start, end }) => {
|
|
2582
|
+
if (end <= start)
|
|
2583
|
+
return error("end must be > start");
|
|
2584
|
+
const check = await requireBridge();
|
|
2585
|
+
if (check !== true)
|
|
2586
|
+
return check;
|
|
2587
|
+
await applyLoopersDelta({
|
|
2588
|
+
[cellId]: { selectionStart: start, selectionEnd: end },
|
|
2589
|
+
});
|
|
2590
|
+
return text(`Looper track ${cellId} selection set to [${start}..${end}].`);
|
|
2591
|
+
});
|
|
2592
|
+
// ============================================================
|
|
2593
|
+
// TOOLS — Looper (native commands)
|
|
2594
|
+
// ============================================================
|
|
2595
|
+
server.registerTool("start_looper_recording", {
|
|
2596
|
+
title: "Start Looper Recording",
|
|
2597
|
+
description: "Queue a looper recording. Recording starts at the next bar boundary when the transport is playing, " +
|
|
2598
|
+
"or immediately when stopped. Tracks must be armed first (or pass armedCellIds to arm + record). " +
|
|
2599
|
+
"Each track's captureSource: 0 = cell output (post-FX), 1 = external input.",
|
|
2600
|
+
inputSchema: {
|
|
2601
|
+
loopBars: z.number().min(0.125).max(64).describe("Loop length in bars."),
|
|
2602
|
+
armedCellIds: z
|
|
2603
|
+
.array(z.number().min(1).max(8))
|
|
2604
|
+
.min(1)
|
|
2605
|
+
.describe("Cells to record (1-8)."),
|
|
2606
|
+
trackMetadata: z
|
|
2607
|
+
.array(z.object({
|
|
2608
|
+
cellId: z.number().min(1).max(8),
|
|
2609
|
+
overdubMode: z.enum(["replace", "overdub"]).default("replace"),
|
|
2610
|
+
existingClipPaths: z.array(z.string()).optional(),
|
|
2611
|
+
existingStartPPQ: z.number().default(0),
|
|
2612
|
+
captureSource: z.number().min(0).max(1).default(0),
|
|
2613
|
+
}))
|
|
2614
|
+
.optional()
|
|
2615
|
+
.describe("Per-track metadata. Defaults: replace mode, cell-output source. Pass existingClipPaths to enable overdub on a prior layer."),
|
|
2616
|
+
},
|
|
2617
|
+
}, async ({ loopBars, armedCellIds, trackMetadata }) => {
|
|
2618
|
+
const check = await requireBridge();
|
|
2619
|
+
if (check !== true)
|
|
2620
|
+
return check;
|
|
2621
|
+
const meta = trackMetadata ??
|
|
2622
|
+
armedCellIds.map((cellId) => ({
|
|
2623
|
+
cellId,
|
|
2624
|
+
overdubMode: "replace",
|
|
2625
|
+
existingClipPaths: [],
|
|
2626
|
+
existingStartPPQ: 0,
|
|
2627
|
+
captureSource: 0,
|
|
2628
|
+
}));
|
|
2629
|
+
await bridge.send("startLooperRecord", {
|
|
2630
|
+
loopBars,
|
|
2631
|
+
armedCellIds,
|
|
2632
|
+
trackMetadata: meta,
|
|
2633
|
+
});
|
|
2634
|
+
return text(`Queued looper recording: ${loopBars} bar${loopBars === 1 ? "" : "s"} on cells ${armedCellIds.join(", ")}.`);
|
|
2635
|
+
});
|
|
2636
|
+
server.registerTool("stop_looper_recording", {
|
|
2637
|
+
title: "Stop Looper Recording",
|
|
2638
|
+
description: "Stop or cancel the active looper recording session.",
|
|
2639
|
+
inputSchema: {},
|
|
2640
|
+
}, async () => {
|
|
2641
|
+
const check = await requireBridge();
|
|
2642
|
+
if (check !== true)
|
|
2643
|
+
return check;
|
|
2644
|
+
await bridge.send("stopLooperRecord");
|
|
2645
|
+
return text("Stopped looper recording.");
|
|
2646
|
+
});
|
|
2647
|
+
server.registerTool("capture_looper_now", {
|
|
2648
|
+
title: "Capture Looper Now",
|
|
2649
|
+
description: "Capture audio that is already in the rolling buffer (requires looper_always_listen=true). " +
|
|
2650
|
+
"Fires immediately if the buffer holds enough audio for the requested bars; otherwise arms and waits.",
|
|
2651
|
+
inputSchema: {
|
|
2652
|
+
bars: z.number().min(0.125).max(64).describe("Bars to capture."),
|
|
2653
|
+
mode: z
|
|
2654
|
+
.enum(["match", "overdub", "replace"])
|
|
2655
|
+
.default("replace")
|
|
2656
|
+
.describe("'match' = follow each track's overdubMode, 'overdub' = layer on existing, 'replace' = overwrite."),
|
|
2657
|
+
},
|
|
2658
|
+
}, async ({ bars, mode }) => {
|
|
2659
|
+
const check = await requireBridge();
|
|
2660
|
+
if (check !== true)
|
|
2661
|
+
return check;
|
|
2662
|
+
await bridge.send("captureLooperNow", { bars, mode });
|
|
2663
|
+
return text(`Captured looper: ${bars} bar${bars === 1 ? "" : "s"} (${mode}).`);
|
|
2664
|
+
});
|
|
2665
|
+
server.registerTool("load_audio_to_looper_track", {
|
|
2666
|
+
title: "Load Audio To Looper Track",
|
|
2667
|
+
description: "Load an audio file directly into a looper track (skips recording). " +
|
|
2668
|
+
"File must be a wav/mp3/aif/flac under the PAM base path or absolute.",
|
|
2669
|
+
inputSchema: {
|
|
2670
|
+
filePath: z.string().describe("Audio file path."),
|
|
2671
|
+
trackId: z.number().min(1).max(8).describe("Looper track (1-8)."),
|
|
2672
|
+
},
|
|
2673
|
+
}, async ({ filePath, trackId }) => {
|
|
2674
|
+
const check = await requireBridge();
|
|
2675
|
+
if (check !== true)
|
|
2676
|
+
return check;
|
|
2677
|
+
await bridge.send("loadAudioToLooperTrack", filePath, trackId);
|
|
2678
|
+
return text(`Loaded ${filePath} into looper track ${trackId}.`);
|
|
2679
|
+
});
|
|
2680
|
+
server.registerTool("request_looper_warp", {
|
|
2681
|
+
title: "Request Looper Warp",
|
|
2682
|
+
description: "Render an offline warp for a looper track's clip at the current host BPM. " +
|
|
2683
|
+
"Result lands in state.loopers[cellId].warpedClipPath when ready.",
|
|
2684
|
+
inputSchema: {
|
|
2685
|
+
cellId: z.number().min(1).max(8).describe("Looper track (1-8)."),
|
|
2686
|
+
},
|
|
2687
|
+
}, async ({ cellId }) => {
|
|
2688
|
+
const check = await requireBridge();
|
|
2689
|
+
if (check !== true)
|
|
2690
|
+
return check;
|
|
2691
|
+
await bridge.send("requestLooperWarp", cellId);
|
|
2692
|
+
return text(`Requested offline warp for looper track ${cellId}.`);
|
|
2693
|
+
});
|
|
2694
|
+
server.registerTool("looper_settings", {
|
|
2695
|
+
title: "Looper Settings",
|
|
2696
|
+
description: "Configure looper-wide behavior: always-listen rolling buffer, input-latency compensation.",
|
|
2697
|
+
inputSchema: {
|
|
2698
|
+
alwaysListen: z
|
|
2699
|
+
.boolean()
|
|
2700
|
+
.optional()
|
|
2701
|
+
.describe("Keep capturing audio into a rolling buffer (required for capture_looper_now)."),
|
|
2702
|
+
latencyCompAuto: z
|
|
2703
|
+
.boolean()
|
|
2704
|
+
.optional()
|
|
2705
|
+
.describe("Auto-compensate for input latency (defaults ON for external input)."),
|
|
2706
|
+
latencyCompOffsetMs: z
|
|
2707
|
+
.number()
|
|
2708
|
+
.min(-500)
|
|
2709
|
+
.max(500)
|
|
2710
|
+
.optional()
|
|
2711
|
+
.describe("Manual fine-tune (-500..+500 ms)."),
|
|
2712
|
+
latencyCompApplyToCells: z
|
|
2713
|
+
.boolean()
|
|
2714
|
+
.optional()
|
|
2715
|
+
.describe("Apply latency comp also when capture source is a cell output."),
|
|
2716
|
+
},
|
|
2717
|
+
}, async (args) => {
|
|
2718
|
+
const check = await requireBridge();
|
|
2719
|
+
if (check !== true)
|
|
2720
|
+
return check;
|
|
2721
|
+
const updates = [];
|
|
2722
|
+
if (typeof args.alwaysListen === "boolean") {
|
|
2723
|
+
await bridge.send("setLooperAlwaysListen", args.alwaysListen);
|
|
2724
|
+
updates.push(`alwaysListen=${args.alwaysListen}`);
|
|
2725
|
+
}
|
|
2726
|
+
if (typeof args.latencyCompAuto === "boolean") {
|
|
2727
|
+
await bridge.send("setLooperLatencyCompAuto", args.latencyCompAuto);
|
|
2728
|
+
updates.push(`latencyCompAuto=${args.latencyCompAuto}`);
|
|
2729
|
+
}
|
|
2730
|
+
if (typeof args.latencyCompOffsetMs === "number") {
|
|
2731
|
+
await bridge.send("setLooperLatencyCompOffsetMs", args.latencyCompOffsetMs);
|
|
2732
|
+
updates.push(`latencyCompOffsetMs=${args.latencyCompOffsetMs}`);
|
|
2733
|
+
}
|
|
2734
|
+
if (typeof args.latencyCompApplyToCells === "boolean") {
|
|
2735
|
+
await bridge.send("setLooperLatencyCompApplyToCells", args.latencyCompApplyToCells);
|
|
2736
|
+
updates.push(`latencyCompApplyToCells=${args.latencyCompApplyToCells}`);
|
|
2737
|
+
}
|
|
2738
|
+
return text(updates.length ? `Looper settings updated: ${updates.join(", ")}` : "No looper settings provided.");
|
|
2739
|
+
});
|
|
2740
|
+
server.registerTool("looper_variation", {
|
|
2741
|
+
title: "Looper Variation",
|
|
2742
|
+
description: "Manage looper variation slots (clip-set snapshots). Operations: 'apply' (immediate), 'queue' (bar-synced), " +
|
|
2743
|
+
"'cancel', 'clear', 'setCurrent', 'save' (snapshot current looper state into the slot).",
|
|
2744
|
+
inputSchema: {
|
|
2745
|
+
op: z
|
|
2746
|
+
.enum(["apply", "queue", "cancel", "clear", "setCurrent", "save"])
|
|
2747
|
+
.describe("Operation to perform."),
|
|
2748
|
+
index: z
|
|
2749
|
+
.number()
|
|
2750
|
+
.min(0)
|
|
2751
|
+
.max(7)
|
|
2752
|
+
.optional()
|
|
2753
|
+
.describe("Variation slot 0-7 (required for apply/queue/clear/setCurrent/save)."),
|
|
2754
|
+
intervalBars: z
|
|
2755
|
+
.number()
|
|
2756
|
+
.min(0.125)
|
|
2757
|
+
.max(64)
|
|
2758
|
+
.optional()
|
|
2759
|
+
.describe("Bar interval for 'queue' (required for queue)."),
|
|
2760
|
+
fadeMs: z.number().min(0).max(2000).optional().describe("Crossfade ms (default 20)."),
|
|
2761
|
+
data: z
|
|
2762
|
+
.record(z.string(), z.unknown())
|
|
2763
|
+
.optional()
|
|
2764
|
+
.describe("For 'save': the looper variation snapshot (object). Pass null to clear instead."),
|
|
2765
|
+
},
|
|
2766
|
+
}, async (args) => {
|
|
2767
|
+
const check = await requireBridge();
|
|
2768
|
+
if (check !== true)
|
|
2769
|
+
return check;
|
|
2770
|
+
switch (args.op) {
|
|
2771
|
+
case "apply":
|
|
2772
|
+
if (args.index === undefined)
|
|
2773
|
+
return error("'apply' requires index");
|
|
2774
|
+
await bridge.send("applyLooperVariationImmediate", args.index);
|
|
2775
|
+
return text(`Applied looper variation ${args.index}.`);
|
|
2776
|
+
case "queue":
|
|
2777
|
+
if (args.index === undefined || args.intervalBars === undefined)
|
|
2778
|
+
return error("'queue' requires index + intervalBars");
|
|
2779
|
+
await bridge.send("queueLooperVariationOnBars", {
|
|
2780
|
+
index: args.index,
|
|
2781
|
+
intervalBars: args.intervalBars,
|
|
2782
|
+
fadeMs: args.fadeMs ?? 20,
|
|
2783
|
+
});
|
|
2784
|
+
return text(`Queued looper variation ${args.index} on next ${args.intervalBars}-bar boundary.`);
|
|
2785
|
+
case "cancel":
|
|
2786
|
+
await bridge.send("cancelQueuedLooperVariation");
|
|
2787
|
+
return text("Cancelled queued looper variation.");
|
|
2788
|
+
case "clear":
|
|
2789
|
+
if (args.index === undefined)
|
|
2790
|
+
return error("'clear' requires index");
|
|
2791
|
+
await bridge.send("clearLooperVariation", args.index);
|
|
2792
|
+
return text(`Cleared looper variation slot ${args.index}.`);
|
|
2793
|
+
case "setCurrent":
|
|
2794
|
+
if (args.index === undefined)
|
|
2795
|
+
return error("'setCurrent' requires index");
|
|
2796
|
+
await bridge.send("setCurrentLooperVariationIndex", args.index);
|
|
2797
|
+
return text(`Current looper variation slot set to ${args.index}.`);
|
|
2798
|
+
case "save":
|
|
2799
|
+
if (args.index === undefined)
|
|
2800
|
+
return error("'save' requires index");
|
|
2801
|
+
await bridge.send("saveLooperVariation", { index: args.index, data: args.data ?? null });
|
|
2802
|
+
return text(args.data
|
|
2803
|
+
? `Saved looper variation slot ${args.index}.`
|
|
2804
|
+
: `Cleared looper variation slot ${args.index} (no data).`);
|
|
2805
|
+
}
|
|
2806
|
+
return text("No-op");
|
|
2807
|
+
});
|
|
2808
|
+
server.registerTool("looper_variation_sync", {
|
|
2809
|
+
title: "Looper Variation Bar-Sync",
|
|
2810
|
+
description: "Configure how looper variation switches synchronize to bar boundaries.",
|
|
2811
|
+
inputSchema: {
|
|
2812
|
+
barSyncEnabled: z.boolean().optional().describe("Toggle bar-sync mode."),
|
|
2813
|
+
intervalBars: z.number().min(0.125).max(64).optional().describe("Sync interval in bars."),
|
|
2814
|
+
},
|
|
2815
|
+
}, async ({ barSyncEnabled, intervalBars }) => {
|
|
2816
|
+
const check = await requireBridge();
|
|
2817
|
+
if (check !== true)
|
|
2818
|
+
return check;
|
|
2819
|
+
const updates = [];
|
|
2820
|
+
if (typeof barSyncEnabled === "boolean") {
|
|
2821
|
+
await bridge.send("setLooperVariationBarSyncEnabled", barSyncEnabled);
|
|
2822
|
+
updates.push(`barSyncEnabled=${barSyncEnabled}`);
|
|
2823
|
+
}
|
|
2824
|
+
if (typeof intervalBars === "number") {
|
|
2825
|
+
await bridge.send("setLooperVariationBarSyncIntervalBars", intervalBars);
|
|
2826
|
+
updates.push(`intervalBars=${intervalBars}`);
|
|
2827
|
+
}
|
|
2828
|
+
return text(updates.length ? `Looper variation sync: ${updates.join(", ")}` : "No values provided.");
|
|
2829
|
+
});
|
|
2830
|
+
// ============================================================
|
|
2831
|
+
// TOOLS — Bar-synced variation scheduling + variation hold
|
|
2832
|
+
// ============================================================
|
|
2833
|
+
server.registerTool("queue_variation", {
|
|
2834
|
+
title: "Queue Variation Switch",
|
|
2835
|
+
description: "Schedule a global variation switch on the next bar boundary. " +
|
|
2836
|
+
"For an immediate switch, use load_variation instead.",
|
|
2837
|
+
inputSchema: {
|
|
2838
|
+
index: z.number().min(0).max(7).describe("Variation slot 0-7."),
|
|
2839
|
+
intervalBars: z.number().min(0.125).max(64).describe("Bars before switching."),
|
|
2840
|
+
fadeMs: z.number().min(0).max(2000).default(20).describe("Crossfade duration in ms."),
|
|
2841
|
+
resetPhase: z
|
|
2842
|
+
.boolean()
|
|
2843
|
+
.default(true)
|
|
2844
|
+
.describe("Reset pattern phase on switch."),
|
|
2845
|
+
},
|
|
2846
|
+
}, async ({ index, intervalBars, fadeMs, resetPhase }) => {
|
|
2847
|
+
const check = await requireBridge();
|
|
2848
|
+
if (check !== true)
|
|
2849
|
+
return check;
|
|
2850
|
+
await bridge.send("queueVariationOnBars", { index, intervalBars, fadeMs, resetPhase });
|
|
2851
|
+
return text(`Queued variation ${index} on next ${intervalBars}-bar boundary.`);
|
|
2852
|
+
});
|
|
2853
|
+
server.registerTool("cancel_queued_variation", {
|
|
2854
|
+
title: "Cancel Queued Variation",
|
|
2855
|
+
description: "Cancel a pending bar-synced variation switch.",
|
|
2856
|
+
inputSchema: {},
|
|
2857
|
+
}, async () => {
|
|
2858
|
+
const check = await requireBridge();
|
|
2859
|
+
if (check !== true)
|
|
2860
|
+
return check;
|
|
2861
|
+
await bridge.send("cancelQueuedVariation");
|
|
2862
|
+
return text("Cancelled queued variation.");
|
|
2863
|
+
});
|
|
2864
|
+
server.registerTool("update_variations", {
|
|
2865
|
+
title: "Update Variations Table",
|
|
2866
|
+
description: "Replace the entire 8-slot variations table with a new array. " +
|
|
2867
|
+
"Each entry is a JSON-stringified variation snapshot (or null for an empty slot). " +
|
|
2868
|
+
"Most agents should prefer save_variation for individual slots.",
|
|
2869
|
+
inputSchema: {
|
|
2870
|
+
variations: z
|
|
2871
|
+
.array(z.union([z.string(), z.null()]))
|
|
2872
|
+
.length(8)
|
|
2873
|
+
.describe("Array of 8 entries (JSON string or null)."),
|
|
2874
|
+
},
|
|
2875
|
+
}, async ({ variations }) => {
|
|
2876
|
+
const check = await requireBridge();
|
|
2877
|
+
if (check !== true)
|
|
2878
|
+
return check;
|
|
2879
|
+
await bridge.send("updateVariations", variations);
|
|
2880
|
+
return text("Updated variations table.");
|
|
2881
|
+
});
|
|
2882
|
+
server.registerTool("variation_hold", {
|
|
2883
|
+
title: "Variation Hold",
|
|
2884
|
+
description: "'Hold while note pressed' mode for variations. Use this to map MIDI notes to momentary " +
|
|
2885
|
+
"variation switches that revert when the note releases.",
|
|
2886
|
+
inputSchema: {
|
|
2887
|
+
enabled: z.boolean().optional().describe("Toggle hold mode on/off."),
|
|
2888
|
+
allNotesOff: z
|
|
2889
|
+
.boolean()
|
|
2890
|
+
.optional()
|
|
2891
|
+
.describe("Force-release any held trigger notes (useful after a stuck MIDI note)."),
|
|
2892
|
+
bindings: z
|
|
2893
|
+
.array(z.object({
|
|
2894
|
+
triggerNote: z.number().min(-1).max(127).describe("MIDI note (-1 unassigned)."),
|
|
2895
|
+
triggerChannel: z.number().min(0).max(16).describe("MIDI channel (0=omni, 1-16)."),
|
|
2896
|
+
}))
|
|
2897
|
+
.max(8)
|
|
2898
|
+
.optional()
|
|
2899
|
+
.describe("Per-variation-slot MIDI bindings (slot 0..7). Array is padded with unassigned slots if shorter than 8."),
|
|
2900
|
+
},
|
|
2901
|
+
}, async ({ enabled, allNotesOff, bindings }) => {
|
|
2902
|
+
const check = await requireBridge();
|
|
2903
|
+
if (check !== true)
|
|
2904
|
+
return check;
|
|
2905
|
+
const updates = [];
|
|
2906
|
+
if (typeof enabled === "boolean") {
|
|
2907
|
+
await bridge.send("setVariationHoldEnabled", enabled);
|
|
2908
|
+
updates.push(`enabled=${enabled}`);
|
|
2909
|
+
}
|
|
2910
|
+
if (bindings) {
|
|
2911
|
+
await bridge.send("syncVariationHoldBindings", bindings);
|
|
2912
|
+
updates.push(`bindings=${bindings.length}`);
|
|
2913
|
+
}
|
|
2914
|
+
if (allNotesOff) {
|
|
2915
|
+
await bridge.send("variationHoldAllNotesOff");
|
|
2916
|
+
updates.push("allNotesOff");
|
|
2917
|
+
}
|
|
2918
|
+
return text(updates.length ? `Variation hold: ${updates.join(", ")}` : "No updates provided.");
|
|
2919
|
+
});
|
|
2920
|
+
// ============================================================
|
|
2921
|
+
// TOOLS — MIDI CC, Program Change, Recording
|
|
2922
|
+
// ============================================================
|
|
2923
|
+
server.registerTool("trigger_midi_cc", {
|
|
2924
|
+
title: "Trigger MIDI CC",
|
|
2925
|
+
description: "Send a MIDI Control Change message into PAM's MIDI router. " +
|
|
2926
|
+
"Maps to any MIDI-learned parameter or looper/variation CC bindings.",
|
|
2927
|
+
inputSchema: {
|
|
2928
|
+
channel: z.number().min(1).max(16).describe("MIDI channel (1-16)."),
|
|
2929
|
+
controller: z.number().min(0).max(127).describe("CC number (0-127)."),
|
|
2930
|
+
value: z.number().min(0).max(127).describe("CC value (0-127)."),
|
|
2931
|
+
},
|
|
2932
|
+
}, async ({ channel, controller, value }) => {
|
|
2933
|
+
const check = await requireBridge();
|
|
2934
|
+
if (check !== true)
|
|
2935
|
+
return check;
|
|
2936
|
+
await bridge.send("midiCC", channel, controller, value);
|
|
2937
|
+
return text(`MIDI CC: ch${channel} cc${controller}=${value}`);
|
|
2938
|
+
});
|
|
2939
|
+
server.registerTool("trigger_midi_program_change", {
|
|
2940
|
+
title: "Trigger MIDI Program Change",
|
|
2941
|
+
description: "Send a MIDI Program Change message (e.g. for preset switching).",
|
|
2942
|
+
inputSchema: {
|
|
2943
|
+
channel: z.number().min(1).max(16).describe("MIDI channel (1-16)."),
|
|
2944
|
+
program: z.number().min(0).max(127).describe("Program number (0-127)."),
|
|
2945
|
+
},
|
|
2946
|
+
}, async ({ channel, program }) => {
|
|
2947
|
+
const check = await requireBridge();
|
|
2948
|
+
if (check !== true)
|
|
2949
|
+
return check;
|
|
2950
|
+
await bridge.send("midiProgramChange", channel, program);
|
|
2951
|
+
return text(`MIDI PC: ch${channel} program=${program}`);
|
|
2952
|
+
});
|
|
2953
|
+
server.registerTool("midi_recording", {
|
|
2954
|
+
title: "MIDI Recording",
|
|
2955
|
+
description: "Control live MIDI recording into the active sequencer. " +
|
|
2956
|
+
"Ops: 'start' (optional opts), 'stop' (with commit flag), 'setOverdub', 'removeNote'.",
|
|
2957
|
+
inputSchema: {
|
|
2958
|
+
op: z
|
|
2959
|
+
.enum(["start", "stop", "setOverdub", "removeNote"])
|
|
2960
|
+
.describe("Operation."),
|
|
2961
|
+
overdub: z
|
|
2962
|
+
.boolean()
|
|
2963
|
+
.optional()
|
|
2964
|
+
.describe("For 'start' or 'setOverdub': overdub vs replace."),
|
|
2965
|
+
commit: z
|
|
2966
|
+
.boolean()
|
|
2967
|
+
.optional()
|
|
2968
|
+
.describe("For 'stop': commit (true, default) or discard (false)."),
|
|
2969
|
+
cellId: z.number().min(1).max(8).optional().describe("For 'removeNote': cell."),
|
|
2970
|
+
time: z.number().optional().describe("For 'removeNote': note time (beats)."),
|
|
2971
|
+
},
|
|
2972
|
+
}, async (args) => {
|
|
2973
|
+
const check = await requireBridge();
|
|
2974
|
+
if (check !== true)
|
|
2975
|
+
return check;
|
|
2976
|
+
switch (args.op) {
|
|
2977
|
+
case "start": {
|
|
2978
|
+
const opts = {};
|
|
2979
|
+
if (typeof args.overdub === "boolean")
|
|
2980
|
+
opts.overdub = args.overdub;
|
|
2981
|
+
await bridge.send("startMidiRecording", opts);
|
|
2982
|
+
return text("Started MIDI recording.");
|
|
2983
|
+
}
|
|
2984
|
+
case "stop":
|
|
2985
|
+
await bridge.send("stopMidiRecording", { commit: args.commit ?? true });
|
|
2986
|
+
return text(`Stopped MIDI recording (commit=${args.commit ?? true}).`);
|
|
2987
|
+
case "setOverdub":
|
|
2988
|
+
if (typeof args.overdub !== "boolean")
|
|
2989
|
+
return error("'setOverdub' requires overdub");
|
|
2990
|
+
await bridge.send("setMidiRecordingOverdub", args.overdub);
|
|
2991
|
+
return text(`MIDI recording overdub=${args.overdub}.`);
|
|
2992
|
+
case "removeNote":
|
|
2993
|
+
if (typeof args.cellId !== "number" || typeof args.time !== "number")
|
|
2994
|
+
return error("'removeNote' requires cellId + time");
|
|
2995
|
+
await bridge.send("removeRecordedNote", { cellId: args.cellId, time: args.time });
|
|
2996
|
+
return text(`Removed recorded note: cell ${args.cellId} at t=${args.time}.`);
|
|
2997
|
+
}
|
|
2998
|
+
return text("No-op");
|
|
2999
|
+
});
|
|
3000
|
+
// ============================================================
|
|
3001
|
+
// TOOLS — Modulation updates (LFO/Env/Vary/Macro/ParamSeq)
|
|
3002
|
+
// ============================================================
|
|
3003
|
+
server.registerTool("update_modulation", {
|
|
3004
|
+
title: "Update Modulation",
|
|
3005
|
+
description: "Apply a delta to a modulation source: LFO, Envelope, Vary, Macro, ParamSeq config, or ParamSeq sequence data. " +
|
|
3006
|
+
"The payload is type-specific; see modulation update flows in interop.ts (applyLfoUpdate / updateEnvelope / etc.). " +
|
|
3007
|
+
"Use add_modulation to first attach a source to a target — then this tool to edit it.",
|
|
3008
|
+
inputSchema: {
|
|
3009
|
+
type: z
|
|
3010
|
+
.enum(["lfo", "envelope", "vary", "macro", "paramSeq", "paramSeqSequence"])
|
|
3011
|
+
.describe("Which modulation system to update."),
|
|
3012
|
+
payload: z
|
|
3013
|
+
.record(z.string(), z.unknown())
|
|
3014
|
+
.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."),
|
|
3015
|
+
},
|
|
3016
|
+
}, async ({ type, payload }) => {
|
|
3017
|
+
const check = await requireBridge();
|
|
3018
|
+
if (check !== true)
|
|
3019
|
+
return check;
|
|
3020
|
+
const data = JSON.stringify(payload);
|
|
3021
|
+
const action = type === "lfo"
|
|
3022
|
+
? "applyLfoUpdate"
|
|
3023
|
+
: type === "envelope"
|
|
3024
|
+
? "applyEnvelopeUpdate"
|
|
3025
|
+
: type === "vary"
|
|
3026
|
+
? "applyVaryUpdate"
|
|
3027
|
+
: type === "macro"
|
|
3028
|
+
? "applyMacroUpdate"
|
|
3029
|
+
: type === "paramSeq"
|
|
3030
|
+
? "applyParamSeqUpdate"
|
|
3031
|
+
: "applyParamSeqSequenceUpdate";
|
|
3032
|
+
await bridge.send(action, data);
|
|
3033
|
+
return text(`Applied ${type} update.`);
|
|
3034
|
+
});
|
|
3035
|
+
// ============================================================
|
|
3036
|
+
// TOOLS — Song mode
|
|
3037
|
+
// ============================================================
|
|
3038
|
+
server.registerTool("song_mode", {
|
|
3039
|
+
title: "Song Mode",
|
|
3040
|
+
description: "Control PAM's song mode (timeline of variation/preset steps). Ops: 'setSequence', 'clear', " +
|
|
3041
|
+
"'toggle', 'setLoopPoints', 'toggleLoop', 'setCrossfadeMs', 'setPlayhead'.",
|
|
3042
|
+
inputSchema: {
|
|
3043
|
+
op: z
|
|
3044
|
+
.enum([
|
|
3045
|
+
"setSequence",
|
|
3046
|
+
"clear",
|
|
3047
|
+
"toggle",
|
|
3048
|
+
"setLoopPoints",
|
|
3049
|
+
"toggleLoop",
|
|
3050
|
+
"setCrossfadeMs",
|
|
3051
|
+
"setPlayhead",
|
|
3052
|
+
])
|
|
3053
|
+
.describe("Operation."),
|
|
3054
|
+
steps: z
|
|
3055
|
+
.array(z.object({
|
|
3056
|
+
variationIndex: z.number().min(0).max(7).describe("Variation slot 0-7."),
|
|
3057
|
+
startPositionPPQ: z.number().describe("Start position in PPQ (4 PPQ = 1 bar at 4/4)."),
|
|
3058
|
+
endPositionPPQ: z.number().optional().describe("End position in PPQ."),
|
|
3059
|
+
presetPath: z
|
|
3060
|
+
.string()
|
|
3061
|
+
.optional()
|
|
3062
|
+
.describe("If non-empty, load this preset instead of switching variation."),
|
|
3063
|
+
muted: z.boolean().optional(),
|
|
3064
|
+
probability: z.number().min(0).max(1).optional().describe("Trigger probability (0-1)."),
|
|
3065
|
+
}))
|
|
3066
|
+
.optional()
|
|
3067
|
+
.describe("For 'setSequence': step list."),
|
|
3068
|
+
enabled: z.boolean().optional().describe("For 'toggle' / 'toggleLoop': enabled state."),
|
|
3069
|
+
start: z
|
|
3070
|
+
.number()
|
|
3071
|
+
.nullable()
|
|
3072
|
+
.optional()
|
|
3073
|
+
.describe("For 'setLoopPoints': loop start PPQ (null clears)."),
|
|
3074
|
+
end: z
|
|
3075
|
+
.number()
|
|
3076
|
+
.nullable()
|
|
3077
|
+
.optional()
|
|
3078
|
+
.describe("For 'setLoopPoints': loop end PPQ (null clears)."),
|
|
3079
|
+
crossfadeMs: z
|
|
3080
|
+
.number()
|
|
3081
|
+
.min(0)
|
|
3082
|
+
.max(2000)
|
|
3083
|
+
.optional()
|
|
3084
|
+
.describe("For 'setCrossfadeMs'."),
|
|
3085
|
+
ppq: z.number().optional().describe("For 'setPlayhead': PPQ position to scrub to."),
|
|
3086
|
+
},
|
|
3087
|
+
}, async (args) => {
|
|
3088
|
+
const check = await requireBridge();
|
|
3089
|
+
if (check !== true)
|
|
3090
|
+
return check;
|
|
3091
|
+
switch (args.op) {
|
|
3092
|
+
case "setSequence":
|
|
3093
|
+
if (!args.steps)
|
|
3094
|
+
return error("'setSequence' requires steps");
|
|
3095
|
+
await bridge.send("setSongModeSequence", args.steps);
|
|
3096
|
+
return text(`Song mode sequence set (${args.steps.length} steps).`);
|
|
3097
|
+
case "clear":
|
|
3098
|
+
await bridge.send("clearSongModeSequence");
|
|
3099
|
+
return text("Cleared song mode sequence.");
|
|
3100
|
+
case "toggle":
|
|
3101
|
+
if (typeof args.enabled !== "boolean")
|
|
3102
|
+
return error("'toggle' requires enabled");
|
|
3103
|
+
await bridge.send("toggleSongMode", args.enabled);
|
|
3104
|
+
return text(`Song mode ${args.enabled ? "enabled" : "disabled"}.`);
|
|
3105
|
+
case "setLoopPoints":
|
|
3106
|
+
await bridge.send("setSongModeLoopPoints", {
|
|
3107
|
+
start: args.start ?? null,
|
|
3108
|
+
end: args.end ?? null,
|
|
3109
|
+
});
|
|
3110
|
+
return text(`Song mode loop: [${args.start ?? "null"}..${args.end ?? "null"}].`);
|
|
3111
|
+
case "toggleLoop":
|
|
3112
|
+
if (typeof args.enabled !== "boolean")
|
|
3113
|
+
return error("'toggleLoop' requires enabled");
|
|
3114
|
+
await bridge.send("toggleSongModeLoop", args.enabled);
|
|
3115
|
+
return text(`Song mode loop ${args.enabled ? "on" : "off"}.`);
|
|
3116
|
+
case "setCrossfadeMs":
|
|
3117
|
+
if (typeof args.crossfadeMs !== "number")
|
|
3118
|
+
return error("'setCrossfadeMs' requires crossfadeMs");
|
|
3119
|
+
await bridge.send("setSongModeCrossfadeMs", args.crossfadeMs);
|
|
3120
|
+
return text(`Song mode crossfade = ${args.crossfadeMs}ms.`);
|
|
3121
|
+
case "setPlayhead":
|
|
3122
|
+
if (typeof args.ppq !== "number")
|
|
3123
|
+
return error("'setPlayhead' requires ppq");
|
|
3124
|
+
await bridge.send("setSongModePlayhead", args.ppq);
|
|
3125
|
+
return text(`Song mode playhead → PPQ ${args.ppq}.`);
|
|
3126
|
+
}
|
|
3127
|
+
return text("No-op");
|
|
3128
|
+
});
|
|
3129
|
+
// ============================================================
|
|
3130
|
+
// TOOLS — Metronome
|
|
3131
|
+
// ============================================================
|
|
3132
|
+
server.registerTool("set_metronome", {
|
|
3133
|
+
title: "Set Metronome",
|
|
3134
|
+
description: "Toggle PAM's internal metronome on or off.",
|
|
3135
|
+
inputSchema: {
|
|
3136
|
+
enabled: z.boolean().describe("Metronome enabled?"),
|
|
3137
|
+
},
|
|
3138
|
+
}, async ({ enabled }) => {
|
|
3139
|
+
const check = await requireBridge();
|
|
3140
|
+
if (check !== true)
|
|
3141
|
+
return check;
|
|
3142
|
+
await bridge.send("setMetronomeEnabled", enabled);
|
|
3143
|
+
return text(`Metronome ${enabled ? "on" : "off"}.`);
|
|
3144
|
+
});
|
|
3145
|
+
// ============================================================
|
|
3146
|
+
// TOOLS — Sample analysis + ops
|
|
3147
|
+
// ============================================================
|
|
3148
|
+
server.registerTool("analyze_sample_tempo", {
|
|
3149
|
+
title: "Analyze Sample Tempo",
|
|
3150
|
+
description: "Run BPM detection and transient/slice analysis on an audio file. " +
|
|
3151
|
+
"Result is dispatched asynchronously into PAM's state — fetch it via get_state after a short delay (~100-500ms). " +
|
|
3152
|
+
"Use this before resample_sample_to_bpm if you don't already know the source BPM.",
|
|
3153
|
+
inputSchema: {
|
|
3154
|
+
filePath: z.string().describe("Path to the audio file."),
|
|
3155
|
+
transientSensitivity: z
|
|
3156
|
+
.number()
|
|
3157
|
+
.min(0)
|
|
3158
|
+
.max(100)
|
|
3159
|
+
.default(50)
|
|
3160
|
+
.describe("Onset-detection sensitivity (0..100)."),
|
|
3161
|
+
sliceStartNorm: z
|
|
3162
|
+
.number()
|
|
3163
|
+
.min(0)
|
|
3164
|
+
.max(1)
|
|
3165
|
+
.default(0)
|
|
3166
|
+
.describe("Analysis window start (0..1 normalized)."),
|
|
3167
|
+
sliceEndNorm: z
|
|
3168
|
+
.number()
|
|
3169
|
+
.min(0)
|
|
3170
|
+
.max(1)
|
|
3171
|
+
.default(1)
|
|
3172
|
+
.describe("Analysis window end (0..1 normalized)."),
|
|
3173
|
+
maxSlices: z
|
|
3174
|
+
.number()
|
|
3175
|
+
.min(1)
|
|
3176
|
+
.max(64)
|
|
3177
|
+
.default(64)
|
|
3178
|
+
.describe("Cap on number of detected slices."),
|
|
3179
|
+
},
|
|
3180
|
+
}, async (args) => {
|
|
3181
|
+
const check = await requireBridge();
|
|
3182
|
+
if (check !== true)
|
|
3183
|
+
return check;
|
|
3184
|
+
await bridge.send("analyzeSampleTempo", args.filePath, args.transientSensitivity, args.sliceStartNorm, args.sliceEndNorm, args.maxSlices);
|
|
3185
|
+
return text(`Queued tempo analysis for ${args.filePath}.`);
|
|
3186
|
+
});
|
|
3187
|
+
server.registerTool("get_sample_peaks", {
|
|
3188
|
+
title: "Get Sample Peaks",
|
|
3189
|
+
description: "Read a sample's waveform peak data for display/analysis. Returns an array of min/max peaks at the requested resolution. " +
|
|
3190
|
+
"Searches VFS first, then loads from disk.",
|
|
3191
|
+
inputSchema: {
|
|
3192
|
+
samplePath: z.string().describe("Sample path (relative or absolute)."),
|
|
3193
|
+
samplesPerPixel: z
|
|
3194
|
+
.number()
|
|
3195
|
+
.min(1)
|
|
3196
|
+
.optional()
|
|
3197
|
+
.describe("Resolution control — if set, peaks downsampled to ~ numSamples/samplesPerPixel bins (clamped 64..8192). Default 0 = 8192 pixels."),
|
|
3198
|
+
},
|
|
3199
|
+
annotations: { readOnlyHint: true },
|
|
3200
|
+
}, async ({ samplePath, samplesPerPixel }) => {
|
|
3201
|
+
const check = await requireBridge();
|
|
3202
|
+
if (check !== true)
|
|
3203
|
+
return check;
|
|
3204
|
+
const result = await bridge.send("requestSamplePeaks", samplePath, samplesPerPixel ?? 0);
|
|
3205
|
+
return json(result);
|
|
3206
|
+
});
|
|
3207
|
+
server.registerTool("preview_slice", {
|
|
3208
|
+
title: "Preview Slice",
|
|
3209
|
+
description: "Audition a single slice of a cell's sample.",
|
|
3210
|
+
inputSchema: {
|
|
3211
|
+
cellId: z.number().min(1).max(8),
|
|
3212
|
+
sliceNumber: z.number().min(0).describe("Slice index."),
|
|
3213
|
+
},
|
|
3214
|
+
}, async ({ cellId, sliceNumber }) => {
|
|
3215
|
+
const check = await requireBridge();
|
|
3216
|
+
if (check !== true)
|
|
3217
|
+
return check;
|
|
3218
|
+
await bridge.send("previewSlice", cellId, sliceNumber);
|
|
3219
|
+
return text(`Previewed slice ${sliceNumber} of cell ${cellId}.`);
|
|
3220
|
+
});
|
|
3221
|
+
server.registerTool("resample_sample_to_bpm", {
|
|
3222
|
+
title: "Resample Sample To BPM",
|
|
3223
|
+
description: "Time-stretch a cell's sample to the current project BPM (or a target BPM). " +
|
|
3224
|
+
"Non-destructive — use restore_original_sample to revert.",
|
|
3225
|
+
inputSchema: {
|
|
3226
|
+
cellId: z.number().min(1).max(8),
|
|
3227
|
+
sourceBpm: z
|
|
3228
|
+
.number()
|
|
3229
|
+
.min(20)
|
|
3230
|
+
.max(999)
|
|
3231
|
+
.optional()
|
|
3232
|
+
.describe("Source BPM. If omitted, PAM uses the analyzed/declared BPM in state."),
|
|
3233
|
+
targetBpm: z
|
|
3234
|
+
.number()
|
|
3235
|
+
.min(20)
|
|
3236
|
+
.max(999)
|
|
3237
|
+
.optional()
|
|
3238
|
+
.describe("Target BPM (defaults to current project BPM)."),
|
|
3239
|
+
},
|
|
3240
|
+
}, async (args) => {
|
|
3241
|
+
const check = await requireBridge();
|
|
3242
|
+
if (check !== true)
|
|
3243
|
+
return check;
|
|
3244
|
+
await bridge.send("resampleSampleToBPM", args);
|
|
3245
|
+
return text(`Resampled cell ${args.cellId} to BPM.`);
|
|
3246
|
+
});
|
|
3247
|
+
server.registerTool("restore_original_sample", {
|
|
3248
|
+
title: "Restore Original Sample",
|
|
3249
|
+
description: "Revert a cell's sample to its un-edited source (undoes resample / trim / etc.).",
|
|
3250
|
+
inputSchema: {
|
|
3251
|
+
cellId: z.number().min(1).max(8),
|
|
3252
|
+
},
|
|
3253
|
+
}, async ({ cellId }) => {
|
|
3254
|
+
const check = await requireBridge();
|
|
3255
|
+
if (check !== true)
|
|
3256
|
+
return check;
|
|
3257
|
+
await bridge.send("restoreOriginalSample", { cellId });
|
|
3258
|
+
return text(`Restored original sample for cell ${cellId}.`);
|
|
3259
|
+
});
|
|
3260
|
+
server.registerTool("build_sample_chain", {
|
|
3261
|
+
title: "Build Sample Chain",
|
|
3262
|
+
description: "Concatenate multiple audio files into one chained sample loaded onto a cell. Useful for sample-flip kits.",
|
|
3263
|
+
inputSchema: {
|
|
3264
|
+
filePaths: z.array(z.string()).min(1).describe("Audio files in chain order."),
|
|
3265
|
+
cellId: z.number().min(1).max(8).describe("Destination cell."),
|
|
3266
|
+
},
|
|
3267
|
+
}, async ({ filePaths, cellId }) => {
|
|
3268
|
+
const check = await requireBridge();
|
|
3269
|
+
if (check !== true)
|
|
3270
|
+
return check;
|
|
3271
|
+
const result = await bridge.send("buildSampleChain", filePaths, cellId);
|
|
3272
|
+
return json(result);
|
|
3273
|
+
});
|
|
3274
|
+
server.registerTool("export_slice", {
|
|
3275
|
+
title: "Export Slice",
|
|
3276
|
+
description: "Export a single slice of a cell's sample as a standalone WAV.",
|
|
3277
|
+
inputSchema: {
|
|
3278
|
+
cellId: z.number().min(1).max(8),
|
|
3279
|
+
sliceIndex: z.number().min(0),
|
|
3280
|
+
},
|
|
3281
|
+
}, async ({ cellId, sliceIndex }) => {
|
|
3282
|
+
const check = await requireBridge();
|
|
3283
|
+
if (check !== true)
|
|
3284
|
+
return check;
|
|
3285
|
+
await bridge.send("exportSliceForCell", { cellId, sliceIndex });
|
|
3286
|
+
return text(`Exported slice ${sliceIndex} of cell ${cellId}.`);
|
|
3287
|
+
});
|
|
3288
|
+
// ============================================================
|
|
3289
|
+
// TOOLS — Live resample (capture graph output into a cell)
|
|
3290
|
+
// ============================================================
|
|
3291
|
+
server.registerTool("live_resample", {
|
|
3292
|
+
title: "Live Resample",
|
|
3293
|
+
description: "Capture PAM's audio graph output into a cell as a new sample. Ops: 'start' / 'stop'. " +
|
|
3294
|
+
"Transport must be playing for the capture to produce audio.",
|
|
3295
|
+
inputSchema: {
|
|
3296
|
+
op: z.enum(["start", "stop"]).describe("Operation."),
|
|
3297
|
+
cellId: z
|
|
3298
|
+
.number()
|
|
3299
|
+
.min(1)
|
|
3300
|
+
.max(8)
|
|
3301
|
+
.optional()
|
|
3302
|
+
.describe("For 'start': destination cell."),
|
|
3303
|
+
bars: z
|
|
3304
|
+
.number()
|
|
3305
|
+
.min(0.125)
|
|
3306
|
+
.max(64)
|
|
3307
|
+
.optional()
|
|
3308
|
+
.describe("For 'start': capture length in bars."),
|
|
3309
|
+
finalize: z
|
|
3310
|
+
.boolean()
|
|
3311
|
+
.default(true)
|
|
3312
|
+
.describe("For 'stop': finalize the captured clip (false discards)."),
|
|
3313
|
+
},
|
|
3314
|
+
}, async (args) => {
|
|
3315
|
+
const check = await requireBridge();
|
|
3316
|
+
if (check !== true)
|
|
3317
|
+
return check;
|
|
3318
|
+
if (args.op === "start") {
|
|
3319
|
+
if (typeof args.cellId !== "number" || typeof args.bars !== "number")
|
|
3320
|
+
return error("'start' requires cellId + bars");
|
|
3321
|
+
await bridge.send("resampleGraphToCell", JSON.stringify({ cellId: args.cellId, bars: args.bars }));
|
|
3322
|
+
return text(`Started live resample → cell ${args.cellId} (${args.bars} bars).`);
|
|
3323
|
+
}
|
|
3324
|
+
await bridge.send("stopLiveResample", args.finalize ?? true);
|
|
3325
|
+
return text(`Stopped live resample (finalize=${args.finalize ?? true}).`);
|
|
3326
|
+
});
|
|
3327
|
+
// ============================================================
|
|
3328
|
+
// TOOLS — Plugin host (scan/load/unload/param/bypass on hosted VST3/AU/CLAP)
|
|
3329
|
+
// Instrument-role only. Effect-side hosting is not exposed via MCP yet
|
|
3330
|
+
// (MCPBridge only routes through the instrument manager).
|
|
3331
|
+
// ============================================================
|
|
3332
|
+
const PLUGIN_FORMAT = z.enum(["VST3", "AudioUnit", "CLAP"]);
|
|
3333
|
+
// Whitelist of scan-path roots. Mirrors
|
|
3334
|
+
// interface/src/utils/pluginScanPaths.ts (DEFAULT_PATHS_MAC / _WIN).
|
|
3335
|
+
// MCP-supplied searchPaths must resolve to entries beneath one of these
|
|
3336
|
+
// roots so an agent can't probe ~/.ssh or other sensitive folders.
|
|
3337
|
+
function expandHome(p) {
|
|
3338
|
+
if (p.startsWith("~/"))
|
|
3339
|
+
return resolve(process.env.HOME ?? "", p.slice(2));
|
|
3340
|
+
if (p === "~")
|
|
3341
|
+
return process.env.HOME ?? "";
|
|
3342
|
+
return p;
|
|
3343
|
+
}
|
|
3344
|
+
const PLUGIN_SCAN_PATH_ROOTS = (process.platform === "win32"
|
|
3345
|
+
? ["C:/Program Files/Common Files/VST3", "C:/Program Files/Common Files/CLAP"]
|
|
3346
|
+
: [
|
|
3347
|
+
"/Library/Audio/Plug-Ins/VST3",
|
|
3348
|
+
"~/Library/Audio/Plug-Ins/VST3",
|
|
3349
|
+
"/Library/Audio/Plug-Ins/Components",
|
|
3350
|
+
"~/Library/Audio/Plug-Ins/Components",
|
|
3351
|
+
"/Library/Audio/Plug-Ins/CLAP",
|
|
3352
|
+
"~/Library/Audio/Plug-Ins/CLAP",
|
|
3353
|
+
]).map((p) => resolve(expandHome(p)));
|
|
3354
|
+
function isPathUnderWhitelist(p) {
|
|
3355
|
+
const abs = resolve(expandHome(p));
|
|
3356
|
+
return PLUGIN_SCAN_PATH_ROOTS.some((root) => abs === root || abs.startsWith(root + "/") || abs.startsWith(root + "\\"));
|
|
3357
|
+
}
|
|
3358
|
+
// fileOrIdentifier values returned by list_plugins. load_plugin only
|
|
3359
|
+
// accepts entries observed here so an agent can't instantiate arbitrary
|
|
3360
|
+
// binaries.
|
|
3361
|
+
const knownPluginIdentifiers = new Set();
|
|
3362
|
+
// Hosted-plugin tools target PAM cells. Cap to the manifest's cell count
|
|
3363
|
+
// (falls back to 8 — PAM's shipping max) rather than the C++ slot
|
|
3364
|
+
// reservation of 32.
|
|
3365
|
+
async function getMaxCellCount() {
|
|
3366
|
+
try {
|
|
3367
|
+
const m = await loadManifest();
|
|
3368
|
+
const cells = m.cells;
|
|
3369
|
+
if (Array.isArray(cells) && cells.length > 0)
|
|
3370
|
+
return cells.length;
|
|
3371
|
+
}
|
|
3372
|
+
catch {
|
|
3373
|
+
// manifest unavailable — use shipping default
|
|
3374
|
+
}
|
|
3375
|
+
return 8;
|
|
3376
|
+
}
|
|
3377
|
+
async function clampCellId(cellId) {
|
|
3378
|
+
const max = await getMaxCellCount();
|
|
3379
|
+
if (!Number.isInteger(cellId) || cellId < 1 || cellId > max) {
|
|
3380
|
+
return `cellId must be an integer in [1, ${max}]`;
|
|
3381
|
+
}
|
|
3382
|
+
return cellId;
|
|
3383
|
+
}
|
|
3384
|
+
server.registerTool("list_plugins", {
|
|
3385
|
+
title: "List Plugins",
|
|
3386
|
+
description: "List plugins that have been discovered by PAM's scanner (VST3, AudioUnit, and CLAP). " +
|
|
3387
|
+
"Returns `plugins` (each: name, manufacturer, format, category, fileOrIdentifier, isInstrument, version) " +
|
|
3388
|
+
"and `blocklist` (paths the scanner marked unsafe). " +
|
|
3389
|
+
"Use this before `load_plugin` to find a valid `path` + `format`. " +
|
|
3390
|
+
"If the list is empty, call `scan_plugins` first. " +
|
|
3391
|
+
"Note: PAM's MCP hosts plugins in the per-cell instrument slot only — FX-side hosting is not exposed yet.",
|
|
3392
|
+
inputSchema: {
|
|
3393
|
+
format: PLUGIN_FORMAT.optional().describe("Filter by plugin format."),
|
|
3394
|
+
isInstrument: z.boolean().optional().describe("If true, only instruments; false, only effects."),
|
|
3395
|
+
nameContains: z.string().optional().describe("Case-insensitive substring match on the plugin name."),
|
|
3396
|
+
},
|
|
3397
|
+
}, async (args) => {
|
|
3398
|
+
const check = await requireBridge();
|
|
3399
|
+
if (check !== true)
|
|
3400
|
+
return check;
|
|
3401
|
+
const filter = {};
|
|
3402
|
+
if (args.format)
|
|
3403
|
+
filter.format = args.format;
|
|
3404
|
+
if (typeof args.isInstrument === "boolean")
|
|
3405
|
+
filter.isInstrument = args.isInstrument;
|
|
3406
|
+
if (args.nameContains)
|
|
3407
|
+
filter.nameContains = args.nameContains;
|
|
3408
|
+
const raw = (await bridge.send("listPlugins", filter));
|
|
3409
|
+
// Record every fileOrIdentifier so load_plugin can validate that the
|
|
3410
|
+
// caller-supplied path was discovered by PAM's own scanner.
|
|
3411
|
+
try {
|
|
3412
|
+
const parsed = JSON.parse(raw);
|
|
3413
|
+
if (Array.isArray(parsed.plugins)) {
|
|
3414
|
+
for (const p of parsed.plugins) {
|
|
3415
|
+
if (typeof p.fileOrIdentifier === "string") {
|
|
3416
|
+
knownPluginIdentifiers.add(p.fileOrIdentifier);
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3421
|
+
catch {
|
|
3422
|
+
// C++ payload not JSON — fall through; load_plugin will reject unknowns.
|
|
3423
|
+
}
|
|
3424
|
+
return text(raw);
|
|
3425
|
+
});
|
|
3426
|
+
server.registerTool("scan_plugins", {
|
|
3427
|
+
title: "Scan Plugins",
|
|
3428
|
+
description: "Trigger an async scan for VST3, AudioUnit, and CLAP plugins. " +
|
|
3429
|
+
"Returns immediately — poll `get_plugin_scan_status` for progress and call `list_plugins` " +
|
|
3430
|
+
"once `isScanning` is false. Without `searchPaths`, scans default OS plugin folders. " +
|
|
3431
|
+
"Supplied `searchPaths` must live under PAM's approved plugin roots " +
|
|
3432
|
+
"(/Library/Audio/Plug-Ins/{VST3,Components,CLAP} or their ~ equivalents on macOS; " +
|
|
3433
|
+
"C:/Program Files/Common Files/{VST3,CLAP} on Windows).",
|
|
3434
|
+
inputSchema: {
|
|
3435
|
+
searchPaths: z
|
|
3436
|
+
.array(z.string())
|
|
3437
|
+
.optional()
|
|
3438
|
+
.describe("Folders to scan. Must be inside an approved plugin root. " +
|
|
3439
|
+
"Leave empty to use OS defaults (/Library/Audio/Plug-Ins/* on macOS, etc.)."),
|
|
3440
|
+
blocklist: z
|
|
3441
|
+
.array(z.string())
|
|
3442
|
+
.optional()
|
|
3443
|
+
.describe("File paths or identifiers the scanner should skip."),
|
|
3444
|
+
},
|
|
3445
|
+
}, async (args) => {
|
|
3446
|
+
const check = await requireBridge();
|
|
3447
|
+
if (check !== true)
|
|
3448
|
+
return check;
|
|
3449
|
+
const payload = {};
|
|
3450
|
+
if (args.searchPaths && args.searchPaths.length > 0) {
|
|
3451
|
+
const rejected = args.searchPaths.filter((p) => !isPathUnderWhitelist(p));
|
|
3452
|
+
if (rejected.length > 0) {
|
|
3453
|
+
return error(`Refusing to scan path(s) outside approved plugin roots: ${rejected.join(", ")}. ` +
|
|
3454
|
+
`Allowed roots: ${PLUGIN_SCAN_PATH_ROOTS.join(", ")}.`);
|
|
3455
|
+
}
|
|
3456
|
+
payload.searchPaths = args.searchPaths;
|
|
3457
|
+
}
|
|
3458
|
+
if (args.blocklist)
|
|
3459
|
+
payload.blocklist = args.blocklist;
|
|
3460
|
+
await bridge.send("scanPlugins", payload);
|
|
3461
|
+
return text("Plugin scan started. Poll get_plugin_scan_status until isScanning is false, then call list_plugins.");
|
|
3462
|
+
});
|
|
3463
|
+
server.registerTool("cancel_plugin_scan", {
|
|
3464
|
+
title: "Cancel Plugin Scan",
|
|
3465
|
+
description: "Cancel the in-flight plugin scan. Safe to call even if no scan is running.",
|
|
3466
|
+
inputSchema: {},
|
|
3467
|
+
}, async () => {
|
|
3468
|
+
const check = await requireBridge();
|
|
3469
|
+
if (check !== true)
|
|
3470
|
+
return check;
|
|
3471
|
+
await bridge.send("cancelPluginScan");
|
|
3472
|
+
return text("Cancelled (or no scan was running).");
|
|
3473
|
+
});
|
|
3474
|
+
server.registerTool("get_plugin_scan_status", {
|
|
3475
|
+
title: "Plugin Scan Status",
|
|
3476
|
+
description: "Read the latest plugin scan progress. Returns `isScanning`, `format`, `currentPath`, " +
|
|
3477
|
+
"`fraction` (0..1), `elapsedMs`, `isStall` (true when the in-flight plugin hasn't returned in 2s), " +
|
|
3478
|
+
"and `updatedAtMs`. Poll while a scan is running.",
|
|
3479
|
+
inputSchema: {},
|
|
3480
|
+
}, async () => {
|
|
3481
|
+
const check = await requireBridge();
|
|
3482
|
+
if (check !== true)
|
|
3483
|
+
return check;
|
|
3484
|
+
const raw = (await bridge.send("getPluginScanStatus"));
|
|
3485
|
+
return text(raw);
|
|
3486
|
+
});
|
|
3487
|
+
server.registerTool("list_loaded_plugins", {
|
|
3488
|
+
title: "List Loaded Plugins",
|
|
3489
|
+
description: "List plugins currently hosted in PAM cells (instrument slot only — FX-side hosting is not exposed). " +
|
|
3490
|
+
"Each entry: cellId, name, manufacturer, format, " +
|
|
3491
|
+
"path, isInstrument, bypassed, and the plugin's full param list (index, name, label, value, defaultValue). " +
|
|
3492
|
+
"Use the param `index` with `set_plugin_param`.",
|
|
3493
|
+
inputSchema: {},
|
|
3494
|
+
}, async () => {
|
|
3495
|
+
const check = await requireBridge();
|
|
3496
|
+
if (check !== true)
|
|
3497
|
+
return check;
|
|
3498
|
+
const raw = (await bridge.send("listLoadedPlugins"));
|
|
3499
|
+
return text(raw);
|
|
3500
|
+
});
|
|
3501
|
+
server.registerTool("load_plugin", {
|
|
3502
|
+
title: "Load Plugin Into Cell",
|
|
3503
|
+
description: "Load a hosted plugin (VST3, AudioUnit, or CLAP) into a cell's instrument slot. Blocks until the plugin probes + " +
|
|
3504
|
+
"instantiates (can take seconds for heavy plugins) and returns success or the underlying error. " +
|
|
3505
|
+
"`path` must be a `fileOrIdentifier` returned by a previous `list_plugins` call in this session.",
|
|
3506
|
+
inputSchema: {
|
|
3507
|
+
cellId: z.number().int().min(1).describe("Target cell (1-based)."),
|
|
3508
|
+
path: z.string().describe("Plugin fileOrIdentifier from list_plugins."),
|
|
3509
|
+
format: PLUGIN_FORMAT.describe("Plugin format."),
|
|
3510
|
+
},
|
|
3511
|
+
}, async (args) => {
|
|
3512
|
+
const check = await requireBridge();
|
|
3513
|
+
if (check !== true)
|
|
3514
|
+
return check;
|
|
3515
|
+
const clamped = await clampCellId(args.cellId);
|
|
3516
|
+
if (typeof clamped === "string")
|
|
3517
|
+
return error(clamped);
|
|
3518
|
+
if (!knownPluginIdentifiers.has(args.path)) {
|
|
3519
|
+
return error(`Plugin path not in the scanner's known list. Call list_plugins first and use one of its ` +
|
|
3520
|
+
`fileOrIdentifier values. Refused: ${args.path}`);
|
|
3521
|
+
}
|
|
3522
|
+
const raw = (await bridge.send("loadPlugin", {
|
|
3523
|
+
cellId: clamped,
|
|
3524
|
+
path: args.path,
|
|
3525
|
+
format: args.format,
|
|
3526
|
+
}));
|
|
3527
|
+
return text(raw);
|
|
3528
|
+
});
|
|
3529
|
+
server.registerTool("unload_plugin", {
|
|
3530
|
+
title: "Unload Plugin From Cell",
|
|
3531
|
+
description: "Unload the hosted plugin from a cell. No-op if no plugin is loaded there.",
|
|
3532
|
+
inputSchema: {
|
|
3533
|
+
cellId: z.number().int().min(1).describe("Target cell."),
|
|
3534
|
+
},
|
|
3535
|
+
}, async (args) => {
|
|
3536
|
+
const check = await requireBridge();
|
|
3537
|
+
if (check !== true)
|
|
3538
|
+
return check;
|
|
3539
|
+
const clamped = await clampCellId(args.cellId);
|
|
3540
|
+
if (typeof clamped === "string")
|
|
3541
|
+
return error(clamped);
|
|
3542
|
+
await bridge.send("unloadPlugin", clamped);
|
|
3543
|
+
return text(`Unload queued for cell ${clamped}.`);
|
|
3544
|
+
});
|
|
3545
|
+
server.registerTool("set_plugin_param", {
|
|
3546
|
+
title: "Set Hosted Plugin Parameter",
|
|
3547
|
+
description: "Set a parameter on a hosted plugin by cell + parameter index. Value is normalized 0..1. " +
|
|
3548
|
+
"Use `list_loaded_plugins` to find param indices and names. " +
|
|
3549
|
+
"This is the dedicated path for hosted plugin params — `set_parameters` won't reach them. " +
|
|
3550
|
+
"Targets the cell's instrument-slot plugin only.",
|
|
3551
|
+
inputSchema: {
|
|
3552
|
+
cellId: z.number().int().min(1),
|
|
3553
|
+
paramIndex: z.number().int().min(0),
|
|
3554
|
+
value: z.number().min(0).max(1).describe("Normalized 0..1."),
|
|
3555
|
+
},
|
|
3556
|
+
}, async (args) => {
|
|
3557
|
+
const check = await requireBridge();
|
|
3558
|
+
if (check !== true)
|
|
3559
|
+
return check;
|
|
3560
|
+
const clamped = await clampCellId(args.cellId);
|
|
3561
|
+
if (typeof clamped === "string")
|
|
3562
|
+
return error(clamped);
|
|
3563
|
+
await bridge.send("setPluginParam", {
|
|
3564
|
+
cellId: clamped,
|
|
3565
|
+
paramIndex: args.paramIndex,
|
|
3566
|
+
value: args.value,
|
|
3567
|
+
});
|
|
3568
|
+
return text(`Queued cell ${clamped} param ${args.paramIndex} = ${args.value}.`);
|
|
3569
|
+
});
|
|
3570
|
+
server.registerTool("set_plugin_bypass", {
|
|
3571
|
+
title: "Bypass Hosted Plugin",
|
|
3572
|
+
description: "Toggle bypass on the hosted plugin in a cell (instrument slot). Bypassed plugins are silenced; the cell sampler " +
|
|
3573
|
+
"still plays in parallel.",
|
|
3574
|
+
inputSchema: {
|
|
3575
|
+
cellId: z.number().int().min(1),
|
|
3576
|
+
bypassed: z.boolean(),
|
|
3577
|
+
},
|
|
3578
|
+
}, async (args) => {
|
|
3579
|
+
const check = await requireBridge();
|
|
3580
|
+
if (check !== true)
|
|
3581
|
+
return check;
|
|
3582
|
+
const clamped = await clampCellId(args.cellId);
|
|
3583
|
+
if (typeof clamped === "string")
|
|
3584
|
+
return error(clamped);
|
|
3585
|
+
await bridge.send("setPluginBypass", { cellId: clamped, bypassed: args.bypassed });
|
|
3586
|
+
return text(`Cell ${clamped} bypass=${args.bypassed}.`);
|
|
3587
|
+
});
|
|
3588
|
+
// ============================================================
|
|
3589
|
+
// TOOLS — Settings, transport config, device + preset management
|
|
3590
|
+
// ============================================================
|
|
3591
|
+
server.registerTool("update_settings", {
|
|
3592
|
+
title: "Update Plugin Settings",
|
|
3593
|
+
description: "Merge a partial settings delta into PAM's plugin settings (the Settings panel state) and apply it live. " +
|
|
3594
|
+
"Returns the full merged settings object so you can discover available keys. This is the only way to flip " +
|
|
3595
|
+
"experimental-feature gates from an agent. Common boolean keys: experimentalMode, experimentalDrumSynth, " +
|
|
3596
|
+
"experimentalPluginHost, experimentalMcpBridge. To enable the drum synth or plugin host, set BOTH " +
|
|
3597
|
+
"experimentalMode:true AND the specific sub-flag (e.g. experimentalDrumSynth:true) in one call. Other keys " +
|
|
3598
|
+
"include UI/library preferences — call get_state or inspect the returned object for the current set.",
|
|
3599
|
+
inputSchema: {
|
|
3600
|
+
settings: z
|
|
3601
|
+
.record(z.string(), z.any())
|
|
3602
|
+
.describe("Partial settings object to merge. Example: { experimentalMode: true, experimentalDrumSynth: true }"),
|
|
3603
|
+
},
|
|
3604
|
+
}, async ({ settings }) => {
|
|
3605
|
+
const check = await requireBridge();
|
|
3606
|
+
if (check !== true)
|
|
3607
|
+
return check;
|
|
3608
|
+
if (!settings || Object.keys(settings).length === 0)
|
|
3609
|
+
return error("Provide at least one settings key to update.");
|
|
3610
|
+
const merged = await bridge.send("applySettingsUpdate", settings);
|
|
3611
|
+
return json(merged);
|
|
3612
|
+
});
|
|
3613
|
+
server.registerTool("configure_transport", {
|
|
3614
|
+
title: "Configure Transport",
|
|
3615
|
+
description: "Configure transport behaviour beyond BPM (use set_bpm for tempo). Set the time signature, toggle host-sync " +
|
|
3616
|
+
"(when off, preset/user BPM overrides the host), toggle external clock sync, mute the audio output (standalone), " +
|
|
3617
|
+
"or release host-sync ownership for manual transport. All fields are optional — only the provided ones are applied.",
|
|
3618
|
+
inputSchema: {
|
|
3619
|
+
timeSigNumerator: z.number().int().min(1).max(32).optional().describe("Beats per bar. Requires timeSigDenominator."),
|
|
3620
|
+
timeSigDenominator: z.number().int().min(1).max(32).optional().describe("Beat unit. Requires timeSigNumerator."),
|
|
3621
|
+
hostSyncEnabled: z.boolean().optional().describe("When false, preset/user BPM overrides the host tempo."),
|
|
3622
|
+
externalClockSync: z.boolean().optional().describe("Sync the internal transport to external MIDI clock."),
|
|
3623
|
+
muteAudioOutput: z.boolean().optional().describe("Standalone only: mute the master output."),
|
|
3624
|
+
releaseHostSyncForManualTransport: z
|
|
3625
|
+
.boolean()
|
|
3626
|
+
.optional()
|
|
3627
|
+
.describe("When true, release host-sync ownership so the internal transport can be driven manually."),
|
|
3628
|
+
},
|
|
3629
|
+
}, async (args) => {
|
|
3630
|
+
const check = await requireBridge();
|
|
3631
|
+
if (check !== true)
|
|
3632
|
+
return check;
|
|
3633
|
+
const applied = [];
|
|
3634
|
+
const { timeSigNumerator, timeSigDenominator } = args;
|
|
3635
|
+
if ((timeSigNumerator === undefined) !== (timeSigDenominator === undefined))
|
|
3636
|
+
return error("timeSigNumerator and timeSigDenominator must be provided together.");
|
|
3637
|
+
if (timeSigNumerator !== undefined && timeSigDenominator !== undefined) {
|
|
3638
|
+
await bridge.send("updateTransportTimeSig", timeSigNumerator, timeSigDenominator);
|
|
3639
|
+
applied.push(`timeSig=${timeSigNumerator}/${timeSigDenominator}`);
|
|
3640
|
+
}
|
|
3641
|
+
if (args.hostSyncEnabled !== undefined) {
|
|
3642
|
+
await bridge.send("setHostSyncEnabled", args.hostSyncEnabled);
|
|
3643
|
+
applied.push(`hostSync=${args.hostSyncEnabled}`);
|
|
3644
|
+
}
|
|
3645
|
+
if (args.externalClockSync !== undefined) {
|
|
3646
|
+
await bridge.send("setExternalClockSync", args.externalClockSync);
|
|
3647
|
+
applied.push(`externalClock=${args.externalClockSync}`);
|
|
3648
|
+
}
|
|
3649
|
+
if (args.muteAudioOutput !== undefined) {
|
|
3650
|
+
await bridge.send("setMuteAudioOutput", args.muteAudioOutput);
|
|
3651
|
+
applied.push(`muteOutput=${args.muteAudioOutput}`);
|
|
3652
|
+
}
|
|
3653
|
+
if (args.releaseHostSyncForManualTransport) {
|
|
3654
|
+
await bridge.send("releaseHostSyncForManualTransport");
|
|
3655
|
+
applied.push("releasedHostSync");
|
|
3656
|
+
}
|
|
3657
|
+
if (applied.length === 0)
|
|
3658
|
+
return error("No transport fields provided.");
|
|
3659
|
+
return text(`Transport configured: ${applied.join(", ")}.`);
|
|
3660
|
+
});
|
|
3661
|
+
server.registerTool("get_audio_device_setup", {
|
|
3662
|
+
title: "Get Audio Device Setup",
|
|
3663
|
+
description: "Get the standalone audio/MIDI device setup (sample rate, buffer size, available and active input/output devices). " +
|
|
3664
|
+
"Returns null when running plugin-hosted (the DAW owns the device config). Use the device IDs/names from here with set_midi_io.",
|
|
3665
|
+
inputSchema: {},
|
|
3666
|
+
annotations: { readOnlyHint: true },
|
|
3667
|
+
}, async () => {
|
|
3668
|
+
const check = await requireBridge();
|
|
3669
|
+
if (check !== true)
|
|
3670
|
+
return check;
|
|
3671
|
+
const setup = await bridge.send("getAudioDeviceSetup");
|
|
3672
|
+
return json(setup);
|
|
3673
|
+
});
|
|
3674
|
+
server.registerTool("set_midi_io", {
|
|
3675
|
+
title: "Set MIDI I/O Devices",
|
|
3676
|
+
description: "Select MIDI input/output devices (standalone). Use get_audio_device_setup to discover device IDs first. " +
|
|
3677
|
+
"All fields optional.",
|
|
3678
|
+
inputSchema: {
|
|
3679
|
+
inputDeviceIds: z.array(z.string()).optional().describe("Enabled MIDI input device IDs (replaces the active set)."),
|
|
3680
|
+
outputDeviceId: z.string().optional().describe("MIDI output device ID."),
|
|
3681
|
+
outputEnabled: z.boolean().optional().describe("Enable/disable MIDI output."),
|
|
3682
|
+
},
|
|
3683
|
+
}, async (args) => {
|
|
3684
|
+
const check = await requireBridge();
|
|
3685
|
+
if (check !== true)
|
|
3686
|
+
return check;
|
|
3687
|
+
const applied = [];
|
|
3688
|
+
if (args.inputDeviceIds !== undefined) {
|
|
3689
|
+
await bridge.send("setMidiInputDevices", args.inputDeviceIds);
|
|
3690
|
+
applied.push(`inputs=${args.inputDeviceIds.length}`);
|
|
3691
|
+
}
|
|
3692
|
+
if (args.outputDeviceId !== undefined) {
|
|
3693
|
+
await bridge.send("setMidiOutputDevice", args.outputDeviceId);
|
|
3694
|
+
applied.push(`output=${args.outputDeviceId}`);
|
|
3695
|
+
}
|
|
3696
|
+
if (args.outputEnabled !== undefined) {
|
|
3697
|
+
await bridge.send("setMidiOutputEnabled", args.outputEnabled);
|
|
3698
|
+
applied.push(`outputEnabled=${args.outputEnabled}`);
|
|
3699
|
+
}
|
|
3700
|
+
if (applied.length === 0)
|
|
3701
|
+
return error("No MIDI I/O fields provided.");
|
|
3702
|
+
return text(`MIDI I/O set: ${applied.join(", ")}.`);
|
|
3703
|
+
});
|
|
3704
|
+
server.registerTool("set_midi_mapping", {
|
|
3705
|
+
title: "Set MIDI Action Mapping",
|
|
3706
|
+
description: "Configure the global MIDI-controller mappings that trigger PAM actions: variation switch, preset change, " +
|
|
3707
|
+
"looper record, live resample, and looper-variation switch. Set the enable flag, MIDI channel, CC number, " +
|
|
3708
|
+
"preset source, or bar-sync for each target. One field per call.",
|
|
3709
|
+
inputSchema: {
|
|
3710
|
+
target: z
|
|
3711
|
+
.enum(["variation", "preset", "looper", "resample", "looperVariation"])
|
|
3712
|
+
.describe("Which action's mapping to configure."),
|
|
3713
|
+
field: z
|
|
3714
|
+
.enum(["enabled", "channel", "ccNumber", "source", "barSync"])
|
|
3715
|
+
.describe("Field to set. 'source' is preset-only; 'barSync' is variation/looperVariation-only; " +
|
|
3716
|
+
"looper/resample have no source/barSync."),
|
|
3717
|
+
value: z
|
|
3718
|
+
.union([z.number(), z.boolean()])
|
|
3719
|
+
.describe("boolean for enabled/barSync; number for channel (1-16), ccNumber (0-127), or source."),
|
|
3720
|
+
},
|
|
3721
|
+
}, async ({ target, field, value }) => {
|
|
3722
|
+
const check = await requireBridge();
|
|
3723
|
+
if (check !== true)
|
|
3724
|
+
return check;
|
|
3725
|
+
await bridge.send("setMidiMapping", { target, field, value });
|
|
3726
|
+
return text(`MIDI mapping ${target}.${field} = ${value}.`);
|
|
3727
|
+
});
|
|
3728
|
+
server.registerTool("delete_preset", {
|
|
3729
|
+
title: "Delete Preset",
|
|
3730
|
+
description: "Delete a user preset file from disk. Factory presets are protected and cannot be deleted. " +
|
|
3731
|
+
"Use the relative path returned by list_presets.",
|
|
3732
|
+
inputSchema: {
|
|
3733
|
+
path: z.string().describe("Relative preset path (e.g. 'Presets/User/MyPreset.preset')."),
|
|
3734
|
+
},
|
|
3735
|
+
annotations: { destructiveHint: true },
|
|
3736
|
+
}, async ({ path: presetPath }) => {
|
|
3737
|
+
const check = await requireBridge();
|
|
3738
|
+
if (check !== true)
|
|
3739
|
+
return check;
|
|
3740
|
+
const result = await bridge.send("removePreset", presetPath);
|
|
3741
|
+
return json(result);
|
|
3742
|
+
});
|
|
3743
|
+
server.registerTool("set_preset_factory_status", {
|
|
3744
|
+
title: "Set Preset Factory Status",
|
|
3745
|
+
description: "Mark a preset as factory (read-only) or user (editable) on disk. Affects the factory flag and the file location.",
|
|
3746
|
+
inputSchema: {
|
|
3747
|
+
path: z.string().describe("Relative preset path."),
|
|
3748
|
+
makeFactory: z.boolean().describe("true → factory (read-only), false → user (editable)."),
|
|
3749
|
+
},
|
|
3750
|
+
}, async ({ path: presetPath, makeFactory }) => {
|
|
3751
|
+
const check = await requireBridge();
|
|
3752
|
+
if (check !== true)
|
|
3753
|
+
return check;
|
|
3754
|
+
const result = await bridge.send("togglePresetFactoryStatus", presetPath, makeFactory);
|
|
3755
|
+
return json(result);
|
|
3756
|
+
});
|
|
3757
|
+
server.registerTool("get_sample_usage", {
|
|
3758
|
+
title: "Get Sample Usage",
|
|
3759
|
+
description: "Cross-reference samples and presets. mode 'presetUsesSamples' lists the samples a preset references; " +
|
|
3760
|
+
"mode 'presetsUsingSample' lists presets that reference a given sample (or any sample under a folder).",
|
|
3761
|
+
inputSchema: {
|
|
3762
|
+
mode: z.enum(["presetUsesSamples", "presetsUsingSample"]),
|
|
3763
|
+
path: z
|
|
3764
|
+
.string()
|
|
3765
|
+
.describe("Relative preset path (presetUsesSamples) or relative sample path (presetsUsingSample)."),
|
|
3766
|
+
isFolder: z
|
|
3767
|
+
.boolean()
|
|
3768
|
+
.optional()
|
|
3769
|
+
.describe("presetsUsingSample only: treat 'path' as a folder and match any sample beneath it."),
|
|
3770
|
+
},
|
|
3771
|
+
annotations: { readOnlyHint: true },
|
|
3772
|
+
}, async ({ mode, path: relPath, isFolder }) => {
|
|
3773
|
+
const check = await requireBridge();
|
|
3774
|
+
if (check !== true)
|
|
3775
|
+
return check;
|
|
3776
|
+
const result = mode === "presetUsesSamples"
|
|
3777
|
+
? await bridge.send("getPresetSampleUsage", relPath)
|
|
3778
|
+
: await bridge.send("getPresetsUsingSample", relPath, isFolder ?? false);
|
|
3779
|
+
return json(result);
|
|
3780
|
+
});
|
|
3781
|
+
server.registerTool("repair_preset_samples", {
|
|
3782
|
+
title: "Repair Preset Sample Paths",
|
|
3783
|
+
description: "Fix broken sample references in presets. mode 'fix' auto-relinks moved/renamed samples by searching the " +
|
|
3784
|
+
"library (optionally scoped to one preset). mode 'rescan' re-audits a preset for still-missing samples and " +
|
|
3785
|
+
"reports them. Useful after moving a sample library or importing presets from another machine.",
|
|
3786
|
+
inputSchema: {
|
|
3787
|
+
mode: z.enum(["fix", "rescan"]),
|
|
3788
|
+
presetPath: z
|
|
3789
|
+
.string()
|
|
3790
|
+
.optional()
|
|
3791
|
+
.describe("Relative preset path. Omit for 'fix' to scan all presets; omit for 'rescan' to audit the active preset."),
|
|
3792
|
+
},
|
|
3793
|
+
}, async ({ mode, presetPath }) => {
|
|
3794
|
+
const check = await requireBridge();
|
|
3795
|
+
if (check !== true)
|
|
3796
|
+
return check;
|
|
3797
|
+
if (mode === "fix") {
|
|
3798
|
+
const result = await bridge.send("fixPresetSamplePaths", presetPath ?? "");
|
|
3799
|
+
return json(result);
|
|
3800
|
+
}
|
|
3801
|
+
await bridge.send("rescanMissingSamples", presetPath ?? "");
|
|
3802
|
+
return text(`Re-audited ${presetPath || "active preset"} for missing samples (results dispatched to UI state).`);
|
|
3803
|
+
});
|
|
3804
|
+
server.registerTool("preview_sample", {
|
|
3805
|
+
title: "Preview Sample",
|
|
3806
|
+
description: "Audition an audio file through PAM's native preview player without loading it into a cell. Pass filePath to " +
|
|
3807
|
+
"start previewing; pass stop:true to stop the current preview.",
|
|
3808
|
+
inputSchema: {
|
|
3809
|
+
filePath: z.string().optional().describe("Absolute or library-relative path to the audio file to preview."),
|
|
3810
|
+
stop: z.boolean().optional().describe("Stop the current preview instead of starting one."),
|
|
3811
|
+
},
|
|
3812
|
+
}, async ({ filePath, stop }) => {
|
|
3813
|
+
const check = await requireBridge();
|
|
3814
|
+
if (check !== true)
|
|
3815
|
+
return check;
|
|
3816
|
+
if (stop) {
|
|
3817
|
+
await bridge.send("stopPreviewNative");
|
|
3818
|
+
return text("Stopped sample preview.");
|
|
3819
|
+
}
|
|
3820
|
+
if (!filePath)
|
|
3821
|
+
return error("Provide filePath to preview, or stop:true to stop.");
|
|
3822
|
+
const ok = await bridge.send("previewSampleNative", filePath);
|
|
3823
|
+
return text(ok ? `Previewing ${basename(filePath)}.` : `Could not preview ${basename(filePath)} (file unreadable?).`);
|
|
3824
|
+
});
|
|
3825
|
+
// ============================================================
|
|
3826
|
+
// TOOLS — Plugin host (extra)
|
|
3827
|
+
// ============================================================
|
|
3828
|
+
server.registerTool("set_plugin_params", {
|
|
3829
|
+
title: "Set Hosted Plugin Params (batch)",
|
|
3830
|
+
description: "Set multiple hosted-plugin parameters in one call (more efficient than set_plugin_param per value). Values are " +
|
|
3831
|
+
"0..1 normalized. Use list_loaded_plugins to discover paramIndex values. role defaults to 'instrument'.",
|
|
3832
|
+
inputSchema: {
|
|
3833
|
+
cellId: z.number().int().min(1).describe("Cell number."),
|
|
3834
|
+
role: z.enum(["instrument", "effect"]).optional().describe("Which hosted slot (default 'instrument')."),
|
|
3835
|
+
values: z
|
|
3836
|
+
.array(z.object({ paramIndex: z.number().int().min(0), value: z.number().min(0).max(1) }))
|
|
3837
|
+
.min(1)
|
|
3838
|
+
.describe("Array of { paramIndex, value } where value is 0..1 normalized."),
|
|
3839
|
+
},
|
|
3840
|
+
}, async (args) => {
|
|
3841
|
+
const check = await requireBridge();
|
|
3842
|
+
if (check !== true)
|
|
3843
|
+
return check;
|
|
3844
|
+
const clamped = await clampCellId(args.cellId);
|
|
3845
|
+
if (typeof clamped === "string")
|
|
3846
|
+
return error(clamped);
|
|
3847
|
+
await bridge.send("setPluginParams", {
|
|
3848
|
+
cellId: clamped,
|
|
3849
|
+
role: args.role ?? "instrument",
|
|
3850
|
+
values: args.values,
|
|
3851
|
+
});
|
|
3852
|
+
return text(`Queued ${args.values.length} param(s) for cell ${clamped} (${args.role ?? "instrument"}).`);
|
|
3853
|
+
});
|
|
3854
|
+
server.registerTool("skip_current_plugin_scan", {
|
|
3855
|
+
title: "Skip Current Plugin Scan",
|
|
3856
|
+
description: "Skip (blocklist) the plugin currently being scanned and continue the scan. Use when a scan is stuck on a " +
|
|
3857
|
+
"crashing/hanging plugin (poll get_plugin_scan_status to detect a stall).",
|
|
3858
|
+
inputSchema: {},
|
|
3859
|
+
}, async () => {
|
|
3860
|
+
const check = await requireBridge();
|
|
3861
|
+
if (check !== true)
|
|
3862
|
+
return check;
|
|
3863
|
+
await bridge.send("skipCurrentPluginScan");
|
|
3864
|
+
return text("Skipped the current plugin and continued the scan.");
|
|
3865
|
+
});
|
|
3866
|
+
server.registerTool("remove_plugin_from_blocklist", {
|
|
3867
|
+
title: "Remove Plugin From Blocklist",
|
|
3868
|
+
description: "Un-blocklist a plugin that was blocklisted after a failed/crashed scan so it can be scanned and loaded again. " +
|
|
3869
|
+
"Pass the fileOrIdentifier as it appears in the blocklist (see list_plugins → blocklist).",
|
|
3870
|
+
inputSchema: {
|
|
3871
|
+
fileOrIdentifier: z.string().describe("The blocklisted plugin's file path or identifier."),
|
|
3872
|
+
},
|
|
3873
|
+
}, async ({ fileOrIdentifier }) => {
|
|
3874
|
+
const check = await requireBridge();
|
|
3875
|
+
if (check !== true)
|
|
3876
|
+
return check;
|
|
3877
|
+
await bridge.send("removeFromBlocklist", fileOrIdentifier);
|
|
3878
|
+
return text(`Removed ${fileOrIdentifier} from the plugin blocklist.`);
|
|
3879
|
+
});
|
|
3880
|
+
// ============================================================
|
|
2162
3881
|
// Start
|
|
2163
3882
|
// ============================================================
|
|
2164
3883
|
async function main() {
|
|
3884
|
+
if (await handleCliArgs())
|
|
3885
|
+
return;
|
|
2165
3886
|
// Try to connect to plugin on startup (non-blocking)
|
|
2166
3887
|
void bridge.connect();
|
|
2167
3888
|
const transport = new StdioServerTransport();
|