@map-audio/pam-mcp-server 1.25.0 → 1.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -10
- package/dist/audioAnalysis.d.ts +1 -1
- package/dist/audioAnalysis.js +3 -4
- package/dist/audioAnalysis.js.map +1 -1
- package/dist/bridge.d.ts +5 -1
- package/dist/bridge.js +48 -48
- package/dist/bridge.js.map +1 -1
- package/dist/daemon.d.ts +32 -0
- package/dist/daemon.js +871 -0
- package/dist/daemon.js.map +7 -0
- package/dist/drumSynth.d.ts +12 -0
- package/dist/drumSynth.js +15 -3
- package/dist/drumSynth.js.map +1 -1
- package/dist/drumSynthEngines.json +9 -1
- package/dist/{fgemma-format.d.ts → functiongemma-format.d.ts} +25 -7
- package/dist/{fgemma-format.js → functiongemma-format.js} +34 -11
- package/dist/functiongemma-format.js.map +1 -0
- package/dist/functiongemma-grammar.d.ts +14 -0
- package/dist/functiongemma-grammar.js +140 -0
- package/dist/functiongemma-grammar.js.map +1 -0
- package/dist/functiongemma-params.d.ts +33 -0
- package/dist/functiongemma-params.js +128 -0
- package/dist/functiongemma-params.js.map +1 -0
- package/dist/{fgemma-runner.d.ts → functiongemma-runner.d.ts} +8 -5
- package/dist/{fgemma-runner.js → functiongemma-runner.js} +107 -25
- package/dist/functiongemma-runner.js.map +1 -0
- package/dist/generated/kvWriteOptions.d.ts +20 -0
- package/dist/generated/kvWriteOptions.js +45 -0
- package/dist/generated/kvWriteOptions.js.map +1 -0
- package/dist/generated/variationSnapshot.js +0 -19
- package/dist/generated/variationSnapshot.js.map +1 -1
- package/dist/generativePlayerPresets.json +9 -0
- package/dist/generativePlayers.json +15 -1
- package/dist/launcher.d.ts +2 -0
- package/dist/launcher.js +36 -0
- package/dist/launcher.js.map +1 -0
- package/dist/manifest-mfx.json +12944 -311
- package/dist/manifest-msq.json +11446 -0
- package/dist/manifest.d.ts +2 -0
- package/dist/manifest.js +12 -27
- package/dist/manifest.js.map +1 -1
- package/dist/manifest.json +37047 -6487
- package/dist/mfxCombos.d.ts +2 -0
- package/dist/mfxCombos.js +179 -56
- package/dist/mfxCombos.js.map +1 -1
- package/dist/midiFile.d.ts +20 -6
- package/dist/midiFile.js +22 -14
- package/dist/midiFile.js.map +1 -1
- package/dist/mixAssist.d.ts +35 -0
- package/dist/mixAssist.js +250 -21
- package/dist/mixAssist.js.map +1 -1
- package/dist/patternLanes.d.ts +40 -0
- package/dist/patternLanes.js +108 -0
- package/dist/patternLanes.js.map +1 -0
- package/dist/players.d.ts +22 -0
- package/dist/players.js +70 -0
- package/dist/players.js.map +1 -1
- package/dist/presetSave.d.ts +3 -0
- package/dist/presetSave.js +13 -0
- package/dist/presetSave.js.map +1 -1
- package/dist/productProfile.d.ts +4 -4
- package/dist/productProfile.js +10 -1
- package/dist/productProfile.js.map +1 -1
- package/dist/qwen-format.d.ts +85 -0
- package/dist/qwen-format.js +355 -0
- package/dist/qwen-format.js.map +1 -0
- package/dist/registration.mjs +41 -0
- package/dist/server.js +2112 -470
- package/dist/server.js.map +1 -1
- package/dist/targetDenylist.json +20 -0
- package/dist/variationSessionKeys.json +7 -5
- package/package.json +13 -7
- package/dist/fgemma-format.js.map +0 -1
- package/dist/fgemma-runner.js.map +0 -1
- package/dist/manifest-cell1.json +0 -12968
package/dist/server.js
CHANGED
|
@@ -4,26 +4,28 @@ 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 { CELL_CONFIG_KV_OPTIONS, EFFECT_CONFIG_KV_OPTIONS, MASTER_CONFIG_KV_OPTIONS, PATTERN_EDIT_KV_OPTIONS, STATE_UPDATE_KV_OPTIONS, } from "./generated/kvWriteOptions.js";
|
|
7
8
|
import { CORPUS_META, CORPUS_RANGES } from "./corpusRanges.js";
|
|
8
9
|
import { CORPUS_FINGERPRINTS, CORPUS_NOVELTY_BASELINE, CORPUS_VARIATION_BASELINE } from "./corpusFingerprints.js";
|
|
9
10
|
import { buildFingerprint, scoreNovelty, similarity as fingerprintSimilarity } from "./fingerprint.js";
|
|
10
11
|
import { analyzeMotion, decodeWav } from "./audioAnalysis.js";
|
|
11
12
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
12
|
-
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
13
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
13
14
|
import { execFileSync } from "node:child_process";
|
|
14
15
|
import { inflateSync } from "node:zlib";
|
|
15
16
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
16
17
|
import { fileURLToPath } from "node:url";
|
|
17
18
|
import { createDrumSynthCatalog, } from "./drumSynth.js";
|
|
18
|
-
import { loadManifest, cellEffectNames, cellEffectOrder, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, setActiveProductHint, walkPresets, } from "./manifest.js";
|
|
19
|
+
import { loadManifest, cellEffectNames, cellEffectOrder, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, productExportDir, setActiveProductHint, walkPresets, } from "./manifest.js";
|
|
19
20
|
import { PluginBridge } from "./bridge.js";
|
|
21
|
+
import { LANE_TARGET_FIELD, PATTERN_LANE_IDS, PATTERN_LANE_SUFFIX, CC_LANES, PARAM_LANES, ccLaneMayLand, ccLaneIndex, ccLaneSlotField, ccLaneSlotOf, CC_OUT_CONFIG_KEYS, CC_OUT_SLOTS, isCcLane, isCcOutValueSuffix, isParamLane, normalizeCcLaneEvents, normalizeTargetLaneEvents, productHasLane, resolveLaneTarget, } from "./patternLanes.js";
|
|
20
22
|
import { buildMixMeasurementFromState, currentFlatFromState, parseMixAnalysisPayload, suggestMix, } from "./mixAssist.js";
|
|
21
|
-
import { normalizePresetLookupPath, parsePresetFile, selectSaveVariationSourcePath, } from "./presetSave.js";
|
|
22
|
-
import { STOP_SEQUENCES, buildGenerationPrompt, parseFunctionCalls, renderContextLine, renderFunctionCall, renderFunctionResponse, truncateAtFabricatedResponse, withContext, } from "./
|
|
23
|
+
import { isFactoryPresetPath, nativePresetPath, normalizePresetLookupPath, parsePresetFile, selectSaveVariationSourcePath, } from "./presetSave.js";
|
|
24
|
+
import { STOP_SEQUENCES, buildGenerationPrompt, parseFunctionCalls, renderContextLine, renderFunctionCall, renderFunctionResponse, truncateAtFabricatedResponse, withContext, } from "./functiongemma-format.js";
|
|
23
25
|
import { mcpProductProfile } from "./productProfile.js";
|
|
24
|
-
import { MFX_COMBO_COUNT, MFX_COMBO_MANUAL_STRENGTH, buildMfxComboTarget, isMfxComboTargetParameter, mfxComboTargetValueFromCurrent, parseMfxComboSlots, reanchorMfxComboTarget, resolveMfxCombos, setMfxComboTargetStop, } from "./mfxCombos.js";
|
|
25
|
-
import { GENERATIVE_PLAYERS, LISTEN_MODES, MODULATABLE_PLAYER_FIELDS, NO_POLY_ARM, PATTERN_MODES, PLAYER_GENRES, PLAYER_NAMES, PLAYER_SLOT_FIELDS, cellPlayerType, describeCellPlayer, describePlayerSpec, getPlayerSpec, isMelodicPlayer, playerDefaultsFor, playerDensityDisplay, playerGenreConfig, playerMutateDisplay, playerPresetsFor, playerSlotDisplay, presetConfig, randomPlayerSeed, resolvePlayerPreset, resolvePlayerSpec, resolveSlotValue, rolePlayerConfigFor, roleSpreadSummary, snapPlayerValue, } from "./players.js";
|
|
26
|
-
import { buildMidiFile, patternSpeedMultiplier, tilePatternEvents } from "./midiFile.js";
|
|
26
|
+
import { MFX_COMBO_COUNT, MFX_COMBO_MANUAL_STRENGTH, buildMfxComboTarget, isMfxComboTargetParameter, mfxComboTargetValueFromCurrent, mfxComboTargetValueAfterRangeEdit, parseMfxComboSlots, reanchorMfxComboTarget, resolveMfxCombos, setMfxComboTargetStop, } from "./mfxCombos.js";
|
|
27
|
+
import { GENERATIVE_PLAYERS, LISTEN_MODES, LISTEN_PAIRS, MODULATABLE_PLAYER_FIELDS, NO_POLY_ARM, NO_SCALE_ARM, PATTERN_MODES, PLAYER_GENRES, PLAYER_GRIDS, PLAYER_NAMES, PLAYER_SLOT_FIELDS, cellPlayerType, describeCellPlayer, describePlayerSpec, getPlayerSpec, isMelodicPlayer, crowdedComplementCell, listenPartnerFor, soleComplementPartner, playerDefaultsFor, playerDensityDisplay, playerGenreConfig, playerGridValue, playerMutateDisplay, playerPresetsFor, playerSlotDisplay, presetConfig, randomPlayerSeed, resolvePlayerPreset, resolvePlayerSpec, resolveSlotValue, rolePlayerConfigFor, roleSpreadSummary, snapPlayerValue, } from "./players.js";
|
|
28
|
+
import { beatsPerBar, buildMidiFile, patternSpeedMultiplier, tilePatternEvents } from "./midiFile.js";
|
|
27
29
|
import { MAX_SLICES, detectOnsetBoundaries, equalBoundaries, normalizeBoundaries } from "./sampleSlicing.js";
|
|
28
30
|
// --- State ---
|
|
29
31
|
// Variation slot count. Must stay in sync with PRESET_VARIATION_SLOTS in
|
|
@@ -32,7 +34,56 @@ import { MAX_SLICES, detectOnsetBoundaries, equalBoundaries, normalizeBoundaries
|
|
|
32
34
|
const PRESET_VARIATION_SLOTS = 8;
|
|
33
35
|
const SERVER_PRODUCT = mcpProductProfile();
|
|
34
36
|
const IS_MFX_SERVER = SERVER_PRODUCT.id === "mfx";
|
|
37
|
+
const IS_MSQ_SERVER = SERVER_PRODUCT.id === "msq";
|
|
38
|
+
// A product launcher attaches to its own product only; pam-mcp follows whichever instance is connected.
|
|
39
|
+
const PINNED_PRODUCT = IS_MFX_SERVER || IS_MSQ_SERVER ? SERVER_PRODUCT.id : undefined;
|
|
40
|
+
if (PINNED_PRODUCT)
|
|
41
|
+
process.env.PAM_BRIDGE_PRODUCT = SERVER_PRODUCT.name;
|
|
42
|
+
const DAEMON_SESSION = process.argv.includes("--pamd-session");
|
|
43
|
+
function sessionOption(name) {
|
|
44
|
+
return process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
45
|
+
}
|
|
46
|
+
const SESSION_DATA_ROOT = sessionOption("data-root");
|
|
47
|
+
const SESSION_OUTPUT_DIR = sessionOption("out-dir");
|
|
48
|
+
const SESSION_DISCOVERY = sessionOption("discovery-file");
|
|
49
|
+
for (const [name, path] of [["data-root", SESSION_DATA_ROOT], ["out-dir", SESSION_OUTPUT_DIR], ["discovery-file", SESSION_DISCOVERY]])
|
|
50
|
+
if (path && !isAbsolute(path))
|
|
51
|
+
throw new Error(`${name} must be absolute`);
|
|
52
|
+
if (SESSION_DATA_ROOT)
|
|
53
|
+
process.env.PAM_AI_DATA_ROOT = SESSION_DATA_ROOT;
|
|
54
|
+
if (DAEMON_SESSION && (!SESSION_DATA_ROOT || !SESSION_OUTPUT_DIR || !SESSION_DISCOVERY))
|
|
55
|
+
throw new Error("pamd session requires captured data-root, out-dir and discovery-file");
|
|
56
|
+
// Loaded only for pam mcp sessions, so the plugin-only server starts without a built pamd client.
|
|
57
|
+
const daemon = DAEMON_SESSION
|
|
58
|
+
? new (await import("./daemon.js")).DaemonSession(SESSION_DISCOVERY, SESSION_DATA_ROOT, SESSION_OUTPUT_DIR) : undefined;
|
|
59
|
+
const daemonDownloads = new Map();
|
|
60
|
+
function daemonModel(engine, model) {
|
|
61
|
+
if (engine === "aigen")
|
|
62
|
+
return "stable-audio-3-small-sfx";
|
|
63
|
+
if (engine === "magenta")
|
|
64
|
+
return model === "base" ? "mrt2-base" : "mrt2-small";
|
|
65
|
+
throw new Error("unsupported_model: notochord remains managed by the plugin");
|
|
66
|
+
}
|
|
35
67
|
const bridge = new PluginBridge();
|
|
68
|
+
async function pinnedGeneration(args, pinned, signal) {
|
|
69
|
+
const destination = pinned ?? new PluginBridge();
|
|
70
|
+
const instance = bridge.getConnectedInstance();
|
|
71
|
+
if (!pinned && (!instance || !await destination.connect({ instanceId: instance.instanceId, product: instance.product })))
|
|
72
|
+
throw new Error("plugin_unavailable: selected instance disconnected");
|
|
73
|
+
try {
|
|
74
|
+
const result = await daemon.generate(args, signal);
|
|
75
|
+
if (args.cellId !== undefined) {
|
|
76
|
+
if (typeof result.wavPath !== "string" || !isAbsolute(result.wavPath))
|
|
77
|
+
throw new Error("invalid_result: generated WAV path missing");
|
|
78
|
+
await destination.send("loadSampleToCell", result.wavPath, args.cellId);
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
if (!pinned)
|
|
84
|
+
destination.disconnect();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
36
87
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
37
88
|
const cellModeChangeSeq = {};
|
|
38
89
|
let lastSavedPresetName;
|
|
@@ -73,12 +124,96 @@ function structuredFromBridge(raw) {
|
|
|
73
124
|
function error(msg) {
|
|
74
125
|
return { content: [{ type: "text", text: msg }], isError: true };
|
|
75
126
|
}
|
|
127
|
+
function daemonFailureCode(cause) {
|
|
128
|
+
try {
|
|
129
|
+
const detail = JSON.parse(cause instanceof Error ? cause.message : String(cause));
|
|
130
|
+
return isRecord(detail) && typeof detail.code === "string" ? detail.code : undefined;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const LOST_DAEMON_JOB = new Set(["job_expired", "not_found", "daemon_restarted", "unavailable"]);
|
|
137
|
+
function daemonFailure(cause, prefix = "") {
|
|
138
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
139
|
+
const result = error(prefix + message);
|
|
140
|
+
try {
|
|
141
|
+
const detail = JSON.parse(message);
|
|
142
|
+
if (isRecord(detail) && detail.ok === false && typeof detail.code === "string" && typeof detail.error === "string")
|
|
143
|
+
return { ...result, structuredContent: detail };
|
|
144
|
+
}
|
|
145
|
+
catch { }
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
76
148
|
function isMfxManifest(manifest) {
|
|
77
149
|
return (manifest.name?.toLowerCase() === "mfx" ||
|
|
78
150
|
manifest.bundleId?.toLowerCase() === "map.audio.mfx" ||
|
|
79
151
|
manifest.pluginCode?.toLowerCase() === "amfx");
|
|
80
152
|
}
|
|
81
|
-
|
|
153
|
+
function isMsqManifest(manifest) {
|
|
154
|
+
return (manifest.name?.toLowerCase() === "msq" ||
|
|
155
|
+
manifest.bundleId?.toLowerCase() === "map.audio.msq" ||
|
|
156
|
+
manifest.pluginCode?.toLowerCase() === "amsq");
|
|
157
|
+
}
|
|
158
|
+
const PACK_TOOL_NAMES = new Set(["list_packs", "install_pack", "get_pack_install_status", "uninstall_pack"]);
|
|
159
|
+
// MSQ sequences MIDI only. These tools drive a part of PAM it does not have, so on an
|
|
160
|
+
// MSQ instance they answer with the reason instead of writing state nothing reads.
|
|
161
|
+
const MSQ_MISSING_BY_TOOL = new Map([
|
|
162
|
+
...[
|
|
163
|
+
"load_sample", "randomize_samples", "trim_sample", "reverse_sample", "normalize_sample", "gain_sample",
|
|
164
|
+
"analyze_sample_pitch", "repitch_sample", "analyze_sample_tempo", "get_sample_peaks", "preview_slice",
|
|
165
|
+
"resample_sample_to_bpm", "restore_original_sample", "build_sample_chain", "export_slice", "set_slices",
|
|
166
|
+
"preview_sample", "get_sample_usage", "repair_preset_samples", "set_drum_synth", "set_drum_synth_wave",
|
|
167
|
+
"set_lane_target",
|
|
168
|
+
].map((tool) => [tool, "sampler"]),
|
|
169
|
+
...["update_effect_config", "set_effects_enabled", "set_effect_order", "set_spectral_delay_curve"].map((tool) => [tool, "cell effects"]),
|
|
170
|
+
...["set_master_effects", "get_master_analysis", "get_mix_analysis", "suggest_mix", "set_metronome"].map((tool) => [tool, "audio output"]),
|
|
171
|
+
...["export_audio", "export_song", "song_mode", "live_resample"].map((tool) => [tool, "audio render or Song Mode"]),
|
|
172
|
+
["dj_set", "DJ view"],
|
|
173
|
+
...[
|
|
174
|
+
"arm_looper_track", "set_looper_track", "clear_looper_track", "set_looper_track_selection",
|
|
175
|
+
"start_looper_recording", "stop_looper_recording", "capture_looper_now", "load_audio_to_looper_track",
|
|
176
|
+
"request_looper_warp", "looper_settings", "looper_variation", "looper_variation_sync",
|
|
177
|
+
].map((tool) => [tool, "looper"]),
|
|
178
|
+
...[
|
|
179
|
+
"set_visual", "list_visuals", "delete_visual", "get_visual_data_url", "import_visual_folder", "get_video_state",
|
|
180
|
+
"video_command", "add_video_layer", "update_video_layer", "set_video_layer_source", "remove_video_layer",
|
|
181
|
+
"apply_video_layout", "add_video_fx", "set_video_fx", "remove_video_fx", "bind_video_param", "set_video_settings",
|
|
182
|
+
"vx_variation", "analyze_motion",
|
|
183
|
+
].map((tool) => [tool, "VX video engine"]),
|
|
184
|
+
...[
|
|
185
|
+
"list_plugins", "scan_plugins", "cancel_plugin_scan", "get_plugin_scan_status", "list_loaded_plugins",
|
|
186
|
+
"load_plugin", "unload_plugin", "set_plugin_param", "set_plugin_params", "set_plugin_bypass",
|
|
187
|
+
"skip_current_plugin_scan", "remove_plugin_from_blocklist",
|
|
188
|
+
].map((tool) => [tool, "plugin host"]),
|
|
189
|
+
...[
|
|
190
|
+
"download_model", "get_model_status", "load_model", "unload_model", "delete_model", "list_models",
|
|
191
|
+
"generate_sample", "generate_kit", "workbench_get_state", "workbench_set_state", "workbench_render",
|
|
192
|
+
"workbench_list_takes", "workbench_load_take", "workbench_undo", "workbench_reset_chain",
|
|
193
|
+
"workbench_sidecar_status", "ask_assistant",
|
|
194
|
+
].map((tool) => [tool, "AI stack"]),
|
|
195
|
+
...[...PACK_TOOL_NAMES].map((tool) => [tool, "preset pack store"]),
|
|
196
|
+
]);
|
|
197
|
+
// Tools whose failures carry a code answer MSQ's refusal with one too.
|
|
198
|
+
const MSQ_MISSING_CODES = new Map([
|
|
199
|
+
...[...PACK_TOOL_NAMES].map((tool) => [tool, "pack_unsupported_product"]),
|
|
200
|
+
["dj_set", "dj_unsupported_product"],
|
|
201
|
+
]);
|
|
202
|
+
const MFX_CELL_CONFIG_KEYS = new Set(["sidechainFrom", "cellmixFrom", "cellmixPreFx"]);
|
|
203
|
+
// MSQ cells are MIDI tracks: their note, channel, scale and output wiring, never sampler config.
|
|
204
|
+
const MSQ_CELL_CONFIG_KEYS = new Set([
|
|
205
|
+
"isPoly", "midiNote", "midiChannel", "midiIsolate", "armed", "polyRootNote", "scaleName", "scaleRoot",
|
|
206
|
+
"midiOutChannel", "sendToHost", "midiOutPort", "polyNoteMin", "polyNoteMax", ...CC_OUT_CONFIG_KEYS,
|
|
207
|
+
...CC_LANES.map(ccLaneSlotField),
|
|
208
|
+
]);
|
|
209
|
+
// A CC out slot's controller (cell config ccOutCc{K}); 120-127 are channel-mode messages.
|
|
210
|
+
const ccOutControllerSchema = (slot) => z
|
|
211
|
+
.number()
|
|
212
|
+
.int()
|
|
213
|
+
.min(-1)
|
|
214
|
+
.max(119)
|
|
215
|
+
.optional()
|
|
216
|
+
.describe(`Controller number CC out slot ${slot} sends, -1=Off. Its value is the parameter cell{N}_ccOut${slot} (0-127).`);
|
|
82
217
|
async function loadManifestForProductGuard() {
|
|
83
218
|
try {
|
|
84
219
|
return await loadManifest();
|
|
@@ -115,7 +250,11 @@ function validateCellIdForManifest(cellId, manifest) {
|
|
|
115
250
|
return true;
|
|
116
251
|
}
|
|
117
252
|
function readPackageVersion() {
|
|
118
|
-
const override = IS_MFX_SERVER
|
|
253
|
+
const override = IS_MFX_SERVER
|
|
254
|
+
? process.env.MFX_MCP_SERVER_VERSION?.trim()
|
|
255
|
+
: IS_MSQ_SERVER
|
|
256
|
+
? process.env.MSQ_MCP_SERVER_VERSION?.trim()
|
|
257
|
+
: undefined;
|
|
119
258
|
if (override)
|
|
120
259
|
return override;
|
|
121
260
|
try {
|
|
@@ -162,12 +301,12 @@ async function requireBridge() {
|
|
|
162
301
|
if (!ok) {
|
|
163
302
|
const pin = process.env.PAM_BRIDGE_PRODUCT;
|
|
164
303
|
return error(`Not connected to ${SERVER_PRODUCT.name}. Start ${SERVER_PRODUCT.name} (standalone or in a DAW) with MCP bridge enabled. ` +
|
|
165
|
-
(
|
|
166
|
-
?
|
|
167
|
-
: "The bridge writes its
|
|
304
|
+
(PINNED_PRODUCT
|
|
305
|
+
? `The launcher finds ${SERVER_PRODUCT.name} through the local bridge registry; the plugin records its actual port on startup.`
|
|
306
|
+
: "The bridge writes an authenticated registration under its data folder on startup. If PAM is already running with the bridge enabled, update PAM to a version that supports authenticated MCP connections.") +
|
|
168
307
|
(pin
|
|
169
|
-
?
|
|
170
|
-
?
|
|
308
|
+
? PINNED_PRODUCT
|
|
309
|
+
? ` The ${SERVER_PRODUCT.name} launcher is product-pinned, but no matching instance was found.`
|
|
171
310
|
: ` Note: PAM_BRIDGE_PRODUCT pins this server to '${pin}' — no matching instance was found (connection_status lists what is running).`
|
|
172
311
|
: ""));
|
|
173
312
|
}
|
|
@@ -178,7 +317,7 @@ async function requireBridge() {
|
|
|
178
317
|
* requireBridge() plus product-aware cell bounds, for tools that ADDRESS a cell.
|
|
179
318
|
*
|
|
180
319
|
* The zod schemas cap cellId at 8 because they are built before we know which
|
|
181
|
-
* product is on the other end.
|
|
320
|
+
* product is on the other end. MFX has 4 cells, so without this
|
|
182
321
|
* an agent aims at cell 7 and gets a bridge-level failure or a silent no-op
|
|
183
322
|
* instead of being told the cell does not exist. Read filters (get_state,
|
|
184
323
|
* get_design_overview) deliberately stay unguarded — returning only the cells
|
|
@@ -358,12 +497,6 @@ const FULL_STATE_RESPONSE_LIMIT_CHARS = 24_000;
|
|
|
358
497
|
// Preset listings grow with every installed pack; cap the unfiltered dump so it
|
|
359
498
|
// can't crowd out the agent's context (`search` narrows it).
|
|
360
499
|
const LIST_PRESETS_MAX = 400;
|
|
361
|
-
const PATTERN_LANES = {
|
|
362
|
-
note: "midiSequence",
|
|
363
|
-
pitch: "pitchSequence",
|
|
364
|
-
slice: "sliceSequence",
|
|
365
|
-
stretch: "stretchSequence",
|
|
366
|
-
};
|
|
367
500
|
const LFO_DIVISIONS = [
|
|
368
501
|
"16 bar", "8 bar", "4 bar", "2 bar", "1 bar",
|
|
369
502
|
"1/2D", "1/2", "1/2T",
|
|
@@ -458,6 +591,44 @@ function formatNotModulatableError(paramId, index) {
|
|
|
458
591
|
const suggestions = suggestParams(paramId, index, { modulatableOnly: true });
|
|
459
592
|
return `Parameter '${paramId}' is not modulatable.${suggestions.length ? ` Try modulatable target ${suggestions.join(", ")}.` : ""}`;
|
|
460
593
|
}
|
|
594
|
+
// Hosted-plugin params exist only while a plugin is loaded, so they resolve against
|
|
595
|
+
// the live instance; the id is the UI's `cell{N}_plugin_{slug(name)}_{index}`.
|
|
596
|
+
const HOSTED_MOD_TARGET_RX = /^cell(\d+)_plugin(Fx)?_[a-z0-9][a-z0-9_]*_(\d+)$/;
|
|
597
|
+
function hostedParamSlug(name) {
|
|
598
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 32) || "p";
|
|
599
|
+
}
|
|
600
|
+
function hostedModTargetId(cellId, param, effect = false) {
|
|
601
|
+
return `cell${cellId}_${effect ? "pluginFx" : "plugin"}_${hostedParamSlug(param.name || param.label || `p${param.index}`)}_${param.index}`;
|
|
602
|
+
}
|
|
603
|
+
/** A manifest param, or a hosted-plugin param checked against the loaded instance; a string is the error. */
|
|
604
|
+
async function resolveModulationTarget(paramId, index) {
|
|
605
|
+
const param = index.get(paramId);
|
|
606
|
+
if (param)
|
|
607
|
+
return param.isModulatable ? param : formatNotModulatableError(paramId, index);
|
|
608
|
+
const hosted = paramId.match(HOSTED_MOD_TARGET_RX);
|
|
609
|
+
if (!hosted)
|
|
610
|
+
return formatUnknownParamError(paramId, index, { modulatableOnly: true });
|
|
611
|
+
const cellId = Number(hosted[1]);
|
|
612
|
+
const hostedTarget = { paramId, name: paramId, displayName: paramId, min: 0, max: 1, defaultValue: 0, step: 0.001, skew: 1, isModulatable: true };
|
|
613
|
+
// list_loaded_plugins covers the instrument slot only; an FX id is taken as named.
|
|
614
|
+
if (hosted[2])
|
|
615
|
+
return hostedTarget;
|
|
616
|
+
let listed;
|
|
617
|
+
try {
|
|
618
|
+
listed = (await bridge.send("listLoadedPlugins"));
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
return `Hosted plugin parameters need a running PAM with the MCP bridge connected (${paramId}).`;
|
|
622
|
+
}
|
|
623
|
+
const plugin = listed?.loadedPlugins?.find((p) => Number(p.cellId) === cellId);
|
|
624
|
+
if (!plugin)
|
|
625
|
+
return `No hosted instrument plugin is loaded in cell ${cellId}; load one before targeting ${paramId}.`;
|
|
626
|
+
const entry = plugin.params?.find((p) => Number(p.index) === Number(hosted[3]));
|
|
627
|
+
if (!entry)
|
|
628
|
+
return `${plugin.name} in cell ${cellId} has no parameter at index ${hosted[3]}.`;
|
|
629
|
+
const expected = hostedModTargetId(cellId, { ...entry, index: Number(entry.index) });
|
|
630
|
+
return expected === paramId ? hostedTarget : `Unknown hosted parameter ${paramId}. Did you mean ${expected}?`;
|
|
631
|
+
}
|
|
461
632
|
function formatRangeError(param, value) {
|
|
462
633
|
const unit = param.suffix ? param.suffix : "unitless";
|
|
463
634
|
const traits = [
|
|
@@ -557,6 +728,29 @@ function filterSectionResult(section, result, opts) {
|
|
|
557
728
|
function countEvents(value) {
|
|
558
729
|
return Array.isArray(value) ? value.length : 0;
|
|
559
730
|
}
|
|
731
|
+
// What an MSQ cell sends: its output wiring, what plays it, and the CC it carries.
|
|
732
|
+
function msqCellSummary(id, cell, state) {
|
|
733
|
+
const prefix = `cell${id}_`;
|
|
734
|
+
const channel = Math.round(Number(cell.midiOutChannel ?? 0));
|
|
735
|
+
return {
|
|
736
|
+
cellId: id,
|
|
737
|
+
muted: state[`${prefix}mute`] === 1 || state[`${prefix}mute`] === true,
|
|
738
|
+
send: Number(cell.sendToHost ?? 1) > 0.5,
|
|
739
|
+
channel: channel >= 1 && channel <= 16 ? channel : `auto (${((id - 1) % 16) + 1})`,
|
|
740
|
+
port: Math.round(Number(cell.midiOutPort ?? 0)),
|
|
741
|
+
triggerNote: cell.midiNote,
|
|
742
|
+
poly: Number(cell.isPoly) > 0.5 ? { low: cell.polyNoteMin ?? 0, high: cell.polyNoteMax ?? 127 } : false,
|
|
743
|
+
player: describeCellPlayer(id, cell, state),
|
|
744
|
+
noteEvents: countEvents(state[`${prefix}${PATTERN_LANE_SUFFIX.note}`]),
|
|
745
|
+
ccLaneEvents: Object.fromEntries(CC_LANES.map((lane) => [lane, countEvents(state[`${prefix}${PATTERN_LANE_SUFFIX[lane]}`])])),
|
|
746
|
+
ccOut: CC_OUT_SLOTS.flatMap((slot) => {
|
|
747
|
+
const controller = Number(cell[`ccOutCc${slot}`]);
|
|
748
|
+
return Number.isInteger(controller) && controller >= 0 && controller <= 119
|
|
749
|
+
? [{ slot, controller, value: state[`${prefix}ccOut${slot}`] }]
|
|
750
|
+
: [];
|
|
751
|
+
}),
|
|
752
|
+
};
|
|
753
|
+
}
|
|
560
754
|
function stemName(path) {
|
|
561
755
|
if (typeof path !== "string" || path.length === 0)
|
|
562
756
|
return undefined;
|
|
@@ -571,11 +765,19 @@ const server = new McpServer({
|
|
|
571
765
|
instructions: IS_MFX_SERVER
|
|
572
766
|
? "MFX is a four-cell modular FX router: cells are patched with cables and each cell runs its own effect chain. " +
|
|
573
767
|
"Use cells 1-4. Treat tool annotations as authoritative and use select_instance when several products are running."
|
|
574
|
-
:
|
|
575
|
-
"
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
768
|
+
: IS_MSQ_SERVER
|
|
769
|
+
? "MSQ is an eight-cell MIDI sequencer: each cell sends its pattern or Player's notes and its CC out on its own " +
|
|
770
|
+
"MIDI channel to the host's instruments or, standalone, to MIDI devices. It makes no sound itself. Notes need a " +
|
|
771
|
+
"running transport; a CC out slot sends whenever its value moves. Treat tool annotations as authoritative and " +
|
|
772
|
+
"use select_instance when several MSQ instances are running."
|
|
773
|
+
: "PAM is an eight-cell sampler. " +
|
|
774
|
+
"Sequencer notes require a running transport; a cell's CC out slots send whenever their value moves. " +
|
|
775
|
+
"UI, video, and visual tools require the plugin " +
|
|
776
|
+
"window open; experimental plugin-host and AI tools require experimentalMode plus the matching " +
|
|
777
|
+
"feature flag. Treat tool annotations as authoritative and use select_instance when several " +
|
|
778
|
+
"products are running. A 'build does not support' error means update the plugin, not only this server. " +
|
|
779
|
+
"An MSQ instance sequences MIDI only: its cells send notes and CC to the host MIDI out, so sample, effect, " +
|
|
780
|
+
"master, looper, video, plugin-host and AI tools refuse it.",
|
|
579
781
|
});
|
|
580
782
|
// --- tool annotation policy ---------------------------------------------------
|
|
581
783
|
// Single audit surface for MCP tool annotations. Every tool MUST have an entry;
|
|
@@ -638,7 +840,10 @@ const TOOL_ANNOTATIONS = {
|
|
|
638
840
|
clear_modulations: DI,
|
|
639
841
|
reset_patterns: DI,
|
|
640
842
|
update_effect_config: WI,
|
|
843
|
+
set_effects_enabled: WI,
|
|
844
|
+
set_spectral_delay_curve: WI,
|
|
641
845
|
update_cell_config: WI,
|
|
846
|
+
set_lane_target: WI,
|
|
642
847
|
set_cell_mute: WI,
|
|
643
848
|
set_master_effects: WI,
|
|
644
849
|
add_param_seq: W,
|
|
@@ -648,6 +853,7 @@ const TOOL_ANNOTATIONS = {
|
|
|
648
853
|
set_drum_synth_wave: WI,
|
|
649
854
|
list_players: RO,
|
|
650
855
|
set_player: WI,
|
|
856
|
+
keep_player: W,
|
|
651
857
|
set_slices: WI,
|
|
652
858
|
set_free_sequencer: WI,
|
|
653
859
|
export_midi: W,
|
|
@@ -668,12 +874,18 @@ const TOOL_ANNOTATIONS = {
|
|
|
668
874
|
trigger_midi_cc: W,
|
|
669
875
|
trigger_midi_program_change: W,
|
|
670
876
|
midi_recording: W,
|
|
877
|
+
session_take: W,
|
|
671
878
|
// Presets / variations
|
|
672
879
|
load_preset: DI, // replaces the working state (unsaved edits are lost)
|
|
673
880
|
get_preset_load_status: RO,
|
|
674
881
|
save_preset: WI,
|
|
675
882
|
new_preset: DI,
|
|
676
883
|
import_preset: WI,
|
|
884
|
+
// Preset packs
|
|
885
|
+
list_packs: RO,
|
|
886
|
+
get_pack_install_status: RO,
|
|
887
|
+
install_pack: WI,
|
|
888
|
+
uninstall_pack: DI, // deletes the pack's presets, samples and media
|
|
677
889
|
export_preset: W,
|
|
678
890
|
update_preset_metadata: WI,
|
|
679
891
|
delete_preset: DI,
|
|
@@ -691,6 +903,8 @@ const TOOL_ANNOTATIONS = {
|
|
|
691
903
|
trim_sample: W, // restore_original_sample recovers the pre-edit file
|
|
692
904
|
reverse_sample: W,
|
|
693
905
|
gain_sample: W,
|
|
906
|
+
repitch_sample: W,
|
|
907
|
+
analyze_sample_pitch: RO,
|
|
694
908
|
normalize_sample: WI,
|
|
695
909
|
analyze_sample_tempo: WI, // dispatches results into state
|
|
696
910
|
resample_sample_to_bpm: WI,
|
|
@@ -713,6 +927,7 @@ const TOOL_ANNOTATIONS = {
|
|
|
713
927
|
live_resample: D, // finalize replaces the destination cell; finalize:false discards the take
|
|
714
928
|
// Song mode / export
|
|
715
929
|
song_mode: W,
|
|
930
|
+
dj_set: DI,
|
|
716
931
|
export_song: W,
|
|
717
932
|
export_audio: W,
|
|
718
933
|
// Settings / devices
|
|
@@ -795,6 +1010,8 @@ const MFX_TOOL_NAMES = new Set([
|
|
|
795
1010
|
"update_modulation",
|
|
796
1011
|
"clear_modulations",
|
|
797
1012
|
"update_effect_config",
|
|
1013
|
+
"set_effects_enabled",
|
|
1014
|
+
"set_spectral_delay_curve",
|
|
798
1015
|
"update_cell_config",
|
|
799
1016
|
"set_cell_mute",
|
|
800
1017
|
"set_master_effects",
|
|
@@ -826,7 +1043,6 @@ const MFX_TOOL_NAMES = new Set([
|
|
|
826
1043
|
"cancel_queued_variation",
|
|
827
1044
|
"update_variations",
|
|
828
1045
|
"variation_hold",
|
|
829
|
-
"export_audio",
|
|
830
1046
|
"update_settings",
|
|
831
1047
|
"get_audio_device_setup",
|
|
832
1048
|
"set_audio_device",
|
|
@@ -847,13 +1063,13 @@ function mfxSurfaceText(value) {
|
|
|
847
1063
|
.replaceAll(" Check corr in get_master_analysis after widening.", "")
|
|
848
1064
|
.replaceAll(" (same constraint as the video tools)", "")
|
|
849
1065
|
.replaceAll("so the preset_novelty scale does", "so a whole-preset novelty scale does")
|
|
850
|
-
.replaceAll("PAM/Cell1-only", "sampler-product-only")
|
|
851
|
-
.replaceAll("PAM/Cell1", "sampler products")
|
|
852
1066
|
.replaceAll("PAM-only", "sampler-product-only")
|
|
853
1067
|
.replaceAll("PAM's", "MFX's")
|
|
854
1068
|
.replaceAll("PAM", "MFX");
|
|
855
1069
|
}
|
|
856
1070
|
const MFX_MAX_CELL = 4;
|
|
1071
|
+
// Copy of syncUtils::kValidBarSyncIntervals (SyncUtils.h); check-parity compares them.
|
|
1072
|
+
const LOOPER_BAR_SYNC_INTERVALS = [0.25, 0.5, 1, 2, 4, 8];
|
|
857
1073
|
// Zod wrappers are immutable, so narrowing a leaf means rebuilding the
|
|
858
1074
|
// optional/default/array/union chain around it and re-attaching the description.
|
|
859
1075
|
function rebuildSchema(schema, narrowLeaf) {
|
|
@@ -895,49 +1111,94 @@ const MFX_CELL_FIELDS = new Set([
|
|
|
895
1111
|
"cellIds",
|
|
896
1112
|
"selectedCellIds",
|
|
897
1113
|
"sidechainFrom",
|
|
1114
|
+
"cellmixFrom",
|
|
898
1115
|
]);
|
|
899
1116
|
// Enum members MFX has no surface for: Vary is removed from its modulation stack,
|
|
900
|
-
//
|
|
1117
|
+
// neither song_mode nor a looper tool is registered on this launcher, and the MIDI
|
|
1118
|
+
// settings page hides the looper, resample and VX mappings.
|
|
901
1119
|
const MFX_ENUM_DROPS = {
|
|
902
1120
|
add_modulation: { type: ["vary"] },
|
|
1121
|
+
update_modulation: { type: ["vary"] },
|
|
903
1122
|
get_state: { section: ["vary", "loopers", "songMode"] },
|
|
904
|
-
|
|
1123
|
+
set_midi_mapping: { target: ["looper", "resample", "vxRecord", "looperVariation"] },
|
|
905
1124
|
};
|
|
906
1125
|
const MFX_KEPT_FIELDS = {
|
|
907
|
-
update_cell_config: ["cellId", "sidechainFrom"],
|
|
1126
|
+
update_cell_config: ["cellId", "sidechainFrom", "cellmixFrom", "cellmixPreFx"],
|
|
908
1127
|
};
|
|
909
1128
|
const MFX_DROPPED_FIELDS = {
|
|
910
1129
|
select_instance: ["product"],
|
|
911
1130
|
// MFX cells are effect slots — there is no synth/sampler tab to switch.
|
|
912
1131
|
capture_ui: ["instrumentView"],
|
|
1132
|
+
// MFX processes host input, which an offline render leaves silent.
|
|
1133
|
+
analyze_motion: ["bars"],
|
|
913
1134
|
};
|
|
914
1135
|
const MFX_TOOL_DESCRIPTIONS = {
|
|
915
1136
|
select_instance: "Choose which running MFX instance the live tools drive. Match by instanceId or port from connection_status. The launcher refuses non-MFX instances.",
|
|
916
|
-
update_cell_config: "Set one MFX cell's sidechain source. A non-null source also enables that cell's
|
|
1137
|
+
update_cell_config: "Set one MFX cell's sidechain or Cell Mix source. A non-null sidechain source also enables that cell's " +
|
|
1138
|
+
"compressor, and a Cell Mix source other than -1 enables its Cell Mix card.",
|
|
917
1139
|
capture_ui: "Render MFX's plugin UI (#app_root) to a PNG via html2canvas and return the saved file path (read it to " +
|
|
918
1140
|
"view the image). Optionally compose the view first: gridSize (1 or 4), mainView (CellGrid|MfxRouting|" +
|
|
919
1141
|
"CtrlAll|Envelope|LFO|ParamSeq|Mixer|PresetBrowser; omit/null = cell grid). REQUIRES MFX's window OPEN — " +
|
|
920
1142
|
"html2canvas needs the live DOM, there is no headless renderer. Beats screen-capture: deterministic, no " +
|
|
921
1143
|
"cursor/screen control, exact UI bitmap. View arguments switch the live editor and the previous view is " +
|
|
922
1144
|
"not restored.",
|
|
1145
|
+
copy_cell: "Copy a cell's manifest parameters and remap its LFO and envelope links onto another cell, overwriting the " +
|
|
1146
|
+
"target's. Not copied: parameter sequencers and the cell config block (sidechain source) — re-apply that with " +
|
|
1147
|
+
"update_cell_config.",
|
|
1148
|
+
set_midi_mapping: "Configure the global MIDI-controller mappings that trigger MFX actions: variation switch and preset change. " +
|
|
1149
|
+
"Set the enable flag, MIDI channel, CC number, source (Program Change or CC), or bar-sync for each target. " +
|
|
1150
|
+
"One field per call.",
|
|
923
1151
|
};
|
|
924
1152
|
const MFX_FIELD_DESCRIPTIONS = {
|
|
925
1153
|
capture_ui: { gridSize: "Cell grid size to set before capture: 1 or 4." },
|
|
926
|
-
|
|
1154
|
+
analyze_motion: {
|
|
1155
|
+
wavPath: "Absolute path to a WAV of material processed through MFX, such as a DAW bounce. Required on MFX.",
|
|
1156
|
+
},
|
|
1157
|
+
set_midi_mapping: {
|
|
1158
|
+
target: "Which action's mapping to configure.",
|
|
1159
|
+
field: "Field to set. 'source' (0 = Program Change, 1 = CC) applies to preset and variation; 'barSync' is variation-only.",
|
|
1160
|
+
},
|
|
1161
|
+
queue_variation: {
|
|
1162
|
+
fadeMs: "Crossfade duration in ms. Omit all transition options to inherit the native Bar Sync settings.",
|
|
1163
|
+
},
|
|
1164
|
+
update_modulation: {
|
|
1165
|
+
payload: "Update payload object, one property per call. lfo, envelope, macro and paramSeq all take " +
|
|
1166
|
+
"{ index, property, value }, where index is the 0-based position in that state array (get_state " +
|
|
1167
|
+
"section 'lfos' / 'envelopes' / 'macros' / 'paramSeqs') and property is the field name to overwrite. " +
|
|
1168
|
+
"paramSeqSequence takes { index, sequence: number[] }. freeSeqSequence takes { sequence: number[] } — the " +
|
|
1169
|
+
"master step contour every free-mode ParamSeq follows.",
|
|
1170
|
+
},
|
|
927
1171
|
add_modulation: {
|
|
928
1172
|
cellId: "Required for envelope: source cell driving the follower (1-4). This is not the target cell; target is inferred from paramId.",
|
|
929
1173
|
},
|
|
930
1174
|
};
|
|
931
1175
|
// Text whose subject is a tool this launcher does not register.
|
|
932
1176
|
function mfxDescriptionEdits(name, value) {
|
|
933
|
-
if (name === "export_audio")
|
|
934
|
-
return value
|
|
935
|
-
.replace("Set `mode` to bounce stems or the whole Song Mode arrangement.", "Set `mode` to bounce per-cell or per-bus stems.")
|
|
936
|
-
.replace(" · 'song-mode' (the Song Mode timeline)", "")
|
|
937
|
-
.replace(/\n\nNOTE on 'song-mode':[\s\S]*$/, "");
|
|
938
1177
|
if (name === "analyze_motion")
|
|
939
|
-
return value
|
|
940
|
-
"
|
|
1178
|
+
return value
|
|
1179
|
+
.replace("Fills the gap left by get_master_analysis (loudness) and get_mix_analysis (aggregate spectrum + onset " +
|
|
1180
|
+
"density), neither of which can tell a modulated preset from a static one.", "No other tool on this surface can tell a modulated preset from a static one.")
|
|
1181
|
+
.replace(/Pass wavPath to analyze an existing render[\s\S]*$/, "Pass wavPath to a WAV of material processed through MFX, such as a DAW bounce. MFX processes its " +
|
|
1182
|
+
"host's input, so there is no offline render to fall back on.");
|
|
1183
|
+
if (name === "reset_cell")
|
|
1184
|
+
return value.replace("Untouched: envelopes targeting this cell from another source (clear_modulations), pattern lanes (reset_patterns), " +
|
|
1185
|
+
"the cell config block (update_cell_config) and the loaded sample.", "Untouched: envelopes targeting this cell from another source (clear_modulations) and the cell config block " +
|
|
1186
|
+
"(update_cell_config).");
|
|
1187
|
+
if (name === "clear_modulations")
|
|
1188
|
+
return value.replace("LFO, envelope, and vary modulation links", "LFO and envelope modulation links")
|
|
1189
|
+
.replace("for envelopes/vary", "for envelopes");
|
|
1190
|
+
if (name === "update_modulation")
|
|
1191
|
+
return value.replace("LFO, Envelope, Vary, Macro", "LFO, Envelope, Macro");
|
|
1192
|
+
if (name === "get_default_locations")
|
|
1193
|
+
return value.replace(" Use these paths with list_samples to browse the sample library.", "");
|
|
1194
|
+
if (name === "save_variation")
|
|
1195
|
+
return value.replace("(load samples, set parameters, enable effects, add modulation)", "(set parameters, enable effects, patch cables, add modulation)");
|
|
1196
|
+
if (name === "queue_variation")
|
|
1197
|
+
return value.replace(" (song mode / an already-engaged queued-flip window only;", " (an already-engaged queued-flip window only;");
|
|
1198
|
+
if (name === "get_design_overview")
|
|
1199
|
+
return value.replace("cells, loaded samples/synth engines, enabled effects, pattern counts, modulation link counts, mixer values, " +
|
|
1200
|
+
"BPM, and master FX.", "cells, enabled effects and order, sidechain sources, modulation link counts, mixer values, cables, cable " +
|
|
1201
|
+
"levels, Combo assignments, band split, BPM, and transport state.");
|
|
941
1202
|
if (name === "set_master_effects")
|
|
942
1203
|
return value.replace("Check results with get_master_analysis (keep tp <= -1 dBTP, PSR >= 8 in the loudest section). ", "Aim for tp <= -1 dBTP and PSR >= 8 in the loudest section; no loudness analysis tool is registered on this surface. ");
|
|
943
1204
|
if (name === "set_parameters")
|
|
@@ -978,6 +1239,202 @@ function mfxToolConfig(name, config) {
|
|
|
978
1239
|
...(inputSchema ? { inputSchema } : {}),
|
|
979
1240
|
};
|
|
980
1241
|
}
|
|
1242
|
+
// MSQ's launcher advertises the pattern layer only. The sampler, effect, master, looper, video,
|
|
1243
|
+
// plugin-host, AI and MFX handlers live in this file too; discovery must not offer them.
|
|
1244
|
+
const MSQ_TOOL_NAMES = new Set([
|
|
1245
|
+
"get_parameter_info", "list_presets", "read_preset", "get_manifest_summary", "variation_distinctness",
|
|
1246
|
+
"get_state", "get_design_overview", "connection_status", "situation", "select_instance", "get_default_locations",
|
|
1247
|
+
"capture_ui", "set_view", "set_parameters", "trigger_midi", "trigger_midi_cc", "trigger_midi_program_change",
|
|
1248
|
+
"stop_cell", "reset_cell", "copy_cell", "update_cell_config", "set_cell_mute", "update_pattern", "reset_patterns",
|
|
1249
|
+
"randomize", "randomize_patterns", "list_players", "set_player", "keep_player", "add_modulation",
|
|
1250
|
+
"update_modulation", "clear_modulations", "add_param_seq", "update_param_seq", "remove_param_seq",
|
|
1251
|
+
"set_free_sequencer", "start_transport", "stop_transport", "full_stop_transport", "get_transport", "set_bpm",
|
|
1252
|
+
"configure_transport", "load_preset", "get_preset_load_status", "new_preset", "save_preset",
|
|
1253
|
+
"update_preset_metadata", "export_preset", "import_preset", "delete_preset", "set_preset_factory_status",
|
|
1254
|
+
"load_variation", "save_variation", "queue_variation", "cancel_queued_variation", "update_variations",
|
|
1255
|
+
"variation_hold", "export_midi", "midi_recording", "update_settings", "get_audio_device_setup",
|
|
1256
|
+
"set_audio_device", "set_midi_io", "set_midi_mapping",
|
|
1257
|
+
]);
|
|
1258
|
+
// No audio means no envelope follower; the looper, Song Mode, resample and VX mappings have no MSQ surface.
|
|
1259
|
+
const MSQ_ENUM_DROPS = {
|
|
1260
|
+
update_pattern: { type: ["pitch", "slice", "stretch", "paramLane1", "paramLane2", "paramLane3", "paramLane4"] },
|
|
1261
|
+
add_modulation: { type: ["envelope"] },
|
|
1262
|
+
update_modulation: { type: ["envelope"] },
|
|
1263
|
+
get_state: { section: ["envelopes", "loopers", "songMode"] },
|
|
1264
|
+
set_midi_mapping: { target: ["looper", "resample", "vxRecord", "looperVariation"] },
|
|
1265
|
+
set_view: { fullscreen: ["effects"] },
|
|
1266
|
+
};
|
|
1267
|
+
const MSQ_KEPT_FIELDS = {
|
|
1268
|
+
update_cell_config: ["cellId", ...MSQ_CELL_CONFIG_KEYS],
|
|
1269
|
+
};
|
|
1270
|
+
const MSQ_DROPPED_FIELDS = {
|
|
1271
|
+
select_instance: ["product"],
|
|
1272
|
+
capture_ui: ["instrumentView"],
|
|
1273
|
+
get_parameter_info: ["effect"],
|
|
1274
|
+
add_modulation: ["pitch", "pitchDuck"],
|
|
1275
|
+
// Without a lane runtime every queued flip lands hard at its boundary: nothing to morph or crossfade.
|
|
1276
|
+
queue_variation: ["fadeMs", "morphBars", "morphMode", "morphCurve", "transition"],
|
|
1277
|
+
};
|
|
1278
|
+
const MSQ_FIELD_SCHEMAS = {
|
|
1279
|
+
randomize: {
|
|
1280
|
+
target: z
|
|
1281
|
+
.enum(["sequences", "lfos", "mods"])
|
|
1282
|
+
.default("sequences")
|
|
1283
|
+
.describe("What to randomize: the cells' note patterns, or the global LFOs / modulation links."),
|
|
1284
|
+
},
|
|
1285
|
+
set_view: {
|
|
1286
|
+
panel: z
|
|
1287
|
+
.enum(["pattern", "player", "midi"])
|
|
1288
|
+
.optional()
|
|
1289
|
+
.describe("Cell card tab to show, on cellId or else every cell: pattern, player or midi."),
|
|
1290
|
+
},
|
|
1291
|
+
};
|
|
1292
|
+
const MSQ_TOOL_DESCRIPTIONS = {
|
|
1293
|
+
select_instance: "Choose which running MSQ instance the live tools drive. Match by instanceId or port from connection_status. The launcher refuses other products' instances.",
|
|
1294
|
+
get_design_overview: "Return a compact snapshot of what each cell sends: Send, output channel (Auto = cell number), port, trigger " +
|
|
1295
|
+
"note, poly range, Player, note and CC-lane event counts, CC out slots and modulation link counts, plus BPM and " +
|
|
1296
|
+
"transport. Use this instead of full get_state.",
|
|
1297
|
+
randomize: "Trigger MSQ's randomizer. target:'sequences' writes new note patterns natively; 'lfos'/'mods' reshape the " +
|
|
1298
|
+
"global modulation and need MSQ's window open. Returns queued, not completed: poll get_state for the requested " +
|
|
1299
|
+
"changes before another randomize, edit, or save. A preset or variation load can discard pending work.",
|
|
1300
|
+
randomize_patterns: "Generate new note patterns with MSQ's native pattern engine from the genre, BPM and each cell's role. " +
|
|
1301
|
+
"Identical to randomize target:'sequences'. Returns queued: poll get_state for changed cellN_midiSequence " +
|
|
1302
|
+
"values before another randomize, edit, or save.",
|
|
1303
|
+
export_preset: "Export a preset as a zip file written to MSQ's Exports folder as <outputFolderName>.zip. An existing folder " +
|
|
1304
|
+
"or zip of that name is replaced, and presets belonging to a preset pack are refused.",
|
|
1305
|
+
update_settings: "Merge a partial settings delta into MSQ's plugin settings (the Settings panel state) and apply it live. " +
|
|
1306
|
+
"Returns the full merged settings object, so call it with {} to discover the keys; experimentalMode and " +
|
|
1307
|
+
"mcpBridgeEnabled are among them (mcpBridgeEnabled:false closes this bridge after it replies).",
|
|
1308
|
+
set_view: "Navigate MSQ's UI: switch the main view, focus a cell, or set the pad-grid size. Routed through the " +
|
|
1309
|
+
"WebView, so MSQ's window must be OPEN.",
|
|
1310
|
+
capture_ui: "Render MSQ's plugin UI (#app_root) to a PNG via html2canvas and return the saved file path (read it to view " +
|
|
1311
|
+
"the image). Optionally compose the view first: gridSize (1|4|8), mainView (CellGrid|CtrlAll|LFO|Vary|" +
|
|
1312
|
+
"ParamSeq|Mixer|PresetBrowser; omit/null = cell grid). REQUIRES MSQ's window OPEN: html2canvas needs the live " +
|
|
1313
|
+
"DOM. View arguments switch the live editor and the previous view is not restored.",
|
|
1314
|
+
copy_cell: "Copy a cell's manifest parameters and remap its LFO and vary links onto another cell, overwriting the " +
|
|
1315
|
+
"target's. A CC lane lands only on a target slot that is Off or on the same controller; CC out values and " +
|
|
1316
|
+
"controllers, output channel, Send and port stay the target's. Not copied: parameter sequencers and the cell " +
|
|
1317
|
+
"config block (poly, trigger note, scale), which update_cell_config sets.",
|
|
1318
|
+
reset_cell: "Reset a single cell's manifest parameters to their defaults. Untouched: pattern lanes (reset_patterns), the " +
|
|
1319
|
+
"cell config block (update_cell_config) and modulation targeting the cell from elsewhere (clear_modulations). " +
|
|
1320
|
+
"Does not touch other cells.",
|
|
1321
|
+
update_cell_config: "Set one MSQ cell's MIDI config: trigger note, poly and scale, output channel (0 = Auto), Send, standalone " +
|
|
1322
|
+
"port, the CC out slots' controllers and the CC lanes' slots.",
|
|
1323
|
+
get_manifest_summary: "Get a high-level summary of MSQ's capabilities: number of cells, modulators, and parameter counts.",
|
|
1324
|
+
add_modulation: "Add a modulation link (LFO or vary) to a parameter. The parameter must be modulatable (check with " +
|
|
1325
|
+
"get_parameter_info).",
|
|
1326
|
+
update_modulation: "Apply a delta to a modulation source: LFO, Vary, Macro, ParamSeq config, ParamSeq sequence data, or the Free " +
|
|
1327
|
+
"Sequencer master pattern. Use add_modulation to first attach a source to a target, then this tool to edit it.",
|
|
1328
|
+
clear_modulations: "Remove all LFO and vary modulation links targeting a cell's parameters (or driven by that cell, for vary). " +
|
|
1329
|
+
"Parameter sequencers survive; use remove_param_seq for those.",
|
|
1330
|
+
load_variation: "Request a switch to a saved variation slot (0-7), including parameters, patterns, players, cell wiring and " +
|
|
1331
|
+
"modulation. Empty slots are rejected. Verify currentVariation and a slot-specific value with get_state before " +
|
|
1332
|
+
"editing or saving.",
|
|
1333
|
+
new_preset: "Reset MSQ to its initial/default state, equivalent to creating a new empty preset. All cells, modulation and " +
|
|
1334
|
+
"sequences are cleared.",
|
|
1335
|
+
set_cell_mute: "Mute, unmute, solo, or unsolo a cell. A muted cell, or every other cell while one is soloed, sends no notes " +
|
|
1336
|
+
"or CC and releases the notes it holds. cell{N}_mute / cell{N}_solo are live-polled state variables.",
|
|
1337
|
+
read_preset: "Read an MSQ preset file and return its state: parameters, patterns, cell config and variations.",
|
|
1338
|
+
variation_distinctness: "Measure whether a preset's variation slots are actually different, and name what is identical about the " +
|
|
1339
|
+
"pairs that are not. Structural comparison of the saved slot snapshots: no render, no transport, no audition " +
|
|
1340
|
+
"loop, and no risk of the load_variation race. Each failing pair reports the dimensions it duplicates plus the " +
|
|
1341
|
+
"move that would fix it.",
|
|
1342
|
+
set_parameters: "Set one or more MSQ parameters. Values are validated against the manifest; get_parameter_info lists valid " +
|
|
1343
|
+
"IDs and ranges. Writing a macro_* id also enables that macro, matching the UI.",
|
|
1344
|
+
update_pattern: "Write one lane of a cell's pattern: the notes, or a CC lane (ccOut1-ccOut4, the timeline of that CC out " +
|
|
1345
|
+
"slot: each event sends its value on the slot's controller for its duration, then the slot returns to its knob).",
|
|
1346
|
+
save_variation: "Capture the current plugin state and save it to a variation slot (0-7). Use this to build presets with " +
|
|
1347
|
+
"multiple variations, e.g. a build-up from sparse to full. Set patterns, players, parameters and modulation " +
|
|
1348
|
+
"first, then snapshot them into a slot. The preset must have been saved once via save_preset, or pass a name " +
|
|
1349
|
+
"to create it.",
|
|
1350
|
+
situation: "One-call orientation snapshot: discovered instances, the connected instance's product/pid/executable (plus " +
|
|
1351
|
+
"build mtime and a dev-build heuristic when resolvable), server and manifest identity, active preset, " +
|
|
1352
|
+
"transport, and what each cell sends. Start a session here; get_design_overview reads deeper.",
|
|
1353
|
+
get_default_locations: "Get the standard folders MSQ knows (home, desktop, documents, music, downloads, presets), for an export_midi " +
|
|
1354
|
+
"outputDir or an import_preset path.",
|
|
1355
|
+
import_preset: "Import a preset from a zip file or folder into MSQ's preset library. Returns the relative path of the first " +
|
|
1356
|
+
"imported preset, or an empty string on failure. A name already in the library is never overwritten: the " +
|
|
1357
|
+
"import lands as <name>_1, <name>_2 and so on.",
|
|
1358
|
+
reset_patterns: "Clear every pattern lane of one cell: the note lane and the four CC lanes. CC out controllers stay.",
|
|
1359
|
+
queue_variation: "Schedule a global variation switch on the next bar boundary; for an immediate switch use load_variation. " +
|
|
1360
|
+
"MSQ switches at the boundary without a morph.",
|
|
1361
|
+
trigger_midi_cc: "Send a MIDI Control Change message into MSQ's MIDI router. Maps to any MIDI-learned parameter or variation " +
|
|
1362
|
+
"CC binding.",
|
|
1363
|
+
set_midi_mapping: "Configure the global MIDI-controller mappings that trigger MSQ actions: variation switch and preset change. " +
|
|
1364
|
+
"Set the enable flag, MIDI channel, CC number, source (Program Change or CC), or bar-sync for each target. " +
|
|
1365
|
+
"One field per call.",
|
|
1366
|
+
export_midi: "Write the cells' note patterns to a Standard MIDI File (type 1, 480 PPQ, tempo from the transport): one track " +
|
|
1367
|
+
"per cell, across `bars`, on the cell's output channel (Auto = cell number). Each sending CC out slot's value " +
|
|
1368
|
+
"opens the track at tick 0 and its CC lane's locks and releases follow on the lane's own clock; a cell with Send " +
|
|
1369
|
+
"off is left out, as are notes outside a poly cell's Low-High. A Player cell exports the cycles it generates, " +
|
|
1370
|
+
"rendered by MSQ itself; Listen responses are left out. Grid timing from base values: swing, probability, CTRL " +
|
|
1371
|
+
"macros and modulators are not applied. Nothing changes in MSQ. Default location is MSQ's Export folder.",
|
|
1372
|
+
};
|
|
1373
|
+
const MSQ_FIELD_DESCRIPTIONS = {
|
|
1374
|
+
get_parameter_info: { paramId: "Specific parameter ID (e.g. 'cell1_patternSwing'). Omit for all." },
|
|
1375
|
+
get_state: { keys: "Exact top-level state keys to return, e.g. ['cell1_patternSwing','cell1_midiSequence']." },
|
|
1376
|
+
add_modulation: {
|
|
1377
|
+
paramId: "Target parameter ID (e.g. 'cell1_ccOut1').",
|
|
1378
|
+
cellId: "Required for vary: the cell whose triggers drive it (1-8). The target cell is inferred from paramId.",
|
|
1379
|
+
},
|
|
1380
|
+
add_param_seq: { paramId: "Target parameter ID (e.g. 'cell1_ccOut1')." },
|
|
1381
|
+
update_modulation: {
|
|
1382
|
+
payload: "Update payload object, one property per call. lfo, vary, macro and paramSeq all take { index, property, " +
|
|
1383
|
+
"value }, where index is the 0-based position in that state array (get_state section 'lfos' / 'vary' / " +
|
|
1384
|
+
"'macros' / 'paramSeqs') and property is the field name to overwrite. paramSeqSequence takes { index, " +
|
|
1385
|
+
"sequence: number[] }. freeSeqSequence takes { sequence: number[] }, the master step contour every " +
|
|
1386
|
+
"free-mode ParamSeq follows.",
|
|
1387
|
+
},
|
|
1388
|
+
set_parameters: { parameters: "Map of paramId to value. Example: { 'cell1_ccOut1': 100, 'cell2_patternSwing': 0.2 }" },
|
|
1389
|
+
update_pattern: {
|
|
1390
|
+
sequence: "Array of sequence events. For 'note': [{time, note, duration, velocity, prob}]. For CC lanes: " +
|
|
1391
|
+
"[{time, value, duration, prob?}] (values 0-127; times and durations in beats).",
|
|
1392
|
+
},
|
|
1393
|
+
randomize: { genreHint: "Genre hint for the generated patterns (e.g. 'techno', 'hip-hop')." },
|
|
1394
|
+
save_preset: { image: "Relative path to a thumbnail image for the preset browser." },
|
|
1395
|
+
update_preset_metadata: { image: "Relative path to a thumbnail image for the preset browser." },
|
|
1396
|
+
update_cell_config: { polyRootNote: "Root note for poly pitch calculation." },
|
|
1397
|
+
variation_distinctness: { threshold: "Similarity above which a pair fails. Default 0.9." },
|
|
1398
|
+
set_midi_mapping: {
|
|
1399
|
+
target: "Which action's mapping to configure.",
|
|
1400
|
+
field: "Field to set. 'source' (0 = Program Change, 1 = CC) applies to preset and variation; 'barSync' is variation-only.",
|
|
1401
|
+
},
|
|
1402
|
+
set_view: {
|
|
1403
|
+
view: "Main view: cells, mixer, ctrl, lfo, vary, paramseq, presets.",
|
|
1404
|
+
fullscreen: "CTRL page rack to show alone (sequencer, players), or none.",
|
|
1405
|
+
},
|
|
1406
|
+
};
|
|
1407
|
+
function msqSurfaceText(value) {
|
|
1408
|
+
return value
|
|
1409
|
+
.replace(/PAM ?(?:≥|>=) ?\d+(?:\.\d+)*/g, "a current MSQ build")
|
|
1410
|
+
.replaceAll("a PAM build", "an MSQ build")
|
|
1411
|
+
.replaceAll("PAM", "MSQ");
|
|
1412
|
+
}
|
|
1413
|
+
function msqToolConfig(name, config) {
|
|
1414
|
+
const source = config;
|
|
1415
|
+
const kept = MSQ_KEPT_FIELDS[name];
|
|
1416
|
+
const droppedFields = MSQ_DROPPED_FIELDS[name] ?? [];
|
|
1417
|
+
const enumDrops = MSQ_ENUM_DROPS[name] ?? {};
|
|
1418
|
+
const fieldText = MSQ_FIELD_DESCRIPTIONS[name] ?? {};
|
|
1419
|
+
const fieldSchemas = MSQ_FIELD_SCHEMAS[name] ?? {};
|
|
1420
|
+
const inputSchema = source.inputSchema
|
|
1421
|
+
? Object.fromEntries(Object.entries(source.inputSchema)
|
|
1422
|
+
.filter(([key]) => (!kept || kept.includes(key)) && !droppedFields.includes(key))
|
|
1423
|
+
.map(([key, schema]) => {
|
|
1424
|
+
if (fieldSchemas[key])
|
|
1425
|
+
return [key, fieldSchemas[key]];
|
|
1426
|
+
const narrowed = enumDrops[key] ? rebuildSchema(schema, dropEnumMembers(enumDrops[key])) : schema;
|
|
1427
|
+
const described = fieldText[key] ?? (narrowed.description ? msqSurfaceText(narrowed.description) : undefined);
|
|
1428
|
+
return [key, described ? narrowed.describe(described) : narrowed];
|
|
1429
|
+
}))
|
|
1430
|
+
: undefined;
|
|
1431
|
+
const description = MSQ_TOOL_DESCRIPTIONS[name] ?? (source.description ? msqSurfaceText(source.description) : undefined);
|
|
1432
|
+
return {
|
|
1433
|
+
...source,
|
|
1434
|
+
...(description ? { description } : {}),
|
|
1435
|
+
...(inputSchema ? { inputSchema } : {}),
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
981
1438
|
{
|
|
982
1439
|
const origRegisterTool = server.registerTool.bind(server);
|
|
983
1440
|
server.registerTool = ((name, config, handler) => {
|
|
@@ -985,8 +1442,19 @@ function mfxToolConfig(name, config) {
|
|
|
985
1442
|
// tools therefore have no runtime handle to return.
|
|
986
1443
|
if (IS_MFX_SERVER && !MFX_TOOL_NAMES.has(name))
|
|
987
1444
|
return undefined;
|
|
988
|
-
|
|
989
|
-
|
|
1445
|
+
if (IS_MSQ_SERVER && !MSQ_TOOL_NAMES.has(name))
|
|
1446
|
+
return undefined;
|
|
1447
|
+
const productConfig = (IS_MFX_SERVER ? mfxToolConfig(name, config) : IS_MSQ_SERVER ? msqToolConfig(name, config) : config);
|
|
1448
|
+
const missing = MSQ_MISSING_BY_TOOL.get(name);
|
|
1449
|
+
// Judged by the instance actually connected, not a manifest hint a previous session left behind.
|
|
1450
|
+
const productHandler = missing
|
|
1451
|
+
? async (args, extra) => (await requireBridge()) === true && bridge.getConnectedInstance()?.product?.toUpperCase() === "MSQ"
|
|
1452
|
+
? MSQ_MISSING_CODES.has(name)
|
|
1453
|
+
? packFailure(MSQ_MISSING_CODES.get(name), `${name} is not available in MSQ: it has no ${missing}.`)
|
|
1454
|
+
: error(`${name} is not available in MSQ: it has no ${missing}.`)
|
|
1455
|
+
: handler(args, extra)
|
|
1456
|
+
: handler;
|
|
1457
|
+
internalTools.set(name, { shape: productConfig.inputSchema, handler: productHandler });
|
|
990
1458
|
// Merge the central annotation policy. Inline annotations at the call site
|
|
991
1459
|
// win per key; openWorldHint is false server-wide (local plugin only).
|
|
992
1460
|
const cfg = productConfig;
|
|
@@ -994,7 +1462,7 @@ function mfxToolConfig(name, config) {
|
|
|
994
1462
|
console.error(`[${SERVER_PRODUCT.command}] tool '${name}' has no TOOL_ANNOTATIONS entry — classify it (check-tool-surface fails on this)`);
|
|
995
1463
|
}
|
|
996
1464
|
cfg.annotations = { openWorldHint: false, ...TOOL_ANNOTATIONS[name], ...cfg.annotations };
|
|
997
|
-
return origRegisterTool(name, productConfig,
|
|
1465
|
+
return origRegisterTool(name, productConfig, productHandler);
|
|
998
1466
|
});
|
|
999
1467
|
}
|
|
1000
1468
|
async function callInternalTool(name, args) {
|
|
@@ -1407,7 +1875,7 @@ server.registerTool("design_context", {
|
|
|
1407
1875
|
filterCutoff: "Use real Hz values; move at least 1.5 octaves for obviously audible modulation.",
|
|
1408
1876
|
delayDelayTime: "With delaySyncEnabled=1, normalized 0..1 maps to musical divisions; do not use cellN_delayTime.",
|
|
1409
1877
|
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+.",
|
|
1410
|
-
eqGains: "cellN_eq{Low,Mid,High}Gain are -60..12 dB. The -60 floor
|
|
1878
|
+
eqGains: "cellN_eq{Low,Mid,High}Gain are -60..12 dB. The -60 floor cuts the band fully, not just a tilt.",
|
|
1411
1879
|
lfoRate: "LFO IDs are 1-based: lfo1_... through lfo6_.... Sync ON uses index/22.",
|
|
1412
1880
|
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.",
|
|
1413
1881
|
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.",
|
|
@@ -1464,7 +1932,7 @@ server.registerTool("design_context", {
|
|
|
1464
1932
|
})),
|
|
1465
1933
|
variationSlots: "API is 0-based (0..7); PAM UI labels slots 1..8. Save/load variation index 0 = UI slot 1.",
|
|
1466
1934
|
generativePlayers: "set_player turns a cell into a live generator (list_players for the roster: EUCLID, KIT, TURING, WALK, MARKOV, " +
|
|
1467
|
-
"RATCHET, PHASE, RIFF, MASK). cell{N}_playerDensity/Mutate/P1..P5 are modulatable params with macro_player* twins; " +
|
|
1935
|
+
"RATCHET, PHASE, RIFF, MASK, PERC). cell{N}_playerDensity/Mutate/P1..P5 are modulatable params with macro_player* twins; " +
|
|
1468
1936
|
"write them through set_player, never set_parameters alone, so the cell config stays in step.",
|
|
1469
1937
|
modulationGuidance: "Primary movement should target per-cell params for distinct audible changes. Use macro_* for performance-level fan-out across cells. " +
|
|
1470
1938
|
"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.",
|
|
@@ -1560,7 +2028,7 @@ server.registerTool("analyze_motion", {
|
|
|
1560
2028
|
"a bar — the last one is what catches a bar-synced gate, tremolo or sidechain pump, whose bars are all " +
|
|
1561
2029
|
"identical to each other.\n\n" +
|
|
1562
2030
|
"Pass wavPath to analyze an existing render or live_resample capture. Otherwise the tool runs an offline " +
|
|
1563
|
-
"export first and leaves that render in PAM's Exports folder
|
|
2031
|
+
"export first and leaves that render in PAM's Exports folder (its path is in the result). Digital silence is " +
|
|
1564
2032
|
"reported as a RENDER FAILURE with the workaround, not as 'no motion' — offline export is known to produce " +
|
|
1565
2033
|
"silence in the standalone build.",
|
|
1566
2034
|
inputSchema: {
|
|
@@ -1572,35 +2040,34 @@ server.registerTool("analyze_motion", {
|
|
|
1572
2040
|
bpm: z
|
|
1573
2041
|
.number()
|
|
1574
2042
|
.optional()
|
|
1575
|
-
.describe("
|
|
2043
|
+
.describe("wavPath only: tempo for the 4/4 per-bar windows (default: the transport's). A render uses its own tempo and meter."),
|
|
1576
2044
|
},
|
|
1577
2045
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
1578
2046
|
}, async ({ wavPath, bars, bpm }) => {
|
|
1579
2047
|
let path = wavPath;
|
|
1580
2048
|
let effectiveBpm = bpm;
|
|
2049
|
+
if (!path && IS_MFX_SERVER) {
|
|
2050
|
+
return error("Pass wavPath: MFX processes its host's input, so an offline render has no source to analyze.");
|
|
2051
|
+
}
|
|
2052
|
+
// Bar windows in seconds; a render reports its own, from the transport's tempo and meter.
|
|
2053
|
+
let barSeconds;
|
|
1581
2054
|
if (!path) {
|
|
1582
2055
|
const check = await requireBridge();
|
|
1583
2056
|
if (check !== true)
|
|
1584
2057
|
return check;
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
const
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
mode: "main",
|
|
1591
|
-
durationInSeconds: (bars * 4 * 60) / (effectiveBpm || 120),
|
|
1592
|
-
bitDepth: 24,
|
|
1593
|
-
});
|
|
2058
|
+
// The bars form of exportAudio is the one that reports its output path.
|
|
2059
|
+
const started = await bridge.send("exportAudio", { bars, bitDepth: 24 });
|
|
2060
|
+
const outputPath = isRecord(started) && typeof started.outputPath === "string" ? started.outputPath : undefined;
|
|
2061
|
+
if (!outputPath)
|
|
2062
|
+
return error("This build did not report the render's output path. Re-run with an explicit wavPath.");
|
|
1594
2063
|
const outcome = await pollExportStatus();
|
|
1595
2064
|
if (outcome !== "success")
|
|
1596
|
-
return error(`Render failed before analysis could run (export ${outcome})
|
|
1597
|
-
|
|
1598
|
-
const
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
}
|
|
1603
|
-
path = resolve(exportDir, `${filename}.wav`);
|
|
2065
|
+
return error(`Render failed before analysis could run (export ${outcome}).${exportFailureDetail()}`);
|
|
2066
|
+
path = outputPath;
|
|
2067
|
+
const renderedSeconds = isRecord(started) ? Number(started.durationInSeconds) : NaN;
|
|
2068
|
+
barSeconds = renderedSeconds > 0 ? renderedSeconds / bars : undefined;
|
|
2069
|
+
if (isRecord(started) && typeof started.bpm === "number")
|
|
2070
|
+
effectiveBpm = started.bpm;
|
|
1604
2071
|
}
|
|
1605
2072
|
else if (effectiveBpm === undefined) {
|
|
1606
2073
|
const check = await requireBridge();
|
|
@@ -1612,7 +2079,7 @@ server.registerTool("analyze_motion", {
|
|
|
1612
2079
|
}
|
|
1613
2080
|
let metrics;
|
|
1614
2081
|
try {
|
|
1615
|
-
metrics = analyzeMotion(decodeWav(await readFile(path)), effectiveBpm);
|
|
2082
|
+
metrics = analyzeMotion(decodeWav(await readFile(path)), barSeconds ?? (effectiveBpm ? (60 / effectiveBpm) * 4 : undefined));
|
|
1616
2083
|
}
|
|
1617
2084
|
catch (e) {
|
|
1618
2085
|
return error(`Could not analyze ${path}: ${e.message}`);
|
|
@@ -1745,69 +2212,6 @@ server.registerTool("variation_distinctness", {
|
|
|
1745
2212
|
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.",
|
|
1746
2213
|
});
|
|
1747
2214
|
});
|
|
1748
|
-
// Standard full update — graph dispatch + KV sync + parameter updates.
|
|
1749
|
-
const KV_OPTS_STANDARD = {
|
|
1750
|
-
shouldDispatch: true,
|
|
1751
|
-
syncKVResources: true,
|
|
1752
|
-
syncConfigResources: true,
|
|
1753
|
-
resyncKVAfterDispatch: true,
|
|
1754
|
-
updateParameters: true,
|
|
1755
|
-
broadcastFollowers: true,
|
|
1756
|
-
};
|
|
1757
|
-
// Modulation update — same as standard but no follower broadcast.
|
|
1758
|
-
const KV_OPTS_MOD = {
|
|
1759
|
-
shouldDispatch: true,
|
|
1760
|
-
syncKVResources: true,
|
|
1761
|
-
syncConfigResources: true,
|
|
1762
|
-
resyncKVAfterDispatch: true,
|
|
1763
|
-
updateParameters: true,
|
|
1764
|
-
broadcastFollowers: false,
|
|
1765
|
-
};
|
|
1766
|
-
// Pattern/sequencer update — KV-only, no graph rebuild, no param updates.
|
|
1767
|
-
const KV_OPTS_PATTERN = {
|
|
1768
|
-
shouldDispatch: true,
|
|
1769
|
-
syncKVResources: true,
|
|
1770
|
-
syncConfigResources: false,
|
|
1771
|
-
skipFileLoadingIfUnchanged: true,
|
|
1772
|
-
resyncKVAfterDispatch: false,
|
|
1773
|
-
updateParameters: false,
|
|
1774
|
-
broadcastFollowers: false,
|
|
1775
|
-
};
|
|
1776
|
-
// Master-chain config — KV + config-resource sync, NO graph dispatch. Every
|
|
1777
|
-
// master key (comp incl. the full promoted Punch surface, tape, iron, clipper,
|
|
1778
|
-
// limiter, EQ/width) is live-polled from master_config by its native node, so
|
|
1779
|
-
// a rebuild here is pure dropout risk (the plan's no-rebuild contract).
|
|
1780
|
-
// Mirrors interop.ts updateMasterConfig's options.
|
|
1781
|
-
const KV_OPTS_MASTER_CONFIG = {
|
|
1782
|
-
shouldDispatch: false,
|
|
1783
|
-
syncKVResources: true,
|
|
1784
|
-
syncConfigResources: true,
|
|
1785
|
-
skipFileLoadingIfUnchanged: true,
|
|
1786
|
-
resyncKVAfterDispatch: false,
|
|
1787
|
-
updateParameters: true,
|
|
1788
|
-
broadcastFollowers: false,
|
|
1789
|
-
};
|
|
1790
|
-
// Effect config toggle — lighter KV path, no graph dispatch.
|
|
1791
|
-
const KV_OPTS_EFFECT_CONFIG = {
|
|
1792
|
-
shouldDispatch: false,
|
|
1793
|
-
syncKVResources: true,
|
|
1794
|
-
syncConfigResources: true,
|
|
1795
|
-
skipFileLoadingIfUnchanged: true,
|
|
1796
|
-
resyncKVAfterDispatch: false,
|
|
1797
|
-
updateParameters: true,
|
|
1798
|
-
broadcastFollowers: true,
|
|
1799
|
-
};
|
|
1800
|
-
// Cell config update — KV-only, syncs cell config resources, no graph dispatch.
|
|
1801
|
-
// Matches interop.ts updateCellConfig() options.
|
|
1802
|
-
const KV_OPTS_CELL_CONFIG = {
|
|
1803
|
-
shouldDispatch: false,
|
|
1804
|
-
syncKVResources: true,
|
|
1805
|
-
syncConfigResources: true,
|
|
1806
|
-
skipFileLoadingIfUnchanged: true,
|
|
1807
|
-
resyncKVAfterDispatch: false,
|
|
1808
|
-
updateParameters: true,
|
|
1809
|
-
broadcastFollowers: false,
|
|
1810
|
-
};
|
|
1811
2215
|
async function applyKv(delta, options, directNodeOps) {
|
|
1812
2216
|
const payload = { delta, options };
|
|
1813
2217
|
if (directNodeOps)
|
|
@@ -1890,6 +2294,29 @@ server.registerTool("get_design_overview", {
|
|
|
1890
2294
|
vary: Array.isArray(state.vary) ? state.vary : [],
|
|
1891
2295
|
paramSeq: Array.isArray(state.paramSeqs) ? state.paramSeqs : [],
|
|
1892
2296
|
};
|
|
2297
|
+
if (isMsqManifest(manifest)) {
|
|
2298
|
+
const cells = ids.map((id) => {
|
|
2299
|
+
const rawCell = cellsObj[String(id)];
|
|
2300
|
+
const targets = new Set([`cell${id}_`]);
|
|
2301
|
+
return {
|
|
2302
|
+
...msqCellSummary(id, isRecord(rawCell) ? rawCell : {}, state),
|
|
2303
|
+
modulation: Object.fromEntries(Object.entries(modArrays).map(([kind, arr]) => [kind, arr.filter((entry) => paramTargetsCell(entry, targets)).length])),
|
|
2304
|
+
};
|
|
2305
|
+
});
|
|
2306
|
+
return json({
|
|
2307
|
+
product: manifest.name,
|
|
2308
|
+
bpm: state.bpm ?? transport.bpm,
|
|
2309
|
+
playing: transport.isPlaying,
|
|
2310
|
+
totals: {
|
|
2311
|
+
cellsSending: cells.filter((cell) => cell.send && !cell.muted).length,
|
|
2312
|
+
playersRunning: cells.filter((cell) => cell.player).length,
|
|
2313
|
+
noteEvents: cells.reduce((sum, cell) => sum + cell.noteEvents, 0),
|
|
2314
|
+
ccSlots: cells.reduce((sum, cell) => sum + cell.ccOut.length, 0),
|
|
2315
|
+
modulationLinks: cells.reduce((sum, cell) => sum + Object.values(cell.modulation).reduce((subtotal, count) => subtotal + count, 0), 0),
|
|
2316
|
+
},
|
|
2317
|
+
cells,
|
|
2318
|
+
});
|
|
2319
|
+
}
|
|
1893
2320
|
if (IS_MFX_SERVER) {
|
|
1894
2321
|
const cells = ids.map((id) => {
|
|
1895
2322
|
const prefix = `cell${id}_`;
|
|
@@ -1917,8 +2344,9 @@ server.registerTool("get_design_overview", {
|
|
|
1917
2344
|
return json({
|
|
1918
2345
|
product: manifest.name,
|
|
1919
2346
|
bpm: state.bpm ?? transport.bpm,
|
|
1920
|
-
playing: transport.
|
|
2347
|
+
playing: transport.isPlaying,
|
|
1921
2348
|
mix: state.mfx_mix,
|
|
2349
|
+
outputGain: state.mfx_outputGain,
|
|
1922
2350
|
routing: state.mfx_routing,
|
|
1923
2351
|
edgeGains: state.mfx_edge_gains,
|
|
1924
2352
|
split: { lowHz: state.mfx_split_lo_hz, highHz: state.mfx_split_hi_hz },
|
|
@@ -1956,10 +2384,14 @@ server.registerTool("get_design_overview", {
|
|
|
1956
2384
|
synthEngine: describeSynthEngine(state[`${prefix}synthEngine`]),
|
|
1957
2385
|
player: describeCellPlayer(id, cellState, state),
|
|
1958
2386
|
enabledEffects: effectNames.filter((effectName) => state[`${prefix}${effectName}Enabled`] === 1 || state[`${prefix}${effectName}Enabled`] === true),
|
|
1959
|
-
patternEvents: Object.fromEntries(Object.entries(
|
|
2387
|
+
patternEvents: Object.fromEntries(Object.entries(PATTERN_LANE_SUFFIX).map(([lane, suffix]) => [
|
|
1960
2388
|
lane,
|
|
1961
2389
|
countEvents(state[`${prefix}${suffix}`]),
|
|
1962
2390
|
])),
|
|
2391
|
+
laneTargets: Object.fromEntries(PARAM_LANES.flatMap((lane) => {
|
|
2392
|
+
const target = cellState[LANE_TARGET_FIELD[lane]];
|
|
2393
|
+
return typeof target === "string" && target ? [[lane, target]] : [];
|
|
2394
|
+
})),
|
|
1963
2395
|
modulation,
|
|
1964
2396
|
mixer: {
|
|
1965
2397
|
volume: state[`${prefix}volume`],
|
|
@@ -1971,7 +2403,7 @@ server.registerTool("get_design_overview", {
|
|
|
1971
2403
|
return json({
|
|
1972
2404
|
product: manifest.name,
|
|
1973
2405
|
bpm: state.bpm ?? transport.bpm,
|
|
1974
|
-
playing: transport.
|
|
2406
|
+
playing: transport.isPlaying,
|
|
1975
2407
|
masterFx: {
|
|
1976
2408
|
compressorEnabled: state.masterCompressorEnabled,
|
|
1977
2409
|
limiterEnabled: state.masterLimiterEnabled,
|
|
@@ -2067,14 +2499,7 @@ server.registerTool("set_parameters", {
|
|
|
2067
2499
|
}
|
|
2068
2500
|
await bridge.send("applyUnifiedStateKvUpdate", {
|
|
2069
2501
|
delta,
|
|
2070
|
-
options:
|
|
2071
|
-
shouldDispatch: true,
|
|
2072
|
-
syncKVResources: true,
|
|
2073
|
-
syncConfigResources: true,
|
|
2074
|
-
resyncKVAfterDispatch: true,
|
|
2075
|
-
updateParameters: true,
|
|
2076
|
-
broadcastFollowers: true,
|
|
2077
|
-
},
|
|
2502
|
+
options: STATE_UPDATE_KV_OPTIONS,
|
|
2078
2503
|
});
|
|
2079
2504
|
return text(`Set ${Object.keys(parameters).length} parameter(s): ${Object.entries(parameters).map(([k, v]) => `${k}=${v}`).join(", ")}` +
|
|
2080
2505
|
(comboTargetCount > 0 ? `; fanned out ${comboTargetCount} MFX combo target write(s)` : ""));
|
|
@@ -2106,9 +2531,25 @@ const MODEL_VARIANT = z
|
|
|
2106
2531
|
.describe("Magenta only: which variant (base = 2.4B, needs ~M2 Max/M3 Pro+; small = 230M, default). Ignored for notochord/aigen (single model).");
|
|
2107
2532
|
server.registerTool("download_model", {
|
|
2108
2533
|
title: "Download AI Model",
|
|
2109
|
-
description: "Start (or resume) downloading model weights for an AI engine.
|
|
2534
|
+
description: "Start (or resume) downloading model weights for an AI engine. Returns at once with the download's progress (in a pam mcp session, the daemon job's jobId); poll get_model_status. Downloads through PAM need the engine's experimental flags, and aigen + notochord then auto-load on completion; magenta and pam mcp session downloads need a separate load_model.",
|
|
2110
2535
|
inputSchema: { engine: MODEL_ENGINE, model: MODEL_VARIANT },
|
|
2111
2536
|
}, async ({ engine, model }) => {
|
|
2537
|
+
if (DAEMON_SESSION && engine !== "notochord") {
|
|
2538
|
+
try {
|
|
2539
|
+
const running = daemonDownloads.get(engine);
|
|
2540
|
+
if (running?.model === daemonModel(engine, model)) {
|
|
2541
|
+
const job = await daemon.downloadStatus(running).catch(() => undefined);
|
|
2542
|
+
if (job?.inFlight)
|
|
2543
|
+
return json({ ok: true, ...job });
|
|
2544
|
+
}
|
|
2545
|
+
const download = await daemon.startDownload(daemonModel(engine, model));
|
|
2546
|
+
daemonDownloads.set(engine, download);
|
|
2547
|
+
return json(download);
|
|
2548
|
+
}
|
|
2549
|
+
catch (cause) {
|
|
2550
|
+
return daemonFailure(cause);
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2112
2553
|
const check = await requireBridge();
|
|
2113
2554
|
if (check !== true)
|
|
2114
2555
|
return check;
|
|
@@ -2127,8 +2568,30 @@ server.registerTool("get_model_status", {
|
|
|
2127
2568
|
version: z.string().optional(),
|
|
2128
2569
|
state: z.string().optional(),
|
|
2129
2570
|
progress: z.number().optional(),
|
|
2571
|
+
jobId: z.string().optional(),
|
|
2572
|
+
daemonEpoch: z.string().optional(),
|
|
2130
2573
|
},
|
|
2131
2574
|
}, async ({ engine }) => {
|
|
2575
|
+
const download = DAEMON_SESSION ? daemonDownloads.get(engine) : undefined;
|
|
2576
|
+
let job;
|
|
2577
|
+
if (download) {
|
|
2578
|
+
try {
|
|
2579
|
+
job = await daemon.downloadStatus(download);
|
|
2580
|
+
}
|
|
2581
|
+
catch (cause) {
|
|
2582
|
+
// A job pamd no longer knows stops shadowing the plugin's own status.
|
|
2583
|
+
if (!LOST_DAEMON_JOB.has(daemonFailureCode(cause) ?? ""))
|
|
2584
|
+
return daemonFailure(cause);
|
|
2585
|
+
daemonDownloads.delete(engine);
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
if (job) {
|
|
2589
|
+
const plugin = bridge.isConnected() || await bridge.connect()
|
|
2590
|
+
? await bridge.send("modelStatus", { engine }).then((raw) => typeof raw === "string" ? JSON.parse(raw) : raw).catch(() => undefined)
|
|
2591
|
+
: undefined;
|
|
2592
|
+
const live = isRecord(plugin) ? plugin : { engine };
|
|
2593
|
+
return structured(job.inFlight ? { ...live, ...job } : { ...job, ...live });
|
|
2594
|
+
}
|
|
2132
2595
|
const check = await requireBridge();
|
|
2133
2596
|
if (check !== true)
|
|
2134
2597
|
return check;
|
|
@@ -2156,9 +2619,33 @@ server.registerTool("unload_model", {
|
|
|
2156
2619
|
});
|
|
2157
2620
|
server.registerTool("delete_model", {
|
|
2158
2621
|
title: "Delete AI Model",
|
|
2159
|
-
description: "Delete downloaded model weights from disk. Unloads first automatically. DESTRUCTIVE — re-download to restore. magenta
|
|
2622
|
+
description: "Delete downloaded model weights from disk. Unloads first automatically. DESTRUCTIVE — re-download to restore. For magenta, model (small|base) removes that variant and keeps the resources the other installed variant shares; omit model to remove both variants and the shared resources.",
|
|
2160
2623
|
inputSchema: { engine: MODEL_ENGINE, model: MODEL_VARIANT },
|
|
2161
|
-
}, async ({ engine, model }) => {
|
|
2624
|
+
}, async ({ engine, model }, extra) => {
|
|
2625
|
+
if (DAEMON_SESSION && engine !== "notochord") {
|
|
2626
|
+
const models = engine === "magenta" && !model ? ["mrt2-small", "mrt2-base"] : [daemonModel(engine, model)];
|
|
2627
|
+
try {
|
|
2628
|
+
if (bridge.isConnected() || await bridge.connect()) {
|
|
2629
|
+
try {
|
|
2630
|
+
await bridge.send("modelDelete", { engine, model });
|
|
2631
|
+
}
|
|
2632
|
+
catch (cause) {
|
|
2633
|
+
const name = engine === "aigen" ? "AIGen" : "Magenta";
|
|
2634
|
+
if (!(cause instanceof Error) || !cause.message.startsWith(`[modelDelete] ${name} disabled —`))
|
|
2635
|
+
throw cause;
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
const results = [];
|
|
2639
|
+
for (const id of models)
|
|
2640
|
+
results.push(await daemon.remove(id, extra?.signal));
|
|
2641
|
+
if (models.includes(daemonDownloads.get(engine)?.model ?? ""))
|
|
2642
|
+
daemonDownloads.delete(engine);
|
|
2643
|
+
return json(results);
|
|
2644
|
+
}
|
|
2645
|
+
catch (cause) {
|
|
2646
|
+
return daemonFailure(cause);
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2162
2649
|
const check = await requireBridge();
|
|
2163
2650
|
if (check !== true)
|
|
2164
2651
|
return check;
|
|
@@ -2176,22 +2663,18 @@ server.registerTool("list_models", {
|
|
|
2176
2663
|
});
|
|
2177
2664
|
server.registerTool("generate_sample", {
|
|
2178
2665
|
title: "Generate Sample (AI)",
|
|
2179
|
-
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.",
|
|
2666
|
+
description: "Generate a new audio sample from a text prompt via the on-device AI engine (Stable Audio 3 Small SFX, 2.71 GB; text-to-audio only — audio-to-audio lives in the Workbench tools). 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.",
|
|
2180
2667
|
inputSchema: {
|
|
2181
2668
|
category: z
|
|
2182
2669
|
.enum(["kick", "snare", "hat", "perc", "bass", "synth", "pad", "fx", "loop"])
|
|
2183
2670
|
.describe("Sound category — drives prompt template defaults."),
|
|
2184
2671
|
prompt: z.string().describe("Text prompt describing the desired sound."),
|
|
2672
|
+
// The plugin renders Small SFX: OnnxAIGenEngine::kMinDurationSeconds and its Sa3Layout maximum.
|
|
2185
2673
|
durationSeconds: z
|
|
2186
2674
|
.number()
|
|
2187
|
-
.min(0.
|
|
2188
|
-
.max(
|
|
2189
|
-
.describe("Output length in seconds (
|
|
2190
|
-
mode: z
|
|
2191
|
-
.enum(["text", "inpaint", "continuation"])
|
|
2192
|
-
.optional()
|
|
2193
|
-
.default("text")
|
|
2194
|
-
.describe("Generation mode. text = unconditional, inpaint = audio-to-audio with mask, continuation = extend existing audio."),
|
|
2675
|
+
.min(0.05)
|
|
2676
|
+
.max(120)
|
|
2677
|
+
.describe("Output length in seconds (the model's window is ~120 s). For loops, pass bar-converted duration: bars * beatsPerBar * 60 / bpm."),
|
|
2195
2678
|
cellId: z
|
|
2196
2679
|
.number()
|
|
2197
2680
|
.int()
|
|
@@ -2199,28 +2682,32 @@ server.registerTool("generate_sample", {
|
|
|
2199
2682
|
.max(8)
|
|
2200
2683
|
.optional()
|
|
2201
2684
|
.describe("If provided, load result directly into this cell."),
|
|
2202
|
-
seed: z
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
.
|
|
2685
|
+
seed: z
|
|
2686
|
+
.number()
|
|
2687
|
+
.int()
|
|
2688
|
+
.min(0)
|
|
2689
|
+
.max(4294967295)
|
|
2206
2690
|
.optional()
|
|
2207
|
-
.describe(
|
|
2691
|
+
.describe("Deterministic seed. Random if omitted or 0; the result's seed reproduces the take."),
|
|
2208
2692
|
},
|
|
2209
|
-
}, async (args) => {
|
|
2693
|
+
}, async (args, extra) => {
|
|
2210
2694
|
const ok = await requireBridge();
|
|
2211
2695
|
if (ok !== true)
|
|
2212
2696
|
return ok;
|
|
2213
2697
|
try {
|
|
2214
|
-
const
|
|
2698
|
+
const cells = await requireBridgeForCells(args.cellId);
|
|
2699
|
+
if (cells !== true)
|
|
2700
|
+
return cells;
|
|
2701
|
+
const result = DAEMON_SESSION ? await pinnedGeneration(args, undefined, extra?.signal) : await bridge.send("aiGenerate", args);
|
|
2215
2702
|
return json(result);
|
|
2216
2703
|
}
|
|
2217
2704
|
catch (e) {
|
|
2218
|
-
return error(`AI generation failed: ${e.message}`);
|
|
2705
|
+
return DAEMON_SESSION ? daemonFailure(e, "AI generation failed: ") : error(`AI generation failed: ${e.message}`);
|
|
2219
2706
|
}
|
|
2220
2707
|
});
|
|
2221
2708
|
server.registerTool("generate_kit", {
|
|
2222
2709
|
title: "Generate Kit (AI)",
|
|
2223
|
-
description: "Generate multiple AI samples
|
|
2710
|
+
description: "Generate multiple AI samples one after another (Stable Audio 3 Small SFX) and load them into a list of cells. Useful for building a coherent drum / instrument kit from a single high-level prompt. Requires the on-device model to be downloaded (Settings → Local models).",
|
|
2224
2711
|
inputSchema: {
|
|
2225
2712
|
prompt: z.string().describe('High-level kit description (e.g. "dark trap drum kit", "lofi house").'),
|
|
2226
2713
|
cells: z
|
|
@@ -2232,7 +2719,7 @@ server.registerTool("generate_kit", {
|
|
|
2232
2719
|
.max(8)
|
|
2233
2720
|
.describe("Cells to populate. Each gets one sample matching the role."),
|
|
2234
2721
|
},
|
|
2235
|
-
}, async (args) => {
|
|
2722
|
+
}, async (args, extra) => {
|
|
2236
2723
|
const ok = await requireBridge();
|
|
2237
2724
|
if (ok !== true)
|
|
2238
2725
|
return ok;
|
|
@@ -2248,11 +2735,29 @@ server.registerTool("generate_kit", {
|
|
|
2248
2735
|
durationSeconds: durationForRole(c.role),
|
|
2249
2736
|
}));
|
|
2250
2737
|
try {
|
|
2738
|
+
if (DAEMON_SESSION) {
|
|
2739
|
+
const check = await requireBridgeForCells(...cells.map((cell) => cell.id));
|
|
2740
|
+
if (check !== true)
|
|
2741
|
+
return check;
|
|
2742
|
+
const instance = bridge.getConnectedInstance();
|
|
2743
|
+
const destination = new PluginBridge();
|
|
2744
|
+
if (!instance || !await destination.connect({ instanceId: instance.instanceId, product: instance.product }))
|
|
2745
|
+
return error("plugin_unavailable: selected instance disconnected");
|
|
2746
|
+
try {
|
|
2747
|
+
const results = [];
|
|
2748
|
+
for (const request of requests)
|
|
2749
|
+
results.push(await pinnedGeneration(request, destination, extra?.signal));
|
|
2750
|
+
return json(results);
|
|
2751
|
+
}
|
|
2752
|
+
finally {
|
|
2753
|
+
destination.disconnect();
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2251
2756
|
const results = await bridge.send("aiGenerateKit", { requests });
|
|
2252
2757
|
return json(results);
|
|
2253
2758
|
}
|
|
2254
2759
|
catch (e) {
|
|
2255
|
-
return error(`Kit generation failed: ${e.message}`);
|
|
2760
|
+
return DAEMON_SESSION ? daemonFailure(e, "Kit generation failed: ") : error(`Kit generation failed: ${e.message}`);
|
|
2256
2761
|
}
|
|
2257
2762
|
});
|
|
2258
2763
|
// --- AI Audio Workbench (Phase A, dev-gated) ---------------------------------
|
|
@@ -2319,7 +2824,7 @@ server.registerTool("workbench_set_state", {
|
|
|
2319
2824
|
server.registerTool("workbench_render", {
|
|
2320
2825
|
title: "Workbench: Render",
|
|
2321
2826
|
description: WORKBENCH_PREREQ +
|
|
2322
|
-
"Render the Workbench transform via the SA3 sidecar and append the result to the take shelf. Renders the CURRENT canvas state merged with any overrides passed here (overrides do not persist).
|
|
2827
|
+
"Render the Workbench transform via the SA3 sidecar and append the result to the take shelf. Renders the CURRENT canvas state merged with any overrides passed here (overrides do not persist). Waits until done (up to ~10 min: pipeline rebuilds + multi-region inpaints render sequentially). Returns the take { id, wavPath, prompt, seed, sigma, maskRegions, createdAt, sourceCellId, sourcePath, durationSeconds, activeSourcePath, chainAdvanced, error? }. Source resolution: sourcePath override > activeSourcePath (chain head) > state.sourceCellId's sample file (a sourceCellId override renders that cell's sample). A successful render of the canvas's own source ADVANCES the chain (activeSourcePath = new take's wavPath) — step back with workbench_undo or workbench_reset_chain; renders with a sourcePath/sourceCellId override, or that finished after the chain moved, leave the head alone (chainAdvanced: false).",
|
|
2323
2828
|
inputSchema: {
|
|
2324
2829
|
...WORKBENCH_STATE_FIELDS,
|
|
2325
2830
|
sourcePath: z.string().optional()
|
|
@@ -2506,17 +3011,16 @@ server.registerTool("add_modulation", {
|
|
|
2506
3011
|
},
|
|
2507
3012
|
}, async ({ type, paramId, cellId, lfoIndex, strength, bipolar, pitch, pitchDuck }) => {
|
|
2508
3013
|
const index = await getParameterIndex();
|
|
2509
|
-
const param =
|
|
2510
|
-
if (
|
|
2511
|
-
return error(
|
|
2512
|
-
if (!param.isModulatable)
|
|
2513
|
-
return error(formatNotModulatableError(paramId, index));
|
|
3014
|
+
const param = await resolveModulationTarget(paramId, index);
|
|
3015
|
+
if (typeof param === "string")
|
|
3016
|
+
return error(param);
|
|
2514
3017
|
const check = await requireBridgeForCells(cellId);
|
|
2515
3018
|
if (check !== true)
|
|
2516
3019
|
return check;
|
|
2517
3020
|
// Compute effect-enabled params to auto-enable disabled effects (mirrors UI behavior)
|
|
2518
3021
|
const effectEnableParams = {};
|
|
2519
|
-
|
|
3022
|
+
// Only cells have per-effect flags; native enables a macro target itself (MCPBridge::handleAddModulation).
|
|
3023
|
+
if (param.effect && /^cell\d+$/.test(param.cell ?? "")) {
|
|
2520
3024
|
effectEnableParams[`${param.cell}_${param.effect}Enabled`] = 1;
|
|
2521
3025
|
}
|
|
2522
3026
|
if (type === "lfo") {
|
|
@@ -2536,34 +3040,42 @@ server.registerTool("add_modulation", {
|
|
|
2536
3040
|
}
|
|
2537
3041
|
return text(`Added ${type} modulation to ${paramId} (strength: ${strength}%).`);
|
|
2538
3042
|
});
|
|
3043
|
+
/** The full parameter rows (ranges, isModulatable) a lane target is checked against. */
|
|
3044
|
+
async function laneTargetParameters() {
|
|
3045
|
+
return loadManifest()
|
|
3046
|
+
.then((manifest) => manifest.parameters)
|
|
3047
|
+
.catch(() => []);
|
|
3048
|
+
}
|
|
3049
|
+
/** A cell's param-lane target suffix ("" = none). */
|
|
3050
|
+
async function laneTargetOf(cellId, lane) {
|
|
3051
|
+
const cells = await bridge.send("getState", "cells").catch(() => undefined);
|
|
3052
|
+
const cell = isRecord(cells) ? cells[String(cellId)] : undefined;
|
|
3053
|
+
const target = isRecord(cell) ? cell[LANE_TARGET_FIELD[lane]] : undefined;
|
|
3054
|
+
return typeof target === "string" ? target : "";
|
|
3055
|
+
}
|
|
2539
3056
|
server.registerTool("update_pattern", {
|
|
2540
3057
|
title: "Update Pattern",
|
|
2541
|
-
description: "Write a
|
|
3058
|
+
description: "Write one lane of a cell's pattern: the notes, a value lane (pitch, slice, stretch), a CC lane (ccOut1-ccOut4, " +
|
|
3059
|
+
"the timeline of that CC out slot: each event sends its value on the slot's controller for its duration, then the " +
|
|
3060
|
+
"slot returns to its knob) or a PAM param lane (paramLane1-paramLane4: each event sets the parameter the lane " +
|
|
3061
|
+
"targets for its duration, in that parameter's units). MSQ has note and CC lanes; MFX has none.",
|
|
2542
3062
|
inputSchema: {
|
|
2543
3063
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
2544
|
-
type: z
|
|
2545
|
-
.enum(["note", "pitch", "slice", "stretch"])
|
|
2546
|
-
.describe("Pattern lane type."),
|
|
3064
|
+
type: z.enum(PATTERN_LANE_IDS).describe("Pattern lane."),
|
|
2547
3065
|
sequence: z
|
|
2548
3066
|
.array(z.any())
|
|
2549
3067
|
.describe("Array of sequence events. For 'note': [{time, note, duration, velocity, prob}]. " +
|
|
2550
|
-
"For
|
|
3068
|
+
"For value and CC lanes: [{time, value, duration, prob?}] (CC values 0-127; times and durations in beats)."),
|
|
2551
3069
|
},
|
|
2552
3070
|
}, async ({ cellId, type, sequence }) => {
|
|
2553
3071
|
const check = await requireBridgeForCells(cellId);
|
|
2554
3072
|
if (check !== true)
|
|
2555
3073
|
return check;
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
slice: "sliceSequence",
|
|
2562
|
-
stretch: "stretchSequence",
|
|
2563
|
-
};
|
|
2564
|
-
const suffix = keyMap[type];
|
|
2565
|
-
if (!suffix)
|
|
2566
|
-
return error(`Unknown pattern type: ${type}`);
|
|
3074
|
+
const manifest = await loadManifestForProductGuard();
|
|
3075
|
+
if (manifest && !productHasLane(cellId, type, manifest.stateVariables)) {
|
|
3076
|
+
return error(`${manifest.name ?? "This product"} has no ${type} lane.`);
|
|
3077
|
+
}
|
|
3078
|
+
const suffix = PATTERN_LANE_SUFFIX[type];
|
|
2567
3079
|
// For note sequences, fill in required defaults that PAM's sequencer expects
|
|
2568
3080
|
let normalizedSequence = sequence;
|
|
2569
3081
|
if (suffix === "midiSequence") {
|
|
@@ -2586,22 +3098,79 @@ server.registerTool("update_pattern", {
|
|
|
2586
3098
|
value: evt.value ?? evt.velocity ?? 127,
|
|
2587
3099
|
}));
|
|
2588
3100
|
}
|
|
3101
|
+
if (isCcLane(type))
|
|
3102
|
+
normalizedSequence = normalizeCcLaneEvents(sequence);
|
|
3103
|
+
if (isParamLane(type)) {
|
|
3104
|
+
const target = await laneTargetOf(cellId, type);
|
|
3105
|
+
if (!target)
|
|
3106
|
+
return error(`Cell ${cellId}'s ${type} has no target parameter: pick one with set_lane_target first.`);
|
|
3107
|
+
const resolved = resolveLaneTarget(cellId, target, await laneTargetParameters());
|
|
3108
|
+
if ("reason" in resolved)
|
|
3109
|
+
return error(`The ${type} lane targets ${target}: ${resolved.reason}.`);
|
|
3110
|
+
normalizedSequence = normalizeTargetLaneEvents(sequence, resolved.param);
|
|
3111
|
+
}
|
|
2589
3112
|
const stateKey = `cell${cellId}_${suffix}`;
|
|
2590
3113
|
await bridge.send("applyUnifiedStateKvUpdate", {
|
|
2591
3114
|
delta: { [stateKey]: normalizedSequence, __forceRender__: true },
|
|
2592
|
-
options:
|
|
2593
|
-
shouldDispatch: true,
|
|
2594
|
-
syncKVResources: true,
|
|
2595
|
-
syncConfigResources: false,
|
|
2596
|
-
skipFileLoadingIfUnchanged: true,
|
|
2597
|
-
resyncKVAfterDispatch: false,
|
|
2598
|
-
updateParameters: false,
|
|
2599
|
-
broadcastFollowers: false,
|
|
2600
|
-
},
|
|
3115
|
+
options: PATTERN_EDIT_KV_OPTIONS,
|
|
2601
3116
|
directNodeOps: [{ key: stateKey, property: "sequence", value: normalizedSequence }],
|
|
2602
3117
|
});
|
|
2603
3118
|
return text(`Updated ${type} pattern for cell ${cellId} (${normalizedSequence.length} events).`);
|
|
2604
3119
|
});
|
|
3120
|
+
server.registerTool("set_lane_target", {
|
|
3121
|
+
title: "Set Lane Target",
|
|
3122
|
+
description: "Point one of a PAM cell's four param lanes (paramLane1-paramLane4) at a modulatable parameter of the same cell " +
|
|
3123
|
+
"(a suffix such as 'filterCutoff'), or clear it with null. Each lane event then sets that parameter for its " +
|
|
3124
|
+
"duration; outside events the parameter follows its knob and modulation. Each param lane loops at its own " +
|
|
3125
|
+
"clock, cell{N}_patternLengthParam{K} and cell{N}_patternSpeedParam{K} (set_parameters; 4 beats at 1x by default), " +
|
|
3126
|
+
"while swing and direction follow the note lane; copy the note lane's length and speed to match it. The lane is cleared (its values meant the old target); another param lane already on that " +
|
|
3127
|
+
"parameter is cleared too. Write the lane with update_pattern in the target's units. For a free-running step " +
|
|
3128
|
+
"contour that adds to the knob, use a ParamSeq instead.",
|
|
3129
|
+
inputSchema: {
|
|
3130
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
3131
|
+
lane: z.enum(PARAM_LANES).describe("Param lane to point."),
|
|
3132
|
+
param: z.string().min(1).nullable().describe("Target parameter suffix (no cellN_ prefix), or null for none."),
|
|
3133
|
+
},
|
|
3134
|
+
}, async ({ cellId, lane, param }) => {
|
|
3135
|
+
const check = await requireBridgeForCells(cellId);
|
|
3136
|
+
if (check !== true)
|
|
3137
|
+
return check;
|
|
3138
|
+
const manifest = await loadManifestForProductGuard();
|
|
3139
|
+
if (manifest && !productHasLane(cellId, lane, manifest.stateVariables)) {
|
|
3140
|
+
return error(`${manifest.name ?? "This product"} has no ${lane} lane.`);
|
|
3141
|
+
}
|
|
3142
|
+
let targetParam = null;
|
|
3143
|
+
if (param !== null) {
|
|
3144
|
+
const resolved = resolveLaneTarget(cellId, param, await laneTargetParameters());
|
|
3145
|
+
if ("reason" in resolved)
|
|
3146
|
+
return error(`Cannot target ${param}: ${resolved.reason}.`);
|
|
3147
|
+
targetParam = resolved.param;
|
|
3148
|
+
}
|
|
3149
|
+
const cells = await bridge.send("getState", "cells").catch(() => undefined);
|
|
3150
|
+
const cell = isRecord(cells) ? cells[String(cellId)] : undefined;
|
|
3151
|
+
const target = param ?? "";
|
|
3152
|
+
const current = isRecord(cell) && typeof cell[LANE_TARGET_FIELD[lane]] === "string" ? cell[LANE_TARGET_FIELD[lane]] : "";
|
|
3153
|
+
if (current === target)
|
|
3154
|
+
return text(`Cell ${cellId} ${lane} already ${target ? `→ ${target}` : "has no target"}; nothing changed.`);
|
|
3155
|
+
const cellDelta = { [LANE_TARGET_FIELD[lane]]: target };
|
|
3156
|
+
const delta = { [`cell${cellId}_${PATTERN_LANE_SUFFIX[lane]}`]: [] };
|
|
3157
|
+
const reverted = [];
|
|
3158
|
+
for (const sibling of PARAM_LANES) {
|
|
3159
|
+
if (sibling === lane || !target || !isRecord(cell) || cell[LANE_TARGET_FIELD[sibling]] !== target)
|
|
3160
|
+
continue;
|
|
3161
|
+
cellDelta[LANE_TARGET_FIELD[sibling]] = "";
|
|
3162
|
+
delta[`cell${cellId}_${PATTERN_LANE_SUFFIX[sibling]}`] = [];
|
|
3163
|
+
reverted.push(sibling);
|
|
3164
|
+
}
|
|
3165
|
+
delta.cells = { [cellId]: cellDelta };
|
|
3166
|
+
// Same as add_modulation: a lane on a bypassed effect's param turns the effect on.
|
|
3167
|
+
if (targetParam?.effect && targetParam.cell)
|
|
3168
|
+
delta[`${targetParam.cell}_${targetParam.effect}Enabled`] = 1;
|
|
3169
|
+
await applyKv(delta, STATE_UPDATE_KV_OPTIONS);
|
|
3170
|
+
const where = target ? `→ ${target}` : "target cleared";
|
|
3171
|
+
const note = reverted.length ? ` ${reverted.join(", ")} lost that target and was cleared.` : "";
|
|
3172
|
+
return text(`Cell ${cellId} ${lane} ${where}; lane cleared.${note}`);
|
|
3173
|
+
});
|
|
2605
3174
|
server.registerTool("randomize", {
|
|
2606
3175
|
title: "Randomize",
|
|
2607
3176
|
description: "Trigger PAM's randomization engine to generate random sounds and patterns. " +
|
|
@@ -2723,10 +3292,8 @@ server.registerTool("randomize_samples", {
|
|
|
2723
3292
|
let pool = allowedSamplePaths;
|
|
2724
3293
|
let diversifyNote = "";
|
|
2725
3294
|
if (!pool && diversify && diversify > 0) {
|
|
2726
|
-
const locations =
|
|
2727
|
-
const roots =
|
|
2728
|
-
.filter(([k, v]) => typeof v === "string" && v.length > 0 && /sample/i.test(k))
|
|
2729
|
-
.map(([, v]) => v);
|
|
3295
|
+
const locations = await defaultLocationPaths();
|
|
3296
|
+
const roots = ["User Samples", "Factory Samples"].flatMap((name) => locations.get(name) ?? []);
|
|
2730
3297
|
if (roots.length) {
|
|
2731
3298
|
const byFolder = await collectLibraryByFolder(roots);
|
|
2732
3299
|
const spread = spreadAcrossFolders(byFolder, diversify, seed ?? 0);
|
|
@@ -2827,12 +3394,14 @@ server.registerTool("full_stop_transport", {
|
|
|
2827
3394
|
});
|
|
2828
3395
|
server.registerTool("get_transport", {
|
|
2829
3396
|
title: "Get Transport",
|
|
2830
|
-
description: "Get current transport state (BPM, playing, position).",
|
|
3397
|
+
description: "Get current transport state (BPM, playing, position, meter).",
|
|
2831
3398
|
inputSchema: {},
|
|
2832
3399
|
outputSchema: {
|
|
2833
3400
|
bpm: z.number().optional(),
|
|
2834
3401
|
isPlaying: z.boolean().optional(),
|
|
2835
3402
|
ppqPosition: z.number().optional(),
|
|
3403
|
+
timeSigNumerator: z.number().optional(),
|
|
3404
|
+
timeSigDenominator: z.number().optional(),
|
|
2836
3405
|
},
|
|
2837
3406
|
annotations: { readOnlyHint: true },
|
|
2838
3407
|
}, async () => {
|
|
@@ -2864,8 +3433,10 @@ server.registerTool("get_mix_analysis", {
|
|
|
2864
3433
|
title: "Get Per-Cell Mix Analysis",
|
|
2865
3434
|
description: "Per-cell stem measurements from a faster-than-realtime offline bounce: LUFS (i/s/roll), LRA, " +
|
|
2866
3435
|
"true/sample peak (tp/sp), PSR/PLR crest, stereo correlation, onset density, and a 25-band Bark " +
|
|
2867
|
-
"spectrum per cell, plus the full-mix master block.
|
|
2868
|
-
"
|
|
3436
|
+
"spectrum per cell, plus the full-mix master block. Each cell also carries coact: {otherCellId: " +
|
|
3437
|
+
"fraction of this cell's sounding time the other cell sounds too}. Pass refresh:true to run a new " +
|
|
3438
|
+
"analysis (one in-context render from bar 0; each cell is measured at its post-fader output before " +
|
|
3439
|
+
"the master chain, so muted cells read silent — takes a few seconds; audio does NOT need to be " +
|
|
2869
3440
|
"playing). Cached result otherwise. Requires a PAM build with Mix Assist support.",
|
|
2870
3441
|
inputSchema: {
|
|
2871
3442
|
refresh: z.boolean().optional().describe("Run a fresh analysis instead of returning the cached one."),
|
|
@@ -2986,7 +3557,7 @@ server.registerTool("suggest_mix", {
|
|
|
2986
3557
|
? { balanceParked: true, note: "Auto Mix AUTO owns the balance — balance scope was skipped." }
|
|
2987
3558
|
: {};
|
|
2988
3559
|
if (apply && suggestion.moves.length > 0) {
|
|
2989
|
-
await applyKv(suggestion.flat,
|
|
3560
|
+
await applyKv(suggestion.flat, STATE_UPDATE_KV_OPTIONS);
|
|
2990
3561
|
if (!analysis.live)
|
|
2991
3562
|
lastAppliedMixEpoch = analysis.epoch;
|
|
2992
3563
|
return json({ ...suggestion, ...parkedNote, applied: true });
|
|
@@ -3070,7 +3641,7 @@ server.registerTool("save_preset", {
|
|
|
3070
3641
|
}
|
|
3071
3642
|
const metadata = { name, factory: false };
|
|
3072
3643
|
if (path !== undefined)
|
|
3073
|
-
metadata.path = path;
|
|
3644
|
+
metadata.path = nativePresetPath(path);
|
|
3074
3645
|
for (const [k, v] of Object.entries(extras)) {
|
|
3075
3646
|
if (v !== undefined)
|
|
3076
3647
|
metadata[k] = v;
|
|
@@ -3169,7 +3740,7 @@ server.registerTool("save_variation", {
|
|
|
3169
3740
|
let resultSuffix = "";
|
|
3170
3741
|
const destinationPath = normalizePresetLookupPath(diskPath);
|
|
3171
3742
|
if (!name && !destinationPath.startsWith("Factory/")) {
|
|
3172
|
-
metadata.path = destinationPath;
|
|
3743
|
+
metadata.path = nativePresetPath(destinationPath);
|
|
3173
3744
|
}
|
|
3174
3745
|
else if (sourcePath.startsWith("Factory/")) {
|
|
3175
3746
|
// Cannot write back to factory location; savePreset will create User/{name}.preset
|
|
@@ -3280,6 +3851,9 @@ server.registerTool("trim_sample", {
|
|
|
3280
3851
|
const check = await requireBridgeForCells(cellId);
|
|
3281
3852
|
if (check !== true)
|
|
3282
3853
|
return check;
|
|
3854
|
+
const loaded = await requireCellSample(cellId);
|
|
3855
|
+
if (loaded !== true)
|
|
3856
|
+
return loaded;
|
|
3283
3857
|
await bridge.send("trimSampleForCell", cellId);
|
|
3284
3858
|
return text(`Trimmed sample in cell ${cellId}.`);
|
|
3285
3859
|
});
|
|
@@ -3293,6 +3867,9 @@ server.registerTool("reverse_sample", {
|
|
|
3293
3867
|
const check = await requireBridgeForCells(cellId);
|
|
3294
3868
|
if (check !== true)
|
|
3295
3869
|
return check;
|
|
3870
|
+
const loaded = await requireCellSample(cellId);
|
|
3871
|
+
if (loaded !== true)
|
|
3872
|
+
return loaded;
|
|
3296
3873
|
await bridge.send("reverseSampleForCell", cellId);
|
|
3297
3874
|
return text(`Reversed sample in cell ${cellId}.`);
|
|
3298
3875
|
});
|
|
@@ -3316,15 +3893,15 @@ server.registerTool("connection_status", {
|
|
|
3316
3893
|
annotations: { readOnlyHint: true },
|
|
3317
3894
|
}, async () => {
|
|
3318
3895
|
const discoveredInstances = await bridge.discover();
|
|
3319
|
-
const instances =
|
|
3320
|
-
? discoveredInstances.filter((instance) => instance.product?.trim().toLowerCase() ===
|
|
3896
|
+
const instances = PINNED_PRODUCT
|
|
3897
|
+
? discoveredInstances.filter((instance) => instance.product?.trim().toLowerCase() === PINNED_PRODUCT)
|
|
3321
3898
|
: discoveredInstances;
|
|
3322
3899
|
if (instances.length === 0) {
|
|
3323
3900
|
return structured({
|
|
3324
3901
|
instances: [],
|
|
3325
3902
|
connected: false,
|
|
3326
3903
|
note: `No running ${SERVER_PRODUCT.name} instances found. To use live tools, ${SERVER_PRODUCT.name} needs the MCP bridge enabled: ` +
|
|
3327
|
-
`start ${SERVER_PRODUCT.name} (standalone or in a DAW) and enable it in Settings →
|
|
3904
|
+
`start ${SERVER_PRODUCT.name} (standalone or in a DAW) and enable it in Settings → Integrations → MCP Bridge. ` +
|
|
3328
3905
|
"Offline tools (get_parameter_info, list_presets, read_preset) work without a connection.",
|
|
3329
3906
|
});
|
|
3330
3907
|
}
|
|
@@ -3342,6 +3919,54 @@ server.registerTool("connection_status", {
|
|
|
3342
3919
|
: "Found instances but could not connect. The plugin may have shut down.",
|
|
3343
3920
|
});
|
|
3344
3921
|
});
|
|
3922
|
+
// A plugin instance's pid is its DAW, so `ps` names the host; the plugin's own
|
|
3923
|
+
// binary is one of the host's mapped files (e.g. PAM.vst3/Contents/MacOS/PAM).
|
|
3924
|
+
function hostedPluginBinaries(pid, product) {
|
|
3925
|
+
const pluginBinary = new RegExp(`/${product}\\.(?:vst3|component|clap)/Contents/MacOS/${product}$`, "i");
|
|
3926
|
+
try {
|
|
3927
|
+
const mapped = execFileSync("lsof", ["-a", "-p", String(pid), "-d", "txt", "-Fn"], {
|
|
3928
|
+
encoding: "utf8",
|
|
3929
|
+
timeout: 5000,
|
|
3930
|
+
});
|
|
3931
|
+
return [...new Set(mapped.split("\n").filter((line) => line.startsWith("n")).map((line) => line.slice(1)))]
|
|
3932
|
+
.filter((path) => pluginBinary.test(path));
|
|
3933
|
+
}
|
|
3934
|
+
catch {
|
|
3935
|
+
return [];
|
|
3936
|
+
}
|
|
3937
|
+
}
|
|
3938
|
+
// build.js restages plugins with size and mtime intact, so an identical file in
|
|
3939
|
+
// the checkout's build tree identifies a development build.
|
|
3940
|
+
function matchesRepoBuild(binary, projectRoot) {
|
|
3941
|
+
const entries = (path) => {
|
|
3942
|
+
try {
|
|
3943
|
+
return readdirSync(path);
|
|
3944
|
+
}
|
|
3945
|
+
catch {
|
|
3946
|
+
return [];
|
|
3947
|
+
}
|
|
3948
|
+
};
|
|
3949
|
+
try {
|
|
3950
|
+
const target = statSync(binary);
|
|
3951
|
+
const bundlePath = binary.split("/").slice(-4).join("/");
|
|
3952
|
+
const darwin = join(resolve(projectRoot), "build", "Darwin");
|
|
3953
|
+
for (const config of entries(darwin))
|
|
3954
|
+
for (const artefacts of entries(join(darwin, config)).filter((name) => name.endsWith("_artefacts")))
|
|
3955
|
+
for (const mode of entries(join(darwin, config, artefacts)))
|
|
3956
|
+
for (const format of entries(join(darwin, config, artefacts, mode))) {
|
|
3957
|
+
const candidate = join(darwin, config, artefacts, mode, format, bundlePath);
|
|
3958
|
+
if (!existsSync(candidate))
|
|
3959
|
+
continue;
|
|
3960
|
+
const stats = statSync(candidate);
|
|
3961
|
+
if (stats.size === target.size && Math.abs(stats.mtimeMs - target.mtimeMs) < 1000)
|
|
3962
|
+
return true;
|
|
3963
|
+
}
|
|
3964
|
+
}
|
|
3965
|
+
catch {
|
|
3966
|
+
/* binary or build tree unreadable */
|
|
3967
|
+
}
|
|
3968
|
+
return false;
|
|
3969
|
+
}
|
|
3345
3970
|
server.registerTool("situation", {
|
|
3346
3971
|
title: "Situation",
|
|
3347
3972
|
description: "One-call orientation snapshot: discovered instances, the connected instance's product/pid/executable " +
|
|
@@ -3359,21 +3984,34 @@ server.registerTool("situation", {
|
|
|
3359
3984
|
let build;
|
|
3360
3985
|
if (instance?.pid) {
|
|
3361
3986
|
try {
|
|
3362
|
-
const
|
|
3987
|
+
const processExecutable = execFileSync("ps", ["-o", "comm=", "-p", String(instance.pid)], {
|
|
3363
3988
|
encoding: "utf8",
|
|
3364
3989
|
}).trim();
|
|
3365
|
-
if (
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3990
|
+
if (processExecutable) {
|
|
3991
|
+
const product = instance.product ?? SERVER_PRODUCT.name;
|
|
3992
|
+
const projectRoot = process.env.PAM_PROJECT_ROOT;
|
|
3993
|
+
const hosted = basename(processExecutable).toLowerCase() !== product.toLowerCase();
|
|
3994
|
+
const pluginBinaries = hosted ? hostedPluginBinaries(instance.pid, product) : [];
|
|
3995
|
+
const executable = hosted ? (pluginBinaries.length === 1 ? pluginBinaries[0] : undefined) : processExecutable;
|
|
3996
|
+
build = hosted ? { host: processExecutable } : {};
|
|
3997
|
+
if (pluginBinaries.length > 1)
|
|
3998
|
+
build.pluginBinaries = pluginBinaries;
|
|
3999
|
+
if (executable) {
|
|
4000
|
+
build.executable = executable;
|
|
4001
|
+
if (!hosted) {
|
|
4002
|
+
build.looksLikeDevBuild = executable.includes("/build/") && !executable.startsWith("/Applications/");
|
|
4003
|
+
}
|
|
4004
|
+
else if (projectRoot) {
|
|
4005
|
+
build.looksLikeDevBuild = matchesRepoBuild(executable, projectRoot);
|
|
4006
|
+
}
|
|
4007
|
+
}
|
|
3370
4008
|
try {
|
|
3371
|
-
|
|
4009
|
+
if (executable)
|
|
4010
|
+
build.executableMtime = statSync(executable).mtime.toISOString();
|
|
3372
4011
|
}
|
|
3373
4012
|
catch {
|
|
3374
4013
|
/* executable path unreadable from this process */
|
|
3375
4014
|
}
|
|
3376
|
-
const projectRoot = process.env.PAM_PROJECT_ROOT;
|
|
3377
4015
|
if (projectRoot && typeof build.executableMtime === "string") {
|
|
3378
4016
|
try {
|
|
3379
4017
|
const headIso = execFileSync("git", ["-C", resolve(projectRoot), "log", "-1", "--format=%cI"], { encoding: "utf8" }).trim();
|
|
@@ -3409,10 +4047,23 @@ server.registerTool("situation", {
|
|
|
3409
4047
|
.filter((cell) => typeof cell === "string" && /^cell\d+$/.test(cell))
|
|
3410
4048
|
.map((cell) => Number(cell.replace("cell", ""))))].sort((a, b) => a - b);
|
|
3411
4049
|
const cellsObj = isRecord(state.cells) ? state.cells : {};
|
|
4050
|
+
const msq = isMsqManifest(manifest);
|
|
3412
4051
|
const cells = ids.map((id) => {
|
|
3413
4052
|
const prefix = `cell${id}_`;
|
|
3414
4053
|
const rawCellState = cellsObj[String(id)];
|
|
3415
4054
|
const cellState = isRecord(rawCellState) ? rawCellState : {};
|
|
4055
|
+
if (msq) {
|
|
4056
|
+
const summary = msqCellSummary(id, cellState, state);
|
|
4057
|
+
return {
|
|
4058
|
+
cellId: id,
|
|
4059
|
+
muted: summary.muted,
|
|
4060
|
+
send: summary.send,
|
|
4061
|
+
channel: summary.channel,
|
|
4062
|
+
player: summary.player?.name,
|
|
4063
|
+
noteEvents: summary.noteEvents,
|
|
4064
|
+
ccSlots: summary.ccOut.length,
|
|
4065
|
+
};
|
|
4066
|
+
}
|
|
3416
4067
|
const samplePath = cellState.filePath ??
|
|
3417
4068
|
cellState.samplePath ??
|
|
3418
4069
|
state[`${prefix}filePath`] ??
|
|
@@ -3429,9 +4080,9 @@ server.registerTool("situation", {
|
|
|
3429
4080
|
live = {
|
|
3430
4081
|
activePreset: state.activePresetPath,
|
|
3431
4082
|
bpm: state.bpm ?? transport.bpm,
|
|
3432
|
-
playing: transport.
|
|
4083
|
+
playing: transport.isPlaying,
|
|
3433
4084
|
cells,
|
|
3434
|
-
...(IS_MFX_SERVER ? { mix: state.mfx_mix, routing: state.mfx_routing } : {}),
|
|
4085
|
+
...(IS_MFX_SERVER ? { mix: state.mfx_mix, outputGain: state.mfx_outputGain, routing: state.mfx_routing } : {}),
|
|
3435
4086
|
};
|
|
3436
4087
|
}
|
|
3437
4088
|
}
|
|
@@ -3450,13 +4101,13 @@ server.registerTool("situation", {
|
|
|
3450
4101
|
});
|
|
3451
4102
|
server.registerTool("select_instance", {
|
|
3452
4103
|
title: "Select Plugin Instance",
|
|
3453
|
-
description: "Choose which running plugin instance (PAM /
|
|
4104
|
+
description: "Choose which running plugin instance (PAM / MFX / MSQ) the live tools drive when more than " +
|
|
3454
4105
|
"one is running. Match by product name, instanceId, or port — connection_status lists them. " +
|
|
3455
4106
|
"Reconnects if currently connected to a different instance, and points offline parameter " +
|
|
3456
4107
|
"validation at the selected product's manifest. The PAM_BRIDGE_PRODUCT env var pins the same " +
|
|
3457
4108
|
"choice at server launch.",
|
|
3458
4109
|
inputSchema: {
|
|
3459
|
-
product: z.string().optional().describe("Product name: PAM,
|
|
4110
|
+
product: z.string().optional().describe("Product name: PAM, MFX, or MSQ (case-insensitive)."),
|
|
3460
4111
|
instanceId: z.string().optional().describe("Exact instanceId from connection_status."),
|
|
3461
4112
|
port: z.number().int().optional().describe("Exact TCP port from connection_status."),
|
|
3462
4113
|
},
|
|
@@ -3469,22 +4120,23 @@ server.registerTool("select_instance", {
|
|
|
3469
4120
|
if (!product && !instanceId && port === undefined) {
|
|
3470
4121
|
return error("Pass at least one of product, instanceId, or port. Call connection_status to list running instances.");
|
|
3471
4122
|
}
|
|
3472
|
-
if (
|
|
3473
|
-
return error(
|
|
4123
|
+
if (PINNED_PRODUCT && product && product.trim().toLowerCase() !== PINNED_PRODUCT) {
|
|
4124
|
+
return error(`The ${SERVER_PRODUCT.command} launcher can select ${SERVER_PRODUCT.name} instances only.`);
|
|
3474
4125
|
}
|
|
3475
|
-
const targetProduct =
|
|
4126
|
+
const targetProduct = PINNED_PRODUCT ? SERVER_PRODUCT.name : product;
|
|
3476
4127
|
const ok = await bridge.connect({ product: targetProduct, instanceId, port });
|
|
3477
4128
|
if (!ok) {
|
|
3478
4129
|
const discovered = await bridge.discover();
|
|
3479
|
-
const instances =
|
|
3480
|
-
? discovered.filter((instance) => instance.product?.trim().toLowerCase() ===
|
|
4130
|
+
const instances = PINNED_PRODUCT
|
|
4131
|
+
? discovered.filter((instance) => instance.product?.trim().toLowerCase() === PINNED_PRODUCT)
|
|
3481
4132
|
: discovered;
|
|
3482
4133
|
const listing = instances.length
|
|
3483
4134
|
? ` Running instances: ${instances
|
|
3484
4135
|
.map((i) => `${i.product} (port ${i.port}, id ${i.instanceId})`)
|
|
3485
4136
|
.join("; ")}.`
|
|
3486
4137
|
: " No running instances found.";
|
|
3487
|
-
|
|
4138
|
+
// pamd keys selection recovery on this code (PluginSession selectorNotFound); keep it with any rewording.
|
|
4139
|
+
return { ...error(`No running instance matches that selector.${listing}`), _meta: { code: "instance_not_found" } };
|
|
3488
4140
|
}
|
|
3489
4141
|
syncProductHint();
|
|
3490
4142
|
return structured({
|
|
@@ -3537,6 +4189,31 @@ async function searchSampleLibrary(rootPath, term, audioOnly) {
|
|
|
3537
4189
|
}
|
|
3538
4190
|
// Walk the library and return audio files grouped by their parent folder.
|
|
3539
4191
|
// Bounded like searchSampleLibrary — this runs inside a tool call, not a job.
|
|
4192
|
+
// The sample-edit actions answer "ok" before their async edit runs, even on an empty cell.
|
|
4193
|
+
async function requireCellSample(cellId, field = "filePath") {
|
|
4194
|
+
const cells = await bridge.send("getState", "cells").catch(() => undefined);
|
|
4195
|
+
if (!isRecord(cells))
|
|
4196
|
+
return true;
|
|
4197
|
+
const cell = cells[String(cellId)];
|
|
4198
|
+
const path = isRecord(cell) ? cell[field] : undefined;
|
|
4199
|
+
if (typeof path === "string" && path.length > 0)
|
|
4200
|
+
return true;
|
|
4201
|
+
return error(field === "filePath" ? `Cell ${cellId} has no sample loaded.` : `Cell ${cellId} has no original sample to restore.`);
|
|
4202
|
+
}
|
|
4203
|
+
// getDefaultLocations returns [{name, path, icon}]; the names are fixed in FileHandler.
|
|
4204
|
+
async function defaultLocationPaths() {
|
|
4205
|
+
const locations = await bridge.send("getDefaultLocations").catch(() => undefined);
|
|
4206
|
+
const entries = Array.isArray(locations) ? locations : [];
|
|
4207
|
+
return new Map(entries
|
|
4208
|
+
.filter((entry) => isRecord(entry) && typeof entry.name === "string" && typeof entry.path === "string")
|
|
4209
|
+
.map((entry) => [entry.name, entry.path]));
|
|
4210
|
+
}
|
|
4211
|
+
// Native exports to <base>/Export beside <base>/Samples ("Plugin Samples"); check-parity pins both.
|
|
4212
|
+
// MSQ keeps no sample folder, so its Export resolves from the product's own base folder.
|
|
4213
|
+
async function exportsFolder() {
|
|
4214
|
+
const pluginSamples = (await defaultLocationPaths()).get("Plugin Samples");
|
|
4215
|
+
return pluginSamples ? join(dirname(pluginSamples), "Export") : productExportDir();
|
|
4216
|
+
}
|
|
3540
4217
|
async function collectLibraryByFolder(roots, maxDirReads = 120) {
|
|
3541
4218
|
const byFolder = new Map();
|
|
3542
4219
|
const queue = roots.map((path) => ({ path, depth: 0 }));
|
|
@@ -3721,17 +4398,21 @@ server.registerTool("export_preset", {
|
|
|
3721
4398
|
const check = await requireBridge();
|
|
3722
4399
|
if (check !== true)
|
|
3723
4400
|
return check;
|
|
3724
|
-
await bridge.send("exportPresetWithSamples", presetPath, outputFolderName);
|
|
3725
|
-
|
|
4401
|
+
const result = await bridge.send("exportPresetWithSamples", nativePresetPath(presetPath), outputFolderName);
|
|
4402
|
+
if (result === false) {
|
|
4403
|
+
return error(`Export refused for ${presetPath}: the preset is missing, belongs to a preset pack, or the folder name is invalid.`);
|
|
4404
|
+
}
|
|
4405
|
+
return text(`Exported preset to: ${outputFolderName}.zip in ${SERVER_PRODUCT.name}'s Exports folder`);
|
|
3726
4406
|
});
|
|
3727
4407
|
server.registerTool("import_preset", {
|
|
3728
4408
|
title: "Import Preset",
|
|
3729
4409
|
description: "Import a preset from a zip file or folder into PAM's preset library. Returns the relative path of the " +
|
|
3730
4410
|
"first imported preset, or an empty string on failure. Referenced samples are copied into the user sample " +
|
|
3731
4411
|
"folder and the preset's paths are rewritten. A name already in the library is never overwritten: the " +
|
|
3732
|
-
"import lands as <name>_1, <name>_2 and so on."
|
|
4412
|
+
"import lands as <name>_1, <name>_2 and so on. A downloaded .pampack file installs as a whole pack and " +
|
|
4413
|
+
"returns true on success.",
|
|
3733
4414
|
inputSchema: {
|
|
3734
|
-
path: z.string().describe("Absolute path to the preset zip file or
|
|
4415
|
+
path: z.string().describe("Absolute path to the preset zip file, folder or .pampack to import."),
|
|
3735
4416
|
},
|
|
3736
4417
|
}, async ({ path: presetPath }) => {
|
|
3737
4418
|
const check = await requireBridge();
|
|
@@ -3740,6 +4421,158 @@ server.registerTool("import_preset", {
|
|
|
3740
4421
|
const result = await bridge.send("importPreset", presetPath);
|
|
3741
4422
|
return json(result);
|
|
3742
4423
|
});
|
|
4424
|
+
const PACK_JOB_TERMINAL = new Set(["succeeded", "failed", "cancelled"]);
|
|
4425
|
+
const PACK_INSTALL_WAIT_MS = 15 * 60_000;
|
|
4426
|
+
function isPackFailure(value) {
|
|
4427
|
+
return isRecord(value) && value.ok === false && typeof value.code === "string" && typeof value.error === "string";
|
|
4428
|
+
}
|
|
4429
|
+
// The failure shape pamd's CLI-backed tools return: structuredContent {ok:false, code, error}.
|
|
4430
|
+
function packFailure(code, message) {
|
|
4431
|
+
const detail = { ok: false, code, error: message };
|
|
4432
|
+
return {
|
|
4433
|
+
content: [{ type: "text", text: JSON.stringify(detail, null, 2) }],
|
|
4434
|
+
structuredContent: detail,
|
|
4435
|
+
isError: true,
|
|
4436
|
+
};
|
|
4437
|
+
}
|
|
4438
|
+
function parseBridgeValue(raw) {
|
|
4439
|
+
if (typeof raw !== "string")
|
|
4440
|
+
return raw;
|
|
4441
|
+
try {
|
|
4442
|
+
return JSON.parse(raw);
|
|
4443
|
+
}
|
|
4444
|
+
catch {
|
|
4445
|
+
return raw;
|
|
4446
|
+
}
|
|
4447
|
+
}
|
|
4448
|
+
/** The connected instance's product; only a build that reports none falls back to the manifest. */
|
|
4449
|
+
async function connectedProduct() {
|
|
4450
|
+
const reported = bridge.getConnectedInstance()?.product?.toUpperCase();
|
|
4451
|
+
if (reported)
|
|
4452
|
+
return reported;
|
|
4453
|
+
const manifest = await loadManifestForProductGuard();
|
|
4454
|
+
if (!manifest)
|
|
4455
|
+
return undefined;
|
|
4456
|
+
return isMfxManifest(manifest) ? "MFX" : isMsqManifest(manifest) ? "MSQ" : "PAM";
|
|
4457
|
+
}
|
|
4458
|
+
async function requirePackStore() {
|
|
4459
|
+
const check = await requireBridge();
|
|
4460
|
+
if (check !== true)
|
|
4461
|
+
return check;
|
|
4462
|
+
return (await connectedProduct()) === "MFX"
|
|
4463
|
+
? packFailure("pack_unsupported_product", "MFX has no preset pack store; packs are a PAM feature.")
|
|
4464
|
+
: true;
|
|
4465
|
+
}
|
|
4466
|
+
const PACK_ENTRY = z.object({}).passthrough();
|
|
4467
|
+
const PACK_JOB_OUTPUT = {
|
|
4468
|
+
jobId: z.string().optional(),
|
|
4469
|
+
daemonEpoch: z.string().optional(),
|
|
4470
|
+
state: z.string().optional(),
|
|
4471
|
+
progress: z.number().optional(),
|
|
4472
|
+
result: z.object({ packs: z.array(PACK_ENTRY).optional() }).passthrough().optional(),
|
|
4473
|
+
error: z.object({ code: z.string().optional(), message: z.string().optional() }).passthrough().optional(),
|
|
4474
|
+
};
|
|
4475
|
+
server.registerTool("list_packs", {
|
|
4476
|
+
title: "List Preset Packs",
|
|
4477
|
+
description: "List the map.audio preset pack catalog with this machine's state: installed and catalog version, whether an " +
|
|
4478
|
+
"update is available, whether the licence owns the pack or it is free with PAM, price and download size. " +
|
|
4479
|
+
"Installed packs the catalog no longer lists appear with listed:false.",
|
|
4480
|
+
inputSchema: {},
|
|
4481
|
+
outputSchema: { packs: z.array(PACK_ENTRY) },
|
|
4482
|
+
}, async () => {
|
|
4483
|
+
const check = await requirePackStore();
|
|
4484
|
+
if (check !== true)
|
|
4485
|
+
return check;
|
|
4486
|
+
const listing = parseBridgeValue(await bridge.send("listPacks"));
|
|
4487
|
+
if (isPackFailure(listing))
|
|
4488
|
+
return packFailure(listing.code, listing.error);
|
|
4489
|
+
return structured(listing);
|
|
4490
|
+
});
|
|
4491
|
+
server.registerTool("install_pack", {
|
|
4492
|
+
title: "Install Preset Packs",
|
|
4493
|
+
description: "Download and install preset packs the licence owns (free packs need an active PAM licence), one at a time. " +
|
|
4494
|
+
"Pass packIds, or allOwned:true for every owned pack that is missing or outdated. Waits for the installs and " +
|
|
4495
|
+
"returns {packs:[{packId, version, state}]}; detach:true returns the job at once. A requestKey makes a retry " +
|
|
4496
|
+
"return the original job instead of downloading again. An unowned pack fails with pack_license_required.",
|
|
4497
|
+
inputSchema: {
|
|
4498
|
+
packIds: z.array(z.string().min(1)).min(1).max(64).optional().describe("Pack ids from the catalog, e.g. 'eko'."),
|
|
4499
|
+
allOwned: z.boolean().optional().describe("Install every owned pack that is missing or outdated."),
|
|
4500
|
+
requestKey: z.string().max(128).optional().describe("Stable caller key; reuse it to recover the same job."),
|
|
4501
|
+
detach: z.boolean().optional().describe("Return the job without waiting for the installs."),
|
|
4502
|
+
},
|
|
4503
|
+
outputSchema: { ...PACK_JOB_OUTPUT, packs: z.array(PACK_ENTRY).optional(), note: z.string().optional() },
|
|
4504
|
+
}, async ({ packIds, allOwned, requestKey, detach }) => {
|
|
4505
|
+
if (Boolean(packIds?.length) === Boolean(allOwned))
|
|
4506
|
+
return packFailure("usage", "Pass packIds or allOwned:true, not both.");
|
|
4507
|
+
const check = await requirePackStore();
|
|
4508
|
+
if (check !== true)
|
|
4509
|
+
return check;
|
|
4510
|
+
const started = parseBridgeValue(await bridge.send("installPack", {
|
|
4511
|
+
...(packIds?.length ? { packIds } : {}),
|
|
4512
|
+
...(allOwned ? { allOwned: true } : {}),
|
|
4513
|
+
...(requestKey ? { requestKey } : {}),
|
|
4514
|
+
}));
|
|
4515
|
+
if (isPackFailure(started))
|
|
4516
|
+
return packFailure(started.code, started.error);
|
|
4517
|
+
if (!isRecord(started) || typeof started.jobId !== "string")
|
|
4518
|
+
return error(`Unexpected installPack reply: ${JSON.stringify(started)}`);
|
|
4519
|
+
if (detach)
|
|
4520
|
+
return structured(started);
|
|
4521
|
+
// The job lives in the instance that accepted it; a select_instance meanwhile must not redirect the polls.
|
|
4522
|
+
const accepting = bridge.getConnectedInstance()?.instanceId;
|
|
4523
|
+
let job = started;
|
|
4524
|
+
const deadline = Date.now() + PACK_INSTALL_WAIT_MS;
|
|
4525
|
+
while (!PACK_JOB_TERMINAL.has(String(job.state)) && Date.now() < deadline) {
|
|
4526
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
4527
|
+
if (bridge.getConnectedInstance()?.instanceId !== accepting)
|
|
4528
|
+
return structured({
|
|
4529
|
+
...job,
|
|
4530
|
+
note: `Instance ${accepting} is still installing; select it again, then poll get_pack_install_status with this jobId.`,
|
|
4531
|
+
});
|
|
4532
|
+
const next = parseBridgeValue(await bridge.send("getPackInstallStatus", started.jobId));
|
|
4533
|
+
if (isPackFailure(next))
|
|
4534
|
+
return packFailure(next.code, next.error);
|
|
4535
|
+
if (isRecord(next))
|
|
4536
|
+
job = next;
|
|
4537
|
+
}
|
|
4538
|
+
if (job.state === "succeeded")
|
|
4539
|
+
return structured(isRecord(job.result) ? job.result : { packs: [] });
|
|
4540
|
+
if (job.state === "failed" || job.state === "cancelled") {
|
|
4541
|
+
const failure = isRecord(job.error) ? job.error : {};
|
|
4542
|
+
return packFailure(typeof failure.code === "string" ? failure.code : job.state === "cancelled" ? "cancelled" : "pack_install_failed", typeof failure.message === "string" ? failure.message : `Pack install job ${started.jobId} ${String(job.state)}.`);
|
|
4543
|
+
}
|
|
4544
|
+
return structured({ ...job, note: "Still installing; poll get_pack_install_status with this jobId." });
|
|
4545
|
+
});
|
|
4546
|
+
server.registerTool("get_pack_install_status", {
|
|
4547
|
+
title: "Get Pack Install Status",
|
|
4548
|
+
description: "Report a pack install job: state (queued, running, succeeded, failed, cancelled), progress 0..1, per-pack " +
|
|
4549
|
+
"state in result.packs, and error {code, message} when it failed.",
|
|
4550
|
+
inputSchema: { jobId: z.string().min(1).describe("The jobId install_pack returned.") },
|
|
4551
|
+
outputSchema: PACK_JOB_OUTPUT,
|
|
4552
|
+
}, async ({ jobId }) => {
|
|
4553
|
+
const check = await requirePackStore();
|
|
4554
|
+
if (check !== true)
|
|
4555
|
+
return check;
|
|
4556
|
+
const job = parseBridgeValue(await bridge.send("getPackInstallStatus", jobId));
|
|
4557
|
+
if (isPackFailure(job))
|
|
4558
|
+
return packFailure(job.code, job.error);
|
|
4559
|
+
return structured(job);
|
|
4560
|
+
});
|
|
4561
|
+
server.registerTool("uninstall_pack", {
|
|
4562
|
+
title: "Uninstall Preset Pack",
|
|
4563
|
+
description: "Remove an installed preset pack: every preset carrying its pack id, its samples and media, and its installed " +
|
|
4564
|
+
"record. Fails with pack_not_installed when nothing of the pack is on disk or recorded.",
|
|
4565
|
+
inputSchema: { packId: z.string().min(1).describe("Pack id from the catalog, e.g. 'eko'.") },
|
|
4566
|
+
outputSchema: { packId: z.string(), removed: z.boolean() },
|
|
4567
|
+
}, async ({ packId }) => {
|
|
4568
|
+
const check = await requirePackStore();
|
|
4569
|
+
if (check !== true)
|
|
4570
|
+
return check;
|
|
4571
|
+
const removed = parseBridgeValue(await bridge.send("uninstallPack", packId));
|
|
4572
|
+
if (isPackFailure(removed))
|
|
4573
|
+
return packFailure(removed.code, removed.error);
|
|
4574
|
+
return structured(removed);
|
|
4575
|
+
});
|
|
3743
4576
|
server.registerTool("set_visual", {
|
|
3744
4577
|
title: "Set Visual",
|
|
3745
4578
|
description: "Save an image file as a preset visual/thumbnail in PAM's media library. " +
|
|
@@ -3862,9 +4695,6 @@ const VIDEO_LAYER_FX = "hueRotate, glow, audioReact, vibration, chromaticAberrat
|
|
|
3862
4695
|
"polarSlitScan, cellVoronoi, spectralZones, paletteLut, asciiDither, pixelSort, " +
|
|
3863
4696
|
"cameraShake, blur, cymatics, lineJitter, crt, vhs";
|
|
3864
4697
|
// Output/global FX chain ids (videoStore, DEFAULT_VIDEO_FX_CONFIG).
|
|
3865
|
-
const VIDEO_OUTPUT_FX = "lines, barrelBlur, brightnessContrast, edge, glow, shake, rgbShift, halftone, mirror, " +
|
|
3866
|
-
"patches, pixelate, rolls, feedback, warp, datamosh, slicer, positionXY, speed, " +
|
|
3867
|
-
"cellVoronoi, polarSlitScan, spectralZones, audioReact";
|
|
3868
4698
|
// Binding source vocabulary (sourceKey string form OR a ModSource object).
|
|
3869
4699
|
const VIDEO_BIND_SOURCES = "Pass `source` as a sourceKey string: " +
|
|
3870
4700
|
"`g:<id>` global (level, bandLow, bandMid, bandHigh, transient, transientLow, " +
|
|
@@ -3901,14 +4731,15 @@ async function resolveNewId(requestId, beforeIds, pick, ackIdKey, timeoutMs = 25
|
|
|
3901
4731
|
if (ack && ack.requestId === requestId) {
|
|
3902
4732
|
const minted = ack.result?.[ackIdKey];
|
|
3903
4733
|
if (typeof minted === "string" && minted.length > 0)
|
|
3904
|
-
return minted;
|
|
3905
|
-
|
|
4734
|
+
return { id: minted };
|
|
4735
|
+
// An acked command never falls back to the diff, which could misattribute a concurrent edit.
|
|
4736
|
+
return ack.ok === false ? { error: ack.error || "the command failed" } : {};
|
|
3906
4737
|
}
|
|
3907
4738
|
const found = pick(s).find((id) => !beforeIds.has(id));
|
|
3908
4739
|
if (found)
|
|
3909
|
-
return found;
|
|
4740
|
+
return { id: found };
|
|
3910
4741
|
}
|
|
3911
|
-
return
|
|
4742
|
+
return {};
|
|
3912
4743
|
}
|
|
3913
4744
|
server.registerTool("get_video_state", {
|
|
3914
4745
|
title: "Get Video State",
|
|
@@ -3951,7 +4782,7 @@ server.registerTool("capture_ui", {
|
|
|
3951
4782
|
.optional()
|
|
3952
4783
|
.describe("Switch all cells to the synth or sampler tab before capture."),
|
|
3953
4784
|
settleMs: z.number().optional().describe("Delay (ms) after view changes before rasterizing. Default 350."),
|
|
3954
|
-
timeoutMs: z.number().optional().default(
|
|
4785
|
+
timeoutMs: z.number().optional().default(30000).describe("Max ms to wait for the WebView to return the image. Default 30000."),
|
|
3955
4786
|
},
|
|
3956
4787
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
3957
4788
|
}, async ({ gridSize, mainView, instrumentView, settleMs, timeoutMs }) => {
|
|
@@ -3970,15 +4801,15 @@ server.registerTool("capture_ui", {
|
|
|
3970
4801
|
setup.settleMs = settleMs;
|
|
3971
4802
|
// Forward is fire-and-forget; poll the C++ cache until the WebView posts the PNG back.
|
|
3972
4803
|
await bridge.send("captureUi", setup);
|
|
3973
|
-
const deadline = Date.now() + (timeoutMs ??
|
|
4804
|
+
const deadline = Date.now() + (timeoutMs ?? 30000);
|
|
3974
4805
|
for (;;) {
|
|
3975
4806
|
const res = (await bridge.send("getUiCapture", { requestId }));
|
|
3976
4807
|
if (res.ready && res.requestId === requestId && res.path) {
|
|
3977
4808
|
return text(`Saved UI screenshot: ${res.path}`);
|
|
3978
4809
|
}
|
|
3979
4810
|
if (Date.now() > deadline) {
|
|
3980
|
-
return error(
|
|
3981
|
-
"
|
|
4811
|
+
return error(`capture_ui got no image within ${timeoutMs ?? 30000} ms. Either PAM's window is closed or suspended ` +
|
|
4812
|
+
"(html2canvas needs the live DOM), or a dense view is still rendering on a loaded machine: retry with a larger timeoutMs.");
|
|
3982
4813
|
}
|
|
3983
4814
|
await new Promise((r) => setTimeout(r, 150));
|
|
3984
4815
|
}
|
|
@@ -3999,14 +4830,14 @@ server.registerTool("video_command", {
|
|
|
3999
4830
|
"addLayerFx{layerId,fxType,params?,beforeFxId?}, removeLayerFx{layerId,fxId}, " +
|
|
4000
4831
|
"removeAllLayerFx{layerId}, setLayerFxParam{layerId,fxId,param,value|params}, " +
|
|
4001
4832
|
"setLayerFxEnabled{layerId,fxId,enabled?}, reorderLayerFx{layerId,fxIds}, " +
|
|
4002
|
-
"resetLayerFx{layerId,fxId}
|
|
4833
|
+
"resetLayerFx{layerId,fxId}, setFxParamMeta{layerId,fxId,param,lock?,limit?:{min,max}|null,snap?:step|null}, " +
|
|
4834
|
+
"randomizeFx{layerId,fxId}, setRandomizeAuto{config} (sanitized AutoRandomizeConfig; see get_video_state); " +
|
|
4835
|
+
"bind{layerId,fxId?,paramKey,source,...}, " +
|
|
4003
4836
|
"unbind{layerId,fxId?,paramKey}, setBindingField{layerId,fxId?,paramKey,field,value}, " +
|
|
4004
4837
|
"fireImpulse{layerId,fxId?,paramKey}, removeBindingsForLayer{layerId}, clearBindings; " +
|
|
4005
4838
|
"addOutputFx{fxId}, removeOutputFx{instanceId}, setOutputFxParam{instanceId|fxId,param,value}, " +
|
|
4006
|
-
"setOutputFxEnabled{instanceId,enabled}
|
|
4007
|
-
"
|
|
4008
|
-
"setModEnabled{enabled}; setModel{sourcePath?,visible?,blendMode?,audioReactive?,autoRotate?,transform?}, " +
|
|
4009
|
-
"resetModel; setSettings{...incl vxVariationFollowAudio,vxBarSyncEnabled,vxBarSyncIntervalBars}, " +
|
|
4839
|
+
"setOutputFxEnabled{instanceId,enabled} (the output chain is the master layer's FX); " +
|
|
4840
|
+
"setSettings{...incl vxVariationFollowAudio,vxBarSyncEnabled,vxBarSyncIntervalBars}, " +
|
|
4010
4841
|
"setActiveCell{cellId} (deprecated no-op), resetWorkspace; " +
|
|
4011
4842
|
"VX variation slots: saveVxVariation{index 0-7}, loadVxVariation{index}, clearVxVariation{index}, " +
|
|
4012
4843
|
"fillVxVariationsRandomized (occupancy/current in get_video_state; or use the vx_variation tool); " +
|
|
@@ -4064,7 +4895,9 @@ server.registerTool("add_video_layer", {
|
|
|
4064
4895
|
const beforeIds = new Set((before.layers ?? []).map((l) => l.id));
|
|
4065
4896
|
const requestId = mintVideoRequestId();
|
|
4066
4897
|
await sendVideoCommand({ op: "addLayer", kind, name, insertAt, sourcePath, maxBytes, requestId });
|
|
4067
|
-
const id = await resolveNewId(requestId, beforeIds, (s) => (s.layers ?? []).map((l) => l.id), "layerId");
|
|
4898
|
+
const { id, error: failure } = await resolveNewId(requestId, beforeIds, (s) => (s.layers ?? []).map((l) => l.id), "layerId");
|
|
4899
|
+
if (failure)
|
|
4900
|
+
return error(`add_video_layer failed: ${failure}`);
|
|
4068
4901
|
return text(id
|
|
4069
4902
|
? `Added ${kind} layer: ${id}`
|
|
4070
4903
|
: `Added ${kind} layer (window may be closed; call get_video_state to confirm).`);
|
|
@@ -4075,7 +4908,8 @@ server.registerTool("update_video_layer", {
|
|
|
4075
4908
|
"(partial) against the current transform; other fields replace. Common: opacity " +
|
|
4076
4909
|
"(0..1), blendMode (normal|add|multiply|screen), enabled, name, transform " +
|
|
4077
4910
|
"{x,y(0..1),scale,rotation(rad),anchor}. Kind-specific via `fields`: text layers " +
|
|
4078
|
-
"{text,fontSize,fontWeight,color,align,lineHeight}; image/video/model3d {sourcePath,
|
|
4911
|
+
"{text,fontSize,fontWeight,color,align,lineHeight}; image/video/model3d {sourcePath (loads the file, as " +
|
|
4912
|
+
"set_video_layer_source does),fit}; " +
|
|
4079
4913
|
"camera {deviceId,mirror,fit}; drawing {paintColor,paintWidth}; model3d " +
|
|
4080
4914
|
"{lightingMode,lightIntensity,rotateRateX/Y/Z,...}. Use get_video_state to see a " +
|
|
4081
4915
|
"layer's current fields.",
|
|
@@ -4098,7 +4932,11 @@ server.registerTool("update_video_layer", {
|
|
|
4098
4932
|
const check = await requireBridge();
|
|
4099
4933
|
if (check !== true)
|
|
4100
4934
|
return check;
|
|
4101
|
-
|
|
4935
|
+
// A sourcePath in the patch is only stored; setLayerSource is what loads the media.
|
|
4936
|
+
const { sourcePath, ...kindFields } = fields ?? {};
|
|
4937
|
+
if (sourcePath !== undefined && typeof sourcePath !== "string")
|
|
4938
|
+
return error("fields.sourcePath must be a file path.");
|
|
4939
|
+
const patch = { ...kindFields };
|
|
4102
4940
|
if (opacity !== undefined)
|
|
4103
4941
|
patch.opacity = opacity;
|
|
4104
4942
|
if (blendMode !== undefined)
|
|
@@ -4109,6 +4947,10 @@ server.registerTool("update_video_layer", {
|
|
|
4109
4947
|
patch.name = name;
|
|
4110
4948
|
if (transform !== undefined)
|
|
4111
4949
|
patch.transform = transform;
|
|
4950
|
+
if (sourcePath !== undefined)
|
|
4951
|
+
await sendVideoCommand({ op: "setLayerSource", layerId, sourcePath });
|
|
4952
|
+
if (Object.keys(patch).length === 0)
|
|
4953
|
+
return text(`Loading ${sourcePath} into layer ${layerId}.`);
|
|
4112
4954
|
const result = await sendVideoCommand({ op: "updateLayer", layerId, patch });
|
|
4113
4955
|
return json(result);
|
|
4114
4956
|
});
|
|
@@ -4158,11 +5000,9 @@ server.registerTool("apply_video_layout", {
|
|
|
4158
5000
|
});
|
|
4159
5001
|
server.registerTool("add_video_fx", {
|
|
4160
5002
|
title: "Add Video FX",
|
|
4161
|
-
description: "Append an FX to a layer's chain (target='layer', needs layerId) or to the output
|
|
4162
|
-
"
|
|
5003
|
+
description: "Append an FX to a layer's chain (target='layer', needs layerId) or to the output " +
|
|
5004
|
+
"chain, which is the master layer's chain (target='output'), and return the new id. FX: " +
|
|
4163
5005
|
VIDEO_LAYER_FX +
|
|
4164
|
-
". Output FX: " +
|
|
4165
|
-
VIDEO_OUTPUT_FX +
|
|
4166
5006
|
". FX params seed from registry defaults; override via `params`. Use set_video_fx " +
|
|
4167
5007
|
"to tweak afterwards.",
|
|
4168
5008
|
inputSchema: {
|
|
@@ -4187,7 +5027,9 @@ server.registerTool("add_video_fx", {
|
|
|
4187
5027
|
const beforeIds = new Set((before.outputFx ?? []).map((f) => f.instanceId));
|
|
4188
5028
|
const requestId = mintVideoRequestId();
|
|
4189
5029
|
await sendVideoCommand({ op: "addOutputFx", fxId: fxType, requestId });
|
|
4190
|
-
const id = await resolveNewId(requestId, beforeIds, (s) => (s.outputFx ?? []).map((f) => f.instanceId), "instanceId");
|
|
5030
|
+
const { id, error: failure } = await resolveNewId(requestId, beforeIds, (s) => (s.outputFx ?? []).map((f) => f.instanceId), "instanceId");
|
|
5031
|
+
if (failure)
|
|
5032
|
+
return error(`add_video_fx failed: ${failure}`);
|
|
4191
5033
|
// Apply param overrides now that the instance exists.
|
|
4192
5034
|
if (params && id)
|
|
4193
5035
|
for (const [param, value] of Object.entries(params))
|
|
@@ -4201,10 +5043,12 @@ server.registerTool("add_video_fx", {
|
|
|
4201
5043
|
const beforeIds = new Set((layer?.fx ?? []).map((f) => f.id));
|
|
4202
5044
|
const requestId = mintVideoRequestId();
|
|
4203
5045
|
await sendVideoCommand({ op: "addLayerFx", layerId, fxType, params, beforeFxId, requestId });
|
|
4204
|
-
const id = await resolveNewId(requestId, beforeIds, (s) => {
|
|
5046
|
+
const { id, error: failure } = await resolveNewId(requestId, beforeIds, (s) => {
|
|
4205
5047
|
const l = (s.layers ?? []).find((x) => x.id === layerId);
|
|
4206
5048
|
return (l?.fx ?? []).map((f) => f.id);
|
|
4207
5049
|
}, "fxId");
|
|
5050
|
+
if (failure)
|
|
5051
|
+
return error(`add_video_fx failed: ${failure}`);
|
|
4208
5052
|
return text(id ? `Added ${fxType} to layer ${layerId}: ${id}` : `Added ${fxType} (confirm via get_video_state).`);
|
|
4209
5053
|
});
|
|
4210
5054
|
server.registerTool("set_video_fx", {
|
|
@@ -4212,7 +5056,7 @@ server.registerTool("set_video_fx", {
|
|
|
4212
5056
|
description: "Set FX params / enabled / strength on a per-layer FX (target='layer', needs " +
|
|
4213
5057
|
"layerId + fxId) or an output FX (target='output', needs instanceId, or fxId to " +
|
|
4214
5058
|
"auto-create). Pass a single {param,value}, multiple {params}, `enabled`, and/or " +
|
|
4215
|
-
"`strength` (
|
|
5059
|
+
"`strength` (unsupported: set the FX mix param). Get current ids/params from get_video_state.",
|
|
4216
5060
|
inputSchema: {
|
|
4217
5061
|
target: z.enum(["layer", "output"]).default("layer"),
|
|
4218
5062
|
layerId: z.string().optional().describe("target='layer': owning layer id."),
|
|
@@ -4222,7 +5066,7 @@ server.registerTool("set_video_fx", {
|
|
|
4222
5066
|
value: z.number().optional().describe("Value for `param`."),
|
|
4223
5067
|
params: z.record(z.string(), z.number()).optional().describe("Multiple params {key: value}."),
|
|
4224
5068
|
enabled: z.boolean().optional().describe("Enable/bypass this FX."),
|
|
4225
|
-
strength: z.number().optional().describe("
|
|
5069
|
+
strength: z.number().optional().describe("Unsupported; output FX run on the master layer and have no strength."),
|
|
4226
5070
|
},
|
|
4227
5071
|
}, async ({ target, layerId, fxId, instanceId, param, value, params, enabled, strength }) => {
|
|
4228
5072
|
const check = await requireBridge();
|
|
@@ -4245,10 +5089,18 @@ server.registerTool("set_video_fx", {
|
|
|
4245
5089
|
if (params)
|
|
4246
5090
|
for (const [p, v] of Object.entries(params))
|
|
4247
5091
|
results.push(await sendVideoCommand({ op: "setOutputFxParam", instanceId, fxId, param: p, value: v }));
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
if (
|
|
4251
|
-
|
|
5092
|
+
// A param write by fxId has just auto-created the FX, so resolve the instance after it.
|
|
5093
|
+
let outputInstance = instanceId;
|
|
5094
|
+
if (!outputInstance && fxId && (enabled !== undefined || strength !== undefined)) {
|
|
5095
|
+
const current = await getVideoStateRaw();
|
|
5096
|
+
outputInstance = (current.outputFx ?? []).find((fx) => fx.fxId === fxId)?.instanceId;
|
|
5097
|
+
if (!outputInstance)
|
|
5098
|
+
return error(`set_video_fx: no output ${fxId} to enable or scale; add_video_fx it first.`);
|
|
5099
|
+
}
|
|
5100
|
+
if (enabled !== undefined && outputInstance)
|
|
5101
|
+
results.push(await sendVideoCommand({ op: "setOutputFxEnabled", instanceId: outputInstance, enabled }));
|
|
5102
|
+
if (strength !== undefined && outputInstance)
|
|
5103
|
+
results.push(await sendVideoCommand({ op: "setOutputFxStrength", instanceId: outputInstance, strength }));
|
|
4252
5104
|
}
|
|
4253
5105
|
if (results.length === 0)
|
|
4254
5106
|
return error("set_video_fx: nothing to set (provide param/value, params, enabled, or strength).");
|
|
@@ -4296,7 +5148,7 @@ server.registerTool("bind_video_param", {
|
|
|
4296
5148
|
.union([z.string(), z.record(z.string(), z.any())])
|
|
4297
5149
|
.describe("sourceKey string (e.g. 'g:bandLow', 'c:1:onset', 't:beat') or ModSource object."),
|
|
4298
5150
|
fxId: z.string().optional().describe("FX instance id; omit/'' for a layer-field target."),
|
|
4299
|
-
amount: z.number().min(-1).max(1).optional().describe("Modulation depth (-1..1), default
|
|
5151
|
+
amount: z.number().min(-1).max(1).optional().describe("Modulation depth (-1..1), default 0.5 (the VX UI default)."),
|
|
4300
5152
|
curve: z.enum(["linear", "exp", "expInverse", "bipolar"]).optional(),
|
|
4301
5153
|
mode: z.enum(["additive", "replace", "multiply", "centered"]).optional(),
|
|
4302
5154
|
attackMs: z.number().min(0).optional(),
|
|
@@ -4350,7 +5202,6 @@ server.registerTool("set_video_settings", {
|
|
|
4350
5202
|
.describe("Canvas design ratio as width/height (e.g. 1.7777 for 16:9). null clears it and follows the window."),
|
|
4351
5203
|
letterboxColor: z.string().describe("#hex fill for the bars outside the design aspect.").optional(),
|
|
4352
5204
|
canvasColor: z.string().describe("#hex background behind every VX layer.").optional(),
|
|
4353
|
-
modEnabled: z.boolean().optional().describe("Mod-matrix master enable."),
|
|
4354
5205
|
vxVariationFollowAudio: z
|
|
4355
5206
|
.boolean()
|
|
4356
5207
|
.optional()
|
|
@@ -4366,7 +5217,7 @@ server.registerTool("set_video_settings", {
|
|
|
4366
5217
|
activeCell: z.number().int().optional().describe("Deprecated/no-op: VX is a single global scene (no per-cell video)."),
|
|
4367
5218
|
resetWorkspace: z.boolean().optional().describe("If true, clear the whole scene first."),
|
|
4368
5219
|
},
|
|
4369
|
-
}, async ({ syncMode, loopBars, aspectRatio, renderQuality, designAspect, letterboxColor, canvasColor,
|
|
5220
|
+
}, async ({ syncMode, loopBars, aspectRatio, renderQuality, designAspect, letterboxColor, canvasColor, vxVariationFollowAudio, vxBarSyncEnabled, vxBarSyncIntervalBars, activeCell, resetWorkspace }) => {
|
|
4370
5221
|
const check = await requireBridge();
|
|
4371
5222
|
if (check !== true)
|
|
4372
5223
|
return check;
|
|
@@ -4374,8 +5225,6 @@ server.registerTool("set_video_settings", {
|
|
|
4374
5225
|
await sendVideoCommand({ op: "resetWorkspace" });
|
|
4375
5226
|
if (activeCell !== undefined)
|
|
4376
5227
|
await sendVideoCommand({ op: "setActiveCell", cellId: activeCell });
|
|
4377
|
-
if (modEnabled !== undefined)
|
|
4378
|
-
await sendVideoCommand({ op: "setModEnabled", enabled: modEnabled });
|
|
4379
5228
|
const settings = {};
|
|
4380
5229
|
if (syncMode !== undefined)
|
|
4381
5230
|
settings.syncMode = syncMode;
|
|
@@ -4463,12 +5312,6 @@ server.registerTool("vx_variation", {
|
|
|
4463
5312
|
// ============================================================
|
|
4464
5313
|
// Composite cell-operation tools
|
|
4465
5314
|
// ============================================================
|
|
4466
|
-
const PATTERN_SUFFIXES = [
|
|
4467
|
-
"midiSequence",
|
|
4468
|
-
"pitchSequence",
|
|
4469
|
-
"sliceSequence",
|
|
4470
|
-
"stretchSequence",
|
|
4471
|
-
];
|
|
4472
5315
|
server.registerTool("reset_cell", {
|
|
4473
5316
|
title: "Reset Cell",
|
|
4474
5317
|
description: "Reset a single cell's manifest parameters to their defaults and remove the envelopes whose SOURCE is this cell. " +
|
|
@@ -4492,6 +5335,8 @@ server.registerTool("reset_cell", {
|
|
|
4492
5335
|
]);
|
|
4493
5336
|
const delta = {};
|
|
4494
5337
|
for (const p of cellParams) {
|
|
5338
|
+
if (isCcOutValueSuffix(p.paramId.replace(/^cell\d+_/, "")))
|
|
5339
|
+
continue;
|
|
4495
5340
|
if (p.defaultValue !== undefined)
|
|
4496
5341
|
delta[p.paramId] = p.defaultValue;
|
|
4497
5342
|
}
|
|
@@ -4501,12 +5346,14 @@ server.registerTool("reset_cell", {
|
|
|
4501
5346
|
const removedEnvs = envArray.length - filteredEnvs.length;
|
|
4502
5347
|
if (removedEnvs > 0)
|
|
4503
5348
|
delta.envelopes = filteredEnvs;
|
|
4504
|
-
await applyKv(delta,
|
|
5349
|
+
await applyKv(delta, STATE_UPDATE_KV_OPTIONS);
|
|
4505
5350
|
return text(`Reset cell ${cellId} to defaults (${paramCount} params, removed ${removedEnvs} envelopes).`);
|
|
4506
5351
|
});
|
|
4507
5352
|
server.registerTool("copy_cell", {
|
|
4508
5353
|
title: "Copy Cell",
|
|
4509
5354
|
description: "Copy a cell's manifest parameters and remap its LFO, envelope and vary links onto another cell, overwriting the target's. " +
|
|
5355
|
+
"Param-lane targets travel with their lanes. A CC lane lands only on a target slot that is Off or on the same " +
|
|
5356
|
+
"controller; CC out values and controllers stay the target's. " +
|
|
4510
5357
|
"Not copied: the loaded sample, parameter sequencers, and the cell config block (polyphony, MIDI note/channel, scale, " +
|
|
4511
5358
|
"sidechain source, pitch/stretch engine, slice keymap) — re-apply those with load_sample and update_cell_config.",
|
|
4512
5359
|
inputSchema: {
|
|
@@ -4533,10 +5380,33 @@ server.registerTool("copy_cell", {
|
|
|
4533
5380
|
const dstPrefix = `cell${targetCellId}_`;
|
|
4534
5381
|
const delta = {};
|
|
4535
5382
|
for (const [key, value] of Object.entries(state)) {
|
|
4536
|
-
if (key.startsWith(srcPrefix)) {
|
|
5383
|
+
if (key.startsWith(srcPrefix) && !isCcOutValueSuffix(key.slice(srcPrefix.length))) {
|
|
4537
5384
|
delta[dstPrefix + key.slice(srcPrefix.length)] = value;
|
|
4538
5385
|
}
|
|
4539
5386
|
}
|
|
5387
|
+
// Destination wiring: a CC lane lands only where the slot it locks is Off or on the same controller,
|
|
5388
|
+
// and then brings that slot; one that stays out keeps the destination's.
|
|
5389
|
+
const cellsState = isRecord(state.cells) ? state.cells : {};
|
|
5390
|
+
const srcRaw = cellsState[String(sourceCellId)];
|
|
5391
|
+
const dstRaw = cellsState[String(targetCellId)];
|
|
5392
|
+
const srcCell = isRecord(srcRaw) ? srcRaw : {};
|
|
5393
|
+
const dstCell = isRecord(dstRaw) ? dstRaw : {};
|
|
5394
|
+
const targetDelta = {};
|
|
5395
|
+
for (const lane of CC_LANES) {
|
|
5396
|
+
const slot = ccLaneSlotOf(srcCell, lane);
|
|
5397
|
+
if (slot === 0 || ccLaneMayLand(srcCell[`ccOutCc${slot}`], dstCell[`ccOutCc${slot}`])) {
|
|
5398
|
+
targetDelta[ccLaneSlotField(lane)] = slot;
|
|
5399
|
+
}
|
|
5400
|
+
else {
|
|
5401
|
+
delete delta[`${dstPrefix}${PATTERN_LANE_SUFFIX[lane]}`];
|
|
5402
|
+
targetDelta[ccLaneSlotField(lane)] = ccLaneSlotOf(dstCell, lane);
|
|
5403
|
+
}
|
|
5404
|
+
}
|
|
5405
|
+
for (const lane of PARAM_LANES) {
|
|
5406
|
+
const target = srcCell[LANE_TARGET_FIELD[lane]];
|
|
5407
|
+
targetDelta[LANE_TARGET_FIELD[lane]] = typeof target === "string" ? target : "";
|
|
5408
|
+
}
|
|
5409
|
+
delta.cells = { [targetCellId]: targetDelta };
|
|
4540
5410
|
// Remap envelopes
|
|
4541
5411
|
const envelopes = state.envelopes || [];
|
|
4542
5412
|
if (envelopes.length > 0) {
|
|
@@ -4612,7 +5482,7 @@ server.registerTool("copy_cell", {
|
|
|
4612
5482
|
if (remapped.length > 0)
|
|
4613
5483
|
delta.paramSeqs = [...kept, ...remapped];
|
|
4614
5484
|
}
|
|
4615
|
-
await applyKv(delta,
|
|
5485
|
+
await applyKv(delta, STATE_UPDATE_KV_OPTIONS);
|
|
4616
5486
|
return text(`Copied cell ${sourceCellId} → cell ${targetCellId} (${Object.keys(delta).length} keys).`);
|
|
4617
5487
|
});
|
|
4618
5488
|
server.registerTool("clear_modulations", {
|
|
@@ -4655,7 +5525,7 @@ server.registerTool("clear_modulations", {
|
|
|
4655
5525
|
if (Object.keys(delta).length === 0) {
|
|
4656
5526
|
return text(`No modulations found for cell ${cellId}.`);
|
|
4657
5527
|
}
|
|
4658
|
-
await applyKv(delta,
|
|
5528
|
+
await applyKv(delta, STATE_UPDATE_KV_OPTIONS);
|
|
4659
5529
|
const removed = (lfos.length - filteredLfos.length) +
|
|
4660
5530
|
(vary.length - filteredVary.length) +
|
|
4661
5531
|
(envelopes.length - filteredEnvs.length);
|
|
@@ -4663,7 +5533,8 @@ server.registerTool("clear_modulations", {
|
|
|
4663
5533
|
});
|
|
4664
5534
|
server.registerTool("reset_patterns", {
|
|
4665
5535
|
title: "Reset Patterns",
|
|
4666
|
-
description: "Clear
|
|
5536
|
+
description: "Clear every pattern lane of one cell (note, pitch, slice, stretch, the CC lanes and the param lanes, as the " +
|
|
5537
|
+
"product has them) and clear the param lanes' targets. CC out controllers stay.",
|
|
4667
5538
|
inputSchema: {
|
|
4668
5539
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
4669
5540
|
},
|
|
@@ -4682,16 +5553,58 @@ server.registerTool("reset_patterns", {
|
|
|
4682
5553
|
return error(`${productName} has no sampler MIDI/pitch/slice/stretch pattern lanes.`);
|
|
4683
5554
|
}
|
|
4684
5555
|
const delta = { __forceRender__: true };
|
|
4685
|
-
for (const
|
|
4686
|
-
|
|
5556
|
+
for (const lane of PATTERN_LANE_IDS) {
|
|
5557
|
+
if (!manifest || productHasLane(cellId, lane, manifest.stateVariables))
|
|
5558
|
+
delta[`cell${cellId}_${PATTERN_LANE_SUFFIX[lane]}`] = [];
|
|
5559
|
+
}
|
|
5560
|
+
if (!manifest || productHasLane(cellId, "paramLane1", manifest.stateVariables)) {
|
|
5561
|
+
delta.cells = { [cellId]: Object.fromEntries(PARAM_LANES.map((lane) => [LANE_TARGET_FIELD[lane], ""])) };
|
|
4687
5562
|
}
|
|
4688
|
-
await applyKv(delta,
|
|
5563
|
+
await applyKv(delta, PATTERN_EDIT_KV_OPTIONS);
|
|
4689
5564
|
return text(`Cleared all patterns for cell ${cellId}.`);
|
|
4690
5565
|
});
|
|
5566
|
+
server.registerTool("set_effects_enabled", {
|
|
5567
|
+
title: "Enable or Bypass Cell Effects",
|
|
5568
|
+
description: "Enable or bypass cell effects. Omit cellId for every cell (CTRL ALL); omit effect for the entire cell FX chain. " +
|
|
5569
|
+
"This changes cell effect enable flags, preserving their settings and the master bus. Use set_master_effects for master-bus changes.",
|
|
5570
|
+
inputSchema: {
|
|
5571
|
+
enabled: z.boolean().describe("true enables, false bypasses."),
|
|
5572
|
+
cellId: z.number().int().min(1).max(8).optional().describe("Cell number (1-8). Omit for all cells, including empty cells."),
|
|
5573
|
+
effect: z.string().optional().describe("Cell effect name, e.g. delay, crusher, fdn, filter. Omit for all cell effects."),
|
|
5574
|
+
},
|
|
5575
|
+
}, async ({ enabled, cellId, effect }) => {
|
|
5576
|
+
const check = await requireBridge();
|
|
5577
|
+
if (check !== true)
|
|
5578
|
+
return check;
|
|
5579
|
+
const manifest = await loadManifestForProductGuard();
|
|
5580
|
+
if (!manifest)
|
|
5581
|
+
return error("The selected product manifest is unavailable; effect availability cannot be validated safely.");
|
|
5582
|
+
const available = cellEffectOrder(manifest);
|
|
5583
|
+
if (!available.length)
|
|
5584
|
+
return error("The selected product has no cell effect chain.");
|
|
5585
|
+
if (effect !== undefined && !available.includes(effect))
|
|
5586
|
+
return error(`Unknown cell effect: ${effect}. Available effects: ${available.join(", ")}.`);
|
|
5587
|
+
if (cellId !== undefined) {
|
|
5588
|
+
const cellCheck = validateCellIdForManifest(cellId, manifest);
|
|
5589
|
+
if (cellCheck !== true)
|
|
5590
|
+
return cellCheck;
|
|
5591
|
+
}
|
|
5592
|
+
const cellCount = manifestMaxCellId(manifest);
|
|
5593
|
+
if (cellCount === null)
|
|
5594
|
+
return error("The selected product manifest has no cells.");
|
|
5595
|
+
const cellIds = cellId === undefined ? Array.from({ length: cellCount }, (_, i) => i + 1) : [cellId];
|
|
5596
|
+
const effects = effect === undefined ? available : [effect];
|
|
5597
|
+
const delta = Object.fromEntries(cellIds.flatMap((id) => effects.map((name) => [`cell${id}_${name}Enabled`, enabled ? 1 : 0])));
|
|
5598
|
+
const stateRaw = await bridge.send("getState");
|
|
5599
|
+
const state = isRecord(stateRaw) ? stateRaw : {};
|
|
5600
|
+
const changed = Object.entries(delta).filter(([key, value]) => Number(state[key] ?? 0) !== value).length;
|
|
5601
|
+
await applyKv(delta, EFFECT_CONFIG_KV_OPTIONS);
|
|
5602
|
+
return text(JSON.stringify({ cellIds, effects, enabled, changed }));
|
|
5603
|
+
});
|
|
4691
5604
|
server.registerTool("update_effect_config", {
|
|
4692
5605
|
title: "Update Effect Config",
|
|
4693
|
-
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,
|
|
4694
|
-
"Wavefold and Resonator ship in every product. Exact availability comes from the selected product manifest. " +
|
|
5606
|
+
description: "Toggle cell effects on/off (filter, delay, crusher, flanger, chorus, phaser, freqshift, drive, lpc, fmod, specdelay, squeeze, tape, granular, noisegate, codec, cellmix, eq, eq8, tablefilter, fdn, gate, compressor, panner, gain, vocoder, ringmod, vinylbrake, transient, repeat, pitchshift, limiter, shimmer, wavefold, resonator). " +
|
|
5607
|
+
"granular is the Grain card and noisegate the Noise Gate. Wavefold and Resonator ship in every product. Exact availability comes from the selected product manifest. " +
|
|
4695
5608
|
"Uses the lighter KV path that does not rebuild the audio graph. Pass 1 to enable, 0 to disable.",
|
|
4696
5609
|
inputSchema: {
|
|
4697
5610
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
@@ -4703,7 +5616,7 @@ server.registerTool("update_effect_config", {
|
|
|
4703
5616
|
fdnEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4704
5617
|
gateEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4705
5618
|
compressorEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4706
|
-
// Shared extended effects. These ship on PAM
|
|
5619
|
+
// Shared extended effects. These ship on PAM too, so leaving them out of the
|
|
4707
5620
|
// schema made them unreachable over the bridge even though the handler is
|
|
4708
5621
|
// generic (it just builds `cell{N}_{key}`) and the state vars exist in
|
|
4709
5622
|
// build.js alongside the eight above.
|
|
@@ -4717,9 +5630,19 @@ server.registerTool("update_effect_config", {
|
|
|
4717
5630
|
repeatEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4718
5631
|
pitchshiftEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4719
5632
|
limiterEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4720
|
-
qverbEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4721
5633
|
chorusEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4722
5634
|
phaserEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
5635
|
+
freqshiftEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
5636
|
+
driveEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
5637
|
+
lpcEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
5638
|
+
specdelayEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
5639
|
+
squeezeEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
5640
|
+
tapeEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
5641
|
+
granularEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
5642
|
+
noisegateEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
5643
|
+
codecEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
5644
|
+
cellmixEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
5645
|
+
fmodEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4723
5646
|
shimmerEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4724
5647
|
tablefilterEnabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
4725
5648
|
// Product-specific extended effects. The handler rejects flags absent from
|
|
@@ -4753,17 +5676,50 @@ server.registerTool("update_effect_config", {
|
|
|
4753
5676
|
if (Object.keys(delta).length === 0) {
|
|
4754
5677
|
return error("At least one effect flag must be provided.");
|
|
4755
5678
|
}
|
|
4756
|
-
await applyKv(delta,
|
|
5679
|
+
await applyKv(delta, EFFECT_CONFIG_KV_OPTIONS);
|
|
4757
5680
|
const summary = Object.entries(delta)
|
|
4758
5681
|
.map(([k, v]) => `${k}=${v}`)
|
|
4759
5682
|
.join(", ");
|
|
4760
5683
|
return text(`Updated effect config for cell ${cellId}: ${summary}`);
|
|
4761
5684
|
});
|
|
5685
|
+
// Spectral Delay's curve is the array state var cell{N}_specdelayBands, which set_parameters
|
|
5686
|
+
// (numbers only) cannot write. KVStateSyncManager projects it to the node, so no WebView is needed.
|
|
5687
|
+
const SPECDELAY_BANDS = 32;
|
|
5688
|
+
server.registerTool("set_spectral_delay_curve", {
|
|
5689
|
+
title: "Set Spectral Delay Curve",
|
|
5690
|
+
description: "Set a cell's Spectral Delay curve: 32 log-spaced bands from 20 Hz to 20 kHz, each 0..1. In TIME mode a " +
|
|
5691
|
+
"band's value is its share of Time (0 = the shortest repeat, 1 = the whole Time); in LEVEL mode it is how " +
|
|
5692
|
+
"much of that band is repeated. Depth scales the curve. Pass shape 'rise' (highs arrive last) or 'fall', or " +
|
|
5693
|
+
"32 explicit bands, low to high. Enable the effect with update_effect_config specdelayEnabled.",
|
|
5694
|
+
inputSchema: {
|
|
5695
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
5696
|
+
shape: z.enum(["rise", "fall"]).optional().describe("A factory curve. Omit when passing bands."),
|
|
5697
|
+
bands: z
|
|
5698
|
+
.array(z.number().min(0).max(1))
|
|
5699
|
+
.length(SPECDELAY_BANDS)
|
|
5700
|
+
.optional()
|
|
5701
|
+
.describe("32 values 0..1, 20 Hz first. Omit when passing shape."),
|
|
5702
|
+
},
|
|
5703
|
+
}, async ({ cellId, shape, bands }) => {
|
|
5704
|
+
const check = await requireBridgeForCells(cellId);
|
|
5705
|
+
if (check !== true)
|
|
5706
|
+
return check;
|
|
5707
|
+
if ((shape === undefined) === (bands === undefined))
|
|
5708
|
+
return error("Pass exactly one of shape or bands.");
|
|
5709
|
+
const manifest = await loadManifestForProductGuard();
|
|
5710
|
+
if (manifest && !cellEffectNames(manifest).includes("specdelay")) {
|
|
5711
|
+
return error(`Spectral Delay is not available in ${manifest.name ?? "the selected product"}.`);
|
|
5712
|
+
}
|
|
5713
|
+
const rise = Array.from({ length: SPECDELAY_BANDS }, (_, b) => b / (SPECDELAY_BANDS - 1));
|
|
5714
|
+
const curve = bands ?? (shape === "fall" ? rise.map((v) => 1 - v) : rise);
|
|
5715
|
+
await applyKv({ [`cell${cellId}_specdelayBands`]: curve }, STATE_UPDATE_KV_OPTIONS);
|
|
5716
|
+
return text(`Set cell ${cellId} Spectral Delay curve (${shape ?? "32 bands"}).`);
|
|
5717
|
+
});
|
|
4762
5718
|
// MFX cable patchbay. `mfx_routing` is a non-numeric state variable, so it was
|
|
4763
5719
|
// unreachable through set_parameters (numbers only) — an agent could shape an MFX
|
|
4764
5720
|
// cell but never patch one to another, which is the whole product.
|
|
4765
5721
|
//
|
|
4766
|
-
// Validation mirrors parseRoutingEdges (interface/src/
|
|
5722
|
+
// Validation mirrors parseRoutingEdges (interface/src/utils/mfxRouting.ts): the
|
|
4767
5723
|
// plugin re-validates on receipt, so this pass exists to fail loudly here rather
|
|
4768
5724
|
// than have a typo silently drop a cable.
|
|
4769
5725
|
const MFX_ROUTING_SOURCE_HINT = "'input', 'sidechain', 'split_lo' / 'split_mid' / 'split_hi', or 'cellN'. " +
|
|
@@ -4812,8 +5768,8 @@ function validateMfxEdges(edges, maxCells) {
|
|
|
4812
5768
|
}
|
|
4813
5769
|
server.registerTool("set_mfx_routing", {
|
|
4814
5770
|
title: "Set MFX Cable Routing",
|
|
4815
|
-
description: "Read or replace the MFX cable patch — the graph that decides which cell feeds which. MFX-only;
|
|
4816
|
-
"
|
|
5771
|
+
description: "Read or replace the MFX cable patch — the graph that decides which cell feeds which. MFX-only; other products " +
|
|
5772
|
+
"return a product error.\n\n" +
|
|
4817
5773
|
"Ports: 'input' (the host's audio in) and 'sidechain' are sources; 'output' is the destination; 'cellN' is both. " +
|
|
4818
5774
|
"A self-edge cellN→cellN is a feedback loop, and input→output is a direct dry path. Duplicate cables are dropped.\n\n" +
|
|
4819
5775
|
"SPLIT is a three-band crossover node: cable 'input' (or 'sidechain') to 'split', then patch 'split_lo', " +
|
|
@@ -4858,7 +5814,7 @@ server.registerTool("set_mfx_routing", {
|
|
|
4858
5814
|
return error(`${validated.reason}. This product exposes cells 1..${maxCells}.`);
|
|
4859
5815
|
}
|
|
4860
5816
|
// mfx_routing is a GRAPH_RENDER state var — a re-patch must rebuild the graph.
|
|
4861
|
-
await applyKv({ mfx_routing: validated.edges },
|
|
5817
|
+
await applyKv({ mfx_routing: validated.edges }, STATE_UPDATE_KV_OPTIONS);
|
|
4862
5818
|
const summary = validated.edges.map((e) => `${e.from}→${e.to}`).join(", ");
|
|
4863
5819
|
return text(`MFX routing set: ${validated.edges.length} cable(s)${summary ? ` — ${summary}` : " (patch cleared)"}.`);
|
|
4864
5820
|
});
|
|
@@ -4895,7 +5851,8 @@ server.registerTool("set_mfx_edge_gain", {
|
|
|
4895
5851
|
return error(`Cable ${from}→${to} is not in the current patch. Add it with set_mfx_routing first.`);
|
|
4896
5852
|
const key = `${from}__${to}`;
|
|
4897
5853
|
gains[key] = gain;
|
|
4898
|
-
|
|
5854
|
+
// Cable gains reach audio only through the graph's mounted refs, which a dispatch updates.
|
|
5855
|
+
await applyKv({ mfx_edge_gains: gains }, STATE_UPDATE_KV_OPTIONS);
|
|
4899
5856
|
return text(`MFX cable gain set: ${from}→${to} = ${gain}.`);
|
|
4900
5857
|
});
|
|
4901
5858
|
const MFX_COMBO_TARGET_INPUT = z.object({
|
|
@@ -4954,7 +5911,7 @@ server.registerTool("configure_mfx_combo", {
|
|
|
4954
5911
|
const currentTargetValue = state[target.id];
|
|
4955
5912
|
delta[target.id] = mfxComboTargetValueFromCurrent(target, typeof previousComboValue === "number" ? previousComboValue : 0, value, info, typeof currentTargetValue === "number" ? currentTargetValue : info.defaultValue);
|
|
4956
5913
|
}
|
|
4957
|
-
await applyKv(delta,
|
|
5914
|
+
await applyKv(delta, STATE_UPDATE_KV_OPTIONS);
|
|
4958
5915
|
return json({ combo: index, value, targetsWritten: combo.targets.map((target) => target.id) });
|
|
4959
5916
|
}
|
|
4960
5917
|
const materialized = () => ({ ...stored, targets: [...combo.targets] });
|
|
@@ -5051,7 +6008,20 @@ server.registerTool("configure_mfx_combo", {
|
|
|
5051
6008
|
locked: next?.locked === 1 ? 1 : 0,
|
|
5052
6009
|
targets: Array.isArray(next?.targets) ? next.targets : null,
|
|
5053
6010
|
};
|
|
5054
|
-
|
|
6011
|
+
const delta = { mfx_combos: { ...slots, [String(index)]: update } };
|
|
6012
|
+
if ((op === "setTargetStop" || op === "invertTarget") && paramId) {
|
|
6013
|
+
const before = combo.targets.find((target) => target.id === paramId);
|
|
6014
|
+
const after = next?.targets?.find((target) => target.id === paramId);
|
|
6015
|
+
const info = parameterIndex.get(paramId);
|
|
6016
|
+
if (before && after && info) {
|
|
6017
|
+
const current = typeof state[paramId] === "number" ? state[paramId] : info.defaultValue;
|
|
6018
|
+
const comboValue = state[`mfx_combo${index}`];
|
|
6019
|
+
const changed = mfxComboTargetValueAfterRangeEdit(before, after, typeof comboValue === "number" ? comboValue : 0, info, current);
|
|
6020
|
+
if (changed !== current)
|
|
6021
|
+
delta[paramId] = changed;
|
|
6022
|
+
}
|
|
6023
|
+
}
|
|
6024
|
+
await applyKv(delta, EFFECT_CONFIG_KV_OPTIONS);
|
|
5055
6025
|
return json({ combo: index, slot: update });
|
|
5056
6026
|
});
|
|
5057
6027
|
server.registerTool("set_effect_order", {
|
|
@@ -5111,15 +6081,16 @@ server.registerTool("set_effect_order", {
|
|
|
5111
6081
|
"To bypass a stage set its *Enabled flag via update_effect_config; leaving it out of the chain is not the same thing.");
|
|
5112
6082
|
}
|
|
5113
6083
|
// effectOrder rebuilds the cell's FX graph, like an MFX re-patch.
|
|
5114
|
-
await applyKv({ [key]: order },
|
|
6084
|
+
await applyKv({ [key]: order }, STATE_UPDATE_KV_OPTIONS);
|
|
5115
6085
|
return text(`Cell ${cellId} FX chain: ${order.join(" → ")}.`);
|
|
5116
6086
|
});
|
|
5117
6087
|
server.registerTool("update_cell_config", {
|
|
5118
6088
|
title: "Update Cell Config",
|
|
5119
|
-
description: "Update cell-level configuration properties like polyphony, MIDI settings, varispeed, and modulation latch. " +
|
|
6089
|
+
description: "Update cell-level configuration properties like polyphony, MIDI settings, MIDI output (channel, Send, CC out controllers), varispeed, and modulation latch. " +
|
|
5120
6090
|
"These are KV-only properties that don't appear in the parameter manifest. " +
|
|
5121
|
-
"
|
|
5122
|
-
"
|
|
6091
|
+
"Most keys take a lightweight KV path; the source keys (sidechainFrom, cellmixFrom, cellmixPreFx) rebuild the graph once. " +
|
|
6092
|
+
"cellmixFrom picks the source the cell's Cell Mix card blends in and switches the card on. " +
|
|
6093
|
+
"When bound to MFX, this tool accepts only sidechainFrom, cellmixFrom and cellmixPreFx; on MSQ it accepts the MIDI and output keys only.",
|
|
5123
6094
|
inputSchema: {
|
|
5124
6095
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
5125
6096
|
isPoly: z.union([z.literal(0), z.literal(1)]).optional().describe("Polyphonic mode (0=mono, 1=poly)."),
|
|
@@ -5173,6 +6144,34 @@ server.registerTool("update_cell_config", {
|
|
|
5173
6144
|
.optional()
|
|
5174
6145
|
.describe("Sidechain compressor source. null=no sidechain, 0=external audio input, 1-8=cell output. " +
|
|
5175
6146
|
"Auto-enables the cell's compressor when set to a non-null value."),
|
|
6147
|
+
cellmixFrom: z
|
|
6148
|
+
.number()
|
|
6149
|
+
.int()
|
|
6150
|
+
.min(-1)
|
|
6151
|
+
.max(8)
|
|
6152
|
+
.optional()
|
|
6153
|
+
.describe("Cell Mix source: -1=none, 0=external sidechain input, 1-8=another cell (never this cell). " +
|
|
6154
|
+
"Any value but -1 also switches the cell's Cell Mix card on."),
|
|
6155
|
+
cellmixPreFx: z
|
|
6156
|
+
.boolean()
|
|
6157
|
+
.optional()
|
|
6158
|
+
.describe("Where Cell Mix taps a source cell: true=before its effects, false=after its effects and before its fader."),
|
|
6159
|
+
midiOutChannel: z.number().int().min(0).max(16).optional().describe("MIDI out channel for the cell's CC out (and MSQ's notes): 0=Auto (the cell number), 1-16 explicit."),
|
|
6160
|
+
sendToHost: z.union([z.literal(0), z.literal(1)]).optional().describe("0 keeps the cell's CC out (and MSQ's notes) off the MIDI out."),
|
|
6161
|
+
midiOutPort: z.number().int().min(0).max(3).optional().describe("Standalone only: 0=Main MIDI Output, 1-3=Ports B-D set in Settings."),
|
|
6162
|
+
polyNoteMin: z.number().int().min(0).max(127).optional().describe("Lowest note a poly cell plays (MSQ: sends), at most polyNoteMax."),
|
|
6163
|
+
polyNoteMax: z.number().int().min(0).max(127).optional().describe("Highest note a poly cell plays (MSQ: sends), at least polyNoteMin."),
|
|
6164
|
+
...Object.fromEntries(CC_OUT_SLOTS.map((slot) => [`ccOutCc${slot}`, ccOutControllerSchema(slot)])),
|
|
6165
|
+
...Object.fromEntries(CC_LANES.map((lane) => [
|
|
6166
|
+
ccLaneSlotField(lane),
|
|
6167
|
+
z
|
|
6168
|
+
.number()
|
|
6169
|
+
.int()
|
|
6170
|
+
.min(0)
|
|
6171
|
+
.max(CC_OUT_SLOTS.length)
|
|
6172
|
+
.optional()
|
|
6173
|
+
.describe(`CC out slot (1-${CC_OUT_SLOTS.length}) that CC lane ${ccLaneIndex(lane)} locks per event, 0=none. Lane L starts on slot L. The lane loops at its own cell{N}_patternLengthCc${ccLaneIndex(lane)} / patternSpeedCc${ccLaneIndex(lane)}.`),
|
|
6174
|
+
])),
|
|
5176
6175
|
},
|
|
5177
6176
|
}, async ({ cellId, ...config }) => {
|
|
5178
6177
|
const check = await requireBridge();
|
|
@@ -5197,10 +6196,38 @@ server.registerTool("update_cell_config", {
|
|
|
5197
6196
|
if (manifest && isMfxManifest(manifest)) {
|
|
5198
6197
|
const unsupported = providedKeys.filter((key) => !MFX_CELL_CONFIG_KEYS.has(key));
|
|
5199
6198
|
if (unsupported.length > 0) {
|
|
5200
|
-
return error(
|
|
5201
|
-
`Sampler/MIDI/instrument config keys are PAM
|
|
6199
|
+
return error(`update_cell_config in MFX accepts only ${[...MFX_CELL_CONFIG_KEYS].join(", ")}. ` +
|
|
6200
|
+
`Sampler/MIDI/instrument config keys are PAM-only: ${unsupported.join(", ")}.`);
|
|
5202
6201
|
}
|
|
5203
6202
|
}
|
|
6203
|
+
if (bridge.getConnectedInstance()?.product?.toUpperCase() === "MSQ") {
|
|
6204
|
+
const unsupported = providedKeys.filter((key) => !MSQ_CELL_CONFIG_KEYS.has(key));
|
|
6205
|
+
if (unsupported.length > 0) {
|
|
6206
|
+
return error(`update_cell_config in MSQ accepts MIDI keys only (${[...MSQ_CELL_CONFIG_KEYS].join(", ")}). ` +
|
|
6207
|
+
`MSQ has no sampler: ${unsupported.join(", ")}.`);
|
|
6208
|
+
}
|
|
6209
|
+
}
|
|
6210
|
+
if (config.cellmixFrom === cellId) {
|
|
6211
|
+
return error(`cellmixFrom cannot be cell ${cellId} itself: use -1 for none, 0 for the sidechain input, or another cell.`);
|
|
6212
|
+
}
|
|
6213
|
+
if (config.cellmixFrom !== undefined && config.cellmixFrom >= 1) {
|
|
6214
|
+
const sourceCheck = validateCellIdForManifest(config.cellmixFrom, manifest);
|
|
6215
|
+
if (sourceCheck !== true)
|
|
6216
|
+
return sourceCheck;
|
|
6217
|
+
}
|
|
6218
|
+
// MSQ's router drops every note outside Low-High as stored, so an inverted pair would silence the cell.
|
|
6219
|
+
if (config.polyNoteMin !== undefined || config.polyNoteMax !== undefined) {
|
|
6220
|
+
let current = {};
|
|
6221
|
+
if (config.polyNoteMin === undefined || config.polyNoteMax === undefined) {
|
|
6222
|
+
const state = await bridge.send("getState");
|
|
6223
|
+
const cells = isRecord(state) && isRecord(state.cells) ? state.cells : {};
|
|
6224
|
+
current = isRecord(cells[String(cellId)]) ? cells[String(cellId)] : {};
|
|
6225
|
+
}
|
|
6226
|
+
const low = config.polyNoteMin ?? Number(current.polyNoteMin ?? 0);
|
|
6227
|
+
const high = config.polyNoteMax ?? Number(current.polyNoteMax ?? 127);
|
|
6228
|
+
if (low > high)
|
|
6229
|
+
return error(`polyNoteMin (${low}) must not exceed polyNoteMax (${high}).`);
|
|
6230
|
+
}
|
|
5204
6231
|
const cellConfig = {};
|
|
5205
6232
|
const flatDelta = {};
|
|
5206
6233
|
for (const [key, value] of Object.entries(config)) {
|
|
@@ -5211,10 +6238,18 @@ server.registerTool("update_cell_config", {
|
|
|
5211
6238
|
else
|
|
5212
6239
|
cellConfig[key] = value;
|
|
5213
6240
|
}
|
|
6241
|
+
// An agent's scale is the user's: a Player switched off later must not put its pre-arm scale back.
|
|
6242
|
+
if (cellConfig.scaleName !== undefined || cellConfig.scaleRoot !== undefined) {
|
|
6243
|
+
cellConfig.playerPreScale = NO_SCALE_ARM;
|
|
6244
|
+
cellConfig.playerPreScaleRoot = NO_POLY_ARM;
|
|
6245
|
+
}
|
|
5214
6246
|
// Auto-enable compressor when setting sidechain source
|
|
5215
6247
|
if (cellConfig.sidechainFrom !== undefined && cellConfig.sidechainFrom !== null) {
|
|
5216
6248
|
flatDelta[`cell${cellId}_compressorEnabled`] = 1;
|
|
5217
6249
|
}
|
|
6250
|
+
if (cellConfig.cellmixFrom !== undefined && cellConfig.cellmixFrom !== -1) {
|
|
6251
|
+
flatDelta[`cell${cellId}_cellmixEnabled`] = 1;
|
|
6252
|
+
}
|
|
5218
6253
|
// Mirror interop.ts updateCellConfig: a pitch/stretch engine change OR a
|
|
5219
6254
|
// sliceKeymapMode flip (which changes pitch interpretation) bumps
|
|
5220
6255
|
// modeChangeSeq so the audio thread flushes its Signalsmith analysis state.
|
|
@@ -5228,7 +6263,9 @@ server.registerTool("update_cell_config", {
|
|
|
5228
6263
|
const delta = { ...flatDelta };
|
|
5229
6264
|
if (Object.keys(cellConfig).length > 0)
|
|
5230
6265
|
delta.cells = { [cellId]: cellConfig };
|
|
5231
|
-
|
|
6266
|
+
// Sidechain and Cell Mix sources are wired at graph build, so they need the dispatch the UI's setters make.
|
|
6267
|
+
const wiresGraph = ["sidechainFrom", "cellmixFrom", "cellmixPreFx"].some((key) => cellConfig[key] !== undefined);
|
|
6268
|
+
await applyKv(delta, wiresGraph ? STATE_UPDATE_KV_OPTIONS : CELL_CONFIG_KV_OPTIONS);
|
|
5232
6269
|
const summary = [
|
|
5233
6270
|
...Object.entries(cellConfig).map(([k, v]) => `${k}=${v}`),
|
|
5234
6271
|
...Object.entries(flatDelta).map(([k, v]) => `${k}=${v}`),
|
|
@@ -5264,10 +6301,10 @@ server.registerTool("set_cell_mute", {
|
|
|
5264
6301
|
}
|
|
5265
6302
|
// cell{N}_mute / cell{N}_solo are flat state vars projected to
|
|
5266
6303
|
// cell{N}_config.enabled by KVStateSyncManager — same options the UI's
|
|
5267
|
-
// applyStateDelta uses (
|
|
6304
|
+
// applyStateDelta uses (STATE_UPDATE_KV_OPTIONS); the projection needs
|
|
5268
6305
|
// syncConfigResources, no graph rebuild required but the standard path is
|
|
5269
6306
|
// the exact UI parity.
|
|
5270
|
-
await applyKv(delta,
|
|
6307
|
+
await applyKv(delta, STATE_UPDATE_KV_OPTIONS);
|
|
5271
6308
|
const summary = Object.entries(delta)
|
|
5272
6309
|
.map(([k, v]) => `${k}=${v}`)
|
|
5273
6310
|
.join(", ");
|
|
@@ -5303,11 +6340,13 @@ server.registerTool("set_master_effects", {
|
|
|
5303
6340
|
masterTapeDrive: z.number().min(0).max(1).optional().describe("Tape drive / saturation amount (0-1, default 0.5). Higher = more harmonic grit + HF compression ('push it harder → it gets duller')."),
|
|
5304
6341
|
masterTapeTone: z.number().min(-1).max(1).optional().describe("Tape tone tilt (-1 dark .. 0 flat .. +1 bright, default 0). Go negative for dusty/lo-fi."),
|
|
5305
6342
|
masterTapeMix: z.number().min(0).max(1).optional().describe("Tape dry/wet (0-1, default 1 = fully wet)."),
|
|
5306
|
-
masterTapeMode: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]).optional().describe("Tape character: 0=Clean (gentle tanh), 1=Lo-Fi (most degradation, dusty), 2=
|
|
6343
|
+
masterTapeMode: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]).optional().describe("Tape character: 0=Clean (gentle tanh), 1=Lo-Fi (most degradation, dusty), 2=Broken (drifting wow/flutter, crackle, hard asymmetric drive), 3=Studio (Jiles-Atherton magnetic hysteresis — the deep hi-fi machine, no wow/hiss). Default 0."),
|
|
5307
6344
|
masterTapeWear: z.number().min(0).max(1).optional().describe("Tape wear: wow/flutter pitch drift + age (0-1, default 0.5). Raise for warble/sag."),
|
|
6345
|
+
masterTapeOutTrim: z.number().min(-24).max(24).optional().describe("Tape output trim dB on the wet signal, after Mix (default 0). Unity while Tape is off."),
|
|
5308
6346
|
masterEqEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the master EQ (trim/tilt/shelves/bells)."),
|
|
5309
6347
|
masterWidthEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the stereo utility (width + elliptical bass-mono)."),
|
|
5310
6348
|
masterInputTrim: z.number().min(-24).max(24).optional().describe("Master input trim in dB (gain staging into the chain; default 0)."),
|
|
6349
|
+
masterEqOutTrim: z.number().min(-24).max(24).optional().describe("Master EQ output trim dB (default 0). Unity while the EQ is off."),
|
|
5311
6350
|
masterEqTilt: z.number().min(-6).max(6).optional().describe("Tilt in dB at the extremes around a 650 Hz pivot (+ = brighter; the coarse tonal-balance lever)."),
|
|
5312
6351
|
masterEqLsFreq: z.number().min(20).max(500).optional().describe("Low-shelf corner Hz (default 90)."),
|
|
5313
6352
|
masterEqLsGain: z.number().min(-12).max(12).optional().describe("Low-shelf gain dB (default 0)."),
|
|
@@ -5319,11 +6358,21 @@ server.registerTool("set_master_effects", {
|
|
|
5319
6358
|
masterClipDrive: z.number().min(0).max(12).optional().describe("Clipper drive dB (default 0). Staging guidance: the clipper shaves the sharpest 2-3 dB so the limiter works 1-2 dB."),
|
|
5320
6359
|
masterClipMix: z.number().min(0).max(1).optional().describe("Clipper parallel blend (1 = fully clipped)."),
|
|
5321
6360
|
masterClipMode: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]).optional().describe("Clipper transfer: 0=Smooth (knee-tanh), 1=Silicon diode (hard knee), 2=Germanium diode (earlier, softer knee), 3=LED (higher knee). Diode modes are exact antiparallel-pair solves, ADAA at the 2x rate. Default 0."),
|
|
6361
|
+
masterDenoiseEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable master noise reduction before EQ and compression; off by default. Adds one FFT frame of latency while enabled; a DAW that is playing keeps that latency after a switch-off until its transport stops, and Fixed latency mode keeps it always."),
|
|
6362
|
+
masterDenoiseLow: z.number().min(0).max(100).optional().describe("Low-band noise reduction strength, percent (default 50)."),
|
|
6363
|
+
masterDenoiseMid: z.number().min(0).max(100).optional().describe("Mid-band noise reduction strength, percent (default 50)."),
|
|
6364
|
+
masterDenoiseHigh: z.number().min(0).max(100).optional().describe("High-band noise reduction strength, percent (default 50)."),
|
|
6365
|
+
masterDenoiseSplitLow: z.number().min(40).max(2000).optional().describe("Low/mid split in Hz (default 250)."),
|
|
6366
|
+
masterDenoiseSplitHigh: z.number().min(500).max(12000).optional().describe("Mid/high split in Hz (default 4000)."),
|
|
6367
|
+
masterDenoiseMix: z.number().min(0).max(1).optional().describe("Master Denoise dry/wet, 0..1 (default 1)."),
|
|
6368
|
+
masterDenoiseOutTrim: z.number().min(-24).max(24).optional().describe("Master Denoise output trim dB on the wet signal, after Mix (default 0). Unity while Denoise is off."),
|
|
6369
|
+
masterDenoisePrint: z.string().optional().describe("Learned noise print ('pdn1:<sampleRate>:<fftSize>:<base64>'), written by the Denoise panel's Learn button. Pass '' to forget it and return to adaptive tracking; copy a value read from get_state to reuse a print."),
|
|
5322
6370
|
masterIronEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the console/transformer iron colour stage (post-Tape, pre-Clipper; zero latency)."),
|
|
5323
6371
|
masterIronDrive: z.number().min(0).max(12).optional().describe("Iron drive dB (default 0). Level-compensated — drive adds colour, not loudness."),
|
|
5324
6372
|
masterIronAmount: z.number().min(0).max(1).optional().describe("Transformer (magnetizing-branch) depth 0..1 — LF-concentrated saturation, the 'iron' signature (default 0.5)."),
|
|
5325
6373
|
masterIronMode: z.union([z.literal(0), z.literal(1)]).optional().describe("0=Console (amp-forward, subtle), 1=Transformer (heavier LF iron)."),
|
|
5326
|
-
|
|
6374
|
+
masterIronOutTrim: z.number().min(-24).max(24).optional().describe("Iron output trim dB on the wet signal (default 0). Unity while Iron is off."),
|
|
6375
|
+
masterSqueezeEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable Squeeze — a 3-band upward+downward compressor (post-Punch, pre-Tape; zero latency). The character (thresholds, ratios) is baked; Depth is the main lever."),
|
|
5327
6376
|
masterSqueezeDepth: z.number().min(0).max(1).optional().describe("Squeeze depth 0..1 (default 0.3): scales the applied per-band gain toward the full character — a density control, NOT a dry/wet. 0.15-0.3 densifies a master; 0.6+ is the aggressive pumped sound."),
|
|
5328
6377
|
masterSqueezeTime: z.number().min(0.1).max(10).optional().describe("Squeeze time multiplier on all band attack+release (0.1 fast .. 10 slow, default 1)."),
|
|
5329
6378
|
masterSqueezeTone: z.number().min(-1).max(1).optional().describe("Squeeze tone: bipolar low<->high band tilt, ±6 dB at the extremes (default 0)."),
|
|
@@ -5332,6 +6381,8 @@ server.registerTool("set_master_effects", {
|
|
|
5332
6381
|
masterSqueezeUpAmount: z.number().min(0).max(1.5).optional().describe("Squeeze upward-branch trim 0..1.5 (default 1). 0 disables the upward lift (downward-only multiband)."),
|
|
5333
6382
|
masterSqueezeDownAmount: z.number().min(0).max(1.5).optional().describe("Squeeze downward-branch trim 0..1.5 (default 1). 0 disables the downward limiting (pure upward lift)."),
|
|
5334
6383
|
masterSqueezeUpCapDb: z.number().min(0).max(24).optional().describe("Squeeze upward max-boost clamp dB (default 12) — bounds how far quiet material (tails, noise floor) is lifted."),
|
|
6384
|
+
masterSqueezeLoCross: z.number().min(10).max(500).optional().describe("Squeeze low/mid crossover Hz (default 88.3)."),
|
|
6385
|
+
masterSqueezeHiCross: z.number().min(500).max(20000).optional().describe("Squeeze mid/high crossover Hz (default 2500)."),
|
|
5335
6386
|
masterLoudnessAutoReset: z.union([z.literal(0), z.literal(1)]).optional().describe("Loudness meter auto-reset on host transport start (default 1). 0 keeps measurements running across transport stops."),
|
|
5336
6387
|
masterEqBells: z
|
|
5337
6388
|
.array(z.object({
|
|
@@ -5390,7 +6441,7 @@ server.registerTool("set_master_effects", {
|
|
|
5390
6441
|
// No-dispatch path: every master key is live-polled from master_config
|
|
5391
6442
|
// (the full Punch surface included), so a graph rebuild here would only
|
|
5392
6443
|
// risk dropouts during automated mastering.
|
|
5393
|
-
await applyKv(delta,
|
|
6444
|
+
await applyKv(delta, MASTER_CONFIG_KV_OPTIONS);
|
|
5394
6445
|
const summary = Object.entries(delta)
|
|
5395
6446
|
.map(([k, v]) => `${k}=${v}`)
|
|
5396
6447
|
.join(", ");
|
|
@@ -5399,7 +6450,7 @@ server.registerTool("set_master_effects", {
|
|
|
5399
6450
|
// Built-in per-cell drum synth ("machines"). Engine enum index = drumsynth::Engine
|
|
5400
6451
|
// (Off = 0). The roster, the curated defaults and the engine-switch rule all come
|
|
5401
6452
|
// from shared/drumSynthEngines.json — the registry build.js codegens the C++ cook
|
|
5402
|
-
// and the manifest from, and interface/src/
|
|
6453
|
+
// and the manifest from, and interface/src/utils/drumSynth.ts derives its
|
|
5403
6454
|
// own tables from. The hand-copied table this replaced had drifted on Kick,
|
|
5404
6455
|
// Cowbell and 808 (issue #999); the switch rule it shipped with carried knob
|
|
5405
6456
|
// positions where the plugin carries engine units.
|
|
@@ -5422,6 +6473,14 @@ const DRUM_SYNTH_REGISTRY = (() => {
|
|
|
5422
6473
|
})();
|
|
5423
6474
|
const drumSynth = createDrumSynthCatalog(DRUM_SYNTH_REGISTRY);
|
|
5424
6475
|
const DRUM_SYNTH_ENGINE_NAMES = drumSynth.engineNames;
|
|
6476
|
+
const DRUM_SYNTH_MODE_ROSTER = drumSynth.engines
|
|
6477
|
+
.filter((e) => e.modes.length > 0)
|
|
6478
|
+
.map((e) => `${e.name}=[${e.modes.map((m) => m.name).join(",")}]`)
|
|
6479
|
+
.join("; ");
|
|
6480
|
+
const DRUM_SYNTH_HOLD_ENGINES = drumSynth.engines
|
|
6481
|
+
.filter((e) => e.hold || e.modes.some((m) => m.hold))
|
|
6482
|
+
.map((e) => (e.hold ? e.name : `${e.name} (${e.modes.filter((m) => m.hold).map((m) => m.name).join("/")} mode)`))
|
|
6483
|
+
.join(", ");
|
|
5425
6484
|
/** State carries the bare enum; agents reason about the name. Report both. */
|
|
5426
6485
|
function describeSynthEngine(raw) {
|
|
5427
6486
|
const id = Math.round(Number(raw ?? 0));
|
|
@@ -5439,12 +6498,28 @@ server.registerTool("set_drum_synth", {
|
|
|
5439
6498
|
"(re-mapped through the destination's own range), so Kick Decay 0.41 becomes Stab Decay 0.336 — the same 198 ms. " +
|
|
5440
6499
|
"Untouched slots, and slots whose label or unit has no counterpart, take the destination's default. Per-engine p-labels: " +
|
|
5441
6500
|
drumSynth.engines.map((e) => `${e.name}=[${e.params.map((p) => p.label).join(",")}]`).join("; ") + ". " +
|
|
6501
|
+
`Engine modes are alternative topologies over the same knobs: ${DRUM_SYNTH_MODE_ROSTER}. A mode belongs to ` +
|
|
6502
|
+
"its engine: a change of engine without mode selects the first one, the original render; re-selecting the " +
|
|
6503
|
+
"cell's current engine keeps its mode. hold:true makes each new " +
|
|
6504
|
+
"hit sustain at its post-attack level for as long as its note is held (a sequencer step's gate length or a " +
|
|
6505
|
+
`held MIDI key), then decay at the live Decay rate. Only ${DRUM_SYNTH_HOLD_ENGINES} sustain; on any other ` +
|
|
6506
|
+
"engine or mode the flag is stored but has no effect. The synth-only output stage after the voice sum is four " +
|
|
6507
|
+
"ordinary parameters, set with set_parameters: cell{N}_synthFilterMode (0 Off, 1 LP, 2 BP, 3 HP), " +
|
|
6508
|
+
"cell{N}_synthCutoff (Hz), cell{N}_synthReso (%), cell{N}_synthDrive (%). " +
|
|
5442
6509
|
"NOTE: requires a PAM build with drum synth (manifest.drumSynth.enabled). This headless tool is product-gated, " +
|
|
5443
6510
|
"not gated by the UI-only drumSynthEnabled preference. " +
|
|
5444
6511
|
"On products without it, this tool returns a product error instead of writing inert synth keys.",
|
|
5445
6512
|
inputSchema: {
|
|
5446
6513
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
5447
6514
|
engine: z.enum(DRUM_SYNTH_ENGINE_NAMES).describe("Engine name. 'Off' disables the synth voice (sample-only cell)."),
|
|
6515
|
+
mode: z
|
|
6516
|
+
.string()
|
|
6517
|
+
.optional()
|
|
6518
|
+
.describe(`Engine mode name, case-insensitive: ${DRUM_SYNTH_MODE_ROSTER}. Other engines have none. Omitted: kept on the same engine, the first mode on a change.`),
|
|
6519
|
+
hold: z
|
|
6520
|
+
.boolean()
|
|
6521
|
+
.optional()
|
|
6522
|
+
.describe(`Sustain each hit while its note is held, then decay (cell{N}_synthHold). Sustains only on ${DRUM_SYNTH_HOLD_ENGINES}; a trigger_midi note-on then rings until its noteOn:false.`),
|
|
5448
6523
|
volume: z.number().min(-60).max(12).optional().describe("Synth output level in dB (default 0)."),
|
|
5449
6524
|
p1: z.number().min(0).max(1).optional().describe("Param 1 (engine-specific; usually Tune). Overrides the engine default."),
|
|
5450
6525
|
p2: z.number().min(0).max(1).optional().describe("Param 2 (engine-specific)."),
|
|
@@ -5455,7 +6530,7 @@ server.registerTool("set_drum_synth", {
|
|
|
5455
6530
|
p7: z.number().min(0).max(1).optional().describe("Param 7 (engine-specific)."),
|
|
5456
6531
|
p8: z.number().min(0).max(1).optional().describe("Param 8 (engine-specific)."),
|
|
5457
6532
|
},
|
|
5458
|
-
}, async ({ cellId, engine, volume, ...overrides }) => {
|
|
6533
|
+
}, async ({ cellId, engine, mode, hold, volume, ...overrides }) => {
|
|
5459
6534
|
const check = await requireBridge();
|
|
5460
6535
|
if (check !== true)
|
|
5461
6536
|
return check;
|
|
@@ -5469,10 +6544,17 @@ server.registerTool("set_drum_synth", {
|
|
|
5469
6544
|
const engineId = DRUM_SYNTH_ENGINE_NAMES.indexOf(engine);
|
|
5470
6545
|
if (engineId < 0)
|
|
5471
6546
|
return error(`Unknown drum synth engine: ${engine}`);
|
|
6547
|
+
const desc = drumSynth.getEngine(engineId);
|
|
6548
|
+
const modes = desc?.modes ?? [];
|
|
6549
|
+
const modeId = mode === undefined ? -1 : modes.findIndex((m) => m.name.toLowerCase() === mode.trim().toLowerCase());
|
|
6550
|
+
if (mode !== undefined && modeId < 0) {
|
|
6551
|
+
return error(modes.length > 0
|
|
6552
|
+
? `Unknown ${engine} mode: ${mode}. Valid modes: ${modes.map((m) => m.name).join(", ")}.`
|
|
6553
|
+
: `${engine} has no engine modes. Modes exist on ${DRUM_SYNTH_MODE_ROSTER}.`);
|
|
6554
|
+
}
|
|
5472
6555
|
const stateRaw = await bridge.send("getState");
|
|
5473
6556
|
const state = isRecord(stateRaw) ? stateRaw : {};
|
|
5474
6557
|
const delta = drumSynth.buildEngineSwitchDelta(cellId, engineId, state);
|
|
5475
|
-
const desc = drumSynth.getEngine(engineId);
|
|
5476
6558
|
if (desc) {
|
|
5477
6559
|
for (const [key, value] of Object.entries(overrides)) {
|
|
5478
6560
|
if (value === undefined)
|
|
@@ -5484,10 +6566,26 @@ server.registerTool("set_drum_synth", {
|
|
|
5484
6566
|
}
|
|
5485
6567
|
if (volume !== undefined)
|
|
5486
6568
|
delta[`cell${cellId}_synthVolume`] = volume;
|
|
5487
|
-
|
|
5488
|
-
|
|
6569
|
+
if (modeId >= 0)
|
|
6570
|
+
delta[`cell${cellId}_synthMode`] = modeId;
|
|
6571
|
+
if (hold !== undefined)
|
|
6572
|
+
delta[`cell${cellId}_synthHold`] = hold ? 1 : 0;
|
|
6573
|
+
// synthEngine is a graph-render key → needs a graph dispatch (STATE_UPDATE_KV_OPTIONS).
|
|
6574
|
+
await applyKv(delta, STATE_UPDATE_KV_OPTIONS);
|
|
5489
6575
|
const labels = desc ? desc.params.map((p) => p.label).join(",") : "—";
|
|
5490
|
-
|
|
6576
|
+
let summary = `Cell ${cellId} drum synth → ${engine}${engineId > 0 ? ` (p1..pN = ${labels})` : ""}.`;
|
|
6577
|
+
if (modeId >= 0)
|
|
6578
|
+
summary += ` Mode ${modes[modeId].name}.`;
|
|
6579
|
+
if (hold === false)
|
|
6580
|
+
summary += " Hold off.";
|
|
6581
|
+
if (hold === true) {
|
|
6582
|
+
const playedMode = Math.round(Number(delta[`cell${cellId}_synthMode`] ?? state[`cell${cellId}_synthMode`] ?? 0));
|
|
6583
|
+
summary += drumSynth.holdsInMode(engineId, playedMode)
|
|
6584
|
+
? " Hold on: each hit sustains while its note is held."
|
|
6585
|
+
: ` Hold on, but ${engine}${modes.length > 0 ? ` in mode ${modes[playedMode]?.name ?? modes[0].name}` : ""} ` +
|
|
6586
|
+
`does not sustain; it takes effect on ${DRUM_SYNTH_HOLD_ENGINES}.`;
|
|
6587
|
+
}
|
|
6588
|
+
return text(summary);
|
|
5491
6589
|
});
|
|
5492
6590
|
server.registerTool("set_drum_synth_wave", {
|
|
5493
6591
|
title: "Set Drum Synth Wave Curve (Wave engine)",
|
|
@@ -5545,7 +6643,7 @@ const playerSlotArg = (n) => z
|
|
|
5545
6643
|
server.registerTool("list_players", {
|
|
5546
6644
|
title: "List Generative Players",
|
|
5547
6645
|
description: "Catalogue of PAM's generative Players — per-cell generators that replace a cell's drawn pattern with a " +
|
|
5548
|
-
"seeded, transport-synced cycle (EUCLID, KIT, TURING, WALK, MARKOV, RATCHET, PHASE, RIFF, MASK). Returns each " +
|
|
6646
|
+
"seeded, transport-synced cycle (EUCLID, KIT, TURING, WALK, MARKOV, RATCHET, PHASE, RIFF, MASK, PERC). Returns each " +
|
|
5549
6647
|
"player's blurb, defaults, P-slot vocabulary (detented positions or 0..1 knobs) and curated preset names, plus " +
|
|
5550
6648
|
"the role spread (which player each cell arms by default). Offline — no bridge needed. Arm one with set_player.",
|
|
5551
6649
|
inputSchema: {
|
|
@@ -5580,13 +6678,29 @@ server.registerTool("list_players", {
|
|
|
5580
6678
|
],
|
|
5581
6679
|
});
|
|
5582
6680
|
});
|
|
6681
|
+
/** The plugin's default-source rule (PlayerListenSection): a cell worth hearing runs a Player or has drawn
|
|
6682
|
+
* notes, and for Harmonic its notes are pitches (`cellNotesArePitches`). */
|
|
6683
|
+
function cellSounds(state, cells, id, mode, midiProduct) {
|
|
6684
|
+
const raw = cells[String(id)];
|
|
6685
|
+
const cell = isRecord(raw) ? raw : {};
|
|
6686
|
+
const drawn = state[`cell${id}_midiSequence`];
|
|
6687
|
+
const sounds = cellPlayerType(cell) > 0 || (Array.isArray(drawn) && drawn.length > 0);
|
|
6688
|
+
if (!sounds || mode !== 3)
|
|
6689
|
+
return sounds;
|
|
6690
|
+
const mono = !midiProduct && Math.round(Number(cell.isPoly ?? 0)) !== 1;
|
|
6691
|
+
const drumPlayer = getPlayerSpec(cellPlayerType(cell))?.tag === "drum";
|
|
6692
|
+
return !mono && (!drumPlayer || Math.round(Number(cell.playerPatternMode ?? 0)) === 1);
|
|
6693
|
+
}
|
|
5583
6694
|
server.registerTool("set_player", {
|
|
5584
6695
|
title: "Set Generative Player",
|
|
5585
6696
|
description: "Arm, edit or switch off a cell's generative Player (PAM-only). Passing `player` arms that generator with the " +
|
|
5586
6697
|
"roster's own voicing (or a curated `preset`), the track genre on genre-aware players, and a reversible poly " +
|
|
5587
6698
|
"arm on melodic ones; omit `player` to edit the running player's values in place. Values land in both the cell " +
|
|
5588
6699
|
"config and the cell{N}_player* automation params, like the plugin UI. 'off' restores the drawn pattern and the " +
|
|
5589
|
-
"cell's pre-arm polyphony.
|
|
6700
|
+
"cell's pre-arm polyphony and scale. A melodic arm in the plugin UI also gives a chromatic cell the house or song " +
|
|
6701
|
+
"scale; here the scale stays update_cell_config's (scaleName/scaleRoot). Pass p1 to choose PERC's drum; without it " +
|
|
6702
|
+
"PERC arms on Conga. A plugin-UI arm also takes the cell's snap as the grid when the cell never chose one; " +
|
|
6703
|
+
"here pass grid, or the cell keeps its own (1/4 when unset). The player sounds only while the transport runs. The UI shows a running player " +
|
|
5590
6704
|
"unprompted; to arm from the UI as well, update_settings { experimentalMode: true, experimentalGenerativePatterns: true }.",
|
|
5591
6705
|
inputSchema: {
|
|
5592
6706
|
cellIds: z
|
|
@@ -5616,19 +6730,33 @@ server.registerTool("set_player", {
|
|
|
5616
6730
|
patternMode: z
|
|
5617
6731
|
.enum(PATTERN_MODES)
|
|
5618
6732
|
.optional()
|
|
5619
|
-
.describe("'mask' (default): the player replaces the drawn pattern. 'keep': the drawn pattern keeps sounding and the player layers over it."),
|
|
6733
|
+
.describe("'mask' (default, shown as Replace in the UI): the player replaces the drawn pattern. 'keep' (shown as Layer): the drawn pattern keeps sounding and the player layers over it."),
|
|
6734
|
+
grid: z
|
|
6735
|
+
.enum(PLAYER_GRIDS)
|
|
6736
|
+
.optional()
|
|
6737
|
+
.describe("The step the player generates on, named as the snap menu names it: 1/N of a beat, so '1/4' is sixteenths (the default), " +
|
|
6738
|
+
"'1/3' eighth-note triplets, '1/2' eighths. A cycle holds at most 128 steps; a longer pattern stretches the step. " +
|
|
6739
|
+
"Swing is off on 1/3, 1/5 and 1/9. Kept per cell; it never follows the snap menu."),
|
|
5620
6740
|
listenMode: z
|
|
5621
6741
|
.enum(LISTEN_MODES)
|
|
5622
6742
|
.optional()
|
|
5623
|
-
.describe("Ensemble listening
|
|
5624
|
-
|
|
5625
|
-
|
|
6743
|
+
.describe("Ensemble listening on the source's last completed cycle: complement gives way where it played and fills its rests, " +
|
|
6744
|
+
"answer replies half a phrase later, harmonic favours the notes it sounds. A soft source note pushes less than the loudest notes the cell hears. " +
|
|
6745
|
+
"Switching listening on without listenSource hears the cell's role partner (the bass hears the kick), as the plugin UI does."),
|
|
6746
|
+
listenSource: z.number().int().min(0).max(8).optional().describe("Listening source: 0 = all other cells, 1-8 = one cell (a cell's own number is skipped)."),
|
|
6747
|
+
listenAmount: z.number().min(0).max(1).optional().describe("Listening strength 0..1: a nudge low down; at 1 the relationship decides which candidates play."),
|
|
6748
|
+
listenPair: z
|
|
6749
|
+
.enum(LISTEN_PAIRS)
|
|
6750
|
+
.optional()
|
|
6751
|
+
.describe("Two cells that complement each other: 'keep' gives this cell the steps they share (its partner gives way), " +
|
|
6752
|
+
"'trade' never settles (they swap those steps every cycle), 'auto' (default) lets the lower cell keep them. " +
|
|
6753
|
+
"Written to both cells of the pair; refused while a cell complements several cells."),
|
|
5626
6754
|
poly: z
|
|
5627
6755
|
.boolean()
|
|
5628
6756
|
.optional()
|
|
5629
6757
|
.describe("Override the automatic poly arm: true forces poly, false keeps the cell mono even for a melodic player."),
|
|
5630
6758
|
},
|
|
5631
|
-
}, async ({ cellIds, player, preset, genre, density, mutate, p1, p2, p3, p4, p5, seed, patternMode, listenMode, listenSource, listenAmount, poly }) => {
|
|
6759
|
+
}, async ({ cellIds, player, preset, genre, density, mutate, p1, p2, p3, p4, p5, seed, patternMode, grid, listenMode, listenSource, listenAmount, listenPair, poly }) => {
|
|
5632
6760
|
const check = await requireBridgeForCells(cellIds);
|
|
5633
6761
|
if (check !== true)
|
|
5634
6762
|
return check;
|
|
@@ -5636,6 +6764,7 @@ server.registerTool("set_player", {
|
|
|
5636
6764
|
const gate = playerProductError(manifest);
|
|
5637
6765
|
if (gate)
|
|
5638
6766
|
return gate;
|
|
6767
|
+
const midiProduct = !!manifest && isMsqManifest(manifest);
|
|
5639
6768
|
const playerRef = typeof player === "string" ? player.trim().toLowerCase() : player;
|
|
5640
6769
|
const wantsOff = playerRef === "off" || playerRef === 0;
|
|
5641
6770
|
const wantsRole = playerRef === "role";
|
|
@@ -5670,11 +6799,20 @@ server.registerTool("set_player", {
|
|
|
5670
6799
|
config.isPoly = prePoly;
|
|
5671
6800
|
if (preRoot !== undefined)
|
|
5672
6801
|
config.polyRootNote = preRoot;
|
|
6802
|
+
// The scale a melodic arm seeded in the plugin UI goes back with the player.
|
|
6803
|
+
const preScale = typeof cell.playerPreScale === "string" && cell.playerPreScale !== NO_SCALE_ARM ? cell.playerPreScale : undefined;
|
|
6804
|
+
if (preScale !== undefined) {
|
|
6805
|
+
config.scaleName = preScale;
|
|
6806
|
+
config.scaleRoot = recorded("playerPreScaleRoot") ?? 0;
|
|
6807
|
+
}
|
|
5673
6808
|
config.playerPrePoly = NO_POLY_ARM;
|
|
5674
6809
|
config.playerPreRoot = NO_POLY_ARM;
|
|
6810
|
+
config.playerPreScale = NO_SCALE_ARM;
|
|
6811
|
+
config.playerPreScaleRoot = NO_POLY_ARM;
|
|
5675
6812
|
cellsDelta[String(cellId)] = config;
|
|
5676
6813
|
lines.push(`Cell ${cellId}: ${getPlayerSpec(currentType)?.name ?? "player"} off — drawn pattern restored` +
|
|
5677
|
-
`${prePoly !== undefined || preRoot !== undefined ? ", pre-arm polyphony restored" : ""}
|
|
6814
|
+
`${prePoly !== undefined || preRoot !== undefined ? ", pre-arm polyphony restored" : ""}` +
|
|
6815
|
+
`${preScale !== undefined ? ", pre-arm scale restored" : ""}.`);
|
|
5678
6816
|
continue;
|
|
5679
6817
|
}
|
|
5680
6818
|
const roleConfig = wantsRole ? rolePlayerConfigFor(cellId) : undefined;
|
|
@@ -5726,12 +6864,23 @@ server.registerTool("set_player", {
|
|
|
5726
6864
|
config.playerSeed = seed === "random" ? randomPlayerSeed() : Math.round(seed);
|
|
5727
6865
|
if (patternMode)
|
|
5728
6866
|
config.playerPatternMode = PATTERN_MODES.indexOf(patternMode);
|
|
6867
|
+
if (grid)
|
|
6868
|
+
config.playerGrid = playerGridValue(grid);
|
|
5729
6869
|
if (listenMode)
|
|
5730
6870
|
config.playerListenMode = LISTEN_MODES.indexOf(listenMode);
|
|
5731
|
-
if (listenSource !== undefined)
|
|
6871
|
+
if (listenSource !== undefined && listenSource !== cellId)
|
|
5732
6872
|
config.playerListenSource = listenSource;
|
|
6873
|
+
// Switching listening on with no source named hears the role partner, never the whole rack by default.
|
|
6874
|
+
const startsListening = (listenSource === undefined || listenSource === cellId) && (config.playerListenMode ?? 0) > 0 &&
|
|
6875
|
+
Math.round(Number(cell.playerListenMode ?? 0)) <= 0 && Math.round(Number(cell.playerListenSource ?? 0)) === 0;
|
|
6876
|
+
if (startsListening) {
|
|
6877
|
+
const mode = config.playerListenMode;
|
|
6878
|
+
config.playerListenSource = listenPartnerFor(cellId, mode, 8, (id) => cellSounds(state, cellsObj, id, mode, midiProduct));
|
|
6879
|
+
}
|
|
5733
6880
|
if (listenAmount !== undefined)
|
|
5734
6881
|
config.playerListenAmount = listenAmount;
|
|
6882
|
+
if (listenPair)
|
|
6883
|
+
config.playerListenPair = LISTEN_PAIRS.indexOf(listenPair);
|
|
5735
6884
|
// Reversible poly arm: record the pre-arm values once, never the armed state.
|
|
5736
6885
|
const autoPoly = arming && poly !== false && (isMelodicPlayer(spec) || roleConfig?.isPoly === 1);
|
|
5737
6886
|
let polyNote = "";
|
|
@@ -5771,16 +6920,74 @@ server.registerTool("set_player", {
|
|
|
5771
6920
|
parts.push(`seed ${config.playerSeed}`);
|
|
5772
6921
|
if (patternMode)
|
|
5773
6922
|
parts.push(`pattern ${patternMode}`);
|
|
6923
|
+
if (grid)
|
|
6924
|
+
parts.push(`grid ${grid}`);
|
|
5774
6925
|
if (listenMode)
|
|
5775
|
-
parts.push(`listen ${listenMode}`);
|
|
6926
|
+
parts.push(`listen ${listenMode}${config.playerListenSource !== undefined ? ` to ${config.playerListenSource > 0 ? `cell ${config.playerListenSource}` : "all"}` : ""}`);
|
|
6927
|
+
if (listenSource === cellId)
|
|
6928
|
+
parts.push(`source ${cellId} skipped (a cell cannot listen to itself)`);
|
|
6929
|
+
if (listenPair)
|
|
6930
|
+
parts.push(`pair ${listenPair}`);
|
|
5776
6931
|
lines.push(`Cell ${cellId}: ${spec.name} ${arming ? "armed" : "updated"} — ${parts.join(", ")}${polyNote}.`);
|
|
5777
6932
|
}
|
|
6933
|
+
// A pair's choice lands on both cells, as the plugin's pair chips write it.
|
|
6934
|
+
if (listenPair) {
|
|
6935
|
+
const cellOf = (id) => {
|
|
6936
|
+
const raw = cellsObj[String(id)];
|
|
6937
|
+
return { ...(isRecord(raw) ? raw : {}), ...(cellsDelta[String(id)] ?? {}) };
|
|
6938
|
+
};
|
|
6939
|
+
const choice = LISTEN_PAIRS.indexOf(listenPair);
|
|
6940
|
+
const partnerChoice = listenPair === "keep" ? LISTEN_PAIRS.indexOf("auto") : choice;
|
|
6941
|
+
for (const cellId of cellIds) {
|
|
6942
|
+
if (!cellsDelta[String(cellId)])
|
|
6943
|
+
continue;
|
|
6944
|
+
const partner = soleComplementPartner(cellOf, cellId, 8);
|
|
6945
|
+
if (partner < 0) {
|
|
6946
|
+
const crowded = crowdedComplementCell(cellOf, cellId, 8);
|
|
6947
|
+
return error(`Cell ${crowded} complements several cells, so one pair choice would settle all of them; give cell ${crowded} one listenSource first.`);
|
|
6948
|
+
}
|
|
6949
|
+
if (partner === 0) {
|
|
6950
|
+
lines.push(`Cell ${cellId}: no cell complements it back yet; its pair choice applies once one does.`);
|
|
6951
|
+
continue;
|
|
6952
|
+
}
|
|
6953
|
+
if (listenPair === "keep" && cellIds.includes(partner))
|
|
6954
|
+
return error(`Cells ${cellId} and ${partner} complement each other: name one of them for listenPair 'keep'.`);
|
|
6955
|
+
cellsDelta[String(partner)] = { ...(cellsDelta[String(partner)] ?? {}), playerListenPair: partnerChoice };
|
|
6956
|
+
if (!cellIds.includes(partner))
|
|
6957
|
+
lines.push(`Cell ${partner}: ${listenPair === "keep" ? `gives cell ${cellId} the steps they share` : `pair ${listenPair}`}.`);
|
|
6958
|
+
}
|
|
6959
|
+
}
|
|
5778
6960
|
if (Object.keys(cellsDelta).length === 0)
|
|
5779
6961
|
return text(lines.join("\n"));
|
|
5780
|
-
await applyKv({ ...flatDelta, cells: cellsDelta },
|
|
6962
|
+
await applyKv({ ...flatDelta, cells: cellsDelta }, CELL_CONFIG_KV_OPTIONS);
|
|
5781
6963
|
return text(`${lines.join("\n")}\n` +
|
|
5782
6964
|
"Players sound only while the transport runs; get_design_overview reports each cell's player.");
|
|
5783
6965
|
});
|
|
6966
|
+
server.registerTool("keep_player", {
|
|
6967
|
+
title: "Keep a Player's Cycles",
|
|
6968
|
+
description: "Commit what a cell's generative Player played into its drawn pattern (PAM-only), through the plugin UI so it is " +
|
|
6969
|
+
"one undo step: 'cycle' merges the cycle playing now, 'previous' the one before it, 'last-two' both, and 'freeze' " +
|
|
6970
|
+
"merges the cycle and switches the Player off. Merges are unions with the drawn notes. Needs the PAM window open " +
|
|
6971
|
+
"and the transport having run a cycle; the commit is asynchronous, so read cell{N}_midiSequence with get_state after.",
|
|
6972
|
+
inputSchema: {
|
|
6973
|
+
cellId: z.number().int().min(1).max(8).describe("The Player cell (1-8)."),
|
|
6974
|
+
keep: z.enum(["cycle", "previous", "last-two", "freeze"]).describe("Which cycles to commit; 'freeze' also switches the Player off."),
|
|
6975
|
+
},
|
|
6976
|
+
}, async ({ cellId, keep }) => {
|
|
6977
|
+
const check = await requireBridgeForCells(cellId);
|
|
6978
|
+
if (check !== true)
|
|
6979
|
+
return check;
|
|
6980
|
+
const gate = playerProductError(await loadManifestForProductGuard());
|
|
6981
|
+
if (gate)
|
|
6982
|
+
return gate;
|
|
6983
|
+
const cells = await bridge.send("getState", "cells").catch(() => undefined);
|
|
6984
|
+
const cell = isRecord(cells) ? cells[String(cellId)] : undefined;
|
|
6985
|
+
if (!(isRecord(cell) && Number(cell.playerType) > 0))
|
|
6986
|
+
return error(`Cell ${cellId} runs no Player — nothing to keep.`);
|
|
6987
|
+
await bridge.send("playerKeep", { cellId, keep });
|
|
6988
|
+
return text(`Cell ${cellId}: ${keep === "freeze" ? "freeze" : `keep ${keep}`} sent to the plugin window. ` +
|
|
6989
|
+
"Nothing lands if that cycle has not played yet; check cell" + cellId + "_midiSequence.");
|
|
6990
|
+
});
|
|
5784
6991
|
/** A cell's sample as a readable file: absolute as given, else relative to a library root. */
|
|
5785
6992
|
async function resolveCellSampleFile(filePath) {
|
|
5786
6993
|
const expanded = expandHome(filePath);
|
|
@@ -5870,7 +7077,7 @@ server.registerTool("set_slices", {
|
|
|
5870
7077
|
};
|
|
5871
7078
|
if (currentKnob < 1 || currentKnob > sliceCount)
|
|
5872
7079
|
delta[`cell${cellId}_slice`] = 1;
|
|
5873
|
-
await applyKv(delta,
|
|
7080
|
+
await applyKv(delta, CELL_CONFIG_KV_OPTIONS);
|
|
5874
7081
|
let sequenceNote = "";
|
|
5875
7082
|
if (sequence) {
|
|
5876
7083
|
const transportRaw = await bridge.send("getTransport").catch(() => undefined);
|
|
@@ -5908,7 +7115,10 @@ server.registerTool("set_slices", {
|
|
|
5908
7115
|
[`cell${cellId}_slice`]: 1,
|
|
5909
7116
|
cells: { [cellId]: { modLatch: 1 } },
|
|
5910
7117
|
__forceRender__: true,
|
|
5911
|
-
},
|
|
7118
|
+
},
|
|
7119
|
+
// Pattern lengths are DAW params and modLatch is cell config: interop's
|
|
7120
|
+
// applySliceSequenceFromBoundaries syncs both, so this must too.
|
|
7121
|
+
{ ...PATTERN_EDIT_KV_OPTIONS, syncConfigResources: true, updateParameters: true });
|
|
5912
7122
|
sequenceNote = ` Chopped into the slice + note lanes over ${patternLength} beats at ${bpm} BPM; start_transport to hear it.`;
|
|
5913
7123
|
}
|
|
5914
7124
|
const positions = resolved.slice(1, -1).map((b) => `${Math.round(b * 1000) / 10}%`);
|
|
@@ -5925,11 +7135,12 @@ server.registerTool("add_param_seq", {
|
|
|
5925
7135
|
cellId: z.number().min(1).max(8).describe("Cell ID (1-8) — used for routing."),
|
|
5926
7136
|
sequence: z
|
|
5927
7137
|
.array(z.number().min(0).max(1))
|
|
7138
|
+
.max(128)
|
|
5928
7139
|
.optional()
|
|
5929
|
-
.describe("Step values (0-1). Defaults to 16 steps of 0.5."),
|
|
7140
|
+
.describe("Step values (0-1), up to 128. Defaults to 16 steps of 0.5."),
|
|
5930
7141
|
strength: z.number().min(0).max(1).default(0.5).describe("Modulation depth (0-1)."),
|
|
5931
7142
|
bipolar: z.union([z.literal(0), z.literal(1)]).default(0).describe("0 = unipolar, 1 = bipolar."),
|
|
5932
|
-
length: z.number().min(
|
|
7143
|
+
length: z.number().min(1 / 64).max(16).default(1).describe("Pattern length in bars."),
|
|
5933
7144
|
speed: z.number().min(0.0625).max(16).default(1).describe("Pattern speed multiplier."),
|
|
5934
7145
|
freeMode: z
|
|
5935
7146
|
.union([z.literal(0), z.literal(1)])
|
|
@@ -5945,13 +7156,25 @@ server.registerTool("add_param_seq", {
|
|
|
5945
7156
|
if (check !== true)
|
|
5946
7157
|
return check;
|
|
5947
7158
|
const index = await getParameterIndex();
|
|
5948
|
-
const param =
|
|
5949
|
-
if (
|
|
5950
|
-
return error(
|
|
5951
|
-
if (!param.isModulatable)
|
|
5952
|
-
return error(formatNotModulatableError(paramId, index));
|
|
7159
|
+
const param = await resolveModulationTarget(paramId, index);
|
|
7160
|
+
if (typeof param === "string")
|
|
7161
|
+
return error(param);
|
|
5953
7162
|
const existing = await bridge.send("getState", "paramSeqs");
|
|
5954
7163
|
const paramSeqs = (Array.isArray(existing) ? existing : []).filter((seq) => seq?.paramId !== paramId);
|
|
7164
|
+
// The target's effect and macro switch on, as the UI's addParamSeq does.
|
|
7165
|
+
const enables = {};
|
|
7166
|
+
if (param.effect && /^cell\d+$/.test(param.cell ?? ""))
|
|
7167
|
+
enables[`${param.cell}_${param.effect}Enabled`] = 1;
|
|
7168
|
+
if (paramId.startsWith("macro_")) {
|
|
7169
|
+
const macrosRaw = await bridge.send("getState", "macros");
|
|
7170
|
+
const macros = Array.isArray(macrosRaw) ? macrosRaw : [];
|
|
7171
|
+
const held = macros.find((m) => m.paramId === paramId);
|
|
7172
|
+
if (held?.enabled !== 1) {
|
|
7173
|
+
enables.macros = held
|
|
7174
|
+
? macros.map((m) => (m === held ? { ...m, enabled: 1 } : m))
|
|
7175
|
+
: [...macros, { paramId, enabled: 1, strength: 0.5, bipolar: 0, mode: 0 }];
|
|
7176
|
+
}
|
|
7177
|
+
}
|
|
5955
7178
|
const newSeq = {
|
|
5956
7179
|
paramId,
|
|
5957
7180
|
cellId,
|
|
@@ -5967,7 +7190,7 @@ server.registerTool("add_param_seq", {
|
|
|
5967
7190
|
stepMode,
|
|
5968
7191
|
};
|
|
5969
7192
|
paramSeqs.push(newSeq);
|
|
5970
|
-
await applyKv({ paramSeqs },
|
|
7193
|
+
await applyKv({ paramSeqs, ...enables }, STATE_UPDATE_KV_OPTIONS);
|
|
5971
7194
|
return text(`Added paramSeq for ${paramId} (${newSeq.sequence.length} steps, strength ${strength}).`);
|
|
5972
7195
|
});
|
|
5973
7196
|
server.registerTool("update_param_seq", {
|
|
@@ -5976,9 +7199,9 @@ server.registerTool("update_param_seq", {
|
|
|
5976
7199
|
"Use add_param_seq first if no paramSeq exists for this paramId.",
|
|
5977
7200
|
inputSchema: {
|
|
5978
7201
|
paramId: z.string().describe("Target parameter ID."),
|
|
5979
|
-
sequence: z.array(z.number().min(0).max(1)).optional().describe("New step values (0-1)."),
|
|
7202
|
+
sequence: z.array(z.number().min(0).max(1)).max(128).optional().describe("New step values (0-1), up to 128."),
|
|
5980
7203
|
strength: z.number().min(0).max(1).optional().describe("Modulation depth (0-1)."),
|
|
5981
|
-
length: z.number().min(
|
|
7204
|
+
length: z.number().min(1 / 64).max(16).optional().describe("Pattern length in bars."),
|
|
5982
7205
|
speed: z.number().min(0.0625).max(16).optional().describe("Pattern speed multiplier."),
|
|
5983
7206
|
bipolar: z.union([z.literal(0), z.literal(1)]).optional().describe("0 = unipolar, 1 = bipolar."),
|
|
5984
7207
|
enabled: z.union([z.literal(0), z.literal(1)]).optional().describe("0 = bypass the sequencer, 1 = run it."),
|
|
@@ -6010,37 +7233,51 @@ server.registerTool("update_param_seq", {
|
|
|
6010
7233
|
return error(`No paramSeq found for ${paramId}. Use add_param_seq first.`);
|
|
6011
7234
|
}
|
|
6012
7235
|
paramSeqs[idx] = { ...paramSeqs[idx], ...cleanUpdates };
|
|
6013
|
-
await applyKv({ paramSeqs },
|
|
7236
|
+
await applyKv({ paramSeqs }, STATE_UPDATE_KV_OPTIONS);
|
|
6014
7237
|
return text(`Updated paramSeq ${paramId}: ${Object.keys(cleanUpdates).join(", ")}`);
|
|
6015
7238
|
});
|
|
6016
7239
|
server.registerTool("set_free_sequencer", {
|
|
6017
7240
|
title: "Set Free Sequencer",
|
|
6018
|
-
description: "Write the Free Sequencer's master step contour (top-level `freeSeqSequence`, 2..
|
|
7241
|
+
description: "Write the Free Sequencer's master step contour (top-level `freeSeqSequence`, 2..128 steps of 0..1). Every " +
|
|
6019
7242
|
"parameter sequencer mapped to it (freeMode:1 via add_param_seq / update_param_seq) follows these steps AND its " +
|
|
6020
7243
|
"clock live, each with its own strength, polarity and smoothing; cell-locked and Gate-mode sequencers keep " +
|
|
6021
7244
|
"their own steps. An empty contour restores the legacy per-sequencer behaviour.",
|
|
6022
7245
|
inputSchema: {
|
|
6023
|
-
sequence: z
|
|
7246
|
+
sequence: z
|
|
7247
|
+
.array(z.number().min(0).max(1))
|
|
7248
|
+
.max(128)
|
|
7249
|
+
.describe("Master step values, 0..1 each: 2..128 steps, or [] to clear the contour."),
|
|
6024
7250
|
},
|
|
6025
7251
|
}, async ({ sequence }) => {
|
|
6026
7252
|
const check = await requireBridge();
|
|
6027
7253
|
if (check !== true)
|
|
6028
7254
|
return check;
|
|
7255
|
+
if (sequence.length === 1)
|
|
7256
|
+
return error("A contour needs 2..128 steps; pass [] to clear it.");
|
|
6029
7257
|
await bridge.send("applyFreeSeqSequenceUpdate", { sequence });
|
|
7258
|
+
if (sequence.length === 0)
|
|
7259
|
+
return text("Free Sequencer contour cleared; mapped sequencers use their own steps.");
|
|
6030
7260
|
return text(`Free Sequencer: ${sequence.length} steps written. Map a paramSeq with freeMode:1 to follow it.`);
|
|
6031
7261
|
});
|
|
7262
|
+
// getMidiExportEvents' cap (MCPBridge::handleGetMidiExportEvents); check-parity compares the two.
|
|
7263
|
+
const MAX_MIDI_EXPORT_BEATS = 1024;
|
|
6032
7264
|
server.registerTool("export_midi", {
|
|
6033
7265
|
title: "Export MIDI",
|
|
6034
|
-
description: "Write the cells'
|
|
6035
|
-
"one track per cell
|
|
6036
|
-
"
|
|
6037
|
-
"
|
|
6038
|
-
|
|
6039
|
-
|
|
6040
|
-
|
|
6041
|
-
|
|
7266
|
+
description: "Write the cells' note patterns to a Standard MIDI File (type 1, 480 PPQ, tempo and meter from the transport): " +
|
|
7267
|
+
"one track per cell, across `bars`. MSQ writes each cell on its output channel (Auto = cell number), leaves " +
|
|
7268
|
+
"out a cell with Send off and notes outside a poly cell's Low-High; PAM writes notes on the cell number and " +
|
|
7269
|
+
"CC on the cell's MIDI out channel. Each sending CC out slot's value opens the track at tick 0, its CC lane's " +
|
|
7270
|
+
"locks and releases follow on the lane's own clock, and a pedal still down at the end is lifted there. A Player cell exports the cycles it " +
|
|
7271
|
+
"generates (density-gated, PATTERN Keep layered), rendered by PAM itself; Listen responses are left out. " +
|
|
7272
|
+
"Grid timing from base values: swing, probability, CTRL macros and modulators are not applied. Nothing " +
|
|
7273
|
+
"changes in PAM. Default location " +
|
|
7274
|
+
"is PAM's Exports folder.",
|
|
7275
|
+
inputSchema: {
|
|
7276
|
+
filename: z.string().optional().describe("Base name without extension (default <product>_midi_<epoch>)."),
|
|
7277
|
+
cellIds: z.array(z.number().int().min(1).max(8)).optional().describe("Cells to include (default: every cell with notes or CC out)."),
|
|
7278
|
+
bars: z.number().min(1).max(64).optional().describe("Length in bars of the transport's meter (default: the longest included pattern cycle, at least 1)."),
|
|
6042
7279
|
mode: z.enum(["combined", "stems"]).optional().describe("'combined' (default): one multi-track file. 'stems': one file per cell."),
|
|
6043
|
-
outputDir: z.string().optional().describe("Directory to write into (default:
|
|
7280
|
+
outputDir: z.string().optional().describe("Directory to write into (default: PAM's Exports folder)."),
|
|
6044
7281
|
},
|
|
6045
7282
|
}, async ({ filename, cellIds, bars, mode, outputDir }) => {
|
|
6046
7283
|
const check = await requireBridgeForCells(cellIds);
|
|
@@ -6049,28 +7286,38 @@ server.registerTool("export_midi", {
|
|
|
6049
7286
|
const manifest = await loadManifestForProductGuard();
|
|
6050
7287
|
if (manifest && isMfxManifest(manifest))
|
|
6051
7288
|
return error("export_midi needs sampler cells with note patterns; MFX has none.");
|
|
6052
|
-
const [stateRaw, transportRaw,
|
|
7289
|
+
const [stateRaw, transportRaw, exportsDir] = await Promise.all([
|
|
6053
7290
|
bridge.send("getState"),
|
|
6054
7291
|
bridge.send("getTransport").catch(() => undefined),
|
|
6055
|
-
|
|
7292
|
+
exportsFolder(),
|
|
6056
7293
|
]);
|
|
6057
7294
|
const state = isRecord(stateRaw) ? stateRaw : {};
|
|
6058
|
-
const
|
|
6059
|
-
|
|
6060
|
-
|
|
6061
|
-
|
|
6062
|
-
|
|
6063
|
-
|
|
6064
|
-
|
|
6065
|
-
}
|
|
6066
|
-
|
|
6067
|
-
|
|
6068
|
-
}
|
|
6069
|
-
}
|
|
7295
|
+
const transport = isRecord(transportRaw) ? transportRaw : {};
|
|
7296
|
+
const bpm = Number(transport.bpm) || Number(state.bpm) || 120;
|
|
7297
|
+
// A build whose getTransport predates the meter writes 4/4, as it always did.
|
|
7298
|
+
const numerator = Math.round(Number(transport.timeSigNumerator));
|
|
7299
|
+
const denominatorPower = Math.round(Math.log2(Number(transport.timeSigDenominator)));
|
|
7300
|
+
const meter = numerator >= 1 && numerator <= 32 && denominatorPower >= 0 && denominatorPower <= 5
|
|
7301
|
+
? { numerator, denominator: 2 ** denominatorPower }
|
|
7302
|
+
: { numerator: 4, denominator: 4 };
|
|
7303
|
+
const barBeats = beatsPerBar(meter);
|
|
7304
|
+
const dir = outputDir ? expandHome(outputDir) : exportsDir;
|
|
6070
7305
|
if (!dir)
|
|
6071
7306
|
return error("Could not resolve PAM's Exports folder from get_default_locations — pass outputDir.");
|
|
6072
7307
|
const maxCell = manifestMaxCellId(manifest) ?? 8;
|
|
6073
7308
|
const ids = (cellIds?.length ? cellIds : Array.from({ length: maxCell }, (_, i) => i + 1)).filter((id) => id <= maxCell);
|
|
7309
|
+
const cellConfig = (id) => {
|
|
7310
|
+
const cells = isRecord(state.cells) ? state.cells : {};
|
|
7311
|
+
const config = cells[String(id)];
|
|
7312
|
+
return isRecord(config) ? config : {};
|
|
7313
|
+
};
|
|
7314
|
+
const playerIds = ids.filter((id) => Number(cellConfig(id).playerType) > 0);
|
|
7315
|
+
// A GRAB follower plays its source's notes, so an empty own grid does not rule it out.
|
|
7316
|
+
const followerIds = ids.filter((id) => Number(cellConfig(id).seqFrom) > 0);
|
|
7317
|
+
const ccIds = ids.filter((id) => CC_OUT_CONFIG_KEYS.some((key) => {
|
|
7318
|
+
const controller = cellConfig(id)[key];
|
|
7319
|
+
return typeof controller === "number" && Number.isInteger(controller) && controller >= 0 && controller <= 119;
|
|
7320
|
+
}));
|
|
6074
7321
|
const cells = ids
|
|
6075
7322
|
.map((id) => ({
|
|
6076
7323
|
id,
|
|
@@ -6078,39 +7325,101 @@ server.registerTool("export_midi", {
|
|
|
6078
7325
|
patternLength: state[`cell${id}_patternLength`],
|
|
6079
7326
|
speed: state[`cell${id}_patternSpeed`],
|
|
6080
7327
|
}))
|
|
6081
|
-
.filter((cell) => Array.isArray(cell.sequence) && cell.sequence.length > 0)
|
|
7328
|
+
.filter((cell) => (Array.isArray(cell.sequence) && cell.sequence.length > 0) ||
|
|
7329
|
+
playerIds.includes(cell.id) ||
|
|
7330
|
+
followerIds.includes(cell.id) ||
|
|
7331
|
+
ccIds.includes(cell.id));
|
|
6082
7332
|
if (cells.length === 0)
|
|
6083
|
-
return error("
|
|
6084
|
-
const
|
|
6085
|
-
const
|
|
6086
|
-
const speedIndex = Math.round(Number(
|
|
7333
|
+
return error("Nothing to export — the selected cells carry no notes, run no Player and send no CC out.");
|
|
7334
|
+
const cycleOf = (length, speed) => {
|
|
7335
|
+
const bars = Number(length);
|
|
7336
|
+
const speedIndex = Math.round(Number(speed));
|
|
6087
7337
|
const mul = Number.isFinite(speedIndex) ? patternSpeedMultiplier(speedIndex) : 1;
|
|
6088
|
-
return (Number.isFinite(
|
|
6089
|
-
}
|
|
6090
|
-
|
|
6091
|
-
const
|
|
6092
|
-
|
|
6093
|
-
|
|
6094
|
-
|
|
6095
|
-
|
|
6096
|
-
|
|
7338
|
+
return (Number.isFinite(bars) && bars > 0 ? bars : 4) / mul / barBeats;
|
|
7339
|
+
};
|
|
7340
|
+
// A CC lane loops on its own clock, so its cycle counts toward the default length too.
|
|
7341
|
+
const cycleBars = cells.flatMap((cell) => [
|
|
7342
|
+
cycleOf(cell.patternLength, cell.speed),
|
|
7343
|
+
...CC_LANES.map(ccLaneIndex)
|
|
7344
|
+
.filter((lane) => {
|
|
7345
|
+
const sequence = state[`cell${cell.id}_ccOut${lane}Sequence`];
|
|
7346
|
+
return Array.isArray(sequence) && sequence.length > 0;
|
|
7347
|
+
})
|
|
7348
|
+
.map((lane) => cycleOf(state[`cell${cell.id}_patternLengthCc${lane}`], state[`cell${cell.id}_patternSpeedCc${lane}`])),
|
|
7349
|
+
]);
|
|
7350
|
+
const maxBars = Math.max(1, Math.floor(MAX_MIDI_EXPORT_BEATS / barBeats));
|
|
7351
|
+
const totalBars = Math.min(maxBars, bars ?? Math.max(1, Math.ceil(Math.max(...cycleBars))));
|
|
7352
|
+
const totalBeats = totalBars * barBeats;
|
|
7353
|
+
// PAM renders the notes itself (Player cycles included); a build without the route
|
|
7354
|
+
// falls back to tiling the drawn grids here. Any other failure is reported, not tiled.
|
|
7355
|
+
let native;
|
|
7356
|
+
try {
|
|
7357
|
+
native = await bridge.send("getMidiExportEvents", { beats: totalBeats, cellIds: cells.map((cell) => cell.id) });
|
|
7358
|
+
}
|
|
7359
|
+
catch (e) {
|
|
7360
|
+
const msg = e.message;
|
|
7361
|
+
if (!/unknown action|does not support/i.test(msg))
|
|
7362
|
+
return error(`export_midi failed: ${msg}`);
|
|
7363
|
+
}
|
|
7364
|
+
const nativeCells = isRecord(native) && Array.isArray(native.cells) ? native.cells.filter(isRecord) : undefined;
|
|
7365
|
+
const staleBuildNote = !nativeCells && playerIds.length > 0
|
|
7366
|
+
? `This PAM build predates Player-aware export: cell ${playerIds.join(", ")} exported its drawn grid, not the Player. Update PAM.`
|
|
7367
|
+
: !nativeCells && ccIds.length > 0
|
|
7368
|
+
? `This PAM build predates CC export: cell ${ccIds.join(", ")} left its CC out behind. Update PAM.`
|
|
7369
|
+
: undefined;
|
|
7370
|
+
const tracks = cells
|
|
7371
|
+
.map((cell) => {
|
|
7372
|
+
const rendered = nativeCells?.find((entry) => Number(entry.cellId) === cell.id);
|
|
7373
|
+
// Only a build without the route lands here without channels; it predates MSQ's output channel.
|
|
7374
|
+
const fallbackChannel = Math.min(16, cell.id);
|
|
7375
|
+
const events = rendered && Array.isArray(rendered.events)
|
|
7376
|
+
? rendered.events.filter(isRecord).map((e) => ({
|
|
7377
|
+
channel: Number(e.channel) || fallbackChannel,
|
|
7378
|
+
note: Number(e.note),
|
|
7379
|
+
velocity: Number(e.velocity),
|
|
7380
|
+
startBeats: Number(e.startBeats),
|
|
7381
|
+
durationBeats: Number(e.durationBeats),
|
|
7382
|
+
}))
|
|
7383
|
+
: tilePatternEvents(cell.sequence, cell.patternLength, cell.speed, totalBeats, fallbackChannel);
|
|
7384
|
+
const cc = rendered && Array.isArray(rendered.cc)
|
|
7385
|
+
? rendered.cc.filter(isRecord).map((e) => ({
|
|
7386
|
+
channel: Number(e.channel) || fallbackChannel,
|
|
7387
|
+
controller: Number(e.controller),
|
|
7388
|
+
value: Number(e.value),
|
|
7389
|
+
beats: Number(e.beats),
|
|
7390
|
+
}))
|
|
7391
|
+
: [];
|
|
7392
|
+
return { name: `Cell ${cell.id}`, events, cc };
|
|
7393
|
+
})
|
|
7394
|
+
.filter((track) => track.events.length > 0 || track.cc.length > 0);
|
|
7395
|
+
if (tracks.length === 0)
|
|
7396
|
+
return error(staleBuildNote ?? "Nothing to export — the selected cells rendered no notes or CC over that length.");
|
|
7397
|
+
const notes = [];
|
|
7398
|
+
if (staleBuildNote)
|
|
7399
|
+
notes.push(staleBuildNote);
|
|
7400
|
+
const listening = playerIds.filter((id) => Number(cellConfig(id).playerListenMode) > 0 && cells.some((cell) => cell.id === id));
|
|
7401
|
+
if (nativeCells && listening.length > 0)
|
|
7402
|
+
notes.push(`Cell ${listening.join(", ")} listens to other cells; the file carries its cycles without that response.`);
|
|
7403
|
+
const product = manifest && isMsqManifest(manifest) ? "msq" : "pam";
|
|
7404
|
+
const base = (filename?.trim() || `${product}_midi_${Date.now()}`).replace(/\.mid$/i, "").replace(/[\\/:]/g, "_");
|
|
6097
7405
|
await mkdir(dir, { recursive: true });
|
|
6098
7406
|
const written = [];
|
|
6099
7407
|
if (mode === "stems") {
|
|
6100
7408
|
for (const track of tracks) {
|
|
6101
7409
|
const path = join(dir, `${base}_cell${track.name.slice(5)}.mid`);
|
|
6102
|
-
await writeFile(path, buildMidiFile(bpm, [track], track.name));
|
|
7410
|
+
await writeFile(path, buildMidiFile(bpm, [track], track.name, meter, totalBeats));
|
|
6103
7411
|
written.push(path);
|
|
6104
7412
|
}
|
|
6105
7413
|
}
|
|
6106
7414
|
else {
|
|
6107
7415
|
const path = join(dir, `${base}.mid`);
|
|
6108
|
-
await writeFile(path, buildMidiFile(bpm, tracks, base));
|
|
7416
|
+
await writeFile(path, buildMidiFile(bpm, tracks, base, meter, totalBeats));
|
|
6109
7417
|
written.push(path);
|
|
6110
7418
|
}
|
|
6111
7419
|
const noteCount = tracks.reduce((sum, track) => sum + track.events.length, 0);
|
|
6112
|
-
|
|
6113
|
-
|
|
7420
|
+
const ccCount = tracks.reduce((sum, track) => sum + track.cc.length, 0);
|
|
7421
|
+
return text(`Exported ${noteCount} notes${ccCount > 0 ? ` and ${ccCount} CC messages` : ""} from ${tracks.length} cell${tracks.length === 1 ? "" : "s"} over ${totalBars} bar${totalBars === 1 ? "" : "s"}${meter.numerator === 4 && meter.denominator === 4 ? "" : ` of ${meter.numerator}/${meter.denominator}`} at ${bpm} BPM.\n` +
|
|
7422
|
+
[...written.map((path) => `File: ${path}`), ...notes].join("\n"));
|
|
6114
7423
|
});
|
|
6115
7424
|
server.registerTool("remove_param_seq", {
|
|
6116
7425
|
title: "Remove Parameter Sequencer",
|
|
@@ -6125,7 +7434,7 @@ server.registerTool("remove_param_seq", {
|
|
|
6125
7434
|
return check;
|
|
6126
7435
|
const existing = await bridge.send("getState", "paramSeqs");
|
|
6127
7436
|
const paramSeqs = (Array.isArray(existing) ? existing : []).filter((seq) => seq?.paramId !== paramId);
|
|
6128
|
-
await applyKv({ paramSeqs },
|
|
7437
|
+
await applyKv({ paramSeqs }, STATE_UPDATE_KV_OPTIONS);
|
|
6129
7438
|
return text(`Removed paramSeq for ${paramId}.`);
|
|
6130
7439
|
});
|
|
6131
7440
|
server.registerTool("normalize_sample", {
|
|
@@ -6138,6 +7447,9 @@ server.registerTool("normalize_sample", {
|
|
|
6138
7447
|
const check = await requireBridgeForCells(cellId);
|
|
6139
7448
|
if (check !== true)
|
|
6140
7449
|
return check;
|
|
7450
|
+
const loaded = await requireCellSample(cellId);
|
|
7451
|
+
if (loaded !== true)
|
|
7452
|
+
return loaded;
|
|
6141
7453
|
await bridge.send("normalizeSampleForCell", cellId);
|
|
6142
7454
|
return text(`Normalized sample in cell ${cellId}.`);
|
|
6143
7455
|
});
|
|
@@ -6157,9 +7469,64 @@ server.registerTool("gain_sample", {
|
|
|
6157
7469
|
const check = await requireBridgeForCells(cellId);
|
|
6158
7470
|
if (check !== true)
|
|
6159
7471
|
return check;
|
|
7472
|
+
const loaded = await requireCellSample(cellId);
|
|
7473
|
+
if (loaded !== true)
|
|
7474
|
+
return loaded;
|
|
6160
7475
|
await bridge.send("gainSampleForCell", { cellId, gainDb });
|
|
6161
7476
|
return text(`Applied ${gainDb > 0 ? "+" : ""}${gainDb} dB to the sample in cell ${cellId}.`);
|
|
6162
7477
|
});
|
|
7478
|
+
// Mirrors pam::repitch::kMaxCoarseSemitones / kMaxFineCents (modules/sampler/SampleRepitch.h).
|
|
7479
|
+
const REPITCH_COARSE_RANGE = 24;
|
|
7480
|
+
const REPITCH_FINE_RANGE = 100;
|
|
7481
|
+
const PITCH_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
|
7482
|
+
const pitchName = (midi) => {
|
|
7483
|
+
const note = Math.round(midi);
|
|
7484
|
+
const cents = Math.round((midi - note) * 100);
|
|
7485
|
+
const name = `${PITCH_NAMES[((note % 12) + 12) % 12]}${Math.floor(note / 12) - 1}`;
|
|
7486
|
+
return cents === 0 ? name : `${name} ${cents > 0 ? "+" : ""}${cents} cents`;
|
|
7487
|
+
};
|
|
7488
|
+
server.registerTool("analyze_sample_pitch", {
|
|
7489
|
+
title: "Analyze Sample Pitch",
|
|
7490
|
+
description: "Detect the fundamental of a cell's single-note sample and suggest the repitch_sample shift that centres it on the nearest C. " +
|
|
7491
|
+
"The sampler's root note is a C, so a sample centred on C is the one the global tuning layer retunes correctly.",
|
|
7492
|
+
inputSchema: {
|
|
7493
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
7494
|
+
},
|
|
7495
|
+
}, async ({ cellId }) => {
|
|
7496
|
+
const check = await requireBridgeForCells(cellId);
|
|
7497
|
+
if (check !== true)
|
|
7498
|
+
return check;
|
|
7499
|
+
const result = (await bridge.send("analyzeSamplePitch", cellId));
|
|
7500
|
+
if (!result?.loaded)
|
|
7501
|
+
return error(`Cell ${cellId} has no readable sample.`);
|
|
7502
|
+
const hz = result.hz ?? 0;
|
|
7503
|
+
if (hz <= 0)
|
|
7504
|
+
return text(`No stable pitch in cell ${cellId}'s sample; tune it by ear with repitch_sample.`);
|
|
7505
|
+
const midi = 69 + 12 * Math.log2(hz / 440);
|
|
7506
|
+
const target = Math.round(midi / 12) * 12;
|
|
7507
|
+
const semitones = Math.round(target - midi) || 0;
|
|
7508
|
+
const cents = Math.round((target - midi - semitones) * 100) || 0;
|
|
7509
|
+
return text(`Cell ${cellId} sounds ${pitchName(midi)} (${hz.toFixed(2)} Hz, confidence ${Math.round((result.confidence ?? 0) * 100)}%). ` +
|
|
7510
|
+
`Nearest C is ${pitchName(target)}: repitch_sample with semitones ${semitones}, cents ${cents}.`);
|
|
7511
|
+
});
|
|
7512
|
+
server.registerTool("repitch_sample", {
|
|
7513
|
+
title: "Repitch Sample",
|
|
7514
|
+
description: "Varispeed a cell's sample by semitones plus cents and render it into a new file (offline, destructive): pitch and length change together, like tape. " +
|
|
7515
|
+
"analyze_sample_pitch suggests the shift that centres a single-note sample on C. The original library file is left unchanged.",
|
|
7516
|
+
inputSchema: {
|
|
7517
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
7518
|
+
semitones: z.number().int().min(-REPITCH_COARSE_RANGE).max(REPITCH_COARSE_RANGE).describe("Coarse shift in semitones."),
|
|
7519
|
+
cents: z.number().min(-REPITCH_FINE_RANGE).max(REPITCH_FINE_RANGE).default(0).describe("Fine shift in cents."),
|
|
7520
|
+
},
|
|
7521
|
+
}, async ({ cellId, semitones, cents }) => {
|
|
7522
|
+
if (semitones === 0 && cents === 0)
|
|
7523
|
+
return error("A zero shift leaves the sample unchanged.");
|
|
7524
|
+
const check = await requireBridgeForCells(cellId);
|
|
7525
|
+
if (check !== true)
|
|
7526
|
+
return check;
|
|
7527
|
+
await bridge.send("repitchSampleForCell", { cellId, semitones, cents });
|
|
7528
|
+
return text(`Repitched the sample in cell ${cellId} by ${semitones} semitones ${cents} cents.`);
|
|
7529
|
+
});
|
|
6163
7530
|
// Poll the offline-render status flag until the export completes or times out.
|
|
6164
7531
|
async function pollExportStatus(maxWaitMs = 180_000, pollMs = 500) {
|
|
6165
7532
|
const t0 = Date.now();
|
|
@@ -6168,6 +7535,7 @@ async function pollExportStatus(maxWaitMs = 180_000, pollMs = 500) {
|
|
|
6168
7535
|
const status = (await bridge.send("getExportStatus"));
|
|
6169
7536
|
if (status.complete) {
|
|
6170
7537
|
lastExportLoudness = status.loudness ?? null;
|
|
7538
|
+
lastExportError = typeof status.error === "string" ? status.error : null;
|
|
6171
7539
|
return status.success ? "success" : "failed";
|
|
6172
7540
|
}
|
|
6173
7541
|
}
|
|
@@ -6175,6 +7543,11 @@ async function pollExportStatus(maxWaitMs = 180_000, pollMs = 500) {
|
|
|
6175
7543
|
}
|
|
6176
7544
|
// Loudness report of the last completed bounce (set by pollExportStatus).
|
|
6177
7545
|
let lastExportLoudness = null;
|
|
7546
|
+
// Native reason for the last failed bounce, when the plugin reports one.
|
|
7547
|
+
let lastExportError = null;
|
|
7548
|
+
function exportFailureDetail() {
|
|
7549
|
+
return lastExportError ? ` ${lastExportError}` : "";
|
|
7550
|
+
}
|
|
6178
7551
|
function loudnessReportLine() {
|
|
6179
7552
|
const l = lastExportLoudness;
|
|
6180
7553
|
if (!l || typeof l.i !== "number")
|
|
@@ -6192,7 +7565,8 @@ function loudnessReportLine() {
|
|
|
6192
7565
|
}
|
|
6193
7566
|
server.registerTool("export_audio", {
|
|
6194
7567
|
title: "Export Audio",
|
|
6195
|
-
description: "Render and export audio from the current preset to a WAV file in PAM's Exports folder. " +
|
|
7568
|
+
description: "Render and export audio from the current preset in a running PAM instance to a WAV file in PAM's Exports folder. " +
|
|
7569
|
+
"For a saved preset with no PAM open, use the paid `preset render` command through `pam mcp`'s `run` tool; it supports master, stems, variations and songs. " +
|
|
6196
7570
|
"Waits for completion. Default mode renders the looping preset for `bars` bars at the current BPM. " +
|
|
6197
7571
|
"Set `mode` to bounce stems or the whole Song Mode arrangement.\n\n" +
|
|
6198
7572
|
"Modes: 'main' (full mix, default) · 'individual' (one cell solo, needs cellId) · " +
|
|
@@ -6250,7 +7624,7 @@ server.registerTool("export_audio", {
|
|
|
6250
7624
|
`Duration: ${durationSecs.toFixed(1)}s (${bars} bars at ${bpm} BPM, ${bitDepth}-bit)` +
|
|
6251
7625
|
loudnessReportLine());
|
|
6252
7626
|
if (outcome === "failed")
|
|
6253
|
-
return error(
|
|
7627
|
+
return error(`Audio export failed.${exportFailureDetail()}`);
|
|
6254
7628
|
return text(`Export timed out. The render may still be running.\nExpected output: ${outputPath}`);
|
|
6255
7629
|
}
|
|
6256
7630
|
// Stem / song-mode modes: build full pass-through options (filename + mode +
|
|
@@ -6281,7 +7655,7 @@ server.registerTool("export_audio", {
|
|
|
6281
7655
|
`File(s) written to ${SERVER_PRODUCT.name}'s Exports folder, base name: ${filename}.wav` +
|
|
6282
7656
|
loudnessReportLine());
|
|
6283
7657
|
if (outcome === "failed")
|
|
6284
|
-
return error(`Export failed (mode '${effectiveMode}')
|
|
7658
|
+
return error(`Export failed (mode '${effectiveMode}').${exportFailureDetail()}`);
|
|
6285
7659
|
return text(`Export timed out (mode '${effectiveMode}'). The render may still be running.`);
|
|
6286
7660
|
});
|
|
6287
7661
|
// ============================================================
|
|
@@ -6333,7 +7707,9 @@ server.registerResource("cell-parameters", new ResourceTemplate(`${SERVER_PRODUC
|
|
|
6333
7707
|
});
|
|
6334
7708
|
server.registerResource("global-parameters", `${SERVER_PRODUCT.resourceScheme}://parameters/global`, {
|
|
6335
7709
|
title: "Global Parameters",
|
|
6336
|
-
description:
|
|
7710
|
+
description: IS_MSQ_SERVER
|
|
7711
|
+
? "Non-cell-specific parameters (LFOs, macros, XY, pattern selectors)."
|
|
7712
|
+
: "Non-cell-specific parameters (volume, crossfade, looper, etc.).",
|
|
6337
7713
|
mimeType: "application/json",
|
|
6338
7714
|
}, async (uri) => {
|
|
6339
7715
|
const params = await getGlobalParameters();
|
|
@@ -6362,7 +7738,7 @@ MFX has four effects cells. Audio reaches a cell only when the cable patch route
|
|
|
6362
7738
|
}],
|
|
6363
7739
|
}));
|
|
6364
7740
|
}
|
|
6365
|
-
else {
|
|
7741
|
+
else if (!IS_MSQ_SERVER) {
|
|
6366
7742
|
server.registerResource("beat-making-guide", "pam://guide/beat-making", {
|
|
6367
7743
|
title: "PAM Beat-Making Guide",
|
|
6368
7744
|
description: "Comprehensive guide for creating beats with PAM. Covers cell roles, slicer for loops, " +
|
|
@@ -6758,12 +8134,37 @@ if (IS_MFX_SERVER) {
|
|
|
6758
8134
|
description: "Diagnose why live tools cannot reach MFX.",
|
|
6759
8135
|
argsSchema: {},
|
|
6760
8136
|
}, () => promptText(`Diagnose the MFX MCP connection:\n\n` +
|
|
6761
|
-
`1. Run connection_status. If no instance appears, start MFX and enable Settings →
|
|
8137
|
+
`1. Run connection_status. If no instance appears, start MFX and enable Settings → Integrations → MCP Bridge.\n` +
|
|
6762
8138
|
`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` +
|
|
6763
8139
|
`3. If an instance appears but cannot connect, it may have closed or be busy with a render; retry once.\n` +
|
|
6764
8140
|
`4. If a live tool is unavailable, update the MFX plugin as well as the mfx-mcp package.`));
|
|
6765
8141
|
}
|
|
6766
|
-
if (
|
|
8142
|
+
if (IS_MSQ_SERVER) {
|
|
8143
|
+
server.registerPrompt("sequence-a-part", {
|
|
8144
|
+
title: "Sequence a Part",
|
|
8145
|
+
description: "Turn a musical brief into cells that drive the host's instruments, then save it.",
|
|
8146
|
+
argsSchema: {
|
|
8147
|
+
brief: z.string().describe("Musical brief, e.g. 'rolling acid bass on channel 2 with a filter sweep'."),
|
|
8148
|
+
},
|
|
8149
|
+
}, ({ brief }) => promptText(`Sequence this in MSQ: "${brief}"\n\n` +
|
|
8150
|
+
`1. connection_status, then get_design_overview for what each cell sends now.\n` +
|
|
8151
|
+
`2. set_bpm, and configure_transport for the time signature if the brief needs one.\n` +
|
|
8152
|
+
`3. Wire each cell with update_cell_config: output channel (0 = Auto, the cell number) matching the instrument that should play it, trigger note, poly and scale, Send on.\n` +
|
|
8153
|
+
`4. Notes: update_pattern (type 'note'), or set_player for a generated part (list_players first); randomize target:'sequences' gives a starting point.\n` +
|
|
8154
|
+
`5. CC: update_cell_config ccOutCc1-16 picks controllers, set_parameters cell{N}_ccOut{K} sets their values, update_pattern with type 'ccOut1'-'ccOut4' draws CC lanes. add_modulation adds movement.\n` +
|
|
8155
|
+
`6. start_transport: notes leave only while it rolls.\n` +
|
|
8156
|
+
`7. save_preset and update_preset_metadata; export_midi writes the parts to a MIDI file.`));
|
|
8157
|
+
server.registerPrompt("troubleshoot-bridge", {
|
|
8158
|
+
title: "Troubleshoot the MSQ Bridge",
|
|
8159
|
+
description: "Diagnose why live tools cannot reach MSQ, or why its instruments stay silent.",
|
|
8160
|
+
argsSchema: {},
|
|
8161
|
+
}, () => promptText(`Diagnose the MSQ MCP connection:\n\n` +
|
|
8162
|
+
`1. Run connection_status. If no instance appears, start MSQ and enable Settings → Integrations → MCP Bridge.\n` +
|
|
8163
|
+
`2. MSQ records its actual port on startup. If an instance appears but cannot connect, it may have closed; retry once.\n` +
|
|
8164
|
+
`3. MSQ makes no sound itself. If nothing plays, check each cell's Send and channel with get_design_overview, that the host routes MSQ's MIDI out to an instrument listening on that channel, and that the transport is rolling.\n` +
|
|
8165
|
+
`4. If a live tool is unavailable, update the MSQ plugin as well as the msq-mcp package.`));
|
|
8166
|
+
}
|
|
8167
|
+
if (SERVER_PRODUCT.id === "pam")
|
|
6767
8168
|
server.registerPrompt("build-a-beat", {
|
|
6768
8169
|
title: "Build a Beat",
|
|
6769
8170
|
description: "Guided kit-building + pattern workflow: coherent samples, genre patterns, transport, refinement, save.",
|
|
@@ -6778,7 +8179,7 @@ if (!IS_MFX_SERVER)
|
|
|
6778
8179
|
`4. start_transport — sequencer output needs the transport rolling.\n` +
|
|
6779
8180
|
`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. For a groove that evolves, set_player (player 'role' across the kit, or KIT / EUCLID / RIFF per cell — list_players first${genre ? `, genre '${genre}'` : ""}).\n` +
|
|
6780
8181
|
`6. save_preset once it works, and update_preset_metadata with genre tags and BPM.`));
|
|
6781
|
-
if (
|
|
8182
|
+
if (SERVER_PRODUCT.id === "pam")
|
|
6782
8183
|
server.registerPrompt("make-a-song", {
|
|
6783
8184
|
title: "Make a Song",
|
|
6784
8185
|
description: "Arrange the current preset into sections with variations + Song Mode, then bounce a full track.",
|
|
@@ -6791,7 +8192,7 @@ if (!IS_MFX_SERVER)
|
|
|
6791
8192
|
`3. Audition: start_transport with Song Mode driving the timeline; queue_variation switches sections live.\n` +
|
|
6792
8193
|
`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` +
|
|
6793
8194
|
`5. The rendered file lands in PAM's Exports/Resamples locations — get_default_locations resolves them.`));
|
|
6794
|
-
if (
|
|
8195
|
+
if (SERVER_PRODUCT.id === "pam")
|
|
6795
8196
|
server.registerPrompt("design-a-preset", {
|
|
6796
8197
|
title: "Design a Preset",
|
|
6797
8198
|
description: "Sound-design workflow from a creative brief to a saved preset.",
|
|
@@ -6805,7 +8206,7 @@ if (!IS_MFX_SERVER)
|
|
|
6805
8206
|
`4. Add movement: add_modulation (LFO / envelope / vary), add_param_seq for stepped motion.\n` +
|
|
6806
8207
|
`5. Write patterns with update_pattern, then start_transport to audition.\n` +
|
|
6807
8208
|
`6. save_preset with a fitting name and update_preset_metadata (category, tags, BPM).`));
|
|
6808
|
-
if (
|
|
8209
|
+
if (SERVER_PRODUCT.id === "pam")
|
|
6809
8210
|
server.registerPrompt("mix-and-master", {
|
|
6810
8211
|
title: "Mix and Master",
|
|
6811
8212
|
description: "Balance the cells and shape the master bus using the analysis + suggestion tools.",
|
|
@@ -6816,13 +8217,13 @@ if (!IS_MFX_SERVER)
|
|
|
6816
8217
|
`3. Shape the master bus with set_master_effects (compressor, tape, limiter — design_context lists the keys).\n` +
|
|
6817
8218
|
`4. Re-check get_master_analysis and iterate toward the loudness target.\n` +
|
|
6818
8219
|
`5. Render with export_audio (mode 'main'; 'all-cells' for stems).`));
|
|
6819
|
-
if (
|
|
8220
|
+
if (SERVER_PRODUCT.id === "pam")
|
|
6820
8221
|
server.registerPrompt("troubleshoot-bridge", {
|
|
6821
8222
|
title: "Troubleshoot the PAM Bridge",
|
|
6822
8223
|
description: "Diagnose why live tools can't reach the plugin.",
|
|
6823
8224
|
argsSchema: {},
|
|
6824
8225
|
}, () => promptText(`Diagnose the PAM MCP connection:\n\n` +
|
|
6825
|
-
`1. connection_status. No instances → PAM isn't running or the bridge is off (PAM Settings →
|
|
8226
|
+
`1. connection_status. No instances → PAM isn't running or the bridge is off (PAM Settings → Integrations → MCP Bridge, default port 9847). Multiple instances → select_instance picks which product the live tools drive.\n` +
|
|
6826
8227
|
`2. Instances found but not connected → the plugin may have shut down, or its message thread is busy (heavy render); retry once.\n` +
|
|
6827
8228
|
`3. Timeouts on WebView-routed tools (video tools, capture_ui, set_drum_synth_wave, randomize target 'synth') → the plugin window must be OPEN.\n` +
|
|
6828
8229
|
`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` +
|
|
@@ -6830,20 +8231,11 @@ if (!IS_MFX_SERVER)
|
|
|
6830
8231
|
// ============================================================
|
|
6831
8232
|
// TOOLS — Looper (state deltas)
|
|
6832
8233
|
// ============================================================
|
|
6833
|
-
// applyStateDelta-equivalent options: full update, dispatch graph, sync KV.
|
|
6834
|
-
const KV_OPTS_LOOPER_STATE = {
|
|
6835
|
-
shouldDispatch: true,
|
|
6836
|
-
syncKVResources: true,
|
|
6837
|
-
syncConfigResources: true,
|
|
6838
|
-
resyncKVAfterDispatch: true,
|
|
6839
|
-
updateParameters: true,
|
|
6840
|
-
broadcastFollowers: true,
|
|
6841
|
-
};
|
|
6842
8234
|
async function applyLoopersDelta(loopers, extra) {
|
|
6843
8235
|
const delta = { loopers, ...(extra ?? {}) };
|
|
6844
8236
|
await bridge.send("applyUnifiedStateKvUpdate", {
|
|
6845
8237
|
delta,
|
|
6846
|
-
options:
|
|
8238
|
+
options: STATE_UPDATE_KV_OPTIONS,
|
|
6847
8239
|
});
|
|
6848
8240
|
}
|
|
6849
8241
|
server.registerTool("arm_looper_track", {
|
|
@@ -7146,7 +8538,7 @@ server.registerTool("looper_settings", {
|
|
|
7146
8538
|
server.registerTool("looper_variation", {
|
|
7147
8539
|
title: "Looper Variation",
|
|
7148
8540
|
description: "Manage looper variation slots (clip-set snapshots). Operations: 'apply' (immediate), 'queue' (bar-synced), " +
|
|
7149
|
-
"'cancel', 'clear', 'setCurrent', 'save' (snapshot
|
|
8541
|
+
"'cancel', 'clear', 'setCurrent', 'save' (write a snapshot object from the `data` field into the slot).",
|
|
7150
8542
|
inputSchema: {
|
|
7151
8543
|
op: z
|
|
7152
8544
|
.enum(["apply", "queue", "cancel", "clear", "setCurrent", "save"])
|
|
@@ -7159,7 +8551,7 @@ server.registerTool("looper_variation", {
|
|
|
7159
8551
|
.describe("Variation slot 0-7 (required for apply/queue/clear/setCurrent/save)."),
|
|
7160
8552
|
intervalBars: z
|
|
7161
8553
|
.number()
|
|
7162
|
-
.min(0.
|
|
8554
|
+
.min(0.25)
|
|
7163
8555
|
.max(64)
|
|
7164
8556
|
.optional()
|
|
7165
8557
|
.describe("Bar interval for 'queue' (required for queue)."),
|
|
@@ -7167,7 +8559,7 @@ server.registerTool("looper_variation", {
|
|
|
7167
8559
|
data: z
|
|
7168
8560
|
.record(z.string(), z.unknown())
|
|
7169
8561
|
.optional()
|
|
7170
|
-
.describe("For 'save': the looper variation snapshot
|
|
8562
|
+
.describe("For 'save' (required): the looper variation snapshot object. Use op 'clear' to empty a slot."),
|
|
7171
8563
|
},
|
|
7172
8564
|
}, async (args) => {
|
|
7173
8565
|
const check = await requireBridge();
|
|
@@ -7204,10 +8596,10 @@ server.registerTool("looper_variation", {
|
|
|
7204
8596
|
case "save":
|
|
7205
8597
|
if (args.index === undefined)
|
|
7206
8598
|
return error("'save' requires index");
|
|
7207
|
-
|
|
7208
|
-
|
|
7209
|
-
|
|
7210
|
-
|
|
8599
|
+
if (!args.data)
|
|
8600
|
+
return error("'save' requires data (a looper variation snapshot); use op 'clear' to empty a slot.");
|
|
8601
|
+
await bridge.send("saveLooperVariation", { index: args.index, data: args.data });
|
|
8602
|
+
return text(`Saved looper variation slot ${args.index}.`);
|
|
7211
8603
|
}
|
|
7212
8604
|
return text("No-op");
|
|
7213
8605
|
});
|
|
@@ -7216,12 +8608,18 @@ server.registerTool("looper_variation_sync", {
|
|
|
7216
8608
|
description: "Configure how looper variation switches synchronize to bar boundaries.",
|
|
7217
8609
|
inputSchema: {
|
|
7218
8610
|
barSyncEnabled: z.boolean().optional().describe("Toggle bar-sync mode."),
|
|
7219
|
-
intervalBars: z
|
|
8611
|
+
intervalBars: z
|
|
8612
|
+
.number()
|
|
8613
|
+
.optional()
|
|
8614
|
+
.describe(`Sync interval in bars: one of ${LOOPER_BAR_SYNC_INTERVALS.join(", ")}.`),
|
|
7220
8615
|
},
|
|
7221
8616
|
}, async ({ barSyncEnabled, intervalBars }) => {
|
|
7222
8617
|
const check = await requireBridge();
|
|
7223
8618
|
if (check !== true)
|
|
7224
8619
|
return check;
|
|
8620
|
+
if (intervalBars !== undefined && !LOOPER_BAR_SYNC_INTERVALS.includes(intervalBars)) {
|
|
8621
|
+
return error(`intervalBars must be one of ${LOOPER_BAR_SYNC_INTERVALS.join(", ")}; native resets anything else to 1.`);
|
|
8622
|
+
}
|
|
7225
8623
|
const updates = [];
|
|
7226
8624
|
if (typeof barSyncEnabled === "boolean") {
|
|
7227
8625
|
await bridge.send("setLooperVariationBarSyncEnabled", barSyncEnabled);
|
|
@@ -7236,6 +8634,18 @@ server.registerTool("looper_variation_sync", {
|
|
|
7236
8634
|
// ============================================================
|
|
7237
8635
|
// TOOLS — Bar-synced variation scheduling + variation hold
|
|
7238
8636
|
// ============================================================
|
|
8637
|
+
// Shared by queue_variation and the song_mode transition schema below.
|
|
8638
|
+
const swapSpecSchema = z.object({
|
|
8639
|
+
order: z
|
|
8640
|
+
.array(z.number().int().min(1).max(16))
|
|
8641
|
+
.refine((order) => new Set(order).size === order.length, { message: "swap.order must not repeat a cell" })
|
|
8642
|
+
.optional()
|
|
8643
|
+
.describe("Cell Swap hand-over order as 1-based cell numbers (leftmost swaps first); unlisted cells follow in ascending order. Omit or [] for left to right."),
|
|
8644
|
+
timing: z
|
|
8645
|
+
.enum(["immediate", "step", "bar"])
|
|
8646
|
+
.optional()
|
|
8647
|
+
.describe("When a cell whose threshold the curve crossed hands over: 'immediate' at once (default), 'step' at the next 16th-note step, 'bar' at the next bar line, both counted back from the transition's anchor bar."),
|
|
8648
|
+
});
|
|
7239
8649
|
server.registerTool("queue_variation", {
|
|
7240
8650
|
title: "Queue Variation Switch",
|
|
7241
8651
|
description: "Schedule a global variation switch on the next bar boundary. " +
|
|
@@ -7244,7 +8654,7 @@ server.registerTool("queue_variation", {
|
|
|
7244
8654
|
"already-engaged queued-flip window only; a from-idle flip hard-switches).",
|
|
7245
8655
|
inputSchema: {
|
|
7246
8656
|
index: z.number().min(0).max(7).describe("Variation slot 0-7."),
|
|
7247
|
-
intervalBars: z.number().min(0.
|
|
8657
|
+
intervalBars: z.number().min(0.25).max(64).describe("Bars before switching."),
|
|
7248
8658
|
fadeMs: z.number().min(0).max(2000).optional().describe("Crossfade duration in ms. Omit all transition options to inherit the native Bar Sync/Song Mode settings."),
|
|
7249
8659
|
resetPhase: z
|
|
7250
8660
|
.boolean()
|
|
@@ -7257,9 +8667,9 @@ server.registerTool("queue_variation", {
|
|
|
7257
8667
|
.optional()
|
|
7258
8668
|
.describe("Pre-anchor morph window in bars for this flip. Omit all transition options to inherit native settings."),
|
|
7259
8669
|
morphMode: z
|
|
7260
|
-
.enum(["auto", "param", "mix", "seq", "all"])
|
|
8670
|
+
.enum(["auto", "param", "mix", "seq", "all", "swap"])
|
|
7261
8671
|
.optional()
|
|
7262
|
-
.describe("Morph style: 'param' glides continuous params (default); 'mix' equal-power blends the two sections; 'seq' adds a staged pattern dissolve to the glide (old notes lose trigger probability as new notes gain it); 'all' runs glide + dissolve + blend together; 'auto' lets the engine pick — mix when the sections differ in graph topology (samples, synth engines, routing) or when nothing can glide, param otherwise."),
|
|
8672
|
+
.describe("Morph style: 'param' glides continuous params (default); 'mix' equal-power blends the two sections; 'seq' adds a staged pattern dissolve to the glide (old notes lose trigger probability as new notes gain it); 'all' runs glide + dissolve + blend together; 'swap' hands whole cells over one by one across the window (PAM only; order via transition.swap.order); 'auto' lets the engine pick — mix when the sections differ in graph topology (samples, synth engines, routing) or when nothing can glide, param otherwise."),
|
|
7263
8673
|
morphCurve: z
|
|
7264
8674
|
.enum(["linear", "ease", "exp", "log"])
|
|
7265
8675
|
.optional()
|
|
@@ -7270,11 +8680,14 @@ server.registerTool("queue_variation", {
|
|
|
7270
8680
|
mixLaw: z.enum(["equalPower", "equalGain", "correlation"]).optional(),
|
|
7271
8681
|
tail: z.enum(["cut", "fade", "ring"]).optional(),
|
|
7272
8682
|
tailMaxBars: z.number().min(0.25).max(8).optional(),
|
|
8683
|
+
swap: swapSpecSchema
|
|
8684
|
+
.optional()
|
|
8685
|
+
.describe("Cell Swap settings for a swap morph: order (1-based cells, leftmost first; unlisted cells follow ascending) and timing."),
|
|
7273
8686
|
})
|
|
7274
8687
|
.optional()
|
|
7275
8688
|
.describe("Advanced transition settings for this flip: pattern policy for a " +
|
|
7276
|
-
"seq/all morph, blend gain law for a mix/all morph, and the outgoing section's " +
|
|
7277
|
-
"tail. Omitted fields keep the defaults (dissolve, equalPower, fade)."),
|
|
8689
|
+
"seq/all morph, blend gain law for a mix/all morph, the Cell Swap order for a swap morph, and the outgoing section's " +
|
|
8690
|
+
"tail. Omitted fields keep the defaults (dissolve, equalPower, left to right, fade)."),
|
|
7278
8691
|
},
|
|
7279
8692
|
}, async ({ index, intervalBars, fadeMs, resetPhase, morphBars, morphMode, morphCurve, transition }) => {
|
|
7280
8693
|
const check = await requireBridge();
|
|
@@ -7346,14 +8759,25 @@ server.registerTool("variation_hold", {
|
|
|
7346
8759
|
if (check !== true)
|
|
7347
8760
|
return check;
|
|
7348
8761
|
const updates = [];
|
|
8762
|
+
// The native router holds the live values; settings persist them the way the UI's
|
|
8763
|
+
// setVariationHoldEnabled / syncVariationHoldBindings do.
|
|
8764
|
+
const persisted = {};
|
|
7349
8765
|
if (typeof enabled === "boolean") {
|
|
7350
8766
|
await bridge.send("setVariationHoldEnabled", enabled);
|
|
8767
|
+
persisted.variationHoldEnabled = enabled;
|
|
7351
8768
|
updates.push(`enabled=${enabled}`);
|
|
7352
8769
|
}
|
|
7353
8770
|
if (bindings) {
|
|
7354
|
-
|
|
8771
|
+
const slots = Array.from({ length: PRESET_VARIATION_SLOTS }, (_, slot) => bindings[slot] ?? { triggerNote: -1, triggerChannel: 0 });
|
|
8772
|
+
await bridge.send("syncVariationHoldBindings", slots);
|
|
8773
|
+
persisted.variationHoldBindings = slots.map(({ triggerNote, triggerChannel }) => ({
|
|
8774
|
+
triggerNote: triggerNote >= 0 ? triggerNote : null,
|
|
8775
|
+
triggerChannel,
|
|
8776
|
+
}));
|
|
7355
8777
|
updates.push(`bindings=${bindings.length}`);
|
|
7356
8778
|
}
|
|
8779
|
+
if (Object.keys(persisted).length)
|
|
8780
|
+
await bridge.send("applySettingsUpdate", persisted);
|
|
7357
8781
|
if (allNotesOff) {
|
|
7358
8782
|
await bridge.send("variationHoldAllNotesOff");
|
|
7359
8783
|
updates.push("allNotesOff");
|
|
@@ -7440,6 +8864,21 @@ server.registerTool("midi_recording", {
|
|
|
7440
8864
|
}
|
|
7441
8865
|
return text("No-op");
|
|
7442
8866
|
});
|
|
8867
|
+
server.registerTool("session_take", {
|
|
8868
|
+
title: "Session Take",
|
|
8869
|
+
description: "Record a session take: the master WAV plus a log of PAM's main window, rendered later to video by pam-render. " +
|
|
8870
|
+
"PAM only; needs Settings > Experimental > Session Recorder. 'start' needs the window open. 'stop' saves in the " +
|
|
8871
|
+
"background: poll 'status' until phase is 'idle' (saved to folder) or 'failed' (error). Returns the recorder status.",
|
|
8872
|
+
inputSchema: {
|
|
8873
|
+
op: z.enum(["start", "stop", "status"]).describe("Operation."),
|
|
8874
|
+
},
|
|
8875
|
+
}, async ({ op }) => {
|
|
8876
|
+
const check = await requireBridge();
|
|
8877
|
+
if (check !== true)
|
|
8878
|
+
return check;
|
|
8879
|
+
const status = await bridge.send("sessionTake", op);
|
|
8880
|
+
return text(typeof status === "string" ? status : JSON.stringify(status));
|
|
8881
|
+
});
|
|
7443
8882
|
// ============================================================
|
|
7444
8883
|
// TOOLS — Modulation updates (LFO/Env/Vary/Macro/ParamSeq)
|
|
7445
8884
|
// ============================================================
|
|
@@ -7486,7 +8925,7 @@ server.registerTool("update_modulation", {
|
|
|
7486
8925
|
// ============================================================
|
|
7487
8926
|
const morphCurveEnum = z.enum(["linear", "ease", "exp", "log"]);
|
|
7488
8927
|
const transitionSpecSchema = z.object({
|
|
7489
|
-
mode: z.enum(["auto", "param", "mix", "seq", "all"]).optional(),
|
|
8928
|
+
mode: z.enum(["auto", "param", "mix", "seq", "all", "swap"]).optional(),
|
|
7490
8929
|
window: z
|
|
7491
8930
|
.object({
|
|
7492
8931
|
bars: z.number().min(0).max(8).optional(),
|
|
@@ -7508,6 +8947,7 @@ const transitionSpecSchema = z.object({
|
|
|
7508
8947
|
})
|
|
7509
8948
|
.optional(),
|
|
7510
8949
|
phase: z.enum(["inherit", "reset", "continue"]).optional(),
|
|
8950
|
+
swap: swapSpecSchema.optional().describe("Cell Swap settings (mode 'swap'; PAM only)."),
|
|
7511
8951
|
});
|
|
7512
8952
|
const TRANSITION_SPEC_HONESTY = "Advanced transition spec: pattern.policy " +
|
|
7513
8953
|
"('dissolve' prob-morphs events, 'wipe' replaces them at a moving frontier, 'cells' hands " +
|
|
@@ -7515,7 +8955,7 @@ const TRANSITION_SPEC_HONESTY = "Advanced transition spec: pattern.policy " +
|
|
|
7515
8955
|
"correlated sections, 'correlation' auto-adapts), audio.tail ('fade' default, 'cut', 'ring' " +
|
|
7516
8956
|
"lets voices/FX decay up to tailMaxBars), phase ('reset'/'continue' overrides the song's " +
|
|
7517
8957
|
"Reset Phase per cable), window.patternCurve/mixCurve (per-domain easing; patternCurve " +
|
|
7518
|
-
"defaults linear). window.staggerBy (off/cell/category), staggerSpread (0..0.9), staggerOrder (forward/reverse), and relations (off/stepped) shape the parameter glide. Runs on variation sections + queued flips, and on preset destinations " +
|
|
8958
|
+
"defaults linear). window.staggerBy (off/cell/category), staggerSpread (0..0.9), staggerOrder (forward/reverse), and relations (off/stepped) shape the parameter glide. swap.order (1-based cells) sets the Cell Swap hand-over order and swap.timing (immediate/step/bar) when a crossed cell hands over, for mode 'swap', which swaps whole cells one by one along the curve (PAM only). Runs on variation sections + queued flips, and on preset destinations " +
|
|
7519
8959
|
"when the lanePresetSections setting is on (the dual-lane preset section " +
|
|
7520
8960
|
"pipeline; with it off, preset destinations coerce to a param glide). Offline bounces " +
|
|
7521
8961
|
"coerce ring→fade, blend→hard switch. 'get' reads back each cable's resolvedTransition " +
|
|
@@ -7565,7 +9005,7 @@ server.registerTool("song_mode", {
|
|
|
7565
9005
|
sections: z
|
|
7566
9006
|
.array(z.object({
|
|
7567
9007
|
variationIndex: z.number().min(0).max(7).describe("Audio variation slot 0-7 this section plays. With presetPath set, this selects the variation INSIDE that preset's own bank — the two compose, they are not mutually exclusive."),
|
|
7568
|
-
bars: z.number().
|
|
9008
|
+
bars: z.number().int().min(1).optional().describe("Section length in whole bars (default: auto from longest pattern)."),
|
|
7569
9009
|
repeat: z.number().int().min(1).optional().describe("Times to play before advancing (default 1)."),
|
|
7570
9010
|
muted: z.boolean().optional().describe("Pass through without an audio trigger."),
|
|
7571
9011
|
probability: z.number().min(0).max(1).optional().describe("Trigger probability (0-1)."),
|
|
@@ -7582,9 +9022,9 @@ server.registerTool("song_mode", {
|
|
|
7582
9022
|
.optional()
|
|
7583
9023
|
.describe("Morph glide easing; default ease. Ignored without morphBars."),
|
|
7584
9024
|
morphMode: z
|
|
7585
|
-
.enum(["auto", "param", "mix", "seq", "all"])
|
|
9025
|
+
.enum(["auto", "param", "mix", "seq", "all", "swap"])
|
|
7586
9026
|
.optional()
|
|
7587
|
-
.describe("Morph realization; default auto — the engine picks mix when the sections differ in graph topology (samples, synth engines, routing) or when nothing can glide, param otherwise. 'param' glides continuous params; 'mix' DJ-blends stems; 'seq' adds a staged pattern dissolve to the glide; 'all' runs glide + dissolve + blend. Ignored without morphBars."),
|
|
9027
|
+
.describe("Morph realization; default auto — the engine picks mix when the sections differ in graph topology (samples, synth engines, routing) or when nothing can glide, param otherwise. 'param' glides continuous params; 'mix' DJ-blends stems; 'seq' adds a staged pattern dissolve to the glide; 'all' runs glide + dissolve + blend; 'swap' hands whole cells over one by one (PAM only). Ignored without morphBars."),
|
|
7588
9028
|
transition: transitionSpecSchema.optional().describe(TRANSITION_SPEC_HONESTY),
|
|
7589
9029
|
transitionPreset: z
|
|
7590
9030
|
.string()
|
|
@@ -7628,7 +9068,7 @@ server.registerTool("song_mode", {
|
|
|
7628
9068
|
.optional()
|
|
7629
9069
|
.describe("Per-cable morph easing; overrides the destination section's morphCurve."),
|
|
7630
9070
|
morphMode: z
|
|
7631
|
-
.enum(["auto", "param", "mix", "seq", "all"])
|
|
9071
|
+
.enum(["auto", "param", "mix", "seq", "all", "swap"])
|
|
7632
9072
|
.optional()
|
|
7633
9073
|
.describe("Per-cable morph realization; overrides the destination section's morphMode. Same modes as the section field, incl. 'seq' (pattern dissolve) and 'all' (glide + dissolve + blend)."),
|
|
7634
9074
|
transition: transitionSpecSchema.optional().describe("Per-cable " + TRANSITION_SPEC_HONESTY),
|
|
@@ -7808,9 +9248,118 @@ server.registerTool("song_mode", {
|
|
|
7808
9248
|
}
|
|
7809
9249
|
return text("No-op");
|
|
7810
9250
|
});
|
|
9251
|
+
// TOOLS — DJ set (docs/plans/DJ_SINGLE_TIMELINE.md D23)
|
|
9252
|
+
const djBarsSchema = z.union([z.literal(1), z.literal(2), z.literal(4), z.literal(8), z.literal(16)]);
|
|
9253
|
+
// shared/dj/set.schema.json's longest window and ring tail, past song_mode's 8 (check-parity compares them).
|
|
9254
|
+
const DJ_TRANSITION_BARS_MAX = 32;
|
|
9255
|
+
// shared/dj/set.schema.json closes every object, so song_mode's spec is closed here section by section.
|
|
9256
|
+
const djTransitionSpecSchema = transitionSpecSchema
|
|
9257
|
+
.extend({
|
|
9258
|
+
window: transitionSpecSchema.shape.window.unwrap()
|
|
9259
|
+
.extend({ bars: z.number().min(0).max(DJ_TRANSITION_BARS_MAX).optional() })
|
|
9260
|
+
.strict()
|
|
9261
|
+
.optional(),
|
|
9262
|
+
pattern: transitionSpecSchema.shape.pattern.unwrap().strict().optional(),
|
|
9263
|
+
audio: transitionSpecSchema.shape.audio.unwrap()
|
|
9264
|
+
.extend({ tailMaxBars: z.number().min(0.25).max(DJ_TRANSITION_BARS_MAX).optional() })
|
|
9265
|
+
.strict()
|
|
9266
|
+
.optional(),
|
|
9267
|
+
swap: swapSpecSchema.strict().optional().describe("Cell Swap settings (mode 'swap')."),
|
|
9268
|
+
})
|
|
9269
|
+
.strict();
|
|
9270
|
+
const djTransitionSchema = z
|
|
9271
|
+
.object({
|
|
9272
|
+
preset: z.string().min(1).max(64).optional().describe("Transition preset id (drop, rise, break, blend, sweep, fill, or a saved one), stamped first."),
|
|
9273
|
+
spec: djTransitionSpecSchema.optional().describe("Advanced transition spec, merged over the preset one level deep in each section."),
|
|
9274
|
+
bars: z.number().min(0).max(DJ_TRANSITION_BARS_MAX).optional().describe(`Window length in bars, 0 to ${DJ_TRANSITION_BARS_MAX}; 0 is a cut.`),
|
|
9275
|
+
mode: z.enum(["auto", "param", "mix", "seq", "all", "swap"]).optional().describe("AUTO, GLIDE (param), BLEND (mix), STEPS (seq), FULL (all) or SWAP."),
|
|
9276
|
+
curve: morphCurveEnum.optional(),
|
|
9277
|
+
})
|
|
9278
|
+
.strict();
|
|
9279
|
+
const djSetDocumentSchema = z
|
|
9280
|
+
.object({
|
|
9281
|
+
queue: z
|
|
9282
|
+
.array(z
|
|
9283
|
+
.object({
|
|
9284
|
+
presetPath: z.string().min(1).max(4096),
|
|
9285
|
+
variationIndex: z.number().int().min(0).max(7).optional().describe("Variation slot; absent plays the preset's first populated one."),
|
|
9286
|
+
bars: z.number().int().min(1).max(64).optional().describe("The item's own length; absent follows stepBars."),
|
|
9287
|
+
transition: djTransitionSchema.optional().describe("The boundary into this item; on queue[0], used only when it queues behind another NOW."),
|
|
9288
|
+
})
|
|
9289
|
+
.strict())
|
|
9290
|
+
.min(1)
|
|
9291
|
+
.max(65)
|
|
9292
|
+
.describe("queue[0] is NOW; the rest play in order."),
|
|
9293
|
+
stepBars: djBarsSchema.optional().describe("Bars each item plays under Auto (default 4)."),
|
|
9294
|
+
launchBars: djBarsSchema.optional().describe("The grid boundaries land on (default 1)."),
|
|
9295
|
+
auto: z.boolean().optional().describe("Step to the next item every stepBars (default true); off holds NOW until a Go."),
|
|
9296
|
+
autoMix: z.boolean().optional().describe("Auto mix: after its dwell, fill NEXT from the library and go (default false)."),
|
|
9297
|
+
defaultTransition: djTransitionSchema.optional().describe("Every boundary without its own (default AUTO, 8 bars, ease); bars, mode or curve alone edit that default."),
|
|
9298
|
+
tempo: z
|
|
9299
|
+
.object({
|
|
9300
|
+
source: z.enum(["master", "followNext"]).optional().describe("The master tempo, or glide to each NEXT's preset tempo."),
|
|
9301
|
+
glideBars: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(4), z.literal(8)]).optional(),
|
|
9302
|
+
bpm: z.number().min(20).max(400).optional().describe("Master tempo to glide to; refused while a DAW or MIDI clock owns it."),
|
|
9303
|
+
})
|
|
9304
|
+
.strict()
|
|
9305
|
+
.optional(),
|
|
9306
|
+
})
|
|
9307
|
+
.strict();
|
|
9308
|
+
const DJ_SET_BOUNDARY = z.object({}).passthrough();
|
|
9309
|
+
server.registerTool("dj_set", {
|
|
9310
|
+
title: "DJ Set",
|
|
9311
|
+
description: "Load a whole DJ set into PAM's DJ view in one call, or read it back (op 'get'). PAM only; the window must be open " +
|
|
9312
|
+
"and the experimental DJ view on (update_settings {experimentalMode:true, experimentalDjView:true}). queue[0] is " +
|
|
9313
|
+
"NOW. Stopped, the set starts on queue[0] (loading it); playing, nothing is cut: a queue[0] equal to NOW keeps " +
|
|
9314
|
+
"playing and the rest replace the queue, else every item queues behind NOW (nowMatched:false), behind any boundary " +
|
|
9315
|
+
"already under way (kept). With an open set, an omitted top-level field keeps its running value. play:true starts " +
|
|
9316
|
+
"the transport, so with auto (the default) the set runs. Pass `set` or the `presets` shorthand. The reply's `set` " +
|
|
9317
|
+
"is canonical: sending it back changes nothing. Failures are {ok:false, code, error}.",
|
|
9318
|
+
inputSchema: {
|
|
9319
|
+
op: z.enum(["set", "get"]).optional().describe("'set' (default) loads; 'get' reads the open set and takes no other field."),
|
|
9320
|
+
set: djSetDocumentSchema.optional(),
|
|
9321
|
+
presets: z.array(z.string().min(1).max(4096)).min(1).max(65).optional().describe("Shorthand: these presets in order, each at its first populated slot."),
|
|
9322
|
+
play: z.boolean().optional().describe("Start the transport after loading."),
|
|
9323
|
+
},
|
|
9324
|
+
outputSchema: {
|
|
9325
|
+
ok: z.boolean(),
|
|
9326
|
+
open: z.boolean().optional(),
|
|
9327
|
+
playing: z.boolean().optional(),
|
|
9328
|
+
bar: z.number().optional(),
|
|
9329
|
+
bpm: z.number().optional(),
|
|
9330
|
+
timeSig: z.array(z.number()).optional(),
|
|
9331
|
+
set: z.object({}).passthrough().nullable().optional(),
|
|
9332
|
+
now: z.object({ startBar: z.number() }).nullable().optional(),
|
|
9333
|
+
boundaries: z.array(DJ_SET_BOUNDARY).optional(),
|
|
9334
|
+
status: z.string().nullable().optional(),
|
|
9335
|
+
nowMatched: z.boolean().optional(),
|
|
9336
|
+
kept: z.number().optional(),
|
|
9337
|
+
code: z.string().optional(),
|
|
9338
|
+
error: z.string().optional(),
|
|
9339
|
+
},
|
|
9340
|
+
}, async ({ op, set, presets, play }) => {
|
|
9341
|
+
if ((op ?? "set") === "get" ? Boolean(set || presets) || play !== undefined : Boolean(set) === Boolean(presets))
|
|
9342
|
+
return packFailure("dj_invalid_set", op === "get" ? "op 'get' takes no other field" : "Pass exactly one of set and presets.");
|
|
9343
|
+
const check = await requireBridge();
|
|
9344
|
+
if (check !== true)
|
|
9345
|
+
return check;
|
|
9346
|
+
const product = await connectedProduct();
|
|
9347
|
+
if (product === "MFX" || product === "GATE" || product === "MSQ")
|
|
9348
|
+
return packFailure("dj_unsupported_product", `${product} has no DJ view; dj_set is a PAM feature.`);
|
|
9349
|
+
const reply = parseBridgeValue(await bridge.send("djSet", {
|
|
9350
|
+
...(op ? { op } : {}),
|
|
9351
|
+
...(set ? { set } : {}),
|
|
9352
|
+
...(presets ? { presets } : {}),
|
|
9353
|
+
...(play !== undefined ? { play } : {}),
|
|
9354
|
+
}));
|
|
9355
|
+
if (isPackFailure(reply))
|
|
9356
|
+
return packFailure(reply.code, reply.error);
|
|
9357
|
+
return structured(reply);
|
|
9358
|
+
});
|
|
7811
9359
|
server.registerTool("export_song", {
|
|
7812
9360
|
title: "Export Song (Full Track)",
|
|
7813
|
-
description: "Bounce the full Song Mode arrangement — the 'render my whole track' button. " +
|
|
9361
|
+
description: "Bounce the full Song Mode arrangement in a running PAM instance — the 'render my whole track' button. " +
|
|
9362
|
+
"For a saved preset song with no PAM open, use the paid `preset render` command with song:true through `pam mcp`'s `run` tool. " +
|
|
7814
9363
|
"Set up the arrangement first with the `song_mode` tool (prefer op setArrangement), then call this.\n\n" +
|
|
7815
9364
|
"Strategies:\n" +
|
|
7816
9365
|
"• 'live' + includeVideo:true — full A/V render: plays the arrangement in real time, films the VX " +
|
|
@@ -8069,7 +9618,7 @@ server.registerTool("preview_slice", {
|
|
|
8069
9618
|
description: "Audition a single slice of a cell's sample.",
|
|
8070
9619
|
inputSchema: {
|
|
8071
9620
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
8072
|
-
sliceNumber: z.number().min(
|
|
9621
|
+
sliceNumber: z.number().int().min(1).describe("Slice number, 1-based as in the UI (1 = first slice)."),
|
|
8073
9622
|
},
|
|
8074
9623
|
}, async ({ cellId, sliceNumber }) => {
|
|
8075
9624
|
const check = await requireBridgeForCells(cellId);
|
|
@@ -8097,12 +9646,15 @@ server.registerTool("resample_sample_to_bpm", {
|
|
|
8097
9646
|
.optional()
|
|
8098
9647
|
.describe("Target BPM (defaults to current project BPM)."),
|
|
8099
9648
|
},
|
|
8100
|
-
}, async (
|
|
9649
|
+
}, async ({ cellId, sourceBpm, targetBpm }) => {
|
|
8101
9650
|
const check = await requireBridge();
|
|
8102
9651
|
if (check !== true)
|
|
8103
9652
|
return check;
|
|
8104
|
-
await
|
|
8105
|
-
|
|
9653
|
+
const loaded = await requireCellSample(cellId);
|
|
9654
|
+
if (loaded !== true)
|
|
9655
|
+
return loaded;
|
|
9656
|
+
await bridge.send("resampleSampleToBPM", { cellId, sourceBPM: sourceBpm, targetBPM: targetBpm });
|
|
9657
|
+
return text(`Resampled cell ${cellId} to ${targetBpm ?? "the project"} BPM.`);
|
|
8106
9658
|
});
|
|
8107
9659
|
server.registerTool("restore_original_sample", {
|
|
8108
9660
|
title: "Restore Original Sample",
|
|
@@ -8114,6 +9666,9 @@ server.registerTool("restore_original_sample", {
|
|
|
8114
9666
|
const check = await requireBridgeForCells(cellId);
|
|
8115
9667
|
if (check !== true)
|
|
8116
9668
|
return check;
|
|
9669
|
+
const loaded = await requireCellSample(cellId, "originalFilePath");
|
|
9670
|
+
if (loaded !== true)
|
|
9671
|
+
return loaded;
|
|
8117
9672
|
await bridge.send("restoreOriginalSample", { cellId });
|
|
8118
9673
|
return text(`Restored original sample for cell ${cellId}.`);
|
|
8119
9674
|
});
|
|
@@ -8136,7 +9691,7 @@ server.registerTool("export_slice", {
|
|
|
8136
9691
|
description: "Export a single slice of a cell's sample as a standalone WAV.",
|
|
8137
9692
|
inputSchema: {
|
|
8138
9693
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
8139
|
-
sliceIndex: z.number().min(
|
|
9694
|
+
sliceIndex: z.number().int().min(1).describe("Slice number, 1-based as in the UI (1 = first slice)."),
|
|
8140
9695
|
},
|
|
8141
9696
|
}, async ({ cellId, sliceIndex }) => {
|
|
8142
9697
|
const check = await requireBridgeForCells(cellId);
|
|
@@ -8167,10 +9722,11 @@ server.registerTool("live_resample", {
|
|
|
8167
9722
|
.describe("For 'start': destination cell the captured clip lands in. Use an empty/scratch cell for a full-mix bounce."),
|
|
8168
9723
|
bars: z
|
|
8169
9724
|
.number()
|
|
8170
|
-
.
|
|
9725
|
+
.int()
|
|
9726
|
+
.min(1)
|
|
8171
9727
|
.max(64)
|
|
8172
9728
|
.optional()
|
|
8173
|
-
.describe("For 'start':
|
|
9729
|
+
.describe("For 'start': whole bars to capture (max 64; the capture also stops on the 'stop' op)."),
|
|
8174
9730
|
source: z
|
|
8175
9731
|
.enum(["cell", "input", "fullmix"])
|
|
8176
9732
|
.default("cell")
|
|
@@ -8433,15 +9989,20 @@ server.registerTool("list_loaded_plugins", {
|
|
|
8433
9989
|
title: "List Loaded Plugins",
|
|
8434
9990
|
description: "List plugins currently hosted in PAM cells (instrument slot only — FX-side hosting is not exposed). " +
|
|
8435
9991
|
"Each entry: cellId, name, manufacturer, format, " +
|
|
8436
|
-
"path, isInstrument, bypassed, and the plugin's
|
|
8437
|
-
"Use the param `index` with `set_plugin_param
|
|
9992
|
+
"path, isInstrument, bypassed, and the plugin's param list (index, name, label, value, defaultValue, discrete, numSteps, and valueStrings for a discrete param; " +
|
|
9993
|
+
"JUCE's MIDI CC proxy parameters are omitted). Use the param `index` with `set_plugin_param`, and its `modTarget` as the paramId for add_modulation / add_param_seq.",
|
|
8438
9994
|
inputSchema: {},
|
|
8439
9995
|
}, async () => {
|
|
8440
9996
|
const check = await requireBridge();
|
|
8441
9997
|
if (check !== true)
|
|
8442
9998
|
return check;
|
|
8443
9999
|
const raw = await bridge.send("listLoadedPlugins");
|
|
8444
|
-
|
|
10000
|
+
const listed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
10001
|
+
for (const plugin of listed?.loadedPlugins ?? []) {
|
|
10002
|
+
for (const p of plugin.params ?? [])
|
|
10003
|
+
p.modTarget = hostedModTargetId(Number(plugin.cellId), p);
|
|
10004
|
+
}
|
|
10005
|
+
return text(bridgeResultText(listed));
|
|
8445
10006
|
});
|
|
8446
10007
|
server.registerTool("load_plugin", {
|
|
8447
10008
|
title: "Load Plugin Into Cell",
|
|
@@ -8545,7 +10106,7 @@ server.registerTool("update_settings", {
|
|
|
8545
10106
|
description: "Merge a partial settings delta into PAM's plugin settings (the Settings panel state) and apply it live. " +
|
|
8546
10107
|
"Returns the full merged settings object so you can discover available keys. This is the only way to flip " +
|
|
8547
10108
|
"experimental-feature gates from an agent. Common boolean keys: experimentalMode, " +
|
|
8548
|
-
"experimentalPluginHost,
|
|
10109
|
+
"experimentalPluginHost, mcpBridgeEnabled (false closes this bridge after it replies). To enable the plugin host, set BOTH " +
|
|
8549
10110
|
"experimentalMode:true AND the specific sub-flag (e.g. experimentalPluginHost:true) in one call. " +
|
|
8550
10111
|
"Set drumSynthEnabled:false to hide its UI without muting loaded synths. Other keys " +
|
|
8551
10112
|
"include UI/library preferences — call get_state or inspect the returned object for the current set.",
|
|
@@ -8614,7 +10175,7 @@ server.registerTool("configure_transport", {
|
|
|
8614
10175
|
server.registerTool("get_audio_device_setup", {
|
|
8615
10176
|
title: "Get Audio Device Setup",
|
|
8616
10177
|
description: "Get the standalone audio/MIDI device setup (sample rate, buffer size, available and active input/output devices). " +
|
|
8617
|
-
"Returns
|
|
10178
|
+
"Returns {pluginHosted:true} inside a DAW, which owns the device config. Use the device IDs/names from here with set_midi_io.",
|
|
8618
10179
|
inputSchema: {},
|
|
8619
10180
|
annotations: { readOnlyHint: true },
|
|
8620
10181
|
}, async () => {
|
|
@@ -8622,6 +10183,9 @@ server.registerTool("get_audio_device_setup", {
|
|
|
8622
10183
|
if (check !== true)
|
|
8623
10184
|
return check;
|
|
8624
10185
|
const setup = await bridge.send("getAudioDeviceSetup");
|
|
10186
|
+
if (setup === null || (isRecord(setup) && Object.keys(setup).length === 0)) {
|
|
10187
|
+
return json({ pluginHosted: true, note: "The DAW owns the audio and MIDI device setup." });
|
|
10188
|
+
}
|
|
8625
10189
|
return json(setup);
|
|
8626
10190
|
});
|
|
8627
10191
|
server.registerTool("get_memory_report", {
|
|
@@ -8655,35 +10219,76 @@ server.registerTool("set_audio_device", {
|
|
|
8655
10219
|
const check = await requireBridge();
|
|
8656
10220
|
if (check !== true)
|
|
8657
10221
|
return check;
|
|
8658
|
-
await bridge.send("setAudioDevice",
|
|
10222
|
+
await bridge.send("setAudioDevice", deviceName);
|
|
8659
10223
|
return text(`Audio output device set to '${deviceName}'.`);
|
|
8660
10224
|
});
|
|
8661
10225
|
server.registerTool("set_midi_io", {
|
|
8662
10226
|
title: "Set MIDI I/O Devices",
|
|
8663
|
-
description: "Select MIDI input/output devices (standalone).
|
|
8664
|
-
"
|
|
10227
|
+
description: "Select MIDI input/output devices (standalone). get_audio_device_setup lists them under midiInputs / " +
|
|
10228
|
+
"midiOutputs (and each port's device under midiOutputPorts); pass each device's identifier or exact name. " +
|
|
10229
|
+
"Ports B-D are extra outputs a cell can pick with update_cell_config midiOutPort. All fields optional.",
|
|
8665
10230
|
inputSchema: {
|
|
8666
|
-
inputDeviceIds: z
|
|
8667
|
-
|
|
10231
|
+
inputDeviceIds: z
|
|
10232
|
+
.array(z.string())
|
|
10233
|
+
.optional()
|
|
10234
|
+
.describe("MIDI inputs to enable, by identifier or name. Replaces the active set; [] disables all inputs."),
|
|
10235
|
+
outputDeviceId: z.string().optional().describe("MIDI output device, by identifier or name; \"\" selects none."),
|
|
8668
10236
|
outputEnabled: z.boolean().optional().describe("Enable/disable MIDI output."),
|
|
10237
|
+
outputPortDeviceIds: z
|
|
10238
|
+
.array(z.string())
|
|
10239
|
+
.max(3)
|
|
10240
|
+
.optional()
|
|
10241
|
+
.describe("Devices for Ports B, C and D in order, by identifier or name; \"\" leaves a port with no device."),
|
|
8669
10242
|
},
|
|
8670
10243
|
}, async (args) => {
|
|
8671
10244
|
const check = await requireBridge();
|
|
8672
10245
|
if (check !== true)
|
|
8673
10246
|
return check;
|
|
8674
10247
|
const applied = [];
|
|
10248
|
+
// Native enables only exact identifiers and disables every other input, so an
|
|
10249
|
+
// unresolved name must fail here rather than silently switch MIDI off.
|
|
10250
|
+
const devices = args.inputDeviceIds?.length || args.outputDeviceId || args.outputPortDeviceIds?.some(Boolean)
|
|
10251
|
+
? await bridge.send("getMidiDevices").catch(() => undefined)
|
|
10252
|
+
: undefined;
|
|
10253
|
+
const resolveDevices = (kind, requested) => {
|
|
10254
|
+
// Builds without getMidiDevices take identifiers as given.
|
|
10255
|
+
if (!isRecord(devices) || !Array.isArray(devices[kind]))
|
|
10256
|
+
return { ids: requested };
|
|
10257
|
+
const listed = devices[kind].filter((device) => isRecord(device) && typeof device.name === "string" && typeof device.identifier === "string");
|
|
10258
|
+
const ids = [];
|
|
10259
|
+
for (const wanted of requested) {
|
|
10260
|
+
const match = listed.find((device) => device.identifier === wanted || device.name === wanted);
|
|
10261
|
+
if (!match)
|
|
10262
|
+
return { error: `Unknown MIDI device '${wanted}'. Available: ${listed.map((device) => device.name).join(", ") || "none"}.` };
|
|
10263
|
+
ids.push(match.identifier);
|
|
10264
|
+
}
|
|
10265
|
+
return { ids };
|
|
10266
|
+
};
|
|
10267
|
+
const inputs = args.inputDeviceIds?.length ? resolveDevices("midiInputs", args.inputDeviceIds) : { ids: [] };
|
|
10268
|
+
if ("error" in inputs)
|
|
10269
|
+
return error(inputs.error);
|
|
10270
|
+
const output = args.outputDeviceId ? resolveDevices("midiOutputs", [args.outputDeviceId]) : { ids: [""] };
|
|
10271
|
+
if ("error" in output)
|
|
10272
|
+
return error(output.error);
|
|
8675
10273
|
if (args.inputDeviceIds !== undefined) {
|
|
8676
|
-
await bridge.send("setMidiInputDevices",
|
|
8677
|
-
applied.push(`inputs=${
|
|
10274
|
+
await bridge.send("setMidiInputDevices", inputs.ids);
|
|
10275
|
+
applied.push(`inputs=${inputs.ids.length}`);
|
|
8678
10276
|
}
|
|
8679
10277
|
if (args.outputDeviceId !== undefined) {
|
|
8680
|
-
await bridge.send("setMidiOutputDevice",
|
|
8681
|
-
applied.push(`output=${args.outputDeviceId}`);
|
|
10278
|
+
await bridge.send("setMidiOutputDevice", output.ids[0]);
|
|
10279
|
+
applied.push(`output=${args.outputDeviceId || "none"}`);
|
|
8682
10280
|
}
|
|
8683
10281
|
if (args.outputEnabled !== undefined) {
|
|
8684
10282
|
await bridge.send("setMidiOutputEnabled", args.outputEnabled);
|
|
8685
10283
|
applied.push(`outputEnabled=${args.outputEnabled}`);
|
|
8686
10284
|
}
|
|
10285
|
+
for (const [index, wanted] of (args.outputPortDeviceIds ?? []).entries()) {
|
|
10286
|
+
const resolved = wanted ? resolveDevices("midiOutputs", [wanted]) : { ids: [""] };
|
|
10287
|
+
if ("error" in resolved)
|
|
10288
|
+
return error(resolved.error);
|
|
10289
|
+
await bridge.send("setMidiOutputPortDevice", index + 1, resolved.ids[0]);
|
|
10290
|
+
applied.push(`port${"BCD"[index]}=${wanted || "none"}`);
|
|
10291
|
+
}
|
|
8687
10292
|
if (applied.length === 0)
|
|
8688
10293
|
return error("No MIDI I/O fields provided.");
|
|
8689
10294
|
return text(`MIDI I/O set: ${applied.join(", ")}.`);
|
|
@@ -8692,18 +10297,19 @@ server.registerTool("set_midi_mapping", {
|
|
|
8692
10297
|
title: "Set MIDI Action Mapping",
|
|
8693
10298
|
description: "Configure the global MIDI-controller mappings that trigger PAM actions: variation switch, preset change, " +
|
|
8694
10299
|
"looper record, live resample, VX video record toggle, and looper-variation switch. Set the enable flag, " +
|
|
8695
|
-
"MIDI channel, CC number,
|
|
10300
|
+
"MIDI channel, CC number, source (Program Change or CC), or bar-sync for each target. One field per call.",
|
|
8696
10301
|
inputSchema: {
|
|
8697
10302
|
target: z
|
|
8698
10303
|
.enum(["variation", "preset", "looper", "resample", "vxRecord", "looperVariation"])
|
|
8699
10304
|
.describe("Which action's mapping to configure. 'vxRecord' toggles VX video recording — requires the PAM window open (VX renders in the WebView)."),
|
|
8700
10305
|
field: z
|
|
8701
10306
|
.enum(["enabled", "channel", "ccNumber", "source", "barSync"])
|
|
8702
|
-
.describe("Field to set. 'source'
|
|
8703
|
-
"
|
|
10307
|
+
.describe("Field to set. 'source' applies to preset and variation (a variation switch on Program Change takes " +
|
|
10308
|
+
"every program on its channel, so presets no longer load from it there); 'barSync' is " +
|
|
10309
|
+
"variation/looperVariation-only; looper/resample/vxRecord have no source/barSync."),
|
|
8704
10310
|
value: z
|
|
8705
10311
|
.union([z.number(), z.boolean()])
|
|
8706
|
-
.describe("boolean for enabled/barSync; number for channel (1-16), ccNumber (0-127), or source."),
|
|
10312
|
+
.describe("boolean for enabled/barSync; number for channel (1-16), ccNumber (0-127), or source (0 = Program Change, 1 = CC)."),
|
|
8707
10313
|
},
|
|
8708
10314
|
}, async ({ target, field, value }) => {
|
|
8709
10315
|
const check = await requireBridge();
|
|
@@ -8724,7 +10330,11 @@ server.registerTool("delete_preset", {
|
|
|
8724
10330
|
const check = await requireBridge();
|
|
8725
10331
|
if (check !== true)
|
|
8726
10332
|
return check;
|
|
8727
|
-
|
|
10333
|
+
if (isFactoryPresetPath(presetPath))
|
|
10334
|
+
return error("Factory presets are protected and cannot be deleted.");
|
|
10335
|
+
const result = await bridge.send("removePreset", nativePresetPath(presetPath));
|
|
10336
|
+
if (result !== true)
|
|
10337
|
+
return error(`Preset not found or not deletable: ${presetPath}`);
|
|
8728
10338
|
return json(result);
|
|
8729
10339
|
});
|
|
8730
10340
|
server.registerTool("set_preset_factory_status", {
|
|
@@ -8738,7 +10348,7 @@ server.registerTool("set_preset_factory_status", {
|
|
|
8738
10348
|
const check = await requireBridge();
|
|
8739
10349
|
if (check !== true)
|
|
8740
10350
|
return check;
|
|
8741
|
-
const result = await bridge.send("togglePresetFactoryStatus", presetPath, makeFactory);
|
|
10351
|
+
const result = await bridge.send("togglePresetFactoryStatus", nativePresetPath(presetPath), makeFactory);
|
|
8742
10352
|
return json(result);
|
|
8743
10353
|
});
|
|
8744
10354
|
server.registerTool("get_sample_usage", {
|
|
@@ -8763,14 +10373,14 @@ server.registerTool("get_sample_usage", {
|
|
|
8763
10373
|
if (check !== true)
|
|
8764
10374
|
return check;
|
|
8765
10375
|
const result = mode === "presetUsesSamples"
|
|
8766
|
-
? await bridge.send("getPresetSampleUsage", relPath)
|
|
10376
|
+
? await bridge.send("getPresetSampleUsage", nativePresetPath(relPath))
|
|
8767
10377
|
: await bridge.send("getPresetsUsingSample", relPath, isFolder ?? false);
|
|
8768
10378
|
return json(result);
|
|
8769
10379
|
});
|
|
8770
10380
|
server.registerTool("repair_preset_samples", {
|
|
8771
10381
|
title: "Repair Preset Sample Paths",
|
|
8772
10382
|
description: "Fix broken sample references in presets. mode 'fix' auto-relinks moved/renamed samples by searching the " +
|
|
8773
|
-
"library (optionally scoped to one preset). mode 'rescan' re-audits
|
|
10383
|
+
"library (optionally scoped to one preset). mode 'rescan' re-audits the ACTIVE preset for still-missing samples and " +
|
|
8774
10384
|
"reports them. Useful after moving a sample library or importing presets from another machine.",
|
|
8775
10385
|
inputSchema: {
|
|
8776
10386
|
mode: z
|
|
@@ -8779,18 +10389,20 @@ server.registerTool("repair_preset_samples", {
|
|
|
8779
10389
|
presetPath: z
|
|
8780
10390
|
.string()
|
|
8781
10391
|
.optional()
|
|
8782
|
-
.describe("
|
|
10392
|
+
.describe("'fix' only: relative preset path. Omit to scan all presets. 'rescan' always audits the active preset."),
|
|
8783
10393
|
},
|
|
8784
10394
|
}, async ({ mode, presetPath }) => {
|
|
8785
10395
|
const check = await requireBridge();
|
|
8786
10396
|
if (check !== true)
|
|
8787
10397
|
return check;
|
|
8788
10398
|
if (mode === "fix") {
|
|
8789
|
-
const result = await bridge.send("fixPresetSamplePaths", presetPath
|
|
10399
|
+
const result = await bridge.send("fixPresetSamplePaths", presetPath ? nativePresetPath(presetPath) : "");
|
|
8790
10400
|
return json(result);
|
|
8791
10401
|
}
|
|
8792
|
-
await bridge.send("rescanMissingSamples",
|
|
8793
|
-
|
|
10402
|
+
const missing = await bridge.send("rescanMissingSamples", "");
|
|
10403
|
+
if (!Array.isArray(missing))
|
|
10404
|
+
return text("Re-audited the active preset; this build reports the result only in its UI.");
|
|
10405
|
+
return json({ missing, note: missing.length ? undefined : "The active preset has no missing samples." });
|
|
8794
10406
|
});
|
|
8795
10407
|
server.registerTool("preview_sample", {
|
|
8796
10408
|
title: "Preview Sample",
|
|
@@ -8874,16 +10486,16 @@ server.registerTool("remove_plugin_from_blocklist", {
|
|
|
8874
10486
|
// TOOL — ask_assistant (drive the FunctionGemma assistant end-to-end)
|
|
8875
10487
|
// ============================================================
|
|
8876
10488
|
// The in-server twin of the ASK bar: renders the SAME FunctionGemma prompt
|
|
8877
|
-
// (shared
|
|
10489
|
+
// (shared functiongemma-format module + live [ctx] line), generates against a local
|
|
8878
10490
|
// OpenAI-compatible endpoint (llama-server serving the fine-tune), parses the
|
|
8879
10491
|
// call blocks, and executes them through this server's own handlers. Lets an
|
|
8880
10492
|
// MCP client (Claude Code) test the assistant model directly against a live
|
|
8881
10493
|
// PAM. Stateless per call — thread follow-ups by passing back `modelTurn`
|
|
8882
10494
|
// from the previous trace as `history`.
|
|
8883
10495
|
//
|
|
8884
|
-
// Dev-tree only: registered only when the
|
|
10496
|
+
// Dev-tree only: registered only when the FunctionGemma toolpack resolves (the
|
|
8885
10497
|
// published npm package doesn't carry it — the tool would be dead weight).
|
|
8886
|
-
const ASSISTANT_TOOLPACK_PATH = resolve(PACKAGE_ROOT, "../
|
|
10498
|
+
const ASSISTANT_TOOLPACK_PATH = resolve(PACKAGE_ROOT, "../ml-research/out/toolpack.json");
|
|
8887
10499
|
// The assistant's view vocabulary → MainViewTypes (stores/Interface.ts).
|
|
8888
10500
|
// Pinned to the in-plugin executor's VIEW_MAP (assistantExecutor.ts) — one
|
|
8889
10501
|
// vocabulary across tool arg, [ctx] view= field, and this dev twin. null =
|
|
@@ -8908,6 +10520,9 @@ const SET_VIEW_MAIN_VIEW = {
|
|
|
8908
10520
|
export: "Export",
|
|
8909
10521
|
presets: "PresetBrowser",
|
|
8910
10522
|
browser: "PresetBrowser",
|
|
10523
|
+
// The preset browser's pack Store tab, PAM only.
|
|
10524
|
+
store: "PresetBrowser",
|
|
10525
|
+
packs: "PresetBrowser",
|
|
8911
10526
|
// MFX-only surfaces. The handler rejects them for other products.
|
|
8912
10527
|
patch: "MfxRouting",
|
|
8913
10528
|
patchbay: "MfxRouting",
|
|
@@ -8931,11 +10546,19 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
|
|
|
8931
10546
|
.union([z.string(), z.number()])
|
|
8932
10547
|
.optional()
|
|
8933
10548
|
.describe("Main view: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets. " +
|
|
8934
|
-
"MFX also takes patch (the cable patchbay)."),
|
|
10549
|
+
"PAM also takes store (the preset browser's pack Store tab); MFX also takes patch (the cable patchbay)."),
|
|
8935
10550
|
cellId: z.number().int().optional().describe("Cell to select/focus (1-8)."),
|
|
8936
10551
|
gridSize: z.number().optional().describe("How many pads the grid shows: 1, 4, or 8."),
|
|
10552
|
+
panel: z
|
|
10553
|
+
.enum(["sampler", "synth", "pattern", "effects"])
|
|
10554
|
+
.optional()
|
|
10555
|
+
.describe("Cell grid tab to show, on cellId or else every cell: sampler, synth, pattern, effects."),
|
|
10556
|
+
fullscreen: z
|
|
10557
|
+
.enum(["sequencer", "effects", "players", "none"])
|
|
10558
|
+
.optional()
|
|
10559
|
+
.describe("CTRL page rack to show alone (sequencer, effects, players), or none."),
|
|
8937
10560
|
},
|
|
8938
|
-
}, async ({ view: viewArg, cellId, gridSize }) => {
|
|
10561
|
+
}, async ({ view: viewArg, cellId, gridSize, panel, fullscreen }) => {
|
|
8939
10562
|
const check = await requireBridgeForCells(cellId);
|
|
8940
10563
|
if (check !== true)
|
|
8941
10564
|
return check;
|
|
@@ -8953,11 +10576,13 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
|
|
|
8953
10576
|
token = view.trim().toLowerCase();
|
|
8954
10577
|
if (!(token in SET_VIEW_MAIN_VIEW))
|
|
8955
10578
|
return error(`Unknown view: ${view}. Views: cells, mixer, ctrl, looper, song, lfo, envelope, vary, paramseq, video, export, presets, patch.`);
|
|
8956
|
-
|
|
8957
|
-
|
|
8958
|
-
|
|
8959
|
-
|
|
8960
|
-
|
|
10579
|
+
const product = token === "patch" || token === "patchbay" || token === "store" || token === "packs"
|
|
10580
|
+
? await connectedProduct()
|
|
10581
|
+
: undefined;
|
|
10582
|
+
if ((token === "patch" || token === "patchbay") && product && product !== "MFX")
|
|
10583
|
+
return error(`${token} is available in MFX only.`);
|
|
10584
|
+
if ((token === "store" || token === "packs") && (product === "MFX" || product === "MSQ"))
|
|
10585
|
+
return error(`${token} is available in PAM only.`);
|
|
8961
10586
|
applied.view = token;
|
|
8962
10587
|
}
|
|
8963
10588
|
if (gridSize !== undefined) {
|
|
@@ -8970,13 +10595,19 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
|
|
|
8970
10595
|
return error("cellId must be 1-8.");
|
|
8971
10596
|
applied.cellId = cellId;
|
|
8972
10597
|
}
|
|
10598
|
+
if (panel !== undefined)
|
|
10599
|
+
applied.panel = panel;
|
|
10600
|
+
if (fullscreen !== undefined)
|
|
10601
|
+
applied.fullscreen = fullscreen;
|
|
8973
10602
|
if (!Object.keys(applied).length)
|
|
8974
|
-
return error("Provide view, cellId, and/or
|
|
10603
|
+
return error("Provide view, cellId, gridSize, panel and/or fullscreen.");
|
|
8975
10604
|
try {
|
|
8976
10605
|
await bridge.send("setView", {
|
|
8977
10606
|
...(token !== undefined ? { view: token } : {}),
|
|
8978
10607
|
...(cellId !== undefined ? { cellId } : {}),
|
|
8979
10608
|
...(gridSize !== undefined ? { gridSize } : {}),
|
|
10609
|
+
...(panel !== undefined ? { panel } : {}),
|
|
10610
|
+
...(fullscreen !== undefined ? { fullscreen } : {}),
|
|
8980
10611
|
});
|
|
8981
10612
|
return json(applied);
|
|
8982
10613
|
}
|
|
@@ -9001,6 +10632,8 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
|
|
|
9001
10632
|
return error(`set_view failed: ${e2.message}`);
|
|
9002
10633
|
}
|
|
9003
10634
|
delete applied.cellId; // not applied on the fallback path — don't over-claim
|
|
10635
|
+
delete applied.panel;
|
|
10636
|
+
delete applied.fullscreen;
|
|
9004
10637
|
return json(applied);
|
|
9005
10638
|
}
|
|
9006
10639
|
});
|
|
@@ -9011,7 +10644,8 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
|
|
|
9011
10644
|
"FunctionGemma prompt (with the live [ctx] state line), generate via a local llama-server, " +
|
|
9012
10645
|
"parse the tool calls, execute them against PAM, and return the full trace. Requires " +
|
|
9013
10646
|
"llama-server running with the fine-tuned GGUF (--special). For multi-turn tests, pass the " +
|
|
9014
|
-
"previous trace's modelTurn back via history."
|
|
10647
|
+
"previous trace's modelTurn back via history. The prompt keeps the v34 [ctx] line and has no " +
|
|
10648
|
+
"[params] line, so for toolpack 36+ models it differs from the plugin's prompt.",
|
|
9015
10649
|
inputSchema: {
|
|
9016
10650
|
text: z.string().describe("The user command, e.g. 'mute the kick' or '174 bpm dnb kit'."),
|
|
9017
10651
|
endpoint: z
|
|
@@ -9147,16 +10781,23 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
|
|
|
9147
10781
|
result: result.text.length > 400 ? `${result.text.slice(0, 400)}…` : result.text,
|
|
9148
10782
|
isError: result.isError,
|
|
9149
10783
|
});
|
|
10784
|
+
// Same wrapping as the in-plugin executor's toResponsePayload, so a
|
|
10785
|
+
// trace here shows the exact function responses the model sees there.
|
|
9150
10786
|
let payload;
|
|
9151
10787
|
if (result.isError)
|
|
9152
10788
|
payload = { error: result.text.slice(0, 400) };
|
|
9153
10789
|
else {
|
|
9154
10790
|
try {
|
|
9155
10791
|
const parsed = JSON.parse(result.text);
|
|
9156
|
-
|
|
9157
|
-
|
|
9158
|
-
|
|
9159
|
-
|
|
10792
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
10793
|
+
const obj = parsed;
|
|
10794
|
+
if (obj.ok === true)
|
|
10795
|
+
delete obj.ok;
|
|
10796
|
+
payload = result.text.length <= 400 ? obj : { result: result.text.slice(0, 400) };
|
|
10797
|
+
}
|
|
10798
|
+
else {
|
|
10799
|
+
payload = { result: parsed };
|
|
10800
|
+
}
|
|
9160
10801
|
}
|
|
9161
10802
|
catch {
|
|
9162
10803
|
payload = { result: result.text.slice(0, 400) };
|
|
@@ -9245,6 +10886,7 @@ const shutdown = async () => {
|
|
|
9245
10886
|
return;
|
|
9246
10887
|
shuttingDown = true;
|
|
9247
10888
|
bridge.disconnect();
|
|
10889
|
+
await daemon?.close();
|
|
9248
10890
|
try {
|
|
9249
10891
|
await server.close();
|
|
9250
10892
|
}
|