@map-audio/pam-mcp-server 1.1.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/bridge.js +67 -6
- package/dist/bridge.js.map +1 -1
- package/dist/manifest.json +55496 -9
- package/dist/server.js +772 -15
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -152,19 +152,23 @@ function formatParam(p) {
|
|
|
152
152
|
// --- Server ---
|
|
153
153
|
const server = new McpServer({
|
|
154
154
|
name: "pam",
|
|
155
|
-
version: "1.
|
|
155
|
+
version: "1.7.0",
|
|
156
156
|
}, {
|
|
157
157
|
instructions: "PAM is an audio sampler plugin. When browsing samples, ALWAYS call get_default_locations " +
|
|
158
158
|
"first to discover the user's actual library paths — never guess paths like ~/Music/PAM. " +
|
|
159
159
|
"The sample library lives under ~/Library/Application Support/, NOT ~/Music/. " +
|
|
160
160
|
"Use the returned paths with list_samples. When loading samples, use load_sample with " +
|
|
161
161
|
"paths from list_samples. Transport must be playing for sequencer output. " +
|
|
162
|
-
"
|
|
163
|
-
"
|
|
164
|
-
"
|
|
165
|
-
"
|
|
166
|
-
"
|
|
167
|
-
"
|
|
162
|
+
"To enable experimental features (drum synth, plugin host) call update_settings with both " +
|
|
163
|
+
"experimentalMode:true AND the specific sub-flag (e.g. experimentalDrumSynth:true). " +
|
|
164
|
+
"Tools added in server version 1.7.0 — update_settings, configure_transport, " +
|
|
165
|
+
"get_audio_device_setup, set_midi_io, set_midi_mapping, delete_preset, " +
|
|
166
|
+
"set_preset_factory_status, get_sample_usage, repair_preset_samples, preview_sample, " +
|
|
167
|
+
"set_plugin_params, skip_current_plugin_scan, remove_plugin_from_blocklist — require PAM ≥ 1.7.0. " +
|
|
168
|
+
"Earlier tool sets (looper, song mode, modulation edits, MIDI recording, sample analysis, " +
|
|
169
|
+
"live resample, variation scheduling, AI generation) require PAM ≥ 1.4.50 / 1.5.0. " +
|
|
170
|
+
"If a tool fails with \"PAM build does not support 'X'\", the connected plugin is older than " +
|
|
171
|
+
"this MCP server; the user must update PAM (updating the npm package alone is not enough).",
|
|
168
172
|
});
|
|
169
173
|
// ============================================================
|
|
170
174
|
// TOOLS — Offline (work without plugin running)
|
|
@@ -478,6 +482,87 @@ server.registerTool("load_sample", {
|
|
|
478
482
|
await bridge.send("loadSampleToCell", filePath, cellId);
|
|
479
483
|
return text(`Loaded ${basename(filePath)} into cell ${cellId}.`);
|
|
480
484
|
});
|
|
485
|
+
server.registerTool("generate_sample", {
|
|
486
|
+
title: "Generate Sample (AI)",
|
|
487
|
+
description: "Generate a new audio sample via the on-device AI engine (Stable Audio 3 Small SFX, ~3.2 GB ONNX). The plugin must have the model downloaded first (Settings → Local models). Returns the on-disk path to the generated WAV and optionally loads it into a cell.",
|
|
488
|
+
inputSchema: {
|
|
489
|
+
category: z
|
|
490
|
+
.enum(["kick", "snare", "hat", "perc", "bass", "synth", "pad", "fx", "loop"])
|
|
491
|
+
.describe("Sound category — drives prompt template defaults."),
|
|
492
|
+
prompt: z.string().describe("Text prompt describing the desired sound."),
|
|
493
|
+
durationSeconds: z
|
|
494
|
+
.number()
|
|
495
|
+
.min(0.1)
|
|
496
|
+
.max(47)
|
|
497
|
+
.describe("Output length in seconds (Stable Audio 3 Small SFX engine cap ≈ 47 s). For loops, pass bar-converted duration: bars * beatsPerBar * 60 / bpm."),
|
|
498
|
+
mode: z
|
|
499
|
+
.enum(["text", "inpaint", "continuation"])
|
|
500
|
+
.optional()
|
|
501
|
+
.default("text")
|
|
502
|
+
.describe("Generation mode. text = unconditional, inpaint = audio-to-audio with mask, continuation = extend existing audio."),
|
|
503
|
+
cellId: z
|
|
504
|
+
.number()
|
|
505
|
+
.int()
|
|
506
|
+
.min(1)
|
|
507
|
+
.max(8)
|
|
508
|
+
.optional()
|
|
509
|
+
.describe("If provided, load result directly into this cell."),
|
|
510
|
+
seed: z.number().int().optional().describe("Deterministic seed. Random if omitted."),
|
|
511
|
+
refAudioPath: z.string().optional().describe("Required for inpaint/continuation. Absolute path to a WAV."),
|
|
512
|
+
maskSpec: z
|
|
513
|
+
.string()
|
|
514
|
+
.optional()
|
|
515
|
+
.describe('Mask spec for inpaint mode: "tail:0.5" or "transient:0-0.05" or "full".'),
|
|
516
|
+
},
|
|
517
|
+
}, async (args) => {
|
|
518
|
+
const ok = await requireBridge();
|
|
519
|
+
if (ok !== true)
|
|
520
|
+
return ok;
|
|
521
|
+
try {
|
|
522
|
+
const result = await bridge.send("aiGenerate", args);
|
|
523
|
+
return json(result);
|
|
524
|
+
}
|
|
525
|
+
catch (e) {
|
|
526
|
+
return error(`AI generation failed: ${e.message}`);
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
server.registerTool("generate_kit", {
|
|
530
|
+
title: "Generate Kit (AI)",
|
|
531
|
+
description: "Generate multiple AI samples in parallel (Stable Audio 3 Small SFX) and load them into a list of cells. Useful for building a coherent drum / instrument kit from a single high-level prompt. Requires the on-device model to be downloaded (Settings → Local models).",
|
|
532
|
+
inputSchema: {
|
|
533
|
+
prompt: z.string().describe('High-level kit description (e.g. "dark trap drum kit", "lofi house").'),
|
|
534
|
+
cells: z
|
|
535
|
+
.array(z.object({
|
|
536
|
+
id: z.number().int().min(1).max(8),
|
|
537
|
+
role: z.enum(["kick", "snare", "hat", "perc", "bass", "synth", "pad", "fx", "loop"]),
|
|
538
|
+
}))
|
|
539
|
+
.min(1)
|
|
540
|
+
.max(8)
|
|
541
|
+
.describe("Cells to populate. Each gets one sample matching the role."),
|
|
542
|
+
},
|
|
543
|
+
}, async (args) => {
|
|
544
|
+
const ok = await requireBridge();
|
|
545
|
+
if (ok !== true)
|
|
546
|
+
return ok;
|
|
547
|
+
const { prompt, cells } = args;
|
|
548
|
+
// C++ handleAIGenerateKit expects { requests: [{ prompt, category, cellId, durationSeconds }] }.
|
|
549
|
+
// Derive a per-role prompt + sensible duration from the single kit prompt;
|
|
550
|
+
// cellId on each request drives the bridge's load-into-cell step. (mode omitted → text.)
|
|
551
|
+
const durationForRole = (role) => role === "loop" ? 4 : role === "bass" || role === "synth" || role === "pad" || role === "fx" ? 2 : 1.5;
|
|
552
|
+
const requests = cells.map((c) => ({
|
|
553
|
+
prompt: `${prompt}, ${c.role}`,
|
|
554
|
+
category: c.role,
|
|
555
|
+
cellId: c.id,
|
|
556
|
+
durationSeconds: durationForRole(c.role),
|
|
557
|
+
}));
|
|
558
|
+
try {
|
|
559
|
+
const results = await bridge.send("aiGenerateKit", { requests });
|
|
560
|
+
return json(results);
|
|
561
|
+
}
|
|
562
|
+
catch (e) {
|
|
563
|
+
return error(`Kit generation failed: ${e.message}`);
|
|
564
|
+
}
|
|
565
|
+
});
|
|
481
566
|
server.registerTool("trigger_midi", {
|
|
482
567
|
title: "Trigger MIDI",
|
|
483
568
|
description: "Send a MIDI note to a specific cell or globally. " +
|
|
@@ -558,8 +643,17 @@ server.registerTool("add_modulation", {
|
|
|
558
643
|
.boolean()
|
|
559
644
|
.default(false)
|
|
560
645
|
.describe("Bipolar modulation (modulates both directions)."),
|
|
646
|
+
pitch: z
|
|
647
|
+
.boolean()
|
|
648
|
+
.optional()
|
|
649
|
+
.describe("Envelope only: route from the cell's pitch-tracked source instead of amp. " +
|
|
650
|
+
"false (default) = AMP; true & pitchDuck=false = BOOST; true & pitchDuck=true = DUCK."),
|
|
651
|
+
pitchDuck: z
|
|
652
|
+
.boolean()
|
|
653
|
+
.optional()
|
|
654
|
+
.describe("Envelope only: when pitch=true, switch from BOOST to DUCK semantics (notch). Ignored if pitch=false."),
|
|
561
655
|
},
|
|
562
|
-
}, async ({ type, paramId, cellId, lfoIndex, strength, bipolar }) => {
|
|
656
|
+
}, async ({ type, paramId, cellId, lfoIndex, strength, bipolar, pitch, pitchDuck }) => {
|
|
563
657
|
const index = await getParameterIndex();
|
|
564
658
|
const param = index.get(paramId);
|
|
565
659
|
if (!param)
|
|
@@ -582,7 +676,7 @@ server.registerTool("add_modulation", {
|
|
|
582
676
|
else if (type === "envelope") {
|
|
583
677
|
if (!cellId)
|
|
584
678
|
return error("cellId is required for envelope modulation.");
|
|
585
|
-
await bridge.send("addEnvelope", paramId, cellId, strength, bipolar, effectEnableParams);
|
|
679
|
+
await bridge.send("addEnvelope", paramId, cellId, strength, bipolar, effectEnableParams, pitch ?? false, pitchDuck ?? false);
|
|
586
680
|
}
|
|
587
681
|
else if (type === "vary") {
|
|
588
682
|
if (!cellId)
|
|
@@ -1628,15 +1722,21 @@ server.registerTool("update_cell_config", {
|
|
|
1628
1722
|
});
|
|
1629
1723
|
server.registerTool("set_master_effects", {
|
|
1630
1724
|
title: "Set Master Effects",
|
|
1631
|
-
description: "Configure the master bus Punch compressor and
|
|
1725
|
+
description: "Configure the master bus Punch compressor, Limiter, and Tape colour. " +
|
|
1632
1726
|
"The Punch compressor is a musical bus compressor — use it for glue and pump. " +
|
|
1633
1727
|
"The Limiter is a 3-stage brick-wall limiter with tanh saturation. " +
|
|
1634
|
-
"
|
|
1728
|
+
"The Tape is a vintage-tape coloration stage (ADAA tanh/asymmetric saturation + HF compression + wow/flutter), " +
|
|
1729
|
+
"the single biggest lever for lo-fi / dusty / 'pushed' master character (hip-hop, lo-fi, Memphis, dub). " +
|
|
1730
|
+
"Pass enabled flags to toggle each on/off. Pass parameters to shape the sound. " +
|
|
1635
1731
|
"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."
|
|
1732
|
+
"medium release (80-200ms), ratio 4-8, autoMakeup true. " +
|
|
1733
|
+
"For lo-fi/Memphis/dusty glue: tapeEnabled=1, tapeMode=1 (Lo-Fi), masterTapeDrive 0.55-0.7, " +
|
|
1734
|
+
"masterTapeTone -0.4..-0.1 (darker), masterTapeWear 0.5-0.7 (wow/flutter). " +
|
|
1735
|
+
"NOTE: the Tape requires a PAM build that ships it (manifest key masterTapeEnabled). On older builds these keys are ignored.",
|
|
1637
1736
|
inputSchema: {
|
|
1638
1737
|
compressorEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable Punch compressor (0=off, 1=on)."),
|
|
1639
1738
|
limiterEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable master Limiter (0=off, 1=on)."),
|
|
1739
|
+
tapeEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable master Tape colour/saturation (0=off, 1=on)."),
|
|
1640
1740
|
masterCompThreshold: z.number().min(-60).max(0).optional().describe("Compressor threshold in dB (default -24)."),
|
|
1641
1741
|
masterCompRatio: z.number().min(1).max(20).optional().describe("Compressor ratio (default 4)."),
|
|
1642
1742
|
masterCompAttack: z.number().min(0.01).max(100).optional().describe("Attack in ms (default 0.2)."),
|
|
@@ -1647,8 +1747,13 @@ server.registerTool("set_master_effects", {
|
|
|
1647
1747
|
masterCompCeiling: z.number().min(-6).max(0).optional().describe("Output ceiling in dB (default -0.05)."),
|
|
1648
1748
|
masterCompInputGain: z.number().min(-24).max(24).optional().describe("Input gain in dB (default 0)."),
|
|
1649
1749
|
masterCompKnee: z.number().min(0).max(30).optional().describe("Knee width in dB (default 0)."),
|
|
1750
|
+
masterTapeDrive: z.number().min(0).max(1).optional().describe("Tape drive / saturation amount (0-1, default 0.5). Higher = more harmonic grit + HF compression ('push it harder → it gets duller')."),
|
|
1751
|
+
masterTapeTone: z.number().min(-1).max(1).optional().describe("Tape tone tilt (-1 dark .. 0 flat .. +1 bright, default 0). Go negative for dusty/lo-fi."),
|
|
1752
|
+
masterTapeMix: z.number().min(0).max(1).optional().describe("Tape dry/wet (0-1, default 1 = fully wet)."),
|
|
1753
|
+
masterTapeMode: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional().describe("Tape character: 0=Clean (gentle tanh), 1=Lo-Fi (most degradation, dusty), 2=Amp (hard asymmetric drive). Default 0."),
|
|
1754
|
+
masterTapeWear: z.number().min(0).max(1).optional().describe("Tape wear: wow/flutter pitch drift + age (0-1, default 0.5). Raise for warble/sag."),
|
|
1650
1755
|
},
|
|
1651
|
-
}, async ({ compressorEnabled, limiterEnabled, ...
|
|
1756
|
+
}, async ({ compressorEnabled, limiterEnabled, tapeEnabled, ...effectParams }) => {
|
|
1652
1757
|
const check = await requireBridge();
|
|
1653
1758
|
if (check !== true)
|
|
1654
1759
|
return check;
|
|
@@ -1658,8 +1763,11 @@ server.registerTool("set_master_effects", {
|
|
|
1658
1763
|
delta.masterCompressorEnabled = compressorEnabled;
|
|
1659
1764
|
if (limiterEnabled !== undefined)
|
|
1660
1765
|
delta.masterLimiterEnabled = limiterEnabled;
|
|
1661
|
-
|
|
1662
|
-
|
|
1766
|
+
if (tapeEnabled !== undefined)
|
|
1767
|
+
delta.masterTapeEnabled = tapeEnabled;
|
|
1768
|
+
// Compressor + tape parameters are already the flat state keys read by the
|
|
1769
|
+
// DSP graph / native master node (masterComp*, masterTape*). Pass through.
|
|
1770
|
+
for (const [key, value] of Object.entries(effectParams)) {
|
|
1663
1771
|
if (value !== undefined)
|
|
1664
1772
|
delta[key] = value;
|
|
1665
1773
|
}
|
|
@@ -1673,6 +1781,83 @@ server.registerTool("set_master_effects", {
|
|
|
1673
1781
|
.join(", ");
|
|
1674
1782
|
return text(`Updated master effects: ${summary}`);
|
|
1675
1783
|
});
|
|
1784
|
+
// Built-in per-cell drum synth ("machines"). Engine enum index = drumsynth::Engine
|
|
1785
|
+
// (Off = 0). Per-engine param labels + musical defaults mirror DRUM_SYNTH_ENGINES
|
|
1786
|
+
// in interface/src/dsp/modules/drumSynth.ts — keep in lockstep (append-only).
|
|
1787
|
+
const DRUM_SYNTH_ENGINES = [
|
|
1788
|
+
{ name: "Kick", params: [["Tune", 0.25], ["Pitch", 0.5], ["P.Decay", 0.35], ["Decay", 0.5], ["Click", 0.3], ["Drive", 0.35], ["Tone", 0.6]] },
|
|
1789
|
+
{ name: "Snare", params: [["Tune", 0.4], ["Snap", 0.45], ["Body", 0.35], ["N.Decay", 0.45], ["N.Tone", 0.5], ["Detune", 0.3], ["Drive", 0.25]] },
|
|
1790
|
+
{ name: "HiHat", params: [["Tune", 0.5], ["Decay", 0.3], ["Tone", 0.5], ["Metal", 0.6], ["Noise", 0.2]] },
|
|
1791
|
+
{ name: "Clap", params: [["Tune", 0.45], ["Spread", 0.4], ["Decay", 0.4], ["Tone", 0.5], ["Drive", 0.2]] },
|
|
1792
|
+
{ name: "Tom", params: [["Tune", 0.4], ["Pitch", 0.4], ["Decay", 0.45], ["Tone", 0.55], ["Noise", 0.2], ["Drive", 0.2]] },
|
|
1793
|
+
{ name: "FM", params: [["Tune", 0.4], ["Ratio", 0.45], ["FM Amt", 0.4], ["Decay", 0.4], ["FM Dcy", 0.4], ["F.Back", 0.1], ["Drive", 0.2]] },
|
|
1794
|
+
{ name: "Cymbal", params: [["Tune", 0.5], ["Decay", 0.5], ["Tone", 0.6], ["Metal", 0.7], ["Sizzle", 0.4]] },
|
|
1795
|
+
{ name: "Cowbell", params: [["Tune", 0.5], ["Decay", 0.4], ["Ratio", 0.45], ["Tone", 0.5], ["Drive", 0.2]] },
|
|
1796
|
+
{ name: "808", params: [["Tune", 0.35], ["Glide", 0.25], ["P.Decay", 0.4], ["Decay", 0.55], ["Saturate", 0.2], ["Click", 0.25], ["Tone", 0.5]] },
|
|
1797
|
+
{ name: "Shaker", params: [["Tune", 0.5], ["Color", 0.3], ["Decay", 0.3], ["Attack", 0.4], ["Rattle", 0.0], ["Tone", 0.4]] },
|
|
1798
|
+
{ name: "Rim", params: [["Tune", 0.4], ["Ring", 0.5], ["Decay", 0.3], ["Mode", 0.4], ["Snap", 0.6], ["Drive", 0.2]] },
|
|
1799
|
+
{ name: "Conga", params: [["Tune", 0.45], ["Slap", 0.4], ["Decay", 0.4], ["Mode", 0.3], ["Tone", 0.55], ["Skin", 0.25], ["Drive", 0.2]] },
|
|
1800
|
+
{ name: "Bell", params: [["Tune", 0.5], ["Ratio", 0.4], ["FM Amt", 0.4], ["Decay", 0.5], ["Inharm", 0.3], ["Strike", 0.3], ["Tone", 0.3]] },
|
|
1801
|
+
{ name: "Pluck", params: [["Tune", 0.4], ["Sustain", 0.6], ["Damp", 0.5], ["Excite", 0.4], ["Tone", 0.6], ["Drive", 0.15], ["Decay", 0.5]] },
|
|
1802
|
+
{ name: "Zap", params: [["Tune", 0.3], ["Sweep", 0.7], ["S.Time", 0.35], ["Decay", 0.4], ["Timbre", 0.3], ["Noise", 0.1], ["Drive", 0.25], ["Tone", 0.6]] },
|
|
1803
|
+
{ name: "Wave", params: [["Tune", 0.35], ["Position", 0.2], ["P.Env", 0.4], ["Decay", 0.4], ["Bright", 0.6], ["Morph", 0.0], ["Drive", 0.2]] },
|
|
1804
|
+
{ name: "Chaos", params: [["Tune", 0.4], ["Chaos", 0.6], ["Decay", 0.4], ["Tone", 0.5], ["Drive", 0.3], ["Noise", 0.1]] },
|
|
1805
|
+
];
|
|
1806
|
+
const DRUM_SYNTH_ENGINE_NAMES = ["Off", ...DRUM_SYNTH_ENGINES.map((e) => e.name)];
|
|
1807
|
+
server.registerTool("set_drum_synth", {
|
|
1808
|
+
title: "Set Drum Synth (built-in machine)",
|
|
1809
|
+
description: "Select and shape a cell's built-in drum-synth 'machine' — a real-time synthesized voice that sums into the cell " +
|
|
1810
|
+
"alongside (or instead of) its sample. 18 engines: Off, Kick, Snare, HiHat, Clap, Tom, FM, Cymbal, Cowbell, 808, " +
|
|
1811
|
+
"Shaker, Rim, Conga, Bell, Pluck, Zap, Wave, Chaos. The 808 engine is a tuned long-glide sub that plays as a chromatic " +
|
|
1812
|
+
"bassline (drive it with a pitch lane + pitch-quantize) — ideal for 808 sub-bass, trap, Memphis. Use a cell with NO " +
|
|
1813
|
+
"sample for a pure synth voice, or layer over a sample. Each engine reinterprets the 8 generic params (p1..p8); pass " +
|
|
1814
|
+
"only the ones you want to nuance — unspecified params seed to the engine's musical default. Per-engine p-labels: " +
|
|
1815
|
+
DRUM_SYNTH_ENGINES.map((e) => `${e.name}=[${e.params.map((p) => p[0]).join(",")}]`).join("; ") + ". " +
|
|
1816
|
+
"NOTE: requires a PAM build with drum synth (manifest.drumSynth.enabled) AND Settings → Experimental → Drum Synth ON. " +
|
|
1817
|
+
"On builds without it, the cell{N}_synthEngine state key is ignored.",
|
|
1818
|
+
inputSchema: {
|
|
1819
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
1820
|
+
engine: z.enum(DRUM_SYNTH_ENGINE_NAMES).describe("Engine name. 'Off' disables the synth voice (sample-only cell)."),
|
|
1821
|
+
volume: z.number().min(-60).max(12).optional().describe("Synth output level in dB (default 0)."),
|
|
1822
|
+
p1: z.number().min(0).max(1).optional().describe("Param 1 (engine-specific; usually Tune). Overrides the engine default."),
|
|
1823
|
+
p2: z.number().min(0).max(1).optional().describe("Param 2 (engine-specific)."),
|
|
1824
|
+
p3: z.number().min(0).max(1).optional().describe("Param 3 (engine-specific)."),
|
|
1825
|
+
p4: z.number().min(0).max(1).optional().describe("Param 4 (engine-specific)."),
|
|
1826
|
+
p5: z.number().min(0).max(1).optional().describe("Param 5 (engine-specific)."),
|
|
1827
|
+
p6: z.number().min(0).max(1).optional().describe("Param 6 (engine-specific)."),
|
|
1828
|
+
p7: z.number().min(0).max(1).optional().describe("Param 7 (engine-specific)."),
|
|
1829
|
+
p8: z.number().min(0).max(1).optional().describe("Param 8 (engine-specific)."),
|
|
1830
|
+
},
|
|
1831
|
+
}, async ({ cellId, engine, volume, ...overrides }) => {
|
|
1832
|
+
const check = await requireBridge();
|
|
1833
|
+
if (check !== true)
|
|
1834
|
+
return check;
|
|
1835
|
+
const engineId = DRUM_SYNTH_ENGINE_NAMES.indexOf(engine);
|
|
1836
|
+
if (engineId < 0)
|
|
1837
|
+
return error(`Unknown drum synth engine: ${engine}`);
|
|
1838
|
+
const delta = { [`cell${cellId}_synthEngine`]: engineId };
|
|
1839
|
+
if (engineId > 0) {
|
|
1840
|
+
// Seed the engine's musical defaults so the voice sounds good immediately,
|
|
1841
|
+
// then apply explicit p-overrides on top (nuance only what you mean to).
|
|
1842
|
+
const desc = DRUM_SYNTH_ENGINES[engineId - 1];
|
|
1843
|
+
desc.params.forEach(([, def], i) => {
|
|
1844
|
+
delta[`cell${cellId}_synth_p${i + 1}`] = def;
|
|
1845
|
+
});
|
|
1846
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
1847
|
+
if (value === undefined)
|
|
1848
|
+
continue;
|
|
1849
|
+
const idx = Number(key.slice(1)); // p3 -> 3
|
|
1850
|
+
if (idx >= 1 && idx <= desc.params.length)
|
|
1851
|
+
delta[`cell${cellId}_synth_p${idx}`] = value;
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
if (volume !== undefined)
|
|
1855
|
+
delta[`cell${cellId}_synthVolume`] = volume;
|
|
1856
|
+
// synthEngine is a graph-render key → needs a graph dispatch (KV_OPTS_STANDARD).
|
|
1857
|
+
await applyKv(delta, KV_OPTS_STANDARD);
|
|
1858
|
+
const labels = engineId > 0 ? DRUM_SYNTH_ENGINES[engineId - 1].params.map((p) => p[0]).join(",") : "—";
|
|
1859
|
+
return text(`Cell ${cellId} drum synth → ${engine}${engineId > 0 ? ` (p1..pN = ${labels})` : ""}.`);
|
|
1860
|
+
});
|
|
1676
1861
|
server.registerTool("add_param_seq", {
|
|
1677
1862
|
title: "Add Parameter Sequencer",
|
|
1678
1863
|
description: "Add a parameter sequencer (step modulation) targeting any modulatable parameter. " +
|
|
@@ -1780,6 +1965,25 @@ server.registerTool("normalize_sample", {
|
|
|
1780
1965
|
await bridge.send("normalizeSampleForCell", cellId);
|
|
1781
1966
|
return text(`Normalized sample in cell ${cellId}.`);
|
|
1782
1967
|
});
|
|
1968
|
+
server.registerTool("gain_sample", {
|
|
1969
|
+
title: "Gain Sample",
|
|
1970
|
+
description: "Apply a gain in decibels to a cell's sample and render it into a new file (offline, destructive). " +
|
|
1971
|
+
"Positive values make it louder, negative quieter; boosting past 0 dBFS clips. The original library file is left unchanged, so the edit can be undone by reloading the source sample.",
|
|
1972
|
+
inputSchema: {
|
|
1973
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
1974
|
+
gainDb: z
|
|
1975
|
+
.number()
|
|
1976
|
+
.min(-96)
|
|
1977
|
+
.max(48)
|
|
1978
|
+
.describe("Gain in decibels (e.g. 3 = +3 dB louder, -6 = 6 dB quieter)."),
|
|
1979
|
+
},
|
|
1980
|
+
}, async ({ cellId, gainDb }) => {
|
|
1981
|
+
const check = await requireBridge();
|
|
1982
|
+
if (check !== true)
|
|
1983
|
+
return check;
|
|
1984
|
+
await bridge.send("gainSampleForCell", { cellId, gainDb });
|
|
1985
|
+
return text(`Applied ${gainDb > 0 ? "+" : ""}${gainDb} dB to the sample in cell ${cellId}.`);
|
|
1986
|
+
});
|
|
1783
1987
|
server.registerTool("export_audio", {
|
|
1784
1988
|
title: "Export Audio",
|
|
1785
1989
|
description: "Render and export audio from the current preset to a WAV file. " +
|
|
@@ -3121,6 +3325,559 @@ server.registerTool("live_resample", {
|
|
|
3121
3325
|
return text(`Stopped live resample (finalize=${args.finalize ?? true}).`);
|
|
3122
3326
|
});
|
|
3123
3327
|
// ============================================================
|
|
3328
|
+
// TOOLS — Plugin host (scan/load/unload/param/bypass on hosted VST3/AU/CLAP)
|
|
3329
|
+
// Instrument-role only. Effect-side hosting is not exposed via MCP yet
|
|
3330
|
+
// (MCPBridge only routes through the instrument manager).
|
|
3331
|
+
// ============================================================
|
|
3332
|
+
const PLUGIN_FORMAT = z.enum(["VST3", "AudioUnit", "CLAP"]);
|
|
3333
|
+
// Whitelist of scan-path roots. Mirrors
|
|
3334
|
+
// interface/src/utils/pluginScanPaths.ts (DEFAULT_PATHS_MAC / _WIN).
|
|
3335
|
+
// MCP-supplied searchPaths must resolve to entries beneath one of these
|
|
3336
|
+
// roots so an agent can't probe ~/.ssh or other sensitive folders.
|
|
3337
|
+
function expandHome(p) {
|
|
3338
|
+
if (p.startsWith("~/"))
|
|
3339
|
+
return resolve(process.env.HOME ?? "", p.slice(2));
|
|
3340
|
+
if (p === "~")
|
|
3341
|
+
return process.env.HOME ?? "";
|
|
3342
|
+
return p;
|
|
3343
|
+
}
|
|
3344
|
+
const PLUGIN_SCAN_PATH_ROOTS = (process.platform === "win32"
|
|
3345
|
+
? ["C:/Program Files/Common Files/VST3", "C:/Program Files/Common Files/CLAP"]
|
|
3346
|
+
: [
|
|
3347
|
+
"/Library/Audio/Plug-Ins/VST3",
|
|
3348
|
+
"~/Library/Audio/Plug-Ins/VST3",
|
|
3349
|
+
"/Library/Audio/Plug-Ins/Components",
|
|
3350
|
+
"~/Library/Audio/Plug-Ins/Components",
|
|
3351
|
+
"/Library/Audio/Plug-Ins/CLAP",
|
|
3352
|
+
"~/Library/Audio/Plug-Ins/CLAP",
|
|
3353
|
+
]).map((p) => resolve(expandHome(p)));
|
|
3354
|
+
function isPathUnderWhitelist(p) {
|
|
3355
|
+
const abs = resolve(expandHome(p));
|
|
3356
|
+
return PLUGIN_SCAN_PATH_ROOTS.some((root) => abs === root || abs.startsWith(root + "/") || abs.startsWith(root + "\\"));
|
|
3357
|
+
}
|
|
3358
|
+
// fileOrIdentifier values returned by list_plugins. load_plugin only
|
|
3359
|
+
// accepts entries observed here so an agent can't instantiate arbitrary
|
|
3360
|
+
// binaries.
|
|
3361
|
+
const knownPluginIdentifiers = new Set();
|
|
3362
|
+
// Hosted-plugin tools target PAM cells. Cap to the manifest's cell count
|
|
3363
|
+
// (falls back to 8 — PAM's shipping max) rather than the C++ slot
|
|
3364
|
+
// reservation of 32.
|
|
3365
|
+
async function getMaxCellCount() {
|
|
3366
|
+
try {
|
|
3367
|
+
const m = await loadManifest();
|
|
3368
|
+
const cells = m.cells;
|
|
3369
|
+
if (Array.isArray(cells) && cells.length > 0)
|
|
3370
|
+
return cells.length;
|
|
3371
|
+
}
|
|
3372
|
+
catch {
|
|
3373
|
+
// manifest unavailable — use shipping default
|
|
3374
|
+
}
|
|
3375
|
+
return 8;
|
|
3376
|
+
}
|
|
3377
|
+
async function clampCellId(cellId) {
|
|
3378
|
+
const max = await getMaxCellCount();
|
|
3379
|
+
if (!Number.isInteger(cellId) || cellId < 1 || cellId > max) {
|
|
3380
|
+
return `cellId must be an integer in [1, ${max}]`;
|
|
3381
|
+
}
|
|
3382
|
+
return cellId;
|
|
3383
|
+
}
|
|
3384
|
+
server.registerTool("list_plugins", {
|
|
3385
|
+
title: "List Plugins",
|
|
3386
|
+
description: "List plugins that have been discovered by PAM's scanner (VST3, AudioUnit, and CLAP). " +
|
|
3387
|
+
"Returns `plugins` (each: name, manufacturer, format, category, fileOrIdentifier, isInstrument, version) " +
|
|
3388
|
+
"and `blocklist` (paths the scanner marked unsafe). " +
|
|
3389
|
+
"Use this before `load_plugin` to find a valid `path` + `format`. " +
|
|
3390
|
+
"If the list is empty, call `scan_plugins` first. " +
|
|
3391
|
+
"Note: PAM's MCP hosts plugins in the per-cell instrument slot only — FX-side hosting is not exposed yet.",
|
|
3392
|
+
inputSchema: {
|
|
3393
|
+
format: PLUGIN_FORMAT.optional().describe("Filter by plugin format."),
|
|
3394
|
+
isInstrument: z.boolean().optional().describe("If true, only instruments; false, only effects."),
|
|
3395
|
+
nameContains: z.string().optional().describe("Case-insensitive substring match on the plugin name."),
|
|
3396
|
+
},
|
|
3397
|
+
}, async (args) => {
|
|
3398
|
+
const check = await requireBridge();
|
|
3399
|
+
if (check !== true)
|
|
3400
|
+
return check;
|
|
3401
|
+
const filter = {};
|
|
3402
|
+
if (args.format)
|
|
3403
|
+
filter.format = args.format;
|
|
3404
|
+
if (typeof args.isInstrument === "boolean")
|
|
3405
|
+
filter.isInstrument = args.isInstrument;
|
|
3406
|
+
if (args.nameContains)
|
|
3407
|
+
filter.nameContains = args.nameContains;
|
|
3408
|
+
const raw = (await bridge.send("listPlugins", filter));
|
|
3409
|
+
// Record every fileOrIdentifier so load_plugin can validate that the
|
|
3410
|
+
// caller-supplied path was discovered by PAM's own scanner.
|
|
3411
|
+
try {
|
|
3412
|
+
const parsed = JSON.parse(raw);
|
|
3413
|
+
if (Array.isArray(parsed.plugins)) {
|
|
3414
|
+
for (const p of parsed.plugins) {
|
|
3415
|
+
if (typeof p.fileOrIdentifier === "string") {
|
|
3416
|
+
knownPluginIdentifiers.add(p.fileOrIdentifier);
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3421
|
+
catch {
|
|
3422
|
+
// C++ payload not JSON — fall through; load_plugin will reject unknowns.
|
|
3423
|
+
}
|
|
3424
|
+
return text(raw);
|
|
3425
|
+
});
|
|
3426
|
+
server.registerTool("scan_plugins", {
|
|
3427
|
+
title: "Scan Plugins",
|
|
3428
|
+
description: "Trigger an async scan for VST3, AudioUnit, and CLAP plugins. " +
|
|
3429
|
+
"Returns immediately — poll `get_plugin_scan_status` for progress and call `list_plugins` " +
|
|
3430
|
+
"once `isScanning` is false. Without `searchPaths`, scans default OS plugin folders. " +
|
|
3431
|
+
"Supplied `searchPaths` must live under PAM's approved plugin roots " +
|
|
3432
|
+
"(/Library/Audio/Plug-Ins/{VST3,Components,CLAP} or their ~ equivalents on macOS; " +
|
|
3433
|
+
"C:/Program Files/Common Files/{VST3,CLAP} on Windows).",
|
|
3434
|
+
inputSchema: {
|
|
3435
|
+
searchPaths: z
|
|
3436
|
+
.array(z.string())
|
|
3437
|
+
.optional()
|
|
3438
|
+
.describe("Folders to scan. Must be inside an approved plugin root. " +
|
|
3439
|
+
"Leave empty to use OS defaults (/Library/Audio/Plug-Ins/* on macOS, etc.)."),
|
|
3440
|
+
blocklist: z
|
|
3441
|
+
.array(z.string())
|
|
3442
|
+
.optional()
|
|
3443
|
+
.describe("File paths or identifiers the scanner should skip."),
|
|
3444
|
+
},
|
|
3445
|
+
}, async (args) => {
|
|
3446
|
+
const check = await requireBridge();
|
|
3447
|
+
if (check !== true)
|
|
3448
|
+
return check;
|
|
3449
|
+
const payload = {};
|
|
3450
|
+
if (args.searchPaths && args.searchPaths.length > 0) {
|
|
3451
|
+
const rejected = args.searchPaths.filter((p) => !isPathUnderWhitelist(p));
|
|
3452
|
+
if (rejected.length > 0) {
|
|
3453
|
+
return error(`Refusing to scan path(s) outside approved plugin roots: ${rejected.join(", ")}. ` +
|
|
3454
|
+
`Allowed roots: ${PLUGIN_SCAN_PATH_ROOTS.join(", ")}.`);
|
|
3455
|
+
}
|
|
3456
|
+
payload.searchPaths = args.searchPaths;
|
|
3457
|
+
}
|
|
3458
|
+
if (args.blocklist)
|
|
3459
|
+
payload.blocklist = args.blocklist;
|
|
3460
|
+
await bridge.send("scanPlugins", payload);
|
|
3461
|
+
return text("Plugin scan started. Poll get_plugin_scan_status until isScanning is false, then call list_plugins.");
|
|
3462
|
+
});
|
|
3463
|
+
server.registerTool("cancel_plugin_scan", {
|
|
3464
|
+
title: "Cancel Plugin Scan",
|
|
3465
|
+
description: "Cancel the in-flight plugin scan. Safe to call even if no scan is running.",
|
|
3466
|
+
inputSchema: {},
|
|
3467
|
+
}, async () => {
|
|
3468
|
+
const check = await requireBridge();
|
|
3469
|
+
if (check !== true)
|
|
3470
|
+
return check;
|
|
3471
|
+
await bridge.send("cancelPluginScan");
|
|
3472
|
+
return text("Cancelled (or no scan was running).");
|
|
3473
|
+
});
|
|
3474
|
+
server.registerTool("get_plugin_scan_status", {
|
|
3475
|
+
title: "Plugin Scan Status",
|
|
3476
|
+
description: "Read the latest plugin scan progress. Returns `isScanning`, `format`, `currentPath`, " +
|
|
3477
|
+
"`fraction` (0..1), `elapsedMs`, `isStall` (true when the in-flight plugin hasn't returned in 2s), " +
|
|
3478
|
+
"and `updatedAtMs`. Poll while a scan is running.",
|
|
3479
|
+
inputSchema: {},
|
|
3480
|
+
}, async () => {
|
|
3481
|
+
const check = await requireBridge();
|
|
3482
|
+
if (check !== true)
|
|
3483
|
+
return check;
|
|
3484
|
+
const raw = (await bridge.send("getPluginScanStatus"));
|
|
3485
|
+
return text(raw);
|
|
3486
|
+
});
|
|
3487
|
+
server.registerTool("list_loaded_plugins", {
|
|
3488
|
+
title: "List Loaded Plugins",
|
|
3489
|
+
description: "List plugins currently hosted in PAM cells (instrument slot only — FX-side hosting is not exposed). " +
|
|
3490
|
+
"Each entry: cellId, name, manufacturer, format, " +
|
|
3491
|
+
"path, isInstrument, bypassed, and the plugin's full param list (index, name, label, value, defaultValue). " +
|
|
3492
|
+
"Use the param `index` with `set_plugin_param`.",
|
|
3493
|
+
inputSchema: {},
|
|
3494
|
+
}, async () => {
|
|
3495
|
+
const check = await requireBridge();
|
|
3496
|
+
if (check !== true)
|
|
3497
|
+
return check;
|
|
3498
|
+
const raw = (await bridge.send("listLoadedPlugins"));
|
|
3499
|
+
return text(raw);
|
|
3500
|
+
});
|
|
3501
|
+
server.registerTool("load_plugin", {
|
|
3502
|
+
title: "Load Plugin Into Cell",
|
|
3503
|
+
description: "Load a hosted plugin (VST3, AudioUnit, or CLAP) into a cell's instrument slot. Blocks until the plugin probes + " +
|
|
3504
|
+
"instantiates (can take seconds for heavy plugins) and returns success or the underlying error. " +
|
|
3505
|
+
"`path` must be a `fileOrIdentifier` returned by a previous `list_plugins` call in this session.",
|
|
3506
|
+
inputSchema: {
|
|
3507
|
+
cellId: z.number().int().min(1).describe("Target cell (1-based)."),
|
|
3508
|
+
path: z.string().describe("Plugin fileOrIdentifier from list_plugins."),
|
|
3509
|
+
format: PLUGIN_FORMAT.describe("Plugin format."),
|
|
3510
|
+
},
|
|
3511
|
+
}, async (args) => {
|
|
3512
|
+
const check = await requireBridge();
|
|
3513
|
+
if (check !== true)
|
|
3514
|
+
return check;
|
|
3515
|
+
const clamped = await clampCellId(args.cellId);
|
|
3516
|
+
if (typeof clamped === "string")
|
|
3517
|
+
return error(clamped);
|
|
3518
|
+
if (!knownPluginIdentifiers.has(args.path)) {
|
|
3519
|
+
return error(`Plugin path not in the scanner's known list. Call list_plugins first and use one of its ` +
|
|
3520
|
+
`fileOrIdentifier values. Refused: ${args.path}`);
|
|
3521
|
+
}
|
|
3522
|
+
const raw = (await bridge.send("loadPlugin", {
|
|
3523
|
+
cellId: clamped,
|
|
3524
|
+
path: args.path,
|
|
3525
|
+
format: args.format,
|
|
3526
|
+
}));
|
|
3527
|
+
return text(raw);
|
|
3528
|
+
});
|
|
3529
|
+
server.registerTool("unload_plugin", {
|
|
3530
|
+
title: "Unload Plugin From Cell",
|
|
3531
|
+
description: "Unload the hosted plugin from a cell. No-op if no plugin is loaded there.",
|
|
3532
|
+
inputSchema: {
|
|
3533
|
+
cellId: z.number().int().min(1).describe("Target cell."),
|
|
3534
|
+
},
|
|
3535
|
+
}, async (args) => {
|
|
3536
|
+
const check = await requireBridge();
|
|
3537
|
+
if (check !== true)
|
|
3538
|
+
return check;
|
|
3539
|
+
const clamped = await clampCellId(args.cellId);
|
|
3540
|
+
if (typeof clamped === "string")
|
|
3541
|
+
return error(clamped);
|
|
3542
|
+
await bridge.send("unloadPlugin", clamped);
|
|
3543
|
+
return text(`Unload queued for cell ${clamped}.`);
|
|
3544
|
+
});
|
|
3545
|
+
server.registerTool("set_plugin_param", {
|
|
3546
|
+
title: "Set Hosted Plugin Parameter",
|
|
3547
|
+
description: "Set a parameter on a hosted plugin by cell + parameter index. Value is normalized 0..1. " +
|
|
3548
|
+
"Use `list_loaded_plugins` to find param indices and names. " +
|
|
3549
|
+
"This is the dedicated path for hosted plugin params — `set_parameters` won't reach them. " +
|
|
3550
|
+
"Targets the cell's instrument-slot plugin only.",
|
|
3551
|
+
inputSchema: {
|
|
3552
|
+
cellId: z.number().int().min(1),
|
|
3553
|
+
paramIndex: z.number().int().min(0),
|
|
3554
|
+
value: z.number().min(0).max(1).describe("Normalized 0..1."),
|
|
3555
|
+
},
|
|
3556
|
+
}, async (args) => {
|
|
3557
|
+
const check = await requireBridge();
|
|
3558
|
+
if (check !== true)
|
|
3559
|
+
return check;
|
|
3560
|
+
const clamped = await clampCellId(args.cellId);
|
|
3561
|
+
if (typeof clamped === "string")
|
|
3562
|
+
return error(clamped);
|
|
3563
|
+
await bridge.send("setPluginParam", {
|
|
3564
|
+
cellId: clamped,
|
|
3565
|
+
paramIndex: args.paramIndex,
|
|
3566
|
+
value: args.value,
|
|
3567
|
+
});
|
|
3568
|
+
return text(`Queued cell ${clamped} param ${args.paramIndex} = ${args.value}.`);
|
|
3569
|
+
});
|
|
3570
|
+
server.registerTool("set_plugin_bypass", {
|
|
3571
|
+
title: "Bypass Hosted Plugin",
|
|
3572
|
+
description: "Toggle bypass on the hosted plugin in a cell (instrument slot). Bypassed plugins are silenced; the cell sampler " +
|
|
3573
|
+
"still plays in parallel.",
|
|
3574
|
+
inputSchema: {
|
|
3575
|
+
cellId: z.number().int().min(1),
|
|
3576
|
+
bypassed: z.boolean(),
|
|
3577
|
+
},
|
|
3578
|
+
}, async (args) => {
|
|
3579
|
+
const check = await requireBridge();
|
|
3580
|
+
if (check !== true)
|
|
3581
|
+
return check;
|
|
3582
|
+
const clamped = await clampCellId(args.cellId);
|
|
3583
|
+
if (typeof clamped === "string")
|
|
3584
|
+
return error(clamped);
|
|
3585
|
+
await bridge.send("setPluginBypass", { cellId: clamped, bypassed: args.bypassed });
|
|
3586
|
+
return text(`Cell ${clamped} bypass=${args.bypassed}.`);
|
|
3587
|
+
});
|
|
3588
|
+
// ============================================================
|
|
3589
|
+
// TOOLS — Settings, transport config, device + preset management
|
|
3590
|
+
// ============================================================
|
|
3591
|
+
server.registerTool("update_settings", {
|
|
3592
|
+
title: "Update Plugin Settings",
|
|
3593
|
+
description: "Merge a partial settings delta into PAM's plugin settings (the Settings panel state) and apply it live. " +
|
|
3594
|
+
"Returns the full merged settings object so you can discover available keys. This is the only way to flip " +
|
|
3595
|
+
"experimental-feature gates from an agent. Common boolean keys: experimentalMode, experimentalDrumSynth, " +
|
|
3596
|
+
"experimentalPluginHost, experimentalMcpBridge. To enable the drum synth or plugin host, set BOTH " +
|
|
3597
|
+
"experimentalMode:true AND the specific sub-flag (e.g. experimentalDrumSynth:true) in one call. Other keys " +
|
|
3598
|
+
"include UI/library preferences — call get_state or inspect the returned object for the current set.",
|
|
3599
|
+
inputSchema: {
|
|
3600
|
+
settings: z
|
|
3601
|
+
.record(z.string(), z.any())
|
|
3602
|
+
.describe("Partial settings object to merge. Example: { experimentalMode: true, experimentalDrumSynth: true }"),
|
|
3603
|
+
},
|
|
3604
|
+
}, async ({ settings }) => {
|
|
3605
|
+
const check = await requireBridge();
|
|
3606
|
+
if (check !== true)
|
|
3607
|
+
return check;
|
|
3608
|
+
if (!settings || Object.keys(settings).length === 0)
|
|
3609
|
+
return error("Provide at least one settings key to update.");
|
|
3610
|
+
const merged = await bridge.send("applySettingsUpdate", settings);
|
|
3611
|
+
return json(merged);
|
|
3612
|
+
});
|
|
3613
|
+
server.registerTool("configure_transport", {
|
|
3614
|
+
title: "Configure Transport",
|
|
3615
|
+
description: "Configure transport behaviour beyond BPM (use set_bpm for tempo). Set the time signature, toggle host-sync " +
|
|
3616
|
+
"(when off, preset/user BPM overrides the host), toggle external clock sync, mute the audio output (standalone), " +
|
|
3617
|
+
"or release host-sync ownership for manual transport. All fields are optional — only the provided ones are applied.",
|
|
3618
|
+
inputSchema: {
|
|
3619
|
+
timeSigNumerator: z.number().int().min(1).max(32).optional().describe("Beats per bar. Requires timeSigDenominator."),
|
|
3620
|
+
timeSigDenominator: z.number().int().min(1).max(32).optional().describe("Beat unit. Requires timeSigNumerator."),
|
|
3621
|
+
hostSyncEnabled: z.boolean().optional().describe("When false, preset/user BPM overrides the host tempo."),
|
|
3622
|
+
externalClockSync: z.boolean().optional().describe("Sync the internal transport to external MIDI clock."),
|
|
3623
|
+
muteAudioOutput: z.boolean().optional().describe("Standalone only: mute the master output."),
|
|
3624
|
+
releaseHostSyncForManualTransport: z
|
|
3625
|
+
.boolean()
|
|
3626
|
+
.optional()
|
|
3627
|
+
.describe("When true, release host-sync ownership so the internal transport can be driven manually."),
|
|
3628
|
+
},
|
|
3629
|
+
}, async (args) => {
|
|
3630
|
+
const check = await requireBridge();
|
|
3631
|
+
if (check !== true)
|
|
3632
|
+
return check;
|
|
3633
|
+
const applied = [];
|
|
3634
|
+
const { timeSigNumerator, timeSigDenominator } = args;
|
|
3635
|
+
if ((timeSigNumerator === undefined) !== (timeSigDenominator === undefined))
|
|
3636
|
+
return error("timeSigNumerator and timeSigDenominator must be provided together.");
|
|
3637
|
+
if (timeSigNumerator !== undefined && timeSigDenominator !== undefined) {
|
|
3638
|
+
await bridge.send("updateTransportTimeSig", timeSigNumerator, timeSigDenominator);
|
|
3639
|
+
applied.push(`timeSig=${timeSigNumerator}/${timeSigDenominator}`);
|
|
3640
|
+
}
|
|
3641
|
+
if (args.hostSyncEnabled !== undefined) {
|
|
3642
|
+
await bridge.send("setHostSyncEnabled", args.hostSyncEnabled);
|
|
3643
|
+
applied.push(`hostSync=${args.hostSyncEnabled}`);
|
|
3644
|
+
}
|
|
3645
|
+
if (args.externalClockSync !== undefined) {
|
|
3646
|
+
await bridge.send("setExternalClockSync", args.externalClockSync);
|
|
3647
|
+
applied.push(`externalClock=${args.externalClockSync}`);
|
|
3648
|
+
}
|
|
3649
|
+
if (args.muteAudioOutput !== undefined) {
|
|
3650
|
+
await bridge.send("setMuteAudioOutput", args.muteAudioOutput);
|
|
3651
|
+
applied.push(`muteOutput=${args.muteAudioOutput}`);
|
|
3652
|
+
}
|
|
3653
|
+
if (args.releaseHostSyncForManualTransport) {
|
|
3654
|
+
await bridge.send("releaseHostSyncForManualTransport");
|
|
3655
|
+
applied.push("releasedHostSync");
|
|
3656
|
+
}
|
|
3657
|
+
if (applied.length === 0)
|
|
3658
|
+
return error("No transport fields provided.");
|
|
3659
|
+
return text(`Transport configured: ${applied.join(", ")}.`);
|
|
3660
|
+
});
|
|
3661
|
+
server.registerTool("get_audio_device_setup", {
|
|
3662
|
+
title: "Get Audio Device Setup",
|
|
3663
|
+
description: "Get the standalone audio/MIDI device setup (sample rate, buffer size, available and active input/output devices). " +
|
|
3664
|
+
"Returns null when running plugin-hosted (the DAW owns the device config). Use the device IDs/names from here with set_midi_io.",
|
|
3665
|
+
inputSchema: {},
|
|
3666
|
+
annotations: { readOnlyHint: true },
|
|
3667
|
+
}, async () => {
|
|
3668
|
+
const check = await requireBridge();
|
|
3669
|
+
if (check !== true)
|
|
3670
|
+
return check;
|
|
3671
|
+
const setup = await bridge.send("getAudioDeviceSetup");
|
|
3672
|
+
return json(setup);
|
|
3673
|
+
});
|
|
3674
|
+
server.registerTool("set_midi_io", {
|
|
3675
|
+
title: "Set MIDI I/O Devices",
|
|
3676
|
+
description: "Select MIDI input/output devices (standalone). Use get_audio_device_setup to discover device IDs first. " +
|
|
3677
|
+
"All fields optional.",
|
|
3678
|
+
inputSchema: {
|
|
3679
|
+
inputDeviceIds: z.array(z.string()).optional().describe("Enabled MIDI input device IDs (replaces the active set)."),
|
|
3680
|
+
outputDeviceId: z.string().optional().describe("MIDI output device ID."),
|
|
3681
|
+
outputEnabled: z.boolean().optional().describe("Enable/disable MIDI output."),
|
|
3682
|
+
},
|
|
3683
|
+
}, async (args) => {
|
|
3684
|
+
const check = await requireBridge();
|
|
3685
|
+
if (check !== true)
|
|
3686
|
+
return check;
|
|
3687
|
+
const applied = [];
|
|
3688
|
+
if (args.inputDeviceIds !== undefined) {
|
|
3689
|
+
await bridge.send("setMidiInputDevices", args.inputDeviceIds);
|
|
3690
|
+
applied.push(`inputs=${args.inputDeviceIds.length}`);
|
|
3691
|
+
}
|
|
3692
|
+
if (args.outputDeviceId !== undefined) {
|
|
3693
|
+
await bridge.send("setMidiOutputDevice", args.outputDeviceId);
|
|
3694
|
+
applied.push(`output=${args.outputDeviceId}`);
|
|
3695
|
+
}
|
|
3696
|
+
if (args.outputEnabled !== undefined) {
|
|
3697
|
+
await bridge.send("setMidiOutputEnabled", args.outputEnabled);
|
|
3698
|
+
applied.push(`outputEnabled=${args.outputEnabled}`);
|
|
3699
|
+
}
|
|
3700
|
+
if (applied.length === 0)
|
|
3701
|
+
return error("No MIDI I/O fields provided.");
|
|
3702
|
+
return text(`MIDI I/O set: ${applied.join(", ")}.`);
|
|
3703
|
+
});
|
|
3704
|
+
server.registerTool("set_midi_mapping", {
|
|
3705
|
+
title: "Set MIDI Action Mapping",
|
|
3706
|
+
description: "Configure the global MIDI-controller mappings that trigger PAM actions: variation switch, preset change, " +
|
|
3707
|
+
"looper record, live resample, and looper-variation switch. Set the enable flag, MIDI channel, CC number, " +
|
|
3708
|
+
"preset source, or bar-sync for each target. One field per call.",
|
|
3709
|
+
inputSchema: {
|
|
3710
|
+
target: z
|
|
3711
|
+
.enum(["variation", "preset", "looper", "resample", "looperVariation"])
|
|
3712
|
+
.describe("Which action's mapping to configure."),
|
|
3713
|
+
field: z
|
|
3714
|
+
.enum(["enabled", "channel", "ccNumber", "source", "barSync"])
|
|
3715
|
+
.describe("Field to set. 'source' is preset-only; 'barSync' is variation/looperVariation-only; " +
|
|
3716
|
+
"looper/resample have no source/barSync."),
|
|
3717
|
+
value: z
|
|
3718
|
+
.union([z.number(), z.boolean()])
|
|
3719
|
+
.describe("boolean for enabled/barSync; number for channel (1-16), ccNumber (0-127), or source."),
|
|
3720
|
+
},
|
|
3721
|
+
}, async ({ target, field, value }) => {
|
|
3722
|
+
const check = await requireBridge();
|
|
3723
|
+
if (check !== true)
|
|
3724
|
+
return check;
|
|
3725
|
+
await bridge.send("setMidiMapping", { target, field, value });
|
|
3726
|
+
return text(`MIDI mapping ${target}.${field} = ${value}.`);
|
|
3727
|
+
});
|
|
3728
|
+
server.registerTool("delete_preset", {
|
|
3729
|
+
title: "Delete Preset",
|
|
3730
|
+
description: "Delete a user preset file from disk. Factory presets are protected and cannot be deleted. " +
|
|
3731
|
+
"Use the relative path returned by list_presets.",
|
|
3732
|
+
inputSchema: {
|
|
3733
|
+
path: z.string().describe("Relative preset path (e.g. 'Presets/User/MyPreset.preset')."),
|
|
3734
|
+
},
|
|
3735
|
+
annotations: { destructiveHint: true },
|
|
3736
|
+
}, async ({ path: presetPath }) => {
|
|
3737
|
+
const check = await requireBridge();
|
|
3738
|
+
if (check !== true)
|
|
3739
|
+
return check;
|
|
3740
|
+
const result = await bridge.send("removePreset", presetPath);
|
|
3741
|
+
return json(result);
|
|
3742
|
+
});
|
|
3743
|
+
server.registerTool("set_preset_factory_status", {
|
|
3744
|
+
title: "Set Preset Factory Status",
|
|
3745
|
+
description: "Mark a preset as factory (read-only) or user (editable) on disk. Affects the factory flag and the file location.",
|
|
3746
|
+
inputSchema: {
|
|
3747
|
+
path: z.string().describe("Relative preset path."),
|
|
3748
|
+
makeFactory: z.boolean().describe("true → factory (read-only), false → user (editable)."),
|
|
3749
|
+
},
|
|
3750
|
+
}, async ({ path: presetPath, makeFactory }) => {
|
|
3751
|
+
const check = await requireBridge();
|
|
3752
|
+
if (check !== true)
|
|
3753
|
+
return check;
|
|
3754
|
+
const result = await bridge.send("togglePresetFactoryStatus", presetPath, makeFactory);
|
|
3755
|
+
return json(result);
|
|
3756
|
+
});
|
|
3757
|
+
server.registerTool("get_sample_usage", {
|
|
3758
|
+
title: "Get Sample Usage",
|
|
3759
|
+
description: "Cross-reference samples and presets. mode 'presetUsesSamples' lists the samples a preset references; " +
|
|
3760
|
+
"mode 'presetsUsingSample' lists presets that reference a given sample (or any sample under a folder).",
|
|
3761
|
+
inputSchema: {
|
|
3762
|
+
mode: z.enum(["presetUsesSamples", "presetsUsingSample"]),
|
|
3763
|
+
path: z
|
|
3764
|
+
.string()
|
|
3765
|
+
.describe("Relative preset path (presetUsesSamples) or relative sample path (presetsUsingSample)."),
|
|
3766
|
+
isFolder: z
|
|
3767
|
+
.boolean()
|
|
3768
|
+
.optional()
|
|
3769
|
+
.describe("presetsUsingSample only: treat 'path' as a folder and match any sample beneath it."),
|
|
3770
|
+
},
|
|
3771
|
+
annotations: { readOnlyHint: true },
|
|
3772
|
+
}, async ({ mode, path: relPath, isFolder }) => {
|
|
3773
|
+
const check = await requireBridge();
|
|
3774
|
+
if (check !== true)
|
|
3775
|
+
return check;
|
|
3776
|
+
const result = mode === "presetUsesSamples"
|
|
3777
|
+
? await bridge.send("getPresetSampleUsage", relPath)
|
|
3778
|
+
: await bridge.send("getPresetsUsingSample", relPath, isFolder ?? false);
|
|
3779
|
+
return json(result);
|
|
3780
|
+
});
|
|
3781
|
+
server.registerTool("repair_preset_samples", {
|
|
3782
|
+
title: "Repair Preset Sample Paths",
|
|
3783
|
+
description: "Fix broken sample references in presets. mode 'fix' auto-relinks moved/renamed samples by searching the " +
|
|
3784
|
+
"library (optionally scoped to one preset). mode 'rescan' re-audits a preset for still-missing samples and " +
|
|
3785
|
+
"reports them. Useful after moving a sample library or importing presets from another machine.",
|
|
3786
|
+
inputSchema: {
|
|
3787
|
+
mode: z.enum(["fix", "rescan"]),
|
|
3788
|
+
presetPath: z
|
|
3789
|
+
.string()
|
|
3790
|
+
.optional()
|
|
3791
|
+
.describe("Relative preset path. Omit for 'fix' to scan all presets; omit for 'rescan' to audit the active preset."),
|
|
3792
|
+
},
|
|
3793
|
+
}, async ({ mode, presetPath }) => {
|
|
3794
|
+
const check = await requireBridge();
|
|
3795
|
+
if (check !== true)
|
|
3796
|
+
return check;
|
|
3797
|
+
if (mode === "fix") {
|
|
3798
|
+
const result = await bridge.send("fixPresetSamplePaths", presetPath ?? "");
|
|
3799
|
+
return json(result);
|
|
3800
|
+
}
|
|
3801
|
+
await bridge.send("rescanMissingSamples", presetPath ?? "");
|
|
3802
|
+
return text(`Re-audited ${presetPath || "active preset"} for missing samples (results dispatched to UI state).`);
|
|
3803
|
+
});
|
|
3804
|
+
server.registerTool("preview_sample", {
|
|
3805
|
+
title: "Preview Sample",
|
|
3806
|
+
description: "Audition an audio file through PAM's native preview player without loading it into a cell. Pass filePath to " +
|
|
3807
|
+
"start previewing; pass stop:true to stop the current preview.",
|
|
3808
|
+
inputSchema: {
|
|
3809
|
+
filePath: z.string().optional().describe("Absolute or library-relative path to the audio file to preview."),
|
|
3810
|
+
stop: z.boolean().optional().describe("Stop the current preview instead of starting one."),
|
|
3811
|
+
},
|
|
3812
|
+
}, async ({ filePath, stop }) => {
|
|
3813
|
+
const check = await requireBridge();
|
|
3814
|
+
if (check !== true)
|
|
3815
|
+
return check;
|
|
3816
|
+
if (stop) {
|
|
3817
|
+
await bridge.send("stopPreviewNative");
|
|
3818
|
+
return text("Stopped sample preview.");
|
|
3819
|
+
}
|
|
3820
|
+
if (!filePath)
|
|
3821
|
+
return error("Provide filePath to preview, or stop:true to stop.");
|
|
3822
|
+
const ok = await bridge.send("previewSampleNative", filePath);
|
|
3823
|
+
return text(ok ? `Previewing ${basename(filePath)}.` : `Could not preview ${basename(filePath)} (file unreadable?).`);
|
|
3824
|
+
});
|
|
3825
|
+
// ============================================================
|
|
3826
|
+
// TOOLS — Plugin host (extra)
|
|
3827
|
+
// ============================================================
|
|
3828
|
+
server.registerTool("set_plugin_params", {
|
|
3829
|
+
title: "Set Hosted Plugin Params (batch)",
|
|
3830
|
+
description: "Set multiple hosted-plugin parameters in one call (more efficient than set_plugin_param per value). Values are " +
|
|
3831
|
+
"0..1 normalized. Use list_loaded_plugins to discover paramIndex values. role defaults to 'instrument'.",
|
|
3832
|
+
inputSchema: {
|
|
3833
|
+
cellId: z.number().int().min(1).describe("Cell number."),
|
|
3834
|
+
role: z.enum(["instrument", "effect"]).optional().describe("Which hosted slot (default 'instrument')."),
|
|
3835
|
+
values: z
|
|
3836
|
+
.array(z.object({ paramIndex: z.number().int().min(0), value: z.number().min(0).max(1) }))
|
|
3837
|
+
.min(1)
|
|
3838
|
+
.describe("Array of { paramIndex, value } where value is 0..1 normalized."),
|
|
3839
|
+
},
|
|
3840
|
+
}, async (args) => {
|
|
3841
|
+
const check = await requireBridge();
|
|
3842
|
+
if (check !== true)
|
|
3843
|
+
return check;
|
|
3844
|
+
const clamped = await clampCellId(args.cellId);
|
|
3845
|
+
if (typeof clamped === "string")
|
|
3846
|
+
return error(clamped);
|
|
3847
|
+
await bridge.send("setPluginParams", {
|
|
3848
|
+
cellId: clamped,
|
|
3849
|
+
role: args.role ?? "instrument",
|
|
3850
|
+
values: args.values,
|
|
3851
|
+
});
|
|
3852
|
+
return text(`Queued ${args.values.length} param(s) for cell ${clamped} (${args.role ?? "instrument"}).`);
|
|
3853
|
+
});
|
|
3854
|
+
server.registerTool("skip_current_plugin_scan", {
|
|
3855
|
+
title: "Skip Current Plugin Scan",
|
|
3856
|
+
description: "Skip (blocklist) the plugin currently being scanned and continue the scan. Use when a scan is stuck on a " +
|
|
3857
|
+
"crashing/hanging plugin (poll get_plugin_scan_status to detect a stall).",
|
|
3858
|
+
inputSchema: {},
|
|
3859
|
+
}, async () => {
|
|
3860
|
+
const check = await requireBridge();
|
|
3861
|
+
if (check !== true)
|
|
3862
|
+
return check;
|
|
3863
|
+
await bridge.send("skipCurrentPluginScan");
|
|
3864
|
+
return text("Skipped the current plugin and continued the scan.");
|
|
3865
|
+
});
|
|
3866
|
+
server.registerTool("remove_plugin_from_blocklist", {
|
|
3867
|
+
title: "Remove Plugin From Blocklist",
|
|
3868
|
+
description: "Un-blocklist a plugin that was blocklisted after a failed/crashed scan so it can be scanned and loaded again. " +
|
|
3869
|
+
"Pass the fileOrIdentifier as it appears in the blocklist (see list_plugins → blocklist).",
|
|
3870
|
+
inputSchema: {
|
|
3871
|
+
fileOrIdentifier: z.string().describe("The blocklisted plugin's file path or identifier."),
|
|
3872
|
+
},
|
|
3873
|
+
}, async ({ fileOrIdentifier }) => {
|
|
3874
|
+
const check = await requireBridge();
|
|
3875
|
+
if (check !== true)
|
|
3876
|
+
return check;
|
|
3877
|
+
await bridge.send("removeFromBlocklist", fileOrIdentifier);
|
|
3878
|
+
return text(`Removed ${fileOrIdentifier} from the plugin blocklist.`);
|
|
3879
|
+
});
|
|
3880
|
+
// ============================================================
|
|
3124
3881
|
// Start
|
|
3125
3882
|
// ============================================================
|
|
3126
3883
|
async function main() {
|