@map-audio/pam-mcp-server 1.1.0 → 1.12.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 +2 -0
- package/dist/bridge.d.ts +1 -0
- package/dist/bridge.js +110 -7
- package/dist/bridge.js.map +1 -1
- package/dist/manifest.json +74682 -6688
- package/dist/server.js +1698 -64
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -3,6 +3,7 @@ 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 { inflateSync } from "node:zlib";
|
|
6
7
|
import { basename, dirname, resolve } from "node:path";
|
|
7
8
|
import { fileURLToPath } from "node:url";
|
|
8
9
|
import { loadManifest, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, walkPresets, } from "./manifest.js";
|
|
@@ -76,23 +77,49 @@ function parsePresetFile(raw) {
|
|
|
76
77
|
const dataStr = raw.slice(prefixLen + metadataSize);
|
|
77
78
|
return { metadata: JSON.parse(metadataStr), data: JSON.parse(dataStr) };
|
|
78
79
|
}
|
|
80
|
+
// PAM ≥ 1.4.61 stores variations ONLY in the data block, zlib+base64-compressed with a
|
|
81
|
+
// "PAMZ1:" marker (see helpers/VariationCodec.h). Decompress one entry to plain JSON;
|
|
82
|
+
// plain (older-format) entries pass through unchanged.
|
|
83
|
+
const VARIATION_MARKER = "PAMZ1:";
|
|
84
|
+
function decompressVariationEntry(v) {
|
|
85
|
+
if (typeof v !== "string" || !v.startsWith(VARIATION_MARKER))
|
|
86
|
+
return v;
|
|
87
|
+
try {
|
|
88
|
+
return inflateSync(Buffer.from(v.slice(VARIATION_MARKER.length), "base64")).toString("utf8");
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return v;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Canonical variations array for a parsed preset, as plain-JSON strings (or nulls).
|
|
96
|
+
* The data block is authoritative (current format); metadata.variations is the fallback
|
|
97
|
+
* for legacy presets that still carried the duplicate copy there.
|
|
98
|
+
*/
|
|
99
|
+
function variationsFromParsedPreset(parsed) {
|
|
100
|
+
const fromData = parsed.data?.variations;
|
|
101
|
+
const fromMeta = parsed.metadata?.variations;
|
|
102
|
+
const src = Array.isArray(fromData) && fromData.length === PRESET_VARIATION_SLOTS ? fromData
|
|
103
|
+
: Array.isArray(fromMeta) && fromMeta.length === PRESET_VARIATION_SLOTS ? fromMeta
|
|
104
|
+
: null;
|
|
105
|
+
if (!src)
|
|
106
|
+
return Array(PRESET_VARIATION_SLOTS).fill(null);
|
|
107
|
+
return src.map(decompressVariationEntry);
|
|
108
|
+
}
|
|
79
109
|
/**
|
|
80
110
|
* Read existing variations array from a preset file on disk.
|
|
81
|
-
* Returns Array(8) of strings or nulls.
|
|
111
|
+
* Returns Array(8) of plain-JSON strings or nulls.
|
|
82
112
|
*/
|
|
83
113
|
async function readExistingVariations(presetPath) {
|
|
84
114
|
try {
|
|
85
115
|
const fullPath = await resolvePresetPath(presetPath);
|
|
86
116
|
const raw = await readFile(fullPath, "utf-8");
|
|
87
|
-
|
|
88
|
-
if (metadata.variations && Array.isArray(metadata.variations) && metadata.variations.length === PRESET_VARIATION_SLOTS) {
|
|
89
|
-
return [...metadata.variations];
|
|
90
|
-
}
|
|
117
|
+
return variationsFromParsedPreset(parsePresetFile(raw));
|
|
91
118
|
}
|
|
92
119
|
catch {
|
|
93
120
|
// Preset doesn't exist yet or can't be read — start fresh
|
|
121
|
+
return Array(PRESET_VARIATION_SLOTS).fill(null);
|
|
94
122
|
}
|
|
95
|
-
return Array(PRESET_VARIATION_SLOTS).fill(null);
|
|
96
123
|
}
|
|
97
124
|
// Mirrors interop.ts VARIATION_EXCLUDED_PARAMS (activePresetPath is excluded separately below, matching interop.ts)
|
|
98
125
|
const VARIATION_EXCLUDED_PARAMS = [
|
|
@@ -152,19 +179,31 @@ function formatParam(p) {
|
|
|
152
179
|
// --- Server ---
|
|
153
180
|
const server = new McpServer({
|
|
154
181
|
name: "pam",
|
|
155
|
-
version: "1.
|
|
182
|
+
version: "1.8.0",
|
|
156
183
|
}, {
|
|
157
184
|
instructions: "PAM is an audio sampler plugin. When browsing samples, ALWAYS call get_default_locations " +
|
|
158
185
|
"first to discover the user's actual library paths — never guess paths like ~/Music/PAM. " +
|
|
159
186
|
"The sample library lives under ~/Library/Application Support/, NOT ~/Music/. " +
|
|
160
187
|
"Use the returned paths with list_samples. When loading samples, use load_sample with " +
|
|
161
188
|
"paths from list_samples. Transport must be playing for sequencer output. " +
|
|
162
|
-
"
|
|
163
|
-
"
|
|
164
|
-
"
|
|
165
|
-
"
|
|
166
|
-
"
|
|
167
|
-
"
|
|
189
|
+
"To enable experimental features (plugin host, AI engines) call update_settings with both " +
|
|
190
|
+
"experimentalMode:true AND the specific sub-flag (e.g. experimentalPluginHost:true). " +
|
|
191
|
+
"The drum synth is a standard feature (PAM ≥ 1.5) — no settings flag needed. " +
|
|
192
|
+
"Tools added in server version 1.7.0 — update_settings, configure_transport, " +
|
|
193
|
+
"get_audio_device_setup, set_midi_io, set_midi_mapping, delete_preset, " +
|
|
194
|
+
"set_preset_factory_status, get_sample_usage, repair_preset_samples, preview_sample, " +
|
|
195
|
+
"set_plugin_params, skip_current_plugin_scan, remove_plugin_from_blocklist — require PAM ≥ 1.7.0. " +
|
|
196
|
+
"VX video tools (get_video_state, video_command, add_video_layer, update_video_layer, " +
|
|
197
|
+
"remove_video_layer, apply_video_layout, add_video_fx, set_video_fx, remove_video_fx, " +
|
|
198
|
+
"bind_video_param, set_video_settings) require PAM ≥ 1.8.0 AND PAM's window to be OPEN " +
|
|
199
|
+
"(the video engine renders in the WebView; there is no headless renderer). For " +
|
|
200
|
+
"audio-reactive control, note the visual audio-react params (visualWaveDisplace, etc.) " +
|
|
201
|
+
"are regular plugin params — drive them with set_parameters; layers/FX/bindings go through " +
|
|
202
|
+
"the video tools. When building a scene, call get_video_state first to learn layer/fx ids. " +
|
|
203
|
+
"Earlier tool sets (looper, song mode, modulation edits, MIDI recording, sample analysis, " +
|
|
204
|
+
"live resample, variation scheduling, AI generation) require PAM ≥ 1.4.50 / 1.5.0. " +
|
|
205
|
+
"If a tool fails with \"PAM build does not support 'X'\", the connected plugin is older than " +
|
|
206
|
+
"this MCP server; the user must update PAM (updating the npm package alone is not enough).",
|
|
168
207
|
});
|
|
169
208
|
// ============================================================
|
|
170
209
|
// TOOLS — Offline (work without plugin running)
|
|
@@ -478,6 +517,293 @@ server.registerTool("load_sample", {
|
|
|
478
517
|
await bridge.send("loadSampleToCell", filePath, cellId);
|
|
479
518
|
return text(`Loaded ${basename(filePath)} into cell ${cellId}.`);
|
|
480
519
|
});
|
|
520
|
+
// ---- Unified AI model management (notochord / aigen / magenta) ----
|
|
521
|
+
// All six map to the C++ MCPBridge model* actions (helpers/MCPBridge.cpp::handleModelOp).
|
|
522
|
+
// Each engine must have its experimental flags on (update_settings: experimentalMode
|
|
523
|
+
// + experimental<Engine>) — the bridge returns a clear error otherwise.
|
|
524
|
+
const MODEL_ENGINE = z
|
|
525
|
+
.enum(["notochord", "aigen", "magenta"])
|
|
526
|
+
.describe("Which AI engine. notochord = MIDI co-player (~18 MB blob); aigen = Stable Audio 3 sample generation (~3.2 GB ONNX); magenta = MagentaRT realtime audio (Apple Silicon, MLX).");
|
|
527
|
+
const MODEL_VARIANT = z
|
|
528
|
+
.enum(["base", "small"])
|
|
529
|
+
.optional()
|
|
530
|
+
.describe("Magenta only: which variant (base = 2.4B, needs ~M2 Max/M3 Pro+; small = 230M, default). Ignored for notochord/aigen (single model).");
|
|
531
|
+
server.registerTool("download_model", {
|
|
532
|
+
title: "Download AI Model",
|
|
533
|
+
description: "Start (or resume) downloading model weights for an AI engine. Async — returns immediately with download progress; poll get_model_status. aigen + notochord auto-load on completion; magenta needs a separate load_model. Requires the engine's experimental flags.",
|
|
534
|
+
inputSchema: { engine: MODEL_ENGINE, model: MODEL_VARIANT },
|
|
535
|
+
}, async ({ engine, model }) => {
|
|
536
|
+
const check = await requireBridge();
|
|
537
|
+
if (check !== true)
|
|
538
|
+
return check;
|
|
539
|
+
return json(await bridge.send("modelDownload", { engine, model }));
|
|
540
|
+
});
|
|
541
|
+
server.registerTool("get_model_status", {
|
|
542
|
+
title: "Get AI Model Status",
|
|
543
|
+
description: "Report downloaded / loaded / loading / download-in-flight + version + disk size + live download progress for an AI engine.",
|
|
544
|
+
inputSchema: { engine: MODEL_ENGINE },
|
|
545
|
+
}, async ({ engine }) => {
|
|
546
|
+
const check = await requireBridge();
|
|
547
|
+
if (check !== true)
|
|
548
|
+
return check;
|
|
549
|
+
return json(await bridge.send("modelStatus", { engine }));
|
|
550
|
+
});
|
|
551
|
+
server.registerTool("load_model", {
|
|
552
|
+
title: "Load AI Model",
|
|
553
|
+
description: "Load a downloaded model into memory (async — poll get_model_status for loaded:true). Use after unload, or for magenta after download. magenta accepts an optional variant; omit to load whichever is on disk.",
|
|
554
|
+
inputSchema: { engine: MODEL_ENGINE, model: MODEL_VARIANT },
|
|
555
|
+
}, async ({ engine, model }) => {
|
|
556
|
+
const check = await requireBridge();
|
|
557
|
+
if (check !== true)
|
|
558
|
+
return check;
|
|
559
|
+
return json(await bridge.send("modelLoad", { engine, model }));
|
|
560
|
+
});
|
|
561
|
+
server.registerTool("unload_model", {
|
|
562
|
+
title: "Unload AI Model",
|
|
563
|
+
description: "Free the loaded model from memory (weights stay on disk). May briefly stall the plugin while an in-flight load finishes (non-interruptible ORT/MLX load).",
|
|
564
|
+
inputSchema: { engine: MODEL_ENGINE },
|
|
565
|
+
}, async ({ engine }) => {
|
|
566
|
+
const check = await requireBridge();
|
|
567
|
+
if (check !== true)
|
|
568
|
+
return check;
|
|
569
|
+
return json(await bridge.send("modelUnload", { engine }));
|
|
570
|
+
});
|
|
571
|
+
server.registerTool("delete_model", {
|
|
572
|
+
title: "Delete AI Model",
|
|
573
|
+
description: "Delete downloaded model weights from disk. Unloads first automatically. DESTRUCTIVE — re-download to restore. magenta delete is variant-agnostic (removes both variants + shared resources).",
|
|
574
|
+
inputSchema: { engine: MODEL_ENGINE, model: MODEL_VARIANT },
|
|
575
|
+
}, async ({ engine, model }) => {
|
|
576
|
+
const check = await requireBridge();
|
|
577
|
+
if (check !== true)
|
|
578
|
+
return check;
|
|
579
|
+
return json(await bridge.send("modelDelete", { engine, model }));
|
|
580
|
+
});
|
|
581
|
+
server.registerTool("list_models", {
|
|
582
|
+
title: "List AI Models",
|
|
583
|
+
description: "List available model variants per engine with downloaded / loaded state. Omit engine to list all engines (notochord, aigen, magenta base + small).",
|
|
584
|
+
inputSchema: { engine: MODEL_ENGINE.optional() },
|
|
585
|
+
}, async ({ engine }) => {
|
|
586
|
+
const check = await requireBridge();
|
|
587
|
+
if (check !== true)
|
|
588
|
+
return check;
|
|
589
|
+
return json(await bridge.send("modelList", { engine }));
|
|
590
|
+
});
|
|
591
|
+
server.registerTool("generate_sample", {
|
|
592
|
+
title: "Generate Sample (AI)",
|
|
593
|
+
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.",
|
|
594
|
+
inputSchema: {
|
|
595
|
+
category: z
|
|
596
|
+
.enum(["kick", "snare", "hat", "perc", "bass", "synth", "pad", "fx", "loop"])
|
|
597
|
+
.describe("Sound category — drives prompt template defaults."),
|
|
598
|
+
prompt: z.string().describe("Text prompt describing the desired sound."),
|
|
599
|
+
durationSeconds: z
|
|
600
|
+
.number()
|
|
601
|
+
.min(0.1)
|
|
602
|
+
.max(47)
|
|
603
|
+
.describe("Output length in seconds (Stable Audio 3 Small SFX engine cap ≈ 47 s). For loops, pass bar-converted duration: bars * beatsPerBar * 60 / bpm."),
|
|
604
|
+
mode: z
|
|
605
|
+
.enum(["text", "inpaint", "continuation"])
|
|
606
|
+
.optional()
|
|
607
|
+
.default("text")
|
|
608
|
+
.describe("Generation mode. text = unconditional, inpaint = audio-to-audio with mask, continuation = extend existing audio."),
|
|
609
|
+
cellId: z
|
|
610
|
+
.number()
|
|
611
|
+
.int()
|
|
612
|
+
.min(1)
|
|
613
|
+
.max(8)
|
|
614
|
+
.optional()
|
|
615
|
+
.describe("If provided, load result directly into this cell."),
|
|
616
|
+
seed: z.number().int().optional().describe("Deterministic seed. Random if omitted."),
|
|
617
|
+
refAudioPath: z.string().optional().describe("Required for inpaint/continuation. Absolute path to a WAV."),
|
|
618
|
+
maskSpec: z
|
|
619
|
+
.string()
|
|
620
|
+
.optional()
|
|
621
|
+
.describe('Mask spec for inpaint mode: "tail:0.5" or "transient:0-0.05" or "full".'),
|
|
622
|
+
},
|
|
623
|
+
}, async (args) => {
|
|
624
|
+
const ok = await requireBridge();
|
|
625
|
+
if (ok !== true)
|
|
626
|
+
return ok;
|
|
627
|
+
try {
|
|
628
|
+
const result = await bridge.send("aiGenerate", args);
|
|
629
|
+
return json(result);
|
|
630
|
+
}
|
|
631
|
+
catch (e) {
|
|
632
|
+
return error(`AI generation failed: ${e.message}`);
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
server.registerTool("generate_kit", {
|
|
636
|
+
title: "Generate Kit (AI)",
|
|
637
|
+
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).",
|
|
638
|
+
inputSchema: {
|
|
639
|
+
prompt: z.string().describe('High-level kit description (e.g. "dark trap drum kit", "lofi house").'),
|
|
640
|
+
cells: z
|
|
641
|
+
.array(z.object({
|
|
642
|
+
id: z.number().int().min(1).max(8),
|
|
643
|
+
role: z.enum(["kick", "snare", "hat", "perc", "bass", "synth", "pad", "fx", "loop"]),
|
|
644
|
+
}))
|
|
645
|
+
.min(1)
|
|
646
|
+
.max(8)
|
|
647
|
+
.describe("Cells to populate. Each gets one sample matching the role."),
|
|
648
|
+
},
|
|
649
|
+
}, async (args) => {
|
|
650
|
+
const ok = await requireBridge();
|
|
651
|
+
if (ok !== true)
|
|
652
|
+
return ok;
|
|
653
|
+
const { prompt, cells } = args;
|
|
654
|
+
// C++ handleAIGenerateKit expects { requests: [{ prompt, category, cellId, durationSeconds }] }.
|
|
655
|
+
// Derive a per-role prompt + sensible duration from the single kit prompt;
|
|
656
|
+
// cellId on each request drives the bridge's load-into-cell step. (mode omitted → text.)
|
|
657
|
+
const durationForRole = (role) => role === "loop" ? 4 : role === "bass" || role === "synth" || role === "pad" || role === "fx" ? 2 : 1.5;
|
|
658
|
+
const requests = cells.map((c) => ({
|
|
659
|
+
prompt: `${prompt}, ${c.role}`,
|
|
660
|
+
category: c.role,
|
|
661
|
+
cellId: c.id,
|
|
662
|
+
durationSeconds: durationForRole(c.role),
|
|
663
|
+
}));
|
|
664
|
+
try {
|
|
665
|
+
const results = await bridge.send("aiGenerateKit", { requests });
|
|
666
|
+
return json(results);
|
|
667
|
+
}
|
|
668
|
+
catch (e) {
|
|
669
|
+
return error(`Kit generation failed: ${e.message}`);
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
// --- AI Audio Workbench (Phase A, dev-gated) ---------------------------------
|
|
673
|
+
// Drives the Transform canvas headlessly: canvas state (mask regions / strength
|
|
674
|
+
// curve / prompt / seed / sigma), sidecar renders, and the take shelf. Requires
|
|
675
|
+
// experimentalMode + experimentalAIGen (update_settings) AND the SA3 Python
|
|
676
|
+
// sidecar running on 127.0.0.1:8093 (packages/sa3-workbench-sidecar/README.md).
|
|
677
|
+
const WORKBENCH_MASK_REGION = z.object({
|
|
678
|
+
startSec: z.number().min(0).describe("Region start in seconds (source time)."),
|
|
679
|
+
endSec: z.number().min(0).describe("Region end in seconds (must be > startSec)."),
|
|
680
|
+
lowHz: z.number().min(0).optional()
|
|
681
|
+
.describe("Optional band low edge in Hz (canvas v2 2D mask). Omit BOTH lowHz/highHz for a full-band region (1D back-compat)."),
|
|
682
|
+
highHz: z.number().min(0).optional()
|
|
683
|
+
.describe("Optional band high edge in Hz (must be > lowHz). lowHz<=20 && highHz>=source nyquist is treated as full band by the sidecar."),
|
|
684
|
+
});
|
|
685
|
+
const WORKBENCH_CURVE_POINT = z.object({
|
|
686
|
+
t: z.number().describe("Normalized time 0..1."),
|
|
687
|
+
v: z.number().describe("Value 0..1 (σ at this point)."),
|
|
688
|
+
tension: z.number().optional().describe("Curve tension (CurveCanvas semantics, default 0)."),
|
|
689
|
+
});
|
|
690
|
+
const WORKBENCH_STATE_FIELDS = {
|
|
691
|
+
sourceCellId: z.number().int().min(1).max(8).nullable().optional()
|
|
692
|
+
.describe("Cell whose sample is the transform source (null to clear)."),
|
|
693
|
+
prompt: z.string().optional().describe("Text prompt for the render."),
|
|
694
|
+
seed: z.number().int().min(0).optional().describe("Render seed."),
|
|
695
|
+
seedLocked: z.boolean().optional().describe("Lock seed across renders (iterate at fixed identity)."),
|
|
696
|
+
sigma: z.number().min(0.02).max(1).optional()
|
|
697
|
+
.describe("Deviation σ / init noise level. a2a: 0.25-0.35 preserves melody, 0.45-0.5 variation; inpaint regime ~0.85."),
|
|
698
|
+
steps: z.number().int().min(1).max(32).optional().describe("Denoise steps (8 is the validated recipe)."),
|
|
699
|
+
maskRegions: z.array(WORKBENCH_MASK_REGION).optional()
|
|
700
|
+
.describe("Transform-lane mask. Empty = pure a2a re-render of the whole source; regions = inpaint (regenerate inside, keep outside). Regions may carry an optional lowHz/highHz band."),
|
|
701
|
+
strengthCurve: z.array(WORKBENCH_CURVE_POINT).optional()
|
|
702
|
+
.describe("Strength-lane breakpoints (stored with full provenance; per-region σ application is experimental)."),
|
|
703
|
+
activeSourcePath: z.string().optional()
|
|
704
|
+
.describe("Render-chain head: absolute WAV path used as the transform source ('' = the source cell's sample). Managed automatically — each successful render advances it to the new take's wavPath; set explicitly only to point the chain at a prior take. Walk it back with workbench_undo / workbench_reset_chain."),
|
|
705
|
+
sketchPath: z.string().optional()
|
|
706
|
+
.describe("SKETCH mode: absolute path to a painted-gesture WAV ('' = no sketch). Normally written by the UI (workbenchSaveSketch saves into ai-cache/workbench/); settable directly for headless tests. When non-empty, renders feed the model source + sketch*sketchGain as init audio AND use that MIX as the preservation reference for masks/paste-back — the gesture survives outside masked regions."),
|
|
707
|
+
sketchGain: z.number().min(0).max(1).optional()
|
|
708
|
+
.describe("SKETCH mix gain 0..1 (default 0.5) applied to the sketch before summing with the source. If the mix peaks above 0.99 the sidecar scales the whole mix down to 0.99."),
|
|
709
|
+
};
|
|
710
|
+
server.registerTool("workbench_get_state", {
|
|
711
|
+
title: "Workbench: Get State",
|
|
712
|
+
description: "Read the AI Audio Workbench canvas state AND the take shelf: { state: { sourceCellId, prompt, seed, seedLocked, sigma, steps, maskRegions, strengthCurve, activeSourcePath, sketchPath, sketchGain }, takes: [...] }.",
|
|
713
|
+
inputSchema: {},
|
|
714
|
+
}, async () => {
|
|
715
|
+
const check = await requireBridge();
|
|
716
|
+
if (check !== true)
|
|
717
|
+
return check;
|
|
718
|
+
return json(await bridge.send("workbenchGetState"));
|
|
719
|
+
});
|
|
720
|
+
server.registerTool("workbench_set_state", {
|
|
721
|
+
title: "Workbench: Set State",
|
|
722
|
+
description: "Partial-merge the Workbench canvas state (only the keys you pass change). Also pushes the merged state into an open Workbench UI. Returns the full merged state.",
|
|
723
|
+
inputSchema: WORKBENCH_STATE_FIELDS,
|
|
724
|
+
}, async (args) => {
|
|
725
|
+
const check = await requireBridge();
|
|
726
|
+
if (check !== true)
|
|
727
|
+
return check;
|
|
728
|
+
return json(await bridge.send("workbenchSetState", args));
|
|
729
|
+
});
|
|
730
|
+
server.registerTool("workbench_render", {
|
|
731
|
+
title: "Workbench: Render",
|
|
732
|
+
description: "Render the Workbench transform via the SA3 sidecar and append the result to the take shelf. Renders the CURRENT canvas state merged with any overrides passed here (overrides do not persist). Blocks until done (up to ~3 min: pipeline rebuilds + multi-region inpaints render sequentially). Returns the take { id, wavPath, prompt, seed, sigma, maskRegions, createdAt, sourceCellId, durationSeconds, error? }. Source resolution: sourcePath override > activeSourcePath (chain head) > state.sourceCellId's sample file. A successful render ADVANCES the chain (activeSourcePath = new take's wavPath) — step back with workbench_undo or workbench_reset_chain.",
|
|
733
|
+
inputSchema: {
|
|
734
|
+
...WORKBENCH_STATE_FIELDS,
|
|
735
|
+
sourcePath: z.string().optional()
|
|
736
|
+
.describe("Absolute path to a source WAV — bypasses sourceCellId resolution."),
|
|
737
|
+
},
|
|
738
|
+
}, async (args) => {
|
|
739
|
+
const check = await requireBridge();
|
|
740
|
+
if (check !== true)
|
|
741
|
+
return check;
|
|
742
|
+
try {
|
|
743
|
+
return json(await bridge.send("workbenchRender", args));
|
|
744
|
+
}
|
|
745
|
+
catch (e) {
|
|
746
|
+
return error(`Workbench render failed: ${e.message}`);
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
server.registerTool("workbench_list_takes", {
|
|
750
|
+
title: "Workbench: List Takes",
|
|
751
|
+
description: "List all Workbench takes (renders) with full provenance, oldest first.",
|
|
752
|
+
inputSchema: {},
|
|
753
|
+
}, async () => {
|
|
754
|
+
const check = await requireBridge();
|
|
755
|
+
if (check !== true)
|
|
756
|
+
return check;
|
|
757
|
+
return json(await bridge.send("workbenchListTakes"));
|
|
758
|
+
});
|
|
759
|
+
server.registerTool("workbench_load_take", {
|
|
760
|
+
title: "Workbench: Load Take",
|
|
761
|
+
description: "Load a rendered take's WAV into a cell (same native path as load_sample).",
|
|
762
|
+
inputSchema: {
|
|
763
|
+
takeId: z.string().describe("Take id from workbench_render / workbench_list_takes."),
|
|
764
|
+
cellId: z.number().int().min(1).max(8).describe("Target cell."),
|
|
765
|
+
},
|
|
766
|
+
}, async ({ takeId, cellId }) => {
|
|
767
|
+
const check = await requireBridge();
|
|
768
|
+
if (check !== true)
|
|
769
|
+
return check;
|
|
770
|
+
try {
|
|
771
|
+
return json(await bridge.send("workbenchLoadTake", { takeId, cellId }));
|
|
772
|
+
}
|
|
773
|
+
catch (e) {
|
|
774
|
+
return error(`Failed to load take: ${e.message}`);
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
server.registerTool("workbench_undo", {
|
|
778
|
+
title: "Workbench: Undo (pop chain)",
|
|
779
|
+
description: "Pop the Workbench render chain one step: restores the previous chain head (activeSourcePath) so the next render re-transforms the prior source. No-op at the chain root (cell sample). Takes are kept. Returns the full { state, takes }.",
|
|
780
|
+
inputSchema: {},
|
|
781
|
+
}, async () => {
|
|
782
|
+
const check = await requireBridge();
|
|
783
|
+
if (check !== true)
|
|
784
|
+
return check;
|
|
785
|
+
return json(await bridge.send("workbenchUndo"));
|
|
786
|
+
});
|
|
787
|
+
server.registerTool("workbench_reset_chain", {
|
|
788
|
+
title: "Workbench: Reset Chain",
|
|
789
|
+
description: "Clear the Workbench render chain: empties the undo stack and resets the source back to the cell sample (activeSourcePath = ''). Takes are kept. Returns the full { state, takes }.",
|
|
790
|
+
inputSchema: {},
|
|
791
|
+
}, async () => {
|
|
792
|
+
const check = await requireBridge();
|
|
793
|
+
if (check !== true)
|
|
794
|
+
return check;
|
|
795
|
+
return json(await bridge.send("workbenchResetChain"));
|
|
796
|
+
});
|
|
797
|
+
server.registerTool("workbench_sidecar_status", {
|
|
798
|
+
title: "Workbench: Sidecar Status",
|
|
799
|
+
description: "Ping the SA3 render sidecar (127.0.0.1:8093). Returns { connected, loaded } — loaded means a pipeline is resident (first render after a cold start also pays the model load).",
|
|
800
|
+
inputSchema: {},
|
|
801
|
+
}, async () => {
|
|
802
|
+
const check = await requireBridge();
|
|
803
|
+
if (check !== true)
|
|
804
|
+
return check;
|
|
805
|
+
return json(await bridge.send("workbenchSidecarStatus"));
|
|
806
|
+
});
|
|
481
807
|
server.registerTool("trigger_midi", {
|
|
482
808
|
title: "Trigger MIDI",
|
|
483
809
|
description: "Send a MIDI note to a specific cell or globally. " +
|
|
@@ -558,8 +884,17 @@ server.registerTool("add_modulation", {
|
|
|
558
884
|
.boolean()
|
|
559
885
|
.default(false)
|
|
560
886
|
.describe("Bipolar modulation (modulates both directions)."),
|
|
887
|
+
pitch: z
|
|
888
|
+
.boolean()
|
|
889
|
+
.optional()
|
|
890
|
+
.describe("Envelope only: route from the cell's pitch-tracked source instead of amp. " +
|
|
891
|
+
"false (default) = AMP; true & pitchDuck=false = BOOST; true & pitchDuck=true = DUCK."),
|
|
892
|
+
pitchDuck: z
|
|
893
|
+
.boolean()
|
|
894
|
+
.optional()
|
|
895
|
+
.describe("Envelope only: when pitch=true, switch from BOOST to DUCK semantics (notch). Ignored if pitch=false."),
|
|
561
896
|
},
|
|
562
|
-
}, async ({ type, paramId, cellId, lfoIndex, strength, bipolar }) => {
|
|
897
|
+
}, async ({ type, paramId, cellId, lfoIndex, strength, bipolar, pitch, pitchDuck }) => {
|
|
563
898
|
const index = await getParameterIndex();
|
|
564
899
|
const param = index.get(paramId);
|
|
565
900
|
if (!param)
|
|
@@ -582,7 +917,7 @@ server.registerTool("add_modulation", {
|
|
|
582
917
|
else if (type === "envelope") {
|
|
583
918
|
if (!cellId)
|
|
584
919
|
return error("cellId is required for envelope modulation.");
|
|
585
|
-
await bridge.send("addEnvelope", paramId, cellId, strength, bipolar, effectEnableParams);
|
|
920
|
+
await bridge.send("addEnvelope", paramId, cellId, strength, bipolar, effectEnableParams, pitch ?? false, pitchDuck ?? false);
|
|
586
921
|
}
|
|
587
922
|
else if (type === "vary") {
|
|
588
923
|
if (!cellId)
|
|
@@ -939,11 +1274,9 @@ server.registerTool("save_variation", {
|
|
|
939
1274
|
try {
|
|
940
1275
|
const fullPath = await resolvePresetPath(diskPath);
|
|
941
1276
|
const raw = await readFile(fullPath, "utf-8");
|
|
942
|
-
const
|
|
943
|
-
existingMeta =
|
|
944
|
-
|
|
945
|
-
variations = [...m.variations];
|
|
946
|
-
}
|
|
1277
|
+
const parsed = parsePresetFile(raw);
|
|
1278
|
+
existingMeta = parsed.metadata;
|
|
1279
|
+
variations = variationsFromParsedPreset(parsed);
|
|
947
1280
|
}
|
|
948
1281
|
catch {
|
|
949
1282
|
// New preset or unreadable — start fresh
|
|
@@ -1332,6 +1665,473 @@ server.registerTool("import_visual_folder", {
|
|
|
1332
1665
|
(result.operationId ? ` (operationId: ${result.operationId})` : ""));
|
|
1333
1666
|
});
|
|
1334
1667
|
// ============================================================
|
|
1668
|
+
// VX video subsystem — layers, FX, audio-reactive bindings, settings
|
|
1669
|
+
// ============================================================
|
|
1670
|
+
//
|
|
1671
|
+
// The video scene lives in PAM's WebView (R3F/WebGL). These tools forward semantic
|
|
1672
|
+
// commands into the live video stores and read back a full-scene snapshot. PAM's
|
|
1673
|
+
// window must be OPEN for commands to apply (no headless renderer). Audio-reactive
|
|
1674
|
+
// *visual params* (visualWaveDisplace, etc.) are regular plugin params — drive those
|
|
1675
|
+
// with set_parameters; everything else (layers/FX/bindings/output FX) goes here.
|
|
1676
|
+
// Per-layer FX types (FxRegistry). Some only apply to drawing/text (trails, beam).
|
|
1677
|
+
const VIDEO_LAYER_FX = "hueRotate, glow, audioReact, vibration, chromaticAberration, pixelate, rgbShift, " +
|
|
1678
|
+
"mirror, trails(drawing/text), beam(drawing/text), shake, rolls, barrelBlur, edge, " +
|
|
1679
|
+
"halftone, patches, brightnessContrast, lines, feedback, warp, datamosh, slicer, " +
|
|
1680
|
+
"polarSlitScan, cellVoronoi, spectralZones, paletteLut, asciiDither, pixelSort, " +
|
|
1681
|
+
"cameraShake, blur";
|
|
1682
|
+
// Output/global FX chain ids (videoStore, DEFAULT_VIDEO_FX_CONFIG).
|
|
1683
|
+
const VIDEO_OUTPUT_FX = "lines, barrelBlur, brightnessContrast, edge, glow, shake, rgbShift, halftone, mirror, " +
|
|
1684
|
+
"patches, pixelate, rolls, feedback, warp, datamosh, slicer, positionXY, speed, " +
|
|
1685
|
+
"cellVoronoi, polarSlitScan, spectralZones, audioReact";
|
|
1686
|
+
// Binding source vocabulary (sourceKey string form OR a ModSource object).
|
|
1687
|
+
const VIDEO_BIND_SOURCES = "Pass `source` as a sourceKey string: " +
|
|
1688
|
+
"`g:<id>` global (level, bandLow, bandMid, bandHigh, transient, transientLow, " +
|
|
1689
|
+
"transientMid, transientHigh, bpm, spectralCentroid, spectralFlux, stereoWidth, " +
|
|
1690
|
+
"varyActivity, phraseLevel, phraseBass, looperBus, masterOnsetLow/Mid/High, " +
|
|
1691
|
+
"masterStereoWidth, masterPanScalar, masterFullMix, masterCompressorGR, " +
|
|
1692
|
+
"sidechainLevel, sidechainEnv); " +
|
|
1693
|
+
"`t:<phase>` transport (beat, bar, phrase); " +
|
|
1694
|
+
"`c:<cell>:<field>` per-cell (level, envFollow, pulse, onset, spectrumPeak, " +
|
|
1695
|
+
"spectrumCentroid, pitch, postFaderL/R, samplerRMSL/R, synthRMS, beatPosition, " +
|
|
1696
|
+
"activeRetriggers, grainActivity, grainPos, grainSpread, grainPitch, " +
|
|
1697
|
+
"grainPitchSpread, grainReverseRatio); " +
|
|
1698
|
+
"`l:<track>:level` looper; `lf:<lfo>:rate|phase` LFO; " +
|
|
1699
|
+
"`p:<paramId>` any DAW param (e.g. p:macro_1). Or a structured object e.g. " +
|
|
1700
|
+
"{kind:'cell',cellIndex:1,field:'onset'}.";
|
|
1701
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1702
|
+
async function getVideoStateRaw() {
|
|
1703
|
+
const r = await bridge.send("getVideoState");
|
|
1704
|
+
return (typeof r === "string" ? JSON.parse(r) : r) ?? {};
|
|
1705
|
+
}
|
|
1706
|
+
const sendVideoCommand = (command) => bridge.send("videoCommand", command);
|
|
1707
|
+
/** Create ops are fire-and-forget; resolve the minted id by diffing the snapshot. */
|
|
1708
|
+
async function resolveNewId(beforeIds, pick, timeoutMs = 2500) {
|
|
1709
|
+
const deadline = Date.now() + timeoutMs;
|
|
1710
|
+
while (Date.now() < deadline) {
|
|
1711
|
+
await sleep(120);
|
|
1712
|
+
const s = await getVideoStateRaw();
|
|
1713
|
+
const found = pick(s).find((id) => !beforeIds.has(id));
|
|
1714
|
+
if (found)
|
|
1715
|
+
return found;
|
|
1716
|
+
}
|
|
1717
|
+
return null;
|
|
1718
|
+
}
|
|
1719
|
+
server.registerTool("get_video_state", {
|
|
1720
|
+
title: "Get Video State",
|
|
1721
|
+
description: "Read the full VX video scene: layers (in render order, top-first) with their " +
|
|
1722
|
+
"per-layer FX chains, the output/global FX chain, audio-reactive bindings, " +
|
|
1723
|
+
"mod-matrix routes, the 3D model overlay, the active cell, and global render/sync " +
|
|
1724
|
+
"settings. Layer/FX/binding ids returned here are what the other video tools " +
|
|
1725
|
+
"address. If `available` is false, open PAM's window so the video UI mounts.",
|
|
1726
|
+
inputSchema: {},
|
|
1727
|
+
annotations: { readOnlyHint: true },
|
|
1728
|
+
}, async () => {
|
|
1729
|
+
const check = await requireBridge();
|
|
1730
|
+
if (check !== true)
|
|
1731
|
+
return check;
|
|
1732
|
+
const state = await getVideoStateRaw();
|
|
1733
|
+
return json(state);
|
|
1734
|
+
});
|
|
1735
|
+
server.registerTool("capture_ui", {
|
|
1736
|
+
title: "Capture UI Screenshot",
|
|
1737
|
+
description: "Render PAM's plugin UI (#app_root) to a PNG via html2canvas and return the saved " +
|
|
1738
|
+
"file path (read it to view the image). Optionally compose the view first: " +
|
|
1739
|
+
"gridSize (1|4|8), mainView (CellGrid|CtrlAll|Envelope|LFO|Vary|Mixer|SongMode|Video|" +
|
|
1740
|
+
"PresetBrowser; omit/null = cell grid), instrumentView ('synth'|'sampler', applied to " +
|
|
1741
|
+
"all cells). REQUIRES PAM's window OPEN — html2canvas needs the live DOM, there is no " +
|
|
1742
|
+
"headless renderer (same constraint as the video tools). Beats screen-capture: " +
|
|
1743
|
+
"deterministic, no cursor/screen control, exact UI bitmap.",
|
|
1744
|
+
inputSchema: {
|
|
1745
|
+
gridSize: z.number().optional().describe("Cell grid size to set before capture: 1, 4, or 8."),
|
|
1746
|
+
mainView: z
|
|
1747
|
+
.string()
|
|
1748
|
+
.optional()
|
|
1749
|
+
.describe("Main view to switch to before capture (CellGrid, Mixer, LFO, …). Omit for the cell grid."),
|
|
1750
|
+
instrumentView: z
|
|
1751
|
+
.enum(["synth", "sampler"])
|
|
1752
|
+
.optional()
|
|
1753
|
+
.describe("Switch all cells to the synth or sampler tab before capture."),
|
|
1754
|
+
settleMs: z.number().optional().describe("Delay (ms) after view changes before rasterizing. Default 350."),
|
|
1755
|
+
timeoutMs: z.number().optional().default(8000).describe("Max ms to wait for the WebView to return the image."),
|
|
1756
|
+
},
|
|
1757
|
+
annotations: { readOnlyHint: true },
|
|
1758
|
+
}, async ({ gridSize, mainView, instrumentView, settleMs, timeoutMs }) => {
|
|
1759
|
+
const check = await requireBridge();
|
|
1760
|
+
if (check !== true)
|
|
1761
|
+
return check;
|
|
1762
|
+
const requestId = `ui-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
|
1763
|
+
const setup = { requestId };
|
|
1764
|
+
if (typeof gridSize === "number")
|
|
1765
|
+
setup.gridSize = gridSize;
|
|
1766
|
+
if (mainView !== undefined)
|
|
1767
|
+
setup.mainView = mainView;
|
|
1768
|
+
if (instrumentView !== undefined)
|
|
1769
|
+
setup.instrumentView = instrumentView;
|
|
1770
|
+
if (typeof settleMs === "number")
|
|
1771
|
+
setup.settleMs = settleMs;
|
|
1772
|
+
// Forward is fire-and-forget; poll the C++ cache until the WebView posts the PNG back.
|
|
1773
|
+
await bridge.send("captureUi", setup);
|
|
1774
|
+
const deadline = Date.now() + (timeoutMs ?? 8000);
|
|
1775
|
+
for (;;) {
|
|
1776
|
+
const res = (await bridge.send("getUiCapture", { requestId }));
|
|
1777
|
+
if (res.ready && res.requestId === requestId && res.path) {
|
|
1778
|
+
return text(`Saved UI screenshot: ${res.path}`);
|
|
1779
|
+
}
|
|
1780
|
+
if (Date.now() > deadline) {
|
|
1781
|
+
return error("capture_ui timed out waiting for the WebView to return the image. Is PAM's window " +
|
|
1782
|
+
"open and active? html2canvas needs the live DOM — a closed/suspended editor cannot render.");
|
|
1783
|
+
}
|
|
1784
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
1785
|
+
}
|
|
1786
|
+
});
|
|
1787
|
+
server.registerTool("video_command", {
|
|
1788
|
+
title: "Video Command (advanced)",
|
|
1789
|
+
description: "Escape hatch to the full video command router — pass any { op, ...args } object. " +
|
|
1790
|
+
"Use the dedicated tools (add_video_layer, set_video_fx, bind_video_param, …) when " +
|
|
1791
|
+
"one fits; this reaches everything else. Ops: " +
|
|
1792
|
+
"addLayer{kind,name?,insertAt?}, removeLayer{layerId}, updateLayer{layerId,patch}, " +
|
|
1793
|
+
"moveLayer{layerId,toIndex}, duplicateLayer{layerId}, renameLayer{layerId,name}, " +
|
|
1794
|
+
"setLayerEnabled{layerId,enabled?}, toggleSolo{layerId}, selectLayer{layerId}, " +
|
|
1795
|
+
"resetLayer{layerId}, applyLayout{preset}, randomizeLayer{layerId}; " +
|
|
1796
|
+
"addLayerFx{layerId,fxType,params?,beforeFxId?}, removeLayerFx{layerId,fxId}, " +
|
|
1797
|
+
"removeAllLayerFx{layerId}, setLayerFxParam{layerId,fxId,param,value|params}, " +
|
|
1798
|
+
"setLayerFxEnabled{layerId,fxId,enabled?}, reorderLayerFx{layerId,fxIds}, " +
|
|
1799
|
+
"resetLayerFx{layerId,fxId}; bind{layerId,fxId?,paramKey,source,...}, " +
|
|
1800
|
+
"unbind{layerId,fxId?,paramKey}, setBindingField{layerId,fxId?,paramKey,field,value}, " +
|
|
1801
|
+
"fireImpulse{layerId,fxId?,paramKey}, removeBindingsForLayer{layerId}, clearBindings; " +
|
|
1802
|
+
"addOutputFx{fxId}, removeOutputFx{instanceId}, setOutputFxParam{instanceId|fxId,param,value}, " +
|
|
1803
|
+
"setOutputFxEnabled{instanceId,enabled}, setOutputFxStrength{instanceId,strength}; " +
|
|
1804
|
+
"addModRoute{route}, removeModRoute{id}, updateModRoute{id,updates}, clearModRoutes, " +
|
|
1805
|
+
"setModEnabled{enabled}; setModel{sourcePath?,visible?,blendMode?,audioReactive?,autoRotate?,transform?}, " +
|
|
1806
|
+
"resetModel; setSettings{...}, setActiveCell{cellId}, resetWorkspace. " +
|
|
1807
|
+
"Layer fx target a per-layer chain; output fx target the global chain (ids differ). " +
|
|
1808
|
+
VIDEO_BIND_SOURCES,
|
|
1809
|
+
inputSchema: {
|
|
1810
|
+
command: z
|
|
1811
|
+
.record(z.string(), z.any())
|
|
1812
|
+
.describe("The command object. Must include a string `op` field."),
|
|
1813
|
+
},
|
|
1814
|
+
}, async ({ command }) => {
|
|
1815
|
+
const check = await requireBridge();
|
|
1816
|
+
if (check !== true)
|
|
1817
|
+
return check;
|
|
1818
|
+
const result = await sendVideoCommand(command);
|
|
1819
|
+
return json(result);
|
|
1820
|
+
});
|
|
1821
|
+
server.registerTool("add_video_layer", {
|
|
1822
|
+
title: "Add Video Layer",
|
|
1823
|
+
description: "Add a layer to the VX scene and return its id. Kinds: video, image, camera, " +
|
|
1824
|
+
"text, drawing, model3d, adjustment (adjustment = full-canvas FX only; master is a " +
|
|
1825
|
+
"managed singleton and cannot be added). After adding, set its source/props with " +
|
|
1826
|
+
"update_video_layer and stack FX with add_video_fx.",
|
|
1827
|
+
inputSchema: {
|
|
1828
|
+
kind: z
|
|
1829
|
+
.enum(["video", "image", "camera", "text", "drawing", "model3d", "adjustment"])
|
|
1830
|
+
.describe("Layer kind."),
|
|
1831
|
+
name: z.string().optional().describe("Display name (auto-generated if omitted)."),
|
|
1832
|
+
insertAt: z
|
|
1833
|
+
.number()
|
|
1834
|
+
.int()
|
|
1835
|
+
.optional()
|
|
1836
|
+
.describe("Data-index to insert at (default: top of stack)."),
|
|
1837
|
+
},
|
|
1838
|
+
}, async ({ kind, name, insertAt }) => {
|
|
1839
|
+
const check = await requireBridge();
|
|
1840
|
+
if (check !== true)
|
|
1841
|
+
return check;
|
|
1842
|
+
const before = await getVideoStateRaw();
|
|
1843
|
+
const beforeIds = new Set((before.layers ?? []).map((l) => l.id));
|
|
1844
|
+
await sendVideoCommand({ op: "addLayer", kind, name, insertAt });
|
|
1845
|
+
const id = await resolveNewId(beforeIds, (s) => (s.layers ?? []).map((l) => l.id));
|
|
1846
|
+
return text(id
|
|
1847
|
+
? `Added ${kind} layer: ${id}`
|
|
1848
|
+
: `Added ${kind} layer (window may be closed; call get_video_state to confirm).`);
|
|
1849
|
+
});
|
|
1850
|
+
server.registerTool("update_video_layer", {
|
|
1851
|
+
title: "Update Video Layer",
|
|
1852
|
+
description: "Update a layer's common props and/or kind-specific fields. transform is merged " +
|
|
1853
|
+
"(partial) against the current transform; other fields replace. Common: opacity " +
|
|
1854
|
+
"(0..1), blendMode (normal|add|multiply|screen), enabled, name, transform " +
|
|
1855
|
+
"{x,y(0..1),scale,rotation(rad),anchor}. Kind-specific via `fields`: text layers " +
|
|
1856
|
+
"{text,fontSize,fontWeight,color,align,lineHeight}; image/video/model3d {sourcePath,fit}; " +
|
|
1857
|
+
"camera {deviceId,mirror,fit}; drawing {paintColor,paintWidth}; model3d " +
|
|
1858
|
+
"{lightingMode,lightIntensity,rotateRateX/Y/Z,...}. Use get_video_state to see a " +
|
|
1859
|
+
"layer's current fields.",
|
|
1860
|
+
inputSchema: {
|
|
1861
|
+
layerId: z.string().describe("Target layer id (from get_video_state)."),
|
|
1862
|
+
opacity: z.number().min(0).max(1).optional(),
|
|
1863
|
+
blendMode: z.enum(["normal", "add", "multiply", "screen"]).optional(),
|
|
1864
|
+
enabled: z.boolean().optional(),
|
|
1865
|
+
name: z.string().optional(),
|
|
1866
|
+
transform: z
|
|
1867
|
+
.record(z.string(), z.any())
|
|
1868
|
+
.optional()
|
|
1869
|
+
.describe("Partial transform: {x?,y?,scale?,rotation?,anchor?}. Merged."),
|
|
1870
|
+
fields: z
|
|
1871
|
+
.record(z.string(), z.any())
|
|
1872
|
+
.optional()
|
|
1873
|
+
.describe("Kind-specific fields (text, sourcePath, fit, fontSize, color, …)."),
|
|
1874
|
+
},
|
|
1875
|
+
}, async ({ layerId, opacity, blendMode, enabled, name, transform, fields }) => {
|
|
1876
|
+
const check = await requireBridge();
|
|
1877
|
+
if (check !== true)
|
|
1878
|
+
return check;
|
|
1879
|
+
const patch = { ...(fields ?? {}) };
|
|
1880
|
+
if (opacity !== undefined)
|
|
1881
|
+
patch.opacity = opacity;
|
|
1882
|
+
if (blendMode !== undefined)
|
|
1883
|
+
patch.blendMode = blendMode;
|
|
1884
|
+
if (enabled !== undefined)
|
|
1885
|
+
patch.enabled = enabled;
|
|
1886
|
+
if (name !== undefined)
|
|
1887
|
+
patch.name = name;
|
|
1888
|
+
if (transform !== undefined)
|
|
1889
|
+
patch.transform = transform;
|
|
1890
|
+
const result = await sendVideoCommand({ op: "updateLayer", layerId, patch });
|
|
1891
|
+
return json(result);
|
|
1892
|
+
});
|
|
1893
|
+
server.registerTool("remove_video_layer", {
|
|
1894
|
+
title: "Remove Video Layer",
|
|
1895
|
+
description: "Remove a layer (and its FX + bindings). The master layer cannot be removed.",
|
|
1896
|
+
inputSchema: { layerId: z.string().describe("Layer id to remove.") },
|
|
1897
|
+
annotations: { destructiveHint: true },
|
|
1898
|
+
}, async ({ layerId }) => {
|
|
1899
|
+
const check = await requireBridge();
|
|
1900
|
+
if (check !== true)
|
|
1901
|
+
return check;
|
|
1902
|
+
const result = await sendVideoCommand({ op: "removeLayer", layerId });
|
|
1903
|
+
return json(result);
|
|
1904
|
+
});
|
|
1905
|
+
server.registerTool("apply_video_layout", {
|
|
1906
|
+
title: "Apply Video Layout",
|
|
1907
|
+
description: "Arrange the spatial layers with a layout preset: centered (stacked), grid, " +
|
|
1908
|
+
"columns, rows, or focus (one large + thumbnail strip). adjustment and master " +
|
|
1909
|
+
"layers are untouched.",
|
|
1910
|
+
inputSchema: {
|
|
1911
|
+
preset: z.enum(["centered", "grid", "columns", "rows", "focus"]).describe("Layout preset."),
|
|
1912
|
+
},
|
|
1913
|
+
}, async ({ preset }) => {
|
|
1914
|
+
const check = await requireBridge();
|
|
1915
|
+
if (check !== true)
|
|
1916
|
+
return check;
|
|
1917
|
+
const result = await sendVideoCommand({ op: "applyLayout", preset });
|
|
1918
|
+
return json(result);
|
|
1919
|
+
});
|
|
1920
|
+
server.registerTool("add_video_fx", {
|
|
1921
|
+
title: "Add Video FX",
|
|
1922
|
+
description: "Append an FX to a layer's chain (target='layer', needs layerId) or to the output/" +
|
|
1923
|
+
"global chain (target='output'), and return the new id. Per-layer FX: " +
|
|
1924
|
+
VIDEO_LAYER_FX +
|
|
1925
|
+
". Output FX: " +
|
|
1926
|
+
VIDEO_OUTPUT_FX +
|
|
1927
|
+
". FX params seed from registry defaults; override via `params`. Use set_video_fx " +
|
|
1928
|
+
"to tweak afterwards.",
|
|
1929
|
+
inputSchema: {
|
|
1930
|
+
target: z.enum(["layer", "output"]).default("layer").describe("Which chain."),
|
|
1931
|
+
layerId: z.string().optional().describe("Required when target='layer'."),
|
|
1932
|
+
fxType: z.string().describe("FX id (see the per-layer / output id lists)."),
|
|
1933
|
+
params: z
|
|
1934
|
+
.record(z.string(), z.number())
|
|
1935
|
+
.optional()
|
|
1936
|
+
.describe("Optional initial param overrides {paramKey: value}."),
|
|
1937
|
+
beforeFxId: z
|
|
1938
|
+
.string()
|
|
1939
|
+
.optional()
|
|
1940
|
+
.describe("Insert before this fx id instead of appending (layer target only)."),
|
|
1941
|
+
},
|
|
1942
|
+
}, async ({ target, layerId, fxType, params, beforeFxId }) => {
|
|
1943
|
+
const check = await requireBridge();
|
|
1944
|
+
if (check !== true)
|
|
1945
|
+
return check;
|
|
1946
|
+
if (target === "output") {
|
|
1947
|
+
const before = await getVideoStateRaw();
|
|
1948
|
+
const beforeIds = new Set((before.outputFx ?? []).map((f) => f.instanceId));
|
|
1949
|
+
await sendVideoCommand({ op: "addOutputFx", fxId: fxType });
|
|
1950
|
+
const id = await resolveNewId(beforeIds, (s) => (s.outputFx ?? []).map((f) => f.instanceId));
|
|
1951
|
+
// Apply param overrides now that the instance exists.
|
|
1952
|
+
if (params && id)
|
|
1953
|
+
for (const [param, value] of Object.entries(params))
|
|
1954
|
+
await sendVideoCommand({ op: "setOutputFxParam", instanceId: id, param, value });
|
|
1955
|
+
return text(id ? `Added output FX ${fxType}: ${id}` : `Added output FX ${fxType} (confirm via get_video_state).`);
|
|
1956
|
+
}
|
|
1957
|
+
if (!layerId)
|
|
1958
|
+
return error("add_video_fx: layerId is required when target='layer'.");
|
|
1959
|
+
const before = await getVideoStateRaw();
|
|
1960
|
+
const layer = (before.layers ?? []).find((l) => l.id === layerId);
|
|
1961
|
+
const beforeIds = new Set((layer?.fx ?? []).map((f) => f.id));
|
|
1962
|
+
await sendVideoCommand({ op: "addLayerFx", layerId, fxType, params, beforeFxId });
|
|
1963
|
+
const id = await resolveNewId(beforeIds, (s) => {
|
|
1964
|
+
const l = (s.layers ?? []).find((x) => x.id === layerId);
|
|
1965
|
+
return (l?.fx ?? []).map((f) => f.id);
|
|
1966
|
+
});
|
|
1967
|
+
return text(id ? `Added ${fxType} to layer ${layerId}: ${id}` : `Added ${fxType} (confirm via get_video_state).`);
|
|
1968
|
+
});
|
|
1969
|
+
server.registerTool("set_video_fx", {
|
|
1970
|
+
title: "Set Video FX",
|
|
1971
|
+
description: "Set FX params / enabled / strength on a per-layer FX (target='layer', needs " +
|
|
1972
|
+
"layerId + fxId) or an output FX (target='output', needs instanceId, or fxId to " +
|
|
1973
|
+
"auto-create). Pass a single {param,value}, multiple {params}, `enabled`, and/or " +
|
|
1974
|
+
"`strength` (output only). Get current ids/params from get_video_state.",
|
|
1975
|
+
inputSchema: {
|
|
1976
|
+
target: z.enum(["layer", "output"]).default("layer"),
|
|
1977
|
+
layerId: z.string().optional().describe("target='layer': owning layer id."),
|
|
1978
|
+
fxId: z.string().optional().describe("Layer fx id, or output fxId (auto-creates the output fx)."),
|
|
1979
|
+
instanceId: z.string().optional().describe("target='output': the output fx instance id."),
|
|
1980
|
+
param: z.string().optional().describe("Single param key to set."),
|
|
1981
|
+
value: z.number().optional().describe("Value for `param`."),
|
|
1982
|
+
params: z.record(z.string(), z.number()).optional().describe("Multiple params {key: value}."),
|
|
1983
|
+
enabled: z.boolean().optional().describe("Enable/bypass this FX."),
|
|
1984
|
+
strength: z.number().optional().describe("Output FX wet strength (output target only)."),
|
|
1985
|
+
},
|
|
1986
|
+
}, async ({ target, layerId, fxId, instanceId, param, value, params, enabled, strength }) => {
|
|
1987
|
+
const check = await requireBridge();
|
|
1988
|
+
if (check !== true)
|
|
1989
|
+
return check;
|
|
1990
|
+
const results = [];
|
|
1991
|
+
if (target === "layer") {
|
|
1992
|
+
if (!layerId || !fxId)
|
|
1993
|
+
return error("set_video_fx: layerId and fxId required for target='layer'.");
|
|
1994
|
+
if (param !== undefined && value !== undefined)
|
|
1995
|
+
results.push(await sendVideoCommand({ op: "setLayerFxParam", layerId, fxId, param, value }));
|
|
1996
|
+
if (params)
|
|
1997
|
+
results.push(await sendVideoCommand({ op: "setLayerFxParam", layerId, fxId, params }));
|
|
1998
|
+
if (enabled !== undefined)
|
|
1999
|
+
results.push(await sendVideoCommand({ op: "setLayerFxEnabled", layerId, fxId, enabled }));
|
|
2000
|
+
}
|
|
2001
|
+
else {
|
|
2002
|
+
if (param !== undefined && value !== undefined)
|
|
2003
|
+
results.push(await sendVideoCommand({ op: "setOutputFxParam", instanceId, fxId, param, value }));
|
|
2004
|
+
if (params)
|
|
2005
|
+
for (const [p, v] of Object.entries(params))
|
|
2006
|
+
results.push(await sendVideoCommand({ op: "setOutputFxParam", instanceId, fxId, param: p, value: v }));
|
|
2007
|
+
if (enabled !== undefined && instanceId)
|
|
2008
|
+
results.push(await sendVideoCommand({ op: "setOutputFxEnabled", instanceId, enabled }));
|
|
2009
|
+
if (strength !== undefined && instanceId)
|
|
2010
|
+
results.push(await sendVideoCommand({ op: "setOutputFxStrength", instanceId, strength }));
|
|
2011
|
+
}
|
|
2012
|
+
if (results.length === 0)
|
|
2013
|
+
return error("set_video_fx: nothing to set (provide param/value, params, enabled, or strength).");
|
|
2014
|
+
return json({ applied: results.length });
|
|
2015
|
+
});
|
|
2016
|
+
server.registerTool("remove_video_fx", {
|
|
2017
|
+
title: "Remove Video FX",
|
|
2018
|
+
description: "Remove an FX from a layer chain (target='layer' + layerId + fxId) or the output " +
|
|
2019
|
+
"chain (target='output' + instanceId).",
|
|
2020
|
+
inputSchema: {
|
|
2021
|
+
target: z.enum(["layer", "output"]).default("layer"),
|
|
2022
|
+
layerId: z.string().optional(),
|
|
2023
|
+
fxId: z.string().optional().describe("target='layer': fx id on the layer."),
|
|
2024
|
+
instanceId: z.string().optional().describe("target='output': output fx instance id."),
|
|
2025
|
+
},
|
|
2026
|
+
annotations: { destructiveHint: true },
|
|
2027
|
+
}, async ({ target, layerId, fxId, instanceId }) => {
|
|
2028
|
+
const check = await requireBridge();
|
|
2029
|
+
if (check !== true)
|
|
2030
|
+
return check;
|
|
2031
|
+
if (target === "layer") {
|
|
2032
|
+
if (!layerId || !fxId)
|
|
2033
|
+
return error("remove_video_fx: layerId and fxId required for target='layer'.");
|
|
2034
|
+
return json(await sendVideoCommand({ op: "removeLayerFx", layerId, fxId }));
|
|
2035
|
+
}
|
|
2036
|
+
if (!instanceId)
|
|
2037
|
+
return error("remove_video_fx: instanceId required for target='output'.");
|
|
2038
|
+
return json(await sendVideoCommand({ op: "removeOutputFx", instanceId }));
|
|
2039
|
+
});
|
|
2040
|
+
server.registerTool("bind_video_param", {
|
|
2041
|
+
title: "Bind Video Param (audio-reactive)",
|
|
2042
|
+
description: "Bind a per-layer FX param or layer field to an audio/transport source so it reacts " +
|
|
2043
|
+
"to the music. Omit fxId (or pass '') to target a layer field: transform.x, " +
|
|
2044
|
+
"transform.y, transform.scale, transform.rotation, opacity, hue, brightness, " +
|
|
2045
|
+
"contrast, saturation, playbackRate, fontSize, pulseAmount, or model3d fields. " +
|
|
2046
|
+
"Pass fxId to target an FX param (param key from that FX). " +
|
|
2047
|
+
VIDEO_BIND_SOURCES +
|
|
2048
|
+
" amount is -1..1; curve linear|exp|expInverse|bipolar; mode additive|replace|" +
|
|
2049
|
+
"multiply|centered; impulse off|pulse|latch (one-shot on threshold crossing). " +
|
|
2050
|
+
"attack/release default per-source if omitted.",
|
|
2051
|
+
inputSchema: {
|
|
2052
|
+
layerId: z.string().describe("Target layer id."),
|
|
2053
|
+
paramKey: z.string().describe("FX param key, or layer field (e.g. transform.scale, opacity)."),
|
|
2054
|
+
source: z
|
|
2055
|
+
.union([z.string(), z.record(z.string(), z.any())])
|
|
2056
|
+
.describe("sourceKey string (e.g. 'g:bandLow', 'c:1:onset', 't:beat') or ModSource object."),
|
|
2057
|
+
fxId: z.string().optional().describe("FX instance id; omit/'' for a layer-field target."),
|
|
2058
|
+
amount: z.number().min(-1).max(1).optional().describe("Modulation depth (-1..1), default 1."),
|
|
2059
|
+
curve: z.enum(["linear", "exp", "expInverse", "bipolar"]).optional(),
|
|
2060
|
+
mode: z.enum(["additive", "replace", "multiply", "centered"]).optional(),
|
|
2061
|
+
attackMs: z.number().min(0).optional(),
|
|
2062
|
+
releaseMs: z.number().min(0).optional(),
|
|
2063
|
+
threshold: z.number().min(0).max(1).optional().describe("Gate / impulse trigger level."),
|
|
2064
|
+
impulse: z.enum(["off", "pulse", "latch"]).optional(),
|
|
2065
|
+
},
|
|
2066
|
+
}, async ({ layerId, paramKey, source, fxId, amount, curve, mode, attackMs, releaseMs, threshold, impulse }) => {
|
|
2067
|
+
const check = await requireBridge();
|
|
2068
|
+
if (check !== true)
|
|
2069
|
+
return check;
|
|
2070
|
+
const result = await sendVideoCommand({
|
|
2071
|
+
op: "bind",
|
|
2072
|
+
layerId,
|
|
2073
|
+
fxId: fxId ?? "",
|
|
2074
|
+
paramKey,
|
|
2075
|
+
source,
|
|
2076
|
+
amount,
|
|
2077
|
+
curve,
|
|
2078
|
+
mode,
|
|
2079
|
+
attackMs,
|
|
2080
|
+
releaseMs,
|
|
2081
|
+
threshold,
|
|
2082
|
+
impulse,
|
|
2083
|
+
});
|
|
2084
|
+
return json(result);
|
|
2085
|
+
});
|
|
2086
|
+
server.registerTool("set_video_settings", {
|
|
2087
|
+
title: "Set Video Settings",
|
|
2088
|
+
description: "Set global VX render/sync settings: syncMode (off|beat-sync|beat-match), loopBars, " +
|
|
2089
|
+
"aspectRatio (auto|16:9|4:3|1:1|stretch), renderQuality (low|medium|high), " +
|
|
2090
|
+
"designAspect (w/h number or null), letterboxColor / canvasColor (#hex), the active " +
|
|
2091
|
+
"video cell (cellId), and the mod-matrix master enable. resetWorkspace clears the " +
|
|
2092
|
+
"scene (preserves the master layer).",
|
|
2093
|
+
inputSchema: {
|
|
2094
|
+
syncMode: z.enum(["off", "beat-sync", "beat-match"]).optional(),
|
|
2095
|
+
loopBars: z.number().optional(),
|
|
2096
|
+
aspectRatio: z.enum(["auto", "16:9", "4:3", "1:1", "stretch"]).optional(),
|
|
2097
|
+
renderQuality: z.enum(["low", "medium", "high"]).optional(),
|
|
2098
|
+
designAspect: z.number().nullable().optional(),
|
|
2099
|
+
letterboxColor: z.string().optional(),
|
|
2100
|
+
canvasColor: z.string().optional(),
|
|
2101
|
+
modEnabled: z.boolean().optional().describe("Mod-matrix master enable."),
|
|
2102
|
+
activeCell: z.number().int().optional().describe("Switch the active per-cell video slice."),
|
|
2103
|
+
resetWorkspace: z.boolean().optional().describe("If true, clear the whole scene first."),
|
|
2104
|
+
},
|
|
2105
|
+
}, async ({ syncMode, loopBars, aspectRatio, renderQuality, designAspect, letterboxColor, canvasColor, modEnabled, activeCell, resetWorkspace }) => {
|
|
2106
|
+
const check = await requireBridge();
|
|
2107
|
+
if (check !== true)
|
|
2108
|
+
return check;
|
|
2109
|
+
if (resetWorkspace)
|
|
2110
|
+
await sendVideoCommand({ op: "resetWorkspace" });
|
|
2111
|
+
if (activeCell !== undefined)
|
|
2112
|
+
await sendVideoCommand({ op: "setActiveCell", cellId: activeCell });
|
|
2113
|
+
if (modEnabled !== undefined)
|
|
2114
|
+
await sendVideoCommand({ op: "setModEnabled", enabled: modEnabled });
|
|
2115
|
+
const settings = {};
|
|
2116
|
+
if (syncMode !== undefined)
|
|
2117
|
+
settings.syncMode = syncMode;
|
|
2118
|
+
if (loopBars !== undefined)
|
|
2119
|
+
settings.loopBars = loopBars;
|
|
2120
|
+
if (aspectRatio !== undefined)
|
|
2121
|
+
settings.aspectRatio = aspectRatio;
|
|
2122
|
+
if (renderQuality !== undefined)
|
|
2123
|
+
settings.renderQuality = renderQuality;
|
|
2124
|
+
if (designAspect !== undefined)
|
|
2125
|
+
settings.designAspect = designAspect;
|
|
2126
|
+
if (letterboxColor !== undefined)
|
|
2127
|
+
settings.letterboxColor = letterboxColor;
|
|
2128
|
+
if (canvasColor !== undefined)
|
|
2129
|
+
settings.canvasColor = canvasColor;
|
|
2130
|
+
if (Object.keys(settings).length > 0)
|
|
2131
|
+
await sendVideoCommand({ op: "setSettings", ...settings });
|
|
2132
|
+
return text("Video settings updated.");
|
|
2133
|
+
});
|
|
2134
|
+
// ============================================================
|
|
1335
2135
|
// Composite cell-operation tools
|
|
1336
2136
|
// ============================================================
|
|
1337
2137
|
const PATTERN_SUFFIXES = [
|
|
@@ -1628,15 +2428,21 @@ server.registerTool("update_cell_config", {
|
|
|
1628
2428
|
});
|
|
1629
2429
|
server.registerTool("set_master_effects", {
|
|
1630
2430
|
title: "Set Master Effects",
|
|
1631
|
-
description: "Configure the master bus Punch compressor and
|
|
2431
|
+
description: "Configure the master bus Punch compressor, Limiter, and Tape colour. " +
|
|
1632
2432
|
"The Punch compressor is a musical bus compressor — use it for glue and pump. " +
|
|
1633
2433
|
"The Limiter is a 3-stage brick-wall limiter with tanh saturation. " +
|
|
1634
|
-
"
|
|
2434
|
+
"The Tape is a vintage-tape coloration stage (ADAA tanh/asymmetric saturation + HF compression + wow/flutter), " +
|
|
2435
|
+
"the single biggest lever for lo-fi / dusty / 'pushed' master character (hip-hop, lo-fi, Memphis, dub). " +
|
|
2436
|
+
"Pass enabled flags to toggle each on/off. Pass parameters to shape the sound. " +
|
|
1635
2437
|
"For pumpy beats: enable punch, set threshold around -18 to -12 dB, fast attack (0.1-0.5ms), " +
|
|
1636
|
-
"medium release (80-200ms), ratio 4-8, autoMakeup true."
|
|
2438
|
+
"medium release (80-200ms), ratio 4-8, autoMakeup true. " +
|
|
2439
|
+
"For lo-fi/Memphis/dusty glue: tapeEnabled=1, tapeMode=1 (Lo-Fi), masterTapeDrive 0.55-0.7, " +
|
|
2440
|
+
"masterTapeTone -0.4..-0.1 (darker), masterTapeWear 0.5-0.7 (wow/flutter). " +
|
|
2441
|
+
"NOTE: the Tape requires a PAM build that ships it (manifest key masterTapeEnabled). On older builds these keys are ignored.",
|
|
1637
2442
|
inputSchema: {
|
|
1638
2443
|
compressorEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable Punch compressor (0=off, 1=on)."),
|
|
1639
2444
|
limiterEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable master Limiter (0=off, 1=on)."),
|
|
2445
|
+
tapeEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable master Tape colour/saturation (0=off, 1=on)."),
|
|
1640
2446
|
masterCompThreshold: z.number().min(-60).max(0).optional().describe("Compressor threshold in dB (default -24)."),
|
|
1641
2447
|
masterCompRatio: z.number().min(1).max(20).optional().describe("Compressor ratio (default 4)."),
|
|
1642
2448
|
masterCompAttack: z.number().min(0.01).max(100).optional().describe("Attack in ms (default 0.2)."),
|
|
@@ -1647,8 +2453,13 @@ server.registerTool("set_master_effects", {
|
|
|
1647
2453
|
masterCompCeiling: z.number().min(-6).max(0).optional().describe("Output ceiling in dB (default -0.05)."),
|
|
1648
2454
|
masterCompInputGain: z.number().min(-24).max(24).optional().describe("Input gain in dB (default 0)."),
|
|
1649
2455
|
masterCompKnee: z.number().min(0).max(30).optional().describe("Knee width in dB (default 0)."),
|
|
2456
|
+
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')."),
|
|
2457
|
+
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."),
|
|
2458
|
+
masterTapeMix: z.number().min(0).max(1).optional().describe("Tape dry/wet (0-1, default 1 = fully wet)."),
|
|
2459
|
+
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."),
|
|
2460
|
+
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."),
|
|
1650
2461
|
},
|
|
1651
|
-
}, async ({ compressorEnabled, limiterEnabled, ...
|
|
2462
|
+
}, async ({ compressorEnabled, limiterEnabled, tapeEnabled, ...effectParams }) => {
|
|
1652
2463
|
const check = await requireBridge();
|
|
1653
2464
|
if (check !== true)
|
|
1654
2465
|
return check;
|
|
@@ -1658,8 +2469,11 @@ server.registerTool("set_master_effects", {
|
|
|
1658
2469
|
delta.masterCompressorEnabled = compressorEnabled;
|
|
1659
2470
|
if (limiterEnabled !== undefined)
|
|
1660
2471
|
delta.masterLimiterEnabled = limiterEnabled;
|
|
1661
|
-
|
|
1662
|
-
|
|
2472
|
+
if (tapeEnabled !== undefined)
|
|
2473
|
+
delta.masterTapeEnabled = tapeEnabled;
|
|
2474
|
+
// Compressor + tape parameters are already the flat state keys read by the
|
|
2475
|
+
// DSP graph / native master node (masterComp*, masterTape*). Pass through.
|
|
2476
|
+
for (const [key, value] of Object.entries(effectParams)) {
|
|
1663
2477
|
if (value !== undefined)
|
|
1664
2478
|
delta[key] = value;
|
|
1665
2479
|
}
|
|
@@ -1673,6 +2487,83 @@ server.registerTool("set_master_effects", {
|
|
|
1673
2487
|
.join(", ");
|
|
1674
2488
|
return text(`Updated master effects: ${summary}`);
|
|
1675
2489
|
});
|
|
2490
|
+
// Built-in per-cell drum synth ("machines"). Engine enum index = drumsynth::Engine
|
|
2491
|
+
// (Off = 0). Per-engine param labels + musical defaults mirror DRUM_SYNTH_ENGINES
|
|
2492
|
+
// in interface/src/dsp/modules/drumSynth.ts — keep in lockstep (append-only).
|
|
2493
|
+
const DRUM_SYNTH_ENGINES = [
|
|
2494
|
+
{ 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]] },
|
|
2495
|
+
{ 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]] },
|
|
2496
|
+
{ name: "HiHat", params: [["Tune", 0.5], ["Decay", 0.3], ["Tone", 0.5], ["Metal", 0.6], ["Noise", 0.2]] },
|
|
2497
|
+
{ name: "Clap", params: [["Tune", 0.45], ["Spread", 0.4], ["Decay", 0.4], ["Tone", 0.5], ["Drive", 0.2]] },
|
|
2498
|
+
{ name: "Tom", params: [["Tune", 0.4], ["Pitch", 0.4], ["Decay", 0.45], ["Tone", 0.55], ["Noise", 0.2], ["Drive", 0.2]] },
|
|
2499
|
+
{ 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]] },
|
|
2500
|
+
{ name: "Cymbal", params: [["Tune", 0.5], ["Decay", 0.5], ["Tone", 0.6], ["Metal", 0.7], ["Sizzle", 0.4]] },
|
|
2501
|
+
{ name: "Cowbell", params: [["Tune", 0.5], ["Decay", 0.4], ["Ratio", 0.45], ["Tone", 0.5], ["Drive", 0.2]] },
|
|
2502
|
+
{ 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]] },
|
|
2503
|
+
{ name: "Shaker", params: [["Tune", 0.5], ["Color", 0.3], ["Decay", 0.3], ["Attack", 0.4], ["Rattle", 0.0], ["Tone", 0.4]] },
|
|
2504
|
+
{ name: "Rim", params: [["Tune", 0.4], ["Ring", 0.5], ["Decay", 0.3], ["Mode", 0.4], ["Snap", 0.6], ["Drive", 0.2]] },
|
|
2505
|
+
{ name: "Conga", params: [["Tune", 0.45], ["Slap", 0.4], ["Decay", 0.4], ["Mode", 0.3], ["Tone", 0.55], ["Skin", 0.25], ["Drive", 0.2]] },
|
|
2506
|
+
{ 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]] },
|
|
2507
|
+
{ name: "Pluck", params: [["Tune", 0.4], ["Sustain", 0.6], ["Damp", 0.5], ["Excite", 0.4], ["Tone", 0.6], ["Drive", 0.15], ["Decay", 0.5]] },
|
|
2508
|
+
{ 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]] },
|
|
2509
|
+
{ 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]] },
|
|
2510
|
+
{ name: "Chaos", params: [["Tune", 0.4], ["Chaos", 0.6], ["Decay", 0.4], ["Tone", 0.5], ["Drive", 0.3], ["Noise", 0.1]] },
|
|
2511
|
+
];
|
|
2512
|
+
const DRUM_SYNTH_ENGINE_NAMES = ["Off", ...DRUM_SYNTH_ENGINES.map((e) => e.name)];
|
|
2513
|
+
server.registerTool("set_drum_synth", {
|
|
2514
|
+
title: "Set Drum Synth (built-in machine)",
|
|
2515
|
+
description: "Select and shape a cell's built-in drum-synth 'machine' — a real-time synthesized voice that sums into the cell " +
|
|
2516
|
+
"alongside (or instead of) its sample. 18 engines: Off, Kick, Snare, HiHat, Clap, Tom, FM, Cymbal, Cowbell, 808, " +
|
|
2517
|
+
"Shaker, Rim, Conga, Bell, Pluck, Zap, Wave, Chaos. The 808 engine is a tuned long-glide sub that plays as a chromatic " +
|
|
2518
|
+
"bassline (drive it with a pitch lane + pitch-quantize) — ideal for 808 sub-bass, trap, Memphis. Use a cell with NO " +
|
|
2519
|
+
"sample for a pure synth voice, or layer over a sample. Each engine reinterprets the 8 generic params (p1..p8); pass " +
|
|
2520
|
+
"only the ones you want to nuance — unspecified params seed to the engine's musical default. Per-engine p-labels: " +
|
|
2521
|
+
DRUM_SYNTH_ENGINES.map((e) => `${e.name}=[${e.params.map((p) => p[0]).join(",")}]`).join("; ") + ". " +
|
|
2522
|
+
"NOTE: requires a PAM build with drum synth (manifest.drumSynth.enabled) — a standard PAM feature, no settings flag. " +
|
|
2523
|
+
"On builds without it, the cell{N}_synthEngine state key is ignored.",
|
|
2524
|
+
inputSchema: {
|
|
2525
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
2526
|
+
engine: z.enum(DRUM_SYNTH_ENGINE_NAMES).describe("Engine name. 'Off' disables the synth voice (sample-only cell)."),
|
|
2527
|
+
volume: z.number().min(-60).max(12).optional().describe("Synth output level in dB (default 0)."),
|
|
2528
|
+
p1: z.number().min(0).max(1).optional().describe("Param 1 (engine-specific; usually Tune). Overrides the engine default."),
|
|
2529
|
+
p2: z.number().min(0).max(1).optional().describe("Param 2 (engine-specific)."),
|
|
2530
|
+
p3: z.number().min(0).max(1).optional().describe("Param 3 (engine-specific)."),
|
|
2531
|
+
p4: z.number().min(0).max(1).optional().describe("Param 4 (engine-specific)."),
|
|
2532
|
+
p5: z.number().min(0).max(1).optional().describe("Param 5 (engine-specific)."),
|
|
2533
|
+
p6: z.number().min(0).max(1).optional().describe("Param 6 (engine-specific)."),
|
|
2534
|
+
p7: z.number().min(0).max(1).optional().describe("Param 7 (engine-specific)."),
|
|
2535
|
+
p8: z.number().min(0).max(1).optional().describe("Param 8 (engine-specific)."),
|
|
2536
|
+
},
|
|
2537
|
+
}, async ({ cellId, engine, volume, ...overrides }) => {
|
|
2538
|
+
const check = await requireBridge();
|
|
2539
|
+
if (check !== true)
|
|
2540
|
+
return check;
|
|
2541
|
+
const engineId = DRUM_SYNTH_ENGINE_NAMES.indexOf(engine);
|
|
2542
|
+
if (engineId < 0)
|
|
2543
|
+
return error(`Unknown drum synth engine: ${engine}`);
|
|
2544
|
+
const delta = { [`cell${cellId}_synthEngine`]: engineId };
|
|
2545
|
+
if (engineId > 0) {
|
|
2546
|
+
// Seed the engine's musical defaults so the voice sounds good immediately,
|
|
2547
|
+
// then apply explicit p-overrides on top (nuance only what you mean to).
|
|
2548
|
+
const desc = DRUM_SYNTH_ENGINES[engineId - 1];
|
|
2549
|
+
desc.params.forEach(([, def], i) => {
|
|
2550
|
+
delta[`cell${cellId}_synth_p${i + 1}`] = def;
|
|
2551
|
+
});
|
|
2552
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
2553
|
+
if (value === undefined)
|
|
2554
|
+
continue;
|
|
2555
|
+
const idx = Number(key.slice(1)); // p3 -> 3
|
|
2556
|
+
if (idx >= 1 && idx <= desc.params.length)
|
|
2557
|
+
delta[`cell${cellId}_synth_p${idx}`] = value;
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
if (volume !== undefined)
|
|
2561
|
+
delta[`cell${cellId}_synthVolume`] = volume;
|
|
2562
|
+
// synthEngine is a graph-render key → needs a graph dispatch (KV_OPTS_STANDARD).
|
|
2563
|
+
await applyKv(delta, KV_OPTS_STANDARD);
|
|
2564
|
+
const labels = engineId > 0 ? DRUM_SYNTH_ENGINES[engineId - 1].params.map((p) => p[0]).join(",") : "—";
|
|
2565
|
+
return text(`Cell ${cellId} drum synth → ${engine}${engineId > 0 ? ` (p1..pN = ${labels})` : ""}.`);
|
|
2566
|
+
});
|
|
1676
2567
|
server.registerTool("add_param_seq", {
|
|
1677
2568
|
title: "Add Parameter Sequencer",
|
|
1678
2569
|
description: "Add a parameter sequencer (step modulation) targeting any modulatable parameter. " +
|
|
@@ -1780,56 +2671,129 @@ server.registerTool("normalize_sample", {
|
|
|
1780
2671
|
await bridge.send("normalizeSampleForCell", cellId);
|
|
1781
2672
|
return text(`Normalized sample in cell ${cellId}.`);
|
|
1782
2673
|
});
|
|
2674
|
+
server.registerTool("gain_sample", {
|
|
2675
|
+
title: "Gain Sample",
|
|
2676
|
+
description: "Apply a gain in decibels to a cell's sample and render it into a new file (offline, destructive). " +
|
|
2677
|
+
"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.",
|
|
2678
|
+
inputSchema: {
|
|
2679
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
2680
|
+
gainDb: z
|
|
2681
|
+
.number()
|
|
2682
|
+
.min(-96)
|
|
2683
|
+
.max(48)
|
|
2684
|
+
.describe("Gain in decibels (e.g. 3 = +3 dB louder, -6 = 6 dB quieter)."),
|
|
2685
|
+
},
|
|
2686
|
+
}, async ({ cellId, gainDb }) => {
|
|
2687
|
+
const check = await requireBridge();
|
|
2688
|
+
if (check !== true)
|
|
2689
|
+
return check;
|
|
2690
|
+
await bridge.send("gainSampleForCell", { cellId, gainDb });
|
|
2691
|
+
return text(`Applied ${gainDb > 0 ? "+" : ""}${gainDb} dB to the sample in cell ${cellId}.`);
|
|
2692
|
+
});
|
|
2693
|
+
// Poll the offline-render status flag until the export completes or times out.
|
|
2694
|
+
async function pollExportStatus(maxWaitMs = 180_000, pollMs = 500) {
|
|
2695
|
+
const t0 = Date.now();
|
|
2696
|
+
while (Date.now() - t0 < maxWaitMs) {
|
|
2697
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
2698
|
+
const status = (await bridge.send("getExportStatus"));
|
|
2699
|
+
if (status.complete)
|
|
2700
|
+
return status.success ? "success" : "failed";
|
|
2701
|
+
}
|
|
2702
|
+
return "timeout";
|
|
2703
|
+
}
|
|
1783
2704
|
server.registerTool("export_audio", {
|
|
1784
2705
|
title: "Export Audio",
|
|
1785
|
-
description: "Render and export audio from the current preset to a WAV file. " +
|
|
1786
|
-
"
|
|
1787
|
-
"
|
|
2706
|
+
description: "Render and export audio from the current preset to a WAV file in PAM's Exports folder. " +
|
|
2707
|
+
"Waits for completion. Default mode renders the looping preset for `bars` bars at the current BPM. " +
|
|
2708
|
+
"Set `mode` to bounce stems or the whole Song Mode arrangement.\n\n" +
|
|
2709
|
+
"Modes: 'main' (full mix, default) · 'individual' (one cell solo, needs cellId) · " +
|
|
2710
|
+
"'all-cells' / 'selected-cells' (per-cell stems, the latter needs selectedCellIds) · " +
|
|
2711
|
+
"'per-bus' (per output bus) · 'song-mode' (the Song Mode timeline).\n\n" +
|
|
2712
|
+
"NOTE on 'song-mode': the OFFLINE bounce only renders the arrangement's FIRST section's live state — " +
|
|
2713
|
+
"variation/preset switches are not applied offline (the message thread is blocked during render). " +
|
|
2714
|
+
"For a true full track that captures every section change, use the `export_song` tool (live capture).",
|
|
1788
2715
|
inputSchema: {
|
|
1789
|
-
bars: z.number().min(1).max(128).default(4).describe("
|
|
2716
|
+
bars: z.number().min(1).max(128).default(4).describe("Bars to render (ignored for 'song-mode', which derives length from the timeline)."),
|
|
1790
2717
|
cellId: z
|
|
1791
2718
|
.number()
|
|
1792
2719
|
.min(1)
|
|
1793
2720
|
.max(8)
|
|
1794
2721
|
.optional()
|
|
1795
|
-
.describe("Render only a specific cell in solo. Omit for full mix."),
|
|
2722
|
+
.describe("Render only a specific cell in solo (implies mode 'individual'). Omit for full mix."),
|
|
2723
|
+
mode: z
|
|
2724
|
+
.enum(["main", "individual", "all-cells", "selected-cells", "per-bus", "song-mode"])
|
|
2725
|
+
.optional()
|
|
2726
|
+
.describe("Export mode. Defaults to 'main' (or 'individual' when cellId is set)."),
|
|
2727
|
+
selectedCellIds: z
|
|
2728
|
+
.array(z.number().min(1).max(8))
|
|
2729
|
+
.optional()
|
|
2730
|
+
.describe("For mode 'selected-cells': cells to bounce as stems."),
|
|
1796
2731
|
bitDepth: z
|
|
1797
2732
|
.number()
|
|
1798
2733
|
.default(24)
|
|
1799
2734
|
.describe("Bit depth (16 or 24)."),
|
|
1800
2735
|
},
|
|
1801
|
-
}, async ({ bars, cellId, bitDepth }) => {
|
|
2736
|
+
}, async ({ bars, cellId, mode, selectedCellIds, bitDepth }) => {
|
|
1802
2737
|
const check = await requireBridge();
|
|
1803
2738
|
if (check !== true)
|
|
1804
2739
|
return check;
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
const bpm = result.bpm;
|
|
1817
|
-
// Poll for completion
|
|
1818
|
-
const maxWaitMs = 120_000;
|
|
1819
|
-
const pollMs = 500;
|
|
1820
|
-
const t0 = Date.now();
|
|
1821
|
-
while (Date.now() - t0 < maxWaitMs) {
|
|
1822
|
-
await new Promise((r) => setTimeout(r, pollMs));
|
|
1823
|
-
const status = (await bridge.send("getExportStatus"));
|
|
1824
|
-
if (status.complete) {
|
|
1825
|
-
if (status.success) {
|
|
1826
|
-
return text(`Audio exported successfully.\nFile: ${outputPath}\n` +
|
|
1827
|
-
`Duration: ${durationSecs.toFixed(1)}s (${bars} bars at ${bpm} BPM, ${bitDepth}-bit)`);
|
|
1828
|
-
}
|
|
1829
|
-
return error(`Audio export failed.`);
|
|
2740
|
+
// Resolve the effective mode. A bare cellId means "individual".
|
|
2741
|
+
const effectiveMode = mode ?? (cellId ? "individual" : "main");
|
|
2742
|
+
// Fast path: main / individual go through the MCP-friendly bridge branch,
|
|
2743
|
+
// which constructs options server-side and returns the output path + duration.
|
|
2744
|
+
if (effectiveMode === "main" || (effectiveMode === "individual" && cellId)) {
|
|
2745
|
+
const options = { bars, bitDepth };
|
|
2746
|
+
if (cellId)
|
|
2747
|
+
options.cellId = cellId;
|
|
2748
|
+
const startResult = await bridge.send("exportAudio", options);
|
|
2749
|
+
if (typeof startResult === "string") {
|
|
2750
|
+
return text(`Audio export started (${bars} bars, ${bitDepth}-bit).`);
|
|
1830
2751
|
}
|
|
2752
|
+
const result = startResult;
|
|
2753
|
+
const outputPath = result.outputPath;
|
|
2754
|
+
const durationSecs = result.durationInSeconds;
|
|
2755
|
+
const bpm = result.bpm;
|
|
2756
|
+
const outcome = await pollExportStatus();
|
|
2757
|
+
if (outcome === "success")
|
|
2758
|
+
return text(`Audio exported successfully.\nFile: ${outputPath}\n` +
|
|
2759
|
+
`Duration: ${durationSecs.toFixed(1)}s (${bars} bars at ${bpm} BPM, ${bitDepth}-bit)`);
|
|
2760
|
+
if (outcome === "failed")
|
|
2761
|
+
return error("Audio export failed.");
|
|
2762
|
+
return text(`Export timed out. The render may still be running.\nExpected output: ${outputPath}`);
|
|
1831
2763
|
}
|
|
1832
|
-
|
|
2764
|
+
// Stem / song-mode modes: build full pass-through options (filename + mode +
|
|
2765
|
+
// durationInSeconds) so the C++ export handler runs the requested mode directly.
|
|
2766
|
+
if (effectiveMode === "selected-cells" && (!selectedCellIds || selectedCellIds.length === 0))
|
|
2767
|
+
return error("mode 'selected-cells' requires selectedCellIds.");
|
|
2768
|
+
if (effectiveMode === "individual" && !cellId)
|
|
2769
|
+
return error("mode 'individual' requires cellId.");
|
|
2770
|
+
const transport = (await bridge.send("getTransport"));
|
|
2771
|
+
const bpm = typeof transport.bpm === "number" && transport.bpm > 0 ? transport.bpm : 120;
|
|
2772
|
+
// 4/4 assumption for the placeholder duration; 'song-mode' recomputes its own length from the timeline.
|
|
2773
|
+
const durationInSeconds = (bars * 4 * 60) / bpm;
|
|
2774
|
+
const filename = `pam_export_${effectiveMode.replace(/[^a-z0-9]/gi, "_")}_${Date.now()}`;
|
|
2775
|
+
const options = {
|
|
2776
|
+
filename,
|
|
2777
|
+
mode: effectiveMode,
|
|
2778
|
+
durationInSeconds,
|
|
2779
|
+
bitDepth,
|
|
2780
|
+
};
|
|
2781
|
+
if (cellId)
|
|
2782
|
+
options.selectedCellId = cellId;
|
|
2783
|
+
if (selectedCellIds)
|
|
2784
|
+
options.selectedCellIds = selectedCellIds;
|
|
2785
|
+
await bridge.send("exportAudio", options);
|
|
2786
|
+
const outcome = await pollExportStatus();
|
|
2787
|
+
if (outcome === "success")
|
|
2788
|
+
return text(`Export complete (mode '${effectiveMode}').\n` +
|
|
2789
|
+
`File(s) written to PAM's Exports folder, base name: ${filename}.wav` +
|
|
2790
|
+
(effectiveMode === "song-mode"
|
|
2791
|
+
? "\n\nReminder: the offline song-mode bounce captures only the first section's state. " +
|
|
2792
|
+
"Use export_song for a full track with section changes."
|
|
2793
|
+
: ""));
|
|
2794
|
+
if (outcome === "failed")
|
|
2795
|
+
return error(`Export failed (mode '${effectiveMode}').`);
|
|
2796
|
+
return text(`Export timed out (mode '${effectiveMode}'). The render may still be running.`);
|
|
1833
2797
|
});
|
|
1834
2798
|
// ============================================================
|
|
1835
2799
|
// RESOURCES
|
|
@@ -2922,6 +3886,99 @@ server.registerTool("song_mode", {
|
|
|
2922
3886
|
}
|
|
2923
3887
|
return text("No-op");
|
|
2924
3888
|
});
|
|
3889
|
+
server.registerTool("export_song", {
|
|
3890
|
+
title: "Export Song (Full Track)",
|
|
3891
|
+
description: "Bounce the full Song Mode arrangement to a single WAV — the 'render my whole track' button. " +
|
|
3892
|
+
"Set up the arrangement first with the `song_mode` tool (setSequence), then call this.\n\n" +
|
|
3893
|
+
"Two strategies:\n" +
|
|
3894
|
+
"• 'live' (DEFAULT, full fidelity): plays the arrangement in real time and captures the master mix, " +
|
|
3895
|
+
"so EVERY section/variation/preset change and all modulation is rendered. Needs a free `cellId` to " +
|
|
3896
|
+
"land the captured clip in (use an empty cell). Capture is capped at 64 bars; for longer songs bounce " +
|
|
3897
|
+
"in sections or raise the cap. The captured WAV also lands in PAM's Resamples folder.\n" +
|
|
3898
|
+
"• 'offline' (fast, LIMITED): renders via the offline song-mode bounce. Only the first section's state " +
|
|
3899
|
+
"is rendered (variation/preset switches are NOT applied offline). Use only for single-section sketches.\n\n" +
|
|
3900
|
+
"After a 'live' bounce, the captured file path is read back from the destination cell; run get_audio_info " +
|
|
3901
|
+
"(resolve against the Resamples location from get_default_locations) to verify it.",
|
|
3902
|
+
inputSchema: {
|
|
3903
|
+
strategy: z.enum(["live", "offline"]).default("live").describe("Capture strategy."),
|
|
3904
|
+
bars: z
|
|
3905
|
+
.number()
|
|
3906
|
+
.min(1)
|
|
3907
|
+
.max(64)
|
|
3908
|
+
.describe("Total arrangement length in bars (the full timeline). Required for 'live'."),
|
|
3909
|
+
cellId: z
|
|
3910
|
+
.number()
|
|
3911
|
+
.min(1)
|
|
3912
|
+
.max(8)
|
|
3913
|
+
.optional()
|
|
3914
|
+
.describe("For 'live': empty destination cell the bounced master clip is written into."),
|
|
3915
|
+
bitDepth: z.number().default(24).describe("Bit depth (16 or 24)."),
|
|
3916
|
+
tailBars: z
|
|
3917
|
+
.number()
|
|
3918
|
+
.min(0)
|
|
3919
|
+
.max(8)
|
|
3920
|
+
.default(1)
|
|
3921
|
+
.describe("For 'live': extra bars captured after the arrangement end for reverb/release tails."),
|
|
3922
|
+
},
|
|
3923
|
+
}, async ({ strategy, bars, cellId, bitDepth, tailBars }) => {
|
|
3924
|
+
const check = await requireBridge();
|
|
3925
|
+
if (check !== true)
|
|
3926
|
+
return check;
|
|
3927
|
+
// -------- Offline strategy: reuse the song-mode bounce (first-section only) --------
|
|
3928
|
+
if (strategy === "offline") {
|
|
3929
|
+
const filename = `pam_song_${Date.now()}`;
|
|
3930
|
+
const transport = (await bridge.send("getTransport"));
|
|
3931
|
+
const bpm = typeof transport.bpm === "number" && transport.bpm > 0 ? transport.bpm : 120;
|
|
3932
|
+
await bridge.send("exportAudio", {
|
|
3933
|
+
filename,
|
|
3934
|
+
mode: "song-mode",
|
|
3935
|
+
durationInSeconds: (bars * 4 * 60) / bpm,
|
|
3936
|
+
bitDepth,
|
|
3937
|
+
});
|
|
3938
|
+
const outcome = await pollExportStatus();
|
|
3939
|
+
if (outcome === "success")
|
|
3940
|
+
return text(`Offline song bounce complete: ${filename}.wav (in PAM's Exports folder).\n` +
|
|
3941
|
+
"Note: only the first section's state was rendered. Use strategy 'live' for the full track.");
|
|
3942
|
+
if (outcome === "failed")
|
|
3943
|
+
return error("Offline song bounce failed. Is Song Mode enabled with a non-empty sequence?");
|
|
3944
|
+
return text("Offline song bounce timed out. The render may still be running.");
|
|
3945
|
+
}
|
|
3946
|
+
// -------- Live strategy: real-time master capture of the whole arrangement --------
|
|
3947
|
+
if (typeof cellId !== "number")
|
|
3948
|
+
return error("'live' strategy requires an empty destination cellId.");
|
|
3949
|
+
const transport = (await bridge.send("getTransport"));
|
|
3950
|
+
const bpm = typeof transport.bpm === "number" && transport.bpm > 0 ? transport.bpm : 120;
|
|
3951
|
+
const totalBars = bars + (tailBars ?? 1);
|
|
3952
|
+
if (totalBars > 64)
|
|
3953
|
+
return error(`Total capture (${totalBars} bars incl. tail) exceeds the 64-bar live cap. Bounce in sections.`);
|
|
3954
|
+
const captureSeconds = (totalBars * 4 * 60) / bpm;
|
|
3955
|
+
// Reset playhead to the top so the arrangement starts at section 1, enable Song
|
|
3956
|
+
// Mode, then roll the transport and capture the full master mix.
|
|
3957
|
+
await bridge.send("fullStopTransport");
|
|
3958
|
+
await bridge.send("toggleSongMode", true);
|
|
3959
|
+
await bridge.send("setSongModePlayhead", 0);
|
|
3960
|
+
await bridge.send("startTransport");
|
|
3961
|
+
await bridge.send("resampleGraphToCell", JSON.stringify({ cellId, bars: totalBars, live: true, captureSource: 2, cellOnly: false }));
|
|
3962
|
+
// Wait out the arrangement (plus a small safety margin), then stop + finalize.
|
|
3963
|
+
await new Promise((r) => setTimeout(r, captureSeconds * 1000 + 1500));
|
|
3964
|
+
await bridge.send("stopLiveResample", true);
|
|
3965
|
+
await bridge.send("stopTransport");
|
|
3966
|
+
// Read the captured clip path back off the destination cell.
|
|
3967
|
+
let capturedPath = "(unknown — check PAM's Resamples folder)";
|
|
3968
|
+
try {
|
|
3969
|
+
const state = (await bridge.send("getState"));
|
|
3970
|
+
const cells = state.cells;
|
|
3971
|
+
const fp = cells?.[String(cellId)]?.filePath;
|
|
3972
|
+
if (typeof fp === "string" && fp.length > 0)
|
|
3973
|
+
capturedPath = fp;
|
|
3974
|
+
}
|
|
3975
|
+
catch {
|
|
3976
|
+
// fall through with the placeholder
|
|
3977
|
+
}
|
|
3978
|
+
return text(`Live song bounce complete (${bars} bars + ${tailBars ?? 1} tail @ ${bpm} BPM, ${bitDepth}-bit).\n` +
|
|
3979
|
+
`Captured into cell ${cellId}.\nClip: ${capturedPath}\n` +
|
|
3980
|
+
"Resolve the absolute path against the Resamples location from get_default_locations, then verify with get_audio_info.");
|
|
3981
|
+
});
|
|
2925
3982
|
// ============================================================
|
|
2926
3983
|
// TOOLS — Metronome
|
|
2927
3984
|
// ============================================================
|
|
@@ -3086,8 +4143,12 @@ server.registerTool("export_slice", {
|
|
|
3086
4143
|
// ============================================================
|
|
3087
4144
|
server.registerTool("live_resample", {
|
|
3088
4145
|
title: "Live Resample",
|
|
3089
|
-
description: "Capture PAM's audio graph output into a cell as a new sample. Ops: 'start' / 'stop'. " +
|
|
3090
|
-
"Transport
|
|
4146
|
+
description: "Capture PAM's audio graph output into a cell as a new sample, in real time. Ops: 'start' / 'stop'. " +
|
|
4147
|
+
"Transport MUST be playing for the capture to produce audio. Because the capture runs live, " +
|
|
4148
|
+
"Song Mode section changes, modulation, and variation switches are all captured faithfully — " +
|
|
4149
|
+
"this is the building block for full-track / Song Mode bounces (see export_song).\n\n" +
|
|
4150
|
+
"`source` selects what to record: 'cell' (the destination cell's own output), 'input' (PAM's audio input), " +
|
|
4151
|
+
"or 'fullmix' (the whole master mix — use this to bounce the entire arrangement).",
|
|
3091
4152
|
inputSchema: {
|
|
3092
4153
|
op: z.enum(["start", "stop"]).describe("Operation."),
|
|
3093
4154
|
cellId: z
|
|
@@ -3095,13 +4156,21 @@ server.registerTool("live_resample", {
|
|
|
3095
4156
|
.min(1)
|
|
3096
4157
|
.max(8)
|
|
3097
4158
|
.optional()
|
|
3098
|
-
.describe("For 'start': destination cell."),
|
|
4159
|
+
.describe("For 'start': destination cell the captured clip lands in. Use an empty/scratch cell for a full-mix bounce."),
|
|
3099
4160
|
bars: z
|
|
3100
4161
|
.number()
|
|
3101
4162
|
.min(0.125)
|
|
3102
4163
|
.max(64)
|
|
3103
4164
|
.optional()
|
|
3104
|
-
.describe("For 'start': capture length in bars."),
|
|
4165
|
+
.describe("For 'start': capture length in bars (max 64; the capture also stops on the 'stop' op)."),
|
|
4166
|
+
source: z
|
|
4167
|
+
.enum(["cell", "input", "fullmix"])
|
|
4168
|
+
.default("cell")
|
|
4169
|
+
.describe("For 'start': capture source. 'fullmix' records the entire master output (the whole song)."),
|
|
4170
|
+
normalize: z
|
|
4171
|
+
.boolean()
|
|
4172
|
+
.default(false)
|
|
4173
|
+
.describe("For 'start': peak-normalize the captured clip after recording."),
|
|
3105
4174
|
finalize: z
|
|
3106
4175
|
.boolean()
|
|
3107
4176
|
.default(true)
|
|
@@ -3114,13 +4183,578 @@ server.registerTool("live_resample", {
|
|
|
3114
4183
|
if (args.op === "start") {
|
|
3115
4184
|
if (typeof args.cellId !== "number" || typeof args.bars !== "number")
|
|
3116
4185
|
return error("'start' requires cellId + bars");
|
|
3117
|
-
|
|
3118
|
-
|
|
4186
|
+
const source = args.source ?? "cell";
|
|
4187
|
+
const captureSource = source === "fullmix" ? 2 : source === "input" ? 1 : 0;
|
|
4188
|
+
await bridge.send("resampleGraphToCell", JSON.stringify({
|
|
4189
|
+
cellId: args.cellId,
|
|
4190
|
+
bars: args.bars,
|
|
4191
|
+
live: true,
|
|
4192
|
+
captureSource,
|
|
4193
|
+
// For a full-mix / input bounce the destination cell must NOT be soloed,
|
|
4194
|
+
// or we'd only capture that one (empty) cell.
|
|
4195
|
+
cellOnly: source === "cell",
|
|
4196
|
+
normalize: args.normalize ?? false,
|
|
4197
|
+
}));
|
|
4198
|
+
return text(`Started live resample → cell ${args.cellId} (${args.bars} bars, source '${source}').`);
|
|
3119
4199
|
}
|
|
3120
4200
|
await bridge.send("stopLiveResample", args.finalize ?? true);
|
|
3121
4201
|
return text(`Stopped live resample (finalize=${args.finalize ?? true}).`);
|
|
3122
4202
|
});
|
|
3123
4203
|
// ============================================================
|
|
4204
|
+
// TOOLS — Plugin host (scan/load/unload/param/bypass on hosted VST3/AU/CLAP)
|
|
4205
|
+
// Instrument-role only. Effect-side hosting is not exposed via MCP yet
|
|
4206
|
+
// (MCPBridge only routes through the instrument manager).
|
|
4207
|
+
// ============================================================
|
|
4208
|
+
const PLUGIN_FORMAT = z.enum(["VST3", "AudioUnit", "CLAP"]);
|
|
4209
|
+
// Whitelist of scan-path roots. Mirrors
|
|
4210
|
+
// interface/src/utils/pluginScanPaths.ts (DEFAULT_PATHS_MAC / _WIN).
|
|
4211
|
+
// MCP-supplied searchPaths must resolve to entries beneath one of these
|
|
4212
|
+
// roots so an agent can't probe ~/.ssh or other sensitive folders.
|
|
4213
|
+
function expandHome(p) {
|
|
4214
|
+
if (p.startsWith("~/"))
|
|
4215
|
+
return resolve(process.env.HOME ?? "", p.slice(2));
|
|
4216
|
+
if (p === "~")
|
|
4217
|
+
return process.env.HOME ?? "";
|
|
4218
|
+
return p;
|
|
4219
|
+
}
|
|
4220
|
+
const PLUGIN_SCAN_PATH_ROOTS = (process.platform === "win32"
|
|
4221
|
+
? ["C:/Program Files/Common Files/VST3", "C:/Program Files/Common Files/CLAP"]
|
|
4222
|
+
: [
|
|
4223
|
+
"/Library/Audio/Plug-Ins/VST3",
|
|
4224
|
+
"~/Library/Audio/Plug-Ins/VST3",
|
|
4225
|
+
"/Library/Audio/Plug-Ins/Components",
|
|
4226
|
+
"~/Library/Audio/Plug-Ins/Components",
|
|
4227
|
+
"/Library/Audio/Plug-Ins/CLAP",
|
|
4228
|
+
"~/Library/Audio/Plug-Ins/CLAP",
|
|
4229
|
+
]).map((p) => resolve(expandHome(p)));
|
|
4230
|
+
function isPathUnderWhitelist(p) {
|
|
4231
|
+
const abs = resolve(expandHome(p));
|
|
4232
|
+
return PLUGIN_SCAN_PATH_ROOTS.some((root) => abs === root || abs.startsWith(root + "/") || abs.startsWith(root + "\\"));
|
|
4233
|
+
}
|
|
4234
|
+
// fileOrIdentifier values returned by list_plugins. load_plugin only
|
|
4235
|
+
// accepts entries observed here so an agent can't instantiate arbitrary
|
|
4236
|
+
// binaries.
|
|
4237
|
+
const knownPluginIdentifiers = new Set();
|
|
4238
|
+
// Hosted-plugin tools target PAM cells. Cap to the manifest's cell count
|
|
4239
|
+
// (falls back to 8 — PAM's shipping max) rather than the C++ slot
|
|
4240
|
+
// reservation of 32.
|
|
4241
|
+
async function getMaxCellCount() {
|
|
4242
|
+
try {
|
|
4243
|
+
const m = await loadManifest();
|
|
4244
|
+
const cells = m.cells;
|
|
4245
|
+
if (Array.isArray(cells) && cells.length > 0)
|
|
4246
|
+
return cells.length;
|
|
4247
|
+
}
|
|
4248
|
+
catch {
|
|
4249
|
+
// manifest unavailable — use shipping default
|
|
4250
|
+
}
|
|
4251
|
+
return 8;
|
|
4252
|
+
}
|
|
4253
|
+
async function clampCellId(cellId) {
|
|
4254
|
+
const max = await getMaxCellCount();
|
|
4255
|
+
if (!Number.isInteger(cellId) || cellId < 1 || cellId > max) {
|
|
4256
|
+
return `cellId must be an integer in [1, ${max}]`;
|
|
4257
|
+
}
|
|
4258
|
+
return cellId;
|
|
4259
|
+
}
|
|
4260
|
+
server.registerTool("list_plugins", {
|
|
4261
|
+
title: "List Plugins",
|
|
4262
|
+
description: "List plugins that have been discovered by PAM's scanner (VST3, AudioUnit, and CLAP). " +
|
|
4263
|
+
"Returns `plugins` (each: name, manufacturer, format, category, fileOrIdentifier, isInstrument, version) " +
|
|
4264
|
+
"and `blocklist` (paths the scanner marked unsafe). " +
|
|
4265
|
+
"Use this before `load_plugin` to find a valid `path` + `format`. " +
|
|
4266
|
+
"If the list is empty, call `scan_plugins` first. " +
|
|
4267
|
+
"Note: PAM's MCP hosts plugins in the per-cell instrument slot only — FX-side hosting is not exposed yet.",
|
|
4268
|
+
inputSchema: {
|
|
4269
|
+
format: PLUGIN_FORMAT.optional().describe("Filter by plugin format."),
|
|
4270
|
+
isInstrument: z.boolean().optional().describe("If true, only instruments; false, only effects."),
|
|
4271
|
+
nameContains: z.string().optional().describe("Case-insensitive substring match on the plugin name."),
|
|
4272
|
+
},
|
|
4273
|
+
}, async (args) => {
|
|
4274
|
+
const check = await requireBridge();
|
|
4275
|
+
if (check !== true)
|
|
4276
|
+
return check;
|
|
4277
|
+
const filter = {};
|
|
4278
|
+
if (args.format)
|
|
4279
|
+
filter.format = args.format;
|
|
4280
|
+
if (typeof args.isInstrument === "boolean")
|
|
4281
|
+
filter.isInstrument = args.isInstrument;
|
|
4282
|
+
if (args.nameContains)
|
|
4283
|
+
filter.nameContains = args.nameContains;
|
|
4284
|
+
const raw = (await bridge.send("listPlugins", filter));
|
|
4285
|
+
// Record every fileOrIdentifier so load_plugin can validate that the
|
|
4286
|
+
// caller-supplied path was discovered by PAM's own scanner.
|
|
4287
|
+
try {
|
|
4288
|
+
const parsed = JSON.parse(raw);
|
|
4289
|
+
if (Array.isArray(parsed.plugins)) {
|
|
4290
|
+
for (const p of parsed.plugins) {
|
|
4291
|
+
if (typeof p.fileOrIdentifier === "string") {
|
|
4292
|
+
knownPluginIdentifiers.add(p.fileOrIdentifier);
|
|
4293
|
+
}
|
|
4294
|
+
}
|
|
4295
|
+
}
|
|
4296
|
+
}
|
|
4297
|
+
catch {
|
|
4298
|
+
// C++ payload not JSON — fall through; load_plugin will reject unknowns.
|
|
4299
|
+
}
|
|
4300
|
+
return text(raw);
|
|
4301
|
+
});
|
|
4302
|
+
server.registerTool("scan_plugins", {
|
|
4303
|
+
title: "Scan Plugins",
|
|
4304
|
+
description: "Trigger an async scan for VST3, AudioUnit, and CLAP plugins. " +
|
|
4305
|
+
"Returns immediately — poll `get_plugin_scan_status` for progress and call `list_plugins` " +
|
|
4306
|
+
"once `isScanning` is false. Without `searchPaths`, scans default OS plugin folders. " +
|
|
4307
|
+
"Supplied `searchPaths` must live under PAM's approved plugin roots " +
|
|
4308
|
+
"(/Library/Audio/Plug-Ins/{VST3,Components,CLAP} or their ~ equivalents on macOS; " +
|
|
4309
|
+
"C:/Program Files/Common Files/{VST3,CLAP} on Windows).",
|
|
4310
|
+
inputSchema: {
|
|
4311
|
+
searchPaths: z
|
|
4312
|
+
.array(z.string())
|
|
4313
|
+
.optional()
|
|
4314
|
+
.describe("Folders to scan. Must be inside an approved plugin root. " +
|
|
4315
|
+
"Leave empty to use OS defaults (/Library/Audio/Plug-Ins/* on macOS, etc.)."),
|
|
4316
|
+
blocklist: z
|
|
4317
|
+
.array(z.string())
|
|
4318
|
+
.optional()
|
|
4319
|
+
.describe("File paths or identifiers the scanner should skip."),
|
|
4320
|
+
},
|
|
4321
|
+
}, async (args) => {
|
|
4322
|
+
const check = await requireBridge();
|
|
4323
|
+
if (check !== true)
|
|
4324
|
+
return check;
|
|
4325
|
+
const payload = {};
|
|
4326
|
+
if (args.searchPaths && args.searchPaths.length > 0) {
|
|
4327
|
+
const rejected = args.searchPaths.filter((p) => !isPathUnderWhitelist(p));
|
|
4328
|
+
if (rejected.length > 0) {
|
|
4329
|
+
return error(`Refusing to scan path(s) outside approved plugin roots: ${rejected.join(", ")}. ` +
|
|
4330
|
+
`Allowed roots: ${PLUGIN_SCAN_PATH_ROOTS.join(", ")}.`);
|
|
4331
|
+
}
|
|
4332
|
+
payload.searchPaths = args.searchPaths;
|
|
4333
|
+
}
|
|
4334
|
+
if (args.blocklist)
|
|
4335
|
+
payload.blocklist = args.blocklist;
|
|
4336
|
+
await bridge.send("scanPlugins", payload);
|
|
4337
|
+
return text("Plugin scan started. Poll get_plugin_scan_status until isScanning is false, then call list_plugins.");
|
|
4338
|
+
});
|
|
4339
|
+
server.registerTool("cancel_plugin_scan", {
|
|
4340
|
+
title: "Cancel Plugin Scan",
|
|
4341
|
+
description: "Cancel the in-flight plugin scan. Safe to call even if no scan is running.",
|
|
4342
|
+
inputSchema: {},
|
|
4343
|
+
}, async () => {
|
|
4344
|
+
const check = await requireBridge();
|
|
4345
|
+
if (check !== true)
|
|
4346
|
+
return check;
|
|
4347
|
+
await bridge.send("cancelPluginScan");
|
|
4348
|
+
return text("Cancelled (or no scan was running).");
|
|
4349
|
+
});
|
|
4350
|
+
server.registerTool("get_plugin_scan_status", {
|
|
4351
|
+
title: "Plugin Scan Status",
|
|
4352
|
+
description: "Read the latest plugin scan progress. Returns `isScanning`, `format`, `currentPath`, " +
|
|
4353
|
+
"`fraction` (0..1), `elapsedMs`, `isStall` (true when the in-flight plugin hasn't returned in 2s), " +
|
|
4354
|
+
"and `updatedAtMs`. Poll while a scan is running.",
|
|
4355
|
+
inputSchema: {},
|
|
4356
|
+
}, async () => {
|
|
4357
|
+
const check = await requireBridge();
|
|
4358
|
+
if (check !== true)
|
|
4359
|
+
return check;
|
|
4360
|
+
const raw = (await bridge.send("getPluginScanStatus"));
|
|
4361
|
+
return text(raw);
|
|
4362
|
+
});
|
|
4363
|
+
server.registerTool("list_loaded_plugins", {
|
|
4364
|
+
title: "List Loaded Plugins",
|
|
4365
|
+
description: "List plugins currently hosted in PAM cells (instrument slot only — FX-side hosting is not exposed). " +
|
|
4366
|
+
"Each entry: cellId, name, manufacturer, format, " +
|
|
4367
|
+
"path, isInstrument, bypassed, and the plugin's full param list (index, name, label, value, defaultValue). " +
|
|
4368
|
+
"Use the param `index` with `set_plugin_param`.",
|
|
4369
|
+
inputSchema: {},
|
|
4370
|
+
}, async () => {
|
|
4371
|
+
const check = await requireBridge();
|
|
4372
|
+
if (check !== true)
|
|
4373
|
+
return check;
|
|
4374
|
+
const raw = (await bridge.send("listLoadedPlugins"));
|
|
4375
|
+
return text(raw);
|
|
4376
|
+
});
|
|
4377
|
+
server.registerTool("load_plugin", {
|
|
4378
|
+
title: "Load Plugin Into Cell",
|
|
4379
|
+
description: "Load a hosted plugin (VST3, AudioUnit, or CLAP) into a cell's instrument slot. Blocks until the plugin probes + " +
|
|
4380
|
+
"instantiates (can take seconds for heavy plugins) and returns success or the underlying error. " +
|
|
4381
|
+
"`path` must be a `fileOrIdentifier` returned by a previous `list_plugins` call in this session.",
|
|
4382
|
+
inputSchema: {
|
|
4383
|
+
cellId: z.number().int().min(1).describe("Target cell (1-based)."),
|
|
4384
|
+
path: z.string().describe("Plugin fileOrIdentifier from list_plugins."),
|
|
4385
|
+
format: PLUGIN_FORMAT.describe("Plugin format."),
|
|
4386
|
+
},
|
|
4387
|
+
}, async (args) => {
|
|
4388
|
+
const check = await requireBridge();
|
|
4389
|
+
if (check !== true)
|
|
4390
|
+
return check;
|
|
4391
|
+
const clamped = await clampCellId(args.cellId);
|
|
4392
|
+
if (typeof clamped === "string")
|
|
4393
|
+
return error(clamped);
|
|
4394
|
+
if (!knownPluginIdentifiers.has(args.path)) {
|
|
4395
|
+
return error(`Plugin path not in the scanner's known list. Call list_plugins first and use one of its ` +
|
|
4396
|
+
`fileOrIdentifier values. Refused: ${args.path}`);
|
|
4397
|
+
}
|
|
4398
|
+
const raw = (await bridge.send("loadPlugin", {
|
|
4399
|
+
cellId: clamped,
|
|
4400
|
+
path: args.path,
|
|
4401
|
+
format: args.format,
|
|
4402
|
+
}));
|
|
4403
|
+
return text(raw);
|
|
4404
|
+
});
|
|
4405
|
+
server.registerTool("unload_plugin", {
|
|
4406
|
+
title: "Unload Plugin From Cell",
|
|
4407
|
+
description: "Unload the hosted plugin from a cell. No-op if no plugin is loaded there.",
|
|
4408
|
+
inputSchema: {
|
|
4409
|
+
cellId: z.number().int().min(1).describe("Target cell."),
|
|
4410
|
+
},
|
|
4411
|
+
}, async (args) => {
|
|
4412
|
+
const check = await requireBridge();
|
|
4413
|
+
if (check !== true)
|
|
4414
|
+
return check;
|
|
4415
|
+
const clamped = await clampCellId(args.cellId);
|
|
4416
|
+
if (typeof clamped === "string")
|
|
4417
|
+
return error(clamped);
|
|
4418
|
+
await bridge.send("unloadPlugin", clamped);
|
|
4419
|
+
return text(`Unload queued for cell ${clamped}.`);
|
|
4420
|
+
});
|
|
4421
|
+
server.registerTool("set_plugin_param", {
|
|
4422
|
+
title: "Set Hosted Plugin Parameter",
|
|
4423
|
+
description: "Set a parameter on a hosted plugin by cell + parameter index. Value is normalized 0..1. " +
|
|
4424
|
+
"Use `list_loaded_plugins` to find param indices and names. " +
|
|
4425
|
+
"This is the dedicated path for hosted plugin params — `set_parameters` won't reach them. " +
|
|
4426
|
+
"Targets the cell's instrument-slot plugin only.",
|
|
4427
|
+
inputSchema: {
|
|
4428
|
+
cellId: z.number().int().min(1),
|
|
4429
|
+
paramIndex: z.number().int().min(0),
|
|
4430
|
+
value: z.number().min(0).max(1).describe("Normalized 0..1."),
|
|
4431
|
+
},
|
|
4432
|
+
}, async (args) => {
|
|
4433
|
+
const check = await requireBridge();
|
|
4434
|
+
if (check !== true)
|
|
4435
|
+
return check;
|
|
4436
|
+
const clamped = await clampCellId(args.cellId);
|
|
4437
|
+
if (typeof clamped === "string")
|
|
4438
|
+
return error(clamped);
|
|
4439
|
+
await bridge.send("setPluginParam", {
|
|
4440
|
+
cellId: clamped,
|
|
4441
|
+
paramIndex: args.paramIndex,
|
|
4442
|
+
value: args.value,
|
|
4443
|
+
});
|
|
4444
|
+
return text(`Queued cell ${clamped} param ${args.paramIndex} = ${args.value}.`);
|
|
4445
|
+
});
|
|
4446
|
+
server.registerTool("set_plugin_bypass", {
|
|
4447
|
+
title: "Bypass Hosted Plugin",
|
|
4448
|
+
description: "Toggle bypass on the hosted plugin in a cell (instrument slot). Bypassed plugins are silenced; the cell sampler " +
|
|
4449
|
+
"still plays in parallel.",
|
|
4450
|
+
inputSchema: {
|
|
4451
|
+
cellId: z.number().int().min(1),
|
|
4452
|
+
bypassed: z.boolean(),
|
|
4453
|
+
},
|
|
4454
|
+
}, async (args) => {
|
|
4455
|
+
const check = await requireBridge();
|
|
4456
|
+
if (check !== true)
|
|
4457
|
+
return check;
|
|
4458
|
+
const clamped = await clampCellId(args.cellId);
|
|
4459
|
+
if (typeof clamped === "string")
|
|
4460
|
+
return error(clamped);
|
|
4461
|
+
await bridge.send("setPluginBypass", { cellId: clamped, bypassed: args.bypassed });
|
|
4462
|
+
return text(`Cell ${clamped} bypass=${args.bypassed}.`);
|
|
4463
|
+
});
|
|
4464
|
+
// ============================================================
|
|
4465
|
+
// TOOLS — Settings, transport config, device + preset management
|
|
4466
|
+
// ============================================================
|
|
4467
|
+
server.registerTool("update_settings", {
|
|
4468
|
+
title: "Update Plugin Settings",
|
|
4469
|
+
description: "Merge a partial settings delta into PAM's plugin settings (the Settings panel state) and apply it live. " +
|
|
4470
|
+
"Returns the full merged settings object so you can discover available keys. This is the only way to flip " +
|
|
4471
|
+
"experimental-feature gates from an agent. Common boolean keys: experimentalMode, " +
|
|
4472
|
+
"experimentalPluginHost, experimentalMcpBridge. To enable the plugin host, set BOTH " +
|
|
4473
|
+
"experimentalMode:true AND the specific sub-flag (e.g. experimentalPluginHost:true) in one call. " +
|
|
4474
|
+
"The drum synth needs no flag — it is a standard feature on PAM. Other keys " +
|
|
4475
|
+
"include UI/library preferences — call get_state or inspect the returned object for the current set.",
|
|
4476
|
+
inputSchema: {
|
|
4477
|
+
settings: z
|
|
4478
|
+
.record(z.string(), z.any())
|
|
4479
|
+
.describe("Partial settings object to merge. Example: { experimentalMode: true, experimentalPluginHost: true }"),
|
|
4480
|
+
},
|
|
4481
|
+
}, async ({ settings }) => {
|
|
4482
|
+
const check = await requireBridge();
|
|
4483
|
+
if (check !== true)
|
|
4484
|
+
return check;
|
|
4485
|
+
if (!settings || Object.keys(settings).length === 0)
|
|
4486
|
+
return error("Provide at least one settings key to update.");
|
|
4487
|
+
const merged = await bridge.send("applySettingsUpdate", settings);
|
|
4488
|
+
return json(merged);
|
|
4489
|
+
});
|
|
4490
|
+
server.registerTool("configure_transport", {
|
|
4491
|
+
title: "Configure Transport",
|
|
4492
|
+
description: "Configure transport behaviour beyond BPM (use set_bpm for tempo). Set the time signature, toggle host-sync " +
|
|
4493
|
+
"(when off, preset/user BPM overrides the host), toggle external clock sync, mute the audio output (standalone), " +
|
|
4494
|
+
"or release host-sync ownership for manual transport. All fields are optional — only the provided ones are applied.",
|
|
4495
|
+
inputSchema: {
|
|
4496
|
+
timeSigNumerator: z.number().int().min(1).max(32).optional().describe("Beats per bar. Requires timeSigDenominator."),
|
|
4497
|
+
timeSigDenominator: z.number().int().min(1).max(32).optional().describe("Beat unit. Requires timeSigNumerator."),
|
|
4498
|
+
hostSyncEnabled: z.boolean().optional().describe("When false, preset/user BPM overrides the host tempo."),
|
|
4499
|
+
externalClockSync: z.boolean().optional().describe("Sync the internal transport to external MIDI clock."),
|
|
4500
|
+
muteAudioOutput: z.boolean().optional().describe("Standalone only: mute the master output."),
|
|
4501
|
+
releaseHostSyncForManualTransport: z
|
|
4502
|
+
.boolean()
|
|
4503
|
+
.optional()
|
|
4504
|
+
.describe("When true, release host-sync ownership so the internal transport can be driven manually."),
|
|
4505
|
+
},
|
|
4506
|
+
}, async (args) => {
|
|
4507
|
+
const check = await requireBridge();
|
|
4508
|
+
if (check !== true)
|
|
4509
|
+
return check;
|
|
4510
|
+
const applied = [];
|
|
4511
|
+
const { timeSigNumerator, timeSigDenominator } = args;
|
|
4512
|
+
if ((timeSigNumerator === undefined) !== (timeSigDenominator === undefined))
|
|
4513
|
+
return error("timeSigNumerator and timeSigDenominator must be provided together.");
|
|
4514
|
+
if (timeSigNumerator !== undefined && timeSigDenominator !== undefined) {
|
|
4515
|
+
await bridge.send("updateTransportTimeSig", timeSigNumerator, timeSigDenominator);
|
|
4516
|
+
applied.push(`timeSig=${timeSigNumerator}/${timeSigDenominator}`);
|
|
4517
|
+
}
|
|
4518
|
+
if (args.hostSyncEnabled !== undefined) {
|
|
4519
|
+
await bridge.send("setHostSyncEnabled", args.hostSyncEnabled);
|
|
4520
|
+
applied.push(`hostSync=${args.hostSyncEnabled}`);
|
|
4521
|
+
}
|
|
4522
|
+
if (args.externalClockSync !== undefined) {
|
|
4523
|
+
await bridge.send("setExternalClockSync", args.externalClockSync);
|
|
4524
|
+
applied.push(`externalClock=${args.externalClockSync}`);
|
|
4525
|
+
}
|
|
4526
|
+
if (args.muteAudioOutput !== undefined) {
|
|
4527
|
+
await bridge.send("setMuteAudioOutput", args.muteAudioOutput);
|
|
4528
|
+
applied.push(`muteOutput=${args.muteAudioOutput}`);
|
|
4529
|
+
}
|
|
4530
|
+
if (args.releaseHostSyncForManualTransport) {
|
|
4531
|
+
await bridge.send("releaseHostSyncForManualTransport");
|
|
4532
|
+
applied.push("releasedHostSync");
|
|
4533
|
+
}
|
|
4534
|
+
if (applied.length === 0)
|
|
4535
|
+
return error("No transport fields provided.");
|
|
4536
|
+
return text(`Transport configured: ${applied.join(", ")}.`);
|
|
4537
|
+
});
|
|
4538
|
+
server.registerTool("get_audio_device_setup", {
|
|
4539
|
+
title: "Get Audio Device Setup",
|
|
4540
|
+
description: "Get the standalone audio/MIDI device setup (sample rate, buffer size, available and active input/output devices). " +
|
|
4541
|
+
"Returns null when running plugin-hosted (the DAW owns the device config). Use the device IDs/names from here with set_midi_io.",
|
|
4542
|
+
inputSchema: {},
|
|
4543
|
+
annotations: { readOnlyHint: true },
|
|
4544
|
+
}, async () => {
|
|
4545
|
+
const check = await requireBridge();
|
|
4546
|
+
if (check !== true)
|
|
4547
|
+
return check;
|
|
4548
|
+
const setup = await bridge.send("getAudioDeviceSetup");
|
|
4549
|
+
return json(setup);
|
|
4550
|
+
});
|
|
4551
|
+
server.registerTool("set_midi_io", {
|
|
4552
|
+
title: "Set MIDI I/O Devices",
|
|
4553
|
+
description: "Select MIDI input/output devices (standalone). Use get_audio_device_setup to discover device IDs first. " +
|
|
4554
|
+
"All fields optional.",
|
|
4555
|
+
inputSchema: {
|
|
4556
|
+
inputDeviceIds: z.array(z.string()).optional().describe("Enabled MIDI input device IDs (replaces the active set)."),
|
|
4557
|
+
outputDeviceId: z.string().optional().describe("MIDI output device ID."),
|
|
4558
|
+
outputEnabled: z.boolean().optional().describe("Enable/disable MIDI output."),
|
|
4559
|
+
},
|
|
4560
|
+
}, async (args) => {
|
|
4561
|
+
const check = await requireBridge();
|
|
4562
|
+
if (check !== true)
|
|
4563
|
+
return check;
|
|
4564
|
+
const applied = [];
|
|
4565
|
+
if (args.inputDeviceIds !== undefined) {
|
|
4566
|
+
await bridge.send("setMidiInputDevices", args.inputDeviceIds);
|
|
4567
|
+
applied.push(`inputs=${args.inputDeviceIds.length}`);
|
|
4568
|
+
}
|
|
4569
|
+
if (args.outputDeviceId !== undefined) {
|
|
4570
|
+
await bridge.send("setMidiOutputDevice", args.outputDeviceId);
|
|
4571
|
+
applied.push(`output=${args.outputDeviceId}`);
|
|
4572
|
+
}
|
|
4573
|
+
if (args.outputEnabled !== undefined) {
|
|
4574
|
+
await bridge.send("setMidiOutputEnabled", args.outputEnabled);
|
|
4575
|
+
applied.push(`outputEnabled=${args.outputEnabled}`);
|
|
4576
|
+
}
|
|
4577
|
+
if (applied.length === 0)
|
|
4578
|
+
return error("No MIDI I/O fields provided.");
|
|
4579
|
+
return text(`MIDI I/O set: ${applied.join(", ")}.`);
|
|
4580
|
+
});
|
|
4581
|
+
server.registerTool("set_midi_mapping", {
|
|
4582
|
+
title: "Set MIDI Action Mapping",
|
|
4583
|
+
description: "Configure the global MIDI-controller mappings that trigger PAM actions: variation switch, preset change, " +
|
|
4584
|
+
"looper record, live resample, and looper-variation switch. Set the enable flag, MIDI channel, CC number, " +
|
|
4585
|
+
"preset source, or bar-sync for each target. One field per call.",
|
|
4586
|
+
inputSchema: {
|
|
4587
|
+
target: z
|
|
4588
|
+
.enum(["variation", "preset", "looper", "resample", "looperVariation"])
|
|
4589
|
+
.describe("Which action's mapping to configure."),
|
|
4590
|
+
field: z
|
|
4591
|
+
.enum(["enabled", "channel", "ccNumber", "source", "barSync"])
|
|
4592
|
+
.describe("Field to set. 'source' is preset-only; 'barSync' is variation/looperVariation-only; " +
|
|
4593
|
+
"looper/resample have no source/barSync."),
|
|
4594
|
+
value: z
|
|
4595
|
+
.union([z.number(), z.boolean()])
|
|
4596
|
+
.describe("boolean for enabled/barSync; number for channel (1-16), ccNumber (0-127), or source."),
|
|
4597
|
+
},
|
|
4598
|
+
}, async ({ target, field, value }) => {
|
|
4599
|
+
const check = await requireBridge();
|
|
4600
|
+
if (check !== true)
|
|
4601
|
+
return check;
|
|
4602
|
+
await bridge.send("setMidiMapping", { target, field, value });
|
|
4603
|
+
return text(`MIDI mapping ${target}.${field} = ${value}.`);
|
|
4604
|
+
});
|
|
4605
|
+
server.registerTool("delete_preset", {
|
|
4606
|
+
title: "Delete Preset",
|
|
4607
|
+
description: "Delete a user preset file from disk. Factory presets are protected and cannot be deleted. " +
|
|
4608
|
+
"Use the relative path returned by list_presets.",
|
|
4609
|
+
inputSchema: {
|
|
4610
|
+
path: z.string().describe("Relative preset path (e.g. 'Presets/User/MyPreset.preset')."),
|
|
4611
|
+
},
|
|
4612
|
+
annotations: { destructiveHint: true },
|
|
4613
|
+
}, async ({ path: presetPath }) => {
|
|
4614
|
+
const check = await requireBridge();
|
|
4615
|
+
if (check !== true)
|
|
4616
|
+
return check;
|
|
4617
|
+
const result = await bridge.send("removePreset", presetPath);
|
|
4618
|
+
return json(result);
|
|
4619
|
+
});
|
|
4620
|
+
server.registerTool("set_preset_factory_status", {
|
|
4621
|
+
title: "Set Preset Factory Status",
|
|
4622
|
+
description: "Mark a preset as factory (read-only) or user (editable) on disk. Affects the factory flag and the file location.",
|
|
4623
|
+
inputSchema: {
|
|
4624
|
+
path: z.string().describe("Relative preset path."),
|
|
4625
|
+
makeFactory: z.boolean().describe("true → factory (read-only), false → user (editable)."),
|
|
4626
|
+
},
|
|
4627
|
+
}, async ({ path: presetPath, makeFactory }) => {
|
|
4628
|
+
const check = await requireBridge();
|
|
4629
|
+
if (check !== true)
|
|
4630
|
+
return check;
|
|
4631
|
+
const result = await bridge.send("togglePresetFactoryStatus", presetPath, makeFactory);
|
|
4632
|
+
return json(result);
|
|
4633
|
+
});
|
|
4634
|
+
server.registerTool("get_sample_usage", {
|
|
4635
|
+
title: "Get Sample Usage",
|
|
4636
|
+
description: "Cross-reference samples and presets. mode 'presetUsesSamples' lists the samples a preset references; " +
|
|
4637
|
+
"mode 'presetsUsingSample' lists presets that reference a given sample (or any sample under a folder).",
|
|
4638
|
+
inputSchema: {
|
|
4639
|
+
mode: z.enum(["presetUsesSamples", "presetsUsingSample"]),
|
|
4640
|
+
path: z
|
|
4641
|
+
.string()
|
|
4642
|
+
.describe("Relative preset path (presetUsesSamples) or relative sample path (presetsUsingSample)."),
|
|
4643
|
+
isFolder: z
|
|
4644
|
+
.boolean()
|
|
4645
|
+
.optional()
|
|
4646
|
+
.describe("presetsUsingSample only: treat 'path' as a folder and match any sample beneath it."),
|
|
4647
|
+
},
|
|
4648
|
+
annotations: { readOnlyHint: true },
|
|
4649
|
+
}, async ({ mode, path: relPath, isFolder }) => {
|
|
4650
|
+
const check = await requireBridge();
|
|
4651
|
+
if (check !== true)
|
|
4652
|
+
return check;
|
|
4653
|
+
const result = mode === "presetUsesSamples"
|
|
4654
|
+
? await bridge.send("getPresetSampleUsage", relPath)
|
|
4655
|
+
: await bridge.send("getPresetsUsingSample", relPath, isFolder ?? false);
|
|
4656
|
+
return json(result);
|
|
4657
|
+
});
|
|
4658
|
+
server.registerTool("repair_preset_samples", {
|
|
4659
|
+
title: "Repair Preset Sample Paths",
|
|
4660
|
+
description: "Fix broken sample references in presets. mode 'fix' auto-relinks moved/renamed samples by searching the " +
|
|
4661
|
+
"library (optionally scoped to one preset). mode 'rescan' re-audits a preset for still-missing samples and " +
|
|
4662
|
+
"reports them. Useful after moving a sample library or importing presets from another machine.",
|
|
4663
|
+
inputSchema: {
|
|
4664
|
+
mode: z.enum(["fix", "rescan"]),
|
|
4665
|
+
presetPath: z
|
|
4666
|
+
.string()
|
|
4667
|
+
.optional()
|
|
4668
|
+
.describe("Relative preset path. Omit for 'fix' to scan all presets; omit for 'rescan' to audit the active preset."),
|
|
4669
|
+
},
|
|
4670
|
+
}, async ({ mode, presetPath }) => {
|
|
4671
|
+
const check = await requireBridge();
|
|
4672
|
+
if (check !== true)
|
|
4673
|
+
return check;
|
|
4674
|
+
if (mode === "fix") {
|
|
4675
|
+
const result = await bridge.send("fixPresetSamplePaths", presetPath ?? "");
|
|
4676
|
+
return json(result);
|
|
4677
|
+
}
|
|
4678
|
+
await bridge.send("rescanMissingSamples", presetPath ?? "");
|
|
4679
|
+
return text(`Re-audited ${presetPath || "active preset"} for missing samples (results dispatched to UI state).`);
|
|
4680
|
+
});
|
|
4681
|
+
server.registerTool("preview_sample", {
|
|
4682
|
+
title: "Preview Sample",
|
|
4683
|
+
description: "Audition an audio file through PAM's native preview player without loading it into a cell. Pass filePath to " +
|
|
4684
|
+
"start previewing; pass stop:true to stop the current preview.",
|
|
4685
|
+
inputSchema: {
|
|
4686
|
+
filePath: z.string().optional().describe("Absolute or library-relative path to the audio file to preview."),
|
|
4687
|
+
stop: z.boolean().optional().describe("Stop the current preview instead of starting one."),
|
|
4688
|
+
},
|
|
4689
|
+
}, async ({ filePath, stop }) => {
|
|
4690
|
+
const check = await requireBridge();
|
|
4691
|
+
if (check !== true)
|
|
4692
|
+
return check;
|
|
4693
|
+
if (stop) {
|
|
4694
|
+
await bridge.send("stopPreviewNative");
|
|
4695
|
+
return text("Stopped sample preview.");
|
|
4696
|
+
}
|
|
4697
|
+
if (!filePath)
|
|
4698
|
+
return error("Provide filePath to preview, or stop:true to stop.");
|
|
4699
|
+
const ok = await bridge.send("previewSampleNative", filePath);
|
|
4700
|
+
return text(ok ? `Previewing ${basename(filePath)}.` : `Could not preview ${basename(filePath)} (file unreadable?).`);
|
|
4701
|
+
});
|
|
4702
|
+
// ============================================================
|
|
4703
|
+
// TOOLS — Plugin host (extra)
|
|
4704
|
+
// ============================================================
|
|
4705
|
+
server.registerTool("set_plugin_params", {
|
|
4706
|
+
title: "Set Hosted Plugin Params (batch)",
|
|
4707
|
+
description: "Set multiple hosted-plugin parameters in one call (more efficient than set_plugin_param per value). Values are " +
|
|
4708
|
+
"0..1 normalized. Use list_loaded_plugins to discover paramIndex values. role defaults to 'instrument'.",
|
|
4709
|
+
inputSchema: {
|
|
4710
|
+
cellId: z.number().int().min(1).describe("Cell number."),
|
|
4711
|
+
role: z.enum(["instrument", "effect"]).optional().describe("Which hosted slot (default 'instrument')."),
|
|
4712
|
+
values: z
|
|
4713
|
+
.array(z.object({ paramIndex: z.number().int().min(0), value: z.number().min(0).max(1) }))
|
|
4714
|
+
.min(1)
|
|
4715
|
+
.describe("Array of { paramIndex, value } where value is 0..1 normalized."),
|
|
4716
|
+
},
|
|
4717
|
+
}, async (args) => {
|
|
4718
|
+
const check = await requireBridge();
|
|
4719
|
+
if (check !== true)
|
|
4720
|
+
return check;
|
|
4721
|
+
const clamped = await clampCellId(args.cellId);
|
|
4722
|
+
if (typeof clamped === "string")
|
|
4723
|
+
return error(clamped);
|
|
4724
|
+
await bridge.send("setPluginParams", {
|
|
4725
|
+
cellId: clamped,
|
|
4726
|
+
role: args.role ?? "instrument",
|
|
4727
|
+
values: args.values,
|
|
4728
|
+
});
|
|
4729
|
+
return text(`Queued ${args.values.length} param(s) for cell ${clamped} (${args.role ?? "instrument"}).`);
|
|
4730
|
+
});
|
|
4731
|
+
server.registerTool("skip_current_plugin_scan", {
|
|
4732
|
+
title: "Skip Current Plugin Scan",
|
|
4733
|
+
description: "Skip (blocklist) the plugin currently being scanned and continue the scan. Use when a scan is stuck on a " +
|
|
4734
|
+
"crashing/hanging plugin (poll get_plugin_scan_status to detect a stall).",
|
|
4735
|
+
inputSchema: {},
|
|
4736
|
+
}, async () => {
|
|
4737
|
+
const check = await requireBridge();
|
|
4738
|
+
if (check !== true)
|
|
4739
|
+
return check;
|
|
4740
|
+
await bridge.send("skipCurrentPluginScan");
|
|
4741
|
+
return text("Skipped the current plugin and continued the scan.");
|
|
4742
|
+
});
|
|
4743
|
+
server.registerTool("remove_plugin_from_blocklist", {
|
|
4744
|
+
title: "Remove Plugin From Blocklist",
|
|
4745
|
+
description: "Un-blocklist a plugin that was blocklisted after a failed/crashed scan so it can be scanned and loaded again. " +
|
|
4746
|
+
"Pass the fileOrIdentifier as it appears in the blocklist (see list_plugins → blocklist).",
|
|
4747
|
+
inputSchema: {
|
|
4748
|
+
fileOrIdentifier: z.string().describe("The blocklisted plugin's file path or identifier."),
|
|
4749
|
+
},
|
|
4750
|
+
}, async ({ fileOrIdentifier }) => {
|
|
4751
|
+
const check = await requireBridge();
|
|
4752
|
+
if (check !== true)
|
|
4753
|
+
return check;
|
|
4754
|
+
await bridge.send("removeFromBlocklist", fileOrIdentifier);
|
|
4755
|
+
return text(`Removed ${fileOrIdentifier} from the plugin blocklist.`);
|
|
4756
|
+
});
|
|
4757
|
+
// ============================================================
|
|
3124
4758
|
// Start
|
|
3125
4759
|
// ============================================================
|
|
3126
4760
|
async function main() {
|