@map-audio/pam-mcp-server 1.21.0 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1 -1
- package/dist/audioAnalysis.d.ts +35 -0
- package/dist/audioAnalysis.js +277 -0
- package/dist/audioAnalysis.js.map +1 -0
- package/dist/bridge.d.ts +2 -0
- package/dist/bridge.js +113 -24
- package/dist/bridge.js.map +1 -1
- package/dist/corpusFingerprints.d.ts +17 -0
- package/dist/corpusFingerprints.js +10 -0
- package/dist/corpusFingerprints.js.map +1 -0
- package/dist/corpusRanges.d.ts +28 -0
- package/dist/corpusRanges.js +1029 -0
- package/dist/corpusRanges.js.map +1 -0
- package/dist/drumSynthEngines.json +376 -0
- package/dist/fingerprint.d.ts +70 -0
- package/dist/fingerprint.js +240 -0
- package/dist/fingerprint.js.map +1 -0
- package/dist/manifest-cell1.json +4972 -249
- package/dist/manifest-mfx.json +11408 -1788
- package/dist/manifest.d.ts +14 -2
- package/dist/manifest.js +12 -6
- package/dist/manifest.js.map +1 -1
- package/dist/manifest.json +23259 -399
- package/dist/mfxCombos.d.ts +45 -0
- package/dist/mfxCombos.js +499 -0
- package/dist/mfxCombos.js.map +1 -0
- package/dist/mixAssist.d.ts +6 -0
- package/dist/mixAssist.js +96 -11
- package/dist/mixAssist.js.map +1 -1
- package/dist/productProfile.d.ts +9 -0
- package/dist/productProfile.js +20 -0
- package/dist/productProfile.js.map +1 -0
- package/dist/server.js +1525 -255
- package/dist/server.js.map +1 -1
- package/package.json +9 -4
package/dist/server.js
CHANGED
|
@@ -4,27 +4,36 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
4
4
|
import { completable } from "@modelcontextprotocol/sdk/server/completable.js";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { buildVariationSnapshotBase } from "./generated/variationSnapshot.js";
|
|
7
|
+
import { CORPUS_META, CORPUS_RANGES } from "./corpusRanges.js";
|
|
8
|
+
import { CORPUS_FINGERPRINTS, CORPUS_NOVELTY_BASELINE, CORPUS_VARIATION_BASELINE } from "./corpusFingerprints.js";
|
|
9
|
+
import { buildFingerprint, scoreNovelty, similarity as fingerprintSimilarity } from "./fingerprint.js";
|
|
10
|
+
import { analyzeMotion, decodeWav } from "./audioAnalysis.js";
|
|
7
11
|
import { readFile } from "node:fs/promises";
|
|
8
12
|
import { existsSync, readFileSync } from "node:fs";
|
|
9
13
|
import { inflateSync } from "node:zlib";
|
|
10
|
-
import { basename, dirname, resolve } from "node:path";
|
|
14
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
11
15
|
import { fileURLToPath } from "node:url";
|
|
12
16
|
import { createDrumSynthCatalog, } from "./drumSynth.js";
|
|
13
|
-
import { loadManifest, cellEffectNames, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, setActiveProductHint, walkPresets, } from "./manifest.js";
|
|
17
|
+
import { loadManifest, cellEffectNames, cellEffectOrder, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, setActiveProductHint, walkPresets, } from "./manifest.js";
|
|
14
18
|
import { PluginBridge } from "./bridge.js";
|
|
15
19
|
import { buildMixMeasurementFromState, currentFlatFromState, parseMixAnalysisPayload, suggestMix, } from "./mixAssist.js";
|
|
16
20
|
import { normalizePresetLookupPath, selectSaveVariationSourcePath, } from "./presetSave.js";
|
|
17
21
|
import { STOP_SEQUENCES, buildGenerationPrompt, parseFunctionCalls, renderContextLine, renderFunctionCall, renderFunctionResponse, truncateAtFabricatedResponse, withContext, } from "./fgemma-format.js";
|
|
22
|
+
import { mcpProductProfile } from "./productProfile.js";
|
|
23
|
+
import { MFX_COMBO_COUNT, MFX_COMBO_MANUAL_STRENGTH, buildMfxComboTarget, isMfxComboTargetParameter, mfxComboTargetValueFromCurrent, parseMfxComboSlots, reanchorMfxComboTarget, resolveMfxCombos, setMfxComboTargetStop, } from "./mfxCombos.js";
|
|
18
24
|
// --- State ---
|
|
19
25
|
// Variation slot count. Must stay in sync with PRESET_VARIATION_SLOTS in
|
|
20
26
|
// interface/src/stores/Preset.ts. Defined locally because the MCP package
|
|
21
27
|
// can't import from the interface workspace without infra changes.
|
|
22
28
|
const PRESET_VARIATION_SLOTS = 8;
|
|
29
|
+
const SERVER_PRODUCT = mcpProductProfile();
|
|
30
|
+
const IS_MFX_SERVER = SERVER_PRODUCT.id === "mfx";
|
|
23
31
|
const bridge = new PluginBridge();
|
|
24
32
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
25
33
|
const cellModeChangeSeq = {};
|
|
26
34
|
let lastSavedPresetName;
|
|
27
35
|
let lastSavedPresetPath;
|
|
36
|
+
let activeLiveResample;
|
|
28
37
|
// --- Helpers ---
|
|
29
38
|
function text(s) {
|
|
30
39
|
return { content: [{ type: "text", text: s }] };
|
|
@@ -102,6 +111,9 @@ function validateCellIdForManifest(cellId, manifest) {
|
|
|
102
111
|
return true;
|
|
103
112
|
}
|
|
104
113
|
function readPackageVersion() {
|
|
114
|
+
const override = IS_MFX_SERVER ? process.env.MFX_MCP_SERVER_VERSION?.trim() : undefined;
|
|
115
|
+
if (override)
|
|
116
|
+
return override;
|
|
105
117
|
try {
|
|
106
118
|
const raw = readFileSync(resolve(PACKAGE_ROOT, "package.json"), "utf-8");
|
|
107
119
|
const pkg = JSON.parse(raw);
|
|
@@ -122,9 +134,9 @@ async function handleCliArgs() {
|
|
|
122
134
|
}
|
|
123
135
|
if (args.has("--help") || args.has("-h")) {
|
|
124
136
|
const version = SERVER_VERSION;
|
|
125
|
-
console.log(
|
|
126
|
-
|
|
127
|
-
|
|
137
|
+
console.log(`${SERVER_PRODUCT.command} ${version}\n\n` +
|
|
138
|
+
`MCP stdio server for ${SERVER_PRODUCT.name}. Configure your MCP client to launch this command; ` +
|
|
139
|
+
`it reads the local bridge registry to find a running ${SERVER_PRODUCT.name} instance.`);
|
|
128
140
|
return true;
|
|
129
141
|
}
|
|
130
142
|
return false;
|
|
@@ -134,21 +146,56 @@ async function handleCliArgs() {
|
|
|
134
146
|
function syncProductHint() {
|
|
135
147
|
setActiveProductHint(bridge.getConnectedInstance()?.product);
|
|
136
148
|
}
|
|
149
|
+
function resolveServerMfxCombos(state, manifest) {
|
|
150
|
+
const instance = bridge.getConnectedInstance();
|
|
151
|
+
const cacheKey = instance?.instanceId ?? (instance ? `${instance.product}:${instance.port}` : manifest.bundleId);
|
|
152
|
+
return resolveMfxCombos(state, manifest, cacheKey);
|
|
153
|
+
}
|
|
137
154
|
async function requireBridge() {
|
|
138
155
|
if (bridge.isConnected())
|
|
139
156
|
return true;
|
|
140
157
|
const ok = await bridge.connect();
|
|
141
158
|
if (!ok) {
|
|
142
159
|
const pin = process.env.PAM_BRIDGE_PRODUCT;
|
|
143
|
-
return error(
|
|
144
|
-
|
|
160
|
+
return error(`Not connected to ${SERVER_PRODUCT.name}. Start ${SERVER_PRODUCT.name} (standalone or in a DAW) with MCP bridge enabled. ` +
|
|
161
|
+
(IS_MFX_SERVER
|
|
162
|
+
? "The launcher finds MFX through the local bridge registry; the plugin records its actual port on startup."
|
|
163
|
+
: "The bridge writes its port to ~/.pam-mcp-bridge.json on startup.") +
|
|
145
164
|
(pin
|
|
146
|
-
?
|
|
165
|
+
? IS_MFX_SERVER
|
|
166
|
+
? " The MFX launcher is product-pinned, but no matching instance was found."
|
|
167
|
+
: ` Note: PAM_BRIDGE_PRODUCT pins this server to '${pin}' — no matching instance was found (connection_status lists what is running).`
|
|
147
168
|
: ""));
|
|
148
169
|
}
|
|
149
170
|
syncProductHint();
|
|
150
171
|
return true;
|
|
151
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* requireBridge() plus product-aware cell bounds, for tools that ADDRESS a cell.
|
|
175
|
+
*
|
|
176
|
+
* The zod schemas cap cellId at 8 because they are built before we know which
|
|
177
|
+
* product is on the other end. Cell1 has 1 cell and MFX has 4, so without this
|
|
178
|
+
* an agent aims at cell 7 and gets a bridge-level failure or a silent no-op
|
|
179
|
+
* instead of being told the cell does not exist. Read filters (get_state,
|
|
180
|
+
* get_design_overview) deliberately stay unguarded — returning only the cells
|
|
181
|
+
* that exist is the right answer there. Manifests are cached, so this is
|
|
182
|
+
* effectively free after the first call.
|
|
183
|
+
*/
|
|
184
|
+
async function requireBridgeForCells(...cellIds) {
|
|
185
|
+
const check = await requireBridge();
|
|
186
|
+
if (check !== true)
|
|
187
|
+
return check;
|
|
188
|
+
const ids = cellIds.flat().filter((c) => typeof c === "number");
|
|
189
|
+
if (ids.length === 0)
|
|
190
|
+
return true;
|
|
191
|
+
const manifest = await loadManifestForProductGuard();
|
|
192
|
+
for (const id of new Set(ids)) {
|
|
193
|
+
const cellCheck = validateCellIdForManifest(id, manifest);
|
|
194
|
+
if (cellCheck !== true)
|
|
195
|
+
return cellCheck;
|
|
196
|
+
}
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
152
199
|
/**
|
|
153
200
|
* Parse the PAM binary preset format: <metadataSize as text int><metadata JSON><data JSON>
|
|
154
201
|
*/
|
|
@@ -261,17 +308,23 @@ function buildVariationString(state) {
|
|
|
261
308
|
* so the save result can say the snapshot is lossy.
|
|
262
309
|
*/
|
|
263
310
|
async function fetchSaveSnapshot() {
|
|
311
|
+
let state;
|
|
312
|
+
let canonical = false;
|
|
264
313
|
try {
|
|
265
314
|
const snap = await bridge.send("getSaveSnapshot");
|
|
266
315
|
if (snap && typeof snap === "object") {
|
|
267
|
-
|
|
316
|
+
state = snap;
|
|
317
|
+
canonical = true;
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
state = (await bridge.send("getState"));
|
|
268
321
|
}
|
|
269
322
|
}
|
|
270
323
|
catch {
|
|
271
324
|
// Older PAM: action not registered. Fall through.
|
|
325
|
+
state = (await bridge.send("getState"));
|
|
272
326
|
}
|
|
273
|
-
|
|
274
|
-
return { state, canonical: false };
|
|
327
|
+
return { state, canonical };
|
|
275
328
|
}
|
|
276
329
|
/** Cells whose hosted plugin this snapshot cannot fully capture. */
|
|
277
330
|
function hostedPluginCells(state) {
|
|
@@ -316,14 +369,9 @@ function formatParam(p) {
|
|
|
316
369
|
return desc;
|
|
317
370
|
}
|
|
318
371
|
const FULL_STATE_RESPONSE_LIMIT_CHARS = 24_000;
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
// limiter, qverb, eq8, panner, gain, vocoder (and MFX's wavefold/resonator)
|
|
323
|
-
// were enabled in state but absent from `enabledEffects`.
|
|
324
|
-
const CELL_EFFECTS_FALLBACK = [
|
|
325
|
-
"filter", "delay", "crusher", "flanger", "eq", "fdn", "gate", "compressor",
|
|
326
|
-
];
|
|
372
|
+
// Preset listings grow with every installed pack; cap the unfiltered dump so it
|
|
373
|
+
// can't crowd out the agent's context (`search` narrows it).
|
|
374
|
+
const LIST_PRESETS_MAX = 400;
|
|
327
375
|
const PATTERN_LANES = {
|
|
328
376
|
note: "midiSequence",
|
|
329
377
|
pitch: "pitchSequence",
|
|
@@ -339,6 +387,10 @@ const LFO_DIVISIONS = [
|
|
|
339
387
|
"1/32D", "1/32", "1/32T",
|
|
340
388
|
"1/64D", "1/64", "1/64T",
|
|
341
389
|
];
|
|
390
|
+
// Index/enum params. derive-corpus-ranges.mjs skips them (a percentile over an
|
|
391
|
+
// index means nothing), so design_context must not read their missing corpus
|
|
392
|
+
// row as "no preset ever used this". Keep in sync with ENUMISH in that script.
|
|
393
|
+
const CORPUS_ENUM_PARAMS = /^(mode|filterType|arpegMode|gateProgram|synthEngine|outputMode|outputIndex|crossfadeAssign|grainWindow|grainArp|fmSource|fmPolarity|gainClipMode|patternDirection|quantRoot|quantScale|.*SyncEnabled|.*Enabled)$/;
|
|
342
394
|
function levenshtein(a, b) {
|
|
343
395
|
const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
344
396
|
const curr = Array(b.length + 1).fill(0);
|
|
@@ -526,55 +578,22 @@ function stemName(path) {
|
|
|
526
578
|
}
|
|
527
579
|
// --- Server ---
|
|
528
580
|
const server = new McpServer({
|
|
529
|
-
name:
|
|
530
|
-
title:
|
|
581
|
+
name: SERVER_PRODUCT.id,
|
|
582
|
+
title: SERVER_PRODUCT.serverTitle,
|
|
531
583
|
version: SERVER_VERSION,
|
|
532
584
|
}, {
|
|
533
|
-
instructions:
|
|
534
|
-
"
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
"
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
"Tools added in server version 1.7.0 — update_settings, configure_transport, " +
|
|
546
|
-
"get_audio_device_setup, set_midi_io, set_midi_mapping, delete_preset, " +
|
|
547
|
-
"set_preset_factory_status, get_sample_usage, repair_preset_samples, preview_sample, " +
|
|
548
|
-
"set_plugin_params, skip_current_plugin_scan, remove_plugin_from_blocklist — require PAM ≥ 1.7.0. " +
|
|
549
|
-
"VX video tools (get_video_state, video_command, add_video_layer, update_video_layer, " +
|
|
550
|
-
"remove_video_layer, apply_video_layout, add_video_fx, set_video_fx, remove_video_fx, " +
|
|
551
|
-
"bind_video_param, set_video_settings, set_video_layer_source) require PAM ≥ 1.8.0 AND PAM's window to be OPEN " +
|
|
552
|
-
"(the video engine renders in the WebView; there is no headless renderer). " +
|
|
553
|
-
"capture_ui, set_visual, set_drum_synth_wave, and randomize with target 'synth' are " +
|
|
554
|
-
"WebView-routed too and need the window open for the same reason. For " +
|
|
555
|
-
"audio-reactive control, note the visual audio-react params (visualWaveDisplace, etc.) " +
|
|
556
|
-
"are regular plugin params — drive them with set_parameters; layers/FX/bindings go through " +
|
|
557
|
-
"the video tools. When building a scene, call get_video_state first to learn layer/fx ids. " +
|
|
558
|
-
"AI model lifecycle tools (download_model, get_model_status, load_model, unload_model, " +
|
|
559
|
-
"delete_model, list_models; engines aigen|magenta|notochord) require PAM ≥ 1.9.0 plus the " +
|
|
560
|
-
"matching experimental flags; load/unload/delete can block for seconds — poll " +
|
|
561
|
-
"get_model_status rather than tight-looping. " +
|
|
562
|
-
"Earlier tool sets (looper, song mode, modulation edits, MIDI recording, sample analysis, " +
|
|
563
|
-
"live resample, variation scheduling, AI generation) require PAM ≥ 1.5.0. " +
|
|
564
|
-
"SONG WORKFLOW: author sections as variation slots (save_variation), arrange them with " +
|
|
565
|
-
"song_mode (op setArrangement authors real Song Mode grid nodes and can pin a VX variation " +
|
|
566
|
-
"slot per section via vxVariationIndex; op setSequence is the older headless raw-step path), " +
|
|
567
|
-
"manage visual variation slots with vx_variation (save/load/clear/fill — the VX siblings of " +
|
|
568
|
-
"save_variation), then bounce with export_song (includeVideo:true films the VX scene with " +
|
|
569
|
-
"variation follow live). song_mode setArrangement/get/assignVx, vx_variation, and " +
|
|
570
|
-
"export_song includeVideo are WebView-routed (window open, PAM ≥ 1.5.7). " +
|
|
571
|
-
"If a tool fails with \"PAM build does not support 'X'\", the connected plugin is older than " +
|
|
572
|
-
"this MCP server; the user must update PAM (updating the npm package alone is not enough). " +
|
|
573
|
-
"Tool annotations are accurate: readOnlyHint tools never mutate the plugin, destructiveHint " +
|
|
574
|
-
"tools discard user state (confirm intent before calling them). When several product " +
|
|
575
|
-
"instances run at once (PAM + MFX), select_instance chooses which one the live tools drive " +
|
|
576
|
-
"(PAM_BRIDGE_PRODUCT pins it at launch). Guided workflow prompts are available: build-a-beat, " +
|
|
577
|
-
"make-a-song, design-a-preset, mix-and-master, troubleshoot-bridge.",
|
|
585
|
+
instructions: IS_MFX_SERVER
|
|
586
|
+
? "MFX is a four-cell modular FX router. Start with design_context and get_design_overview; " +
|
|
587
|
+
"use set_mfx_routing for cables, set_effect_order for each chain, and set_parameters or " +
|
|
588
|
+
"update_effect_config to shape effects. Use cells 1-4 and save finished work with save_preset. " +
|
|
589
|
+
"Treat tool annotations as authoritative and use select_instance when several products are running."
|
|
590
|
+
: "PAM is an eight-cell sampler. Start read and design work with design_context and " +
|
|
591
|
+
"get_design_overview; filter get_state with section, keys, or cellIds. For samples, call " +
|
|
592
|
+
"get_default_locations before list_samples and load only returned paths; never guess library paths. " +
|
|
593
|
+
"Sequencer output requires a running transport. UI, video, and visual tools require the plugin " +
|
|
594
|
+
"window open; experimental plugin-host and AI tools require experimentalMode plus the matching " +
|
|
595
|
+
"feature flag. Treat tool annotations as authoritative and use select_instance when several " +
|
|
596
|
+
"products are running. A 'build does not support' error means update the plugin, not only this server.",
|
|
578
597
|
});
|
|
579
598
|
// --- tool annotation policy ---------------------------------------------------
|
|
580
599
|
// Single audit surface for MCP tool annotations. Every tool MUST have an entry;
|
|
@@ -598,6 +617,9 @@ const TOOL_ANNOTATIONS = {
|
|
|
598
617
|
read_preset: RO,
|
|
599
618
|
get_manifest_summary: RO,
|
|
600
619
|
design_context: RO,
|
|
620
|
+
preset_novelty: RO,
|
|
621
|
+
variation_distinctness: RO,
|
|
622
|
+
analyze_motion: W,
|
|
601
623
|
get_state: RO,
|
|
602
624
|
get_design_overview: RO,
|
|
603
625
|
get_transport: RO,
|
|
@@ -611,6 +633,7 @@ const TOOL_ANNOTATIONS = {
|
|
|
611
633
|
capture_ui: RO, // writes a screenshot file, mutates no plugin state
|
|
612
634
|
get_sample_peaks: RO,
|
|
613
635
|
get_audio_device_setup: RO,
|
|
636
|
+
get_memory_report: RO,
|
|
614
637
|
get_sample_usage: RO,
|
|
615
638
|
get_model_status: RO,
|
|
616
639
|
list_models: RO,
|
|
@@ -698,7 +721,7 @@ const TOOL_ANNOTATIONS = {
|
|
|
698
721
|
looper_settings: WI,
|
|
699
722
|
looper_variation: W,
|
|
700
723
|
looper_variation_sync: WI,
|
|
701
|
-
live_resample:
|
|
724
|
+
live_resample: D, // finalize replaces the destination cell; finalize:false discards the take
|
|
702
725
|
// Song mode / export
|
|
703
726
|
song_mode: W,
|
|
704
727
|
export_song: W,
|
|
@@ -707,6 +730,9 @@ const TOOL_ANNOTATIONS = {
|
|
|
707
730
|
update_settings: WI,
|
|
708
731
|
set_audio_device: WI,
|
|
709
732
|
set_mfx_routing: WI,
|
|
733
|
+
set_mfx_edge_gain: WI,
|
|
734
|
+
configure_mfx_combo: WI,
|
|
735
|
+
set_effect_order: WI,
|
|
710
736
|
set_midi_io: WI,
|
|
711
737
|
set_midi_mapping: WI,
|
|
712
738
|
// Plugin host
|
|
@@ -755,18 +781,116 @@ const TOOL_ANNOTATIONS = {
|
|
|
755
781
|
ask_assistant: D, // executes arbitrary model-chosen tool calls
|
|
756
782
|
};
|
|
757
783
|
const internalTools = new Map();
|
|
784
|
+
// The shared implementation contains sampler, song, video, AI, hosted-
|
|
785
|
+
// instrument, and MFX operations. A dedicated MFX launcher must not advertise
|
|
786
|
+
// sampler workflows merely because their handlers live in this file: tool
|
|
787
|
+
// discovery is part of the product contract, not an implementation inventory.
|
|
788
|
+
const MFX_TOOL_NAMES = new Set([
|
|
789
|
+
"get_parameter_info",
|
|
790
|
+
"list_presets",
|
|
791
|
+
"read_preset",
|
|
792
|
+
"get_manifest_summary",
|
|
793
|
+
"design_context",
|
|
794
|
+
"variation_distinctness",
|
|
795
|
+
"analyze_motion",
|
|
796
|
+
"get_state",
|
|
797
|
+
"get_design_overview",
|
|
798
|
+
"get_transport",
|
|
799
|
+
"connection_status",
|
|
800
|
+
"get_default_locations",
|
|
801
|
+
"capture_ui",
|
|
802
|
+
"get_memory_report",
|
|
803
|
+
"set_parameters",
|
|
804
|
+
"add_modulation",
|
|
805
|
+
"update_modulation",
|
|
806
|
+
"clear_modulations",
|
|
807
|
+
"update_effect_config",
|
|
808
|
+
"update_cell_config",
|
|
809
|
+
"set_cell_mute",
|
|
810
|
+
"set_master_effects",
|
|
811
|
+
"add_param_seq",
|
|
812
|
+
"update_param_seq",
|
|
813
|
+
"remove_param_seq",
|
|
814
|
+
"stop_cell",
|
|
815
|
+
"reset_cell",
|
|
816
|
+
"copy_cell",
|
|
817
|
+
"start_transport",
|
|
818
|
+
"stop_transport",
|
|
819
|
+
"full_stop_transport",
|
|
820
|
+
"set_bpm",
|
|
821
|
+
"set_metronome",
|
|
822
|
+
"configure_transport",
|
|
823
|
+
"load_preset",
|
|
824
|
+
"save_preset",
|
|
825
|
+
"new_preset",
|
|
826
|
+
"import_preset",
|
|
827
|
+
"export_preset",
|
|
828
|
+
"update_preset_metadata",
|
|
829
|
+
"delete_preset",
|
|
830
|
+
"set_preset_factory_status",
|
|
831
|
+
"load_variation",
|
|
832
|
+
"save_variation",
|
|
833
|
+
"queue_variation",
|
|
834
|
+
"cancel_queued_variation",
|
|
835
|
+
"update_variations",
|
|
836
|
+
"variation_hold",
|
|
837
|
+
"export_audio",
|
|
838
|
+
"update_settings",
|
|
839
|
+
"set_audio_device",
|
|
840
|
+
"set_mfx_routing",
|
|
841
|
+
"set_mfx_edge_gain",
|
|
842
|
+
"configure_mfx_combo",
|
|
843
|
+
"set_effect_order",
|
|
844
|
+
"set_midi_io",
|
|
845
|
+
"set_midi_mapping",
|
|
846
|
+
"select_instance",
|
|
847
|
+
]);
|
|
848
|
+
function mfxSurfaceText(value) {
|
|
849
|
+
return value
|
|
850
|
+
.replaceAll("PAM/Cell1-only", "sampler-product-only")
|
|
851
|
+
.replaceAll("PAM/Cell1", "sampler products")
|
|
852
|
+
.replaceAll("PAM-only", "sampler-product-only")
|
|
853
|
+
.replaceAll("PAM's", "MFX's")
|
|
854
|
+
.replaceAll("PAM", "MFX");
|
|
855
|
+
}
|
|
856
|
+
function mfxToolConfig(name, config) {
|
|
857
|
+
const source = config;
|
|
858
|
+
const inputEntries = Object.entries(source.inputSchema ?? {}).filter(([key]) => (name !== "update_cell_config" || key === "cellId" || key === "sidechainFrom") &&
|
|
859
|
+
(name !== "select_instance" || key !== "product"));
|
|
860
|
+
const inputSchema = source.inputSchema
|
|
861
|
+
? Object.fromEntries(inputEntries.map(([key, schema]) => [
|
|
862
|
+
key,
|
|
863
|
+
schema.description ? schema.describe(mfxSurfaceText(schema.description)) : schema,
|
|
864
|
+
]))
|
|
865
|
+
: undefined;
|
|
866
|
+
const description = name === "select_instance"
|
|
867
|
+
? "Choose which running MFX instance the live tools drive. Match by instanceId or port from connection_status. The launcher refuses non-MFX instances."
|
|
868
|
+
: name === "update_cell_config"
|
|
869
|
+
? "Set one MFX cell's sidechain source. A non-null source also enables that cell's compressor."
|
|
870
|
+
: source.description ? mfxSurfaceText(source.description) : undefined;
|
|
871
|
+
return {
|
|
872
|
+
...source,
|
|
873
|
+
...(description ? { description } : {}),
|
|
874
|
+
...(inputSchema ? { inputSchema } : {}),
|
|
875
|
+
};
|
|
876
|
+
}
|
|
758
877
|
{
|
|
759
878
|
const origRegisterTool = server.registerTool.bind(server);
|
|
760
879
|
server.registerTool = ((name, config, handler) => {
|
|
761
|
-
|
|
880
|
+
// Registration handles are never retained by this module; omitted product
|
|
881
|
+
// tools therefore have no runtime handle to return.
|
|
882
|
+
if (IS_MFX_SERVER && !MFX_TOOL_NAMES.has(name))
|
|
883
|
+
return undefined;
|
|
884
|
+
const productConfig = (IS_MFX_SERVER ? mfxToolConfig(name, config) : config);
|
|
885
|
+
internalTools.set(name, { shape: productConfig.inputSchema, handler });
|
|
762
886
|
// Merge the central annotation policy. Inline annotations at the call site
|
|
763
887
|
// win per key; openWorldHint is false server-wide (local plugin only).
|
|
764
|
-
const cfg =
|
|
888
|
+
const cfg = productConfig;
|
|
765
889
|
if (!TOOL_ANNOTATIONS[name]) {
|
|
766
|
-
console.error(`[
|
|
890
|
+
console.error(`[${SERVER_PRODUCT.command}] tool '${name}' has no TOOL_ANNOTATIONS entry — classify it (check-tool-surface fails on this)`);
|
|
767
891
|
}
|
|
768
892
|
cfg.annotations = { openWorldHint: false, ...TOOL_ANNOTATIONS[name], ...cfg.annotations };
|
|
769
|
-
return origRegisterTool(name,
|
|
893
|
+
return origRegisterTool(name, productConfig, handler);
|
|
770
894
|
});
|
|
771
895
|
}
|
|
772
896
|
async function callInternalTool(name, args) {
|
|
@@ -892,7 +1016,20 @@ server.registerTool("list_presets", {
|
|
|
892
1016
|
}
|
|
893
1017
|
if (allPresets.length === 0)
|
|
894
1018
|
return text("No presets found.");
|
|
895
|
-
|
|
1019
|
+
// A pack-heavy library runs to thousands of entries and would swamp the
|
|
1020
|
+
// agent's context. `search` is the narrowing control — it is already in the
|
|
1021
|
+
// FunctionGemma toolpack schema, so this stays an output-side cap and the
|
|
1022
|
+
// inputSchema does not drift. The plain array shape is preserved unless the
|
|
1023
|
+
// cap actually bites, so normal libraries see exactly what they saw before.
|
|
1024
|
+
const entries = allPresets.map((p) => ({ name: p.name, path: p.relativePath }));
|
|
1025
|
+
if (entries.length <= LIST_PRESETS_MAX)
|
|
1026
|
+
return json(entries);
|
|
1027
|
+
return json({
|
|
1028
|
+
presets: entries.slice(0, LIST_PRESETS_MAX),
|
|
1029
|
+
total: entries.length,
|
|
1030
|
+
truncated: entries.length - LIST_PRESETS_MAX,
|
|
1031
|
+
hint: "Pass `search` to narrow the list — only the first entries are shown.",
|
|
1032
|
+
});
|
|
896
1033
|
});
|
|
897
1034
|
server.registerTool("read_preset", {
|
|
898
1035
|
title: "Read Preset",
|
|
@@ -974,8 +1111,11 @@ server.registerTool("get_manifest_summary", {
|
|
|
974
1111
|
});
|
|
975
1112
|
server.registerTool("design_context", {
|
|
976
1113
|
title: "Design Context",
|
|
977
|
-
description: "Compact parameter-design cheat sheet for agents:
|
|
978
|
-
"
|
|
1114
|
+
description: "Compact parameter-design cheat sheet for agents: the product's global controls and full per-cell effect vocabulary, " +
|
|
1115
|
+
"value bands measured from the " +
|
|
1116
|
+
"shipped factory presets, an underexplored-parameter list, macro targets, unit traps, LFO/delay division " +
|
|
1117
|
+
"tables, FX chain order, MIDI cell note map, and variation slot semantics. The bands describe where " +
|
|
1118
|
+
"shipped presets sit — they are not limits, and the distinctive end of a range is usually outside them.",
|
|
979
1119
|
inputSchema: {
|
|
980
1120
|
cellId: z
|
|
981
1121
|
.number()
|
|
@@ -984,13 +1124,74 @@ server.registerTool("design_context", {
|
|
|
984
1124
|
.max(8)
|
|
985
1125
|
.default(1)
|
|
986
1126
|
.describe("Cell number to use for per-cell example param IDs. Defaults to cell 1."),
|
|
1127
|
+
groups: z
|
|
1128
|
+
.array(z.string())
|
|
1129
|
+
.optional()
|
|
1130
|
+
.describe("Narrow keyPerCellParams to these groups (e.g. ['delay','vocoder']) on a follow-up call. " +
|
|
1131
|
+
"Omit on the FIRST call — the full vocabulary is the point, and designing from a narrowed " +
|
|
1132
|
+
"subset is what makes presets resemble each other."),
|
|
987
1133
|
},
|
|
988
1134
|
annotations: { readOnlyHint: true },
|
|
989
|
-
}, async ({ cellId }) => {
|
|
1135
|
+
}, async ({ cellId, groups: groupFilter }) => {
|
|
990
1136
|
const manifest = await loadManifest();
|
|
1137
|
+
const cellCheck = validateCellIdForManifest(cellId, manifest);
|
|
1138
|
+
if (cellCheck !== true)
|
|
1139
|
+
return cellCheck;
|
|
991
1140
|
const params = manifest.parameters;
|
|
1141
|
+
const defaultEffectOrder = cellEffectOrder(manifest);
|
|
992
1142
|
const byId = new Map(params.map((p) => [p.paramId, p]));
|
|
993
1143
|
const example = (suffix) => byId.get(`cell${cellId}_${suffix}`)?.paramId ?? `cell${cellId}_${suffix}`;
|
|
1144
|
+
if (IS_MFX_SERVER) {
|
|
1145
|
+
const requestedGroups = groupFilter?.length ? new Set(groupFilter) : null;
|
|
1146
|
+
const effectGroups = Object.fromEntries(cellEffectNames(manifest).filter((effect) => !requestedGroups || requestedGroups.has(effect)).map((effect) => [
|
|
1147
|
+
effect,
|
|
1148
|
+
params
|
|
1149
|
+
.filter((parameter) => parameter.cell === `cell${cellId}` && parameter.effect === effect)
|
|
1150
|
+
.map((parameter) => parameter.paramId),
|
|
1151
|
+
]));
|
|
1152
|
+
return json({
|
|
1153
|
+
product: manifest.name,
|
|
1154
|
+
architecture: "Four effect cells connected by a replaceable cable graph.",
|
|
1155
|
+
cells: [1, 2, 3, 4],
|
|
1156
|
+
routing: {
|
|
1157
|
+
tool: "set_mfx_routing",
|
|
1158
|
+
gainTool: "set_mfx_edge_gain",
|
|
1159
|
+
ports: {
|
|
1160
|
+
sources: ["input", "sidechain", "cell1..cell4", "split_lo", "split_mid", "split_hi"],
|
|
1161
|
+
destinations: ["cell1..cell4", "split", "output"],
|
|
1162
|
+
},
|
|
1163
|
+
serialExample: [
|
|
1164
|
+
{ from: "input", to: "cell1" },
|
|
1165
|
+
{ from: "cell1", to: "cell2" },
|
|
1166
|
+
{ from: "cell2", to: "cell3" },
|
|
1167
|
+
{ from: "cell3", to: "cell4" },
|
|
1168
|
+
{ from: "cell4", to: "output" },
|
|
1169
|
+
],
|
|
1170
|
+
splitParameters: ["mfx_split_lo_hz", "mfx_split_hi_hz"],
|
|
1171
|
+
},
|
|
1172
|
+
combos: {
|
|
1173
|
+
tool: "configure_mfx_combo",
|
|
1174
|
+
parameters: ["mfx_combo1", "mfx_combo2", "mfx_combo3"],
|
|
1175
|
+
roles: ["Morph", "Color", "Motion"],
|
|
1176
|
+
note: "The tool resolves automatic assignments, edits targets and labels, locks assignments, and fans combo values out while the MFX window is closed.",
|
|
1177
|
+
},
|
|
1178
|
+
globalParameters: params
|
|
1179
|
+
.filter((parameter) => !parameter.cell && parameter.paramId.startsWith("mfx_"))
|
|
1180
|
+
.map((parameter) => parameter.paramId),
|
|
1181
|
+
effectOrder: {
|
|
1182
|
+
tool: "set_effect_order",
|
|
1183
|
+
paramId: `cell${cellId}_effectOrder`,
|
|
1184
|
+
defaultOrder: defaultEffectOrder,
|
|
1185
|
+
},
|
|
1186
|
+
keyPerCellParams: effectGroups,
|
|
1187
|
+
parameterRanges: "Call get_parameter_info with a paramId above for its exact range, default, unit, and enum labels.",
|
|
1188
|
+
modulation: {
|
|
1189
|
+
tools: ["add_modulation", "update_modulation", "clear_modulations", "add_param_seq"],
|
|
1190
|
+
note: "Target the cell effect parameters above or the three combo parameters.",
|
|
1191
|
+
},
|
|
1192
|
+
variationSlots: "API indices are 0..7.",
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
994
1195
|
const macroTargetsByEffect = {};
|
|
995
1196
|
for (const p of params.filter((param) => param.paramId.startsWith("macro_") && param.isModulatable)) {
|
|
996
1197
|
const key = p.effect ?? "global";
|
|
@@ -1014,39 +1215,131 @@ server.registerTool("design_context", {
|
|
|
1014
1215
|
["1/32", 18, 0.818],
|
|
1015
1216
|
["1/64", 21, 0.955],
|
|
1016
1217
|
].map(([division, index, value]) => ({ division, index, lfoRate: value }));
|
|
1218
|
+
// Terse corpus band for one param suffix. Reports where the shipped
|
|
1219
|
+
// presets actually sit AND how much of the legal span they leave unused —
|
|
1220
|
+
// the second number is the one that matters when the brief is "make it
|
|
1221
|
+
// sound like nothing we've shipped".
|
|
1222
|
+
const band = (suffix) => {
|
|
1223
|
+
const r = CORPUS_RANGES[suffix];
|
|
1224
|
+
if (!r)
|
|
1225
|
+
return undefined;
|
|
1226
|
+
const headroom = r.spanUsedPct < 60 ? ` — corpus only visits ${r.spanUsedPct}% of the legal span` : "";
|
|
1227
|
+
return `typical ${r.p10}..${r.p90} (median ${r.p50}); corpus ${r.lo}..${r.hi}; legal ${r.legal[0]}..${r.legal[1]}; default ${r.def}${headroom}`;
|
|
1228
|
+
};
|
|
1229
|
+
// Attach bands to a group's param IDs so one lookup answers both "what is
|
|
1230
|
+
// the ID" and "what value is musical".
|
|
1231
|
+
const group = (...suffixes) => Object.fromEntries(suffixes.map((s) => [example(s), band(s) ?? "no corpus data — use get_parameter_info for the legal range"]));
|
|
1232
|
+
const wanted = groupFilter?.length ? new Set(groupFilter) : null;
|
|
1233
|
+
const pick = (all) => wanted ? Object.fromEntries(Object.entries(all).filter(([k]) => wanted.has(k))) : all;
|
|
1234
|
+
// Params the corpus barely explores. Presented as opportunity, not as a
|
|
1235
|
+
// warning: an agent that only ever writes values inside the dense part of
|
|
1236
|
+
// the distribution reproduces the average shipped preset by construction.
|
|
1237
|
+
const underexplored = Object.entries(CORPUS_RANGES)
|
|
1238
|
+
.filter(([, r]) => r.spanUsedPct < 55 || r.distinct <= 8)
|
|
1239
|
+
.sort((a, b) => a[1].spanUsedPct - b[1].spanUsedPct)
|
|
1240
|
+
.map(([k, r]) => `${k}: corpus uses ${r.lo}..${r.hi} of ${r.legal[0]}..${r.legal[1]} (${r.distinct} distinct values)`);
|
|
1241
|
+
// Params with no corpus row at all — no shipped preset has ever moved them.
|
|
1242
|
+
// Enable flags, sync toggles and the per-band EQ8 grid are excluded because
|
|
1243
|
+
// they are switches and a 49-row template, not sound-design dials.
|
|
1244
|
+
const neverUsed = params
|
|
1245
|
+
.filter((p) => p.paramId.startsWith(`cell${cellId}_`))
|
|
1246
|
+
.map((p) => p.paramId.slice(`cell${cellId}_`.length))
|
|
1247
|
+
.filter((s) => !CORPUS_RANGES[s] &&
|
|
1248
|
+
// Enum/index params carry no corpus row because a percentile over an
|
|
1249
|
+
// index is meaningless — their absence is not evidence of disuse.
|
|
1250
|
+
!CORPUS_ENUM_PARAMS.test(s) &&
|
|
1251
|
+
!s.startsWith("eq8_") &&
|
|
1252
|
+
!s.startsWith("magenta") &&
|
|
1253
|
+
!/^synth_p[4-8]$/.test(s))
|
|
1254
|
+
.sort();
|
|
1017
1255
|
return json({
|
|
1018
1256
|
product: manifest.name,
|
|
1019
1257
|
exampleCell: cellId,
|
|
1020
1258
|
macroTargetsByEffect,
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1259
|
+
// Ordered roughly by how much each group changes the character of a
|
|
1260
|
+
// sound. The sampler group is first because startPos/endPos/pitchShift
|
|
1261
|
+
// are the three most actively-designed params in the whole factory
|
|
1262
|
+
// corpus — chopping and re-tuning the source moves a preset further than
|
|
1263
|
+
// any amount of downstream FX.
|
|
1264
|
+
keyPerCellParams: pick({
|
|
1265
|
+
sampler: group("startPos", "endPos", "cutFrom", "fadeIn", "fadeOut", "mode"),
|
|
1266
|
+
pitch: group("pitchShift", "stretch", "arpeg", "arpegMode"),
|
|
1267
|
+
envelope: group("envAttack", "envGain", "envRelease"),
|
|
1268
|
+
retrigger: group("retrigger", "retriggerDelay"),
|
|
1269
|
+
groove: group("patternSwing"),
|
|
1270
|
+
mixer: group("volume", "pan", "gainGain"),
|
|
1271
|
+
filter: group("filterCutoff", "filterQ", "filterType"),
|
|
1272
|
+
eq: group("eqLowGain", "eqLowMidFreq", "eqMidGain", "eqMidHighFreq", "eqHighGain"),
|
|
1273
|
+
delay: group("delayDelayTime", "delayFeedback", "delayTilt", "delayMix"),
|
|
1274
|
+
flanger: group("flangerMix", "flangerTime", "flangerFeedback", "flangerFreq", "flangerSyncEnabled"),
|
|
1275
|
+
crusher: group("crusherBitReduction", "crusherRateReduction", "crusherDrive", "crusherMix"),
|
|
1276
|
+
fdn: group("fdnSize", "fdnFeedback", "fdnModDepth", "fdnMix"),
|
|
1277
|
+
gate: group("gateTime", "gateProgram", "gateMix"),
|
|
1278
|
+
compressor: group("compressorThreshold", "compressorRatio", "compressorAttack", "compressorRelease"),
|
|
1279
|
+
panner: group("pannerBalance", "pannerWidth"),
|
|
1280
|
+
clip: group("gainClip", "gainClipMode"),
|
|
1281
|
+
drumSynth: group("synthEngine", "synthVolume", "synthPitch", "synthAttack", "synth_p1", "synth_p2", "synth_p3"),
|
|
1282
|
+
// Everything below is a real per-cell engine that no shipped factory
|
|
1283
|
+
// preset has ever touched. They are the least-explored territory in the
|
|
1284
|
+
// instrument, which makes them the first place to look when the brief
|
|
1285
|
+
// is "something we haven't heard".
|
|
1286
|
+
granular: group("stretchGrainMs", "stretchCrossfadeFrac", "stretchSyncEnabled", "grainDensity", "grainWindow", "grainArp", "grainScan", "grainFeedback", "grainTone"),
|
|
1287
|
+
vocoder: group("vocoderMix", "vocoderWave", "vocoderNote", "vocoderFormant", "vocoderSpeed", "vocoderWidth"),
|
|
1288
|
+
resonator: group("envResonatorMix", "envResonatorResonance", "envResonatorDampHz", "envResonatorDetune", "envResonatorGlideMs"),
|
|
1289
|
+
crossModulation: group("fmSource", "fmAmount", "fmPolarity", "spread", "chaos", "formantShift", "vcaDepth"),
|
|
1290
|
+
patternShape: group("patternDirection", "patternLength", "patternSpeed"),
|
|
1291
|
+
pitchQuantize: group("quantRoot", "quantScale", "quantStrength"),
|
|
1292
|
+
retriggerShape: group("retriggerSyncEnabled", "retriggerDecayDb", "retriggerRamp"),
|
|
1293
|
+
slicing: group("slice"),
|
|
1294
|
+
}),
|
|
1031
1295
|
unitNotes: {
|
|
1032
|
-
|
|
1296
|
+
sampleWindow: "cellN_startPos / cellN_endPos are 0..100 percent of the sample (defaults 0 and 100). Trimming endPos is how the corpus builds chops — a quarter of shipped sounding cells end below 69%. cellN_cutFrom (0..8) picks the slice source.",
|
|
1297
|
+
pitchShift: "cellN_pitchShift is -24..24 SEMITONES, not a ratio. Pair with update_cell_config quantRoot/quantScale to keep a pitched cell in key.",
|
|
1298
|
+
stretch: "cellN_stretch is 0..1 with 0.25 = unstretched. Below 0.25 smears toward granular freeze, above plays faster. This is PAM's signature control — a value away from 0.25 is audible immediately.",
|
|
1299
|
+
envelope: "cellN_envAttack 1..100 and cellN_envRelease 1..5000 are ms; cellN_envGain is -24..24 dB. A long envRelease on a short one-shot is how a tail is built without reverb.",
|
|
1300
|
+
retrigger: "cellN_retrigger is a repeat COUNT (0..10); cellN_retriggerDelay is a normalized 0..1 division, not seconds.",
|
|
1301
|
+
arpeg: "cellN_arpeg is a -24..24 semitone step and cellN_arpegMode selects the pattern (0=Up 1=Down 2=Up/Down 3=Random 4=Triad 5=Major 6=Minor 7=Penta Maj ...). Non-zero arpeg turns a one-shot cell into a melodic line.",
|
|
1302
|
+
pan: "cellN_pan is -1..1, so -0.15 is about 15% left; use cellN_pannerBalance for -100..100% (needs pannerEnabled).",
|
|
1033
1303
|
percentages: "Most mix/feedback/depth controls are 0..100 percent.",
|
|
1034
1304
|
filterCutoff: "Use real Hz values; move at least 1.5 octaves for obviously audible modulation.",
|
|
1035
1305
|
delayDelayTime: "With delaySyncEnabled=1, normalized 0..1 maps to musical divisions; do not use cellN_delayTime.",
|
|
1306
|
+
flanger: "cellN_flangerTime and cellN_flangerFreq are normalized 0..1, not ms/Hz. flangerFeedback is where the character lives — the corpus median is 70 and the top quartile is at 95+.",
|
|
1307
|
+
eqGains: "cellN_eq{Low,Mid,High}Gain are -60..12 dB. The -60 floor makes the EQ a band KILL, not just a tilt.",
|
|
1036
1308
|
lfoRate: "LFO IDs are 1-based: lfo1_... through lfo6_.... Sync ON uses index/22.",
|
|
1309
|
+
lfoFeel: "lfoN_jitter and lfoN_offset (both 0..100, both default 0) are what stop an LFO sounding like a metronome — jitter randomizes the period, offset moves the starting phase so two LFOs at the same rate stop moving in lockstep. Only 8% and 6% of shipped LFOs set them.",
|
|
1310
|
+
granular: "The granular engine runs under cellN_stretch. cellN_stretchGrainMs (1..2000) is grain size — small values buzz, 200+ smears into a pad; cellN_grainDensity (1..32) is grains in flight; cellN_grainScan (-100..100) moves the read head independently of playback, so a negative scan plays a sample backwards through itself; cellN_grainFeedback (0..100) recirculates grains; cellN_grainTone (-100..100) tilts them; cellN_grainWindow (0..4) is the grain envelope shape.",
|
|
1311
|
+
vocoder: "Per-cell vocoder: cellN_vocoderMix (0..100) blends it in, cellN_vocoderNote (24..96) is the carrier MIDI note, cellN_vocoderWave (0..100) morphs the carrier, cellN_vocoderFormant (-12..12) shifts formants against pitch, cellN_vocoderSpeed (0.25..4) is band tracking rate. Any cell can vocode any source — it does not need a voice sample.",
|
|
1312
|
+
resonator: "cellN_envResonator* is the tuned comb resonator: mix (0..100), resonance (0..100), dampHz (200..20000), detune (-24..24 semitones), glideMs (0..500). High resonance turns a percussive hit into a pitched tone that follows the cell — see rules/tunedcomb.md for PRE/POST routing.",
|
|
1313
|
+
crossModulation: "cellN_fmSource (0..8) picks ANOTHER cell as an FM modulator and cellN_fmAmount (0..100) sets depth, so cells can modulate each other rather than each sitting in its own lane. cellN_chaos (0..100) and cellN_spread (0..100) add instability and stereo scatter; cellN_formantShift (-12..12) moves formants without pitch; cellN_vcaDepth (0..100) sets how hard the amp envelope bites.",
|
|
1314
|
+
patternShape: "cellN_patternDirection (0..4), cellN_patternLength (1..32) and cellN_patternSpeed (0..19) reshape a lane without rewriting a single note. Odd lengths against a 16-step grid (7, 11, 13) produce phasing polymeter, and the pitch/slice/stretch lanes have their own patternLength*/patternSpeed* variants so lanes can run at different lengths against each other.",
|
|
1315
|
+
pitchQuantize: "cellN_quantRoot (0..11), cellN_quantScale (0..9) and cellN_quantStrength (0..100) force a pitched cell into key. Set these before writing a pitch lane so transposition stays musical.",
|
|
1316
|
+
clip: "cellN_gainClip (0..12 dB) with cellN_gainClipMode (0..3) is a per-cell clipper — a different distortion character from the crusher's drive.",
|
|
1317
|
+
slice: "cellN_slice (0..64) selects the active slice; drive it from a slice lane for stutter edits without touching the sample.",
|
|
1318
|
+
},
|
|
1319
|
+
// Derived from the shipped factory presets, not hand-written. Regenerate
|
|
1320
|
+
// with `node scripts/derive-corpus-ranges.mjs` after changing the corpus.
|
|
1321
|
+
corpusBands: {
|
|
1322
|
+
source: `${CORPUS_META.presets} factory presets, ${CORPUS_META.soundingCells} sounding cells (${CORPUS_META.source})`,
|
|
1323
|
+
reading: "'typical' is p10..p90 — where the shipped presets sit. It is a description of the house average, NOT a limit. The interesting end of a range is usually outside it, and 'corpus' shows how far the shipped presets actually went.",
|
|
1324
|
+
underexplored,
|
|
1325
|
+
neverUsed: {
|
|
1326
|
+
note: "No shipped factory preset has ever moved these. That makes them the cheapest route to a sound the library does not already contain — reach here before adding another filter sweep.",
|
|
1327
|
+
params: neverUsed,
|
|
1328
|
+
},
|
|
1037
1329
|
},
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1330
|
+
eq8: {
|
|
1331
|
+
note: "Every cell also has a full 8-band parametric EQ, separate from the 3-band eq group above. It is a per-band grid rather than a fixed param list.",
|
|
1332
|
+
template: `cell${cellId}_eq8_b{1..8}_{freq,gain,q,type,slope,enabled}`,
|
|
1333
|
+
bandRanges: "freq 10..30000 Hz, gain -30..30 dB, q 0.025..40, type 0..5, slope 0..2, enabled 0/1",
|
|
1334
|
+
outputGain: example("eq8_outputGain"),
|
|
1335
|
+
idea: "A q of 40 at a chosen freq is a resonant whistle, not an EQ move — eight of them tuned to a chord turn any noise source into a pitched pad.",
|
|
1044
1336
|
},
|
|
1045
1337
|
lfoSyncTable: {
|
|
1046
1338
|
formula: "set lfoN_lfoSyncEnabled=1 and lfoN_lfoRate=index/22",
|
|
1047
1339
|
quick: lfoQuick,
|
|
1048
1340
|
fullDivisions: LFO_DIVISIONS,
|
|
1049
1341
|
shapes: ["0=Sine", "1=Saw", "2=Tri", "3=Square", "4=Random", "5=Wander", "6=Custom"],
|
|
1342
|
+
shapeNote: "Shapes 4 (Random) and 5 (Wander) are stepped/drifting rather than periodic — they read as performance rather than automation. Half of shipped LFOs are shape 0 (Sine).",
|
|
1050
1343
|
},
|
|
1051
1344
|
delaySyncDivisions: [
|
|
1052
1345
|
"1/32T", "1/32", "1/32D", "1/16T", "1/16", "1/16D",
|
|
@@ -1056,12 +1349,293 @@ server.registerTool("design_context", {
|
|
|
1056
1349
|
filterTypes: filterType?.strRepr?.map((label, index) => `${index}=${label}`) ?? [
|
|
1057
1350
|
"0=Lowpass", "1=Highpass", "2=Bandpass", "3=Peak", "4=Notch",
|
|
1058
1351
|
],
|
|
1352
|
+
effectOrder: {
|
|
1353
|
+
paramId: `cell${cellId}_effectOrder`,
|
|
1354
|
+
tool: "set_effect_order",
|
|
1355
|
+
note: "Per-cell FX chain order is a JSON string array, NOT a numeric param — set_parameters cannot reach it. Order is audible: filter-into-delay smears the resonance, delay-into-filter darkens the repeats.",
|
|
1356
|
+
defaultOrder: defaultEffectOrder,
|
|
1357
|
+
},
|
|
1059
1358
|
midiNoteMap: Object.fromEntries(Array.from({ length: 8 }, (_, i) => {
|
|
1060
1359
|
const id = i + 1;
|
|
1061
1360
|
return [`cell${id}`, 47 + id];
|
|
1062
1361
|
})),
|
|
1063
1362
|
variationSlots: "API is 0-based (0..7); PAM UI labels slots 1..8. Save/load variation index 0 = UI slot 1.",
|
|
1064
|
-
modulationGuidance: "Primary movement should target per-cell params for distinct audible changes. Use macro_* for performance-level fan-out across cells."
|
|
1363
|
+
modulationGuidance: "Primary movement should target per-cell params for distinct audible changes. Use macro_* for performance-level fan-out across cells. " +
|
|
1364
|
+
"Shipped presets that modulate at all carry a mean of 8.2 sources; the top third carry 8-46. Four sources is the corpus MEDIAN, i.e. the unremarkable middle — not a target.",
|
|
1365
|
+
});
|
|
1366
|
+
});
|
|
1367
|
+
server.registerTool("preset_novelty", {
|
|
1368
|
+
title: "Score Preset Novelty",
|
|
1369
|
+
description: "Score a preset against the shipped factory corpus and name what it duplicates. Answers the question " +
|
|
1370
|
+
"nothing else in the pipeline could: 'does this already exist?'\n\n" +
|
|
1371
|
+
"Compares structure, not audio — which params are off their defaults, the FX set and chain orders, the " +
|
|
1372
|
+
"modulation topology, per-cell onset patterns, transpositions, and sample sources. No render, no audio, " +
|
|
1373
|
+
"and (for source 'file') no plugin connection needed.\n\n" +
|
|
1374
|
+
"novelty is 1 - (closest match). Read it against percentileVsCorpus, NOT as an absolute: every PAM preset " +
|
|
1375
|
+
"shares an instrument, so a perfectly good one still scores around " +
|
|
1376
|
+
`${CORPUS_NOVELTY_BASELINE.p50}. The closest pair that actually shipped scores ${CORPUS_NOVELTY_BASELINE.floor} — ` +
|
|
1377
|
+
"below that, a preset is more derivative than anything in the library.\n\n" +
|
|
1378
|
+
"duplicatedDimensions is the actionable part: it names which dimensions to change.",
|
|
1379
|
+
inputSchema: {
|
|
1380
|
+
source: z
|
|
1381
|
+
.enum(["live", "file"])
|
|
1382
|
+
.default("live")
|
|
1383
|
+
.describe("'live' fingerprints the running plugin's current state; 'file' reads a .preset from disk."),
|
|
1384
|
+
path: z.string().optional().describe("Preset path for source 'file' (absolute or relative to the preset dirs)."),
|
|
1385
|
+
topN: z.number().int().min(1).max(20).default(5).describe("How many nearest neighbours to return."),
|
|
1386
|
+
},
|
|
1387
|
+
annotations: { readOnlyHint: true },
|
|
1388
|
+
}, async ({ source, path: presetPath, topN }) => {
|
|
1389
|
+
const manifest = await loadManifest();
|
|
1390
|
+
const defaults = {};
|
|
1391
|
+
for (const p of manifest.parameters) {
|
|
1392
|
+
const m = /^cell1_(.+)$/.exec(p.paramId);
|
|
1393
|
+
if (m)
|
|
1394
|
+
defaults[m[1]] = p.defaultValue;
|
|
1395
|
+
}
|
|
1396
|
+
const effectNames = cellEffectNames(manifest);
|
|
1397
|
+
let state;
|
|
1398
|
+
let label = "live state";
|
|
1399
|
+
if (source === "file") {
|
|
1400
|
+
if (!presetPath)
|
|
1401
|
+
return error("source 'file' requires a `path`.");
|
|
1402
|
+
try {
|
|
1403
|
+
const full = await resolvePresetPath(presetPath);
|
|
1404
|
+
const { metadata, data } = parsePresetFile(await readFile(full, "utf-8"));
|
|
1405
|
+
state = data;
|
|
1406
|
+
label = metadata.name || basename(full, ".preset");
|
|
1407
|
+
}
|
|
1408
|
+
catch (e) {
|
|
1409
|
+
return error(`Failed to read preset: ${e.message}`);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
else {
|
|
1413
|
+
const check = await requireBridge();
|
|
1414
|
+
if (check !== true)
|
|
1415
|
+
return check;
|
|
1416
|
+
state = (await bridge.send("getState"));
|
|
1417
|
+
}
|
|
1418
|
+
const candidate = buildFingerprint(state, defaults, effectNames, label);
|
|
1419
|
+
if (candidate.cells === 0) {
|
|
1420
|
+
return error("No cell carries any notes, so there is nothing to fingerprint. Write note patterns first (update_pattern), then re-score.");
|
|
1421
|
+
}
|
|
1422
|
+
// Re-scoring a preset that already shipped would otherwise match itself at
|
|
1423
|
+
// 1.0 and report "more derivative than anything in the library", which is
|
|
1424
|
+
// true but useless. Drop the identity match and say so.
|
|
1425
|
+
const selfIndex = CORPUS_FINGERPRINTS.findIndex((f) => f.name === candidate.name && fingerprintSimilarity(candidate, f).overall > 0.999);
|
|
1426
|
+
const corpus = selfIndex >= 0 ? CORPUS_FINGERPRINTS.filter((_, i) => i !== selfIndex) : CORPUS_FINGERPRINTS;
|
|
1427
|
+
const report = scoreNovelty(candidate, corpus, topN, CORPUS_NOVELTY_BASELINE);
|
|
1428
|
+
return json({
|
|
1429
|
+
subject: label,
|
|
1430
|
+
alreadyInCorpus: selfIndex >= 0 || undefined,
|
|
1431
|
+
...report,
|
|
1432
|
+
corpusBaseline: {
|
|
1433
|
+
...CORPUS_NOVELTY_BASELINE,
|
|
1434
|
+
note: `Leave-one-out novelty of the ${CORPUS_FINGERPRINTS.length} shipped presets against each other. p50 is a typical preset; below floor is more derivative than any shipped pair.`,
|
|
1435
|
+
},
|
|
1436
|
+
candidateProfile: {
|
|
1437
|
+
soundingCells: candidate.cells,
|
|
1438
|
+
paramsMoved: candidate.params.length,
|
|
1439
|
+
fx: candidate.fx,
|
|
1440
|
+
distinctChainOrders: candidate.chains.length,
|
|
1441
|
+
modulationSources: candidate.mod.lfo + candidate.mod.env + candidate.mod.vary + candidate.mod.seq,
|
|
1442
|
+
modulationTargets: candidate.mod.targets.length,
|
|
1443
|
+
transpositions: candidate.pitch.length,
|
|
1444
|
+
sampleSources: candidate.sources.length,
|
|
1445
|
+
},
|
|
1446
|
+
});
|
|
1447
|
+
});
|
|
1448
|
+
server.registerTool("analyze_motion", {
|
|
1449
|
+
title: "Analyze Preset Motion",
|
|
1450
|
+
description: "Measure whether a preset actually MOVES over time, rather than whether it is mixed. Fills the gap left by " +
|
|
1451
|
+
"get_master_analysis (loudness) and get_mix_analysis (aggregate spectrum + onset density), neither of which " +
|
|
1452
|
+
"can tell a modulated preset from a static one.\n\n" +
|
|
1453
|
+
"Reports spectral flux, brightness wander, per-bar level and brightness variation, and level movement WITHIN " +
|
|
1454
|
+
"a bar — the last one is what catches a bar-synced gate, tremolo or sidechain pump, whose bars are all " +
|
|
1455
|
+
"identical to each other.\n\n" +
|
|
1456
|
+
"Pass wavPath to analyze an existing render or live_resample capture. Otherwise the tool runs an offline " +
|
|
1457
|
+
"export first. Digital silence is reported as a RENDER FAILURE with the workaround, not as 'no motion' — " +
|
|
1458
|
+
"offline export is known to produce silence in the standalone build.",
|
|
1459
|
+
inputSchema: {
|
|
1460
|
+
wavPath: z
|
|
1461
|
+
.string()
|
|
1462
|
+
.optional()
|
|
1463
|
+
.describe("Absolute path to an existing WAV. Skips rendering — use this for a live_resample capture."),
|
|
1464
|
+
bars: z.number().int().min(1).max(32).default(4).describe("Bars to render when no wavPath is given."),
|
|
1465
|
+
bpm: z
|
|
1466
|
+
.number()
|
|
1467
|
+
.optional()
|
|
1468
|
+
.describe("Tempo for the per-bar windows. Read from the transport when omitted."),
|
|
1469
|
+
},
|
|
1470
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
1471
|
+
}, async ({ wavPath, bars, bpm }) => {
|
|
1472
|
+
let path = wavPath;
|
|
1473
|
+
let effectiveBpm = bpm;
|
|
1474
|
+
if (!path) {
|
|
1475
|
+
const check = await requireBridge();
|
|
1476
|
+
if (check !== true)
|
|
1477
|
+
return check;
|
|
1478
|
+
const transport = (await bridge.send("getTransport"));
|
|
1479
|
+
effectiveBpm ??= typeof transport?.bpm === "number" ? transport.bpm : 120;
|
|
1480
|
+
const filename = `pam_motion_${Date.now()}`;
|
|
1481
|
+
await bridge.send("exportAudio", {
|
|
1482
|
+
filename,
|
|
1483
|
+
mode: "main",
|
|
1484
|
+
durationInSeconds: (bars * 4 * 60) / (effectiveBpm || 120),
|
|
1485
|
+
bitDepth: 24,
|
|
1486
|
+
});
|
|
1487
|
+
const outcome = await pollExportStatus();
|
|
1488
|
+
if (outcome !== "success")
|
|
1489
|
+
return error(`Render failed before analysis could run (export ${outcome}).`);
|
|
1490
|
+
const locations = (await bridge.send("getDefaultLocations"));
|
|
1491
|
+
const exportDir = [locations?.exports, locations?.exportsLocation, locations?.Exports].find((v) => typeof v === "string" && v.length > 0);
|
|
1492
|
+
if (!exportDir) {
|
|
1493
|
+
return error(`Rendered ${filename}.wav but could not resolve PAM's Exports folder from get_default_locations. ` +
|
|
1494
|
+
"Re-run with an explicit wavPath.");
|
|
1495
|
+
}
|
|
1496
|
+
path = resolve(exportDir, `${filename}.wav`);
|
|
1497
|
+
}
|
|
1498
|
+
else if (effectiveBpm === undefined) {
|
|
1499
|
+
const check = await requireBridge();
|
|
1500
|
+
if (check === true) {
|
|
1501
|
+
const transport = (await bridge.send("getTransport"));
|
|
1502
|
+
if (typeof transport?.bpm === "number")
|
|
1503
|
+
effectiveBpm = transport.bpm;
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
let metrics;
|
|
1507
|
+
try {
|
|
1508
|
+
metrics = analyzeMotion(decodeWav(await readFile(path)), effectiveBpm);
|
|
1509
|
+
}
|
|
1510
|
+
catch (e) {
|
|
1511
|
+
return error(`Could not analyze ${path}: ${e.message}`);
|
|
1512
|
+
}
|
|
1513
|
+
return json({
|
|
1514
|
+
file: path,
|
|
1515
|
+
bpm: effectiveBpm ?? null,
|
|
1516
|
+
...metrics,
|
|
1517
|
+
reading: "static:true means nothing measurably changes — brightness, per-bar level and within-bar level are all flat. " +
|
|
1518
|
+
"centroidVariability and perBarCentroidVariability catch slow filter/timbre motion; intraBarRmsVariability " +
|
|
1519
|
+
"catches per-hit and per-beat movers (gate, tremolo, sidechain duck). A preset can pass the modulation-count " +
|
|
1520
|
+
"check and still fail here when depths are too small to hear.",
|
|
1521
|
+
});
|
|
1522
|
+
});
|
|
1523
|
+
// What to change when a pair of variations is identical on a given dimension.
|
|
1524
|
+
// The dimension name alone is not actionable; this maps it to a move.
|
|
1525
|
+
const VARIATION_FIX_HINTS = {
|
|
1526
|
+
groove: "rewrite the note patterns — different onsets, not just different density",
|
|
1527
|
+
params: "change the sound, not the mix: filter type, chop window (startPos/endPos), stretch, or a different FX",
|
|
1528
|
+
mod: "change what MOVES — retarget the front modulator or swap the mover type",
|
|
1529
|
+
pitch: "transpose or re-voice the melodic parts",
|
|
1530
|
+
fx: "enable or bypass a different effect on at least one cell",
|
|
1531
|
+
chains: "reorder one cell's FX chain (set_effect_order)",
|
|
1532
|
+
sources: "swap a sample on at least one cell",
|
|
1533
|
+
};
|
|
1534
|
+
server.registerTool("variation_distinctness", {
|
|
1535
|
+
title: "Check Variation Distinctness",
|
|
1536
|
+
description: "Measure whether a preset's variation slots are actually different, and name what is identical about the " +
|
|
1537
|
+
"pairs that are not. Structural comparison of the saved slot snapshots — no render, no transport, no " +
|
|
1538
|
+
"audition loop, and no risk of the load_variation race.\n\n" +
|
|
1539
|
+
`Read scores against the shipped corpus, where sibling variation pairs sit at a median of ` +
|
|
1540
|
+
`${CORPUS_VARIATION_BASELINE.p50} similarity (p75 ${CORPUS_VARIATION_BASELINE.p75}, p90 ${CORPUS_VARIATION_BASELINE.p90}). ` +
|
|
1541
|
+
`Siblings are far more alike than two different presets by construction, so the preset_novelty scale does ` +
|
|
1542
|
+
`not apply. ${CORPUS_VARIATION_BASELINE.nearIdenticalShare}% of shipped pairs are already near-identical — ` +
|
|
1543
|
+
"this gate exists to stop adding to that number.\n\n" +
|
|
1544
|
+
"Each failing pair reports the dimensions it duplicates plus the move that would fix it.",
|
|
1545
|
+
inputSchema: {
|
|
1546
|
+
source: z
|
|
1547
|
+
.enum(["live", "file"])
|
|
1548
|
+
.default("live")
|
|
1549
|
+
.describe("'live' reads the running plugin's variation slots; 'file' reads them from a .preset."),
|
|
1550
|
+
path: z.string().optional().describe("Preset path for source 'file'."),
|
|
1551
|
+
threshold: z
|
|
1552
|
+
.number()
|
|
1553
|
+
.min(0.5)
|
|
1554
|
+
.max(1)
|
|
1555
|
+
.default(0.9)
|
|
1556
|
+
.describe(`Similarity above which a pair fails. Default 0.9 — worse than 75% of shipped sibling pairs (p75 ${CORPUS_VARIATION_BASELINE.p75}).`),
|
|
1557
|
+
},
|
|
1558
|
+
annotations: { readOnlyHint: true },
|
|
1559
|
+
}, async ({ source, path: presetPath, threshold }) => {
|
|
1560
|
+
const manifest = await loadManifest();
|
|
1561
|
+
const defaults = {};
|
|
1562
|
+
for (const p of manifest.parameters) {
|
|
1563
|
+
const m = /^cell1_(.+)$/.exec(p.paramId);
|
|
1564
|
+
if (m)
|
|
1565
|
+
defaults[m[1]] = p.defaultValue;
|
|
1566
|
+
}
|
|
1567
|
+
const effectNames = cellEffectNames(manifest);
|
|
1568
|
+
let slots;
|
|
1569
|
+
let label;
|
|
1570
|
+
if (source === "file") {
|
|
1571
|
+
if (!presetPath)
|
|
1572
|
+
return error("source 'file' requires a `path`.");
|
|
1573
|
+
try {
|
|
1574
|
+
const full = await resolvePresetPath(presetPath);
|
|
1575
|
+
const parsed = parsePresetFile(await readFile(full, "utf-8"));
|
|
1576
|
+
slots = variationsFromParsedPreset(parsed);
|
|
1577
|
+
label = parsed.metadata.name || basename(full, ".preset");
|
|
1578
|
+
}
|
|
1579
|
+
catch (e) {
|
|
1580
|
+
return error(`Failed to read preset: ${e.message}`);
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
else {
|
|
1584
|
+
const check = await requireBridge();
|
|
1585
|
+
if (check !== true)
|
|
1586
|
+
return check;
|
|
1587
|
+
const state = (await bridge.send("getState"));
|
|
1588
|
+
const raw = Array.isArray(state.variations) ? state.variations : [];
|
|
1589
|
+
slots = Array.from({ length: PRESET_VARIATION_SLOTS }, (_, i) => typeof raw[i] === "string" ? decompressVariationEntry(raw[i]) : null);
|
|
1590
|
+
label = "live state";
|
|
1591
|
+
}
|
|
1592
|
+
const filled = [];
|
|
1593
|
+
for (const [i, slot] of slots.entries()) {
|
|
1594
|
+
if (typeof slot !== "string" || slot.length < 10)
|
|
1595
|
+
continue;
|
|
1596
|
+
try {
|
|
1597
|
+
filled.push({ slot: i, fp: buildFingerprint(JSON.parse(slot), defaults, effectNames, `slot ${i}`) });
|
|
1598
|
+
}
|
|
1599
|
+
catch {
|
|
1600
|
+
/* unreadable slot — reported via the occupancy count below */
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
if (filled.length < 2) {
|
|
1604
|
+
return error(`Only ${filled.length} readable variation slot(s) — need at least 2 to compare. ` +
|
|
1605
|
+
"Save more slots with save_variation (API indices are 0-based; UI labels them 1-8).");
|
|
1606
|
+
}
|
|
1607
|
+
const pairs = [];
|
|
1608
|
+
for (let i = 0; i < filled.length; i += 1) {
|
|
1609
|
+
for (let j = i + 1; j < filled.length; j += 1) {
|
|
1610
|
+
const sim = fingerprintSimilarity(filled[i].fp, filled[j].fp);
|
|
1611
|
+
const identical = Object.entries(sim.byDimension)
|
|
1612
|
+
.filter(([, v]) => v >= 0.95)
|
|
1613
|
+
.sort((a, b) => b[1] - a[1])
|
|
1614
|
+
.map(([k]) => k);
|
|
1615
|
+
pairs.push({
|
|
1616
|
+
pair: `slot ${filled[i].slot} vs slot ${filled[j].slot}`,
|
|
1617
|
+
similarity: Math.round(sim.overall * 1000) / 1000,
|
|
1618
|
+
pass: sim.overall < threshold,
|
|
1619
|
+
identicalDimensions: identical,
|
|
1620
|
+
fix: sim.overall < threshold ? undefined : identical.map((d) => VARIATION_FIX_HINTS[d] ?? d),
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
const failing = pairs.filter((p) => !p.pass).sort((a, b) => b.similarity - a.similarity);
|
|
1625
|
+
return json({
|
|
1626
|
+
subject: label,
|
|
1627
|
+
filledSlots: filled.map((f) => f.slot),
|
|
1628
|
+
threshold,
|
|
1629
|
+
pass: failing.length === 0,
|
|
1630
|
+
verdict: failing.length === 0
|
|
1631
|
+
? `All ${pairs.length} slot pairs differ by more than the ${threshold} threshold.`
|
|
1632
|
+
: `${failing.length} of ${pairs.length} slot pairs are too similar. Give one slot in each a real change — density alone is not a variation.`,
|
|
1633
|
+
failingPairs: failing,
|
|
1634
|
+
allPairs: pairs,
|
|
1635
|
+
corpusBaseline: CORPUS_VARIATION_BASELINE,
|
|
1636
|
+
// Shipped variations move these dimensions and leave those alone — useful
|
|
1637
|
+
// when deciding WHICH dimension to change.
|
|
1638
|
+
corpusHabit: "Shipped variations mostly change modulation and groove; they almost never change samples or FX chain order. Those two are therefore the least-used lever available to you.",
|
|
1065
1639
|
});
|
|
1066
1640
|
});
|
|
1067
1641
|
// Standard full update — graph dispatch + KV sync + parameter updates.
|
|
@@ -1195,9 +1769,8 @@ server.registerTool("get_design_overview", {
|
|
|
1195
1769
|
]);
|
|
1196
1770
|
const state = isRecord(stateRaw) ? stateRaw : {};
|
|
1197
1771
|
const transport = isRecord(transportRaw) ? transportRaw : {};
|
|
1198
|
-
//
|
|
1199
|
-
const
|
|
1200
|
-
const effectNames = derivedEffects.length ? derivedEffects : CELL_EFFECTS_FALLBACK;
|
|
1772
|
+
// The manifest is authoritative; every current product carries both character effects.
|
|
1773
|
+
const effectNames = cellEffectNames(manifest);
|
|
1201
1774
|
const manifestCellIds = [...new Set(manifest.parameters
|
|
1202
1775
|
.map((p) => p.cell)
|
|
1203
1776
|
.filter((cell) => typeof cell === "string" && /^cell\d+$/.test(cell))
|
|
@@ -1210,6 +1783,49 @@ server.registerTool("get_design_overview", {
|
|
|
1210
1783
|
vary: Array.isArray(state.vary) ? state.vary : [],
|
|
1211
1784
|
paramSeq: Array.isArray(state.paramSeqs) ? state.paramSeqs : [],
|
|
1212
1785
|
};
|
|
1786
|
+
if (IS_MFX_SERVER) {
|
|
1787
|
+
const cells = ids.map((id) => {
|
|
1788
|
+
const prefix = `cell${id}_`;
|
|
1789
|
+
const targetPrefix = new Set([prefix]);
|
|
1790
|
+
const storedOrder = state[`${prefix}effectOrder`];
|
|
1791
|
+
const rawCell = cellsObj[String(id)];
|
|
1792
|
+
const cellConfig = isRecord(rawCell) ? rawCell : {};
|
|
1793
|
+
return {
|
|
1794
|
+
cellId: id,
|
|
1795
|
+
muted: state[`${prefix}mute`] === 1 || state[`${prefix}mute`] === true,
|
|
1796
|
+
sidechainFrom: cellConfig.sidechainFrom ?? state[`${prefix}sidechainFrom`],
|
|
1797
|
+
effectOrder: Array.isArray(storedOrder) ? storedOrder : cellEffectOrder(manifest),
|
|
1798
|
+
enabledEffects: effectNames.filter((effectName) => state[`${prefix}${effectName}Enabled`] === 1 || state[`${prefix}${effectName}Enabled`] === true),
|
|
1799
|
+
modulation: Object.fromEntries(Object.entries(modArrays).map(([kind, arr]) => [
|
|
1800
|
+
kind,
|
|
1801
|
+
arr.filter((entry) => paramTargetsCell(entry, targetPrefix)).length,
|
|
1802
|
+
])),
|
|
1803
|
+
mixer: {
|
|
1804
|
+
volume: state[`${prefix}volume`],
|
|
1805
|
+
pan: state[`${prefix}pan`],
|
|
1806
|
+
gain: state[`${prefix}gainGain`],
|
|
1807
|
+
},
|
|
1808
|
+
};
|
|
1809
|
+
});
|
|
1810
|
+
return json({
|
|
1811
|
+
product: manifest.name,
|
|
1812
|
+
bpm: state.bpm ?? transport.bpm,
|
|
1813
|
+
playing: transport.playing,
|
|
1814
|
+
mix: state.mfx_mix,
|
|
1815
|
+
routing: state.mfx_routing,
|
|
1816
|
+
edgeGains: state.mfx_edge_gains,
|
|
1817
|
+
split: { lowHz: state.mfx_split_lo_hz, highHz: state.mfx_split_hi_hz },
|
|
1818
|
+
combos: resolveServerMfxCombos(state, manifest).map((combo) => ({
|
|
1819
|
+
...combo,
|
|
1820
|
+
value: state[`mfx_combo${combo.index}`],
|
|
1821
|
+
})),
|
|
1822
|
+
totals: {
|
|
1823
|
+
enabledEffects: cells.reduce((sum, cell) => sum + cell.enabledEffects.length, 0),
|
|
1824
|
+
modulationLinks: cells.reduce((sum, cell) => sum + Object.values(cell.modulation).reduce((subtotal, count) => subtotal + count, 0), 0),
|
|
1825
|
+
},
|
|
1826
|
+
cells,
|
|
1827
|
+
});
|
|
1828
|
+
}
|
|
1213
1829
|
const cells = ids.map((id) => {
|
|
1214
1830
|
const prefix = `cell${id}_`;
|
|
1215
1831
|
const rawCellState = cellsObj[String(id)];
|
|
@@ -1308,6 +1924,36 @@ server.registerTool("set_parameters", {
|
|
|
1308
1924
|
}
|
|
1309
1925
|
delta.macros = macros;
|
|
1310
1926
|
}
|
|
1927
|
+
let comboTargetCount = 0;
|
|
1928
|
+
if (IS_MFX_SERVER) {
|
|
1929
|
+
const comboValues = new Map();
|
|
1930
|
+
for (let comboIndex = 1; comboIndex <= MFX_COMBO_COUNT; comboIndex++) {
|
|
1931
|
+
const value = parameters[`mfx_combo${comboIndex}`];
|
|
1932
|
+
if (typeof value === "number")
|
|
1933
|
+
comboValues.set(comboIndex, value);
|
|
1934
|
+
}
|
|
1935
|
+
if (comboValues.size > 0) {
|
|
1936
|
+
const stateRaw = await bridge.send("getState");
|
|
1937
|
+
const state = isRecord(stateRaw) ? stateRaw : {};
|
|
1938
|
+
const manifest = await loadManifest();
|
|
1939
|
+
for (const combo of resolveServerMfxCombos(state, manifest)) {
|
|
1940
|
+
const value = comboValues.get(combo.index);
|
|
1941
|
+
if (value === undefined)
|
|
1942
|
+
continue;
|
|
1943
|
+
if (combo.targets.some((target) => Object.hasOwn(parameters, target.id)))
|
|
1944
|
+
continue;
|
|
1945
|
+
for (const target of combo.targets) {
|
|
1946
|
+
const info = index.get(target.id);
|
|
1947
|
+
if (!info)
|
|
1948
|
+
continue;
|
|
1949
|
+
const previousComboValue = state[`mfx_combo${combo.index}`];
|
|
1950
|
+
const currentTargetValue = state[target.id];
|
|
1951
|
+
delta[target.id] = mfxComboTargetValueFromCurrent(target, typeof previousComboValue === "number" ? previousComboValue : 0, value, info, typeof currentTargetValue === "number" ? currentTargetValue : info.defaultValue);
|
|
1952
|
+
comboTargetCount++;
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1311
1957
|
await bridge.send("applyUnifiedStateKvUpdate", {
|
|
1312
1958
|
delta,
|
|
1313
1959
|
options: {
|
|
@@ -1319,7 +1965,8 @@ server.registerTool("set_parameters", {
|
|
|
1319
1965
|
broadcastFollowers: true,
|
|
1320
1966
|
},
|
|
1321
1967
|
});
|
|
1322
|
-
return text(`Set ${Object.keys(parameters).length} parameter(s): ${Object.entries(parameters).map(([k, v]) => `${k}=${v}`).join(", ")}`
|
|
1968
|
+
return text(`Set ${Object.keys(parameters).length} parameter(s): ${Object.entries(parameters).map(([k, v]) => `${k}=${v}`).join(", ")}` +
|
|
1969
|
+
(comboTargetCount > 0 ? `; fanned out ${comboTargetCount} MFX combo target write(s)` : ""));
|
|
1323
1970
|
});
|
|
1324
1971
|
server.registerTool("load_sample", {
|
|
1325
1972
|
title: "Load Sample",
|
|
@@ -1329,7 +1976,7 @@ server.registerTool("load_sample", {
|
|
|
1329
1976
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
1330
1977
|
},
|
|
1331
1978
|
}, async ({ filePath, cellId }) => {
|
|
1332
|
-
const check = await
|
|
1979
|
+
const check = await requireBridgeForCells(cellId);
|
|
1333
1980
|
if (check !== true)
|
|
1334
1981
|
return check;
|
|
1335
1982
|
await bridge.send("loadSampleToCell", filePath, cellId);
|
|
@@ -1341,7 +1988,7 @@ server.registerTool("load_sample", {
|
|
|
1341
1988
|
// + experimental<Engine>) — the bridge returns a clear error otherwise.
|
|
1342
1989
|
const MODEL_ENGINE = z
|
|
1343
1990
|
.enum(["notochord", "aigen", "magenta"])
|
|
1344
|
-
.describe("Which AI engine. notochord = MIDI co-player (~18 MB blob); aigen = Stable Audio 3 sample generation (
|
|
1991
|
+
.describe("Which AI engine. notochord = MIDI co-player (~18 MB blob); aigen = Stable Audio 3 sample generation (2.71 GB); magenta = MagentaRT realtime audio (Apple Silicon, MLX).");
|
|
1345
1992
|
const MODEL_VARIANT = z
|
|
1346
1993
|
.enum(["base", "small"])
|
|
1347
1994
|
.optional()
|
|
@@ -1418,7 +2065,7 @@ server.registerTool("list_models", {
|
|
|
1418
2065
|
});
|
|
1419
2066
|
server.registerTool("generate_sample", {
|
|
1420
2067
|
title: "Generate Sample (AI)",
|
|
1421
|
-
description: "Generate a new audio sample via the on-device AI engine (Stable Audio 3 Small SFX,
|
|
2068
|
+
description: "Generate a new audio sample via the on-device AI engine (Stable Audio 3 Small SFX, 2.71 GB). The plugin must have the model downloaded first (Settings → Local models). Returns the on-disk path to the generated WAV and optionally loads it into a cell.",
|
|
1422
2069
|
inputSchema: {
|
|
1423
2070
|
category: z
|
|
1424
2071
|
.enum(["kick", "snare", "hat", "perc", "bass", "synth", "pad", "fx", "loop"])
|
|
@@ -1592,7 +2239,7 @@ server.registerTool("workbench_load_take", {
|
|
|
1592
2239
|
cellId: z.number().int().min(1).max(8).describe("Target cell."),
|
|
1593
2240
|
},
|
|
1594
2241
|
}, async ({ takeId, cellId }) => {
|
|
1595
|
-
const check = await
|
|
2242
|
+
const check = await requireBridgeForCells(cellId);
|
|
1596
2243
|
if (check !== true)
|
|
1597
2244
|
return check;
|
|
1598
2245
|
try {
|
|
@@ -1649,7 +2296,7 @@ server.registerTool("trigger_midi", {
|
|
|
1649
2296
|
channel: z.number().min(1).max(16).default(1).describe("MIDI channel (1-16)."),
|
|
1650
2297
|
},
|
|
1651
2298
|
}, async ({ cellId, note, noteOn, channel }) => {
|
|
1652
|
-
const check = await
|
|
2299
|
+
const check = await requireBridgeForCells(cellId);
|
|
1653
2300
|
if (check !== true)
|
|
1654
2301
|
return check;
|
|
1655
2302
|
if (cellId) {
|
|
@@ -1667,7 +2314,7 @@ server.registerTool("stop_cell", {
|
|
|
1667
2314
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
1668
2315
|
},
|
|
1669
2316
|
}, async ({ cellId }) => {
|
|
1670
|
-
const check = await
|
|
2317
|
+
const check = await requireBridgeForCells(cellId);
|
|
1671
2318
|
if (check !== true)
|
|
1672
2319
|
return check;
|
|
1673
2320
|
await bridge.send("stopCell", cellId);
|
|
@@ -1733,7 +2380,7 @@ server.registerTool("add_modulation", {
|
|
|
1733
2380
|
return error(formatUnknownParamError(paramId, index, { modulatableOnly: true }));
|
|
1734
2381
|
if (!param.isModulatable)
|
|
1735
2382
|
return error(formatNotModulatableError(paramId, index));
|
|
1736
|
-
const check = await
|
|
2383
|
+
const check = await requireBridgeForCells(cellId);
|
|
1737
2384
|
if (check !== true)
|
|
1738
2385
|
return check;
|
|
1739
2386
|
// Compute effect-enabled params to auto-enable disabled effects (mirrors UI behavior)
|
|
@@ -1772,7 +2419,7 @@ server.registerTool("update_pattern", {
|
|
|
1772
2419
|
"For 'pitch'/'slice'/'stretch': [{time, value, duration}]."),
|
|
1773
2420
|
},
|
|
1774
2421
|
}, async ({ cellId, type, sequence }) => {
|
|
1775
|
-
const check = await
|
|
2422
|
+
const check = await requireBridgeForCells(cellId);
|
|
1776
2423
|
if (check !== true)
|
|
1777
2424
|
return check;
|
|
1778
2425
|
// Map pattern type to state key suffix
|
|
@@ -1857,9 +2504,21 @@ server.registerTool("randomize", {
|
|
|
1857
2504
|
.max(1)
|
|
1858
2505
|
.optional()
|
|
1859
2506
|
.describe("Mutation amount for pattern randomization (0-1). Lower = subtler changes."),
|
|
2507
|
+
density: z
|
|
2508
|
+
.number()
|
|
2509
|
+
.min(-1)
|
|
2510
|
+
.max(1)
|
|
2511
|
+
.optional()
|
|
2512
|
+
.describe("Pattern density for 'sequences'/'full' (-1 = sparse, 0 = neutral, 1 = dense). Omit for the genre and role default."),
|
|
2513
|
+
syncopation: z
|
|
2514
|
+
.number()
|
|
2515
|
+
.min(-1)
|
|
2516
|
+
.max(1)
|
|
2517
|
+
.optional()
|
|
2518
|
+
.describe("Rhythmic placement for 'sequences'/'full' (-1 = strong beats, 0 = neutral, 1 = weaker offbeats). Omit for the genre and role default."),
|
|
1860
2519
|
},
|
|
1861
|
-
}, async ({ target, cellIds, genreHint, bpmHint, mutationAmount }) => {
|
|
1862
|
-
const check = await
|
|
2520
|
+
}, async ({ target, cellIds, genreHint, bpmHint, mutationAmount, density, syncopation }) => {
|
|
2521
|
+
const check = await requireBridgeForCells(cellIds);
|
|
1863
2522
|
if (check !== true)
|
|
1864
2523
|
return check;
|
|
1865
2524
|
if (target === "synth") {
|
|
@@ -1877,7 +2536,7 @@ server.registerTool("randomize", {
|
|
|
1877
2536
|
return text(`Randomized ${target === "lfos" ? "LFO modulation links" : "all modulation (LFOs, envelopes, vary)"}. ` +
|
|
1878
2537
|
`(Runs in the WebView — if nothing changed, ensure PAM's window is open.)`);
|
|
1879
2538
|
}
|
|
1880
|
-
await bridge.send("randomize", target, cellIds ?? [], genreHint ?? "", bpmHint ?? -1, mutationAmount ?? -1);
|
|
2539
|
+
await bridge.send("randomize", target, cellIds ?? [], genreHint ?? "", bpmHint ?? -1, mutationAmount ?? -1, density ?? null, syncopation ?? null);
|
|
1881
2540
|
return text(`Randomized ${target}${cellIds ? ` for cells ${cellIds.join(", ")}` : ""}.`);
|
|
1882
2541
|
});
|
|
1883
2542
|
server.registerTool("randomize_samples", {
|
|
@@ -1891,7 +2550,7 @@ server.registerTool("randomize_samples", {
|
|
|
1891
2550
|
genreHint: z
|
|
1892
2551
|
.string()
|
|
1893
2552
|
.optional()
|
|
1894
|
-
.describe("Genre hint to guide sample selection. Supported: boom_bap (aliases hip-hop, lofi), trap, drill, house, techno, dnb (aliases jungle, breakbeat, breaks). Other values fall back to neutral role-based selection; omit to auto-detect from the library."),
|
|
2553
|
+
.describe("Genre hint to guide sample selection. Supported: boom_bap (aliases hip-hop, lofi), trap, drill, house, techno, dnb (aliases jungle, breakbeat, breaks), idm (aliases braindance, electronica, breakcore). Other values fall back to neutral role-based selection; omit to auto-detect from the library."),
|
|
1895
2554
|
bpmHint: z
|
|
1896
2555
|
.number()
|
|
1897
2556
|
.min(20)
|
|
@@ -1906,13 +2565,51 @@ server.registerTool("randomize_samples", {
|
|
|
1906
2565
|
.array(z.string())
|
|
1907
2566
|
.optional()
|
|
1908
2567
|
.describe("If provided, only pick samples from these paths. Overrides default library scan."),
|
|
2568
|
+
diversify: z
|
|
2569
|
+
.number()
|
|
2570
|
+
.min(0)
|
|
2571
|
+
.max(1)
|
|
2572
|
+
.optional()
|
|
2573
|
+
.describe("Anti-centroid mode (0 = default behaviour, 1 = maximum spread). The native engine scores FOLDER " +
|
|
2574
|
+
"COHERENCE, which is correct for a kit-from-one-pack and is also why repeated randomizes keep landing " +
|
|
2575
|
+
"on the same few well-stocked folders. Above 0 this builds a cross-folder pool first and restricts the " +
|
|
2576
|
+
"engine to it, keeping role assignment while removing its ability to converge. Use it for the DIVERGE " +
|
|
2577
|
+
"step; ignored when allowedSamplePaths is given."),
|
|
2578
|
+
seed: z
|
|
2579
|
+
.number()
|
|
2580
|
+
.int()
|
|
2581
|
+
.min(0)
|
|
2582
|
+
.optional()
|
|
2583
|
+
.describe("Seed for diversify, so a diverged kit can be reproduced. Defaults to 0."),
|
|
1909
2584
|
},
|
|
1910
|
-
}, async ({ cellIds, genreHint, bpmHint, excludedFolders, allowedSamplePaths }) => {
|
|
1911
|
-
const check = await
|
|
1912
|
-
if (check !== true)
|
|
1913
|
-
return check;
|
|
1914
|
-
|
|
1915
|
-
|
|
2585
|
+
}, async ({ cellIds, genreHint, bpmHint, excludedFolders, allowedSamplePaths, diversify, seed }) => {
|
|
2586
|
+
const check = await requireBridgeForCells(cellIds);
|
|
2587
|
+
if (check !== true)
|
|
2588
|
+
return check;
|
|
2589
|
+
let pool = allowedSamplePaths;
|
|
2590
|
+
let diversifyNote = "";
|
|
2591
|
+
if (!pool && diversify && diversify > 0) {
|
|
2592
|
+
const locations = (await bridge.send("getDefaultLocations"));
|
|
2593
|
+
const roots = Object.entries(locations ?? {})
|
|
2594
|
+
.filter(([k, v]) => typeof v === "string" && v.length > 0 && /sample/i.test(k))
|
|
2595
|
+
.map(([, v]) => v);
|
|
2596
|
+
if (roots.length) {
|
|
2597
|
+
const byFolder = await collectLibraryByFolder(roots);
|
|
2598
|
+
const spread = spreadAcrossFolders(byFolder, diversify, seed ?? 0);
|
|
2599
|
+
if (spread.length >= 4) {
|
|
2600
|
+
pool = spread;
|
|
2601
|
+
diversifyNote = ` Diversified: ${spread.length} samples drawn across ${byFolder.size} folders (diversify ${diversify}, seed ${seed ?? 0}).`;
|
|
2602
|
+
}
|
|
2603
|
+
else {
|
|
2604
|
+
diversifyNote = " Diversify found too few samples to build a pool; fell back to the normal library scan.";
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
else {
|
|
2608
|
+
diversifyNote = " Diversify could not resolve a sample library root; fell back to the normal library scan.";
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
await bridge.send("randomizeSamples", cellIds ?? [], genreHint ?? "", bpmHint ?? -1, excludedFolders ?? [], pool ?? []);
|
|
2612
|
+
return text(`Randomized samples${cellIds ? ` for cells ${cellIds.join(", ")}` : " for all cells"}${genreHint ? ` (genre: ${genreHint})` : ""}.${diversifyNote}`);
|
|
1916
2613
|
});
|
|
1917
2614
|
server.registerTool("randomize_patterns", {
|
|
1918
2615
|
title: "Randomize Patterns",
|
|
@@ -1937,13 +2634,28 @@ server.registerTool("randomize_patterns", {
|
|
|
1937
2634
|
.min(0)
|
|
1938
2635
|
.max(1)
|
|
1939
2636
|
.optional()
|
|
1940
|
-
.describe("How much to vary from typical patterns (0 = conventional, 1 = experimental). Default ~0.5."
|
|
2637
|
+
.describe("How much to vary from typical patterns (0 = conventional, 1 = experimental). Default ~0.5. " +
|
|
2638
|
+
"This is the pattern-side divergence lever: the generator otherwise produces the genre's most typical " +
|
|
2639
|
+
"rhythm, which is exactly what makes a batch of presets feel the same. Push it to 0.7-1.0 during the " +
|
|
2640
|
+
"DIVERGE step, and keep it low only when the brief genuinely wants a stock groove."),
|
|
2641
|
+
density: z
|
|
2642
|
+
.number()
|
|
2643
|
+
.min(-1)
|
|
2644
|
+
.max(1)
|
|
2645
|
+
.optional()
|
|
2646
|
+
.describe("Pattern density (-1 = sparse, 0 = neutral, 1 = dense). Omit for the genre and role default."),
|
|
2647
|
+
syncopation: z
|
|
2648
|
+
.number()
|
|
2649
|
+
.min(-1)
|
|
2650
|
+
.max(1)
|
|
2651
|
+
.optional()
|
|
2652
|
+
.describe("Rhythmic placement (-1 = strong beats, 0 = neutral, 1 = weaker offbeats). Omit for the genre and role default."),
|
|
1941
2653
|
},
|
|
1942
|
-
}, async ({ cellIds, genreHint, bpmHint, mutationAmount }) => {
|
|
1943
|
-
const check = await
|
|
2654
|
+
}, async ({ cellIds, genreHint, bpmHint, mutationAmount, density, syncopation }) => {
|
|
2655
|
+
const check = await requireBridgeForCells(cellIds);
|
|
1944
2656
|
if (check !== true)
|
|
1945
2657
|
return check;
|
|
1946
|
-
await bridge.send("randomize", "sequences", cellIds ?? [], genreHint ?? "", bpmHint ?? -1, mutationAmount ?? -1);
|
|
2658
|
+
await bridge.send("randomize", "sequences", cellIds ?? [], genreHint ?? "", bpmHint ?? -1, mutationAmount ?? -1, density ?? null, syncopation ?? null);
|
|
1947
2659
|
return text(`Randomized patterns${cellIds ? ` for cells ${cellIds.join(", ")}` : " for all cells"}${genreHint ? ` (genre: ${genreHint})` : ""}.`);
|
|
1948
2660
|
});
|
|
1949
2661
|
server.registerTool("load_variation", {
|
|
@@ -2034,14 +2746,32 @@ server.registerTool("get_mix_analysis", {
|
|
|
2034
2746
|
while (Date.now() - t0 < 120_000) {
|
|
2035
2747
|
await new Promise((r) => setTimeout(r, 500));
|
|
2036
2748
|
const status = (await bridge.send("getMixAnalysis"));
|
|
2037
|
-
if (status.status !== "running")
|
|
2038
|
-
|
|
2749
|
+
if (status.status !== "running") {
|
|
2750
|
+
const silent = silentMixAnalysisMessage(status);
|
|
2751
|
+
return silent ? error(silent) : json(status);
|
|
2752
|
+
}
|
|
2039
2753
|
}
|
|
2040
2754
|
return error("Mix analysis timed out after 120 s.");
|
|
2041
2755
|
}
|
|
2042
2756
|
const result = await bridge.send("getMixAnalysis");
|
|
2043
|
-
|
|
2757
|
+
const silent = silentMixAnalysisMessage(result);
|
|
2758
|
+
return silent ? error(silent) : json(result);
|
|
2044
2759
|
});
|
|
2760
|
+
function silentMixAnalysisMessage(raw) {
|
|
2761
|
+
if (!isRecord(raw))
|
|
2762
|
+
return null;
|
|
2763
|
+
const analysis = parseMixAnalysisPayload(raw.analysis);
|
|
2764
|
+
if (!analysis || analysis.cells.length === 0)
|
|
2765
|
+
return null;
|
|
2766
|
+
const stems = [...analysis.cells, analysis.master];
|
|
2767
|
+
const silent = stems.every((stem) => stem.barkDb.length > 0 &&
|
|
2768
|
+
stem.onsets === 0 &&
|
|
2769
|
+
(stem.lufsI === undefined || stem.lufsI <= -119) &&
|
|
2770
|
+
stem.barkDb.every((band) => band <= -119));
|
|
2771
|
+
return silent
|
|
2772
|
+
? "Mix analysis rendered digital silence. No balance or EQ inference is safe from this result; use live playback measurement or retry in a plugin host."
|
|
2773
|
+
: null;
|
|
2774
|
+
}
|
|
2045
2775
|
// Epoch of the last OFFLINE analysis suggest_mix applied moves from — the
|
|
2046
2776
|
// stacking guard's memory (live payloads refresh their epoch, so they never
|
|
2047
2777
|
// stay consumed). Session-scoped by design: a server restart just re-allows a
|
|
@@ -2071,6 +2801,9 @@ server.registerTool("suggest_mix", {
|
|
|
2071
2801
|
const analysis = parseMixAnalysisPayload(r.analysis);
|
|
2072
2802
|
if (!analysis)
|
|
2073
2803
|
return error("Cached mix analysis is malformed — re-run get_mix_analysis with refresh:true.");
|
|
2804
|
+
const silent = silentMixAnalysisMessage(r);
|
|
2805
|
+
if (silent)
|
|
2806
|
+
return error(silent);
|
|
2074
2807
|
// Stacking guard (mirrors the panel's consumed-epoch gate): the engine's
|
|
2075
2808
|
// deltas come from the analysis but land on the LIVE params, so suggesting
|
|
2076
2809
|
// again from an analysis that was already applied doubles the correction.
|
|
@@ -2431,7 +3164,7 @@ server.registerTool("trim_sample", {
|
|
|
2431
3164
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
2432
3165
|
},
|
|
2433
3166
|
}, async ({ cellId }) => {
|
|
2434
|
-
const check = await
|
|
3167
|
+
const check = await requireBridgeForCells(cellId);
|
|
2435
3168
|
if (check !== true)
|
|
2436
3169
|
return check;
|
|
2437
3170
|
await bridge.send("trimSampleForCell", cellId);
|
|
@@ -2444,7 +3177,7 @@ server.registerTool("reverse_sample", {
|
|
|
2444
3177
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
2445
3178
|
},
|
|
2446
3179
|
}, async ({ cellId }) => {
|
|
2447
|
-
const check = await
|
|
3180
|
+
const check = await requireBridgeForCells(cellId);
|
|
2448
3181
|
if (check !== true)
|
|
2449
3182
|
return check;
|
|
2450
3183
|
await bridge.send("reverseSampleForCell", cellId);
|
|
@@ -2469,13 +3202,16 @@ server.registerTool("connection_status", {
|
|
|
2469
3202
|
},
|
|
2470
3203
|
annotations: { readOnlyHint: true },
|
|
2471
3204
|
}, async () => {
|
|
2472
|
-
const
|
|
3205
|
+
const discoveredInstances = await bridge.discover();
|
|
3206
|
+
const instances = IS_MFX_SERVER
|
|
3207
|
+
? discoveredInstances.filter((instance) => instance.product?.trim().toLowerCase() === "mfx")
|
|
3208
|
+
: discoveredInstances;
|
|
2473
3209
|
if (instances.length === 0) {
|
|
2474
3210
|
return structured({
|
|
2475
3211
|
instances: [],
|
|
2476
3212
|
connected: false,
|
|
2477
|
-
note:
|
|
2478
|
-
|
|
3213
|
+
note: `No running ${SERVER_PRODUCT.name} instances found. To use live tools, ${SERVER_PRODUCT.name} needs the MCP bridge enabled: ` +
|
|
3214
|
+
`start ${SERVER_PRODUCT.name} (standalone or in a DAW) and enable it in Settings → Experimental. ` +
|
|
2479
3215
|
"Offline tools (get_parameter_info, list_presets, read_preset) work without a connection.",
|
|
2480
3216
|
});
|
|
2481
3217
|
}
|
|
@@ -2488,8 +3224,8 @@ server.registerTool("connection_status", {
|
|
|
2488
3224
|
connectedTo: bridge.getConnectedInstance(),
|
|
2489
3225
|
note: connected
|
|
2490
3226
|
? instances.length > 1
|
|
2491
|
-
?
|
|
2492
|
-
:
|
|
3227
|
+
? `Connected. Multiple ${SERVER_PRODUCT.name} instances are running; the launcher uses the first available instance.`
|
|
3228
|
+
: `Connected to ${SERVER_PRODUCT.name}. All advertised tools are available.`
|
|
2493
3229
|
: "Found instances but could not connect. The plugin may have shut down.",
|
|
2494
3230
|
});
|
|
2495
3231
|
});
|
|
@@ -2514,9 +3250,16 @@ server.registerTool("select_instance", {
|
|
|
2514
3250
|
if (!product && !instanceId && port === undefined) {
|
|
2515
3251
|
return error("Pass at least one of product, instanceId, or port. Call connection_status to list running instances.");
|
|
2516
3252
|
}
|
|
2517
|
-
|
|
3253
|
+
if (IS_MFX_SERVER && product && product.trim().toLowerCase() !== "mfx") {
|
|
3254
|
+
return error("The mfx-mcp launcher can select MFX instances only.");
|
|
3255
|
+
}
|
|
3256
|
+
const targetProduct = IS_MFX_SERVER ? "MFX" : product;
|
|
3257
|
+
const ok = await bridge.connect({ product: targetProduct, instanceId, port });
|
|
2518
3258
|
if (!ok) {
|
|
2519
|
-
const
|
|
3259
|
+
const discovered = await bridge.discover();
|
|
3260
|
+
const instances = IS_MFX_SERVER
|
|
3261
|
+
? discovered.filter((instance) => instance.product?.trim().toLowerCase() === "mfx")
|
|
3262
|
+
: discovered;
|
|
2520
3263
|
const listing = instances.length
|
|
2521
3264
|
? ` Running instances: ${instances
|
|
2522
3265
|
.map((i) => `${i.product} (port ${i.port}, id ${i.instanceId})`)
|
|
@@ -2573,6 +3316,69 @@ async function searchSampleLibrary(rootPath, term, audioOnly) {
|
|
|
2573
3316
|
}
|
|
2574
3317
|
return matches;
|
|
2575
3318
|
}
|
|
3319
|
+
// Walk the library and return audio files grouped by their parent folder.
|
|
3320
|
+
// Bounded like searchSampleLibrary — this runs inside a tool call, not a job.
|
|
3321
|
+
async function collectLibraryByFolder(roots, maxDirReads = 120) {
|
|
3322
|
+
const byFolder = new Map();
|
|
3323
|
+
const queue = roots.map((path) => ({ path, depth: 0 }));
|
|
3324
|
+
const options = JSON.stringify({ audioOnly: true, sort: "name" });
|
|
3325
|
+
let dirReads = 0;
|
|
3326
|
+
while (queue.length && dirReads < maxDirReads) {
|
|
3327
|
+
const { path, depth } = queue.shift();
|
|
3328
|
+
dirReads += 1;
|
|
3329
|
+
let entries = [];
|
|
3330
|
+
try {
|
|
3331
|
+
const res = await bridge.send("listDirectory", path, options);
|
|
3332
|
+
if (Array.isArray(res))
|
|
3333
|
+
entries = res;
|
|
3334
|
+
}
|
|
3335
|
+
catch {
|
|
3336
|
+
continue;
|
|
3337
|
+
}
|
|
3338
|
+
for (const e of entries) {
|
|
3339
|
+
if (typeof e.path !== "string")
|
|
3340
|
+
continue;
|
|
3341
|
+
if (e.isDir) {
|
|
3342
|
+
if (depth < 3)
|
|
3343
|
+
queue.push({ path: e.path, depth: depth + 1 });
|
|
3344
|
+
}
|
|
3345
|
+
else {
|
|
3346
|
+
const list = byFolder.get(path) ?? [];
|
|
3347
|
+
list.push(e.path);
|
|
3348
|
+
byFolder.set(path, list);
|
|
3349
|
+
}
|
|
3350
|
+
}
|
|
3351
|
+
}
|
|
3352
|
+
return byFolder;
|
|
3353
|
+
}
|
|
3354
|
+
// Build a sample pool that deliberately SPANS folders instead of collapsing
|
|
3355
|
+
// into one pack.
|
|
3356
|
+
//
|
|
3357
|
+
// WHY: randomize_samples' native engine scores folder coherence, which is the
|
|
3358
|
+
// right default (a kit from one pack sounds like a kit) and the wrong one when
|
|
3359
|
+
// the brief is "something we haven't heard" — coherence pulls every result
|
|
3360
|
+
// toward the same few well-stocked folders. Restricting the engine to a
|
|
3361
|
+
// cross-folder pool keeps its role assignment while removing its ability to
|
|
3362
|
+
// converge. Deterministic given the same seed, so a diverged kit can be
|
|
3363
|
+
// reproduced.
|
|
3364
|
+
function spreadAcrossFolders(byFolder, diversify, seed) {
|
|
3365
|
+
const folders = [...byFolder.keys()].sort();
|
|
3366
|
+
if (!folders.length)
|
|
3367
|
+
return [];
|
|
3368
|
+
// At diversify 1 every folder contributes a single file (maximum spread); at
|
|
3369
|
+
// low values a few folders contribute many (close to normal behaviour).
|
|
3370
|
+
const perFolder = Math.max(1, Math.round(12 - 11 * diversify));
|
|
3371
|
+
const folderCount = Math.max(1, Math.min(folders.length, Math.round(2 + (folders.length - 2) * diversify)));
|
|
3372
|
+
const start = folders.length ? seed % folders.length : 0;
|
|
3373
|
+
const pool = [];
|
|
3374
|
+
for (let i = 0; i < folderCount; i += 1) {
|
|
3375
|
+
const files = byFolder.get(folders[(start + i) % folders.length]) ?? [];
|
|
3376
|
+
for (let j = 0; j < Math.min(perFolder, files.length); j += 1) {
|
|
3377
|
+
pool.push(files[(seed + j * 7) % files.length]);
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
return [...new Set(pool)];
|
|
3381
|
+
}
|
|
2576
3382
|
// Search-result payloads feed the assistant's 256-token continuation round —
|
|
2577
3383
|
// trim to that budget instead of letting the listing collapse to an opaque
|
|
2578
3384
|
// truncation string (executor parity: assistantExecutor.ts fitListing).
|
|
@@ -3651,8 +4457,8 @@ server.registerTool("reset_patterns", {
|
|
|
3651
4457
|
});
|
|
3652
4458
|
server.registerTool("update_effect_config", {
|
|
3653
4459
|
title: "Update Effect Config",
|
|
3654
|
-
description: "Toggle cell effects on/off (filter, delay, crusher, flanger, eq, eq8, fdn, gate, compressor, panner, gain, vocoder, limiter, qverb, wavefold, resonator). " +
|
|
3655
|
-
"
|
|
4460
|
+
description: "Toggle cell effects on/off (filter, delay, crusher, flanger, chorus, phaser, eq, eq8, tablefilter, fdn, gate, compressor, panner, gain, vocoder, ringmod, vinylbrake, transient, repeat, pitchshift, limiter, qverb, shimmer, wavefold, resonator). " +
|
|
4461
|
+
"Wavefold and Resonator ship in every product. Exact availability comes from the selected product manifest. " +
|
|
3656
4462
|
"Uses the lighter KV path that does not rebuild the audio graph. Pass 1 to enable, 0 to disable.",
|
|
3657
4463
|
inputSchema: {
|
|
3658
4464
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
@@ -3664,7 +4470,7 @@ server.registerTool("update_effect_config", {
|
|
|
3664
4470
|
fdnEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3665
4471
|
gateEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3666
4472
|
compressorEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3667
|
-
//
|
|
4473
|
+
// Shared extended effects. These ship on PAM/Cell1 too, so leaving them out of the
|
|
3668
4474
|
// schema made them unreachable over the bridge even though the handler is
|
|
3669
4475
|
// generic (it just builds `cell{N}_{key}`) and the state vars exist in
|
|
3670
4476
|
// build.js alongside the eight above.
|
|
@@ -3672,10 +4478,19 @@ server.registerTool("update_effect_config", {
|
|
|
3672
4478
|
pannerEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3673
4479
|
gainEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3674
4480
|
vocoderEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4481
|
+
ringmodEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4482
|
+
vinylbrakeEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4483
|
+
transientEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4484
|
+
repeatEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4485
|
+
pitchshiftEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3675
4486
|
limiterEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3676
4487
|
qverbEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3677
|
-
|
|
3678
|
-
|
|
4488
|
+
chorusEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4489
|
+
phaserEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4490
|
+
shimmerEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4491
|
+
tablefilterEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4492
|
+
// Product-specific extended effects. The handler rejects flags absent from
|
|
4493
|
+
// the connected product instead of relying on the bridge to ignore them.
|
|
3679
4494
|
wavefoldEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3680
4495
|
resonatorEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
3681
4496
|
},
|
|
@@ -3687,10 +4502,20 @@ server.registerTool("update_effect_config", {
|
|
|
3687
4502
|
const cellCheck = validateCellIdForManifest(cellId, manifest);
|
|
3688
4503
|
if (cellCheck !== true)
|
|
3689
4504
|
return cellCheck;
|
|
4505
|
+
if (!manifest) {
|
|
4506
|
+
return error("The selected product manifest is unavailable; effect availability cannot be validated safely.");
|
|
4507
|
+
}
|
|
4508
|
+
const availableEffects = new Set(cellEffectNames(manifest));
|
|
3690
4509
|
const delta = {};
|
|
3691
4510
|
for (const [key, value] of Object.entries(config)) {
|
|
3692
|
-
if (value
|
|
3693
|
-
|
|
4511
|
+
if (value === undefined)
|
|
4512
|
+
continue;
|
|
4513
|
+
const effect = key.endsWith("Enabled") ? key.slice(0, -"Enabled".length) : key;
|
|
4514
|
+
if (!availableEffects.has(effect)) {
|
|
4515
|
+
return error(`${effect} is not available in ${manifest.name ?? "the selected product"}. ` +
|
|
4516
|
+
`Available effects: ${[...availableEffects].join(", ")}.`);
|
|
4517
|
+
}
|
|
4518
|
+
delta[`cell${cellId}_${key}`] = value;
|
|
3694
4519
|
}
|
|
3695
4520
|
if (Object.keys(delta).length === 0) {
|
|
3696
4521
|
return error("At least one effect flag must be provided.");
|
|
@@ -3708,8 +4533,13 @@ server.registerTool("update_effect_config", {
|
|
|
3708
4533
|
// Validation mirrors parseRoutingEdges (interface/src/dsp/mfxRouting.ts): the
|
|
3709
4534
|
// plugin re-validates on receipt, so this pass exists to fail loudly here rather
|
|
3710
4535
|
// than have a typo silently drop a cable.
|
|
3711
|
-
const MFX_ROUTING_SOURCE_HINT = "'input', 'sidechain',
|
|
3712
|
-
|
|
4536
|
+
const MFX_ROUTING_SOURCE_HINT = "'input', 'sidechain', 'split_lo' / 'split_mid' / 'split_hi', or 'cellN'. " +
|
|
4537
|
+
"'output' and 'split' are destinations only.";
|
|
4538
|
+
const MFX_ROUTING_SINK_HINT = "'output', 'split', or 'cellN'. 'input', 'sidechain' and the three split bands are sources only.";
|
|
4539
|
+
// SPLIT — the three-band crossover patch node. One sink, three band sources.
|
|
4540
|
+
const MFX_SPLIT_SINK = "split";
|
|
4541
|
+
const MFX_SPLIT_BANDS = ["split_lo", "split_mid", "split_hi"];
|
|
4542
|
+
const isMfxSplitBand = (port) => MFX_SPLIT_BANDS.includes(port);
|
|
3713
4543
|
function mfxPortCell(port) {
|
|
3714
4544
|
const m = /^cell([1-9]\d*)$/.exec(port); // canonical form only — 'cell01' is not a port
|
|
3715
4545
|
return m ? Number(m[1]) : null;
|
|
@@ -3722,12 +4552,23 @@ function validateMfxEdges(edges, maxCells) {
|
|
|
3722
4552
|
return c !== null && c >= 1 && c <= maxCells;
|
|
3723
4553
|
};
|
|
3724
4554
|
for (const { from, to } of edges) {
|
|
3725
|
-
if (from !== "input" && from !== "sidechain" && !inRange(from)) {
|
|
4555
|
+
if (from !== "input" && from !== "sidechain" && !isMfxSplitBand(from) && !inRange(from)) {
|
|
3726
4556
|
return { ok: false, reason: `invalid source port '${from}' — expected ${MFX_ROUTING_SOURCE_HINT}` };
|
|
3727
4557
|
}
|
|
3728
|
-
if (to !== "output" && !inRange(to)) {
|
|
4558
|
+
if (to !== "output" && to !== MFX_SPLIT_SINK && !inRange(to)) {
|
|
3729
4559
|
return { ok: false, reason: `invalid destination port '${to}' — expected ${MFX_ROUTING_SINK_HINT}` };
|
|
3730
4560
|
}
|
|
4561
|
+
// Only a source ENDPOINT may feed the crossover. Mirrors parseRoutingEdges: SPLIT sits
|
|
4562
|
+
// outside the cell evaluation order, so a cell→split cable would put it inside a cycle
|
|
4563
|
+
// the plugin's resolver cannot see. The plugin drops such a cable silently; failing here
|
|
4564
|
+
// is what tells the agent why its patch came back short.
|
|
4565
|
+
if (to === MFX_SPLIT_SINK && from !== "input" && from !== "sidechain") {
|
|
4566
|
+
return {
|
|
4567
|
+
ok: false,
|
|
4568
|
+
reason: `'${from}' cannot feed '${MFX_SPLIT_SINK}' — only 'input' and 'sidechain' can. ` +
|
|
4569
|
+
"Split what arrives, then patch the bands.",
|
|
4570
|
+
};
|
|
4571
|
+
}
|
|
3731
4572
|
const key = `${from}->${to}`;
|
|
3732
4573
|
if (seen.has(key))
|
|
3733
4574
|
continue; // dedupe, matching the plugin
|
|
@@ -3742,6 +4583,11 @@ server.registerTool("set_mfx_routing", {
|
|
|
3742
4583
|
"this returns a product error.\n\n" +
|
|
3743
4584
|
"Ports: 'input' (the host's audio in) and 'sidechain' are sources; 'output' is the destination; 'cellN' is both. " +
|
|
3744
4585
|
"A self-edge cellN→cellN is a feedback loop, and input→output is a direct dry path. Duplicate cables are dropped.\n\n" +
|
|
4586
|
+
"SPLIT is a three-band crossover node: cable 'input' (or 'sidechain') to 'split', then patch 'split_lo', " +
|
|
4587
|
+
"'split_mid' and 'split_hi' to different cells. Each band then has its own full FX rack, its own modulation " +
|
|
4588
|
+
"and its own sidechain key — which is how you build a multiband gate or a multiband distortion here. Only " +
|
|
4589
|
+
"'input' and 'sidechain' may feed 'split'; the bands are sources only. Its two corner frequencies are the " +
|
|
4590
|
+
"ordinary parameters mfx_split_lo_hz and mfx_split_hi_hz (set_parameters).\n\n" +
|
|
3745
4591
|
"A plain serial chain on 4 cells is input→cell1, cell1→cell2, cell2→cell3, cell3→cell4, cell4→output. " +
|
|
3746
4592
|
"Parallel is input→each cell plus each cell→output.\n\n" +
|
|
3747
4593
|
"'set' REPLACES the whole patch, so send the full cable list. Per-cable levels live in mfx_edge_gains and are " +
|
|
@@ -3783,6 +4629,253 @@ server.registerTool("set_mfx_routing", {
|
|
|
3783
4629
|
const summary = validated.edges.map((e) => `${e.from}→${e.to}`).join(", ");
|
|
3784
4630
|
return text(`MFX routing set: ${validated.edges.length} cable(s)${summary ? ` — ${summary}` : " (patch cleared)"}.`);
|
|
3785
4631
|
});
|
|
4632
|
+
server.registerTool("set_mfx_edge_gain", {
|
|
4633
|
+
title: "Set MFX Cable Gain",
|
|
4634
|
+
description: "Read or set the gain for one cable in the current MFX patch. Gain is linear: 0 is silent, 1 is unity, " +
|
|
4635
|
+
"and 1.5 is the maximum. The cable must already exist. Other cable gains are preserved.",
|
|
4636
|
+
inputSchema: {
|
|
4637
|
+
op: z.enum(["get", "set"]),
|
|
4638
|
+
from: z.string().optional().describe(`Cable source for 'set': ${MFX_ROUTING_SOURCE_HINT}`),
|
|
4639
|
+
to: z.string().optional().describe(`Cable destination for 'set': ${MFX_ROUTING_SINK_HINT}`),
|
|
4640
|
+
gain: z.number().min(0).max(1.5).optional().describe("Linear gain for 'set'."),
|
|
4641
|
+
},
|
|
4642
|
+
}, async ({ op, from, to, gain }) => {
|
|
4643
|
+
const check = await requireBridge();
|
|
4644
|
+
if (check !== true)
|
|
4645
|
+
return check;
|
|
4646
|
+
const manifest = await loadManifestForProductGuard();
|
|
4647
|
+
if (!manifest || !isMfxManifest(manifest))
|
|
4648
|
+
return error("set_mfx_edge_gain is MFX-only.");
|
|
4649
|
+
const stateRaw = await bridge.send("getState");
|
|
4650
|
+
const state = isRecord(stateRaw) ? stateRaw : {};
|
|
4651
|
+
const gains = isRecord(state.mfx_edge_gains) ? { ...state.mfx_edge_gains } : {};
|
|
4652
|
+
if (op === "get")
|
|
4653
|
+
return json(gains);
|
|
4654
|
+
if (!from || !to || typeof gain !== "number")
|
|
4655
|
+
return error("op 'set' requires from, to, and gain.");
|
|
4656
|
+
const validated = validateMfxEdges([{ from, to }], manifestMaxCellId(manifest) ?? 4);
|
|
4657
|
+
if (!validated.ok)
|
|
4658
|
+
return error(validated.reason);
|
|
4659
|
+
const edges = Array.isArray(state.mfx_routing) ? state.mfx_routing : [];
|
|
4660
|
+
const exists = edges.some((edge) => isRecord(edge) && edge.from === from && edge.to === to);
|
|
4661
|
+
if (!exists)
|
|
4662
|
+
return error(`Cable ${from}→${to} is not in the current patch. Add it with set_mfx_routing first.`);
|
|
4663
|
+
const key = `${from}__${to}`;
|
|
4664
|
+
gains[key] = gain;
|
|
4665
|
+
await applyKv({ mfx_edge_gains: gains }, KV_OPTS_EFFECT_CONFIG);
|
|
4666
|
+
return text(`MFX cable gain set: ${from}→${to} = ${gain}.`);
|
|
4667
|
+
});
|
|
4668
|
+
const MFX_COMBO_TARGET_INPUT = z.object({
|
|
4669
|
+
paramId: z.string(),
|
|
4670
|
+
from: z.number().optional(),
|
|
4671
|
+
mid: z.number().optional(),
|
|
4672
|
+
to: z.number().optional(),
|
|
4673
|
+
});
|
|
4674
|
+
server.registerTool("configure_mfx_combo", {
|
|
4675
|
+
title: "Configure MFX Combo",
|
|
4676
|
+
description: "Read, edit, or move one of MFX's three performance combos. Automatic assignments follow the audible patch. " +
|
|
4677
|
+
"Manual target edits materialize and lock the assignment. setValue writes the combo parameter and its real targets " +
|
|
4678
|
+
"in one native state update, so it works with the MFX window closed.",
|
|
4679
|
+
inputSchema: {
|
|
4680
|
+
op: z.enum([
|
|
4681
|
+
"get", "setValue", "rename", "lock", "unlock", "rescan", "setTargets", "addTarget",
|
|
4682
|
+
"removeTarget", "clearTargets", "invertTarget", "setTargetStop", "anchor",
|
|
4683
|
+
]),
|
|
4684
|
+
index: z.number().int().min(1).max(3).default(1),
|
|
4685
|
+
value: z.number().min(-100).max(100).optional().describe("Combo value for setValue, or target value for setTargetStop."),
|
|
4686
|
+
name: z.string().max(24).optional().describe("New label for rename; blank restores the role label."),
|
|
4687
|
+
paramId: z.string().optional().describe("Target parameter for add/remove/invert/setTargetStop."),
|
|
4688
|
+
stop: z.enum(["from", "mid", "to"]).optional().describe("Target stop edited by setTargetStop."),
|
|
4689
|
+
targets: z.array(MFX_COMBO_TARGET_INPUT).max(6).optional().describe("Complete target list for setTargets."),
|
|
4690
|
+
},
|
|
4691
|
+
}, async ({ op, index, value, name, paramId, stop, targets }) => {
|
|
4692
|
+
const check = await requireBridge();
|
|
4693
|
+
if (check !== true)
|
|
4694
|
+
return check;
|
|
4695
|
+
const manifest = await loadManifest();
|
|
4696
|
+
if (!isMfxManifest(manifest))
|
|
4697
|
+
return error("configure_mfx_combo is MFX-only.");
|
|
4698
|
+
const stateRaw = await bridge.send("getState");
|
|
4699
|
+
const state = isRecord(stateRaw) ? stateRaw : {};
|
|
4700
|
+
const resolved = resolveServerMfxCombos(state, manifest);
|
|
4701
|
+
const combo = resolved[index - 1];
|
|
4702
|
+
const slots = parseMfxComboSlots(state.mfx_combos);
|
|
4703
|
+
const stored = slots[String(index)] ?? {};
|
|
4704
|
+
const parameterIndex = new Map(manifest.parameters.map((parameter) => [parameter.paramId, parameter]));
|
|
4705
|
+
if (op === "get")
|
|
4706
|
+
return json({ combos: resolved.map((entry) => ({ ...entry, value: state[`mfx_combo${entry.index}`] })) });
|
|
4707
|
+
if (op === "setValue") {
|
|
4708
|
+
if (typeof value !== "number")
|
|
4709
|
+
return error("setValue requires value.");
|
|
4710
|
+
const delta = { [`mfx_combo${index}`]: value };
|
|
4711
|
+
for (const target of combo.targets) {
|
|
4712
|
+
const info = parameterIndex.get(target.id);
|
|
4713
|
+
if (!info)
|
|
4714
|
+
continue;
|
|
4715
|
+
const previousComboValue = state[`mfx_combo${index}`];
|
|
4716
|
+
const currentTargetValue = state[target.id];
|
|
4717
|
+
delta[target.id] = mfxComboTargetValueFromCurrent(target, typeof previousComboValue === "number" ? previousComboValue : 0, value, info, typeof currentTargetValue === "number" ? currentTargetValue : info.defaultValue);
|
|
4718
|
+
}
|
|
4719
|
+
await applyKv(delta, KV_OPTS_STANDARD);
|
|
4720
|
+
return json({ combo: index, value, targetsWritten: combo.targets.map((target) => target.id) });
|
|
4721
|
+
}
|
|
4722
|
+
const materialized = () => ({ ...stored, targets: [...combo.targets] });
|
|
4723
|
+
let next = stored;
|
|
4724
|
+
if (op === "rename") {
|
|
4725
|
+
if (name === undefined)
|
|
4726
|
+
return error("rename requires name.");
|
|
4727
|
+
next = { ...stored };
|
|
4728
|
+
const trimmed = name.trim().slice(0, 24);
|
|
4729
|
+
if (trimmed)
|
|
4730
|
+
next.name = trimmed;
|
|
4731
|
+
else
|
|
4732
|
+
delete next.name;
|
|
4733
|
+
}
|
|
4734
|
+
else if (op === "lock") {
|
|
4735
|
+
next = { ...materialized(), locked: 1 };
|
|
4736
|
+
}
|
|
4737
|
+
else if (op === "unlock" || op === "rescan") {
|
|
4738
|
+
next = stored.name ? { name: stored.name } : null;
|
|
4739
|
+
}
|
|
4740
|
+
else {
|
|
4741
|
+
let edited = materialized();
|
|
4742
|
+
if (op === "setTargets") {
|
|
4743
|
+
if (!targets)
|
|
4744
|
+
return error("setTargets requires targets.");
|
|
4745
|
+
const built = [];
|
|
4746
|
+
for (const target of targets) {
|
|
4747
|
+
const info = parameterIndex.get(target.paramId);
|
|
4748
|
+
if (!info || !isMfxComboTargetParameter(info)) {
|
|
4749
|
+
return error(`'${target.paramId}' is not an available MFX combo target.`);
|
|
4750
|
+
}
|
|
4751
|
+
if (built.some((candidate) => candidate.id === target.paramId)) {
|
|
4752
|
+
return error(`'${target.paramId}' appears more than once in targets.`);
|
|
4753
|
+
}
|
|
4754
|
+
const current = state[target.paramId];
|
|
4755
|
+
let builtTarget = buildMfxComboTarget(target.paramId, info, MFX_COMBO_MANUAL_STRENGTH, 1, typeof current === "number" ? current : info.defaultValue);
|
|
4756
|
+
for (const targetStop of ["from", "mid", "to"]) {
|
|
4757
|
+
const targetValue = target[targetStop];
|
|
4758
|
+
if (typeof targetValue === "number")
|
|
4759
|
+
builtTarget = setMfxComboTargetStop(builtTarget, info, targetStop, targetValue);
|
|
4760
|
+
}
|
|
4761
|
+
built.push(builtTarget);
|
|
4762
|
+
}
|
|
4763
|
+
edited.targets = built;
|
|
4764
|
+
}
|
|
4765
|
+
else if (op === "clearTargets") {
|
|
4766
|
+
edited.targets = [];
|
|
4767
|
+
}
|
|
4768
|
+
else if (op === "anchor") {
|
|
4769
|
+
edited.targets = edited.targets.map((target) => {
|
|
4770
|
+
const info = parameterIndex.get(target.id);
|
|
4771
|
+
const current = state[target.id];
|
|
4772
|
+
return info && typeof current === "number" ? reanchorMfxComboTarget(target, info, current) : target;
|
|
4773
|
+
});
|
|
4774
|
+
}
|
|
4775
|
+
else {
|
|
4776
|
+
if (!paramId)
|
|
4777
|
+
return error(`${op} requires paramId.`);
|
|
4778
|
+
const info = parameterIndex.get(paramId);
|
|
4779
|
+
if (!info || !isMfxComboTargetParameter(info)) {
|
|
4780
|
+
return error(`'${paramId}' is not an available MFX combo target.`);
|
|
4781
|
+
}
|
|
4782
|
+
if (op === "addTarget") {
|
|
4783
|
+
if (!edited.targets.some((target) => target.id === paramId)) {
|
|
4784
|
+
if (edited.targets.length >= 6)
|
|
4785
|
+
return error("A combo can drive at most 6 targets.");
|
|
4786
|
+
const current = state[paramId];
|
|
4787
|
+
edited.targets.push(buildMfxComboTarget(paramId, info, MFX_COMBO_MANUAL_STRENGTH, 1, typeof current === "number" ? current : info.defaultValue));
|
|
4788
|
+
}
|
|
4789
|
+
}
|
|
4790
|
+
else if (op === "removeTarget") {
|
|
4791
|
+
edited.targets = edited.targets.filter((target) => target.id !== paramId);
|
|
4792
|
+
}
|
|
4793
|
+
else if (op === "invertTarget") {
|
|
4794
|
+
edited.targets = edited.targets.map((target) => target.id === paramId
|
|
4795
|
+
? { id: target.id, from: target.to, mid: target.mid, to: target.from }
|
|
4796
|
+
: target);
|
|
4797
|
+
}
|
|
4798
|
+
else if (op === "setTargetStop") {
|
|
4799
|
+
if (!stop || typeof value !== "number")
|
|
4800
|
+
return error("setTargetStop requires paramId, stop, and value.");
|
|
4801
|
+
if (!edited.targets.some((target) => target.id === paramId)) {
|
|
4802
|
+
return error(`'${paramId}' is not assigned to combo ${index}.`);
|
|
4803
|
+
}
|
|
4804
|
+
edited.targets = edited.targets.map((target) => target.id === paramId
|
|
4805
|
+
? setMfxComboTargetStop(target, info, stop, value)
|
|
4806
|
+
: target);
|
|
4807
|
+
}
|
|
4808
|
+
}
|
|
4809
|
+
next = { ...edited, locked: 1 };
|
|
4810
|
+
}
|
|
4811
|
+
const update = {
|
|
4812
|
+
name: next?.name ?? null,
|
|
4813
|
+
locked: next?.locked === 1 ? 1 : 0,
|
|
4814
|
+
targets: Array.isArray(next?.targets) ? next.targets : null,
|
|
4815
|
+
};
|
|
4816
|
+
await applyKv({ mfx_combos: { ...slots, [String(index)]: update } }, KV_OPTS_EFFECT_CONFIG);
|
|
4817
|
+
return json({ combo: index, slot: update });
|
|
4818
|
+
});
|
|
4819
|
+
server.registerTool("set_effect_order", {
|
|
4820
|
+
title: "Set Cell FX Chain Order",
|
|
4821
|
+
description: "Read or replace one cell's FX chain order. cellN_effectOrder is a JSON string array, not a numeric " +
|
|
4822
|
+
"parameter, so set_parameters cannot reach it — this tool owns it (the set_mfx_routing precedent).\n\n" +
|
|
4823
|
+
"The available stages and their fresh-cell order are product-specific and come from the selected manifest; " +
|
|
4824
|
+
"design_context reports that default order. 'set' REPLACES the chain, so send every listed stage " +
|
|
4825
|
+
"in the order you want it to process; the array must be a permutation, not a subset (use the per-effect " +
|
|
4826
|
+
"*Enabled flags via update_effect_config to switch stages off).\n\n" +
|
|
4827
|
+
"Order is audible and is a cheap way to make two otherwise identical patches sound different: filter before " +
|
|
4828
|
+
"delay smears the resonance into the repeats, delay before filter darkens the repeats instead. Crusher " +
|
|
4829
|
+
"before filter grinds then tames; filter before crusher grinds only what survived. Reordering rebuilds the " +
|
|
4830
|
+
"cell's graph.",
|
|
4831
|
+
inputSchema: {
|
|
4832
|
+
cellId: z.number().int().min(1).max(8).describe("Cell to read or reorder."),
|
|
4833
|
+
op: z.enum(["get", "set"]).default("set").describe("'get' reads the current chain; 'set' replaces it."),
|
|
4834
|
+
order: z
|
|
4835
|
+
.array(z.string().min(1))
|
|
4836
|
+
.optional()
|
|
4837
|
+
.describe("Full product-specific chain order for 'set' — a permutation of every stage reported by design_context."),
|
|
4838
|
+
},
|
|
4839
|
+
annotations: { destructiveHint: false, idempotentHint: true },
|
|
4840
|
+
}, async ({ cellId, op, order }) => {
|
|
4841
|
+
const check = await requireBridgeForCells(cellId);
|
|
4842
|
+
if (check !== true)
|
|
4843
|
+
return check;
|
|
4844
|
+
const key = `cell${cellId}_effectOrder`;
|
|
4845
|
+
if (op === "get") {
|
|
4846
|
+
const raw = await bridge.send("getState", key);
|
|
4847
|
+
return json(isRecord(raw) ? raw[key] ?? raw : raw);
|
|
4848
|
+
}
|
|
4849
|
+
if (!order)
|
|
4850
|
+
return error("op 'set' requires an `order` array.");
|
|
4851
|
+
const manifest = await loadManifestForProductGuard();
|
|
4852
|
+
if (!manifest) {
|
|
4853
|
+
return error("The selected product manifest is unavailable; its complete FX chain cannot be validated safely.");
|
|
4854
|
+
}
|
|
4855
|
+
const expectedOrder = cellEffectOrder(manifest);
|
|
4856
|
+
if (expectedOrder.length === 0) {
|
|
4857
|
+
return error(`${manifest.name ?? "The selected product"} manifest has no valid cell1_effectOrder default.`);
|
|
4858
|
+
}
|
|
4859
|
+
const seen = new Set(order);
|
|
4860
|
+
if (seen.size !== order.length) {
|
|
4861
|
+
const dupes = order.filter((s, i) => order.indexOf(s) !== i);
|
|
4862
|
+
return error(`Duplicate stage(s) in order: ${[...new Set(dupes)].join(", ")}.`);
|
|
4863
|
+
}
|
|
4864
|
+
// A subset would silently drop stages from the chain rather than bypass
|
|
4865
|
+
// them, so it is rejected here instead of at the plugin.
|
|
4866
|
+
const expected = new Set(expectedOrder);
|
|
4867
|
+
const unknown = order.filter((stage) => !expected.has(stage));
|
|
4868
|
+
const missing = expectedOrder.filter((stage) => !seen.has(stage));
|
|
4869
|
+
if (unknown.length || missing.length) {
|
|
4870
|
+
return error(`order must be a full ${manifest.name ?? "product"} permutation` +
|
|
4871
|
+
`${unknown.length ? ` — unknown: ${[...new Set(unknown)].join(", ")}` : ""}` +
|
|
4872
|
+
`${missing.length ? ` — missing: ${missing.join(", ")}` : ""}. ` +
|
|
4873
|
+
"To bypass a stage set its *Enabled flag via update_effect_config; leaving it out of the chain is not the same thing.");
|
|
4874
|
+
}
|
|
4875
|
+
// effectOrder rebuilds the cell's FX graph, like an MFX re-patch.
|
|
4876
|
+
await applyKv({ [key]: order }, KV_OPTS_STANDARD);
|
|
4877
|
+
return text(`Cell ${cellId} FX chain: ${order.join(" → ")}.`);
|
|
4878
|
+
});
|
|
3786
4879
|
server.registerTool("update_cell_config", {
|
|
3787
4880
|
title: "Update Cell Config",
|
|
3788
4881
|
description: "Update cell-level configuration properties like polyphony, MIDI settings, varispeed, and modulation latch. " +
|
|
@@ -4218,7 +5311,7 @@ server.registerTool("add_param_seq", {
|
|
|
4218
5311
|
speed: z.number().min(0.0625).max(16).default(1).describe("Pattern speed multiplier."),
|
|
4219
5312
|
},
|
|
4220
5313
|
}, async ({ paramId, cellId, sequence, strength, bipolar, length, speed }) => {
|
|
4221
|
-
const check = await
|
|
5314
|
+
const check = await requireBridgeForCells(cellId);
|
|
4222
5315
|
if (check !== true)
|
|
4223
5316
|
return check;
|
|
4224
5317
|
const index = await getParameterIndex();
|
|
@@ -4304,7 +5397,7 @@ server.registerTool("normalize_sample", {
|
|
|
4304
5397
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
4305
5398
|
},
|
|
4306
5399
|
}, async ({ cellId }) => {
|
|
4307
|
-
const check = await
|
|
5400
|
+
const check = await requireBridgeForCells(cellId);
|
|
4308
5401
|
if (check !== true)
|
|
4309
5402
|
return check;
|
|
4310
5403
|
await bridge.send("normalizeSampleForCell", cellId);
|
|
@@ -4323,7 +5416,7 @@ server.registerTool("gain_sample", {
|
|
|
4323
5416
|
.describe("Gain in decibels (e.g. 3 = +3 dB louder, -6 = 6 dB quieter)."),
|
|
4324
5417
|
},
|
|
4325
5418
|
}, async ({ cellId, gainDb }) => {
|
|
4326
|
-
const check = await
|
|
5419
|
+
const check = await requireBridgeForCells(cellId);
|
|
4327
5420
|
if (check !== true)
|
|
4328
5421
|
return check;
|
|
4329
5422
|
await bridge.send("gainSampleForCell", { cellId, gainDb });
|
|
@@ -4392,7 +5485,7 @@ server.registerTool("export_audio", {
|
|
|
4392
5485
|
.describe("Bit depth (16 or 24)."),
|
|
4393
5486
|
},
|
|
4394
5487
|
}, async ({ bars, cellId, mode, selectedCellIds, bitDepth }) => {
|
|
4395
|
-
const check = await
|
|
5488
|
+
const check = await requireBridgeForCells(cellId, selectedCellIds);
|
|
4396
5489
|
if (check !== true)
|
|
4397
5490
|
return check;
|
|
4398
5491
|
// Resolve the effective mode. A bare cellId means "individual".
|
|
@@ -4430,7 +5523,7 @@ server.registerTool("export_audio", {
|
|
|
4430
5523
|
const bpm = typeof transport.bpm === "number" && transport.bpm > 0 ? transport.bpm : 120;
|
|
4431
5524
|
// 4/4 assumption for the placeholder duration; 'song-mode' recomputes its own length from the timeline.
|
|
4432
5525
|
const durationInSeconds = (bars * 4 * 60) / bpm;
|
|
4433
|
-
const filename =
|
|
5526
|
+
const filename = `${SERVER_PRODUCT.id}_export_${effectiveMode.replace(/[^a-z0-9]/gi, "_")}_${Date.now()}`;
|
|
4434
5527
|
const options = {
|
|
4435
5528
|
filename,
|
|
4436
5529
|
mode: effectiveMode,
|
|
@@ -4445,12 +5538,8 @@ server.registerTool("export_audio", {
|
|
|
4445
5538
|
const outcome = await pollExportStatus();
|
|
4446
5539
|
if (outcome === "success")
|
|
4447
5540
|
return text(`Export complete (mode '${effectiveMode}').\n` +
|
|
4448
|
-
`File(s) written to
|
|
4449
|
-
loudnessReportLine()
|
|
4450
|
-
(effectiveMode === "song-mode"
|
|
4451
|
-
? "\n\nReminder: the offline song-mode bounce captures only the first section's state. " +
|
|
4452
|
-
"Use export_song for a full track with section changes."
|
|
4453
|
-
: ""));
|
|
5541
|
+
`File(s) written to ${SERVER_PRODUCT.name}'s Exports folder, base name: ${filename}.wav` +
|
|
5542
|
+
loudnessReportLine());
|
|
4454
5543
|
if (outcome === "failed")
|
|
4455
5544
|
return error(`Export failed (mode '${effectiveMode}').`);
|
|
4456
5545
|
return text(`Export timed out (mode '${effectiveMode}'). The render may still be running.`);
|
|
@@ -4458,8 +5547,8 @@ server.registerTool("export_audio", {
|
|
|
4458
5547
|
// ============================================================
|
|
4459
5548
|
// RESOURCES
|
|
4460
5549
|
// ============================================================
|
|
4461
|
-
server.registerResource("parameter-manifest",
|
|
4462
|
-
title:
|
|
5550
|
+
server.registerResource("parameter-manifest", `${SERVER_PRODUCT.resourceScheme}://parameters`, {
|
|
5551
|
+
title: `${SERVER_PRODUCT.name} Parameter Manifest`,
|
|
4463
5552
|
description: "Complete parameter definitions with ranges, defaults, and metadata.",
|
|
4464
5553
|
mimeType: "application/json",
|
|
4465
5554
|
}, async (uri) => {
|
|
@@ -4473,8 +5562,8 @@ server.registerResource("parameter-manifest", "pam://parameters", {
|
|
|
4473
5562
|
],
|
|
4474
5563
|
};
|
|
4475
5564
|
});
|
|
4476
|
-
server.registerResource("parameter-summary",
|
|
4477
|
-
title:
|
|
5565
|
+
server.registerResource("parameter-summary", `${SERVER_PRODUCT.resourceScheme}://parameters/summary`, {
|
|
5566
|
+
title: `${SERVER_PRODUCT.name} Parameter Summary`,
|
|
4478
5567
|
description: "Human-readable parameter list with ranges and descriptions.",
|
|
4479
5568
|
mimeType: "text/plain",
|
|
4480
5569
|
}, async (uri) => {
|
|
@@ -4484,10 +5573,10 @@ server.registerResource("parameter-summary", "pam://parameters/summary", {
|
|
|
4484
5573
|
contents: [{ uri: uri.href, text: lines.join("\n") }],
|
|
4485
5574
|
};
|
|
4486
5575
|
});
|
|
4487
|
-
server.registerResource("cell-parameters", new ResourceTemplate(
|
|
5576
|
+
server.registerResource("cell-parameters", new ResourceTemplate(`${SERVER_PRODUCT.resourceScheme}://cell/{cellId}/parameters`, {
|
|
4488
5577
|
list: async () => ({
|
|
4489
|
-
resources: Array.from({ length:
|
|
4490
|
-
uri:
|
|
5578
|
+
resources: Array.from({ length: SERVER_PRODUCT.cellCount }, (_, i) => ({
|
|
5579
|
+
uri: `${SERVER_PRODUCT.resourceScheme}://cell/cell${i + 1}/parameters`,
|
|
4491
5580
|
name: `Cell ${i + 1} Parameters`,
|
|
4492
5581
|
})),
|
|
4493
5582
|
}),
|
|
@@ -4502,7 +5591,7 @@ server.registerResource("cell-parameters", new ResourceTemplate("pam://cell/{cel
|
|
|
4502
5591
|
contents: [{ uri: uri.href, text: JSON.stringify(params, null, 2) }],
|
|
4503
5592
|
};
|
|
4504
5593
|
});
|
|
4505
|
-
server.registerResource("global-parameters",
|
|
5594
|
+
server.registerResource("global-parameters", `${SERVER_PRODUCT.resourceScheme}://parameters/global`, {
|
|
4506
5595
|
title: "Global Parameters",
|
|
4507
5596
|
description: "Non-cell-specific parameters (volume, crossfade, looper, etc.).",
|
|
4508
5597
|
mimeType: "application/json",
|
|
@@ -4512,16 +5601,38 @@ server.registerResource("global-parameters", "pam://parameters/global", {
|
|
|
4512
5601
|
contents: [{ uri: uri.href, text: JSON.stringify(params, null, 2) }],
|
|
4513
5602
|
};
|
|
4514
5603
|
});
|
|
4515
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
"
|
|
4519
|
-
|
|
4520
|
-
}, async () => ({
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
5604
|
+
if (IS_MFX_SERVER) {
|
|
5605
|
+
server.registerResource("mfx-routing-guide", "mfx://guide/routing", {
|
|
5606
|
+
title: "MFX Routing Guide",
|
|
5607
|
+
description: "A concise workflow for cabling, ordering, shaping, and modulating an MFX patch.",
|
|
5608
|
+
mimeType: "text/markdown",
|
|
5609
|
+
}, async () => ({
|
|
5610
|
+
contents: [{
|
|
5611
|
+
uri: "mfx://guide/routing",
|
|
5612
|
+
text: `# MFX Routing Guide
|
|
5613
|
+
|
|
5614
|
+
MFX has four effects cells. Audio reaches a cell only when the cable patch routes a source into it.
|
|
5615
|
+
|
|
5616
|
+
1. Call \`set_mfx_routing\` with \`op: "get"\` before changing the patch.
|
|
5617
|
+
2. Use \`design_context\` to inspect the available effects, parameters, and default effect order.
|
|
5618
|
+
3. Replace the complete cable list with \`set_mfx_routing\` and \`op: "set"\`. A serial patch is input → cell1 → cell2 → cell3 → cell4 → output.
|
|
5619
|
+
4. Use \`set_effect_order\`, \`update_effect_config\`, and \`set_parameters\` to shape each cell.
|
|
5620
|
+
5. Add movement with the modulation tools, audition the result, and save it with \`save_preset\`.
|
|
5621
|
+
`,
|
|
5622
|
+
}],
|
|
5623
|
+
}));
|
|
5624
|
+
}
|
|
5625
|
+
else {
|
|
5626
|
+
server.registerResource("beat-making-guide", "pam://guide/beat-making", {
|
|
5627
|
+
title: "PAM Beat-Making Guide",
|
|
5628
|
+
description: "Comprehensive guide for creating beats with PAM. Covers cell roles, slicer for loops, " +
|
|
5629
|
+
"sidechain pumping, poly/arp patterns, macros, variations, gain staging, and master effects.",
|
|
5630
|
+
mimeType: "text/markdown",
|
|
5631
|
+
}, async () => ({
|
|
5632
|
+
contents: [
|
|
5633
|
+
{
|
|
5634
|
+
uri: "pam://guide/beat-making",
|
|
5635
|
+
text: `# PAM Beat-Making Guide
|
|
4525
5636
|
|
|
4526
5637
|
## Cell Architecture — 8 Cells, Each a Full Instrument
|
|
4527
5638
|
|
|
@@ -4861,9 +5972,10 @@ Index → multiplier: 0=1/32, 1=1/16, 2=1/12, 3=1/8, 4=1/6, 5=1/5, 6=1/4, 7=1/3,
|
|
|
4861
5972
|
### Pattern Direction
|
|
4862
5973
|
\`cell{N}_patternDirection\`: 0=Forward, 1=Backward, 2=Ping-Pong, 3=Random Cycle (shuffle each loop), 4=Random Step (each step independent)
|
|
4863
5974
|
`,
|
|
4864
|
-
|
|
4865
|
-
|
|
4866
|
-
}));
|
|
5975
|
+
},
|
|
5976
|
+
],
|
|
5977
|
+
}));
|
|
5978
|
+
}
|
|
4867
5979
|
// ============================================================
|
|
4868
5980
|
// PROMPTS — guided workflows (client-invokable, e.g. slash
|
|
4869
5981
|
// commands in Claude Code). Each stages the same flows the
|
|
@@ -4872,7 +5984,7 @@ Index → multiplier: 0=1/32, 1=1/16, 2=1/12, 3=1/8, 4=1/6, 5=1/5, 6=1/4, 7=1/3,
|
|
|
4872
5984
|
// Mirror of RANDOMIZER_GENRES (shared/randomizer/types.ts) — the published
|
|
4873
5985
|
// package can't import repo-relative shared modules, so the copy is pinned by
|
|
4874
5986
|
// interface/test/randomizerGenre.test.ts (source-text parity check).
|
|
4875
|
-
const GENRE_SUGGESTIONS = ["boom_bap", "trap", "drill", "house", "techno", "dnb"];
|
|
5987
|
+
const GENRE_SUGGESTIONS = ["boom_bap", "trap", "drill", "house", "techno", "dnb", "idm"];
|
|
4876
5988
|
const completeGenre = (value) => GENRE_SUGGESTIONS.filter((g) => g.startsWith((value ?? "").toLowerCase()));
|
|
4877
5989
|
// completable() tags the schema OBJECT with a symbol, so ordering is delicate:
|
|
4878
5990
|
// .describe()/.optional() clone schemas and drop the tag, SDK 1.29 unwraps
|
|
@@ -4880,7 +5992,7 @@ const completeGenre = (value) => GENRE_SUGGESTIONS.filter((g) => g.startsWith((v
|
|
|
4880
5992
|
// completer at request time — the tag must sit on both the inner type and the
|
|
4881
5993
|
// outer optional. Covered by the completion assertion in check-tool-surface's
|
|
4882
5994
|
// sibling smoke; revisit when the SDK unifies the two lookups.
|
|
4883
|
-
const genreArg = completable(completable(z.string().describe("Genre hint, e.g. techno, house, dnb, trap, boom_bap, drill."), completeGenre).optional(), completeGenre);
|
|
5995
|
+
const genreArg = completable(completable(z.string().describe("Genre hint, e.g. techno, house, dnb, trap, boom_bap, drill, idm."), completeGenre).optional(), completeGenre);
|
|
4884
5996
|
function promptText(text) {
|
|
4885
5997
|
return {
|
|
4886
5998
|
messages: [
|
|
@@ -4888,65 +6000,93 @@ function promptText(text) {
|
|
|
4888
6000
|
],
|
|
4889
6001
|
};
|
|
4890
6002
|
}
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
4898
|
-
}, ({
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
|
|
4902
|
-
|
|
4903
|
-
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
4922
|
-
},
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
|
|
4930
|
-
server.registerPrompt("
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
4944
|
-
|
|
4945
|
-
|
|
4946
|
-
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
|
|
6003
|
+
if (IS_MFX_SERVER) {
|
|
6004
|
+
server.registerPrompt("design-a-preset", {
|
|
6005
|
+
title: "Design an MFX Preset",
|
|
6006
|
+
description: "Turn a creative effects brief into a routed, modulated, saved MFX preset.",
|
|
6007
|
+
argsSchema: {
|
|
6008
|
+
brief: z.string().describe("Creative brief, e.g. 'wide rhythmic distortion with a filtered parallel tail'."),
|
|
6009
|
+
},
|
|
6010
|
+
}, ({ brief }) => promptText(`Design an MFX preset from this brief: "${brief}"\n\n` +
|
|
6011
|
+
`1. Call connection_status, design_context, and get_design_overview.\n` +
|
|
6012
|
+
`2. Read the current cable patch with set_mfx_routing op 'get', then replace the full patch only if the routing needs to change.\n` +
|
|
6013
|
+
`3. Shape cells 1-4 with set_effect_order, update_effect_config, and set_parameters.\n` +
|
|
6014
|
+
`4. Add movement with add_modulation or add_param_seq, then audition and refine.\n` +
|
|
6015
|
+
`5. Save the result with save_preset and useful metadata.`));
|
|
6016
|
+
server.registerPrompt("troubleshoot-bridge", {
|
|
6017
|
+
title: "Troubleshoot the MFX Bridge",
|
|
6018
|
+
description: "Diagnose why live tools cannot reach MFX.",
|
|
6019
|
+
argsSchema: {},
|
|
6020
|
+
}, () => promptText(`Diagnose the MFX MCP connection:\n\n` +
|
|
6021
|
+
`1. Run connection_status. If no instance appears, start MFX and enable Settings → Experimental → MCP Bridge.\n` +
|
|
6022
|
+
`2. MFX starts at the configured port and automatically takes the next available port when needed; do not assign a second fixed product port.\n` +
|
|
6023
|
+
`3. If an instance appears but cannot connect, it may have closed or be busy with a render; retry once.\n` +
|
|
6024
|
+
`4. If a live tool is unavailable, update the MFX plugin as well as the mfx-mcp package.`));
|
|
6025
|
+
}
|
|
6026
|
+
if (!IS_MFX_SERVER)
|
|
6027
|
+
server.registerPrompt("build-a-beat", {
|
|
6028
|
+
title: "Build a Beat",
|
|
6029
|
+
description: "Guided kit-building + pattern workflow: coherent samples, genre patterns, transport, refinement, save.",
|
|
6030
|
+
argsSchema: {
|
|
6031
|
+
genre: genreArg,
|
|
6032
|
+
bpm: z.string().optional().describe("Target tempo in BPM."),
|
|
6033
|
+
},
|
|
6034
|
+
}, ({ genre, bpm }) => promptText(`Build a beat in PAM${genre ? ` in the style of ${genre}` : ""}${bpm ? ` at ${bpm} BPM` : ""}. Work in stages, listening between them:\n\n` +
|
|
6035
|
+
`1. connection_status to confirm the bridge, then get_design_overview for the current state.\n` +
|
|
6036
|
+
`2. Load a kit: randomize_samples${genre ? ` with genreHint '${genre}'` : " (pass a genreHint)"} — the native engine picks role-coherent samples; prefer it over manual browsing. For specific sounds: get_default_locations → list_samples → load_sample.\n` +
|
|
6037
|
+
`3. ${bpm ? `set_bpm to ${bpm}` : "set_bpm to the genre's tempo"}, then randomize_patterns to generate genre-appropriate patterns from the loaded sample roles.\n` +
|
|
6038
|
+
`4. start_transport — sequencer output needs the transport rolling.\n` +
|
|
6039
|
+
`5. Refine: update_pattern for individual lanes, set_parameters for levels/filters (ranges via design_context), update_effect_config to switch FX on, add_modulation for movement.\n` +
|
|
6040
|
+
`6. save_preset once it works, and update_preset_metadata with genre tags and BPM.`));
|
|
6041
|
+
if (!IS_MFX_SERVER)
|
|
6042
|
+
server.registerPrompt("make-a-song", {
|
|
6043
|
+
title: "Make a Song",
|
|
6044
|
+
description: "Arrange the current preset into sections with variations + Song Mode, then bounce a full track.",
|
|
6045
|
+
argsSchema: {
|
|
6046
|
+
bars: z.string().optional().describe("Approximate song length in bars."),
|
|
6047
|
+
},
|
|
6048
|
+
}, ({ bars }) => promptText(`Turn the current PAM preset into an arranged song${bars ? ` of roughly ${bars} bars` : ""}:\n\n` +
|
|
6049
|
+
`1. Design 2-4 contrasting sections (intro / groove / peak / outro): shape the state for each, then save_variation into slots 0-7.\n` +
|
|
6050
|
+
`2. Arrange with song_mode op 'setArrangement' — an ordered sections list ({variationIndex, bars, repeat}, minimum 2 sections). Read it back with op 'get'.\n` +
|
|
6051
|
+
`3. Audition: start_transport with Song Mode driving the timeline; queue_variation switches sections live.\n` +
|
|
6052
|
+
`4. Bounce with export_song — strategy 'live' captures the ground-truth master mix (required for Magenta/Notochord/automation), strategy 'offline' is the deterministic multi-section bounce with no bar cap. includeVideo:true films the VX scene (window must be open).\n` +
|
|
6053
|
+
`5. The rendered file lands in PAM's Exports/Resamples locations — get_default_locations resolves them.`));
|
|
6054
|
+
if (!IS_MFX_SERVER)
|
|
6055
|
+
server.registerPrompt("design-a-preset", {
|
|
6056
|
+
title: "Design a Preset",
|
|
6057
|
+
description: "Sound-design workflow from a creative brief to a saved preset.",
|
|
6058
|
+
argsSchema: {
|
|
6059
|
+
brief: z.string().describe("Creative brief, e.g. 'dusty lo-fi kit with a wobbly sub'."),
|
|
6060
|
+
},
|
|
6061
|
+
}, ({ brief }) => promptText(`Design a PAM preset from this brief: "${brief}"\n\n` +
|
|
6062
|
+
`1. Read design_context (curated parameter cheat sheet) and get_design_overview first — avoid full get_state dumps.\n` +
|
|
6063
|
+
`2. Source sounds: randomize_samples with a genre hint, or get_default_locations → list_samples → load_sample. generate_sample creates bespoke hits (AI engines need update_settings experimental flags + a downloaded model).\n` +
|
|
6064
|
+
`3. Shape each cell: set_parameters (design_context teaches ranges and units), update_effect_config for FX chains, set_drum_synth for synthesized drum layers.\n` +
|
|
6065
|
+
`4. Add movement: add_modulation (LFO / envelope / vary), add_param_seq for stepped motion.\n` +
|
|
6066
|
+
`5. Write patterns with update_pattern, then start_transport to audition.\n` +
|
|
6067
|
+
`6. save_preset with a fitting name and update_preset_metadata (category, tags, BPM).`));
|
|
6068
|
+
if (!IS_MFX_SERVER)
|
|
6069
|
+
server.registerPrompt("mix-and-master", {
|
|
6070
|
+
title: "Mix and Master",
|
|
6071
|
+
description: "Balance the cells and shape the master bus using the analysis + suggestion tools.",
|
|
6072
|
+
argsSchema: {},
|
|
6073
|
+
}, () => promptText(`Balance and master the current PAM project:\n\n` +
|
|
6074
|
+
`1. get_mix_analysis with refresh:true (offline per-cell stem measurements — takes a few seconds) and get_master_analysis (live loudness snapshot).\n` +
|
|
6075
|
+
`2. suggest_mix for rule-based balance / pan / EQ / dynamics moves; apply the returned parameter moves with set_parameters and enable keys with update_effect_config.\n` +
|
|
6076
|
+
`3. Shape the master bus with set_master_effects (compressor, tape, limiter — design_context lists the keys).\n` +
|
|
6077
|
+
`4. Re-check get_master_analysis and iterate toward the loudness target.\n` +
|
|
6078
|
+
`5. Render with export_audio (mode 'main'; 'all-cells' for stems).`));
|
|
6079
|
+
if (!IS_MFX_SERVER)
|
|
6080
|
+
server.registerPrompt("troubleshoot-bridge", {
|
|
6081
|
+
title: "Troubleshoot the PAM Bridge",
|
|
6082
|
+
description: "Diagnose why live tools can't reach the plugin.",
|
|
6083
|
+
argsSchema: {},
|
|
6084
|
+
}, () => promptText(`Diagnose the PAM MCP connection:\n\n` +
|
|
6085
|
+
`1. connection_status. No instances → PAM isn't running or the bridge is off (PAM Settings → Experimental Mode → MCP Bridge, default port 9847). Multiple instances → select_instance picks which product the live tools drive.\n` +
|
|
6086
|
+
`2. Instances found but not connected → the plugin may have shut down, or its message thread is busy (heavy render); retry once.\n` +
|
|
6087
|
+
`3. Timeouts on WebView-routed tools (video tools, capture_ui, set_drum_synth_wave, randomize target 'synth') → the plugin window must be OPEN.\n` +
|
|
6088
|
+
`4. "PAM build does not support 'X'" errors → the plugin is older than this server; the user must update PAM (updating the npm package alone is not enough).\n` +
|
|
6089
|
+
`5. Offline tools always work without a plugin: get_parameter_info, design_context, list_presets, read_preset, get_manifest_summary.`));
|
|
4950
6090
|
// ============================================================
|
|
4951
6091
|
// TOOLS — Looper (state deltas)
|
|
4952
6092
|
// ============================================================
|
|
@@ -4975,7 +6115,7 @@ server.registerTool("arm_looper_track", {
|
|
|
4975
6115
|
armed: z.boolean().describe("true to arm, false to disarm."),
|
|
4976
6116
|
},
|
|
4977
6117
|
}, async ({ cellId, armed }) => {
|
|
4978
|
-
const check = await
|
|
6118
|
+
const check = await requireBridgeForCells(cellId);
|
|
4979
6119
|
if (check !== true)
|
|
4980
6120
|
return check;
|
|
4981
6121
|
await applyLoopersDelta({ [cellId]: { armed: armed ? 1 : 0 } });
|
|
@@ -5075,7 +6215,7 @@ server.registerTool("clear_looper_track", {
|
|
|
5075
6215
|
cellId: z.number().min(1).max(8).describe("Looper track / cell (1-8)."),
|
|
5076
6216
|
},
|
|
5077
6217
|
}, async ({ cellId }) => {
|
|
5078
|
-
const check = await
|
|
6218
|
+
const check = await requireBridgeForCells(cellId);
|
|
5079
6219
|
if (check !== true)
|
|
5080
6220
|
return check;
|
|
5081
6221
|
await applyLoopersDelta({
|
|
@@ -5103,7 +6243,7 @@ server.registerTool("set_looper_track_selection", {
|
|
|
5103
6243
|
}, async ({ cellId, start, end }) => {
|
|
5104
6244
|
if (end <= start)
|
|
5105
6245
|
return error("end must be > start");
|
|
5106
|
-
const check = await
|
|
6246
|
+
const check = await requireBridgeForCells(cellId);
|
|
5107
6247
|
if (check !== true)
|
|
5108
6248
|
return check;
|
|
5109
6249
|
await applyLoopersDelta({
|
|
@@ -5137,7 +6277,7 @@ server.registerTool("start_looper_recording", {
|
|
|
5137
6277
|
.describe("Per-track metadata. Defaults: replace mode, cell-output source. Pass existingClipPaths to enable overdub on a prior layer."),
|
|
5138
6278
|
},
|
|
5139
6279
|
}, async ({ loopBars, armedCellIds, trackMetadata }) => {
|
|
5140
|
-
const check = await
|
|
6280
|
+
const check = await requireBridgeForCells(armedCellIds);
|
|
5141
6281
|
if (check !== true)
|
|
5142
6282
|
return check;
|
|
5143
6283
|
// Arm in UI state too — the plugin's live recording overlay and progress
|
|
@@ -5211,7 +6351,7 @@ server.registerTool("request_looper_warp", {
|
|
|
5211
6351
|
cellId: z.number().min(1).max(8).describe("Looper track (1-8)."),
|
|
5212
6352
|
},
|
|
5213
6353
|
}, async ({ cellId }) => {
|
|
5214
|
-
const check = await
|
|
6354
|
+
const check = await requireBridgeForCells(cellId);
|
|
5215
6355
|
if (check !== true)
|
|
5216
6356
|
return check;
|
|
5217
6357
|
await bridge.send("requestLooperWarp", cellId);
|
|
@@ -5951,7 +7091,7 @@ server.registerTool("export_song", {
|
|
|
5951
7091
|
.describe("For legacy cell-capture 'live': extra bars captured after the arrangement end for tails."),
|
|
5952
7092
|
},
|
|
5953
7093
|
}, async ({ strategy, bars, cellId, includeVideo, name, normalize, encoderId, bitDepth, tailBars }) => {
|
|
5954
|
-
const check = await
|
|
7094
|
+
const check = await requireBridgeForCells(cellId);
|
|
5955
7095
|
if (check !== true)
|
|
5956
7096
|
return check;
|
|
5957
7097
|
// -------- Offline audio-only: headless multi-section bounce --------
|
|
@@ -6160,7 +7300,7 @@ server.registerTool("preview_slice", {
|
|
|
6160
7300
|
sliceNumber: z.number().min(0).describe("Slice index."),
|
|
6161
7301
|
},
|
|
6162
7302
|
}, async ({ cellId, sliceNumber }) => {
|
|
6163
|
-
const check = await
|
|
7303
|
+
const check = await requireBridgeForCells(cellId);
|
|
6164
7304
|
if (check !== true)
|
|
6165
7305
|
return check;
|
|
6166
7306
|
await bridge.send("previewSlice", cellId, sliceNumber);
|
|
@@ -6199,7 +7339,7 @@ server.registerTool("restore_original_sample", {
|
|
|
6199
7339
|
cellId: z.number().min(1).max(8),
|
|
6200
7340
|
},
|
|
6201
7341
|
}, async ({ cellId }) => {
|
|
6202
|
-
const check = await
|
|
7342
|
+
const check = await requireBridgeForCells(cellId);
|
|
6203
7343
|
if (check !== true)
|
|
6204
7344
|
return check;
|
|
6205
7345
|
await bridge.send("restoreOriginalSample", { cellId });
|
|
@@ -6213,7 +7353,7 @@ server.registerTool("build_sample_chain", {
|
|
|
6213
7353
|
cellId: z.number().min(1).max(8).describe("Destination cell."),
|
|
6214
7354
|
},
|
|
6215
7355
|
}, async ({ filePaths, cellId }) => {
|
|
6216
|
-
const check = await
|
|
7356
|
+
const check = await requireBridgeForCells(cellId);
|
|
6217
7357
|
if (check !== true)
|
|
6218
7358
|
return check;
|
|
6219
7359
|
const result = await bridge.send("buildSampleChain", filePaths, cellId);
|
|
@@ -6227,7 +7367,7 @@ server.registerTool("export_slice", {
|
|
|
6227
7367
|
sliceIndex: z.number().min(0),
|
|
6228
7368
|
},
|
|
6229
7369
|
}, async ({ cellId, sliceIndex }) => {
|
|
6230
|
-
const check = await
|
|
7370
|
+
const check = await requireBridgeForCells(cellId);
|
|
6231
7371
|
if (check !== true)
|
|
6232
7372
|
return check;
|
|
6233
7373
|
await bridge.send("exportSliceForCell", { cellId, sliceIndex });
|
|
@@ -6272,14 +7412,21 @@ server.registerTool("live_resample", {
|
|
|
6272
7412
|
.describe("For 'stop': finalize the captured clip (false discards)."),
|
|
6273
7413
|
},
|
|
6274
7414
|
}, async (args) => {
|
|
6275
|
-
const check = await
|
|
7415
|
+
const check = await requireBridgeForCells(args.op === "start" ? args.cellId : undefined);
|
|
6276
7416
|
if (check !== true)
|
|
6277
7417
|
return check;
|
|
6278
7418
|
if (args.op === "start") {
|
|
7419
|
+
if (activeLiveResample) {
|
|
7420
|
+
return error(`A live resample started by this server is already targeting cell ${activeLiveResample.cellId}. Stop it first.`);
|
|
7421
|
+
}
|
|
6279
7422
|
if (typeof args.cellId !== "number" || typeof args.bars !== "number")
|
|
6280
7423
|
return error("'start' requires cellId + bars");
|
|
6281
7424
|
const source = args.source ?? "cell";
|
|
6282
7425
|
const captureSource = source === "fullmix" ? 2 : source === "input" ? 1 : 0;
|
|
7426
|
+
const beforeRaw = await bridge.send("getState");
|
|
7427
|
+
const beforeCells = isRecord(beforeRaw) && isRecord(beforeRaw.cells) ? beforeRaw.cells : {};
|
|
7428
|
+
const beforeCell = beforeCells[String(args.cellId)];
|
|
7429
|
+
const before = isRecord(beforeCell) ? beforeCell : {};
|
|
6283
7430
|
await bridge.send("resampleGraphToCell", JSON.stringify({
|
|
6284
7431
|
cellId: args.cellId,
|
|
6285
7432
|
bars: args.bars,
|
|
@@ -6290,10 +7437,48 @@ server.registerTool("live_resample", {
|
|
|
6290
7437
|
cellOnly: source === "cell",
|
|
6291
7438
|
normalize: args.normalize ?? false,
|
|
6292
7439
|
}));
|
|
7440
|
+
activeLiveResample = {
|
|
7441
|
+
cellId: args.cellId,
|
|
7442
|
+
previousPath: typeof before.filePath === "string" ? before.filePath : undefined,
|
|
7443
|
+
};
|
|
6293
7444
|
return text(`Started live resample → cell ${args.cellId} (${args.bars} bars, source '${source}').`);
|
|
6294
7445
|
}
|
|
6295
|
-
|
|
6296
|
-
|
|
7446
|
+
const finalize = args.finalize ?? true;
|
|
7447
|
+
await bridge.send("stopLiveResample", finalize);
|
|
7448
|
+
const capture = activeLiveResample;
|
|
7449
|
+
activeLiveResample = undefined;
|
|
7450
|
+
if (!finalize)
|
|
7451
|
+
return text("Stopped live resample and discarded the capture.");
|
|
7452
|
+
if (!capture)
|
|
7453
|
+
return error("Stopped live resample, but this server did not start it and cannot identify the destination cell.");
|
|
7454
|
+
let capturedPath;
|
|
7455
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
7456
|
+
const stateRaw = await bridge.send("getState");
|
|
7457
|
+
const state = isRecord(stateRaw) ? stateRaw : {};
|
|
7458
|
+
const stateCells = isRecord(state.cells) ? state.cells : {};
|
|
7459
|
+
const rawCell = stateCells[String(capture.cellId)];
|
|
7460
|
+
const cell = isRecord(rawCell) ? rawCell : {};
|
|
7461
|
+
if (typeof cell.filePath === "string" && cell.filePath && cell.filePath !== capture.previousPath) {
|
|
7462
|
+
capturedPath = cell.filePath;
|
|
7463
|
+
break;
|
|
7464
|
+
}
|
|
7465
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 250));
|
|
7466
|
+
}
|
|
7467
|
+
if (!capturedPath) {
|
|
7468
|
+
return error(`Capture finalized into cell ${capture.cellId}, but its new file path was not available after 5 seconds.`);
|
|
7469
|
+
}
|
|
7470
|
+
if (!isAbsolute(capturedPath)) {
|
|
7471
|
+
const locations = await bridge.send("getDefaultLocations");
|
|
7472
|
+
const userSamples = Array.isArray(locations)
|
|
7473
|
+
? locations.find((location) => isRecord(location) && location.name === "User Samples")
|
|
7474
|
+
: undefined;
|
|
7475
|
+
if (isRecord(userSamples) && typeof userSamples.path === "string") {
|
|
7476
|
+
const normalized = capturedPath.replaceAll("\\", "/");
|
|
7477
|
+
const userRelative = normalized.replace(/^Samples\/User\//, "");
|
|
7478
|
+
capturedPath = join(userSamples.path, userRelative);
|
|
7479
|
+
}
|
|
7480
|
+
}
|
|
7481
|
+
return text(`Stopped live resample and finalized cell ${capture.cellId}.\nFile: ${capturedPath}`);
|
|
6297
7482
|
});
|
|
6298
7483
|
// ============================================================
|
|
6299
7484
|
// TOOLS — Plugin host (scan/load/unload/param/bypass on hosted VST3/AU/CLAP)
|
|
@@ -6665,6 +7850,22 @@ server.registerTool("get_audio_device_setup", {
|
|
|
6665
7850
|
const setup = await bridge.send("getAudioDeviceSetup");
|
|
6666
7851
|
return json(setup);
|
|
6667
7852
|
});
|
|
7853
|
+
server.registerTool("get_memory_report", {
|
|
7854
|
+
title: "Get Memory Report",
|
|
7855
|
+
description: "Report the instance's audio memory: vfsBytes (decoded samples resident in the engine's virtual file system), " +
|
|
7856
|
+
"vfsRetiredBytes (superseded buffers not yet reclaimed), vfsEntryCount, looperRingBytes (allocated looper " +
|
|
7857
|
+
"capture rings), and residentBytes (whole-process RSS; residentBytesAvailable is false when the platform " +
|
|
7858
|
+
"could not answer, in which case residentBytes is 0 rather than empty). Use it to check whether loading, " +
|
|
7859
|
+
"replacing or auditioning samples leaves memory flat or growing.",
|
|
7860
|
+
inputSchema: {},
|
|
7861
|
+
annotations: { readOnlyHint: true },
|
|
7862
|
+
}, async () => {
|
|
7863
|
+
const check = await requireBridge();
|
|
7864
|
+
if (check !== true)
|
|
7865
|
+
return check;
|
|
7866
|
+
const report = await bridge.send("getMemoryReport");
|
|
7867
|
+
return json(report);
|
|
7868
|
+
});
|
|
6668
7869
|
server.registerTool("set_audio_device", {
|
|
6669
7870
|
title: "Set Audio Output Device",
|
|
6670
7871
|
description: "Switch the active audio OUTPUT device by name (standalone only — plugin-hosted builds let the DAW own the " +
|
|
@@ -6927,6 +8128,9 @@ const SET_VIEW_MAIN_VIEW = {
|
|
|
6927
8128
|
export: "Export",
|
|
6928
8129
|
presets: "PresetBrowser",
|
|
6929
8130
|
browser: "PresetBrowser",
|
|
8131
|
+
// MFX-only surfaces. The handler rejects them for other products.
|
|
8132
|
+
patch: "MfxRouting",
|
|
8133
|
+
patchbay: "MfxRouting",
|
|
6930
8134
|
};
|
|
6931
8135
|
// Dev-tree twin of the in-plugin assistant's set_view (toolpack-gated like
|
|
6932
8136
|
// ask_assistant — the published server deliberately excludes window/view
|
|
@@ -6946,12 +8150,13 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
|
|
|
6946
8150
|
view: z
|
|
6947
8151
|
.union([z.string(), z.number()])
|
|
6948
8152
|
.optional()
|
|
6949
|
-
.describe("Main view: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets."
|
|
8153
|
+
.describe("Main view: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets. " +
|
|
8154
|
+
"MFX also takes patch (the cable patchbay)."),
|
|
6950
8155
|
cellId: z.number().int().optional().describe("Cell to select/focus (1-8)."),
|
|
6951
8156
|
gridSize: z.number().optional().describe("How many pads the grid shows: 1, 4, or 8."),
|
|
6952
8157
|
},
|
|
6953
8158
|
}, async ({ view: viewArg, cellId, gridSize }) => {
|
|
6954
|
-
const check = await
|
|
8159
|
+
const check = await requireBridgeForCells(cellId);
|
|
6955
8160
|
if (check !== true)
|
|
6956
8161
|
return check;
|
|
6957
8162
|
let view = viewArg === undefined ? undefined : String(viewArg);
|
|
@@ -6967,7 +8172,12 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
|
|
|
6967
8172
|
if (typeof view === "string" && view.trim()) {
|
|
6968
8173
|
token = view.trim().toLowerCase();
|
|
6969
8174
|
if (!(token in SET_VIEW_MAIN_VIEW))
|
|
6970
|
-
return error(`Unknown view: ${view}. Views: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets.`);
|
|
8175
|
+
return error(`Unknown view: ${view}. Views: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets, patch.`);
|
|
8176
|
+
if (token === "patch" || token === "patchbay") {
|
|
8177
|
+
const manifest = await loadManifestForProductGuard();
|
|
8178
|
+
if (manifest && !isMfxManifest(manifest))
|
|
8179
|
+
return error(`${token} is available in MFX only.`);
|
|
8180
|
+
}
|
|
6971
8181
|
applied.view = token;
|
|
6972
8182
|
}
|
|
6973
8183
|
if (gridSize !== undefined) {
|
|
@@ -7185,13 +8395,55 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
|
|
|
7185
8395
|
// ============================================================
|
|
7186
8396
|
// Start
|
|
7187
8397
|
// ============================================================
|
|
8398
|
+
// A stdio server that dies takes its whole tool surface with it, and the client
|
|
8399
|
+
// only ever reports "Connection closed". Nothing short of an explicit exit is
|
|
8400
|
+
// worth that, so stray async faults are logged to stderr and the server lives.
|
|
8401
|
+
// The rule must stay BOUNDED: a fault that repeats every tick turns "the server
|
|
8402
|
+
// lives" into a permanent 100% CPU spin, and each report re-formats a tsx
|
|
8403
|
+
// source-mapped stack. Five orphaned servers burned a core each for over a week.
|
|
8404
|
+
const FAULT_WINDOW_MS = 10_000;
|
|
8405
|
+
const FAULT_LIMIT = 100;
|
|
8406
|
+
let faultTimes = [];
|
|
8407
|
+
function surviveFault(label, value) {
|
|
8408
|
+
const now = Date.now();
|
|
8409
|
+
faultTimes = faultTimes.filter((t) => now - t < FAULT_WINDOW_MS);
|
|
8410
|
+
faultTimes.push(now);
|
|
8411
|
+
if (faultTimes.length > FAULT_LIMIT)
|
|
8412
|
+
process.exit(1);
|
|
8413
|
+
try {
|
|
8414
|
+
console.error(label, value);
|
|
8415
|
+
}
|
|
8416
|
+
catch {
|
|
8417
|
+
process.exit(1);
|
|
8418
|
+
}
|
|
8419
|
+
}
|
|
8420
|
+
// EPIPE on our own stdio is the dead-client signal, not a stray fault: the SDK
|
|
8421
|
+
// writes JSON-RPC to stdout unguarded, and routing that through the handler
|
|
8422
|
+
// below made the logger re-raise the fault it was reporting. Exit at the source.
|
|
8423
|
+
for (const s of [process.stdout, process.stderr]) {
|
|
8424
|
+
s.on("error", (e) => {
|
|
8425
|
+
if (e?.code === "EPIPE")
|
|
8426
|
+
process.exit(0);
|
|
8427
|
+
});
|
|
8428
|
+
}
|
|
8429
|
+
process.on("unhandledRejection", (reason) => {
|
|
8430
|
+
surviveFault("[pam-mcp] unhandled rejection (server continuing):", reason);
|
|
8431
|
+
});
|
|
8432
|
+
process.on("uncaughtException", (err) => {
|
|
8433
|
+
surviveFault("[pam-mcp] uncaught exception (server continuing):", err);
|
|
8434
|
+
});
|
|
7188
8435
|
async function main() {
|
|
7189
8436
|
if (await handleCliArgs())
|
|
7190
8437
|
return;
|
|
7191
8438
|
// Try to connect to plugin on startup (non-blocking)
|
|
7192
|
-
void bridge
|
|
8439
|
+
void bridge
|
|
8440
|
+
.connect()
|
|
8441
|
+
.then((ok) => {
|
|
7193
8442
|
if (ok)
|
|
7194
8443
|
syncProductHint();
|
|
8444
|
+
})
|
|
8445
|
+
.catch((e) => {
|
|
8446
|
+
console.error("[pam-mcp] startup bridge connect failed:", e);
|
|
7195
8447
|
});
|
|
7196
8448
|
const transport = new StdioServerTransport();
|
|
7197
8449
|
await server.connect(transport);
|
|
@@ -7202,9 +8454,27 @@ main().catch((e) => {
|
|
|
7202
8454
|
console.error("Fatal:", e);
|
|
7203
8455
|
process.exit(1);
|
|
7204
8456
|
});
|
|
7205
|
-
|
|
8457
|
+
// MCP clients terminate stdio servers with SIGTERM as often as SIGINT; without
|
|
8458
|
+
// both, the bridge socket is left for the OS to reap.
|
|
8459
|
+
let shuttingDown = false;
|
|
8460
|
+
const shutdown = async () => {
|
|
8461
|
+
if (shuttingDown)
|
|
8462
|
+
return;
|
|
8463
|
+
shuttingDown = true;
|
|
7206
8464
|
bridge.disconnect();
|
|
7207
|
-
|
|
8465
|
+
try {
|
|
8466
|
+
await server.close();
|
|
8467
|
+
}
|
|
8468
|
+
catch {
|
|
8469
|
+
// Already torn down — exiting anyway.
|
|
8470
|
+
}
|
|
7208
8471
|
process.exit(0);
|
|
7209
|
-
}
|
|
8472
|
+
};
|
|
8473
|
+
process.on("SIGINT", shutdown);
|
|
8474
|
+
process.on("SIGTERM", shutdown);
|
|
8475
|
+
// A client that dies without signalling leaves no signal at all — the process is
|
|
8476
|
+
// reparented to launchd and only stdin EOF says the session is gone. Without this
|
|
8477
|
+
// the server outlives every crashed client; ten-day-old orphans were measured.
|
|
8478
|
+
process.stdin.on("end", () => void shutdown());
|
|
8479
|
+
process.stdin.on("close", () => void shutdown());
|
|
7210
8480
|
//# sourceMappingURL=server.js.map
|