@map-audio/pam-mcp-server 1.22.0 → 1.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -6
- 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 +4 -0
- package/dist/bridge.js +53 -160
- package/dist/bridge.js.map +1 -1
- package/dist/drumSynth.js +1 -1
- package/dist/drumSynth.js.map +1 -1
- package/dist/drumSynthEngines.json +1 -1
- package/dist/fgemma-runner.js +20 -4
- package/dist/fgemma-runner.js.map +1 -1
- 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.d.ts +8 -4
- package/dist/generated/variationSnapshot.js +6 -23
- package/dist/generated/variationSnapshot.js.map +1 -1
- package/dist/generativePlayerPresets.json +70 -0
- package/dist/generativePlayers.json +134 -0
- package/dist/manifest-cell1.json +2 -2
- package/dist/manifest-mfx.json +178 -37
- package/dist/manifest-msq.json +9564 -0
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.js +17 -9
- package/dist/manifest.js.map +1 -1
- package/dist/manifest.json +11227 -2841
- package/dist/mfxCombos.js +1 -1
- package/dist/mfxCombos.js.map +1 -1
- package/dist/midiFile.d.ts +21 -0
- package/dist/midiFile.js +108 -0
- package/dist/midiFile.js.map +1 -0
- package/dist/mixAssist.d.ts +35 -0
- package/dist/mixAssist.js +247 -19
- 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 +89 -0
- package/dist/players.js +235 -0
- package/dist/players.js.map +1 -0
- package/dist/presetSave.d.ts +8 -0
- package/dist/presetSave.js +35 -0
- package/dist/presetSave.js.map +1 -1
- package/dist/sampleSlicing.d.ts +12 -0
- package/dist/sampleSlicing.js +78 -0
- package/dist/sampleSlicing.js.map +1 -0
- package/dist/server.js +1828 -525
- package/dist/server.js.map +1 -1
- package/dist/targetDenylist.json +20 -0
- package/dist/variationSessionKeys.json +34 -1
- package/package.json +5 -4
package/dist/server.js
CHANGED
|
@@ -4,23 +4,29 @@ 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
|
-
import { readFile } from "node:fs/promises";
|
|
12
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
13
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
13
15
|
import { inflateSync } from "node:zlib";
|
|
14
16
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
15
17
|
import { fileURLToPath } from "node:url";
|
|
16
18
|
import { createDrumSynthCatalog, } from "./drumSynth.js";
|
|
17
19
|
import { loadManifest, cellEffectNames, cellEffectOrder, getParameterIndex, getParametersByCell, getGlobalParameters, findPresetDirs, resolvePresetPath, setActiveProductHint, walkPresets, } from "./manifest.js";
|
|
18
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";
|
|
19
22
|
import { buildMixMeasurementFromState, currentFlatFromState, parseMixAnalysisPayload, suggestMix, } from "./mixAssist.js";
|
|
20
|
-
import { normalizePresetLookupPath, selectSaveVariationSourcePath, } from "./presetSave.js";
|
|
23
|
+
import { isFactoryPresetPath, nativePresetPath, normalizePresetLookupPath, parsePresetFile, selectSaveVariationSourcePath, } from "./presetSave.js";
|
|
21
24
|
import { STOP_SEQUENCES, buildGenerationPrompt, parseFunctionCalls, renderContextLine, renderFunctionCall, renderFunctionResponse, truncateAtFabricatedResponse, withContext, } from "./fgemma-format.js";
|
|
22
25
|
import { mcpProductProfile } from "./productProfile.js";
|
|
23
26
|
import { MFX_COMBO_COUNT, MFX_COMBO_MANUAL_STRENGTH, buildMfxComboTarget, isMfxComboTargetParameter, mfxComboTargetValueFromCurrent, parseMfxComboSlots, reanchorMfxComboTarget, resolveMfxCombos, setMfxComboTargetStop, } from "./mfxCombos.js";
|
|
27
|
+
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";
|
|
28
|
+
import { buildMidiFile, patternSpeedMultiplier, tilePatternEvents } from "./midiFile.js";
|
|
29
|
+
import { MAX_SLICES, detectOnsetBoundaries, equalBoundaries, normalizeBoundaries } from "./sampleSlicing.js";
|
|
24
30
|
// --- State ---
|
|
25
31
|
// Variation slot count. Must stay in sync with PRESET_VARIATION_SLOTS in
|
|
26
32
|
// interface/src/stores/Preset.ts. Defined locally because the MCP package
|
|
@@ -74,7 +80,56 @@ function isMfxManifest(manifest) {
|
|
|
74
80
|
manifest.bundleId?.toLowerCase() === "map.audio.mfx" ||
|
|
75
81
|
manifest.pluginCode?.toLowerCase() === "amfx");
|
|
76
82
|
}
|
|
83
|
+
// MSQ sequences MIDI only. These tools drive a part of PAM it does not have, so on an
|
|
84
|
+
// MSQ instance they answer with the reason instead of writing state nothing reads.
|
|
85
|
+
const MSQ_MISSING_BY_TOOL = new Map([
|
|
86
|
+
...[
|
|
87
|
+
"load_sample", "randomize_samples", "trim_sample", "reverse_sample", "normalize_sample", "gain_sample",
|
|
88
|
+
"analyze_sample_pitch", "repitch_sample", "analyze_sample_tempo", "get_sample_peaks", "preview_slice",
|
|
89
|
+
"resample_sample_to_bpm", "restore_original_sample", "build_sample_chain", "export_slice", "set_slices",
|
|
90
|
+
"preview_sample", "get_sample_usage", "repair_preset_samples", "set_drum_synth", "set_drum_synth_wave",
|
|
91
|
+
"set_lane_target",
|
|
92
|
+
].map((tool) => [tool, "sampler"]),
|
|
93
|
+
...["update_effect_config", "set_effect_order"].map((tool) => [tool, "cell effects"]),
|
|
94
|
+
...["set_master_effects", "get_master_analysis", "get_mix_analysis", "suggest_mix", "set_metronome"].map((tool) => [tool, "audio output"]),
|
|
95
|
+
...["export_audio", "export_song", "song_mode", "live_resample"].map((tool) => [tool, "audio render or Song Mode"]),
|
|
96
|
+
...[
|
|
97
|
+
"arm_looper_track", "set_looper_track", "clear_looper_track", "set_looper_track_selection",
|
|
98
|
+
"start_looper_recording", "stop_looper_recording", "capture_looper_now", "load_audio_to_looper_track",
|
|
99
|
+
"request_looper_warp", "looper_settings", "looper_variation", "looper_variation_sync",
|
|
100
|
+
].map((tool) => [tool, "looper"]),
|
|
101
|
+
...[
|
|
102
|
+
"set_visual", "list_visuals", "delete_visual", "get_visual_data_url", "import_visual_folder", "get_video_state",
|
|
103
|
+
"video_command", "add_video_layer", "update_video_layer", "set_video_layer_source", "remove_video_layer",
|
|
104
|
+
"apply_video_layout", "add_video_fx", "set_video_fx", "remove_video_fx", "bind_video_param", "set_video_settings",
|
|
105
|
+
"vx_variation", "analyze_motion",
|
|
106
|
+
].map((tool) => [tool, "VX video engine"]),
|
|
107
|
+
...[
|
|
108
|
+
"list_plugins", "scan_plugins", "cancel_plugin_scan", "get_plugin_scan_status", "list_loaded_plugins",
|
|
109
|
+
"load_plugin", "unload_plugin", "set_plugin_param", "set_plugin_params", "set_plugin_bypass",
|
|
110
|
+
"skip_current_plugin_scan", "remove_plugin_from_blocklist",
|
|
111
|
+
].map((tool) => [tool, "plugin host"]),
|
|
112
|
+
...[
|
|
113
|
+
"download_model", "get_model_status", "load_model", "unload_model", "delete_model", "list_models",
|
|
114
|
+
"generate_sample", "generate_kit", "workbench_get_state", "workbench_set_state", "workbench_render",
|
|
115
|
+
"workbench_list_takes", "workbench_load_take", "workbench_undo", "workbench_reset_chain",
|
|
116
|
+
"workbench_sidecar_status", "ask_assistant",
|
|
117
|
+
].map((tool) => [tool, "AI stack"]),
|
|
118
|
+
]);
|
|
77
119
|
const MFX_CELL_CONFIG_KEYS = new Set(["sidechainFrom"]);
|
|
120
|
+
// MSQ cells are MIDI tracks: their note, channel, scale and output wiring, never sampler config.
|
|
121
|
+
const MSQ_CELL_CONFIG_KEYS = new Set([
|
|
122
|
+
"isPoly", "midiNote", "midiChannel", "midiIsolate", "armed", "polyRootNote", "scaleName", "scaleRoot",
|
|
123
|
+
"midiOutChannel", "sendToHost", "midiOutPort", ...CC_OUT_CONFIG_KEYS, ...CC_LANES.map(ccLaneSlotField),
|
|
124
|
+
]);
|
|
125
|
+
// A CC out slot's controller (cell config ccOutCc{K}); 120-127 are channel-mode messages.
|
|
126
|
+
const ccOutControllerSchema = (slot) => z
|
|
127
|
+
.number()
|
|
128
|
+
.int()
|
|
129
|
+
.min(-1)
|
|
130
|
+
.max(119)
|
|
131
|
+
.optional()
|
|
132
|
+
.describe(`Controller number CC out slot ${slot} sends, -1=Off. Its value is the parameter cell{N}_ccOut${slot} (0-127).`);
|
|
78
133
|
async function loadManifestForProductGuard() {
|
|
79
134
|
try {
|
|
80
135
|
return await loadManifest();
|
|
@@ -196,22 +251,6 @@ async function requireBridgeForCells(...cellIds) {
|
|
|
196
251
|
}
|
|
197
252
|
return true;
|
|
198
253
|
}
|
|
199
|
-
/**
|
|
200
|
-
* Parse the PAM binary preset format: <metadataSize as text int><metadata JSON><data JSON>
|
|
201
|
-
*/
|
|
202
|
-
function parsePresetFile(raw) {
|
|
203
|
-
const sizeMatch = raw.match(/^(\d+)/);
|
|
204
|
-
if (!sizeMatch)
|
|
205
|
-
throw new Error("invalid format (no metadata size prefix)");
|
|
206
|
-
const prefixLen = sizeMatch[1].length;
|
|
207
|
-
const metadataSize = parseInt(sizeMatch[1], 10);
|
|
208
|
-
if (metadataSize <= 0 || prefixLen + metadataSize > raw.length) {
|
|
209
|
-
throw new Error(`invalid format (metadata size ${metadataSize} out of bounds for file length ${raw.length})`);
|
|
210
|
-
}
|
|
211
|
-
const metadataStr = raw.slice(prefixLen, prefixLen + metadataSize);
|
|
212
|
-
const dataStr = raw.slice(prefixLen + metadataSize);
|
|
213
|
-
return { metadata: JSON.parse(metadataStr), data: JSON.parse(dataStr) };
|
|
214
|
-
}
|
|
215
254
|
// PAM ≥ 1.4.61 stores variations ONLY in the data block, zlib+base64-compressed with a
|
|
216
255
|
// "PAMZ1:" marker (see helpers/VariationCodec.h). Decompress one entry to plain JSON;
|
|
217
256
|
// plain (older-format) entries pass through unchanged.
|
|
@@ -241,20 +280,17 @@ function variationsFromParsedPreset(parsed) {
|
|
|
241
280
|
return Array(PRESET_VARIATION_SLOTS).fill(null);
|
|
242
281
|
return src.map(decompressVariationEntry);
|
|
243
282
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
* Returns Array(8) of plain-JSON strings or nulls.
|
|
247
|
-
*/
|
|
248
|
-
async function readExistingVariations(presetPath) {
|
|
283
|
+
async function readPresetForSave(presetPath, allowMissing = false) {
|
|
284
|
+
let fullPath;
|
|
249
285
|
try {
|
|
250
|
-
|
|
251
|
-
const raw = await readFile(fullPath, "utf-8");
|
|
252
|
-
return variationsFromParsedPreset(parsePresetFile(raw));
|
|
286
|
+
fullPath = await resolvePresetPath(presetPath);
|
|
253
287
|
}
|
|
254
|
-
catch {
|
|
255
|
-
|
|
256
|
-
|
|
288
|
+
catch (error) {
|
|
289
|
+
if (allowMissing && error.code === "ENOENT")
|
|
290
|
+
return null;
|
|
291
|
+
throw error;
|
|
257
292
|
}
|
|
293
|
+
return parsePresetFile(await readFile(fullPath));
|
|
258
294
|
}
|
|
259
295
|
const VARIATION_CONTRACT = (() => {
|
|
260
296
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -294,6 +330,7 @@ const VARIATION_EXCLUDED_PARAMS = [
|
|
|
294
330
|
function buildVariationString(state) {
|
|
295
331
|
return JSON.stringify(buildVariationSnapshotBase(state, {
|
|
296
332
|
sessionKeys: VARIATION_EXCLUDED_PARAMS,
|
|
333
|
+
transientKeys: VARIATION_CONTRACT.transientDispatchKeys ?? [],
|
|
297
334
|
visualParamIds: VARIATION_CONTRACT.visualParamIds ?? [],
|
|
298
335
|
lfoGeneratorCount: VARIATION_CONTRACT.lfoGeneratorCount ?? 6,
|
|
299
336
|
}));
|
|
@@ -372,12 +409,6 @@ const FULL_STATE_RESPONSE_LIMIT_CHARS = 24_000;
|
|
|
372
409
|
// Preset listings grow with every installed pack; cap the unfiltered dump so it
|
|
373
410
|
// can't crowd out the agent's context (`search` narrows it).
|
|
374
411
|
const LIST_PRESETS_MAX = 400;
|
|
375
|
-
const PATTERN_LANES = {
|
|
376
|
-
note: "midiSequence",
|
|
377
|
-
pitch: "pitchSequence",
|
|
378
|
-
slice: "sliceSequence",
|
|
379
|
-
stretch: "stretchSequence",
|
|
380
|
-
};
|
|
381
412
|
const LFO_DIVISIONS = [
|
|
382
413
|
"16 bar", "8 bar", "4 bar", "2 bar", "1 bar",
|
|
383
414
|
"1/2D", "1/2", "1/2T",
|
|
@@ -472,6 +503,44 @@ function formatNotModulatableError(paramId, index) {
|
|
|
472
503
|
const suggestions = suggestParams(paramId, index, { modulatableOnly: true });
|
|
473
504
|
return `Parameter '${paramId}' is not modulatable.${suggestions.length ? ` Try modulatable target ${suggestions.join(", ")}.` : ""}`;
|
|
474
505
|
}
|
|
506
|
+
// Hosted-plugin params exist only while a plugin is loaded, so they resolve against
|
|
507
|
+
// the live instance; the id is the UI's `cell{N}_plugin_{slug(name)}_{index}`.
|
|
508
|
+
const HOSTED_MOD_TARGET_RX = /^cell(\d+)_plugin(Fx)?_[a-z0-9][a-z0-9_]*_(\d+)$/;
|
|
509
|
+
function hostedParamSlug(name) {
|
|
510
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 32) || "p";
|
|
511
|
+
}
|
|
512
|
+
function hostedModTargetId(cellId, param, effect = false) {
|
|
513
|
+
return `cell${cellId}_${effect ? "pluginFx" : "plugin"}_${hostedParamSlug(param.name || param.label || `p${param.index}`)}_${param.index}`;
|
|
514
|
+
}
|
|
515
|
+
/** A manifest param, or a hosted-plugin param checked against the loaded instance; a string is the error. */
|
|
516
|
+
async function resolveModulationTarget(paramId, index) {
|
|
517
|
+
const param = index.get(paramId);
|
|
518
|
+
if (param)
|
|
519
|
+
return param.isModulatable ? param : formatNotModulatableError(paramId, index);
|
|
520
|
+
const hosted = paramId.match(HOSTED_MOD_TARGET_RX);
|
|
521
|
+
if (!hosted)
|
|
522
|
+
return formatUnknownParamError(paramId, index, { modulatableOnly: true });
|
|
523
|
+
const cellId = Number(hosted[1]);
|
|
524
|
+
const hostedTarget = { paramId, name: paramId, displayName: paramId, min: 0, max: 1, defaultValue: 0, step: 0.001, skew: 1, isModulatable: true };
|
|
525
|
+
// list_loaded_plugins covers the instrument slot only; an FX id is taken as named.
|
|
526
|
+
if (hosted[2])
|
|
527
|
+
return hostedTarget;
|
|
528
|
+
let listed;
|
|
529
|
+
try {
|
|
530
|
+
listed = (await bridge.send("listLoadedPlugins"));
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
return `Hosted plugin parameters need a running PAM with the MCP bridge connected (${paramId}).`;
|
|
534
|
+
}
|
|
535
|
+
const plugin = listed?.loadedPlugins?.find((p) => Number(p.cellId) === cellId);
|
|
536
|
+
if (!plugin)
|
|
537
|
+
return `No hosted instrument plugin is loaded in cell ${cellId}; load one before targeting ${paramId}.`;
|
|
538
|
+
const entry = plugin.params?.find((p) => Number(p.index) === Number(hosted[3]));
|
|
539
|
+
if (!entry)
|
|
540
|
+
return `${plugin.name} in cell ${cellId} has no parameter at index ${hosted[3]}.`;
|
|
541
|
+
const expected = hostedModTargetId(cellId, { ...entry, index: Number(entry.index) });
|
|
542
|
+
return expected === paramId ? hostedTarget : `Unknown hosted parameter ${paramId}. Did you mean ${expected}?`;
|
|
543
|
+
}
|
|
475
544
|
function formatRangeError(param, value) {
|
|
476
545
|
const unit = param.suffix ? param.suffix : "unitless";
|
|
477
546
|
const traits = [
|
|
@@ -583,17 +652,16 @@ const server = new McpServer({
|
|
|
583
652
|
version: SERVER_VERSION,
|
|
584
653
|
}, {
|
|
585
654
|
instructions: IS_MFX_SERVER
|
|
586
|
-
? "MFX is a four-cell modular FX router
|
|
587
|
-
"
|
|
588
|
-
|
|
589
|
-
"
|
|
590
|
-
|
|
591
|
-
"get_design_overview; filter get_state with section, keys, or cellIds. For samples, call " +
|
|
592
|
-
"get_default_locations before list_samples and load only returned paths; never guess library paths. " +
|
|
593
|
-
"Sequencer output requires a running transport. UI, video, and visual tools require the plugin " +
|
|
655
|
+
? "MFX is a four-cell modular FX router: cells are patched with cables and each cell runs its own effect chain. " +
|
|
656
|
+
"Use cells 1-4. Treat tool annotations as authoritative and use select_instance when several products are running."
|
|
657
|
+
: "PAM is an eight-cell sampler. " +
|
|
658
|
+
"Sequencer notes require a running transport; a cell's CC out slots send whenever their value moves. " +
|
|
659
|
+
"UI, video, and visual tools require the plugin " +
|
|
594
660
|
"window open; experimental plugin-host and AI tools require experimentalMode plus the matching " +
|
|
595
661
|
"feature flag. Treat tool annotations as authoritative and use select_instance when several " +
|
|
596
|
-
"products are running. A 'build does not support' error means update the plugin, not only this server."
|
|
662
|
+
"products are running. A 'build does not support' error means update the plugin, not only this server. " +
|
|
663
|
+
"An MSQ instance sequences MIDI only: its cells send notes and CC to the host MIDI out, so sample, effect, " +
|
|
664
|
+
"master, looper, video, plugin-host and AI tools refuse it.",
|
|
597
665
|
});
|
|
598
666
|
// --- tool annotation policy ---------------------------------------------------
|
|
599
667
|
// Single audit surface for MCP tool annotations. Every tool MUST have an entry;
|
|
@@ -624,13 +692,14 @@ const TOOL_ANNOTATIONS = {
|
|
|
624
692
|
get_design_overview: RO,
|
|
625
693
|
get_transport: RO,
|
|
626
694
|
get_master_analysis: RO,
|
|
627
|
-
suggest_mix:
|
|
695
|
+
suggest_mix: W,
|
|
628
696
|
connection_status: RO,
|
|
697
|
+
situation: RO,
|
|
629
698
|
list_samples: RO,
|
|
630
699
|
get_default_locations: RO,
|
|
631
700
|
get_audio_info: RO,
|
|
632
701
|
get_video_state: RO,
|
|
633
|
-
capture_ui:
|
|
702
|
+
capture_ui: WI,
|
|
634
703
|
get_sample_peaks: RO,
|
|
635
704
|
get_audio_device_setup: RO,
|
|
636
705
|
get_memory_report: RO,
|
|
@@ -656,6 +725,7 @@ const TOOL_ANNOTATIONS = {
|
|
|
656
725
|
reset_patterns: DI,
|
|
657
726
|
update_effect_config: WI,
|
|
658
727
|
update_cell_config: WI,
|
|
728
|
+
set_lane_target: WI,
|
|
659
729
|
set_cell_mute: WI,
|
|
660
730
|
set_master_effects: WI,
|
|
661
731
|
add_param_seq: W,
|
|
@@ -663,6 +733,12 @@ const TOOL_ANNOTATIONS = {
|
|
|
663
733
|
remove_param_seq: DI,
|
|
664
734
|
set_drum_synth: WI,
|
|
665
735
|
set_drum_synth_wave: WI,
|
|
736
|
+
list_players: RO,
|
|
737
|
+
set_player: WI,
|
|
738
|
+
keep_player: W,
|
|
739
|
+
set_slices: WI,
|
|
740
|
+
set_free_sequencer: WI,
|
|
741
|
+
export_midi: W,
|
|
666
742
|
stop_cell: WI,
|
|
667
743
|
reset_cell: DI,
|
|
668
744
|
copy_cell: DI, // overwrites the destination cell
|
|
@@ -680,8 +756,10 @@ const TOOL_ANNOTATIONS = {
|
|
|
680
756
|
trigger_midi_cc: W,
|
|
681
757
|
trigger_midi_program_change: W,
|
|
682
758
|
midi_recording: W,
|
|
759
|
+
session_take: W,
|
|
683
760
|
// Presets / variations
|
|
684
761
|
load_preset: DI, // replaces the working state (unsaved edits are lost)
|
|
762
|
+
get_preset_load_status: RO,
|
|
685
763
|
save_preset: WI,
|
|
686
764
|
new_preset: DI,
|
|
687
765
|
import_preset: WI,
|
|
@@ -702,6 +780,8 @@ const TOOL_ANNOTATIONS = {
|
|
|
702
780
|
trim_sample: W, // restore_original_sample recovers the pre-edit file
|
|
703
781
|
reverse_sample: W,
|
|
704
782
|
gain_sample: W,
|
|
783
|
+
repitch_sample: W,
|
|
784
|
+
analyze_sample_pitch: RO,
|
|
705
785
|
normalize_sample: WI,
|
|
706
786
|
analyze_sample_tempo: WI, // dispatches results into state
|
|
707
787
|
resample_sample_to_bpm: WI,
|
|
@@ -772,7 +852,7 @@ const TOOL_ANNOTATIONS = {
|
|
|
772
852
|
set_video_fx: WI,
|
|
773
853
|
remove_video_fx: DI,
|
|
774
854
|
bind_video_param: WI,
|
|
775
|
-
set_video_settings:
|
|
855
|
+
set_video_settings: D, // resetWorkspace:true clears the scene
|
|
776
856
|
vx_variation: W,
|
|
777
857
|
// Connection management
|
|
778
858
|
select_instance: WI,
|
|
@@ -797,6 +877,7 @@ const MFX_TOOL_NAMES = new Set([
|
|
|
797
877
|
"get_design_overview",
|
|
798
878
|
"get_transport",
|
|
799
879
|
"connection_status",
|
|
880
|
+
"situation",
|
|
800
881
|
"get_default_locations",
|
|
801
882
|
"capture_ui",
|
|
802
883
|
"get_memory_report",
|
|
@@ -811,6 +892,7 @@ const MFX_TOOL_NAMES = new Set([
|
|
|
811
892
|
"add_param_seq",
|
|
812
893
|
"update_param_seq",
|
|
813
894
|
"remove_param_seq",
|
|
895
|
+
"set_free_sequencer",
|
|
814
896
|
"stop_cell",
|
|
815
897
|
"reset_cell",
|
|
816
898
|
"copy_cell",
|
|
@@ -821,6 +903,7 @@ const MFX_TOOL_NAMES = new Set([
|
|
|
821
903
|
"set_metronome",
|
|
822
904
|
"configure_transport",
|
|
823
905
|
"load_preset",
|
|
906
|
+
"get_preset_load_status",
|
|
824
907
|
"save_preset",
|
|
825
908
|
"new_preset",
|
|
826
909
|
"import_preset",
|
|
@@ -834,8 +917,8 @@ const MFX_TOOL_NAMES = new Set([
|
|
|
834
917
|
"cancel_queued_variation",
|
|
835
918
|
"update_variations",
|
|
836
919
|
"variation_hold",
|
|
837
|
-
"export_audio",
|
|
838
920
|
"update_settings",
|
|
921
|
+
"get_audio_device_setup",
|
|
839
922
|
"set_audio_device",
|
|
840
923
|
"set_mfx_routing",
|
|
841
924
|
"set_mfx_edge_gain",
|
|
@@ -847,27 +930,182 @@ const MFX_TOOL_NAMES = new Set([
|
|
|
847
930
|
]);
|
|
848
931
|
function mfxSurfaceText(value) {
|
|
849
932
|
return value
|
|
933
|
+
// MFX has its own version line, so a PAM version gate names a build MFX never reaches.
|
|
934
|
+
.replace(/PAM ?(?:≥|>=) ?\d+(?:\.\d+)*/g, "a current MFX build")
|
|
935
|
+
.replaceAll("a PAM build", "an MFX build")
|
|
936
|
+
.replaceAll(" saved via set_visual", "")
|
|
937
|
+
.replaceAll(" Check corr in get_master_analysis after widening.", "")
|
|
938
|
+
.replaceAll(" (same constraint as the video tools)", "")
|
|
939
|
+
.replaceAll("so the preset_novelty scale does", "so a whole-preset novelty scale does")
|
|
850
940
|
.replaceAll("PAM/Cell1-only", "sampler-product-only")
|
|
851
941
|
.replaceAll("PAM/Cell1", "sampler products")
|
|
852
942
|
.replaceAll("PAM-only", "sampler-product-only")
|
|
853
943
|
.replaceAll("PAM's", "MFX's")
|
|
854
944
|
.replaceAll("PAM", "MFX");
|
|
855
945
|
}
|
|
946
|
+
const MFX_MAX_CELL = 4;
|
|
947
|
+
// Copy of syncUtils::kValidBarSyncIntervals (SyncUtils.h); check-parity compares them.
|
|
948
|
+
const LOOPER_BAR_SYNC_INTERVALS = [0.25, 0.5, 1, 2, 4, 8];
|
|
949
|
+
// Zod wrappers are immutable, so narrowing a leaf means rebuilding the
|
|
950
|
+
// optional/default/array/union chain around it and re-attaching the description.
|
|
951
|
+
function rebuildSchema(schema, narrowLeaf) {
|
|
952
|
+
const def = schema._zod?.def;
|
|
953
|
+
const keepText = (next) => (schema.description ? next.describe(schema.description) : next);
|
|
954
|
+
const inner = () => rebuildSchema(def.innerType, narrowLeaf);
|
|
955
|
+
switch (def?.type) {
|
|
956
|
+
case "optional":
|
|
957
|
+
return keepText(inner().optional());
|
|
958
|
+
case "nullable":
|
|
959
|
+
return keepText(inner().nullable());
|
|
960
|
+
case "default":
|
|
961
|
+
return keepText(inner().default(def.defaultValue));
|
|
962
|
+
case "array":
|
|
963
|
+
return keepText(z.array(rebuildSchema(def.element, narrowLeaf)));
|
|
964
|
+
case "union":
|
|
965
|
+
return keepText(z.union(def.options.map((option) => rebuildSchema(option, narrowLeaf))));
|
|
966
|
+
default:
|
|
967
|
+
return keepText(narrowLeaf(schema));
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
function schemaKind(schema) {
|
|
971
|
+
return schema._zod?.def?.type;
|
|
972
|
+
}
|
|
973
|
+
const capCellId = (leaf) => schemaKind(leaf) === "number" ? leaf.max(MFX_MAX_CELL) : leaf;
|
|
974
|
+
const dropEnumMembers = (drop) => (leaf) => {
|
|
975
|
+
if (schemaKind(leaf) !== "enum")
|
|
976
|
+
return leaf;
|
|
977
|
+
const entries = leaf._zod.def.entries;
|
|
978
|
+
const kept = Object.values(entries).filter((value) => !drop.includes(value));
|
|
979
|
+
return z.enum(kept);
|
|
980
|
+
};
|
|
981
|
+
// MFX ships four cells and the shared schemas advertise PAM's eight. The runtime
|
|
982
|
+
// guard is manifest-driven; this is the advertised half of the same contract.
|
|
983
|
+
const MFX_CELL_FIELDS = new Set([
|
|
984
|
+
"cellId",
|
|
985
|
+
"sourceCellId",
|
|
986
|
+
"targetCellId",
|
|
987
|
+
"cellIds",
|
|
988
|
+
"selectedCellIds",
|
|
989
|
+
"sidechainFrom",
|
|
990
|
+
]);
|
|
991
|
+
// Enum members MFX has no surface for: Vary is removed from its modulation stack,
|
|
992
|
+
// neither song_mode nor a looper tool is registered on this launcher, and the MIDI
|
|
993
|
+
// settings page hides the looper, resample and VX mappings.
|
|
994
|
+
const MFX_ENUM_DROPS = {
|
|
995
|
+
add_modulation: { type: ["vary"] },
|
|
996
|
+
update_modulation: { type: ["vary"] },
|
|
997
|
+
get_state: { section: ["vary", "loopers", "songMode"] },
|
|
998
|
+
set_midi_mapping: { target: ["looper", "resample", "vxRecord", "looperVariation"] },
|
|
999
|
+
};
|
|
1000
|
+
const MFX_KEPT_FIELDS = {
|
|
1001
|
+
update_cell_config: ["cellId", "sidechainFrom"],
|
|
1002
|
+
};
|
|
1003
|
+
const MFX_DROPPED_FIELDS = {
|
|
1004
|
+
select_instance: ["product"],
|
|
1005
|
+
// MFX cells are effect slots — there is no synth/sampler tab to switch.
|
|
1006
|
+
capture_ui: ["instrumentView"],
|
|
1007
|
+
// MFX processes host input, which an offline render leaves silent.
|
|
1008
|
+
analyze_motion: ["bars"],
|
|
1009
|
+
};
|
|
1010
|
+
const MFX_TOOL_DESCRIPTIONS = {
|
|
1011
|
+
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.",
|
|
1012
|
+
update_cell_config: "Set one MFX cell's sidechain source. A non-null source also enables that cell's compressor.",
|
|
1013
|
+
capture_ui: "Render MFX's plugin UI (#app_root) to a PNG via html2canvas and return the saved file path (read it to " +
|
|
1014
|
+
"view the image). Optionally compose the view first: gridSize (1 or 4), mainView (CellGrid|MfxRouting|" +
|
|
1015
|
+
"CtrlAll|Envelope|LFO|ParamSeq|Mixer|PresetBrowser; omit/null = cell grid). REQUIRES MFX's window OPEN — " +
|
|
1016
|
+
"html2canvas needs the live DOM, there is no headless renderer. Beats screen-capture: deterministic, no " +
|
|
1017
|
+
"cursor/screen control, exact UI bitmap. View arguments switch the live editor and the previous view is " +
|
|
1018
|
+
"not restored.",
|
|
1019
|
+
copy_cell: "Copy a cell's manifest parameters and remap its LFO and envelope links onto another cell, overwriting the " +
|
|
1020
|
+
"target's. Not copied: parameter sequencers and the cell config block (sidechain source) — re-apply that with " +
|
|
1021
|
+
"update_cell_config.",
|
|
1022
|
+
set_midi_mapping: "Configure the global MIDI-controller mappings that trigger MFX actions: variation switch and preset change. " +
|
|
1023
|
+
"Set the enable flag, MIDI channel, CC number, preset source, or bar-sync for each target. One field per call.",
|
|
1024
|
+
};
|
|
1025
|
+
const MFX_FIELD_DESCRIPTIONS = {
|
|
1026
|
+
capture_ui: { gridSize: "Cell grid size to set before capture: 1 or 4." },
|
|
1027
|
+
analyze_motion: {
|
|
1028
|
+
wavPath: "Absolute path to a WAV of material processed through MFX, such as a DAW bounce. Required on MFX.",
|
|
1029
|
+
},
|
|
1030
|
+
set_midi_mapping: {
|
|
1031
|
+
target: "Which action's mapping to configure.",
|
|
1032
|
+
field: "Field to set. 'source' is preset-only; 'barSync' is variation-only.",
|
|
1033
|
+
},
|
|
1034
|
+
queue_variation: {
|
|
1035
|
+
fadeMs: "Crossfade duration in ms. Omit all transition options to inherit the native Bar Sync settings.",
|
|
1036
|
+
},
|
|
1037
|
+
update_modulation: {
|
|
1038
|
+
payload: "Update payload object, one property per call. lfo, envelope, macro and paramSeq all take " +
|
|
1039
|
+
"{ index, property, value }, where index is the 0-based position in that state array (get_state " +
|
|
1040
|
+
"section 'lfos' / 'envelopes' / 'macros' / 'paramSeqs') and property is the field name to overwrite. " +
|
|
1041
|
+
"paramSeqSequence takes { index, sequence: number[] }. freeSeqSequence takes { sequence: number[] } — the " +
|
|
1042
|
+
"master step contour every free-mode ParamSeq follows.",
|
|
1043
|
+
},
|
|
1044
|
+
add_modulation: {
|
|
1045
|
+
cellId: "Required for envelope: source cell driving the follower (1-4). This is not the target cell; target is inferred from paramId.",
|
|
1046
|
+
},
|
|
1047
|
+
};
|
|
1048
|
+
// Text whose subject is a tool this launcher does not register.
|
|
1049
|
+
function mfxDescriptionEdits(name, value) {
|
|
1050
|
+
if (name === "analyze_motion")
|
|
1051
|
+
return value
|
|
1052
|
+
.replace("Fills the gap left by get_master_analysis (loudness) and get_mix_analysis (aggregate spectrum + onset " +
|
|
1053
|
+
"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.")
|
|
1054
|
+
.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 " +
|
|
1055
|
+
"host's input, so there is no offline render to fall back on.");
|
|
1056
|
+
if (name === "reset_cell")
|
|
1057
|
+
return value.replace("Untouched: envelopes targeting this cell from another source (clear_modulations), pattern lanes (reset_patterns), " +
|
|
1058
|
+
"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 " +
|
|
1059
|
+
"(update_cell_config).");
|
|
1060
|
+
if (name === "clear_modulations")
|
|
1061
|
+
return value.replace("LFO, envelope, and vary modulation links", "LFO and envelope modulation links")
|
|
1062
|
+
.replace("for envelopes/vary", "for envelopes");
|
|
1063
|
+
if (name === "update_modulation")
|
|
1064
|
+
return value.replace("LFO, Envelope, Vary, Macro", "LFO, Envelope, Macro");
|
|
1065
|
+
if (name === "get_default_locations")
|
|
1066
|
+
return value.replace(" Use these paths with list_samples to browse the sample library.", "");
|
|
1067
|
+
if (name === "save_variation")
|
|
1068
|
+
return value.replace("(load samples, set parameters, enable effects, add modulation)", "(set parameters, enable effects, patch cables, add modulation)");
|
|
1069
|
+
if (name === "queue_variation")
|
|
1070
|
+
return value.replace(" (song mode / an already-engaged queued-flip window only;", " (an already-engaged queued-flip window only;");
|
|
1071
|
+
if (name === "get_design_overview")
|
|
1072
|
+
return value.replace("cells, loaded samples/synth engines, enabled effects, pattern counts, modulation link counts, mixer values, " +
|
|
1073
|
+
"BPM, and master FX.", "cells, enabled effects and order, sidechain sources, modulation link counts, mixer values, cables, cable " +
|
|
1074
|
+
"levels, Combo assignments, band split, BPM, and transport state.");
|
|
1075
|
+
if (name === "set_master_effects")
|
|
1076
|
+
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. ");
|
|
1077
|
+
if (name === "set_parameters")
|
|
1078
|
+
return (value +
|
|
1079
|
+
" Writing mfx_combo1, mfx_combo2 or mfx_combo3 also fans the value out to that combo's resolved target " +
|
|
1080
|
+
"parameters, unless one of those targets is written in the same call.");
|
|
1081
|
+
return value;
|
|
1082
|
+
}
|
|
856
1083
|
function mfxToolConfig(name, config) {
|
|
857
1084
|
const source = config;
|
|
858
|
-
const
|
|
859
|
-
|
|
1085
|
+
const kept = MFX_KEPT_FIELDS[name];
|
|
1086
|
+
const droppedFields = MFX_DROPPED_FIELDS[name] ?? [];
|
|
1087
|
+
const enumDrops = MFX_ENUM_DROPS[name] ?? {};
|
|
1088
|
+
const fieldText = MFX_FIELD_DESCRIPTIONS[name] ?? {};
|
|
860
1089
|
const inputSchema = source.inputSchema
|
|
861
|
-
? Object.fromEntries(
|
|
862
|
-
key
|
|
863
|
-
|
|
864
|
-
|
|
1090
|
+
? Object.fromEntries(Object.entries(source.inputSchema)
|
|
1091
|
+
.filter(([key]) => (!kept || kept.includes(key)) && !droppedFields.includes(key))
|
|
1092
|
+
.map(([key, schema]) => {
|
|
1093
|
+
let narrowed = schema;
|
|
1094
|
+
if (enumDrops[key])
|
|
1095
|
+
narrowed = rebuildSchema(narrowed, dropEnumMembers(enumDrops[key]));
|
|
1096
|
+
if (MFX_CELL_FIELDS.has(key))
|
|
1097
|
+
narrowed = rebuildSchema(narrowed, capCellId);
|
|
1098
|
+
const described = fieldText[key] ??
|
|
1099
|
+
(narrowed.description
|
|
1100
|
+
? mfxSurfaceText(MFX_CELL_FIELDS.has(key)
|
|
1101
|
+
? narrowed.description.replaceAll("1-8", "1-4").replaceAll("1..8", "1..4")
|
|
1102
|
+
: narrowed.description)
|
|
1103
|
+
: undefined);
|
|
1104
|
+
return [key, described ? narrowed.describe(described) : narrowed];
|
|
1105
|
+
}))
|
|
865
1106
|
: undefined;
|
|
866
|
-
const description = name
|
|
867
|
-
|
|
868
|
-
: name === "update_cell_config"
|
|
869
|
-
? "Set one MFX cell's sidechain source. A non-null source also enables that cell's compressor."
|
|
870
|
-
: source.description ? mfxSurfaceText(source.description) : undefined;
|
|
1107
|
+
const description = MFX_TOOL_DESCRIPTIONS[name] ??
|
|
1108
|
+
(source.description ? mfxSurfaceText(mfxDescriptionEdits(name, source.description)) : undefined);
|
|
871
1109
|
return {
|
|
872
1110
|
...source,
|
|
873
1111
|
...(description ? { description } : {}),
|
|
@@ -882,7 +1120,14 @@ function mfxToolConfig(name, config) {
|
|
|
882
1120
|
if (IS_MFX_SERVER && !MFX_TOOL_NAMES.has(name))
|
|
883
1121
|
return undefined;
|
|
884
1122
|
const productConfig = (IS_MFX_SERVER ? mfxToolConfig(name, config) : config);
|
|
885
|
-
|
|
1123
|
+
const missing = MSQ_MISSING_BY_TOOL.get(name);
|
|
1124
|
+
// Judged by the instance actually connected, not a manifest hint a previous session left behind.
|
|
1125
|
+
const productHandler = missing
|
|
1126
|
+
? async (args) => (await requireBridge()) === true && bridge.getConnectedInstance()?.product?.toUpperCase() === "MSQ"
|
|
1127
|
+
? error(`${name} is not available in MSQ: it has no ${missing}.`)
|
|
1128
|
+
: handler(args)
|
|
1129
|
+
: handler;
|
|
1130
|
+
internalTools.set(name, { shape: productConfig.inputSchema, handler: productHandler });
|
|
886
1131
|
// Merge the central annotation policy. Inline annotations at the call site
|
|
887
1132
|
// win per key; openWorldHint is false server-wide (local plugin only).
|
|
888
1133
|
const cfg = productConfig;
|
|
@@ -890,7 +1135,7 @@ function mfxToolConfig(name, config) {
|
|
|
890
1135
|
console.error(`[${SERVER_PRODUCT.command}] tool '${name}' has no TOOL_ANNOTATIONS entry — classify it (check-tool-surface fails on this)`);
|
|
891
1136
|
}
|
|
892
1137
|
cfg.annotations = { openWorldHint: false, ...TOOL_ANNOTATIONS[name], ...cfg.annotations };
|
|
893
|
-
return origRegisterTool(name, productConfig,
|
|
1138
|
+
return origRegisterTool(name, productConfig, productHandler);
|
|
894
1139
|
});
|
|
895
1140
|
}
|
|
896
1141
|
async function callInternalTool(name, args) {
|
|
@@ -996,7 +1241,7 @@ server.registerTool("get_parameter_info", {
|
|
|
996
1241
|
});
|
|
997
1242
|
server.registerTool("list_presets", {
|
|
998
1243
|
title: "List Presets",
|
|
999
|
-
description: "List available PAM presets (factory and user).",
|
|
1244
|
+
description: "List available PAM presets (factory and user) as an array of {name, path}. Above 400 matches it returns {presets, total, truncated, hint} instead — narrow with `search` rather than paging.",
|
|
1000
1245
|
inputSchema: {
|
|
1001
1246
|
search: z.string().optional().describe("Filter presets by name."),
|
|
1002
1247
|
},
|
|
@@ -1127,9 +1372,8 @@ server.registerTool("design_context", {
|
|
|
1127
1372
|
groups: z
|
|
1128
1373
|
.array(z.string())
|
|
1129
1374
|
.optional()
|
|
1130
|
-
.describe("Narrow keyPerCellParams to these groups (e.g. ['delay','vocoder'])
|
|
1131
|
-
"Omit
|
|
1132
|
-
"subset is what makes presets resemble each other."),
|
|
1375
|
+
.describe("Narrow keyPerCellParams to these groups (e.g. ['delay','vocoder']). " +
|
|
1376
|
+
"Omit it to get the full per-cell vocabulary."),
|
|
1133
1377
|
},
|
|
1134
1378
|
annotations: { readOnlyHint: true },
|
|
1135
1379
|
}, async ({ cellId, groups: groupFilter }) => {
|
|
@@ -1278,7 +1522,7 @@ server.registerTool("design_context", {
|
|
|
1278
1522
|
compressor: group("compressorThreshold", "compressorRatio", "compressorAttack", "compressorRelease"),
|
|
1279
1523
|
panner: group("pannerBalance", "pannerWidth"),
|
|
1280
1524
|
clip: group("gainClip", "gainClipMode"),
|
|
1281
|
-
drumSynth: group("synthEngine", "synthVolume", "synthPitch", "synthAttack", "synth_p1", "synth_p2", "synth_p3"),
|
|
1525
|
+
drumSynth: group("synthEngine", "synthVolume", "synthPitch", "synthAttack", "synthWaveFm", "synth_p1", "synth_p2", "synth_p3"),
|
|
1282
1526
|
// Everything below is a real per-cell engine that no shipped factory
|
|
1283
1527
|
// preset has ever touched. They are the least-explored territory in the
|
|
1284
1528
|
// instrument, which makes them the first place to look when the brief
|
|
@@ -1360,6 +1604,9 @@ server.registerTool("design_context", {
|
|
|
1360
1604
|
return [`cell${id}`, 47 + id];
|
|
1361
1605
|
})),
|
|
1362
1606
|
variationSlots: "API is 0-based (0..7); PAM UI labels slots 1..8. Save/load variation index 0 = UI slot 1.",
|
|
1607
|
+
generativePlayers: "set_player turns a cell into a live generator (list_players for the roster: EUCLID, KIT, TURING, WALK, MARKOV, " +
|
|
1608
|
+
"RATCHET, PHASE, RIFF, MASK). cell{N}_playerDensity/Mutate/P1..P5 are modulatable params with macro_player* twins; " +
|
|
1609
|
+
"write them through set_player, never set_parameters alone, so the cell config stays in step.",
|
|
1363
1610
|
modulationGuidance: "Primary movement should target per-cell params for distinct audible changes. Use macro_* for performance-level fan-out across cells. " +
|
|
1364
1611
|
"Shipped presets that modulate at all carry a mean of 8.2 sources; the top third carry 8-46. Four sources is the corpus MEDIAN, i.e. the unremarkable middle — not a target.",
|
|
1365
1612
|
});
|
|
@@ -1454,8 +1701,9 @@ server.registerTool("analyze_motion", {
|
|
|
1454
1701
|
"a bar — the last one is what catches a bar-synced gate, tremolo or sidechain pump, whose bars are all " +
|
|
1455
1702
|
"identical to each other.\n\n" +
|
|
1456
1703
|
"Pass wavPath to analyze an existing render or live_resample capture. Otherwise the tool runs an offline " +
|
|
1457
|
-
"export first
|
|
1458
|
-
"offline export is known to produce
|
|
1704
|
+
"export first and leaves that render in PAM's Exports folder (its path is in the result). Digital silence is " +
|
|
1705
|
+
"reported as a RENDER FAILURE with the workaround, not as 'no motion' — offline export is known to produce " +
|
|
1706
|
+
"silence in the standalone build.",
|
|
1459
1707
|
inputSchema: {
|
|
1460
1708
|
wavPath: z
|
|
1461
1709
|
.string()
|
|
@@ -1465,35 +1713,34 @@ server.registerTool("analyze_motion", {
|
|
|
1465
1713
|
bpm: z
|
|
1466
1714
|
.number()
|
|
1467
1715
|
.optional()
|
|
1468
|
-
.describe("
|
|
1716
|
+
.describe("wavPath only: tempo for the 4/4 per-bar windows (default: the transport's). A render uses its own tempo and meter."),
|
|
1469
1717
|
},
|
|
1470
1718
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
1471
1719
|
}, async ({ wavPath, bars, bpm }) => {
|
|
1472
1720
|
let path = wavPath;
|
|
1473
1721
|
let effectiveBpm = bpm;
|
|
1722
|
+
if (!path && IS_MFX_SERVER) {
|
|
1723
|
+
return error("Pass wavPath: MFX processes its host's input, so an offline render has no source to analyze.");
|
|
1724
|
+
}
|
|
1725
|
+
// Bar windows in seconds; a render reports its own, from the transport's tempo and meter.
|
|
1726
|
+
let barSeconds;
|
|
1474
1727
|
if (!path) {
|
|
1475
1728
|
const check = await requireBridge();
|
|
1476
1729
|
if (check !== true)
|
|
1477
1730
|
return check;
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
const
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
mode: "main",
|
|
1484
|
-
durationInSeconds: (bars * 4 * 60) / (effectiveBpm || 120),
|
|
1485
|
-
bitDepth: 24,
|
|
1486
|
-
});
|
|
1731
|
+
// The bars form of exportAudio is the one that reports its output path.
|
|
1732
|
+
const started = await bridge.send("exportAudio", { bars, bitDepth: 24 });
|
|
1733
|
+
const outputPath = isRecord(started) && typeof started.outputPath === "string" ? started.outputPath : undefined;
|
|
1734
|
+
if (!outputPath)
|
|
1735
|
+
return error("This build did not report the render's output path. Re-run with an explicit wavPath.");
|
|
1487
1736
|
const outcome = await pollExportStatus();
|
|
1488
1737
|
if (outcome !== "success")
|
|
1489
1738
|
return error(`Render failed before analysis could run (export ${outcome}).`);
|
|
1490
|
-
|
|
1491
|
-
const
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
}
|
|
1496
|
-
path = resolve(exportDir, `${filename}.wav`);
|
|
1739
|
+
path = outputPath;
|
|
1740
|
+
const renderedSeconds = isRecord(started) ? Number(started.durationInSeconds) : NaN;
|
|
1741
|
+
barSeconds = renderedSeconds > 0 ? renderedSeconds / bars : undefined;
|
|
1742
|
+
if (isRecord(started) && typeof started.bpm === "number")
|
|
1743
|
+
effectiveBpm = started.bpm;
|
|
1497
1744
|
}
|
|
1498
1745
|
else if (effectiveBpm === undefined) {
|
|
1499
1746
|
const check = await requireBridge();
|
|
@@ -1505,7 +1752,7 @@ server.registerTool("analyze_motion", {
|
|
|
1505
1752
|
}
|
|
1506
1753
|
let metrics;
|
|
1507
1754
|
try {
|
|
1508
|
-
metrics = analyzeMotion(decodeWav(await readFile(path)), effectiveBpm);
|
|
1755
|
+
metrics = analyzeMotion(decodeWav(await readFile(path)), barSeconds ?? (effectiveBpm ? (60 / effectiveBpm) * 4 : undefined));
|
|
1509
1756
|
}
|
|
1510
1757
|
catch (e) {
|
|
1511
1758
|
return error(`Could not analyze ${path}: ${e.message}`);
|
|
@@ -1638,69 +1885,6 @@ server.registerTool("variation_distinctness", {
|
|
|
1638
1885
|
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.",
|
|
1639
1886
|
});
|
|
1640
1887
|
});
|
|
1641
|
-
// Standard full update — graph dispatch + KV sync + parameter updates.
|
|
1642
|
-
const KV_OPTS_STANDARD = {
|
|
1643
|
-
shouldDispatch: true,
|
|
1644
|
-
syncKVResources: true,
|
|
1645
|
-
syncConfigResources: true,
|
|
1646
|
-
resyncKVAfterDispatch: true,
|
|
1647
|
-
updateParameters: true,
|
|
1648
|
-
broadcastFollowers: true,
|
|
1649
|
-
};
|
|
1650
|
-
// Modulation update — same as standard but no follower broadcast.
|
|
1651
|
-
const KV_OPTS_MOD = {
|
|
1652
|
-
shouldDispatch: true,
|
|
1653
|
-
syncKVResources: true,
|
|
1654
|
-
syncConfigResources: true,
|
|
1655
|
-
resyncKVAfterDispatch: true,
|
|
1656
|
-
updateParameters: true,
|
|
1657
|
-
broadcastFollowers: false,
|
|
1658
|
-
};
|
|
1659
|
-
// Pattern/sequencer update — KV-only, no graph rebuild, no param updates.
|
|
1660
|
-
const KV_OPTS_PATTERN = {
|
|
1661
|
-
shouldDispatch: true,
|
|
1662
|
-
syncKVResources: true,
|
|
1663
|
-
syncConfigResources: false,
|
|
1664
|
-
skipFileLoadingIfUnchanged: true,
|
|
1665
|
-
resyncKVAfterDispatch: false,
|
|
1666
|
-
updateParameters: false,
|
|
1667
|
-
broadcastFollowers: false,
|
|
1668
|
-
};
|
|
1669
|
-
// Master-chain config — KV + config-resource sync, NO graph dispatch. Every
|
|
1670
|
-
// master key (comp incl. the full promoted Punch surface, tape, iron, clipper,
|
|
1671
|
-
// limiter, EQ/width) is live-polled from master_config by its native node, so
|
|
1672
|
-
// a rebuild here is pure dropout risk (the plan's no-rebuild contract).
|
|
1673
|
-
// Mirrors interop.ts updateMasterConfig's options.
|
|
1674
|
-
const KV_OPTS_MASTER_CONFIG = {
|
|
1675
|
-
shouldDispatch: false,
|
|
1676
|
-
syncKVResources: true,
|
|
1677
|
-
syncConfigResources: true,
|
|
1678
|
-
skipFileLoadingIfUnchanged: true,
|
|
1679
|
-
resyncKVAfterDispatch: false,
|
|
1680
|
-
updateParameters: true,
|
|
1681
|
-
broadcastFollowers: false,
|
|
1682
|
-
};
|
|
1683
|
-
// Effect config toggle — lighter KV path, no graph dispatch.
|
|
1684
|
-
const KV_OPTS_EFFECT_CONFIG = {
|
|
1685
|
-
shouldDispatch: false,
|
|
1686
|
-
syncKVResources: true,
|
|
1687
|
-
syncConfigResources: true,
|
|
1688
|
-
skipFileLoadingIfUnchanged: true,
|
|
1689
|
-
resyncKVAfterDispatch: false,
|
|
1690
|
-
updateParameters: true,
|
|
1691
|
-
broadcastFollowers: true,
|
|
1692
|
-
};
|
|
1693
|
-
// Cell config update — KV-only, syncs cell config resources, no graph dispatch.
|
|
1694
|
-
// Matches interop.ts updateCellConfig() options.
|
|
1695
|
-
const KV_OPTS_CELL_CONFIG = {
|
|
1696
|
-
shouldDispatch: false,
|
|
1697
|
-
syncKVResources: true,
|
|
1698
|
-
syncConfigResources: true,
|
|
1699
|
-
skipFileLoadingIfUnchanged: true,
|
|
1700
|
-
resyncKVAfterDispatch: false,
|
|
1701
|
-
updateParameters: true,
|
|
1702
|
-
broadcastFollowers: false,
|
|
1703
|
-
};
|
|
1704
1888
|
async function applyKv(delta, options, directNodeOps) {
|
|
1705
1889
|
const payload = { delta, options };
|
|
1706
1890
|
if (directNodeOps)
|
|
@@ -1810,7 +1994,7 @@ server.registerTool("get_design_overview", {
|
|
|
1810
1994
|
return json({
|
|
1811
1995
|
product: manifest.name,
|
|
1812
1996
|
bpm: state.bpm ?? transport.bpm,
|
|
1813
|
-
playing: transport.
|
|
1997
|
+
playing: transport.isPlaying,
|
|
1814
1998
|
mix: state.mfx_mix,
|
|
1815
1999
|
routing: state.mfx_routing,
|
|
1816
2000
|
edgeGains: state.mfx_edge_gains,
|
|
@@ -1840,17 +2024,23 @@ server.registerTool("get_design_overview", {
|
|
|
1840
2024
|
]));
|
|
1841
2025
|
return {
|
|
1842
2026
|
cellId: id,
|
|
2027
|
+
muted: state[`${prefix}mute`] === 1 || state[`${prefix}mute`] === true,
|
|
1843
2028
|
sample: {
|
|
1844
2029
|
name: stemName(samplePath),
|
|
1845
2030
|
path: typeof samplePath === "string" ? samplePath : undefined,
|
|
1846
2031
|
},
|
|
1847
2032
|
// Both halves: the enum an agent writes back, and the name it reasons about.
|
|
1848
2033
|
synthEngine: describeSynthEngine(state[`${prefix}synthEngine`]),
|
|
2034
|
+
player: describeCellPlayer(id, cellState, state),
|
|
1849
2035
|
enabledEffects: effectNames.filter((effectName) => state[`${prefix}${effectName}Enabled`] === 1 || state[`${prefix}${effectName}Enabled`] === true),
|
|
1850
|
-
patternEvents: Object.fromEntries(Object.entries(
|
|
2036
|
+
patternEvents: Object.fromEntries(Object.entries(PATTERN_LANE_SUFFIX).map(([lane, suffix]) => [
|
|
1851
2037
|
lane,
|
|
1852
2038
|
countEvents(state[`${prefix}${suffix}`]),
|
|
1853
2039
|
])),
|
|
2040
|
+
laneTargets: Object.fromEntries(PARAM_LANES.flatMap((lane) => {
|
|
2041
|
+
const target = cellState[LANE_TARGET_FIELD[lane]];
|
|
2042
|
+
return typeof target === "string" && target ? [[lane, target]] : [];
|
|
2043
|
+
})),
|
|
1854
2044
|
modulation,
|
|
1855
2045
|
mixer: {
|
|
1856
2046
|
volume: state[`${prefix}volume`],
|
|
@@ -1862,7 +2052,7 @@ server.registerTool("get_design_overview", {
|
|
|
1862
2052
|
return json({
|
|
1863
2053
|
product: manifest.name,
|
|
1864
2054
|
bpm: state.bpm ?? transport.bpm,
|
|
1865
|
-
playing: transport.
|
|
2055
|
+
playing: transport.isPlaying,
|
|
1866
2056
|
masterFx: {
|
|
1867
2057
|
compressorEnabled: state.masterCompressorEnabled,
|
|
1868
2058
|
limiterEnabled: state.masterLimiterEnabled,
|
|
@@ -1870,6 +2060,7 @@ server.registerTool("get_design_overview", {
|
|
|
1870
2060
|
},
|
|
1871
2061
|
totals: {
|
|
1872
2062
|
cellsWithSamples: cells.filter((c) => c.sample.path).length,
|
|
2063
|
+
playersRunning: cells.filter((c) => c.player).length,
|
|
1873
2064
|
activePatternEvents: cells.reduce((sum, c) => sum + Object.values(c.patternEvents).reduce((a, b) => a + b, 0), 0),
|
|
1874
2065
|
modulationLinks: cells.reduce((sum, c) => sum + Object.values(c.modulation).reduce((a, b) => a + b, 0), 0),
|
|
1875
2066
|
},
|
|
@@ -1880,7 +2071,8 @@ server.registerTool("set_parameters", {
|
|
|
1880
2071
|
title: "Set Parameters",
|
|
1881
2072
|
description: "Set one or more PAM parameters. Values are validated against the manifest. " +
|
|
1882
2073
|
"Effect enable flags such as cell1_filterEnabled are settable here too, so params + enables can land in one call " +
|
|
1883
|
-
"when a graph dispatch is acceptable. Use get_parameter_info or design_context to discover valid IDs and ranges."
|
|
2074
|
+
"when a graph dispatch is acceptable. Use get_parameter_info or design_context to discover valid IDs and ranges. " +
|
|
2075
|
+
"Writing a macro_* id also enables that macro, matching the UI.",
|
|
1884
2076
|
inputSchema: {
|
|
1885
2077
|
parameters: z
|
|
1886
2078
|
.record(z.string(), z.number())
|
|
@@ -1925,7 +2117,7 @@ server.registerTool("set_parameters", {
|
|
|
1925
2117
|
delta.macros = macros;
|
|
1926
2118
|
}
|
|
1927
2119
|
let comboTargetCount = 0;
|
|
1928
|
-
if (
|
|
2120
|
+
if (isMfxManifest(await loadManifest())) {
|
|
1929
2121
|
const comboValues = new Map();
|
|
1930
2122
|
for (let comboIndex = 1; comboIndex <= MFX_COMBO_COUNT; comboIndex++) {
|
|
1931
2123
|
const value = parameters[`mfx_combo${comboIndex}`];
|
|
@@ -1956,21 +2148,14 @@ server.registerTool("set_parameters", {
|
|
|
1956
2148
|
}
|
|
1957
2149
|
await bridge.send("applyUnifiedStateKvUpdate", {
|
|
1958
2150
|
delta,
|
|
1959
|
-
options:
|
|
1960
|
-
shouldDispatch: true,
|
|
1961
|
-
syncKVResources: true,
|
|
1962
|
-
syncConfigResources: true,
|
|
1963
|
-
resyncKVAfterDispatch: true,
|
|
1964
|
-
updateParameters: true,
|
|
1965
|
-
broadcastFollowers: true,
|
|
1966
|
-
},
|
|
2151
|
+
options: STATE_UPDATE_KV_OPTIONS,
|
|
1967
2152
|
});
|
|
1968
2153
|
return text(`Set ${Object.keys(parameters).length} parameter(s): ${Object.entries(parameters).map(([k, v]) => `${k}=${v}`).join(", ")}` +
|
|
1969
2154
|
(comboTargetCount > 0 ? `; fanned out ${comboTargetCount} MFX combo target write(s)` : ""));
|
|
1970
2155
|
});
|
|
1971
2156
|
server.registerTool("load_sample", {
|
|
1972
2157
|
title: "Load Sample",
|
|
1973
|
-
description: "Load an audio file (wav, mp3, aif, flac) into a PAM cell.",
|
|
2158
|
+
description: "Load an audio file (wav, mp3, aif, flac) into a PAM cell, replacing whatever the cell currently holds. Take filePath from list_samples or a get_default_locations root.",
|
|
1974
2159
|
inputSchema: {
|
|
1975
2160
|
filePath: z.string().describe("Path to the audio file (absolute, or relative as returned by list_samples)."),
|
|
1976
2161
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
@@ -2065,7 +2250,7 @@ server.registerTool("list_models", {
|
|
|
2065
2250
|
});
|
|
2066
2251
|
server.registerTool("generate_sample", {
|
|
2067
2252
|
title: "Generate Sample (AI)",
|
|
2068
|
-
description: "Generate a new audio sample via the on-device AI engine (Stable Audio 3 Small SFX, 2.71 GB). The plugin must have the model downloaded first (Settings → Local models). Returns the on-disk path to the generated WAV and optionally loads it into a cell.",
|
|
2253
|
+
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.",
|
|
2069
2254
|
inputSchema: {
|
|
2070
2255
|
category: z
|
|
2071
2256
|
.enum(["kick", "snare", "hat", "perc", "bass", "synth", "pad", "fx", "loop"])
|
|
@@ -2074,13 +2259,8 @@ server.registerTool("generate_sample", {
|
|
|
2074
2259
|
durationSeconds: z
|
|
2075
2260
|
.number()
|
|
2076
2261
|
.min(0.1)
|
|
2077
|
-
.max(
|
|
2078
|
-
.describe("Output length in seconds (
|
|
2079
|
-
mode: z
|
|
2080
|
-
.enum(["text", "inpaint", "continuation"])
|
|
2081
|
-
.optional()
|
|
2082
|
-
.default("text")
|
|
2083
|
-
.describe("Generation mode. text = unconditional, inpaint = audio-to-audio with mask, continuation = extend existing audio."),
|
|
2262
|
+
.max(120)
|
|
2263
|
+
.describe("Output length in seconds (the model's window is ~120 s). For loops, pass bar-converted duration: bars * beatsPerBar * 60 / bpm."),
|
|
2084
2264
|
cellId: z
|
|
2085
2265
|
.number()
|
|
2086
2266
|
.int()
|
|
@@ -2089,11 +2269,6 @@ server.registerTool("generate_sample", {
|
|
|
2089
2269
|
.optional()
|
|
2090
2270
|
.describe("If provided, load result directly into this cell."),
|
|
2091
2271
|
seed: z.number().int().optional().describe("Deterministic seed. Random if omitted."),
|
|
2092
|
-
refAudioPath: z.string().optional().describe("Required for inpaint/continuation. Absolute path to a WAV."),
|
|
2093
|
-
maskSpec: z
|
|
2094
|
-
.string()
|
|
2095
|
-
.optional()
|
|
2096
|
-
.describe('Mask spec for inpaint mode: "tail:0.5" or "transient:0-0.05" or "full".'),
|
|
2097
2272
|
},
|
|
2098
2273
|
}, async (args) => {
|
|
2099
2274
|
const ok = await requireBridge();
|
|
@@ -2109,7 +2284,7 @@ server.registerTool("generate_sample", {
|
|
|
2109
2284
|
});
|
|
2110
2285
|
server.registerTool("generate_kit", {
|
|
2111
2286
|
title: "Generate Kit (AI)",
|
|
2112
|
-
description: "Generate multiple AI samples
|
|
2287
|
+
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).",
|
|
2113
2288
|
inputSchema: {
|
|
2114
2289
|
prompt: z.string().describe('High-level kit description (e.g. "dark trap drum kit", "lofi house").'),
|
|
2115
2290
|
cells: z
|
|
@@ -2149,6 +2324,7 @@ server.registerTool("generate_kit", {
|
|
|
2149
2324
|
// curve / prompt / seed / sigma), sidecar renders, and the take shelf. Requires
|
|
2150
2325
|
// experimentalMode + experimentalAIGen (update_settings) AND the SA3 Python
|
|
2151
2326
|
// sidecar running on 127.0.0.1:8093 (packages/sa3-workbench-sidecar/README.md).
|
|
2327
|
+
const WORKBENCH_PREREQ = "Requires experimentalMode + experimentalAIGen (update_settings) and the SA3 sidecar on 127.0.0.1:8093; check workbench_sidecar_status first. ";
|
|
2152
2328
|
const WORKBENCH_MASK_REGION = z.object({
|
|
2153
2329
|
startSec: z.number().min(0).describe("Region start in seconds (source time)."),
|
|
2154
2330
|
endSec: z.number().min(0).describe("Region end in seconds (must be > startSec)."),
|
|
@@ -2184,7 +2360,8 @@ const WORKBENCH_STATE_FIELDS = {
|
|
|
2184
2360
|
};
|
|
2185
2361
|
server.registerTool("workbench_get_state", {
|
|
2186
2362
|
title: "Workbench: Get State",
|
|
2187
|
-
description:
|
|
2363
|
+
description: WORKBENCH_PREREQ +
|
|
2364
|
+
"Read the AI Audio Workbench canvas state AND the take shelf: { state: { sourceCellId, prompt, seed, seedLocked, sigma, steps, maskRegions, strengthCurve, activeSourcePath, sketchPath, sketchGain }, takes: [...] }.",
|
|
2188
2365
|
inputSchema: {},
|
|
2189
2366
|
}, async () => {
|
|
2190
2367
|
const check = await requireBridge();
|
|
@@ -2194,7 +2371,8 @@ server.registerTool("workbench_get_state", {
|
|
|
2194
2371
|
});
|
|
2195
2372
|
server.registerTool("workbench_set_state", {
|
|
2196
2373
|
title: "Workbench: Set State",
|
|
2197
|
-
description:
|
|
2374
|
+
description: WORKBENCH_PREREQ +
|
|
2375
|
+
"Partial-merge the Workbench canvas state (only the keys you pass change). Also pushes the merged state into an open Workbench UI. Returns the full merged state.",
|
|
2198
2376
|
inputSchema: WORKBENCH_STATE_FIELDS,
|
|
2199
2377
|
}, async (args) => {
|
|
2200
2378
|
const check = await requireBridge();
|
|
@@ -2204,7 +2382,8 @@ server.registerTool("workbench_set_state", {
|
|
|
2204
2382
|
});
|
|
2205
2383
|
server.registerTool("workbench_render", {
|
|
2206
2384
|
title: "Workbench: Render",
|
|
2207
|
-
description:
|
|
2385
|
+
description: WORKBENCH_PREREQ +
|
|
2386
|
+
"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).",
|
|
2208
2387
|
inputSchema: {
|
|
2209
2388
|
...WORKBENCH_STATE_FIELDS,
|
|
2210
2389
|
sourcePath: z.string().optional()
|
|
@@ -2223,7 +2402,7 @@ server.registerTool("workbench_render", {
|
|
|
2223
2402
|
});
|
|
2224
2403
|
server.registerTool("workbench_list_takes", {
|
|
2225
2404
|
title: "Workbench: List Takes",
|
|
2226
|
-
description: "List all Workbench takes (renders) with full provenance, oldest first.",
|
|
2405
|
+
description: WORKBENCH_PREREQ + "List all Workbench takes (renders) with full provenance, oldest first.",
|
|
2227
2406
|
inputSchema: {},
|
|
2228
2407
|
}, async () => {
|
|
2229
2408
|
const check = await requireBridge();
|
|
@@ -2233,7 +2412,7 @@ server.registerTool("workbench_list_takes", {
|
|
|
2233
2412
|
});
|
|
2234
2413
|
server.registerTool("workbench_load_take", {
|
|
2235
2414
|
title: "Workbench: Load Take",
|
|
2236
|
-
description: "Load a rendered take's WAV into a cell (same native path as load_sample).",
|
|
2415
|
+
description: WORKBENCH_PREREQ + "Load a rendered take's WAV into a cell (same native path as load_sample).",
|
|
2237
2416
|
inputSchema: {
|
|
2238
2417
|
takeId: z.string().describe("Take id from workbench_render / workbench_list_takes."),
|
|
2239
2418
|
cellId: z.number().int().min(1).max(8).describe("Target cell."),
|
|
@@ -2251,7 +2430,8 @@ server.registerTool("workbench_load_take", {
|
|
|
2251
2430
|
});
|
|
2252
2431
|
server.registerTool("workbench_undo", {
|
|
2253
2432
|
title: "Workbench: Undo (pop chain)",
|
|
2254
|
-
description:
|
|
2433
|
+
description: WORKBENCH_PREREQ +
|
|
2434
|
+
"Pop the Workbench render chain one step: restores the previous chain head (activeSourcePath) so the next render re-transforms the prior source. No-op at the chain root (cell sample). Takes are kept. Returns the full { state, takes }.",
|
|
2255
2435
|
inputSchema: {},
|
|
2256
2436
|
}, async () => {
|
|
2257
2437
|
const check = await requireBridge();
|
|
@@ -2261,7 +2441,8 @@ server.registerTool("workbench_undo", {
|
|
|
2261
2441
|
});
|
|
2262
2442
|
server.registerTool("workbench_reset_chain", {
|
|
2263
2443
|
title: "Workbench: Reset Chain",
|
|
2264
|
-
description:
|
|
2444
|
+
description: WORKBENCH_PREREQ +
|
|
2445
|
+
"Clear the Workbench render chain: empties the undo stack and resets the source back to the cell sample (activeSourcePath = ''). Takes are kept. Returns the full { state, takes }.",
|
|
2265
2446
|
inputSchema: {},
|
|
2266
2447
|
}, async () => {
|
|
2267
2448
|
const check = await requireBridge();
|
|
@@ -2322,17 +2503,31 @@ server.registerTool("stop_cell", {
|
|
|
2322
2503
|
});
|
|
2323
2504
|
server.registerTool("load_preset", {
|
|
2324
2505
|
title: "Load Preset",
|
|
2325
|
-
description: "
|
|
2506
|
+
description: "Queue a preset load through PAM's native load/morph lifecycle. Returns requestId and status:'queued'. " +
|
|
2507
|
+
"Poll get_preset_load_status until status:'succeeded' before editing, randomizing, or saving; failure, cancellation, and supersession are explicit. " +
|
|
2508
|
+
"Replaces working state and the variation bank; save_preset first if unsaved edits matter. Requires a plugin build with tracked preset loading.",
|
|
2326
2509
|
inputSchema: {
|
|
2327
2510
|
path: z.string().describe("Preset file path (absolute or relative to preset dirs)."),
|
|
2511
|
+
immediate: z.boolean().optional().describe("true bypasses the preset morph; false/omitted follows the native preset morph settings."),
|
|
2328
2512
|
},
|
|
2329
|
-
}, async ({ path: presetPath }) => {
|
|
2513
|
+
}, async ({ path: presetPath, immediate }) => {
|
|
2330
2514
|
const check = await requireBridge();
|
|
2331
2515
|
if (check !== true)
|
|
2332
2516
|
return check;
|
|
2333
2517
|
const fullPath = await resolvePresetPath(presetPath);
|
|
2334
|
-
await bridge.send("
|
|
2335
|
-
|
|
2518
|
+
return json(await bridge.send("loadPresetTracked", fullPath, immediate === true));
|
|
2519
|
+
});
|
|
2520
|
+
server.registerTool("get_preset_load_status", {
|
|
2521
|
+
title: "Get Preset Load Status",
|
|
2522
|
+
description: "Read the outcome of a load_preset request without requiring the plugin window. " +
|
|
2523
|
+
"Statuses: queued, morphing, succeeded, failed, cancelled, superseded. Only succeeded confirms the world committed, including same-path reloads. " +
|
|
2524
|
+
"PAM retains the latest 64 requests per instance; unknown/expired IDs return an error. Keep the same selected instance while polling.",
|
|
2525
|
+
inputSchema: { requestId: z.string().min(1).describe("requestId returned by load_preset.") },
|
|
2526
|
+
}, async ({ requestId }) => {
|
|
2527
|
+
const check = await requireBridge();
|
|
2528
|
+
if (check !== true)
|
|
2529
|
+
return check;
|
|
2530
|
+
return json(await bridge.send("getPresetLoadStatus", requestId));
|
|
2336
2531
|
});
|
|
2337
2532
|
server.registerTool("add_modulation", {
|
|
2338
2533
|
title: "Add Modulation",
|
|
@@ -2375,11 +2570,9 @@ server.registerTool("add_modulation", {
|
|
|
2375
2570
|
},
|
|
2376
2571
|
}, async ({ type, paramId, cellId, lfoIndex, strength, bipolar, pitch, pitchDuck }) => {
|
|
2377
2572
|
const index = await getParameterIndex();
|
|
2378
|
-
const param =
|
|
2379
|
-
if (
|
|
2380
|
-
return error(
|
|
2381
|
-
if (!param.isModulatable)
|
|
2382
|
-
return error(formatNotModulatableError(paramId, index));
|
|
2573
|
+
const param = await resolveModulationTarget(paramId, index);
|
|
2574
|
+
if (typeof param === "string")
|
|
2575
|
+
return error(param);
|
|
2383
2576
|
const check = await requireBridgeForCells(cellId);
|
|
2384
2577
|
if (check !== true)
|
|
2385
2578
|
return check;
|
|
@@ -2405,34 +2598,42 @@ server.registerTool("add_modulation", {
|
|
|
2405
2598
|
}
|
|
2406
2599
|
return text(`Added ${type} modulation to ${paramId} (strength: ${strength}%).`);
|
|
2407
2600
|
});
|
|
2601
|
+
/** The full parameter rows (ranges, isModulatable) a lane target is checked against. */
|
|
2602
|
+
async function laneTargetParameters() {
|
|
2603
|
+
return loadManifest()
|
|
2604
|
+
.then((manifest) => manifest.parameters)
|
|
2605
|
+
.catch(() => []);
|
|
2606
|
+
}
|
|
2607
|
+
/** A cell's param-lane target suffix ("" = none). */
|
|
2608
|
+
async function laneTargetOf(cellId, lane) {
|
|
2609
|
+
const cells = await bridge.send("getState", "cells").catch(() => undefined);
|
|
2610
|
+
const cell = isRecord(cells) ? cells[String(cellId)] : undefined;
|
|
2611
|
+
const target = isRecord(cell) ? cell[LANE_TARGET_FIELD[lane]] : undefined;
|
|
2612
|
+
return typeof target === "string" ? target : "";
|
|
2613
|
+
}
|
|
2408
2614
|
server.registerTool("update_pattern", {
|
|
2409
2615
|
title: "Update Pattern",
|
|
2410
|
-
description: "Write a
|
|
2616
|
+
description: "Write one lane of a cell's pattern: the notes, a value lane (pitch, slice, stretch), a CC lane (ccOut1-ccOut4, " +
|
|
2617
|
+
"the timeline of that CC out slot: each event sends its value on the slot's controller for its duration, then the " +
|
|
2618
|
+
"slot returns to its knob) or a PAM param lane (paramLane1-paramLane4: each event sets the parameter the lane " +
|
|
2619
|
+
"targets for its duration, in that parameter's units). MSQ has note and CC lanes; MFX has none.",
|
|
2411
2620
|
inputSchema: {
|
|
2412
2621
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
2413
|
-
type: z
|
|
2414
|
-
.enum(["note", "pitch", "slice", "stretch"])
|
|
2415
|
-
.describe("Pattern lane type."),
|
|
2622
|
+
type: z.enum(PATTERN_LANE_IDS).describe("Pattern lane."),
|
|
2416
2623
|
sequence: z
|
|
2417
2624
|
.array(z.any())
|
|
2418
2625
|
.describe("Array of sequence events. For 'note': [{time, note, duration, velocity, prob}]. " +
|
|
2419
|
-
"For
|
|
2626
|
+
"For value and CC lanes: [{time, value, duration, prob?}] (CC values 0-127; times and durations in beats)."),
|
|
2420
2627
|
},
|
|
2421
2628
|
}, async ({ cellId, type, sequence }) => {
|
|
2422
2629
|
const check = await requireBridgeForCells(cellId);
|
|
2423
2630
|
if (check !== true)
|
|
2424
2631
|
return check;
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
slice: "sliceSequence",
|
|
2431
|
-
stretch: "stretchSequence",
|
|
2432
|
-
};
|
|
2433
|
-
const suffix = keyMap[type];
|
|
2434
|
-
if (!suffix)
|
|
2435
|
-
return error(`Unknown pattern type: ${type}`);
|
|
2632
|
+
const manifest = await loadManifestForProductGuard();
|
|
2633
|
+
if (manifest && !productHasLane(cellId, type, manifest.stateVariables)) {
|
|
2634
|
+
return error(`${manifest.name ?? "This product"} has no ${type} lane.`);
|
|
2635
|
+
}
|
|
2636
|
+
const suffix = PATTERN_LANE_SUFFIX[type];
|
|
2436
2637
|
// For note sequences, fill in required defaults that PAM's sequencer expects
|
|
2437
2638
|
let normalizedSequence = sequence;
|
|
2438
2639
|
if (suffix === "midiSequence") {
|
|
@@ -2455,28 +2656,88 @@ server.registerTool("update_pattern", {
|
|
|
2455
2656
|
value: evt.value ?? evt.velocity ?? 127,
|
|
2456
2657
|
}));
|
|
2457
2658
|
}
|
|
2659
|
+
if (isCcLane(type))
|
|
2660
|
+
normalizedSequence = normalizeCcLaneEvents(sequence);
|
|
2661
|
+
if (isParamLane(type)) {
|
|
2662
|
+
const target = await laneTargetOf(cellId, type);
|
|
2663
|
+
if (!target)
|
|
2664
|
+
return error(`Cell ${cellId}'s ${type} has no target parameter: pick one with set_lane_target first.`);
|
|
2665
|
+
const resolved = resolveLaneTarget(cellId, target, await laneTargetParameters());
|
|
2666
|
+
if ("reason" in resolved)
|
|
2667
|
+
return error(`The ${type} lane targets ${target}: ${resolved.reason}.`);
|
|
2668
|
+
normalizedSequence = normalizeTargetLaneEvents(sequence, resolved.param);
|
|
2669
|
+
}
|
|
2458
2670
|
const stateKey = `cell${cellId}_${suffix}`;
|
|
2459
2671
|
await bridge.send("applyUnifiedStateKvUpdate", {
|
|
2460
2672
|
delta: { [stateKey]: normalizedSequence, __forceRender__: true },
|
|
2461
|
-
options:
|
|
2462
|
-
shouldDispatch: true,
|
|
2463
|
-
syncKVResources: true,
|
|
2464
|
-
syncConfigResources: false,
|
|
2465
|
-
skipFileLoadingIfUnchanged: true,
|
|
2466
|
-
resyncKVAfterDispatch: false,
|
|
2467
|
-
updateParameters: false,
|
|
2468
|
-
broadcastFollowers: false,
|
|
2469
|
-
},
|
|
2673
|
+
options: PATTERN_EDIT_KV_OPTIONS,
|
|
2470
2674
|
directNodeOps: [{ key: stateKey, property: "sequence", value: normalizedSequence }],
|
|
2471
2675
|
});
|
|
2472
2676
|
return text(`Updated ${type} pattern for cell ${cellId} (${normalizedSequence.length} events).`);
|
|
2473
2677
|
});
|
|
2678
|
+
server.registerTool("set_lane_target", {
|
|
2679
|
+
title: "Set Lane Target",
|
|
2680
|
+
description: "Point one of a PAM cell's four param lanes (paramLane1-paramLane4) at a modulatable parameter of the same cell " +
|
|
2681
|
+
"(a suffix such as 'filterCutoff'), or clear it with null. Each lane event then sets that parameter for its " +
|
|
2682
|
+
"duration; outside events the parameter follows its knob and modulation. Each param lane loops at its own " +
|
|
2683
|
+
"clock, cell{N}_patternLengthParam{K} and cell{N}_patternSpeedParam{K} (set_parameters; 4 beats at 1x by default), " +
|
|
2684
|
+
"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 " +
|
|
2685
|
+
"parameter is cleared too. Write the lane with update_pattern in the target's units. For a free-running step " +
|
|
2686
|
+
"contour that adds to the knob, use a ParamSeq instead.",
|
|
2687
|
+
inputSchema: {
|
|
2688
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
2689
|
+
lane: z.enum(PARAM_LANES).describe("Param lane to point."),
|
|
2690
|
+
param: z.string().min(1).nullable().describe("Target parameter suffix (no cellN_ prefix), or null for none."),
|
|
2691
|
+
},
|
|
2692
|
+
}, async ({ cellId, lane, param }) => {
|
|
2693
|
+
const check = await requireBridgeForCells(cellId);
|
|
2694
|
+
if (check !== true)
|
|
2695
|
+
return check;
|
|
2696
|
+
const manifest = await loadManifestForProductGuard();
|
|
2697
|
+
if (manifest && !productHasLane(cellId, lane, manifest.stateVariables)) {
|
|
2698
|
+
return error(`${manifest.name ?? "This product"} has no ${lane} lane.`);
|
|
2699
|
+
}
|
|
2700
|
+
let targetParam = null;
|
|
2701
|
+
if (param !== null) {
|
|
2702
|
+
const resolved = resolveLaneTarget(cellId, param, await laneTargetParameters());
|
|
2703
|
+
if ("reason" in resolved)
|
|
2704
|
+
return error(`Cannot target ${param}: ${resolved.reason}.`);
|
|
2705
|
+
targetParam = resolved.param;
|
|
2706
|
+
}
|
|
2707
|
+
const cells = await bridge.send("getState", "cells").catch(() => undefined);
|
|
2708
|
+
const cell = isRecord(cells) ? cells[String(cellId)] : undefined;
|
|
2709
|
+
const target = param ?? "";
|
|
2710
|
+
const current = isRecord(cell) && typeof cell[LANE_TARGET_FIELD[lane]] === "string" ? cell[LANE_TARGET_FIELD[lane]] : "";
|
|
2711
|
+
if (current === target)
|
|
2712
|
+
return text(`Cell ${cellId} ${lane} already ${target ? `→ ${target}` : "has no target"}; nothing changed.`);
|
|
2713
|
+
const cellDelta = { [LANE_TARGET_FIELD[lane]]: target };
|
|
2714
|
+
const delta = { [`cell${cellId}_${PATTERN_LANE_SUFFIX[lane]}`]: [] };
|
|
2715
|
+
const reverted = [];
|
|
2716
|
+
for (const sibling of PARAM_LANES) {
|
|
2717
|
+
if (sibling === lane || !target || !isRecord(cell) || cell[LANE_TARGET_FIELD[sibling]] !== target)
|
|
2718
|
+
continue;
|
|
2719
|
+
cellDelta[LANE_TARGET_FIELD[sibling]] = "";
|
|
2720
|
+
delta[`cell${cellId}_${PATTERN_LANE_SUFFIX[sibling]}`] = [];
|
|
2721
|
+
reverted.push(sibling);
|
|
2722
|
+
}
|
|
2723
|
+
delta.cells = { [cellId]: cellDelta };
|
|
2724
|
+
// Same as add_modulation: a lane on a bypassed effect's param turns the effect on.
|
|
2725
|
+
if (targetParam?.effect && targetParam.cell)
|
|
2726
|
+
delta[`${targetParam.cell}_${targetParam.effect}Enabled`] = 1;
|
|
2727
|
+
await applyKv(delta, STATE_UPDATE_KV_OPTIONS);
|
|
2728
|
+
const where = target ? `→ ${target}` : "target cleared";
|
|
2729
|
+
const note = reverted.length ? ` ${reverted.join(", ")} lost that target and was cleared.` : "";
|
|
2730
|
+
return text(`Cell ${cellId} ${lane} ${where}; lane cleared.${note}`);
|
|
2731
|
+
});
|
|
2474
2732
|
server.registerTool("randomize", {
|
|
2475
2733
|
title: "Randomize",
|
|
2476
2734
|
description: "Trigger PAM's randomization engine to generate random sounds and patterns. " +
|
|
2477
2735
|
"target:'synth' runs the musical, engine-aware drum-synth randomizer (picks an engine + " +
|
|
2478
2736
|
"shapes its params per cell) — it is routed through the WebView, so it requires PAM's window " +
|
|
2479
|
-
"to be OPEN (samples/sequences/full run natively and do not).
|
|
2737
|
+
"to be OPEN (samples/sequences/full run natively and do not). 'lfos'/'mods' are global and require an open window. " +
|
|
2738
|
+
"Returns queued, not completed: poll get_state for the requested changes before another randomize, edit, or save. " +
|
|
2739
|
+
"Concurrent requests can coalesce; a preset/variation load can discard pending work. " +
|
|
2740
|
+
"Use target:'full' to chain sample selection and pattern generation in the native engine.",
|
|
2480
2741
|
inputSchema: {
|
|
2481
2742
|
// 'lfos'/'mods' added for the assistant surface (1.15.0) — WebView-routed
|
|
2482
2743
|
// like 'synth'. The describe() text is pinned (exported toolpack bytes).
|
|
@@ -2487,7 +2748,7 @@ server.registerTool("randomize", {
|
|
|
2487
2748
|
cellIds: z
|
|
2488
2749
|
.array(z.number().min(1).max(8))
|
|
2489
2750
|
.optional()
|
|
2490
|
-
.describe("Specific cells to randomize. Omit for all."),
|
|
2751
|
+
.describe("Specific cells to randomize. Omit for all. Not supported for global 'lfos'/'mods'."),
|
|
2491
2752
|
genreHint: z
|
|
2492
2753
|
.string()
|
|
2493
2754
|
.optional()
|
|
@@ -2518,6 +2779,8 @@ server.registerTool("randomize", {
|
|
|
2518
2779
|
.describe("Rhythmic placement for 'sequences'/'full' (-1 = strong beats, 0 = neutral, 1 = weaker offbeats). Omit for the genre and role default."),
|
|
2519
2780
|
},
|
|
2520
2781
|
}, async ({ target, cellIds, genreHint, bpmHint, mutationAmount, density, syncopation }) => {
|
|
2782
|
+
if ((target === "lfos" || target === "mods") && cellIds !== undefined)
|
|
2783
|
+
return error(`target:'${target}' randomizes global modulation; omit cellIds or edit individual links with update_modulation.`);
|
|
2521
2784
|
const check = await requireBridgeForCells(cellIds);
|
|
2522
2785
|
if (check !== true)
|
|
2523
2786
|
return check;
|
|
@@ -2525,23 +2788,21 @@ server.registerTool("randomize", {
|
|
|
2525
2788
|
// Routed to the canonical WebView randomizer (single source of truth) — needs the
|
|
2526
2789
|
// window open. Empty array → all non-excluded cells.
|
|
2527
2790
|
await bridge.send("randomizeDrumSynth", cellIds ?? []);
|
|
2528
|
-
return
|
|
2529
|
-
`(Drum-synth randomize runs in the WebView — if nothing changed, ensure PAM's window is open.)`);
|
|
2791
|
+
return json({ status: "queued", target, cellIds, note: "Open PAM's window and poll get_design_overview for synth changes before editing or saving." });
|
|
2530
2792
|
}
|
|
2531
2793
|
if (target === "lfos" || target === "mods") {
|
|
2532
|
-
// The data-driven modulation randomizer lives in WebView JS
|
|
2533
|
-
// (interop.ts randomizeLfos/randomizeEnvelopes/randomizeVary) — routed
|
|
2534
|
-
// like 'synth'. Needs PAM ≥ 1.15.0 and the window open.
|
|
2535
2794
|
await bridge.send("randomizeModulations", target, mutationAmount ?? -1);
|
|
2536
|
-
return
|
|
2537
|
-
`(Runs in the WebView — if nothing changed, ensure PAM's window is open.)`);
|
|
2795
|
+
return json({ status: "queued", target, note: "Open PAM's window and poll get_state for modulation changes before editing or saving." });
|
|
2538
2796
|
}
|
|
2539
2797
|
await bridge.send("randomize", target, cellIds ?? [], genreHint ?? "", bpmHint ?? -1, mutationAmount ?? -1, density ?? null, syncopation ?? null);
|
|
2540
|
-
return
|
|
2798
|
+
return json({ status: "queued", target, cellIds, note: "Poll get_state for changed sample paths and/or sequences before another randomize, edit, or save. An acknowledgment does not prove the request applied." });
|
|
2541
2799
|
});
|
|
2542
2800
|
server.registerTool("randomize_samples", {
|
|
2543
2801
|
title: "Randomize Samples",
|
|
2544
|
-
description: "Use PAM's native sample randomization engine to pick coherent samples based on genre, BPM, and kit role.
|
|
2802
|
+
description: "Use PAM's native sample randomization engine to pick coherent samples based on genre, BPM, and kit role. Supports folder filtering. " +
|
|
2803
|
+
"Returns queued: poll get_state section:'cells' for sample changes before generating patterns, editing, or saving. " +
|
|
2804
|
+
"Serialize requests because concurrent requests can coalesce; a preset/variation load can discard pending work. " +
|
|
2805
|
+
"For samples plus patterns, prefer randomize target:'full'.",
|
|
2545
2806
|
inputSchema: {
|
|
2546
2807
|
cellIds: z
|
|
2547
2808
|
.array(z.number().min(1).max(8))
|
|
@@ -2573,8 +2834,8 @@ server.registerTool("randomize_samples", {
|
|
|
2573
2834
|
.describe("Anti-centroid mode (0 = default behaviour, 1 = maximum spread). The native engine scores FOLDER " +
|
|
2574
2835
|
"COHERENCE, which is correct for a kit-from-one-pack and is also why repeated randomizes keep landing " +
|
|
2575
2836
|
"on the same few well-stocked folders. Above 0 this builds a cross-folder pool first and restricts the " +
|
|
2576
|
-
"engine to it, keeping role assignment while removing its ability to converge.
|
|
2577
|
-
"
|
|
2837
|
+
"engine to it, keeping role assignment while removing its ability to converge. Ignored when " +
|
|
2838
|
+
"allowedSamplePaths is given."),
|
|
2578
2839
|
seed: z
|
|
2579
2840
|
.number()
|
|
2580
2841
|
.int()
|
|
@@ -2589,10 +2850,8 @@ server.registerTool("randomize_samples", {
|
|
|
2589
2850
|
let pool = allowedSamplePaths;
|
|
2590
2851
|
let diversifyNote = "";
|
|
2591
2852
|
if (!pool && diversify && diversify > 0) {
|
|
2592
|
-
const locations =
|
|
2593
|
-
const roots =
|
|
2594
|
-
.filter(([k, v]) => typeof v === "string" && v.length > 0 && /sample/i.test(k))
|
|
2595
|
-
.map(([, v]) => v);
|
|
2853
|
+
const locations = await defaultLocationPaths();
|
|
2854
|
+
const roots = ["User Samples", "Factory Samples"].flatMap((name) => locations.get(name) ?? []);
|
|
2596
2855
|
if (roots.length) {
|
|
2597
2856
|
const byFolder = await collectLibraryByFolder(roots);
|
|
2598
2857
|
const spread = spreadAcrossFolders(byFolder, diversify, seed ?? 0);
|
|
@@ -2609,11 +2868,14 @@ server.registerTool("randomize_samples", {
|
|
|
2609
2868
|
}
|
|
2610
2869
|
}
|
|
2611
2870
|
await bridge.send("randomizeSamples", cellIds ?? [], genreHint ?? "", bpmHint ?? -1, excludedFolders ?? [], pool ?? []);
|
|
2612
|
-
return
|
|
2871
|
+
return json({ status: "queued", target: "samples", cellIds, genreHint,
|
|
2872
|
+
note: "Poll get_state section:'cells' for changed sample paths before another randomize, patterns, edit, or save." + diversifyNote });
|
|
2613
2873
|
});
|
|
2614
2874
|
server.registerTool("randomize_patterns", {
|
|
2615
2875
|
title: "Randomize Patterns",
|
|
2616
|
-
description: "Use PAM's native pattern generation engine to create
|
|
2876
|
+
description: "Use PAM's native pattern generation engine to create note patterns based on loaded sample roles, genre, and BPM. " +
|
|
2877
|
+
"Identical to randomize target:'sequences'. Returns queued: poll get_state for changed cellN_midiSequence values " +
|
|
2878
|
+
"before another randomize, edit, or save. Wait for sample loading/randomization first; concurrent requests can coalesce.",
|
|
2617
2879
|
inputSchema: {
|
|
2618
2880
|
cellIds: z
|
|
2619
2881
|
.array(z.number().min(1).max(8))
|
|
@@ -2636,8 +2898,8 @@ server.registerTool("randomize_patterns", {
|
|
|
2636
2898
|
.optional()
|
|
2637
2899
|
.describe("How much to vary from typical patterns (0 = conventional, 1 = experimental). Default ~0.5. " +
|
|
2638
2900
|
"This is the pattern-side divergence lever: the generator otherwise produces the genre's most typical " +
|
|
2639
|
-
"rhythm, which is exactly what makes a batch of presets feel the same
|
|
2640
|
-
"
|
|
2901
|
+
"rhythm, which is exactly what makes a batch of presets feel the same; 0.7-1.0 buys rhythmic " +
|
|
2902
|
+
"distinctness, low values keep a stock groove."),
|
|
2641
2903
|
density: z
|
|
2642
2904
|
.number()
|
|
2643
2905
|
.min(-1)
|
|
@@ -2656,11 +2918,12 @@ server.registerTool("randomize_patterns", {
|
|
|
2656
2918
|
if (check !== true)
|
|
2657
2919
|
return check;
|
|
2658
2920
|
await bridge.send("randomize", "sequences", cellIds ?? [], genreHint ?? "", bpmHint ?? -1, mutationAmount ?? -1, density ?? null, syncopation ?? null);
|
|
2659
|
-
return
|
|
2921
|
+
return json({ status: "queued", target: "sequences", cellIds, genreHint,
|
|
2922
|
+
note: "Poll get_state for changed cellN_midiSequence values before another randomize, edit, or save." });
|
|
2660
2923
|
});
|
|
2661
2924
|
server.registerTool("load_variation", {
|
|
2662
2925
|
title: "Load Variation",
|
|
2663
|
-
description: "
|
|
2926
|
+
description: "Request a switch to a saved variation slot (0-7), including parameters, effects, patterns, and modulation. Empty slots are rejected. Verify currentVariation and a slot-specific value with get_state before editing or saving.",
|
|
2664
2927
|
inputSchema: {
|
|
2665
2928
|
index: z
|
|
2666
2929
|
.number()
|
|
@@ -2674,7 +2937,7 @@ server.registerTool("load_variation", {
|
|
|
2674
2937
|
if (check !== true)
|
|
2675
2938
|
return check;
|
|
2676
2939
|
await bridge.send("loadVariation", index);
|
|
2677
|
-
return text(`
|
|
2940
|
+
return text(`Requested variation ${index} (UI slot ${index + 1}); verify currentVariation and the intended state with get_state before editing or saving.`);
|
|
2678
2941
|
});
|
|
2679
2942
|
server.registerTool("full_stop_transport", {
|
|
2680
2943
|
title: "Full Stop Transport",
|
|
@@ -2726,9 +2989,11 @@ server.registerTool("get_mix_analysis", {
|
|
|
2726
2989
|
title: "Get Per-Cell Mix Analysis",
|
|
2727
2990
|
description: "Per-cell stem measurements from a faster-than-realtime offline bounce: LUFS (i/s/roll), LRA, " +
|
|
2728
2991
|
"true/sample peak (tp/sp), PSR/PLR crest, stereo correlation, onset density, and a 25-band Bark " +
|
|
2729
|
-
"spectrum per cell, plus the full-mix master block.
|
|
2730
|
-
"
|
|
2731
|
-
"
|
|
2992
|
+
"spectrum per cell, plus the full-mix master block. Each cell also carries coact: {otherCellId: " +
|
|
2993
|
+
"fraction of this cell's sounding time the other cell sounds too}. Pass refresh:true to run a new " +
|
|
2994
|
+
"analysis (one in-context render from bar 0; each cell is measured at its post-fader output before " +
|
|
2995
|
+
"the master chain, so muted cells read silent — takes a few seconds; audio does NOT need to be " +
|
|
2996
|
+
"playing). Cached result otherwise. Requires a PAM build with Mix Assist support.",
|
|
2732
2997
|
inputSchema: {
|
|
2733
2998
|
refresh: z.boolean().optional().describe("Run a fresh analysis instead of returning the cached one."),
|
|
2734
2999
|
durationSeconds: z.number().min(5).max(30).optional().describe("Analysis render length (default 8 bars at the current BPM, clamped 5-30 s)."),
|
|
@@ -2783,7 +3048,7 @@ server.registerTool("suggest_mix", {
|
|
|
2783
3048
|
"partial-loudness fader balance, pan spread, bounded masking EQ cuts, and optional compressor " +
|
|
2784
3049
|
"settings. Returns absolute moves with per-move rationale and guard notes; apply:true lands them " +
|
|
2785
3050
|
"as one state update. Run get_mix_analysis with refresh:true first if the mix changed. " +
|
|
2786
|
-
"Requires PAM
|
|
3051
|
+
"Requires a PAM build with Mix Assist support.",
|
|
2787
3052
|
inputSchema: {
|
|
2788
3053
|
scopes: z
|
|
2789
3054
|
.array(z.enum(["balance", "pan", "eq", "dynamics"]))
|
|
@@ -2848,7 +3113,7 @@ server.registerTool("suggest_mix", {
|
|
|
2848
3113
|
? { balanceParked: true, note: "Auto Mix AUTO owns the balance — balance scope was skipped." }
|
|
2849
3114
|
: {};
|
|
2850
3115
|
if (apply && suggestion.moves.length > 0) {
|
|
2851
|
-
await applyKv(suggestion.flat,
|
|
3116
|
+
await applyKv(suggestion.flat, STATE_UPDATE_KV_OPTIONS);
|
|
2852
3117
|
if (!analysis.live)
|
|
2853
3118
|
lastAppliedMixEpoch = analysis.epoch;
|
|
2854
3119
|
return json({ ...suggestion, ...parkedNote, applied: true });
|
|
@@ -2870,7 +3135,8 @@ server.registerTool("new_preset", {
|
|
|
2870
3135
|
});
|
|
2871
3136
|
server.registerTool("save_preset", {
|
|
2872
3137
|
title: "Save Preset",
|
|
2873
|
-
description: "Save the current PAM state as a
|
|
3138
|
+
description: "Save the current PAM state as a preset file with metadata. Omit `name` to re-save the active user preset in place " +
|
|
3139
|
+
"(factory presets are refused). Every save also snapshots the current state into variation slot 0 if the bank is empty, otherwise into the active slot. " +
|
|
2874
3140
|
"Writes to the user preset folder by default; pass `path` for a specific location. " +
|
|
2875
3141
|
"Use `update_preset_metadata` to edit an existing preset without re-saving plugin state.",
|
|
2876
3142
|
inputSchema: {
|
|
@@ -2921,24 +3187,17 @@ server.registerTool("save_preset", {
|
|
|
2921
3187
|
return error(`Could not derive a name from the active preset path (${activePath}) — pass name explicitly.`);
|
|
2922
3188
|
if (path === undefined)
|
|
2923
3189
|
path = activePath;
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
continue;
|
|
2931
|
-
if (extras[k] === undefined)
|
|
2932
|
-
extras[k] = v;
|
|
2933
|
-
}
|
|
2934
|
-
}
|
|
2935
|
-
catch {
|
|
2936
|
-
// unreadable/new — save fresh with whatever the caller passed
|
|
3190
|
+
const parsed = await readPresetForSave(readPath);
|
|
3191
|
+
for (const [k, v] of Object.entries(parsed.metadata)) {
|
|
3192
|
+
if (["name", "factory", "path", "variations", "currentVariation"].includes(k))
|
|
3193
|
+
continue;
|
|
3194
|
+
if (extras[k] === undefined)
|
|
3195
|
+
extras[k] = v;
|
|
2937
3196
|
}
|
|
2938
3197
|
}
|
|
2939
3198
|
const metadata = { name, factory: false };
|
|
2940
3199
|
if (path !== undefined)
|
|
2941
|
-
metadata.path = path;
|
|
3200
|
+
metadata.path = nativePresetPath(path);
|
|
2942
3201
|
for (const [k, v] of Object.entries(extras)) {
|
|
2943
3202
|
if (v !== undefined)
|
|
2944
3203
|
metadata[k] = v;
|
|
@@ -2952,7 +3211,11 @@ server.registerTool("save_preset", {
|
|
|
2952
3211
|
// the new destination starts from eight empty slots because that file does
|
|
2953
3212
|
// not exist yet, which used to collapse every named save to one slot.
|
|
2954
3213
|
const variationSourcePath = selectSaveVariationSourcePath(state.activePresetPath, presetRelPath);
|
|
2955
|
-
const
|
|
3214
|
+
const hasActivePreset = typeof state.activePresetPath === "string"
|
|
3215
|
+
&& state.activePresetPath.length > 0 && state.activePresetPath !== "Init";
|
|
3216
|
+
const source = await readPresetForSave(variationSourcePath, !hasActivePreset);
|
|
3217
|
+
const existingVariations = source
|
|
3218
|
+
? variationsFromParsedPreset(source) : Array(PRESET_VARIATION_SLOTS).fill(null);
|
|
2956
3219
|
const hasVariations = existingVariations.some((v) => v !== null);
|
|
2957
3220
|
if (!hasVariations) {
|
|
2958
3221
|
existingVariations[0] = variationString;
|
|
@@ -3002,49 +3265,25 @@ server.registerTool("save_variation", {
|
|
|
3002
3265
|
const check = await requireBridge();
|
|
3003
3266
|
if (check !== true)
|
|
3004
3267
|
return check;
|
|
3005
|
-
// 1. Canonical save snapshot (engine + hosted-plugin state), then shape it
|
|
3006
3268
|
const { state, canonical } = await fetchSaveSnapshot();
|
|
3007
3269
|
const variationString = buildVariationString(state);
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
let diskPath;
|
|
3011
|
-
const activePath = state.activePresetPath;
|
|
3012
|
-
if (!presetName && activePath) {
|
|
3013
|
-
const filename = activePath.split("/").pop() || "";
|
|
3014
|
-
presetName = filename.replace(/\.preset$/, "");
|
|
3015
|
-
// Use active path for disk lookup (preserves subfolder structure).
|
|
3016
|
-
// Strip "Presets/" prefix if present — resolvePresetPath already prepends
|
|
3017
|
-
// the presets base directory, so "Presets/User/X" would double up to
|
|
3018
|
-
// ".../Presets/Presets/User/X" and silently fail to read back variations.
|
|
3019
|
-
diskPath = normalizePresetLookupPath(activePath);
|
|
3020
|
-
}
|
|
3021
|
-
if (!presetName && lastSavedPresetName) {
|
|
3022
|
-
presetName = lastSavedPresetName;
|
|
3023
|
-
diskPath = lastSavedPresetPath;
|
|
3270
|
+
if (!name && state.activePresetPath === "Init") {
|
|
3271
|
+
return error("No active preset. Call save_preset with a name first, or pass a name to save_variation.");
|
|
3024
3272
|
}
|
|
3273
|
+
const activePath = typeof state.activePresetPath === "string" && state.activePresetPath !== "Init"
|
|
3274
|
+
? normalizePresetLookupPath(state.activePresetPath) : "";
|
|
3275
|
+
const presetName = name || (activePath.split("/").pop() || "").replace(/\.preset$/, "") || lastSavedPresetName;
|
|
3025
3276
|
if (!presetName) {
|
|
3026
|
-
return error("No preset name
|
|
3027
|
-
|
|
3028
|
-
}
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
try {
|
|
3036
|
-
const fullPath = await resolvePresetPath(diskPath);
|
|
3037
|
-
const raw = await readFile(fullPath, "utf-8");
|
|
3038
|
-
const parsed = parsePresetFile(raw);
|
|
3039
|
-
existingMeta = parsed.metadata;
|
|
3040
|
-
variations = variationsFromParsedPreset(parsed);
|
|
3041
|
-
}
|
|
3042
|
-
catch {
|
|
3043
|
-
// New preset or unreadable — start fresh
|
|
3044
|
-
}
|
|
3045
|
-
// 4. Update the target slot
|
|
3277
|
+
return error("No active preset. Call save_preset with a name first, or pass a name to save_variation.");
|
|
3278
|
+
}
|
|
3279
|
+
const diskPath = name ? `User/${presetName}.preset`
|
|
3280
|
+
: activePath || lastSavedPresetPath || `User/${presetName}.preset`;
|
|
3281
|
+
const sourcePath = selectSaveVariationSourcePath(activePath, diskPath);
|
|
3282
|
+
const parsed = await readPresetForSave(sourcePath, !!name && !activePath);
|
|
3283
|
+
const existingMeta = parsed?.metadata ?? {};
|
|
3284
|
+
const variations = parsed
|
|
3285
|
+
? variationsFromParsedPreset(parsed) : Array(PRESET_VARIATION_SLOTS).fill(null);
|
|
3046
3286
|
variations[index] = variationString;
|
|
3047
|
-
// 5. Build metadata: merge existing (preserves author/tags/etc.) then override with caller args
|
|
3048
3287
|
const metadata = {
|
|
3049
3288
|
...existingMeta,
|
|
3050
3289
|
name: presetName,
|
|
@@ -3053,12 +3292,13 @@ server.registerTool("save_variation", {
|
|
|
3053
3292
|
currentVariation: index,
|
|
3054
3293
|
...Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined)),
|
|
3055
3294
|
};
|
|
3056
|
-
|
|
3295
|
+
delete metadata.path;
|
|
3057
3296
|
let resultSuffix = "";
|
|
3058
|
-
|
|
3059
|
-
|
|
3297
|
+
const destinationPath = normalizePresetLookupPath(diskPath);
|
|
3298
|
+
if (!name && !destinationPath.startsWith("Factory/")) {
|
|
3299
|
+
metadata.path = nativePresetPath(destinationPath);
|
|
3060
3300
|
}
|
|
3061
|
-
else if (
|
|
3301
|
+
else if (sourcePath.startsWith("Factory/")) {
|
|
3062
3302
|
// Cannot write back to factory location; savePreset will create User/{name}.preset
|
|
3063
3303
|
resultSuffix = "\n(Note: active preset is a factory preset — saved as new user preset)";
|
|
3064
3304
|
}
|
|
@@ -3159,7 +3399,7 @@ server.registerTool("set_bpm", {
|
|
|
3159
3399
|
});
|
|
3160
3400
|
server.registerTool("trim_sample", {
|
|
3161
3401
|
title: "Trim Sample",
|
|
3162
|
-
description: "Trim a cell's sample to its start/end markers
|
|
3402
|
+
description: "Trim a cell's sample to its start/end markers: writes a new WAV into Samples/Resamples/Trimmed/ and points the cell at it. The original file is untouched, but a preset saved afterwards references the trimmed copy, and calling it again crops the trimmed file further.",
|
|
3163
3403
|
inputSchema: {
|
|
3164
3404
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
3165
3405
|
},
|
|
@@ -3167,12 +3407,15 @@ server.registerTool("trim_sample", {
|
|
|
3167
3407
|
const check = await requireBridgeForCells(cellId);
|
|
3168
3408
|
if (check !== true)
|
|
3169
3409
|
return check;
|
|
3410
|
+
const loaded = await requireCellSample(cellId);
|
|
3411
|
+
if (loaded !== true)
|
|
3412
|
+
return loaded;
|
|
3170
3413
|
await bridge.send("trimSampleForCell", cellId);
|
|
3171
3414
|
return text(`Trimmed sample in cell ${cellId}.`);
|
|
3172
3415
|
});
|
|
3173
3416
|
server.registerTool("reverse_sample", {
|
|
3174
3417
|
title: "Reverse Sample",
|
|
3175
|
-
description: "Reverse a cell's loaded sample.",
|
|
3418
|
+
description: "Reverse a cell's loaded sample. Writes <name>_reversed.<ext> next to the source file and points the cell at it; calling it again on an already-reversed sample restores the original instead of reversing twice.",
|
|
3176
3419
|
inputSchema: {
|
|
3177
3420
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
3178
3421
|
},
|
|
@@ -3180,6 +3423,9 @@ server.registerTool("reverse_sample", {
|
|
|
3180
3423
|
const check = await requireBridgeForCells(cellId);
|
|
3181
3424
|
if (check !== true)
|
|
3182
3425
|
return check;
|
|
3426
|
+
const loaded = await requireCellSample(cellId);
|
|
3427
|
+
if (loaded !== true)
|
|
3428
|
+
return loaded;
|
|
3183
3429
|
await bridge.send("reverseSampleForCell", cellId);
|
|
3184
3430
|
return text(`Reversed sample in cell ${cellId}.`);
|
|
3185
3431
|
});
|
|
@@ -3224,11 +3470,178 @@ server.registerTool("connection_status", {
|
|
|
3224
3470
|
connectedTo: bridge.getConnectedInstance(),
|
|
3225
3471
|
note: connected
|
|
3226
3472
|
? instances.length > 1
|
|
3227
|
-
? `Connected. Multiple ${SERVER_PRODUCT.name} instances are running;
|
|
3228
|
-
: `Connected to ${SERVER_PRODUCT.name}.
|
|
3473
|
+
? `Connected. Multiple ${SERVER_PRODUCT.name} instances are running; use select_instance to choose; reconnects stay pinned to that instance.`
|
|
3474
|
+
: `Connected to ${SERVER_PRODUCT.name}. Tool availability also depends on this plugin build, open windows, and feature settings.`
|
|
3229
3475
|
: "Found instances but could not connect. The plugin may have shut down.",
|
|
3230
3476
|
});
|
|
3231
3477
|
});
|
|
3478
|
+
// A plugin instance's pid is its DAW, so `ps` names the host; the plugin's own
|
|
3479
|
+
// binary is one of the host's mapped files (e.g. PAM.vst3/Contents/MacOS/PAM).
|
|
3480
|
+
function hostedPluginBinaries(pid, product) {
|
|
3481
|
+
const pluginBinary = new RegExp(`/${product}\\.(?:vst3|component|clap)/Contents/MacOS/${product}$`, "i");
|
|
3482
|
+
try {
|
|
3483
|
+
const mapped = execFileSync("lsof", ["-a", "-p", String(pid), "-d", "txt", "-Fn"], {
|
|
3484
|
+
encoding: "utf8",
|
|
3485
|
+
timeout: 5000,
|
|
3486
|
+
});
|
|
3487
|
+
return [...new Set(mapped.split("\n").filter((line) => line.startsWith("n")).map((line) => line.slice(1)))]
|
|
3488
|
+
.filter((path) => pluginBinary.test(path));
|
|
3489
|
+
}
|
|
3490
|
+
catch {
|
|
3491
|
+
return [];
|
|
3492
|
+
}
|
|
3493
|
+
}
|
|
3494
|
+
// build.js restages plugins with size and mtime intact, so an identical file in
|
|
3495
|
+
// the checkout's build tree identifies a development build.
|
|
3496
|
+
function matchesRepoBuild(binary, projectRoot) {
|
|
3497
|
+
const entries = (path) => {
|
|
3498
|
+
try {
|
|
3499
|
+
return readdirSync(path);
|
|
3500
|
+
}
|
|
3501
|
+
catch {
|
|
3502
|
+
return [];
|
|
3503
|
+
}
|
|
3504
|
+
};
|
|
3505
|
+
try {
|
|
3506
|
+
const target = statSync(binary);
|
|
3507
|
+
const bundlePath = binary.split("/").slice(-4).join("/");
|
|
3508
|
+
const darwin = join(resolve(projectRoot), "build", "Darwin");
|
|
3509
|
+
for (const config of entries(darwin))
|
|
3510
|
+
for (const artefacts of entries(join(darwin, config)).filter((name) => name.endsWith("_artefacts")))
|
|
3511
|
+
for (const mode of entries(join(darwin, config, artefacts)))
|
|
3512
|
+
for (const format of entries(join(darwin, config, artefacts, mode))) {
|
|
3513
|
+
const candidate = join(darwin, config, artefacts, mode, format, bundlePath);
|
|
3514
|
+
if (!existsSync(candidate))
|
|
3515
|
+
continue;
|
|
3516
|
+
const stats = statSync(candidate);
|
|
3517
|
+
if (stats.size === target.size && Math.abs(stats.mtimeMs - target.mtimeMs) < 1000)
|
|
3518
|
+
return true;
|
|
3519
|
+
}
|
|
3520
|
+
}
|
|
3521
|
+
catch {
|
|
3522
|
+
/* binary or build tree unreadable */
|
|
3523
|
+
}
|
|
3524
|
+
return false;
|
|
3525
|
+
}
|
|
3526
|
+
server.registerTool("situation", {
|
|
3527
|
+
title: "Situation",
|
|
3528
|
+
description: "One-call orientation snapshot: discovered instances, the connected instance's product/pid/executable " +
|
|
3529
|
+
"(plus build mtime and a dev-build heuristic when resolvable), server and manifest identity, active preset, " +
|
|
3530
|
+
"transport, and per-cell occupancy counts. Start a session here — it collapses the connection/product/build " +
|
|
3531
|
+
"preflight and the first design reads into one round trip. Deeper reads: get_design_overview, design_context.",
|
|
3532
|
+
inputSchema: {},
|
|
3533
|
+
annotations: { readOnlyHint: true },
|
|
3534
|
+
}, async () => {
|
|
3535
|
+
const instances = await bridge.discover().catch(() => []);
|
|
3536
|
+
const connected = bridge.isConnected() || (await bridge.connect().catch(() => false)) === true;
|
|
3537
|
+
if (connected)
|
|
3538
|
+
syncProductHint();
|
|
3539
|
+
const instance = connected ? bridge.getConnectedInstance() : null;
|
|
3540
|
+
let build;
|
|
3541
|
+
if (instance?.pid) {
|
|
3542
|
+
try {
|
|
3543
|
+
const processExecutable = execFileSync("ps", ["-o", "comm=", "-p", String(instance.pid)], {
|
|
3544
|
+
encoding: "utf8",
|
|
3545
|
+
}).trim();
|
|
3546
|
+
if (processExecutable) {
|
|
3547
|
+
const product = instance.product ?? SERVER_PRODUCT.name;
|
|
3548
|
+
const projectRoot = process.env.PAM_PROJECT_ROOT;
|
|
3549
|
+
const hosted = basename(processExecutable).toLowerCase() !== product.toLowerCase();
|
|
3550
|
+
const pluginBinaries = hosted ? hostedPluginBinaries(instance.pid, product) : [];
|
|
3551
|
+
const executable = hosted ? (pluginBinaries.length === 1 ? pluginBinaries[0] : undefined) : processExecutable;
|
|
3552
|
+
build = hosted ? { host: processExecutable } : {};
|
|
3553
|
+
if (pluginBinaries.length > 1)
|
|
3554
|
+
build.pluginBinaries = pluginBinaries;
|
|
3555
|
+
if (executable) {
|
|
3556
|
+
build.executable = executable;
|
|
3557
|
+
if (!hosted) {
|
|
3558
|
+
build.looksLikeDevBuild = executable.includes("/build/") && !executable.startsWith("/Applications/");
|
|
3559
|
+
}
|
|
3560
|
+
else if (projectRoot) {
|
|
3561
|
+
build.looksLikeDevBuild = matchesRepoBuild(executable, projectRoot);
|
|
3562
|
+
}
|
|
3563
|
+
}
|
|
3564
|
+
try {
|
|
3565
|
+
if (executable)
|
|
3566
|
+
build.executableMtime = statSync(executable).mtime.toISOString();
|
|
3567
|
+
}
|
|
3568
|
+
catch {
|
|
3569
|
+
/* executable path unreadable from this process */
|
|
3570
|
+
}
|
|
3571
|
+
if (projectRoot && typeof build.executableMtime === "string") {
|
|
3572
|
+
try {
|
|
3573
|
+
const headIso = execFileSync("git", ["-C", resolve(projectRoot), "log", "-1", "--format=%cI"], { encoding: "utf8" }).trim();
|
|
3574
|
+
build.repoHeadCommitTime = headIso;
|
|
3575
|
+
build.binaryOlderThanHeadCommit =
|
|
3576
|
+
new Date(build.executableMtime).getTime() < new Date(headIso).getTime();
|
|
3577
|
+
}
|
|
3578
|
+
catch {
|
|
3579
|
+
/* PAM_PROJECT_ROOT is not a git checkout */
|
|
3580
|
+
}
|
|
3581
|
+
}
|
|
3582
|
+
}
|
|
3583
|
+
}
|
|
3584
|
+
catch {
|
|
3585
|
+
/* ps unavailable (Windows) or pid already gone */
|
|
3586
|
+
}
|
|
3587
|
+
}
|
|
3588
|
+
let live;
|
|
3589
|
+
let manifestName;
|
|
3590
|
+
if (connected) {
|
|
3591
|
+
const [stateRaw, transportRaw, manifest] = await Promise.all([
|
|
3592
|
+
bridge.send("getState").catch(() => undefined),
|
|
3593
|
+
bridge.send("getTransport").catch(() => undefined),
|
|
3594
|
+
loadManifest().catch(() => null),
|
|
3595
|
+
]);
|
|
3596
|
+
manifestName = manifest?.name;
|
|
3597
|
+
const state = isRecord(stateRaw) ? stateRaw : {};
|
|
3598
|
+
const transport = isRecord(transportRaw) ? transportRaw : {};
|
|
3599
|
+
if (manifest) {
|
|
3600
|
+
const effectNames = cellEffectNames(manifest);
|
|
3601
|
+
const ids = [...new Set(manifest.parameters
|
|
3602
|
+
.map((p) => p.cell)
|
|
3603
|
+
.filter((cell) => typeof cell === "string" && /^cell\d+$/.test(cell))
|
|
3604
|
+
.map((cell) => Number(cell.replace("cell", ""))))].sort((a, b) => a - b);
|
|
3605
|
+
const cellsObj = isRecord(state.cells) ? state.cells : {};
|
|
3606
|
+
const cells = ids.map((id) => {
|
|
3607
|
+
const prefix = `cell${id}_`;
|
|
3608
|
+
const rawCellState = cellsObj[String(id)];
|
|
3609
|
+
const cellState = isRecord(rawCellState) ? rawCellState : {};
|
|
3610
|
+
const samplePath = cellState.filePath ??
|
|
3611
|
+
cellState.samplePath ??
|
|
3612
|
+
state[`${prefix}filePath`] ??
|
|
3613
|
+
state[`${prefix}samplePath`];
|
|
3614
|
+
const synth = IS_MFX_SERVER ? undefined : describeSynthEngine(state[`${prefix}synthEngine`]);
|
|
3615
|
+
return {
|
|
3616
|
+
cellId: id,
|
|
3617
|
+
sample: stemName(samplePath),
|
|
3618
|
+
synthEngine: synth && synth.id > 0 ? synth.name : undefined,
|
|
3619
|
+
muted: state[`${prefix}mute`] === 1 || state[`${prefix}mute`] === true,
|
|
3620
|
+
enabledEffects: effectNames.filter((n) => state[`${prefix}${n}Enabled`] === 1 || state[`${prefix}${n}Enabled`] === true).length,
|
|
3621
|
+
};
|
|
3622
|
+
});
|
|
3623
|
+
live = {
|
|
3624
|
+
activePreset: state.activePresetPath,
|
|
3625
|
+
bpm: state.bpm ?? transport.bpm,
|
|
3626
|
+
playing: transport.isPlaying,
|
|
3627
|
+
cells,
|
|
3628
|
+
...(IS_MFX_SERVER ? { mix: state.mfx_mix, routing: state.mfx_routing } : {}),
|
|
3629
|
+
};
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
return json({
|
|
3633
|
+
server: { name: SERVER_PRODUCT.name, version: SERVER_VERSION },
|
|
3634
|
+
instances,
|
|
3635
|
+
connected,
|
|
3636
|
+
instance,
|
|
3637
|
+
manifest: manifestName,
|
|
3638
|
+
build,
|
|
3639
|
+
live,
|
|
3640
|
+
note: connected
|
|
3641
|
+
? undefined
|
|
3642
|
+
: "Not connected. connection_status has enable instructions; offline tools still work.",
|
|
3643
|
+
});
|
|
3644
|
+
});
|
|
3232
3645
|
server.registerTool("select_instance", {
|
|
3233
3646
|
title: "Select Plugin Instance",
|
|
3234
3647
|
description: "Choose which running plugin instance (PAM / CELL1 / MFX) the live tools drive when more than " +
|
|
@@ -3318,6 +3731,30 @@ async function searchSampleLibrary(rootPath, term, audioOnly) {
|
|
|
3318
3731
|
}
|
|
3319
3732
|
// Walk the library and return audio files grouped by their parent folder.
|
|
3320
3733
|
// Bounded like searchSampleLibrary — this runs inside a tool call, not a job.
|
|
3734
|
+
// The sample-edit actions answer "ok" before their async edit runs, even on an empty cell.
|
|
3735
|
+
async function requireCellSample(cellId, field = "filePath") {
|
|
3736
|
+
const cells = await bridge.send("getState", "cells").catch(() => undefined);
|
|
3737
|
+
if (!isRecord(cells))
|
|
3738
|
+
return true;
|
|
3739
|
+
const cell = cells[String(cellId)];
|
|
3740
|
+
const path = isRecord(cell) ? cell[field] : undefined;
|
|
3741
|
+
if (typeof path === "string" && path.length > 0)
|
|
3742
|
+
return true;
|
|
3743
|
+
return error(field === "filePath" ? `Cell ${cellId} has no sample loaded.` : `Cell ${cellId} has no original sample to restore.`);
|
|
3744
|
+
}
|
|
3745
|
+
// getDefaultLocations returns [{name, path, icon}]; the names are fixed in FileHandler.
|
|
3746
|
+
async function defaultLocationPaths() {
|
|
3747
|
+
const locations = await bridge.send("getDefaultLocations").catch(() => undefined);
|
|
3748
|
+
const entries = Array.isArray(locations) ? locations : [];
|
|
3749
|
+
return new Map(entries
|
|
3750
|
+
.filter((entry) => isRecord(entry) && typeof entry.name === "string" && typeof entry.path === "string")
|
|
3751
|
+
.map((entry) => [entry.name, entry.path]));
|
|
3752
|
+
}
|
|
3753
|
+
// Native exports to <base>/Export beside <base>/Samples ("Plugin Samples"); check-parity pins both.
|
|
3754
|
+
async function exportsFolder() {
|
|
3755
|
+
const pluginSamples = (await defaultLocationPaths()).get("Plugin Samples");
|
|
3756
|
+
return pluginSamples ? join(dirname(pluginSamples), "Export") : undefined;
|
|
3757
|
+
}
|
|
3321
3758
|
async function collectLibraryByFolder(roots, maxDirReads = 120) {
|
|
3322
3759
|
const byFolder = new Map();
|
|
3323
3760
|
const queue = roots.map((path) => ({ path, depth: 0 }));
|
|
@@ -3393,14 +3830,12 @@ function fitListing(base, itemsKey, items, moreKey, total) {
|
|
|
3393
3830
|
}
|
|
3394
3831
|
server.registerTool("list_samples", {
|
|
3395
3832
|
title: "List Samples",
|
|
3396
|
-
description: "Browse sample library directories.
|
|
3397
|
-
"get_default_locations
|
|
3398
|
-
"paths here. Paths vary per user and platform.",
|
|
3833
|
+
description: "Browse sample library directories. Library paths vary per user and platform, so take them from " +
|
|
3834
|
+
"get_default_locations rather than guessing.",
|
|
3399
3835
|
inputSchema: {
|
|
3400
3836
|
path: z
|
|
3401
3837
|
.string()
|
|
3402
|
-
.describe("Directory path to list
|
|
3403
|
-
"or a subdirectory of one. Do not guess paths like ~/Music/PAM."),
|
|
3838
|
+
.describe("Directory path to list: a path from get_default_locations, or a subdirectory of one."),
|
|
3404
3839
|
audioOnly: z
|
|
3405
3840
|
.boolean()
|
|
3406
3841
|
.default(true)
|
|
@@ -3489,8 +3924,9 @@ server.registerTool("get_audio_info", {
|
|
|
3489
3924
|
});
|
|
3490
3925
|
server.registerTool("export_preset", {
|
|
3491
3926
|
title: "Export Preset",
|
|
3492
|
-
description: "Export a preset as a zip file bundled with all referenced samples
|
|
3493
|
-
"
|
|
3927
|
+
description: "Export a preset as a zip file bundled with all referenced samples, written to PAM's Exports folder as " +
|
|
3928
|
+
"<outputFolderName>.zip (get_default_locations resolves that folder). An existing folder or zip of that " +
|
|
3929
|
+
"name is replaced, and presets belonging to a preset pack are refused.",
|
|
3494
3930
|
inputSchema: {
|
|
3495
3931
|
presetPath: z
|
|
3496
3932
|
.string()
|
|
@@ -3503,12 +3939,18 @@ server.registerTool("export_preset", {
|
|
|
3503
3939
|
const check = await requireBridge();
|
|
3504
3940
|
if (check !== true)
|
|
3505
3941
|
return check;
|
|
3506
|
-
await bridge.send("exportPresetWithSamples", presetPath, outputFolderName);
|
|
3507
|
-
|
|
3942
|
+
const result = await bridge.send("exportPresetWithSamples", nativePresetPath(presetPath), outputFolderName);
|
|
3943
|
+
if (result === false) {
|
|
3944
|
+
return error(`Export refused for ${presetPath}: the preset is missing, belongs to a preset pack, or the folder name is invalid.`);
|
|
3945
|
+
}
|
|
3946
|
+
return text(`Exported preset to: ${outputFolderName}.zip in ${SERVER_PRODUCT.name}'s Exports folder`);
|
|
3508
3947
|
});
|
|
3509
3948
|
server.registerTool("import_preset", {
|
|
3510
3949
|
title: "Import Preset",
|
|
3511
|
-
description: "Import a preset from a zip file or folder into PAM's preset library."
|
|
3950
|
+
description: "Import a preset from a zip file or folder into PAM's preset library. Returns the relative path of the " +
|
|
3951
|
+
"first imported preset, or an empty string on failure. Referenced samples are copied into the user sample " +
|
|
3952
|
+
"folder and the preset's paths are rewritten. A name already in the library is never overwritten: the " +
|
|
3953
|
+
"import lands as <name>_1, <name>_2 and so on.",
|
|
3512
3954
|
inputSchema: {
|
|
3513
3955
|
path: z.string().describe("Absolute path to the preset zip file or folder to import."),
|
|
3514
3956
|
},
|
|
@@ -3680,14 +4122,15 @@ async function resolveNewId(requestId, beforeIds, pick, ackIdKey, timeoutMs = 25
|
|
|
3680
4122
|
if (ack && ack.requestId === requestId) {
|
|
3681
4123
|
const minted = ack.result?.[ackIdKey];
|
|
3682
4124
|
if (typeof minted === "string" && minted.length > 0)
|
|
3683
|
-
return minted;
|
|
3684
|
-
|
|
4125
|
+
return { id: minted };
|
|
4126
|
+
// An acked command never falls back to the diff, which could misattribute a concurrent edit.
|
|
4127
|
+
return ack.ok === false ? { error: ack.error || "the command failed" } : {};
|
|
3685
4128
|
}
|
|
3686
4129
|
const found = pick(s).find((id) => !beforeIds.has(id));
|
|
3687
4130
|
if (found)
|
|
3688
|
-
return found;
|
|
4131
|
+
return { id: found };
|
|
3689
4132
|
}
|
|
3690
|
-
return
|
|
4133
|
+
return {};
|
|
3691
4134
|
}
|
|
3692
4135
|
server.registerTool("get_video_state", {
|
|
3693
4136
|
title: "Get Video State",
|
|
@@ -3718,7 +4161,7 @@ server.registerTool("capture_ui", {
|
|
|
3718
4161
|
"PresetBrowser; omit/null = cell grid), instrumentView ('synth'|'sampler', applied to " +
|
|
3719
4162
|
"all cells). REQUIRES PAM's window OPEN — html2canvas needs the live DOM, there is no " +
|
|
3720
4163
|
"headless renderer (same constraint as the video tools). Beats screen-capture: " +
|
|
3721
|
-
"deterministic, no cursor/screen control, exact UI bitmap.",
|
|
4164
|
+
"deterministic, no cursor/screen control, exact UI bitmap. View arguments switch the live editor and the previous view is not restored.",
|
|
3722
4165
|
inputSchema: {
|
|
3723
4166
|
gridSize: z.number().optional().describe("Cell grid size to set before capture: 1, 4, or 8."),
|
|
3724
4167
|
mainView: z
|
|
@@ -3732,7 +4175,7 @@ server.registerTool("capture_ui", {
|
|
|
3732
4175
|
settleMs: z.number().optional().describe("Delay (ms) after view changes before rasterizing. Default 350."),
|
|
3733
4176
|
timeoutMs: z.number().optional().default(8000).describe("Max ms to wait for the WebView to return the image."),
|
|
3734
4177
|
},
|
|
3735
|
-
annotations: { readOnlyHint: true },
|
|
4178
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
3736
4179
|
}, async ({ gridSize, mainView, instrumentView, settleMs, timeoutMs }) => {
|
|
3737
4180
|
const check = await requireBridge();
|
|
3738
4181
|
if (check !== true)
|
|
@@ -3843,7 +4286,9 @@ server.registerTool("add_video_layer", {
|
|
|
3843
4286
|
const beforeIds = new Set((before.layers ?? []).map((l) => l.id));
|
|
3844
4287
|
const requestId = mintVideoRequestId();
|
|
3845
4288
|
await sendVideoCommand({ op: "addLayer", kind, name, insertAt, sourcePath, maxBytes, requestId });
|
|
3846
|
-
const id = await resolveNewId(requestId, beforeIds, (s) => (s.layers ?? []).map((l) => l.id), "layerId");
|
|
4289
|
+
const { id, error: failure } = await resolveNewId(requestId, beforeIds, (s) => (s.layers ?? []).map((l) => l.id), "layerId");
|
|
4290
|
+
if (failure)
|
|
4291
|
+
return error(`add_video_layer failed: ${failure}`);
|
|
3847
4292
|
return text(id
|
|
3848
4293
|
? `Added ${kind} layer: ${id}`
|
|
3849
4294
|
: `Added ${kind} layer (window may be closed; call get_video_state to confirm).`);
|
|
@@ -3854,7 +4299,8 @@ server.registerTool("update_video_layer", {
|
|
|
3854
4299
|
"(partial) against the current transform; other fields replace. Common: opacity " +
|
|
3855
4300
|
"(0..1), blendMode (normal|add|multiply|screen), enabled, name, transform " +
|
|
3856
4301
|
"{x,y(0..1),scale,rotation(rad),anchor}. Kind-specific via `fields`: text layers " +
|
|
3857
|
-
"{text,fontSize,fontWeight,color,align,lineHeight}; image/video/model3d {sourcePath,
|
|
4302
|
+
"{text,fontSize,fontWeight,color,align,lineHeight}; image/video/model3d {sourcePath (loads the file, as " +
|
|
4303
|
+
"set_video_layer_source does),fit}; " +
|
|
3858
4304
|
"camera {deviceId,mirror,fit}; drawing {paintColor,paintWidth}; model3d " +
|
|
3859
4305
|
"{lightingMode,lightIntensity,rotateRateX/Y/Z,...}. Use get_video_state to see a " +
|
|
3860
4306
|
"layer's current fields.",
|
|
@@ -3877,7 +4323,11 @@ server.registerTool("update_video_layer", {
|
|
|
3877
4323
|
const check = await requireBridge();
|
|
3878
4324
|
if (check !== true)
|
|
3879
4325
|
return check;
|
|
3880
|
-
|
|
4326
|
+
// A sourcePath in the patch is only stored; setLayerSource is what loads the media.
|
|
4327
|
+
const { sourcePath, ...kindFields } = fields ?? {};
|
|
4328
|
+
if (sourcePath !== undefined && typeof sourcePath !== "string")
|
|
4329
|
+
return error("fields.sourcePath must be a file path.");
|
|
4330
|
+
const patch = { ...kindFields };
|
|
3881
4331
|
if (opacity !== undefined)
|
|
3882
4332
|
patch.opacity = opacity;
|
|
3883
4333
|
if (blendMode !== undefined)
|
|
@@ -3888,6 +4338,10 @@ server.registerTool("update_video_layer", {
|
|
|
3888
4338
|
patch.name = name;
|
|
3889
4339
|
if (transform !== undefined)
|
|
3890
4340
|
patch.transform = transform;
|
|
4341
|
+
if (sourcePath !== undefined)
|
|
4342
|
+
await sendVideoCommand({ op: "setLayerSource", layerId, sourcePath });
|
|
4343
|
+
if (Object.keys(patch).length === 0)
|
|
4344
|
+
return text(`Loading ${sourcePath} into layer ${layerId}.`);
|
|
3891
4345
|
const result = await sendVideoCommand({ op: "updateLayer", layerId, patch });
|
|
3892
4346
|
return json(result);
|
|
3893
4347
|
});
|
|
@@ -3966,7 +4420,9 @@ server.registerTool("add_video_fx", {
|
|
|
3966
4420
|
const beforeIds = new Set((before.outputFx ?? []).map((f) => f.instanceId));
|
|
3967
4421
|
const requestId = mintVideoRequestId();
|
|
3968
4422
|
await sendVideoCommand({ op: "addOutputFx", fxId: fxType, requestId });
|
|
3969
|
-
const id = await resolveNewId(requestId, beforeIds, (s) => (s.outputFx ?? []).map((f) => f.instanceId), "instanceId");
|
|
4423
|
+
const { id, error: failure } = await resolveNewId(requestId, beforeIds, (s) => (s.outputFx ?? []).map((f) => f.instanceId), "instanceId");
|
|
4424
|
+
if (failure)
|
|
4425
|
+
return error(`add_video_fx failed: ${failure}`);
|
|
3970
4426
|
// Apply param overrides now that the instance exists.
|
|
3971
4427
|
if (params && id)
|
|
3972
4428
|
for (const [param, value] of Object.entries(params))
|
|
@@ -3980,10 +4436,12 @@ server.registerTool("add_video_fx", {
|
|
|
3980
4436
|
const beforeIds = new Set((layer?.fx ?? []).map((f) => f.id));
|
|
3981
4437
|
const requestId = mintVideoRequestId();
|
|
3982
4438
|
await sendVideoCommand({ op: "addLayerFx", layerId, fxType, params, beforeFxId, requestId });
|
|
3983
|
-
const id = await resolveNewId(requestId, beforeIds, (s) => {
|
|
4439
|
+
const { id, error: failure } = await resolveNewId(requestId, beforeIds, (s) => {
|
|
3984
4440
|
const l = (s.layers ?? []).find((x) => x.id === layerId);
|
|
3985
4441
|
return (l?.fx ?? []).map((f) => f.id);
|
|
3986
4442
|
}, "fxId");
|
|
4443
|
+
if (failure)
|
|
4444
|
+
return error(`add_video_fx failed: ${failure}`);
|
|
3987
4445
|
return text(id ? `Added ${fxType} to layer ${layerId}: ${id}` : `Added ${fxType} (confirm via get_video_state).`);
|
|
3988
4446
|
});
|
|
3989
4447
|
server.registerTool("set_video_fx", {
|
|
@@ -4024,10 +4482,18 @@ server.registerTool("set_video_fx", {
|
|
|
4024
4482
|
if (params)
|
|
4025
4483
|
for (const [p, v] of Object.entries(params))
|
|
4026
4484
|
results.push(await sendVideoCommand({ op: "setOutputFxParam", instanceId, fxId, param: p, value: v }));
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
if (
|
|
4030
|
-
|
|
4485
|
+
// A param write by fxId has just auto-created the FX, so resolve the instance after it.
|
|
4486
|
+
let outputInstance = instanceId;
|
|
4487
|
+
if (!outputInstance && fxId && (enabled !== undefined || strength !== undefined)) {
|
|
4488
|
+
const current = await getVideoStateRaw();
|
|
4489
|
+
outputInstance = (current.outputFx ?? []).find((fx) => fx.fxId === fxId)?.instanceId;
|
|
4490
|
+
if (!outputInstance)
|
|
4491
|
+
return error(`set_video_fx: no output ${fxId} to enable or scale; add_video_fx it first.`);
|
|
4492
|
+
}
|
|
4493
|
+
if (enabled !== undefined && outputInstance)
|
|
4494
|
+
results.push(await sendVideoCommand({ op: "setOutputFxEnabled", instanceId: outputInstance, enabled }));
|
|
4495
|
+
if (strength !== undefined && outputInstance)
|
|
4496
|
+
results.push(await sendVideoCommand({ op: "setOutputFxStrength", instanceId: outputInstance, strength }));
|
|
4031
4497
|
}
|
|
4032
4498
|
if (results.length === 0)
|
|
4033
4499
|
return error("set_video_fx: nothing to set (provide param/value, params, enabled, or strength).");
|
|
@@ -4112,13 +4578,23 @@ server.registerTool("set_video_settings", {
|
|
|
4112
4578
|
"loads on the next bar boundary while playing). resetWorkspace clears the " +
|
|
4113
4579
|
"scene (preserves the master layer).",
|
|
4114
4580
|
inputSchema: {
|
|
4115
|
-
syncMode: z
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4581
|
+
syncMode: z
|
|
4582
|
+
.enum(["off", "beat-sync", "beat-match"])
|
|
4583
|
+
.optional()
|
|
4584
|
+
.describe("Clock source: 'off' free-runs, 'beat-sync' locks playback to the bar grid, 'beat-match' retimes clips to the transport."),
|
|
4585
|
+
loopBars: z.number().min(0).optional().describe("Loop length in bars for the beat-sync and beat-match modes."),
|
|
4586
|
+
aspectRatio: z
|
|
4587
|
+
.enum(["auto", "16:9", "4:3", "1:1", "stretch"])
|
|
4588
|
+
.optional()
|
|
4589
|
+
.describe("How a layer is fitted to the canvas. 'auto' keeps the source ratio, 'stretch' fills it."),
|
|
4590
|
+
renderQuality: z.enum(["low", "medium", "high"]).optional().describe("VX render resolution/effort tier."),
|
|
4591
|
+
designAspect: z
|
|
4592
|
+
.number()
|
|
4593
|
+
.nullable()
|
|
4594
|
+
.optional()
|
|
4595
|
+
.describe("Canvas design ratio as width/height (e.g. 1.7777 for 16:9). null clears it and follows the window."),
|
|
4596
|
+
letterboxColor: z.string().describe("#hex fill for the bars outside the design aspect.").optional(),
|
|
4597
|
+
canvasColor: z.string().describe("#hex background behind every VX layer.").optional(),
|
|
4122
4598
|
modEnabled: z.boolean().optional().describe("Mod-matrix master enable."),
|
|
4123
4599
|
vxVariationFollowAudio: z
|
|
4124
4600
|
.boolean()
|
|
@@ -4232,16 +4708,11 @@ server.registerTool("vx_variation", {
|
|
|
4232
4708
|
// ============================================================
|
|
4233
4709
|
// Composite cell-operation tools
|
|
4234
4710
|
// ============================================================
|
|
4235
|
-
const PATTERN_SUFFIXES = [
|
|
4236
|
-
"midiSequence",
|
|
4237
|
-
"pitchSequence",
|
|
4238
|
-
"sliceSequence",
|
|
4239
|
-
"stretchSequence",
|
|
4240
|
-
];
|
|
4241
4711
|
server.registerTool("reset_cell", {
|
|
4242
4712
|
title: "Reset Cell",
|
|
4243
|
-
description: "Reset
|
|
4244
|
-
"
|
|
4713
|
+
description: "Reset a single cell's manifest parameters to their defaults and remove the envelopes whose SOURCE is this cell. " +
|
|
4714
|
+
"Untouched: envelopes targeting this cell from another source (clear_modulations), pattern lanes (reset_patterns), " +
|
|
4715
|
+
"the cell config block (update_cell_config) and the loaded sample. Does not touch other cells.",
|
|
4245
4716
|
inputSchema: {
|
|
4246
4717
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
4247
4718
|
},
|
|
@@ -4260,6 +4731,8 @@ server.registerTool("reset_cell", {
|
|
|
4260
4731
|
]);
|
|
4261
4732
|
const delta = {};
|
|
4262
4733
|
for (const p of cellParams) {
|
|
4734
|
+
if (isCcOutValueSuffix(p.paramId.replace(/^cell\d+_/, "")))
|
|
4735
|
+
continue;
|
|
4263
4736
|
if (p.defaultValue !== undefined)
|
|
4264
4737
|
delta[p.paramId] = p.defaultValue;
|
|
4265
4738
|
}
|
|
@@ -4269,13 +4742,16 @@ server.registerTool("reset_cell", {
|
|
|
4269
4742
|
const removedEnvs = envArray.length - filteredEnvs.length;
|
|
4270
4743
|
if (removedEnvs > 0)
|
|
4271
4744
|
delta.envelopes = filteredEnvs;
|
|
4272
|
-
await applyKv(delta,
|
|
4745
|
+
await applyKv(delta, STATE_UPDATE_KV_OPTIONS);
|
|
4273
4746
|
return text(`Reset cell ${cellId} to defaults (${paramCount} params, removed ${removedEnvs} envelopes).`);
|
|
4274
4747
|
});
|
|
4275
4748
|
server.registerTool("copy_cell", {
|
|
4276
4749
|
title: "Copy Cell",
|
|
4277
|
-
description: "Copy
|
|
4278
|
-
"
|
|
4750
|
+
description: "Copy a cell's manifest parameters and remap its LFO, envelope and vary links onto another cell, overwriting the target's. " +
|
|
4751
|
+
"Param-lane targets travel with their lanes. A CC lane lands only on a target slot that is Off or on the same " +
|
|
4752
|
+
"controller; CC out values and controllers stay the target's. " +
|
|
4753
|
+
"Not copied: the loaded sample, parameter sequencers, and the cell config block (polyphony, MIDI note/channel, scale, " +
|
|
4754
|
+
"sidechain source, pitch/stretch engine, slice keymap) — re-apply those with load_sample and update_cell_config.",
|
|
4279
4755
|
inputSchema: {
|
|
4280
4756
|
sourceCellId: z.number().min(1).max(8).describe("Source cell (1-8)."),
|
|
4281
4757
|
targetCellId: z.number().min(1).max(8).describe("Target cell (1-8)."),
|
|
@@ -4300,10 +4776,33 @@ server.registerTool("copy_cell", {
|
|
|
4300
4776
|
const dstPrefix = `cell${targetCellId}_`;
|
|
4301
4777
|
const delta = {};
|
|
4302
4778
|
for (const [key, value] of Object.entries(state)) {
|
|
4303
|
-
if (key.startsWith(srcPrefix)) {
|
|
4779
|
+
if (key.startsWith(srcPrefix) && !isCcOutValueSuffix(key.slice(srcPrefix.length))) {
|
|
4304
4780
|
delta[dstPrefix + key.slice(srcPrefix.length)] = value;
|
|
4305
4781
|
}
|
|
4306
4782
|
}
|
|
4783
|
+
// Destination wiring: a CC lane lands only where the slot it locks is Off or on the same controller,
|
|
4784
|
+
// and then brings that slot; one that stays out keeps the destination's.
|
|
4785
|
+
const cellsState = isRecord(state.cells) ? state.cells : {};
|
|
4786
|
+
const srcRaw = cellsState[String(sourceCellId)];
|
|
4787
|
+
const dstRaw = cellsState[String(targetCellId)];
|
|
4788
|
+
const srcCell = isRecord(srcRaw) ? srcRaw : {};
|
|
4789
|
+
const dstCell = isRecord(dstRaw) ? dstRaw : {};
|
|
4790
|
+
const targetDelta = {};
|
|
4791
|
+
for (const lane of CC_LANES) {
|
|
4792
|
+
const slot = ccLaneSlotOf(srcCell, lane);
|
|
4793
|
+
if (slot === 0 || ccLaneMayLand(srcCell[`ccOutCc${slot}`], dstCell[`ccOutCc${slot}`])) {
|
|
4794
|
+
targetDelta[ccLaneSlotField(lane)] = slot;
|
|
4795
|
+
}
|
|
4796
|
+
else {
|
|
4797
|
+
delete delta[`${dstPrefix}${PATTERN_LANE_SUFFIX[lane]}`];
|
|
4798
|
+
targetDelta[ccLaneSlotField(lane)] = ccLaneSlotOf(dstCell, lane);
|
|
4799
|
+
}
|
|
4800
|
+
}
|
|
4801
|
+
for (const lane of PARAM_LANES) {
|
|
4802
|
+
const target = srcCell[LANE_TARGET_FIELD[lane]];
|
|
4803
|
+
targetDelta[LANE_TARGET_FIELD[lane]] = typeof target === "string" ? target : "";
|
|
4804
|
+
}
|
|
4805
|
+
delta.cells = { [targetCellId]: targetDelta };
|
|
4307
4806
|
// Remap envelopes
|
|
4308
4807
|
const envelopes = state.envelopes || [];
|
|
4309
4808
|
if (envelopes.length > 0) {
|
|
@@ -4379,13 +4878,13 @@ server.registerTool("copy_cell", {
|
|
|
4379
4878
|
if (remapped.length > 0)
|
|
4380
4879
|
delta.paramSeqs = [...kept, ...remapped];
|
|
4381
4880
|
}
|
|
4382
|
-
await applyKv(delta,
|
|
4881
|
+
await applyKv(delta, STATE_UPDATE_KV_OPTIONS);
|
|
4383
4882
|
return text(`Copied cell ${sourceCellId} → cell ${targetCellId} (${Object.keys(delta).length} keys).`);
|
|
4384
4883
|
});
|
|
4385
4884
|
server.registerTool("clear_modulations", {
|
|
4386
4885
|
title: "Clear Modulations",
|
|
4387
4886
|
description: "Remove all LFO, envelope, and vary modulation links targeting a cell's parameters " +
|
|
4388
|
-
"(or originating from that cell, for envelopes/vary).
|
|
4887
|
+
"(or originating from that cell, for envelopes/vary). Parameter sequencers survive — use remove_param_seq for those.",
|
|
4389
4888
|
inputSchema: {
|
|
4390
4889
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
4391
4890
|
},
|
|
@@ -4422,7 +4921,7 @@ server.registerTool("clear_modulations", {
|
|
|
4422
4921
|
if (Object.keys(delta).length === 0) {
|
|
4423
4922
|
return text(`No modulations found for cell ${cellId}.`);
|
|
4424
4923
|
}
|
|
4425
|
-
await applyKv(delta,
|
|
4924
|
+
await applyKv(delta, STATE_UPDATE_KV_OPTIONS);
|
|
4426
4925
|
const removed = (lfos.length - filteredLfos.length) +
|
|
4427
4926
|
(vary.length - filteredVary.length) +
|
|
4428
4927
|
(envelopes.length - filteredEnvs.length);
|
|
@@ -4430,7 +4929,8 @@ server.registerTool("clear_modulations", {
|
|
|
4430
4929
|
});
|
|
4431
4930
|
server.registerTool("reset_patterns", {
|
|
4432
4931
|
title: "Reset Patterns",
|
|
4433
|
-
description: "Clear
|
|
4932
|
+
description: "Clear every pattern lane of one cell (note, pitch, slice, stretch, the CC lanes and the param lanes, as the " +
|
|
4933
|
+
"product has them) and clear the param lanes' targets. CC out controllers stay.",
|
|
4434
4934
|
inputSchema: {
|
|
4435
4935
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
4436
4936
|
},
|
|
@@ -4449,10 +4949,14 @@ server.registerTool("reset_patterns", {
|
|
|
4449
4949
|
return error(`${productName} has no sampler MIDI/pitch/slice/stretch pattern lanes.`);
|
|
4450
4950
|
}
|
|
4451
4951
|
const delta = { __forceRender__: true };
|
|
4452
|
-
for (const
|
|
4453
|
-
|
|
4952
|
+
for (const lane of PATTERN_LANE_IDS) {
|
|
4953
|
+
if (!manifest || productHasLane(cellId, lane, manifest.stateVariables))
|
|
4954
|
+
delta[`cell${cellId}_${PATTERN_LANE_SUFFIX[lane]}`] = [];
|
|
4454
4955
|
}
|
|
4455
|
-
|
|
4956
|
+
if (!manifest || productHasLane(cellId, "paramLane1", manifest.stateVariables)) {
|
|
4957
|
+
delta.cells = { [cellId]: Object.fromEntries(PARAM_LANES.map((lane) => [LANE_TARGET_FIELD[lane], ""])) };
|
|
4958
|
+
}
|
|
4959
|
+
await applyKv(delta, PATTERN_EDIT_KV_OPTIONS);
|
|
4456
4960
|
return text(`Cleared all patterns for cell ${cellId}.`);
|
|
4457
4961
|
});
|
|
4458
4962
|
server.registerTool("update_effect_config", {
|
|
@@ -4520,7 +5024,7 @@ server.registerTool("update_effect_config", {
|
|
|
4520
5024
|
if (Object.keys(delta).length === 0) {
|
|
4521
5025
|
return error("At least one effect flag must be provided.");
|
|
4522
5026
|
}
|
|
4523
|
-
await applyKv(delta,
|
|
5027
|
+
await applyKv(delta, EFFECT_CONFIG_KV_OPTIONS);
|
|
4524
5028
|
const summary = Object.entries(delta)
|
|
4525
5029
|
.map(([k, v]) => `${k}=${v}`)
|
|
4526
5030
|
.join(", ");
|
|
@@ -4530,7 +5034,7 @@ server.registerTool("update_effect_config", {
|
|
|
4530
5034
|
// unreachable through set_parameters (numbers only) — an agent could shape an MFX
|
|
4531
5035
|
// cell but never patch one to another, which is the whole product.
|
|
4532
5036
|
//
|
|
4533
|
-
// Validation mirrors parseRoutingEdges (interface/src/
|
|
5037
|
+
// Validation mirrors parseRoutingEdges (interface/src/utils/mfxRouting.ts): the
|
|
4534
5038
|
// plugin re-validates on receipt, so this pass exists to fail loudly here rather
|
|
4535
5039
|
// than have a typo silently drop a cable.
|
|
4536
5040
|
const MFX_ROUTING_SOURCE_HINT = "'input', 'sidechain', 'split_lo' / 'split_mid' / 'split_hi', or 'cellN'. " +
|
|
@@ -4625,7 +5129,7 @@ server.registerTool("set_mfx_routing", {
|
|
|
4625
5129
|
return error(`${validated.reason}. This product exposes cells 1..${maxCells}.`);
|
|
4626
5130
|
}
|
|
4627
5131
|
// mfx_routing is a GRAPH_RENDER state var — a re-patch must rebuild the graph.
|
|
4628
|
-
await applyKv({ mfx_routing: validated.edges },
|
|
5132
|
+
await applyKv({ mfx_routing: validated.edges }, STATE_UPDATE_KV_OPTIONS);
|
|
4629
5133
|
const summary = validated.edges.map((e) => `${e.from}→${e.to}`).join(", ");
|
|
4630
5134
|
return text(`MFX routing set: ${validated.edges.length} cable(s)${summary ? ` — ${summary}` : " (patch cleared)"}.`);
|
|
4631
5135
|
});
|
|
@@ -4634,7 +5138,7 @@ server.registerTool("set_mfx_edge_gain", {
|
|
|
4634
5138
|
description: "Read or set the gain for one cable in the current MFX patch. Gain is linear: 0 is silent, 1 is unity, " +
|
|
4635
5139
|
"and 1.5 is the maximum. The cable must already exist. Other cable gains are preserved.",
|
|
4636
5140
|
inputSchema: {
|
|
4637
|
-
op: z.enum(["get", "set"]),
|
|
5141
|
+
op: z.enum(["get", "set"]).describe("'get' returns every cable gain; 'set' writes one."),
|
|
4638
5142
|
from: z.string().optional().describe(`Cable source for 'set': ${MFX_ROUTING_SOURCE_HINT}`),
|
|
4639
5143
|
to: z.string().optional().describe(`Cable destination for 'set': ${MFX_ROUTING_SINK_HINT}`),
|
|
4640
5144
|
gain: z.number().min(0).max(1.5).optional().describe("Linear gain for 'set'."),
|
|
@@ -4662,7 +5166,8 @@ server.registerTool("set_mfx_edge_gain", {
|
|
|
4662
5166
|
return error(`Cable ${from}→${to} is not in the current patch. Add it with set_mfx_routing first.`);
|
|
4663
5167
|
const key = `${from}__${to}`;
|
|
4664
5168
|
gains[key] = gain;
|
|
4665
|
-
|
|
5169
|
+
// Cable gains reach audio only through the graph's mounted refs, which a dispatch updates.
|
|
5170
|
+
await applyKv({ mfx_edge_gains: gains }, STATE_UPDATE_KV_OPTIONS);
|
|
4666
5171
|
return text(`MFX cable gain set: ${from}→${to} = ${gain}.`);
|
|
4667
5172
|
});
|
|
4668
5173
|
const MFX_COMBO_TARGET_INPUT = z.object({
|
|
@@ -4680,9 +5185,12 @@ server.registerTool("configure_mfx_combo", {
|
|
|
4680
5185
|
op: z.enum([
|
|
4681
5186
|
"get", "setValue", "rename", "lock", "unlock", "rescan", "setTargets", "addTarget",
|
|
4682
5187
|
"removeTarget", "clearTargets", "invertTarget", "setTargetStop", "anchor",
|
|
4683
|
-
])
|
|
5188
|
+
]).describe("get reads the combo; setValue writes its value and every resolved target; rename relabels it (blank restores the role label); " +
|
|
5189
|
+
"lock freezes the current automatic assignment as manual; unlock and rescan return it to automatic; " +
|
|
5190
|
+
"setTargets/addTarget/removeTarget/clearTargets edit the manual target list; invertTarget swaps a target's from/to stops; " +
|
|
5191
|
+
"setTargetStop sets one target's stop value; anchor re-derives every target's stops from the current parameter values."),
|
|
4684
5192
|
index: z.number().int().min(1).max(3).default(1),
|
|
4685
|
-
value: z.number().
|
|
5193
|
+
value: z.number().optional().describe("Combo value (−100 to 100) for setValue, or a value in the target parameter's units for setTargetStop."),
|
|
4686
5194
|
name: z.string().max(24).optional().describe("New label for rename; blank restores the role label."),
|
|
4687
5195
|
paramId: z.string().optional().describe("Target parameter for add/remove/invert/setTargetStop."),
|
|
4688
5196
|
stop: z.enum(["from", "mid", "to"]).optional().describe("Target stop edited by setTargetStop."),
|
|
@@ -4707,6 +5215,8 @@ server.registerTool("configure_mfx_combo", {
|
|
|
4707
5215
|
if (op === "setValue") {
|
|
4708
5216
|
if (typeof value !== "number")
|
|
4709
5217
|
return error("setValue requires value.");
|
|
5218
|
+
if (value < -100 || value > 100)
|
|
5219
|
+
return error("Combo value must be between -100 and 100.");
|
|
4710
5220
|
const delta = { [`mfx_combo${index}`]: value };
|
|
4711
5221
|
for (const target of combo.targets) {
|
|
4712
5222
|
const info = parameterIndex.get(target.id);
|
|
@@ -4716,7 +5226,7 @@ server.registerTool("configure_mfx_combo", {
|
|
|
4716
5226
|
const currentTargetValue = state[target.id];
|
|
4717
5227
|
delta[target.id] = mfxComboTargetValueFromCurrent(target, typeof previousComboValue === "number" ? previousComboValue : 0, value, info, typeof currentTargetValue === "number" ? currentTargetValue : info.defaultValue);
|
|
4718
5228
|
}
|
|
4719
|
-
await applyKv(delta,
|
|
5229
|
+
await applyKv(delta, STATE_UPDATE_KV_OPTIONS);
|
|
4720
5230
|
return json({ combo: index, value, targetsWritten: combo.targets.map((target) => target.id) });
|
|
4721
5231
|
}
|
|
4722
5232
|
const materialized = () => ({ ...stored, targets: [...combo.targets] });
|
|
@@ -4813,7 +5323,7 @@ server.registerTool("configure_mfx_combo", {
|
|
|
4813
5323
|
locked: next?.locked === 1 ? 1 : 0,
|
|
4814
5324
|
targets: Array.isArray(next?.targets) ? next.targets : null,
|
|
4815
5325
|
};
|
|
4816
|
-
await applyKv({ mfx_combos: { ...slots, [String(index)]: update } },
|
|
5326
|
+
await applyKv({ mfx_combos: { ...slots, [String(index)]: update } }, EFFECT_CONFIG_KV_OPTIONS);
|
|
4817
5327
|
return json({ combo: index, slot: update });
|
|
4818
5328
|
});
|
|
4819
5329
|
server.registerTool("set_effect_order", {
|
|
@@ -4873,15 +5383,15 @@ server.registerTool("set_effect_order", {
|
|
|
4873
5383
|
"To bypass a stage set its *Enabled flag via update_effect_config; leaving it out of the chain is not the same thing.");
|
|
4874
5384
|
}
|
|
4875
5385
|
// effectOrder rebuilds the cell's FX graph, like an MFX re-patch.
|
|
4876
|
-
await applyKv({ [key]: order },
|
|
5386
|
+
await applyKv({ [key]: order }, STATE_UPDATE_KV_OPTIONS);
|
|
4877
5387
|
return text(`Cell ${cellId} FX chain: ${order.join(" → ")}.`);
|
|
4878
5388
|
});
|
|
4879
5389
|
server.registerTool("update_cell_config", {
|
|
4880
5390
|
title: "Update Cell Config",
|
|
4881
|
-
description: "Update cell-level configuration properties like polyphony, MIDI settings, varispeed, and modulation latch. " +
|
|
5391
|
+
description: "Update cell-level configuration properties like polyphony, MIDI settings, MIDI output (channel, Send, CC out controllers), varispeed, and modulation latch. " +
|
|
4882
5392
|
"These are KV-only properties that don't appear in the parameter manifest. " +
|
|
4883
5393
|
"Uses a lightweight KV path without graph rebuild. " +
|
|
4884
|
-
"When bound to MFX, this tool only accepts sidechainFrom;
|
|
5394
|
+
"When bound to MFX, this tool only accepts sidechainFrom; on MSQ it accepts the MIDI and output keys only.",
|
|
4885
5395
|
inputSchema: {
|
|
4886
5396
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
4887
5397
|
isPoly: z.union([z.literal(0), z.literal(1)]).optional().describe("Polyphonic mode (0=mono, 1=poly)."),
|
|
@@ -4923,8 +5433,8 @@ server.registerTool("update_cell_config", {
|
|
|
4923
5433
|
"An out-of-vocabulary string would silently play as chromatic while junk persists into the preset, so the name is validated."),
|
|
4924
5434
|
scaleRoot: z.number().min(0).max(11).optional().describe("Root note of the scale (0=C, 1=C#, ..., 11=B)."),
|
|
4925
5435
|
pitchMode: z.number().int().min(0).max(3).optional().describe("Pitch engine: 0=Varispeed, 1=Formant, 2=Pitch Shift Quantize, 3=Varispeed Quantize."),
|
|
4926
|
-
stretchMode: z.number().int().min(0).max(3).optional().describe("Stretch engine: 0=OLA, 1=Cloud, 2=
|
|
4927
|
-
quantScale: z.number().int().min(0).max(9).optional().describe("Pitch quantize scale when pitchMode=2 (Pitch Shift Quantize) or 3 (Varispeed Quantize): 0=Chromatic, 1=Major, 2=Minor, 3=Penta Maj, 4=Penta Min, 5=Dorian, 6=Mixolydian, 7=Blues, 8=Harm Min, 9=Phrygian. Order locked to QuantScale in modules/
|
|
5436
|
+
stretchMode: z.number().int().min(0).max(3).optional().describe("Stretch engine: 0=Default (OLA), 1=Cloud, 2=Spectral, 3=Beats."),
|
|
5437
|
+
quantScale: z.number().int().min(0).max(9).optional().describe("Pitch quantize scale when pitchMode=2 (Pitch Shift Quantize) or 3 (Varispeed Quantize): 0=Chromatic, 1=Major, 2=Minor, 3=Penta Maj, 4=Penta Min, 5=Dorian, 6=Mixolydian, 7=Blues, 8=Harm Min, 9=Phrygian. Order locked to QuantScale in modules/dsp-core/Scale.h."),
|
|
4928
5438
|
quantRoot: z.number().int().min(0).max(11).optional().describe("Pitch quantize root when pitchMode=2 (Pitch Shift Quantize) or 3 (Varispeed Quantize): 0=C, 1=C#, ..., 11=B."),
|
|
4929
5439
|
grainDensity: z.number().int().min(1).max(24).optional().describe("Cloud stretch grain density when stretchMode=1."),
|
|
4930
5440
|
grainWindow: z.number().int().min(0).max(4).optional().describe("Cloud stretch grain envelope when stretchMode=1: 0=Hann, 1=Tukey, 2=Welch, 3=Punch, 4=Swell."),
|
|
@@ -4935,6 +5445,20 @@ server.registerTool("update_cell_config", {
|
|
|
4935
5445
|
.optional()
|
|
4936
5446
|
.describe("Sidechain compressor source. null=no sidechain, 0=external audio input, 1-8=cell output. " +
|
|
4937
5447
|
"Auto-enables the cell's compressor when set to a non-null value."),
|
|
5448
|
+
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."),
|
|
5449
|
+
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."),
|
|
5450
|
+
midiOutPort: z.number().int().min(0).max(3).optional().describe("Standalone only: 0=Main MIDI Output, 1-3=Ports B-D set in Settings."),
|
|
5451
|
+
...Object.fromEntries(CC_OUT_SLOTS.map((slot) => [`ccOutCc${slot}`, ccOutControllerSchema(slot)])),
|
|
5452
|
+
...Object.fromEntries(CC_LANES.map((lane) => [
|
|
5453
|
+
ccLaneSlotField(lane),
|
|
5454
|
+
z
|
|
5455
|
+
.number()
|
|
5456
|
+
.int()
|
|
5457
|
+
.min(0)
|
|
5458
|
+
.max(CC_OUT_SLOTS.length)
|
|
5459
|
+
.optional()
|
|
5460
|
+
.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)}.`),
|
|
5461
|
+
])),
|
|
4938
5462
|
},
|
|
4939
5463
|
}, async ({ cellId, ...config }) => {
|
|
4940
5464
|
const check = await requireBridge();
|
|
@@ -4963,6 +5487,13 @@ server.registerTool("update_cell_config", {
|
|
|
4963
5487
|
`Sampler/MIDI/instrument config keys are PAM/Cell1-only: ${unsupported.join(", ")}.`);
|
|
4964
5488
|
}
|
|
4965
5489
|
}
|
|
5490
|
+
if (bridge.getConnectedInstance()?.product?.toUpperCase() === "MSQ") {
|
|
5491
|
+
const unsupported = providedKeys.filter((key) => !MSQ_CELL_CONFIG_KEYS.has(key));
|
|
5492
|
+
if (unsupported.length > 0) {
|
|
5493
|
+
return error(`update_cell_config in MSQ accepts MIDI keys only (${[...MSQ_CELL_CONFIG_KEYS].join(", ")}). ` +
|
|
5494
|
+
`MSQ has no sampler: ${unsupported.join(", ")}.`);
|
|
5495
|
+
}
|
|
5496
|
+
}
|
|
4966
5497
|
const cellConfig = {};
|
|
4967
5498
|
const flatDelta = {};
|
|
4968
5499
|
for (const [key, value] of Object.entries(config)) {
|
|
@@ -4990,7 +5521,8 @@ server.registerTool("update_cell_config", {
|
|
|
4990
5521
|
const delta = { ...flatDelta };
|
|
4991
5522
|
if (Object.keys(cellConfig).length > 0)
|
|
4992
5523
|
delta.cells = { [cellId]: cellConfig };
|
|
4993
|
-
|
|
5524
|
+
// A sidechain source is wired at graph build, so it needs the dispatch the UI's setter makes.
|
|
5525
|
+
await applyKv(delta, cellConfig.sidechainFrom !== undefined ? STATE_UPDATE_KV_OPTIONS : CELL_CONFIG_KV_OPTIONS);
|
|
4994
5526
|
const summary = [
|
|
4995
5527
|
...Object.entries(cellConfig).map(([k, v]) => `${k}=${v}`),
|
|
4996
5528
|
...Object.entries(flatDelta).map(([k, v]) => `${k}=${v}`),
|
|
@@ -5002,7 +5534,7 @@ server.registerTool("set_cell_mute", {
|
|
|
5002
5534
|
description: "Mute, unmute, solo, or unsolo a cell in the mixer — the real channel mute/solo, cutting the whole channel " +
|
|
5003
5535
|
"(source + all effect tails) rather than pulling the volume fader down. Soloing a cell silences every other " +
|
|
5004
5536
|
"non-soloed cell until unsoloed. cell{N}_mute / cell{N}_solo are live-polled state variables (no graph rebuild). " +
|
|
5005
|
-
"Prefer this over
|
|
5537
|
+
"Prefer this over pulling cell{N}_volume down: a fader move leaves effect tails audible, a channel mute cuts them.",
|
|
5006
5538
|
inputSchema: {
|
|
5007
5539
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
5008
5540
|
mute: z.boolean().optional().describe("Mute (true) or unmute (false) the cell's channel."),
|
|
@@ -5026,10 +5558,10 @@ server.registerTool("set_cell_mute", {
|
|
|
5026
5558
|
}
|
|
5027
5559
|
// cell{N}_mute / cell{N}_solo are flat state vars projected to
|
|
5028
5560
|
// cell{N}_config.enabled by KVStateSyncManager — same options the UI's
|
|
5029
|
-
// applyStateDelta uses (
|
|
5561
|
+
// applyStateDelta uses (STATE_UPDATE_KV_OPTIONS); the projection needs
|
|
5030
5562
|
// syncConfigResources, no graph rebuild required but the standard path is
|
|
5031
5563
|
// the exact UI parity.
|
|
5032
|
-
await applyKv(delta,
|
|
5564
|
+
await applyKv(delta, STATE_UPDATE_KV_OPTIONS);
|
|
5033
5565
|
const summary = Object.entries(delta)
|
|
5034
5566
|
.map(([k, v]) => `${k}=${v}`)
|
|
5035
5567
|
.join(", ");
|
|
@@ -5043,11 +5575,7 @@ server.registerTool("set_master_effects", {
|
|
|
5043
5575
|
"Check results with get_master_analysis (keep tp <= -1 dBTP, PSR >= 8 in the loudest section). " +
|
|
5044
5576
|
"The Tape is a vintage-tape coloration stage (ADAA tanh/asymmetric saturation + HF compression + wow/flutter), " +
|
|
5045
5577
|
"the single biggest lever for lo-fi / dusty / 'pushed' master character (hip-hop, lo-fi, Memphis, dub). " +
|
|
5046
|
-
"Pass enabled flags to toggle each on/off. Pass parameters to shape the sound. " +
|
|
5047
|
-
"For pumpy beats: enable punch, set threshold around -18 to -12 dB, fast attack (0.1-0.5ms), " +
|
|
5048
|
-
"medium release (80-200ms), ratio 4-8, autoMakeup true. " +
|
|
5049
|
-
"For lo-fi/Memphis/dusty glue: tapeEnabled=1, tapeMode=1 (Lo-Fi), masterTapeDrive 0.55-0.7, " +
|
|
5050
|
-
"masterTapeTone -0.4..-0.1 (darker), masterTapeWear 0.5-0.7 (wow/flutter). " +
|
|
5578
|
+
"Pass enabled flags to toggle each on/off. Pass parameters to shape the sound; genre recipes live in the beat-making-guide resource. " +
|
|
5051
5579
|
"Squeeze (masterSqueeze*) is the 3-band up/down density stage between Punch and Tape — for dense modern-electronic masters set masterSqueezeEnabled=1 with masterSqueezeDepth 0.2-0.4; it lifts quiet detail and limits loud bands per band (a character stage, not glue — Punch stays the glue). " +
|
|
5052
5580
|
"NOTE: the Tape and Squeeze stages require a PAM build that ships them (flat state keys, not manifest params). On older builds these keys are ignored.",
|
|
5053
5581
|
inputSchema: {
|
|
@@ -5081,10 +5609,18 @@ server.registerTool("set_master_effects", {
|
|
|
5081
5609
|
masterEqHsGain: z.number().min(-12).max(12).optional().describe("High-shelf gain dB (default 0)."),
|
|
5082
5610
|
masterWidth: z.number().min(0).max(2).optional().describe("Stereo width (0=mono, 1=unity, 2=double side; default 1). Check corr in get_master_analysis after widening."),
|
|
5083
5611
|
masterBassMonoFreq: z.number().min(0).max(300).optional().describe("Elliptical bass-mono corner Hz; < 20 = off. Engage (~100-120) when corrLow < +0.7."),
|
|
5084
|
-
masterClipEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the oversampled soft clipper (post-Tape, pre-Limiter).
|
|
5612
|
+
masterClipEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the oversampled soft clipper (post-Tape, pre-Limiter). Its 23 samples of latency are reported whether it is on or off, so toggling it never moves the host's compensation."),
|
|
5085
5613
|
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."),
|
|
5086
5614
|
masterClipMix: z.number().min(0).max(1).optional().describe("Clipper parallel blend (1 = fully clipped)."),
|
|
5087
5615
|
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."),
|
|
5616
|
+
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."),
|
|
5617
|
+
masterDenoiseLow: z.number().min(0).max(100).optional().describe("Low-band noise reduction strength, percent (default 50)."),
|
|
5618
|
+
masterDenoiseMid: z.number().min(0).max(100).optional().describe("Mid-band noise reduction strength, percent (default 50)."),
|
|
5619
|
+
masterDenoiseHigh: z.number().min(0).max(100).optional().describe("High-band noise reduction strength, percent (default 50)."),
|
|
5620
|
+
masterDenoiseSplitLow: z.number().min(40).max(2000).optional().describe("Low/mid split in Hz (default 250)."),
|
|
5621
|
+
masterDenoiseSplitHigh: z.number().min(500).max(12000).optional().describe("Mid/high split in Hz (default 4000)."),
|
|
5622
|
+
masterDenoiseMix: z.number().min(0).max(1).optional().describe("Master Denoise dry/wet, 0..1 (default 1)."),
|
|
5623
|
+
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."),
|
|
5088
5624
|
masterIronEnabled: z.union([z.literal(0), z.literal(1)]).optional().describe("Enable the console/transformer iron colour stage (post-Tape, pre-Clipper; zero latency)."),
|
|
5089
5625
|
masterIronDrive: z.number().min(0).max(12).optional().describe("Iron drive dB (default 0). Level-compensated — drive adds colour, not loudness."),
|
|
5090
5626
|
masterIronAmount: z.number().min(0).max(1).optional().describe("Transformer (magnetizing-branch) depth 0..1 — LF-concentrated saturation, the 'iron' signature (default 0.5)."),
|
|
@@ -5156,7 +5692,7 @@ server.registerTool("set_master_effects", {
|
|
|
5156
5692
|
// No-dispatch path: every master key is live-polled from master_config
|
|
5157
5693
|
// (the full Punch surface included), so a graph rebuild here would only
|
|
5158
5694
|
// risk dropouts during automated mastering.
|
|
5159
|
-
await applyKv(delta,
|
|
5695
|
+
await applyKv(delta, MASTER_CONFIG_KV_OPTIONS);
|
|
5160
5696
|
const summary = Object.entries(delta)
|
|
5161
5697
|
.map(([k, v]) => `${k}=${v}`)
|
|
5162
5698
|
.join(", ");
|
|
@@ -5165,7 +5701,7 @@ server.registerTool("set_master_effects", {
|
|
|
5165
5701
|
// Built-in per-cell drum synth ("machines"). Engine enum index = drumsynth::Engine
|
|
5166
5702
|
// (Off = 0). The roster, the curated defaults and the engine-switch rule all come
|
|
5167
5703
|
// from shared/drumSynthEngines.json — the registry build.js codegens the C++ cook
|
|
5168
|
-
// and the manifest from, and interface/src/
|
|
5704
|
+
// and the manifest from, and interface/src/utils/drumSynth.ts derives its
|
|
5169
5705
|
// own tables from. The hand-copied table this replaced had drifted on Kick,
|
|
5170
5706
|
// Cowbell and 808 (issue #999); the switch rule it shipped with carried knob
|
|
5171
5707
|
// positions where the plugin carries engine units.
|
|
@@ -5250,8 +5786,8 @@ server.registerTool("set_drum_synth", {
|
|
|
5250
5786
|
}
|
|
5251
5787
|
if (volume !== undefined)
|
|
5252
5788
|
delta[`cell${cellId}_synthVolume`] = volume;
|
|
5253
|
-
// synthEngine is a graph-render key → needs a graph dispatch (
|
|
5254
|
-
await applyKv(delta,
|
|
5789
|
+
// synthEngine is a graph-render key → needs a graph dispatch (STATE_UPDATE_KV_OPTIONS).
|
|
5790
|
+
await applyKv(delta, STATE_UPDATE_KV_OPTIONS);
|
|
5255
5791
|
const labels = desc ? desc.params.map((p) => p.label).join(",") : "—";
|
|
5256
5792
|
return text(`Cell ${cellId} drum synth → ${engine}${engineId > 0 ? ` (p1..pN = ${labels})` : ""}.`);
|
|
5257
5793
|
});
|
|
@@ -5262,7 +5798,7 @@ server.registerTool("set_drum_synth_wave", {
|
|
|
5262
5798
|
"centre, tension −1..+1 with 0 = linear). PAM bakes them into a DC-removed, peak-normalized single cycle " +
|
|
5263
5799
|
"that the Wave engine's Draw param (p8) crossfades against the factory bank. Only the Wave engine reads it — " +
|
|
5264
5800
|
"select 'Wave' via set_drum_synth and raise Draw (p8) to hear it. Runs in the WebView (the curve transform " +
|
|
5265
|
-
"is single-sourced there), so PAM's window must be OPEN
|
|
5801
|
+
"is single-sourced there), so PAM's window must be OPEN with drum-synth wave editing support.",
|
|
5266
5802
|
inputSchema: {
|
|
5267
5803
|
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
5268
5804
|
points: z
|
|
@@ -5293,6 +5829,422 @@ server.registerTool("set_drum_synth_wave", {
|
|
|
5293
5829
|
return text(`Set cell ${cellId} Wave-engine custom waveform (${points.length} breakpoints). ` +
|
|
5294
5830
|
`Select the Wave engine and raise Draw (p8) to hear it. (Runs in the WebView — ensure PAM's window is open.)`);
|
|
5295
5831
|
});
|
|
5832
|
+
// Generative Players (per-cell PLAYER mode). Arming semantics mirror
|
|
5833
|
+
// interface/src/interop.ts setCellPlayer; the registry is shared/generativePlayers.json.
|
|
5834
|
+
const PLAYER_REF_HELP = `one of ${PLAYER_NAMES.join(", ")} (or its id 1..${GENERATIVE_PLAYERS.length}), ` +
|
|
5835
|
+
"'role' for the cell's role default, or 'off'";
|
|
5836
|
+
function playerProductError(manifest) {
|
|
5837
|
+
if (manifest && manifest.generativePatterns?.enabled !== true) {
|
|
5838
|
+
return error(`Generative players are PAM-only. ${manifest.name ?? "This product"}'s manifest exposes none.`);
|
|
5839
|
+
}
|
|
5840
|
+
return null;
|
|
5841
|
+
}
|
|
5842
|
+
const playerSlotArg = (n) => z
|
|
5843
|
+
.union([z.number().min(0).max(1), z.string()])
|
|
5844
|
+
.optional()
|
|
5845
|
+
.describe(`P${n} slot: 0..1 (snapped to the slot's detents) or a position label such as 'Techno' or 'Triad'. ` +
|
|
5846
|
+
"Its meaning is per player — list_players names it.");
|
|
5847
|
+
server.registerTool("list_players", {
|
|
5848
|
+
title: "List Generative Players",
|
|
5849
|
+
description: "Catalogue of PAM's generative Players — per-cell generators that replace a cell's drawn pattern with a " +
|
|
5850
|
+
"seeded, transport-synced cycle (EUCLID, KIT, TURING, WALK, MARKOV, RATCHET, PHASE, RIFF, MASK). Returns each " +
|
|
5851
|
+
"player's blurb, defaults, P-slot vocabulary (detented positions or 0..1 knobs) and curated preset names, plus " +
|
|
5852
|
+
"the role spread (which player each cell arms by default). Offline — no bridge needed. Arm one with set_player.",
|
|
5853
|
+
inputSchema: {
|
|
5854
|
+
player: z.string().optional().describe("Return one player's full detail (name or id). Omit for the whole roster."),
|
|
5855
|
+
presets: z
|
|
5856
|
+
.boolean()
|
|
5857
|
+
.optional()
|
|
5858
|
+
.describe("Include each curated preset's values, not only its name. Default false."),
|
|
5859
|
+
},
|
|
5860
|
+
annotations: { readOnlyHint: true },
|
|
5861
|
+
}, async ({ player, presets }) => {
|
|
5862
|
+
const manifest = await loadManifestForProductGuard();
|
|
5863
|
+
const available = manifest ? manifest.generativePatterns?.enabled === true : true;
|
|
5864
|
+
const spec = player ? resolvePlayerSpec(player) : undefined;
|
|
5865
|
+
if (player && !spec)
|
|
5866
|
+
return error(`Unknown player '${player}' — ${PLAYER_REF_HELP}.`);
|
|
5867
|
+
const specs = spec ? [spec] : GENERATIVE_PLAYERS;
|
|
5868
|
+
return json({
|
|
5869
|
+
available,
|
|
5870
|
+
...(available ? {} : { note: `${manifest?.name ?? "This product"} exposes no players; the catalogue is informational.` }),
|
|
5871
|
+
players: specs.map((entry) => ({
|
|
5872
|
+
...describePlayerSpec(entry, true),
|
|
5873
|
+
...(presets ? { presetValues: playerPresetsFor(entry.id) } : {}),
|
|
5874
|
+
})),
|
|
5875
|
+
roleSpread: roleSpreadSummary(),
|
|
5876
|
+
notes: [
|
|
5877
|
+
"A player replaces what the cell PLAYS, never the drawn pattern: set_player 'off' restores the grid exactly.",
|
|
5878
|
+
"Sequencer output needs the transport running (start_transport).",
|
|
5879
|
+
"Melodic players arm the cell poly with the root on its trigger note; 'off' restores both.",
|
|
5880
|
+
"KIT follows its Genre slot — pass genre to set_player so it matches the track.",
|
|
5881
|
+
"patternMode 'keep' layers the player over the drawn pattern instead of masking it.",
|
|
5882
|
+
],
|
|
5883
|
+
});
|
|
5884
|
+
});
|
|
5885
|
+
server.registerTool("set_player", {
|
|
5886
|
+
title: "Set Generative Player",
|
|
5887
|
+
description: "Arm, edit or switch off a cell's generative Player (PAM-only). Passing `player` arms that generator with the " +
|
|
5888
|
+
"roster's own voicing (or a curated `preset`), the track genre on genre-aware players, and a reversible poly " +
|
|
5889
|
+
"arm on melodic ones; omit `player` to edit the running player's values in place. Values land in both the cell " +
|
|
5890
|
+
"config and the cell{N}_player* automation params, like the plugin UI. 'off' restores the drawn pattern and the " +
|
|
5891
|
+
"cell's pre-arm polyphony. The player sounds only while the transport runs. The UI shows a running player " +
|
|
5892
|
+
"unprompted; to arm from the UI as well, update_settings { experimentalMode: true, experimentalGenerativePatterns: true }.",
|
|
5893
|
+
inputSchema: {
|
|
5894
|
+
cellIds: z
|
|
5895
|
+
.array(z.number().int().min(1).max(8))
|
|
5896
|
+
.min(1)
|
|
5897
|
+
.describe("Cells to write (1-8). Several cells take the same write, each snapped to its own player."),
|
|
5898
|
+
player: z
|
|
5899
|
+
.union([z.string(), z.number().int().min(0)])
|
|
5900
|
+
.optional()
|
|
5901
|
+
.describe(`Player to arm: ${PLAYER_REF_HELP}. Omit to edit the running player.`),
|
|
5902
|
+
preset: z.string().optional().describe("Curated preset name for the armed or running player — list_players lists them."),
|
|
5903
|
+
genre: z
|
|
5904
|
+
.enum(PLAYER_GENRES)
|
|
5905
|
+
.optional()
|
|
5906
|
+
.describe("Genre for genre-aware players (KIT's Genre slot). Defaults to the roster default when arming."),
|
|
5907
|
+
density: z.number().min(0).max(1).optional().describe("How much plays, 0..1. Audible from the next step."),
|
|
5908
|
+
mutate: z.number().min(0).max(1).optional().describe("Cycle-to-cycle evolution, 0..1 (below 0.02 locks the loop)."),
|
|
5909
|
+
p1: playerSlotArg(1),
|
|
5910
|
+
p2: playerSlotArg(2),
|
|
5911
|
+
p3: playerSlotArg(3),
|
|
5912
|
+
p4: playerSlotArg(4),
|
|
5913
|
+
p5: playerSlotArg(5),
|
|
5914
|
+
seed: z
|
|
5915
|
+
.union([z.number().int().min(0), z.literal("random")])
|
|
5916
|
+
.optional()
|
|
5917
|
+
.describe("Player seed (uint31) or 'random' to reroll — the DICE gesture."),
|
|
5918
|
+
patternMode: z
|
|
5919
|
+
.enum(PATTERN_MODES)
|
|
5920
|
+
.optional()
|
|
5921
|
+
.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."),
|
|
5922
|
+
listenMode: z
|
|
5923
|
+
.enum(LISTEN_MODES)
|
|
5924
|
+
.optional()
|
|
5925
|
+
.describe("Ensemble listening: react to another cell's last completed cycle — complement its gaps, answer it, or shade velocity harmonically."),
|
|
5926
|
+
listenSource: z.number().int().min(0).max(8).optional().describe("Listening source: 0 = all other cells, 1-8 = one cell (never itself)."),
|
|
5927
|
+
listenAmount: z.number().min(0).max(1).optional().describe("Listening strength 0..1."),
|
|
5928
|
+
poly: z
|
|
5929
|
+
.boolean()
|
|
5930
|
+
.optional()
|
|
5931
|
+
.describe("Override the automatic poly arm: true forces poly, false keeps the cell mono even for a melodic player."),
|
|
5932
|
+
},
|
|
5933
|
+
}, async ({ cellIds, player, preset, genre, density, mutate, p1, p2, p3, p4, p5, seed, patternMode, listenMode, listenSource, listenAmount, poly }) => {
|
|
5934
|
+
const check = await requireBridgeForCells(cellIds);
|
|
5935
|
+
if (check !== true)
|
|
5936
|
+
return check;
|
|
5937
|
+
const manifest = await loadManifestForProductGuard();
|
|
5938
|
+
const gate = playerProductError(manifest);
|
|
5939
|
+
if (gate)
|
|
5940
|
+
return gate;
|
|
5941
|
+
const playerRef = typeof player === "string" ? player.trim().toLowerCase() : player;
|
|
5942
|
+
const wantsOff = playerRef === "off" || playerRef === 0;
|
|
5943
|
+
const wantsRole = playerRef === "role";
|
|
5944
|
+
const explicitSpec = player !== undefined && !wantsOff && !wantsRole ? resolvePlayerSpec(player) : undefined;
|
|
5945
|
+
if (player !== undefined && !wantsOff && !wantsRole && !explicitSpec) {
|
|
5946
|
+
return error(`Unknown player '${player}' — ${PLAYER_REF_HELP}.`);
|
|
5947
|
+
}
|
|
5948
|
+
const stateRaw = await bridge.send("getState");
|
|
5949
|
+
const state = isRecord(stateRaw) ? stateRaw : {};
|
|
5950
|
+
const cellsObj = isRecord(state.cells) ? state.cells : {};
|
|
5951
|
+
const slotInputs = [p1, p2, p3, p4, p5];
|
|
5952
|
+
const flatDelta = {};
|
|
5953
|
+
const cellsDelta = {};
|
|
5954
|
+
const lines = [];
|
|
5955
|
+
for (const cellId of new Set(cellIds)) {
|
|
5956
|
+
const rawCell = cellsObj[String(cellId)];
|
|
5957
|
+
const cell = isRecord(rawCell) ? rawCell : {};
|
|
5958
|
+
const currentType = cellPlayerType(cell);
|
|
5959
|
+
const recorded = (key) => {
|
|
5960
|
+
const value = Number(cell[key]);
|
|
5961
|
+
return Number.isFinite(value) && value !== NO_POLY_ARM ? Math.round(value) : undefined;
|
|
5962
|
+
};
|
|
5963
|
+
if (wantsOff) {
|
|
5964
|
+
if (currentType <= 0) {
|
|
5965
|
+
lines.push(`Cell ${cellId}: no player running.`);
|
|
5966
|
+
continue;
|
|
5967
|
+
}
|
|
5968
|
+
const config = { playerType: 0, playerLastType: currentType };
|
|
5969
|
+
const prePoly = recorded("playerPrePoly");
|
|
5970
|
+
const preRoot = recorded("playerPreRoot");
|
|
5971
|
+
if (prePoly !== undefined)
|
|
5972
|
+
config.isPoly = prePoly;
|
|
5973
|
+
if (preRoot !== undefined)
|
|
5974
|
+
config.polyRootNote = preRoot;
|
|
5975
|
+
config.playerPrePoly = NO_POLY_ARM;
|
|
5976
|
+
config.playerPreRoot = NO_POLY_ARM;
|
|
5977
|
+
cellsDelta[String(cellId)] = config;
|
|
5978
|
+
lines.push(`Cell ${cellId}: ${getPlayerSpec(currentType)?.name ?? "player"} off — drawn pattern restored` +
|
|
5979
|
+
`${prePoly !== undefined || preRoot !== undefined ? ", pre-arm polyphony restored" : ""}.`);
|
|
5980
|
+
continue;
|
|
5981
|
+
}
|
|
5982
|
+
const roleConfig = wantsRole ? rolePlayerConfigFor(cellId) : undefined;
|
|
5983
|
+
const spec = explicitSpec ?? (roleConfig ? getPlayerSpec(roleConfig.playerType) : getPlayerSpec(currentType));
|
|
5984
|
+
if (!spec)
|
|
5985
|
+
return error(`Cell ${cellId} runs no player. Pass player to arm one — ${PLAYER_REF_HELP}.`);
|
|
5986
|
+
const arming = explicitSpec !== undefined || roleConfig !== undefined;
|
|
5987
|
+
const config = {};
|
|
5988
|
+
if (arming) {
|
|
5989
|
+
Object.assign(config, playerDefaultsFor(spec.id), playerGenreConfig(spec, genre));
|
|
5990
|
+
if (roleConfig?.playerP1 !== undefined)
|
|
5991
|
+
config.playerP1 = roleConfig.playerP1;
|
|
5992
|
+
if (roleConfig?.playerP5 !== undefined)
|
|
5993
|
+
config.playerP5 = roleConfig.playerP5;
|
|
5994
|
+
config.playerLastType = spec.id;
|
|
5995
|
+
}
|
|
5996
|
+
else if (genre) {
|
|
5997
|
+
Object.assign(config, playerGenreConfig(spec, genre));
|
|
5998
|
+
}
|
|
5999
|
+
if (preset) {
|
|
6000
|
+
const found = resolvePlayerPreset(spec, preset);
|
|
6001
|
+
if (!found) {
|
|
6002
|
+
const names = playerPresetsFor(spec.id).map((entry) => entry.name);
|
|
6003
|
+
return error(`${spec.name} has no preset '${preset}' — one of: ${names.join(", ")}.`);
|
|
6004
|
+
}
|
|
6005
|
+
Object.assign(config, presetConfig(found));
|
|
6006
|
+
}
|
|
6007
|
+
if (density !== undefined)
|
|
6008
|
+
config.playerDensity = density;
|
|
6009
|
+
if (mutate !== undefined)
|
|
6010
|
+
config.playerMutate = mutate;
|
|
6011
|
+
for (let i = 0; i < slotInputs.length; i++) {
|
|
6012
|
+
const input = slotInputs[i];
|
|
6013
|
+
if (input === undefined)
|
|
6014
|
+
continue;
|
|
6015
|
+
const slot = spec.slots[i];
|
|
6016
|
+
if (!slot)
|
|
6017
|
+
return error(`${spec.name} has no P${i + 1} slot (it has ${spec.slots.length}).`);
|
|
6018
|
+
const resolved = resolveSlotValue(slot, input);
|
|
6019
|
+
if (typeof resolved === "string")
|
|
6020
|
+
return error(`Cell ${cellId} ${spec.name} P${i + 1}: ${resolved}`);
|
|
6021
|
+
config[PLAYER_SLOT_FIELDS[i]] = resolved;
|
|
6022
|
+
}
|
|
6023
|
+
PLAYER_SLOT_FIELDS.forEach((field, i) => {
|
|
6024
|
+
if (config[field] !== undefined)
|
|
6025
|
+
config[field] = snapPlayerValue(spec.slots[i]?.steps, config[field]);
|
|
6026
|
+
});
|
|
6027
|
+
if (seed !== undefined)
|
|
6028
|
+
config.playerSeed = seed === "random" ? randomPlayerSeed() : Math.round(seed);
|
|
6029
|
+
if (patternMode)
|
|
6030
|
+
config.playerPatternMode = PATTERN_MODES.indexOf(patternMode);
|
|
6031
|
+
if (listenMode)
|
|
6032
|
+
config.playerListenMode = LISTEN_MODES.indexOf(listenMode);
|
|
6033
|
+
if (listenSource !== undefined)
|
|
6034
|
+
config.playerListenSource = listenSource;
|
|
6035
|
+
if (listenAmount !== undefined)
|
|
6036
|
+
config.playerListenAmount = listenAmount;
|
|
6037
|
+
// Reversible poly arm: record the pre-arm values once, never the armed state.
|
|
6038
|
+
const autoPoly = arming && poly !== false && (isMelodicPlayer(spec) || roleConfig?.isPoly === 1);
|
|
6039
|
+
let polyNote = "";
|
|
6040
|
+
if (autoPoly) {
|
|
6041
|
+
if (recorded("playerPrePoly") === undefined && recorded("playerPreRoot") === undefined) {
|
|
6042
|
+
config.playerPrePoly = Math.round(Number(cell.isPoly ?? 0)) || 0;
|
|
6043
|
+
config.playerPreRoot = Math.round(Number(cell.polyRootNote ?? 48)) || 48;
|
|
6044
|
+
}
|
|
6045
|
+
config.isPoly = 1;
|
|
6046
|
+
config.polyRootNote = Math.round(Number(cell.midiNote ?? 47 + cellId)) || 47 + cellId;
|
|
6047
|
+
polyNote = `; poly armed (root ${config.polyRootNote})`;
|
|
6048
|
+
}
|
|
6049
|
+
else if (poly !== undefined) {
|
|
6050
|
+
config.isPoly = poly ? 1 : 0;
|
|
6051
|
+
polyNote = `; poly ${poly ? "on" : "off"}`;
|
|
6052
|
+
}
|
|
6053
|
+
if (arming)
|
|
6054
|
+
config.playerType = spec.id;
|
|
6055
|
+
if (Object.keys(config).length === 0)
|
|
6056
|
+
return error("Nothing to write — pass player, preset, or at least one value.");
|
|
6057
|
+
cellsDelta[String(cellId)] = config;
|
|
6058
|
+
for (const field of MODULATABLE_PLAYER_FIELDS) {
|
|
6059
|
+
if (typeof config[field] === "number")
|
|
6060
|
+
flatDelta[`cell${cellId}_${field}`] = config[field];
|
|
6061
|
+
}
|
|
6062
|
+
const parts = [];
|
|
6063
|
+
if (config.playerDensity !== undefined)
|
|
6064
|
+
parts.push(`density ${playerDensityDisplay(config.playerDensity)}`);
|
|
6065
|
+
if (config.playerMutate !== undefined)
|
|
6066
|
+
parts.push(`mutate ${playerMutateDisplay(config.playerMutate)}`);
|
|
6067
|
+
spec.slots.forEach((slot, i) => {
|
|
6068
|
+
const value = config[PLAYER_SLOT_FIELDS[i]];
|
|
6069
|
+
if (value !== undefined)
|
|
6070
|
+
parts.push(`${slot.label}=${playerSlotDisplay(slot, value)}`);
|
|
6071
|
+
});
|
|
6072
|
+
if (config.playerSeed !== undefined)
|
|
6073
|
+
parts.push(`seed ${config.playerSeed}`);
|
|
6074
|
+
if (patternMode)
|
|
6075
|
+
parts.push(`pattern ${patternMode}`);
|
|
6076
|
+
if (listenMode)
|
|
6077
|
+
parts.push(`listen ${listenMode}`);
|
|
6078
|
+
lines.push(`Cell ${cellId}: ${spec.name} ${arming ? "armed" : "updated"} — ${parts.join(", ")}${polyNote}.`);
|
|
6079
|
+
}
|
|
6080
|
+
if (Object.keys(cellsDelta).length === 0)
|
|
6081
|
+
return text(lines.join("\n"));
|
|
6082
|
+
await applyKv({ ...flatDelta, cells: cellsDelta }, CELL_CONFIG_KV_OPTIONS);
|
|
6083
|
+
return text(`${lines.join("\n")}\n` +
|
|
6084
|
+
"Players sound only while the transport runs; get_design_overview reports each cell's player.");
|
|
6085
|
+
});
|
|
6086
|
+
server.registerTool("keep_player", {
|
|
6087
|
+
title: "Keep a Player's Cycles",
|
|
6088
|
+
description: "Commit what a cell's generative Player played into its drawn pattern (PAM-only), through the plugin UI so it is " +
|
|
6089
|
+
"one undo step: 'cycle' merges the cycle playing now, 'previous' the one before it, 'last-two' both, and 'freeze' " +
|
|
6090
|
+
"merges the cycle and switches the Player off. Merges are unions with the drawn notes. Needs the PAM window open " +
|
|
6091
|
+
"and the transport having run a cycle; the commit is asynchronous, so read cell{N}_midiSequence with get_state after.",
|
|
6092
|
+
inputSchema: {
|
|
6093
|
+
cellId: z.number().int().min(1).max(8).describe("The Player cell (1-8)."),
|
|
6094
|
+
keep: z.enum(["cycle", "previous", "last-two", "freeze"]).describe("Which cycles to commit; 'freeze' also switches the Player off."),
|
|
6095
|
+
},
|
|
6096
|
+
}, async ({ cellId, keep }) => {
|
|
6097
|
+
const check = await requireBridgeForCells(cellId);
|
|
6098
|
+
if (check !== true)
|
|
6099
|
+
return check;
|
|
6100
|
+
const gate = playerProductError(await loadManifestForProductGuard());
|
|
6101
|
+
if (gate)
|
|
6102
|
+
return gate;
|
|
6103
|
+
const cells = await bridge.send("getState", "cells").catch(() => undefined);
|
|
6104
|
+
const cell = isRecord(cells) ? cells[String(cellId)] : undefined;
|
|
6105
|
+
if (!(isRecord(cell) && Number(cell.playerType) > 0))
|
|
6106
|
+
return error(`Cell ${cellId} runs no Player — nothing to keep.`);
|
|
6107
|
+
await bridge.send("playerKeep", { cellId, keep });
|
|
6108
|
+
return text(`Cell ${cellId}: ${keep === "freeze" ? "freeze" : `keep ${keep}`} sent to the plugin window. ` +
|
|
6109
|
+
"Nothing lands if that cycle has not played yet; check cell" + cellId + "_midiSequence.");
|
|
6110
|
+
});
|
|
6111
|
+
/** A cell's sample as a readable file: absolute as given, else relative to a library root. */
|
|
6112
|
+
async function resolveCellSampleFile(filePath) {
|
|
6113
|
+
const expanded = expandHome(filePath);
|
|
6114
|
+
if (isAbsolute(expanded))
|
|
6115
|
+
return existsSync(expanded) ? expanded : undefined;
|
|
6116
|
+
const locations = await bridge.send("getDefaultLocations").catch(() => undefined);
|
|
6117
|
+
const roots = Array.isArray(locations)
|
|
6118
|
+
? locations.filter((entry) => isRecord(entry) && typeof entry.path === "string")
|
|
6119
|
+
: [];
|
|
6120
|
+
const normalized = filePath.replaceAll("\\", "/");
|
|
6121
|
+
const candidates = [];
|
|
6122
|
+
for (const root of roots) {
|
|
6123
|
+
const stripped = normalized.replace(/^Samples\/(User|Factory)\//, "");
|
|
6124
|
+
if (/user/i.test(root.name) && /^Samples\/User\//.test(normalized))
|
|
6125
|
+
candidates.push(join(root.path, stripped));
|
|
6126
|
+
if (/factory/i.test(root.name) && /^Samples\/Factory\//.test(normalized))
|
|
6127
|
+
candidates.push(join(root.path, stripped));
|
|
6128
|
+
candidates.push(join(root.path, normalized), join(root.path, stripped));
|
|
6129
|
+
}
|
|
6130
|
+
return candidates.find((candidate) => existsSync(candidate));
|
|
6131
|
+
}
|
|
6132
|
+
server.registerTool("set_slices", {
|
|
6133
|
+
title: "Set Sample Slices",
|
|
6134
|
+
description: "Slice the sample loaded in a cell: explicit `boundaries` (0..1 of the file), `count` equal slices, or " +
|
|
6135
|
+
"`transients` detected from the audio (WAV files, offline). Writes cell{N}_sliceBoundaries / _sliceCount — " +
|
|
6136
|
+
"what the slice lane, Slice Keymap, preview_slice and export_slice all read — and switches the Slice knob on. " +
|
|
6137
|
+
"`sequence:true` also chops the loop into the slice + note lanes so it plays back in order at native rate " +
|
|
6138
|
+
"(the Slice → Sequence gesture: modLatch on, both lanes at 1× over the loop's length in beats).",
|
|
6139
|
+
inputSchema: {
|
|
6140
|
+
cellId: z.number().int().min(1).max(8).describe("Cell number (1-8); it must have a sample loaded."),
|
|
6141
|
+
boundaries: z
|
|
6142
|
+
.array(z.number().min(0).max(1))
|
|
6143
|
+
.min(2)
|
|
6144
|
+
.max(MAX_SLICES + 1)
|
|
6145
|
+
.optional()
|
|
6146
|
+
.describe("Slice boundaries as 0..1 positions in the file, ascending; 0 and 1 are added when missing."),
|
|
6147
|
+
count: z.number().int().min(1).max(MAX_SLICES).optional().describe(`Equal slices, 1..${MAX_SLICES}.`),
|
|
6148
|
+
transients: z.boolean().optional().describe("Detect onsets in the sample and slice there (WAV only)."),
|
|
6149
|
+
sensitivity: z.number().min(0).max(1).optional().describe("Transient sensitivity 0..1 (default 0.5; higher finds more onsets)."),
|
|
6150
|
+
maxSlices: z.number().int().min(2).max(MAX_SLICES).optional().describe("Cap on transient slices (default 32)."),
|
|
6151
|
+
sequence: z.boolean().optional().describe("Also write the slice + note lanes so the slices play in order (loops)."),
|
|
6152
|
+
},
|
|
6153
|
+
}, async ({ cellId, boundaries, count, transients, sensitivity, maxSlices, sequence }) => {
|
|
6154
|
+
const check = await requireBridgeForCells(cellId);
|
|
6155
|
+
if (check !== true)
|
|
6156
|
+
return check;
|
|
6157
|
+
const manifest = await loadManifestForProductGuard();
|
|
6158
|
+
if (manifest && isMfxManifest(manifest))
|
|
6159
|
+
return error("set_slices needs a sampler cell; MFX cells carry no sample.");
|
|
6160
|
+
const modes = [boundaries !== undefined, count !== undefined, transients === true].filter(Boolean).length;
|
|
6161
|
+
if (modes !== 1)
|
|
6162
|
+
return error("Pass exactly one of boundaries, count, or transients:true.");
|
|
6163
|
+
const stateRaw = await bridge.send("getState");
|
|
6164
|
+
const state = isRecord(stateRaw) ? stateRaw : {};
|
|
6165
|
+
const cellsObj = isRecord(state.cells) ? state.cells : {};
|
|
6166
|
+
const cell = isRecord(cellsObj[String(cellId)]) ? cellsObj[String(cellId)] : {};
|
|
6167
|
+
const filePath = [cell.filePath, cell.samplePath, state[`cell${cellId}_filePath`]].find((v) => typeof v === "string" && v.length > 0);
|
|
6168
|
+
if (!filePath)
|
|
6169
|
+
return error(`Cell ${cellId} has no sample loaded — load_sample first.`);
|
|
6170
|
+
let resolved;
|
|
6171
|
+
let wavDuration;
|
|
6172
|
+
if (boundaries)
|
|
6173
|
+
resolved = normalizeBoundaries(boundaries);
|
|
6174
|
+
else if (count !== undefined)
|
|
6175
|
+
resolved = equalBoundaries(count);
|
|
6176
|
+
else {
|
|
6177
|
+
const file = await resolveCellSampleFile(filePath);
|
|
6178
|
+
if (!file)
|
|
6179
|
+
return error(`Could not find '${filePath}' on disk to analyse — pass count or boundaries instead.`);
|
|
6180
|
+
let wav;
|
|
6181
|
+
try {
|
|
6182
|
+
wav = decodeWav(await readFile(file));
|
|
6183
|
+
}
|
|
6184
|
+
catch (e) {
|
|
6185
|
+
return error(`Transient slicing reads WAV files only (${e.message}) — pass count or boundaries for ${basename(file)}.`);
|
|
6186
|
+
}
|
|
6187
|
+
wavDuration = wav.durationSeconds;
|
|
6188
|
+
resolved = detectOnsetBoundaries(wav, sensitivity ?? 0.5, maxSlices ?? 32);
|
|
6189
|
+
if (resolved.length < 3)
|
|
6190
|
+
return error("No transients found above the threshold — raise sensitivity or pass count.");
|
|
6191
|
+
}
|
|
6192
|
+
const sliceCount = resolved.length - 1;
|
|
6193
|
+
const currentKnob = Number(state[`cell${cellId}_slice`]) || 0;
|
|
6194
|
+
const delta = {
|
|
6195
|
+
[`cell${cellId}_sliceBoundaries`]: resolved,
|
|
6196
|
+
[`cell${cellId}_sliceCount`]: sliceCount,
|
|
6197
|
+
};
|
|
6198
|
+
if (currentKnob < 1 || currentKnob > sliceCount)
|
|
6199
|
+
delta[`cell${cellId}_slice`] = 1;
|
|
6200
|
+
await applyKv(delta, CELL_CONFIG_KV_OPTIONS);
|
|
6201
|
+
let sequenceNote = "";
|
|
6202
|
+
if (sequence) {
|
|
6203
|
+
const transportRaw = await bridge.send("getTransport").catch(() => undefined);
|
|
6204
|
+
const bpm = Number(isRecord(transportRaw) ? transportRaw.bpm : undefined) || Number(state.bpm) || 120;
|
|
6205
|
+
let duration = wavDuration;
|
|
6206
|
+
if (duration === undefined) {
|
|
6207
|
+
const info = await bridge.send("getAudioFileInfo", filePath).catch(() => undefined);
|
|
6208
|
+
const parsed = typeof info === "string" ? JSON.parse(info) : info;
|
|
6209
|
+
duration = [parsed?.duration, parsed?.durationSeconds, parsed?.lengthSeconds, parsed?.length]
|
|
6210
|
+
.map(Number)
|
|
6211
|
+
.find((v) => Number.isFinite(v) && v > 0);
|
|
6212
|
+
}
|
|
6213
|
+
const loopBeats = duration ? (duration * bpm) / 60 : Number(state[`cell${cellId}_patternLengthSlice`]) || 16;
|
|
6214
|
+
const patternLength = Math.max(1, Math.min(32, duration ? Math.ceil(loopBeats) : loopBeats));
|
|
6215
|
+
const note = typeof cell.midiNote === "number" ? cell.midiNote : 48 + cellId - 1;
|
|
6216
|
+
const channel = typeof cell.midiChannel === "number" ? cell.midiChannel : 1;
|
|
6217
|
+
const sliceEvents = [];
|
|
6218
|
+
const noteEvents = [];
|
|
6219
|
+
for (let i = 0; i < sliceCount; i++) {
|
|
6220
|
+
const time = resolved[i] * loopBeats;
|
|
6221
|
+
const dur = (resolved[i + 1] - resolved[i]) * loopBeats;
|
|
6222
|
+
if (dur <= 0)
|
|
6223
|
+
continue;
|
|
6224
|
+
sliceEvents.push({ note, channel, time, duration: dur, velocity: 100, value: i + 1 });
|
|
6225
|
+
noteEvents.push({ note, channel, time, duration: dur, velocity: 100 });
|
|
6226
|
+
}
|
|
6227
|
+
await applyKv({
|
|
6228
|
+
[`cell${cellId}_sliceSequence`]: sliceEvents,
|
|
6229
|
+
[`cell${cellId}_midiSequence`]: noteEvents,
|
|
6230
|
+
[`cell${cellId}_patternLengthSlice`]: patternLength,
|
|
6231
|
+
[`cell${cellId}_patternLength`]: patternLength,
|
|
6232
|
+
[`cell${cellId}_patternSpeedSlice`]: 9,
|
|
6233
|
+
[`cell${cellId}_patternSpeed`]: 9,
|
|
6234
|
+
[`cell${cellId}_patternDirection`]: 0,
|
|
6235
|
+
[`cell${cellId}_slice`]: 1,
|
|
6236
|
+
cells: { [cellId]: { modLatch: 1 } },
|
|
6237
|
+
__forceRender__: true,
|
|
6238
|
+
},
|
|
6239
|
+
// Pattern lengths are DAW params and modLatch is cell config: interop's
|
|
6240
|
+
// applySliceSequenceFromBoundaries syncs both, so this must too.
|
|
6241
|
+
{ ...PATTERN_EDIT_KV_OPTIONS, syncConfigResources: true, updateParameters: true });
|
|
6242
|
+
sequenceNote = ` Chopped into the slice + note lanes over ${patternLength} beats at ${bpm} BPM; start_transport to hear it.`;
|
|
6243
|
+
}
|
|
6244
|
+
const positions = resolved.slice(1, -1).map((b) => `${Math.round(b * 1000) / 10}%`);
|
|
6245
|
+
return text(`Cell ${cellId}: ${sliceCount} slice${sliceCount === 1 ? "" : "s"}` +
|
|
6246
|
+
`${positions.length ? ` (cuts at ${positions.join(", ")})` : ""}.${sequenceNote}`);
|
|
6247
|
+
});
|
|
5296
6248
|
server.registerTool("add_param_seq", {
|
|
5297
6249
|
title: "Add Parameter Sequencer",
|
|
5298
6250
|
description: "Add a parameter sequencer (step modulation) targeting any modulatable parameter. " +
|
|
@@ -5309,17 +6261,23 @@ server.registerTool("add_param_seq", {
|
|
|
5309
6261
|
bipolar: z.union([z.literal(0), z.literal(1)]).default(0).describe("0 = unipolar, 1 = bipolar."),
|
|
5310
6262
|
length: z.number().min(0.0625).max(16).default(1).describe("Pattern length in bars."),
|
|
5311
6263
|
speed: z.number().min(0.0625).max(16).default(1).describe("Pattern speed multiplier."),
|
|
6264
|
+
freeMode: z
|
|
6265
|
+
.union([z.literal(0), z.literal(1)])
|
|
6266
|
+
.default(0)
|
|
6267
|
+
.describe("0 = locked to the source cell's clock and its own steps; 1 = mapped to the Free Sequencer (its clock AND its master steps, set_free_sequencer)."),
|
|
6268
|
+
stepMode: z
|
|
6269
|
+
.union([z.literal(0), z.literal(1)])
|
|
6270
|
+
.default(0)
|
|
6271
|
+
.describe("0 = Clock; 1 = Gate: advance one step per trigger of the source cell (cell-locked only; MFX has no Gate)."),
|
|
5312
6272
|
},
|
|
5313
|
-
}, async ({ paramId, cellId, sequence, strength, bipolar, length, speed }) => {
|
|
6273
|
+
}, async ({ paramId, cellId, sequence, strength, bipolar, length, speed, freeMode, stepMode }) => {
|
|
5314
6274
|
const check = await requireBridgeForCells(cellId);
|
|
5315
6275
|
if (check !== true)
|
|
5316
6276
|
return check;
|
|
5317
6277
|
const index = await getParameterIndex();
|
|
5318
|
-
const param =
|
|
5319
|
-
if (
|
|
5320
|
-
return error(
|
|
5321
|
-
if (!param.isModulatable)
|
|
5322
|
-
return error(formatNotModulatableError(paramId, index));
|
|
6278
|
+
const param = await resolveModulationTarget(paramId, index);
|
|
6279
|
+
if (typeof param === "string")
|
|
6280
|
+
return error(param);
|
|
5323
6281
|
const existing = await bridge.send("getState", "paramSeqs");
|
|
5324
6282
|
const paramSeqs = (Array.isArray(existing) ? existing : []).filter((seq) => seq?.paramId !== paramId);
|
|
5325
6283
|
const newSeq = {
|
|
@@ -5333,10 +6291,11 @@ server.registerTool("add_param_seq", {
|
|
|
5333
6291
|
bipolar,
|
|
5334
6292
|
boost: 0,
|
|
5335
6293
|
interpolate: 0,
|
|
5336
|
-
freeMode
|
|
6294
|
+
freeMode,
|
|
6295
|
+
stepMode,
|
|
5337
6296
|
};
|
|
5338
6297
|
paramSeqs.push(newSeq);
|
|
5339
|
-
await applyKv({ paramSeqs },
|
|
6298
|
+
await applyKv({ paramSeqs }, STATE_UPDATE_KV_OPTIONS);
|
|
5340
6299
|
return text(`Added paramSeq for ${paramId} (${newSeq.sequence.length} steps, strength ${strength}).`);
|
|
5341
6300
|
});
|
|
5342
6301
|
server.registerTool("update_param_seq", {
|
|
@@ -5346,11 +6305,19 @@ server.registerTool("update_param_seq", {
|
|
|
5346
6305
|
inputSchema: {
|
|
5347
6306
|
paramId: z.string().describe("Target parameter ID."),
|
|
5348
6307
|
sequence: z.array(z.number().min(0).max(1)).optional().describe("New step values (0-1)."),
|
|
5349
|
-
strength: z.number().min(0).max(1).optional(),
|
|
5350
|
-
length: z.number().min(0.0625).max(16).optional(),
|
|
5351
|
-
speed: z.number().min(0.0625).max(16).optional(),
|
|
5352
|
-
bipolar: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
5353
|
-
enabled: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
6308
|
+
strength: z.number().min(0).max(1).optional().describe("Modulation depth (0-1)."),
|
|
6309
|
+
length: z.number().min(0.0625).max(16).optional().describe("Pattern length in bars."),
|
|
6310
|
+
speed: z.number().min(0.0625).max(16).optional().describe("Pattern speed multiplier."),
|
|
6311
|
+
bipolar: z.union([z.literal(0), z.literal(1)]).optional().describe("0 = unipolar, 1 = bipolar."),
|
|
6312
|
+
enabled: z.union([z.literal(0), z.literal(1)]).optional().describe("0 = bypass the sequencer, 1 = run it."),
|
|
6313
|
+
freeMode: z
|
|
6314
|
+
.union([z.literal(0), z.literal(1)])
|
|
6315
|
+
.optional()
|
|
6316
|
+
.describe("0 = cell-locked with its own steps; 1 = mapped to the Free Sequencer master contour (set_free_sequencer)."),
|
|
6317
|
+
stepMode: z
|
|
6318
|
+
.union([z.literal(0), z.literal(1)])
|
|
6319
|
+
.optional()
|
|
6320
|
+
.describe("0 = Clock; 1 = Gate (one step per source-cell trigger; falls back to Clock when free or on MFX)."),
|
|
5354
6321
|
},
|
|
5355
6322
|
}, async ({ paramId, ...updates }) => {
|
|
5356
6323
|
const check = await requireBridge();
|
|
@@ -5371,9 +6338,150 @@ server.registerTool("update_param_seq", {
|
|
|
5371
6338
|
return error(`No paramSeq found for ${paramId}. Use add_param_seq first.`);
|
|
5372
6339
|
}
|
|
5373
6340
|
paramSeqs[idx] = { ...paramSeqs[idx], ...cleanUpdates };
|
|
5374
|
-
await applyKv({ paramSeqs },
|
|
6341
|
+
await applyKv({ paramSeqs }, STATE_UPDATE_KV_OPTIONS);
|
|
5375
6342
|
return text(`Updated paramSeq ${paramId}: ${Object.keys(cleanUpdates).join(", ")}`);
|
|
5376
6343
|
});
|
|
6344
|
+
server.registerTool("set_free_sequencer", {
|
|
6345
|
+
title: "Set Free Sequencer",
|
|
6346
|
+
description: "Write the Free Sequencer's master step contour (top-level `freeSeqSequence`, 2..64 steps of 0..1). Every " +
|
|
6347
|
+
"parameter sequencer mapped to it (freeMode:1 via add_param_seq / update_param_seq) follows these steps AND its " +
|
|
6348
|
+
"clock live, each with its own strength, polarity and smoothing; cell-locked and Gate-mode sequencers keep " +
|
|
6349
|
+
"their own steps. An empty contour restores the legacy per-sequencer behaviour.",
|
|
6350
|
+
inputSchema: {
|
|
6351
|
+
sequence: z
|
|
6352
|
+
.array(z.number().min(0).max(1))
|
|
6353
|
+
.max(64)
|
|
6354
|
+
.describe("Master step values, 0..1 each: 2..64 steps, or [] to clear the contour."),
|
|
6355
|
+
},
|
|
6356
|
+
}, async ({ sequence }) => {
|
|
6357
|
+
const check = await requireBridge();
|
|
6358
|
+
if (check !== true)
|
|
6359
|
+
return check;
|
|
6360
|
+
if (sequence.length === 1)
|
|
6361
|
+
return error("A contour needs 2..64 steps; pass [] to clear it.");
|
|
6362
|
+
await bridge.send("applyFreeSeqSequenceUpdate", { sequence });
|
|
6363
|
+
if (sequence.length === 0)
|
|
6364
|
+
return text("Free Sequencer contour cleared; mapped sequencers use their own steps.");
|
|
6365
|
+
return text(`Free Sequencer: ${sequence.length} steps written. Map a paramSeq with freeMode:1 to follow it.`);
|
|
6366
|
+
});
|
|
6367
|
+
server.registerTool("export_midi", {
|
|
6368
|
+
title: "Export MIDI",
|
|
6369
|
+
description: "Write the cells' note patterns to a Standard MIDI File (type 1, 480 PPQ, tempo from the transport): " +
|
|
6370
|
+
"one track per cell on MIDI channel = cell number, across `bars`. A Player cell exports the cycles it " +
|
|
6371
|
+
"generates (density-gated, PATTERN Keep layered), rendered by PAM itself; Listen responses are left out. " +
|
|
6372
|
+
"Grid timing from base values: swing, probability, CTRL macros and modulators are not applied. Nothing " +
|
|
6373
|
+
"changes in PAM. Default location " +
|
|
6374
|
+
"is PAM's Exports folder.",
|
|
6375
|
+
inputSchema: {
|
|
6376
|
+
filename: z.string().optional().describe("Base name without extension (default pam_midi_<epoch>)."),
|
|
6377
|
+
cellIds: z.array(z.number().int().min(1).max(8)).optional().describe("Cells to include (default: every cell with notes)."),
|
|
6378
|
+
bars: z.number().min(1).max(64).optional().describe("Length in 4/4 bars (default: the longest included pattern cycle, at least 1)."),
|
|
6379
|
+
mode: z.enum(["combined", "stems"]).optional().describe("'combined' (default): one multi-track file. 'stems': one file per cell."),
|
|
6380
|
+
outputDir: z.string().optional().describe("Directory to write into (default: PAM's Exports folder)."),
|
|
6381
|
+
},
|
|
6382
|
+
}, async ({ filename, cellIds, bars, mode, outputDir }) => {
|
|
6383
|
+
const check = await requireBridgeForCells(cellIds);
|
|
6384
|
+
if (check !== true)
|
|
6385
|
+
return check;
|
|
6386
|
+
const manifest = await loadManifestForProductGuard();
|
|
6387
|
+
if (manifest && isMfxManifest(manifest))
|
|
6388
|
+
return error("export_midi needs sampler cells with note patterns; MFX has none.");
|
|
6389
|
+
const [stateRaw, transportRaw, exportsDir] = await Promise.all([
|
|
6390
|
+
bridge.send("getState"),
|
|
6391
|
+
bridge.send("getTransport").catch(() => undefined),
|
|
6392
|
+
exportsFolder(),
|
|
6393
|
+
]);
|
|
6394
|
+
const state = isRecord(stateRaw) ? stateRaw : {};
|
|
6395
|
+
const bpm = Number(isRecord(transportRaw) ? transportRaw.bpm : undefined) || Number(state.bpm) || 120;
|
|
6396
|
+
const dir = outputDir ? expandHome(outputDir) : exportsDir;
|
|
6397
|
+
if (!dir)
|
|
6398
|
+
return error("Could not resolve PAM's Exports folder from get_default_locations — pass outputDir.");
|
|
6399
|
+
const maxCell = manifestMaxCellId(manifest) ?? 8;
|
|
6400
|
+
const ids = (cellIds?.length ? cellIds : Array.from({ length: maxCell }, (_, i) => i + 1)).filter((id) => id <= maxCell);
|
|
6401
|
+
const cellConfig = (id) => {
|
|
6402
|
+
const cells = isRecord(state.cells) ? state.cells : {};
|
|
6403
|
+
const config = cells[String(id)];
|
|
6404
|
+
return isRecord(config) ? config : {};
|
|
6405
|
+
};
|
|
6406
|
+
const playerIds = ids.filter((id) => Number(cellConfig(id).playerType) > 0);
|
|
6407
|
+
// A GRAB follower plays its source's notes, so an empty own grid does not rule it out.
|
|
6408
|
+
const followerIds = ids.filter((id) => Number(cellConfig(id).seqFrom) > 0);
|
|
6409
|
+
const cells = ids
|
|
6410
|
+
.map((id) => ({
|
|
6411
|
+
id,
|
|
6412
|
+
sequence: state[`cell${id}_midiSequence`],
|
|
6413
|
+
patternLength: state[`cell${id}_patternLength`],
|
|
6414
|
+
speed: state[`cell${id}_patternSpeed`],
|
|
6415
|
+
}))
|
|
6416
|
+
.filter((cell) => (Array.isArray(cell.sequence) && cell.sequence.length > 0) || playerIds.includes(cell.id) || followerIds.includes(cell.id));
|
|
6417
|
+
if (cells.length === 0)
|
|
6418
|
+
return error("No note patterns to export — the selected cells carry no notes and run no Player.");
|
|
6419
|
+
const cycleBars = cells.map((cell) => {
|
|
6420
|
+
const length = Number(cell.patternLength);
|
|
6421
|
+
const speedIndex = Math.round(Number(cell.speed));
|
|
6422
|
+
const mul = Number.isFinite(speedIndex) ? patternSpeedMultiplier(speedIndex) : 1;
|
|
6423
|
+
return (Number.isFinite(length) && length > 0 ? length : 4) / mul / 4;
|
|
6424
|
+
});
|
|
6425
|
+
const totalBars = bars ?? Math.max(1, Math.ceil(Math.max(...cycleBars)));
|
|
6426
|
+
const totalBeats = totalBars * 4;
|
|
6427
|
+
// PAM renders the notes itself (Player cycles included); a build without the route
|
|
6428
|
+
// falls back to tiling the drawn grids here. Any other failure is reported, not tiled.
|
|
6429
|
+
let native;
|
|
6430
|
+
try {
|
|
6431
|
+
native = await bridge.send("getMidiExportEvents", { beats: totalBeats, cellIds: cells.map((cell) => cell.id) });
|
|
6432
|
+
}
|
|
6433
|
+
catch (e) {
|
|
6434
|
+
const msg = e.message;
|
|
6435
|
+
if (!/unknown action|does not support/i.test(msg))
|
|
6436
|
+
return error(`export_midi failed: ${msg}`);
|
|
6437
|
+
}
|
|
6438
|
+
const nativeCells = isRecord(native) && Array.isArray(native.cells) ? native.cells.filter(isRecord) : undefined;
|
|
6439
|
+
const staleBuildNote = !nativeCells && playerIds.length > 0
|
|
6440
|
+
? `This PAM build predates Player-aware export: cell ${playerIds.join(", ")} exported its drawn grid, not the Player. Update PAM.`
|
|
6441
|
+
: undefined;
|
|
6442
|
+
const tracks = cells
|
|
6443
|
+
.map((cell) => {
|
|
6444
|
+
const rendered = nativeCells?.find((entry) => Number(entry.cellId) === cell.id);
|
|
6445
|
+
const channel = Math.min(16, cell.id);
|
|
6446
|
+
const events = rendered && Array.isArray(rendered.events)
|
|
6447
|
+
? rendered.events.filter(isRecord).map((e) => ({
|
|
6448
|
+
channel,
|
|
6449
|
+
note: Number(e.note),
|
|
6450
|
+
velocity: Number(e.velocity),
|
|
6451
|
+
startBeats: Number(e.startBeats),
|
|
6452
|
+
durationBeats: Number(e.durationBeats),
|
|
6453
|
+
}))
|
|
6454
|
+
: tilePatternEvents(cell.sequence, cell.patternLength, cell.speed, totalBeats, channel);
|
|
6455
|
+
return { name: `Cell ${cell.id}`, events };
|
|
6456
|
+
})
|
|
6457
|
+
.filter((track) => track.events.length > 0);
|
|
6458
|
+
if (tracks.length === 0)
|
|
6459
|
+
return error(staleBuildNote ?? "No notes to export — the selected cells rendered nothing over that length.");
|
|
6460
|
+
const notes = [];
|
|
6461
|
+
if (staleBuildNote)
|
|
6462
|
+
notes.push(staleBuildNote);
|
|
6463
|
+
const listening = playerIds.filter((id) => Number(cellConfig(id).playerListenMode) > 0 && cells.some((cell) => cell.id === id));
|
|
6464
|
+
if (nativeCells && listening.length > 0)
|
|
6465
|
+
notes.push(`Cell ${listening.join(", ")} listens to other cells; the file carries its cycles without that response.`);
|
|
6466
|
+
const base = (filename?.trim() || `pam_midi_${Date.now()}`).replace(/\.mid$/i, "").replace(/[\\/:]/g, "_");
|
|
6467
|
+
await mkdir(dir, { recursive: true });
|
|
6468
|
+
const written = [];
|
|
6469
|
+
if (mode === "stems") {
|
|
6470
|
+
for (const track of tracks) {
|
|
6471
|
+
const path = join(dir, `${base}_cell${track.name.slice(5)}.mid`);
|
|
6472
|
+
await writeFile(path, buildMidiFile(bpm, [track], track.name));
|
|
6473
|
+
written.push(path);
|
|
6474
|
+
}
|
|
6475
|
+
}
|
|
6476
|
+
else {
|
|
6477
|
+
const path = join(dir, `${base}.mid`);
|
|
6478
|
+
await writeFile(path, buildMidiFile(bpm, tracks, base));
|
|
6479
|
+
written.push(path);
|
|
6480
|
+
}
|
|
6481
|
+
const noteCount = tracks.reduce((sum, track) => sum + track.events.length, 0);
|
|
6482
|
+
return text(`Exported ${noteCount} notes from ${tracks.length} cell${tracks.length === 1 ? "" : "s"} over ${totalBars} bar${totalBars === 1 ? "" : "s"} at ${bpm} BPM.\n` +
|
|
6483
|
+
[...written.map((path) => `File: ${path}`), ...notes].join("\n"));
|
|
6484
|
+
});
|
|
5377
6485
|
server.registerTool("remove_param_seq", {
|
|
5378
6486
|
title: "Remove Parameter Sequencer",
|
|
5379
6487
|
description: "Remove a parameter sequencer for the given paramId.",
|
|
@@ -5387,7 +6495,7 @@ server.registerTool("remove_param_seq", {
|
|
|
5387
6495
|
return check;
|
|
5388
6496
|
const existing = await bridge.send("getState", "paramSeqs");
|
|
5389
6497
|
const paramSeqs = (Array.isArray(existing) ? existing : []).filter((seq) => seq?.paramId !== paramId);
|
|
5390
|
-
await applyKv({ paramSeqs },
|
|
6498
|
+
await applyKv({ paramSeqs }, STATE_UPDATE_KV_OPTIONS);
|
|
5391
6499
|
return text(`Removed paramSeq for ${paramId}.`);
|
|
5392
6500
|
});
|
|
5393
6501
|
server.registerTool("normalize_sample", {
|
|
@@ -5400,6 +6508,9 @@ server.registerTool("normalize_sample", {
|
|
|
5400
6508
|
const check = await requireBridgeForCells(cellId);
|
|
5401
6509
|
if (check !== true)
|
|
5402
6510
|
return check;
|
|
6511
|
+
const loaded = await requireCellSample(cellId);
|
|
6512
|
+
if (loaded !== true)
|
|
6513
|
+
return loaded;
|
|
5403
6514
|
await bridge.send("normalizeSampleForCell", cellId);
|
|
5404
6515
|
return text(`Normalized sample in cell ${cellId}.`);
|
|
5405
6516
|
});
|
|
@@ -5419,9 +6530,64 @@ server.registerTool("gain_sample", {
|
|
|
5419
6530
|
const check = await requireBridgeForCells(cellId);
|
|
5420
6531
|
if (check !== true)
|
|
5421
6532
|
return check;
|
|
6533
|
+
const loaded = await requireCellSample(cellId);
|
|
6534
|
+
if (loaded !== true)
|
|
6535
|
+
return loaded;
|
|
5422
6536
|
await bridge.send("gainSampleForCell", { cellId, gainDb });
|
|
5423
6537
|
return text(`Applied ${gainDb > 0 ? "+" : ""}${gainDb} dB to the sample in cell ${cellId}.`);
|
|
5424
6538
|
});
|
|
6539
|
+
// Mirrors pam::repitch::kMaxCoarseSemitones / kMaxFineCents (modules/sampler/SampleRepitch.h).
|
|
6540
|
+
const REPITCH_COARSE_RANGE = 24;
|
|
6541
|
+
const REPITCH_FINE_RANGE = 100;
|
|
6542
|
+
const PITCH_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
|
6543
|
+
const pitchName = (midi) => {
|
|
6544
|
+
const note = Math.round(midi);
|
|
6545
|
+
const cents = Math.round((midi - note) * 100);
|
|
6546
|
+
const name = `${PITCH_NAMES[((note % 12) + 12) % 12]}${Math.floor(note / 12) - 1}`;
|
|
6547
|
+
return cents === 0 ? name : `${name} ${cents > 0 ? "+" : ""}${cents} cents`;
|
|
6548
|
+
};
|
|
6549
|
+
server.registerTool("analyze_sample_pitch", {
|
|
6550
|
+
title: "Analyze Sample Pitch",
|
|
6551
|
+
description: "Detect the fundamental of a cell's single-note sample and suggest the repitch_sample shift that centres it on the nearest C. " +
|
|
6552
|
+
"The sampler's root note is a C, so a sample centred on C is the one the global tuning layer retunes correctly.",
|
|
6553
|
+
inputSchema: {
|
|
6554
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
6555
|
+
},
|
|
6556
|
+
}, async ({ cellId }) => {
|
|
6557
|
+
const check = await requireBridgeForCells(cellId);
|
|
6558
|
+
if (check !== true)
|
|
6559
|
+
return check;
|
|
6560
|
+
const result = (await bridge.send("analyzeSamplePitch", cellId));
|
|
6561
|
+
if (!result?.loaded)
|
|
6562
|
+
return error(`Cell ${cellId} has no readable sample.`);
|
|
6563
|
+
const hz = result.hz ?? 0;
|
|
6564
|
+
if (hz <= 0)
|
|
6565
|
+
return text(`No stable pitch in cell ${cellId}'s sample; tune it by ear with repitch_sample.`);
|
|
6566
|
+
const midi = 69 + 12 * Math.log2(hz / 440);
|
|
6567
|
+
const target = Math.round(midi / 12) * 12;
|
|
6568
|
+
const semitones = Math.round(target - midi) || 0;
|
|
6569
|
+
const cents = Math.round((target - midi - semitones) * 100) || 0;
|
|
6570
|
+
return text(`Cell ${cellId} sounds ${pitchName(midi)} (${hz.toFixed(2)} Hz, confidence ${Math.round((result.confidence ?? 0) * 100)}%). ` +
|
|
6571
|
+
`Nearest C is ${pitchName(target)}: repitch_sample with semitones ${semitones}, cents ${cents}.`);
|
|
6572
|
+
});
|
|
6573
|
+
server.registerTool("repitch_sample", {
|
|
6574
|
+
title: "Repitch Sample",
|
|
6575
|
+
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. " +
|
|
6576
|
+
"analyze_sample_pitch suggests the shift that centres a single-note sample on C. The original library file is left unchanged.",
|
|
6577
|
+
inputSchema: {
|
|
6578
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
6579
|
+
semitones: z.number().int().min(-REPITCH_COARSE_RANGE).max(REPITCH_COARSE_RANGE).describe("Coarse shift in semitones."),
|
|
6580
|
+
cents: z.number().min(-REPITCH_FINE_RANGE).max(REPITCH_FINE_RANGE).default(0).describe("Fine shift in cents."),
|
|
6581
|
+
},
|
|
6582
|
+
}, async ({ cellId, semitones, cents }) => {
|
|
6583
|
+
if (semitones === 0 && cents === 0)
|
|
6584
|
+
return error("A zero shift leaves the sample unchanged.");
|
|
6585
|
+
const check = await requireBridgeForCells(cellId);
|
|
6586
|
+
if (check !== true)
|
|
6587
|
+
return check;
|
|
6588
|
+
await bridge.send("repitchSampleForCell", { cellId, semitones, cents });
|
|
6589
|
+
return text(`Repitched the sample in cell ${cellId} by ${semitones} semitones ${cents} cents.`);
|
|
6590
|
+
});
|
|
5425
6591
|
// Poll the offline-render status flag until the export completes or times out.
|
|
5426
6592
|
async function pollExportStatus(maxWaitMs = 180_000, pollMs = 500) {
|
|
5427
6593
|
const t0 = Date.now();
|
|
@@ -5462,7 +6628,9 @@ server.registerTool("export_audio", {
|
|
|
5462
6628
|
"'per-bus' (per output bus) · 'song-mode' (the Song Mode timeline).\n\n" +
|
|
5463
6629
|
"NOTE on 'song-mode': the offline bounce applies EVERY section on its anchor (PAM ≥ 1.5.7 with " +
|
|
5464
6630
|
"offline song export; older builds render only the first section). Wall-clock sources " +
|
|
5465
|
-
"(Magenta/Notochord) render silent offline — use the `export_song` tool's live strategy for those."
|
|
6631
|
+
"(Magenta/Notochord) render silent offline — use the `export_song` tool's live strategy for those. " +
|
|
6632
|
+
"It issues the same render as `export_song` with strategy 'offline'; prefer `export_song` for arrangements, " +
|
|
6633
|
+
"which is the only route to the live and audio+video strategies.",
|
|
5466
6634
|
inputSchema: {
|
|
5467
6635
|
bars: z.number().min(1).max(128).default(4).describe("Bars to render (ignored for 'song-mode', which derives length from the timeline)."),
|
|
5468
6636
|
cellId: z
|
|
@@ -5480,9 +6648,9 @@ server.registerTool("export_audio", {
|
|
|
5480
6648
|
.optional()
|
|
5481
6649
|
.describe("For mode 'selected-cells': cells to bounce as stems."),
|
|
5482
6650
|
bitDepth: z
|
|
5483
|
-
.
|
|
6651
|
+
.union([z.literal(16), z.literal(24)])
|
|
5484
6652
|
.default(24)
|
|
5485
|
-
.describe("Bit depth
|
|
6653
|
+
.describe("Bit depth: 16 or 24."),
|
|
5486
6654
|
},
|
|
5487
6655
|
}, async ({ bars, cellId, mode, selectedCellIds, bitDepth }) => {
|
|
5488
6656
|
const check = await requireBridgeForCells(cellId, selectedCellIds);
|
|
@@ -6033,10 +7201,10 @@ if (!IS_MFX_SERVER)
|
|
|
6033
7201
|
},
|
|
6034
7202
|
}, ({ genre, bpm }) => promptText(`Build a beat in PAM${genre ? ` in the style of ${genre}` : ""}${bpm ? ` at ${bpm} BPM` : ""}. Work in stages, listening between them:\n\n` +
|
|
6035
7203
|
`1. connection_status to confirm the bridge, then get_design_overview for the current state.\n` +
|
|
6036
|
-
`2.
|
|
6037
|
-
`3. ${
|
|
7204
|
+
`2. ${bpm ? `set_bpm to ${bpm}` : "set_bpm to the genre's tempo"}.\n` +
|
|
7205
|
+
`3. Build the kit and groove with randomize target:'full'${genre ? ` and genreHint '${genre}'` : " (pass a genreHint)"}; native chains samples before patterns. The reply is queued: poll get_state for changed cells and cellN_midiSequence before editing/saving. For specific sounds: get_default_locations → list_samples → load_sample, verify paths, then randomize_patterns and verify sequences.\n` +
|
|
6038
7206
|
`4. start_transport — sequencer output needs the transport rolling.\n` +
|
|
6039
|
-
`5. Refine: update_pattern for individual lanes, set_parameters for levels/filters (ranges via design_context), update_effect_config to switch FX on, add_modulation for movement.\n` +
|
|
7207
|
+
`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` +
|
|
6040
7208
|
`6. save_preset once it works, and update_preset_metadata with genre tags and BPM.`));
|
|
6041
7209
|
if (!IS_MFX_SERVER)
|
|
6042
7210
|
server.registerPrompt("make-a-song", {
|
|
@@ -6090,26 +7258,17 @@ if (!IS_MFX_SERVER)
|
|
|
6090
7258
|
// ============================================================
|
|
6091
7259
|
// TOOLS — Looper (state deltas)
|
|
6092
7260
|
// ============================================================
|
|
6093
|
-
// applyStateDelta-equivalent options: full update, dispatch graph, sync KV.
|
|
6094
|
-
const KV_OPTS_LOOPER_STATE = {
|
|
6095
|
-
shouldDispatch: true,
|
|
6096
|
-
syncKVResources: true,
|
|
6097
|
-
syncConfigResources: true,
|
|
6098
|
-
resyncKVAfterDispatch: true,
|
|
6099
|
-
updateParameters: true,
|
|
6100
|
-
broadcastFollowers: true,
|
|
6101
|
-
};
|
|
6102
7261
|
async function applyLoopersDelta(loopers, extra) {
|
|
6103
7262
|
const delta = { loopers, ...(extra ?? {}) };
|
|
6104
7263
|
await bridge.send("applyUnifiedStateKvUpdate", {
|
|
6105
7264
|
delta,
|
|
6106
|
-
options:
|
|
7265
|
+
options: STATE_UPDATE_KV_OPTIONS,
|
|
6107
7266
|
});
|
|
6108
7267
|
}
|
|
6109
7268
|
server.registerTool("arm_looper_track", {
|
|
6110
7269
|
title: "Arm / Disarm Looper Track",
|
|
6111
7270
|
description: "Arm or disarm a looper track for recording. Arming alone does not start " +
|
|
6112
|
-
"recording — call start_looper_recording afterwards
|
|
7271
|
+
"recording — call start_looper_recording afterwards, or pass armedCellIds to start_looper_recording to arm and record in one call.",
|
|
6113
7272
|
inputSchema: {
|
|
6114
7273
|
cellId: z.number().min(1).max(8).describe("Looper track / cell (1-8)."),
|
|
6115
7274
|
armed: z.boolean().describe("true to arm, false to disarm."),
|
|
@@ -6123,8 +7282,8 @@ server.registerTool("arm_looper_track", {
|
|
|
6123
7282
|
});
|
|
6124
7283
|
server.registerTool("set_looper_track", {
|
|
6125
7284
|
title: "Set Looper Track State",
|
|
6126
|
-
description: "Update one or more per-track looper fields (mute, overdub mode, reverse, " +
|
|
6127
|
-
"pitch, warp, capture source, level). Pass only the fields you want to change.",
|
|
7285
|
+
description: "Update one or more per-track looper fields (mute, solo, overdub mode, reverse, " +
|
|
7286
|
+
"pitch, warp, capture source, level, auto-select and recorded BPM). Pass only the fields you want to change.",
|
|
6128
7287
|
inputSchema: {
|
|
6129
7288
|
cellId: z.number().min(1).max(8).describe("Looper track / cell (1-8)."),
|
|
6130
7289
|
mute: z.boolean().optional().describe("Mute / unmute."),
|
|
@@ -6406,7 +7565,7 @@ server.registerTool("looper_settings", {
|
|
|
6406
7565
|
server.registerTool("looper_variation", {
|
|
6407
7566
|
title: "Looper Variation",
|
|
6408
7567
|
description: "Manage looper variation slots (clip-set snapshots). Operations: 'apply' (immediate), 'queue' (bar-synced), " +
|
|
6409
|
-
"'cancel', 'clear', 'setCurrent', 'save' (snapshot
|
|
7568
|
+
"'cancel', 'clear', 'setCurrent', 'save' (write a snapshot object from the `data` field into the slot).",
|
|
6410
7569
|
inputSchema: {
|
|
6411
7570
|
op: z
|
|
6412
7571
|
.enum(["apply", "queue", "cancel", "clear", "setCurrent", "save"])
|
|
@@ -6419,7 +7578,7 @@ server.registerTool("looper_variation", {
|
|
|
6419
7578
|
.describe("Variation slot 0-7 (required for apply/queue/clear/setCurrent/save)."),
|
|
6420
7579
|
intervalBars: z
|
|
6421
7580
|
.number()
|
|
6422
|
-
.min(0.
|
|
7581
|
+
.min(0.25)
|
|
6423
7582
|
.max(64)
|
|
6424
7583
|
.optional()
|
|
6425
7584
|
.describe("Bar interval for 'queue' (required for queue)."),
|
|
@@ -6427,7 +7586,7 @@ server.registerTool("looper_variation", {
|
|
|
6427
7586
|
data: z
|
|
6428
7587
|
.record(z.string(), z.unknown())
|
|
6429
7588
|
.optional()
|
|
6430
|
-
.describe("For 'save': the looper variation snapshot
|
|
7589
|
+
.describe("For 'save' (required): the looper variation snapshot object. Use op 'clear' to empty a slot."),
|
|
6431
7590
|
},
|
|
6432
7591
|
}, async (args) => {
|
|
6433
7592
|
const check = await requireBridge();
|
|
@@ -6464,10 +7623,10 @@ server.registerTool("looper_variation", {
|
|
|
6464
7623
|
case "save":
|
|
6465
7624
|
if (args.index === undefined)
|
|
6466
7625
|
return error("'save' requires index");
|
|
6467
|
-
|
|
6468
|
-
|
|
6469
|
-
|
|
6470
|
-
|
|
7626
|
+
if (!args.data)
|
|
7627
|
+
return error("'save' requires data (a looper variation snapshot); use op 'clear' to empty a slot.");
|
|
7628
|
+
await bridge.send("saveLooperVariation", { index: args.index, data: args.data });
|
|
7629
|
+
return text(`Saved looper variation slot ${args.index}.`);
|
|
6471
7630
|
}
|
|
6472
7631
|
return text("No-op");
|
|
6473
7632
|
});
|
|
@@ -6476,12 +7635,18 @@ server.registerTool("looper_variation_sync", {
|
|
|
6476
7635
|
description: "Configure how looper variation switches synchronize to bar boundaries.",
|
|
6477
7636
|
inputSchema: {
|
|
6478
7637
|
barSyncEnabled: z.boolean().optional().describe("Toggle bar-sync mode."),
|
|
6479
|
-
intervalBars: z
|
|
7638
|
+
intervalBars: z
|
|
7639
|
+
.number()
|
|
7640
|
+
.optional()
|
|
7641
|
+
.describe(`Sync interval in bars: one of ${LOOPER_BAR_SYNC_INTERVALS.join(", ")}.`),
|
|
6480
7642
|
},
|
|
6481
7643
|
}, async ({ barSyncEnabled, intervalBars }) => {
|
|
6482
7644
|
const check = await requireBridge();
|
|
6483
7645
|
if (check !== true)
|
|
6484
7646
|
return check;
|
|
7647
|
+
if (intervalBars !== undefined && !LOOPER_BAR_SYNC_INTERVALS.includes(intervalBars)) {
|
|
7648
|
+
return error(`intervalBars must be one of ${LOOPER_BAR_SYNC_INTERVALS.join(", ")}; native resets anything else to 1.`);
|
|
7649
|
+
}
|
|
6485
7650
|
const updates = [];
|
|
6486
7651
|
if (typeof barSyncEnabled === "boolean") {
|
|
6487
7652
|
await bridge.send("setLooperVariationBarSyncEnabled", barSyncEnabled);
|
|
@@ -6504,22 +7669,22 @@ server.registerTool("queue_variation", {
|
|
|
6504
7669
|
"already-engaged queued-flip window only; a from-idle flip hard-switches).",
|
|
6505
7670
|
inputSchema: {
|
|
6506
7671
|
index: z.number().min(0).max(7).describe("Variation slot 0-7."),
|
|
6507
|
-
intervalBars: z.number().min(0.
|
|
6508
|
-
fadeMs: z.number().min(0).max(2000).
|
|
7672
|
+
intervalBars: z.number().min(0.25).max(64).describe("Bars before switching."),
|
|
7673
|
+
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."),
|
|
6509
7674
|
resetPhase: z
|
|
6510
7675
|
.boolean()
|
|
6511
|
-
.
|
|
6512
|
-
.describe("Reset pattern phase on switch."),
|
|
7676
|
+
.optional()
|
|
7677
|
+
.describe("Reset pattern phase on switch. Omit to inherit native settings."),
|
|
6513
7678
|
morphBars: z
|
|
6514
7679
|
.number()
|
|
6515
7680
|
.min(0.25)
|
|
6516
7681
|
.max(8)
|
|
6517
7682
|
.optional()
|
|
6518
|
-
.describe("Pre-anchor morph window in bars for this flip
|
|
7683
|
+
.describe("Pre-anchor morph window in bars for this flip. Omit all transition options to inherit native settings."),
|
|
6519
7684
|
morphMode: z
|
|
6520
7685
|
.enum(["auto", "param", "mix", "seq", "all"])
|
|
6521
7686
|
.optional()
|
|
6522
|
-
.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
|
|
7687
|
+
.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."),
|
|
6523
7688
|
morphCurve: z
|
|
6524
7689
|
.enum(["linear", "ease", "exp", "log"])
|
|
6525
7690
|
.optional()
|
|
@@ -6532,7 +7697,7 @@ server.registerTool("queue_variation", {
|
|
|
6532
7697
|
tailMaxBars: z.number().min(0.25).max(8).optional(),
|
|
6533
7698
|
})
|
|
6534
7699
|
.optional()
|
|
6535
|
-
.describe("Advanced transition settings for this flip
|
|
7700
|
+
.describe("Advanced transition settings for this flip: pattern policy for a " +
|
|
6536
7701
|
"seq/all morph, blend gain law for a mix/all morph, and the outgoing section's " +
|
|
6537
7702
|
"tail. Omitted fields keep the defaults (dissolve, equalPower, fade)."),
|
|
6538
7703
|
},
|
|
@@ -6543,8 +7708,8 @@ server.registerTool("queue_variation", {
|
|
|
6543
7708
|
await bridge.send("queueVariationOnBars", {
|
|
6544
7709
|
index,
|
|
6545
7710
|
intervalBars,
|
|
6546
|
-
fadeMs,
|
|
6547
|
-
resetPhase,
|
|
7711
|
+
...(fadeMs !== undefined && { fadeMs }),
|
|
7712
|
+
...(resetPhase !== undefined && { resetPhase }),
|
|
6548
7713
|
...(morphBars !== undefined && { morphBars }),
|
|
6549
7714
|
...(morphMode !== undefined && { morphMode }),
|
|
6550
7715
|
...(morphCurve !== undefined && { morphCurve }),
|
|
@@ -6585,7 +7750,7 @@ server.registerTool("update_variations", {
|
|
|
6585
7750
|
server.registerTool("variation_hold", {
|
|
6586
7751
|
title: "Variation Hold",
|
|
6587
7752
|
description: "'Hold while note pressed' mode for variations. Use this to map MIDI notes to momentary " +
|
|
6588
|
-
"variation switches
|
|
7753
|
+
"variation switches. Hold mode reverts when the note releases; latch mode stays.",
|
|
6589
7754
|
inputSchema: {
|
|
6590
7755
|
enabled: z.boolean().optional().describe("Toggle hold mode on/off."),
|
|
6591
7756
|
allNotesOff: z
|
|
@@ -6606,14 +7771,25 @@ server.registerTool("variation_hold", {
|
|
|
6606
7771
|
if (check !== true)
|
|
6607
7772
|
return check;
|
|
6608
7773
|
const updates = [];
|
|
7774
|
+
// The native router holds the live values; settings persist them the way the UI's
|
|
7775
|
+
// setVariationHoldEnabled / syncVariationHoldBindings do.
|
|
7776
|
+
const persisted = {};
|
|
6609
7777
|
if (typeof enabled === "boolean") {
|
|
6610
7778
|
await bridge.send("setVariationHoldEnabled", enabled);
|
|
7779
|
+
persisted.variationHoldEnabled = enabled;
|
|
6611
7780
|
updates.push(`enabled=${enabled}`);
|
|
6612
7781
|
}
|
|
6613
7782
|
if (bindings) {
|
|
6614
|
-
|
|
7783
|
+
const slots = Array.from({ length: PRESET_VARIATION_SLOTS }, (_, slot) => bindings[slot] ?? { triggerNote: -1, triggerChannel: 0 });
|
|
7784
|
+
await bridge.send("syncVariationHoldBindings", slots);
|
|
7785
|
+
persisted.variationHoldBindings = slots.map(({ triggerNote, triggerChannel }) => ({
|
|
7786
|
+
triggerNote: triggerNote >= 0 ? triggerNote : null,
|
|
7787
|
+
triggerChannel,
|
|
7788
|
+
}));
|
|
6615
7789
|
updates.push(`bindings=${bindings.length}`);
|
|
6616
7790
|
}
|
|
7791
|
+
if (Object.keys(persisted).length)
|
|
7792
|
+
await bridge.send("applySettingsUpdate", persisted);
|
|
6617
7793
|
if (allNotesOff) {
|
|
6618
7794
|
await bridge.send("variationHoldAllNotesOff");
|
|
6619
7795
|
updates.push("allNotesOff");
|
|
@@ -6700,6 +7876,21 @@ server.registerTool("midi_recording", {
|
|
|
6700
7876
|
}
|
|
6701
7877
|
return text("No-op");
|
|
6702
7878
|
});
|
|
7879
|
+
server.registerTool("session_take", {
|
|
7880
|
+
title: "Session Take",
|
|
7881
|
+
description: "Record a session take: the master WAV plus a log of PAM's main window, rendered later to video by pam-render. " +
|
|
7882
|
+
"PAM only; needs Settings > Experimental > Session Recorder. 'start' needs the window open. 'stop' saves in the " +
|
|
7883
|
+
"background: poll 'status' until phase is 'idle' (saved to folder) or 'failed' (error). Returns the recorder status.",
|
|
7884
|
+
inputSchema: {
|
|
7885
|
+
op: z.enum(["start", "stop", "status"]).describe("Operation."),
|
|
7886
|
+
},
|
|
7887
|
+
}, async ({ op }) => {
|
|
7888
|
+
const check = await requireBridge();
|
|
7889
|
+
if (check !== true)
|
|
7890
|
+
return check;
|
|
7891
|
+
const status = await bridge.send("sessionTake", op);
|
|
7892
|
+
return text(typeof status === "string" ? status : JSON.stringify(status));
|
|
7893
|
+
});
|
|
6703
7894
|
// ============================================================
|
|
6704
7895
|
// TOOLS — Modulation updates (LFO/Env/Vary/Macro/ParamSeq)
|
|
6705
7896
|
// ============================================================
|
|
@@ -6707,7 +7898,6 @@ server.registerTool("update_modulation", {
|
|
|
6707
7898
|
title: "Update Modulation",
|
|
6708
7899
|
description: "Apply a delta to a modulation source: LFO, Envelope, Vary, Macro, ParamSeq config, ParamSeq sequence data, " +
|
|
6709
7900
|
"or the Free Sequencer master pattern. " +
|
|
6710
|
-
"The payload is type-specific; see modulation update flows in interop.ts (applyLfoUpdate / updateEnvelope / etc.). " +
|
|
6711
7901
|
"Use add_modulation to first attach a source to a target — then this tool to edit it.",
|
|
6712
7902
|
inputSchema: {
|
|
6713
7903
|
type: z
|
|
@@ -6715,7 +7905,11 @@ server.registerTool("update_modulation", {
|
|
|
6715
7905
|
.describe("Which modulation system to update."),
|
|
6716
7906
|
payload: z
|
|
6717
7907
|
.record(z.string(), z.unknown())
|
|
6718
|
-
.describe("Update payload object
|
|
7908
|
+
.describe("Update payload object, one property per call. lfo, envelope, vary, macro and paramSeq all take " +
|
|
7909
|
+
"{ index, property, value }, where index is the 0-based position in that state array (get_state " +
|
|
7910
|
+
"section 'lfos' / 'envelopes' / 'vary' / 'macros' / 'paramSeqs') and property is the field name to " +
|
|
7911
|
+
"overwrite. paramSeqSequence takes { index, sequence: number[] }. freeSeqSequence takes " +
|
|
7912
|
+
"{ sequence: number[] } — the master step contour every free-mode ParamSeq follows."),
|
|
6719
7913
|
},
|
|
6720
7914
|
}, async ({ type, payload }) => {
|
|
6721
7915
|
const check = await requireBridge();
|
|
@@ -6741,13 +7935,6 @@ server.registerTool("update_modulation", {
|
|
|
6741
7935
|
// ============================================================
|
|
6742
7936
|
// TOOLS — Song mode
|
|
6743
7937
|
// ============================================================
|
|
6744
|
-
// Advanced transition spec (ADR-001, PAM >= 1.19.6; engine families live from
|
|
6745
|
-
// PAM >= 1.20.0). Honest contract: it persists, round-trips, and reads back
|
|
6746
|
-
// RESOLVED. Pattern policy (dissolve/wipe/cells), mix law (equal power / equal
|
|
6747
|
-
// gain / correlation), tail (cut/fade/ring) and phase run on song VARIATION
|
|
6748
|
-
// sections and queued bar-sync flips; preset destinations coerce to a param
|
|
6749
|
-
// glide, and offline bounces coerce ring→fade and blend→hard switch (each
|
|
6750
|
-
// reported, never silent). Wire enums are append-only.
|
|
6751
7938
|
const morphCurveEnum = z.enum(["linear", "ease", "exp", "log"]);
|
|
6752
7939
|
const transitionSpecSchema = z.object({
|
|
6753
7940
|
mode: z.enum(["auto", "param", "mix", "seq", "all"]).optional(),
|
|
@@ -6757,6 +7944,10 @@ const transitionSpecSchema = z.object({
|
|
|
6757
7944
|
paramCurve: morphCurveEnum.optional(),
|
|
6758
7945
|
patternCurve: morphCurveEnum.optional(),
|
|
6759
7946
|
mixCurve: morphCurveEnum.optional(),
|
|
7947
|
+
staggerBy: z.enum(["off", "cell", "category"]).optional(),
|
|
7948
|
+
staggerSpread: z.number().min(0).max(0.9).optional(),
|
|
7949
|
+
staggerOrder: z.enum(["forward", "reverse"]).optional(),
|
|
7950
|
+
relations: z.enum(["off", "stepped"]).optional(),
|
|
6760
7951
|
})
|
|
6761
7952
|
.optional(),
|
|
6762
7953
|
pattern: z.object({ policy: z.enum(["dissolve", "wipe", "cells"]).optional() }).optional(),
|
|
@@ -6769,23 +7960,25 @@ const transitionSpecSchema = z.object({
|
|
|
6769
7960
|
.optional(),
|
|
6770
7961
|
phase: z.enum(["inherit", "reset", "continue"]).optional(),
|
|
6771
7962
|
});
|
|
6772
|
-
const TRANSITION_SPEC_HONESTY = "Advanced transition spec
|
|
7963
|
+
const TRANSITION_SPEC_HONESTY = "Advanced transition spec: pattern.policy " +
|
|
6773
7964
|
"('dissolve' prob-morphs events, 'wipe' replaces them at a moving frontier, 'cells' hands " +
|
|
6774
7965
|
"whole cells over in ascending order), audio.mixLaw ('equalPower' default, 'equalGain' for " +
|
|
6775
7966
|
"correlated sections, 'correlation' auto-adapts), audio.tail ('fade' default, 'cut', 'ring' " +
|
|
6776
7967
|
"lets voices/FX decay up to tailMaxBars), phase ('reset'/'continue' overrides the song's " +
|
|
6777
7968
|
"Reset Phase per cable), window.patternCurve/mixCurve (per-domain easing; patternCurve " +
|
|
6778
|
-
"defaults linear). Runs on variation sections + queued flips, and on preset destinations " +
|
|
6779
|
-
"when the lanePresetSections setting is on (
|
|
7969
|
+
"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 " +
|
|
7970
|
+
"when the lanePresetSections setting is on (the dual-lane preset section " +
|
|
6780
7971
|
"pipeline; with it off, preset destinations coerce to a param glide). Offline bounces " +
|
|
6781
7972
|
"coerce ring→fade, blend→hard switch. 'get' reads back each cable's resolvedTransition " +
|
|
6782
7973
|
"(effective values after inheritance and capability coercion) and lastMorphResolution " +
|
|
6783
|
-
"(the engine's latest AUTO decision + reason
|
|
7974
|
+
"(the engine's latest AUTO decision + reason; published only for an AUTO request, and " +
|
|
7975
|
+
"one per song rather than one per cable).";
|
|
6784
7976
|
server.registerTool("song_mode", {
|
|
6785
7977
|
title: "Song Mode",
|
|
6786
7978
|
description: "Control PAM's song mode (timeline of variation/preset sections). Ops: 'setArrangement', " +
|
|
6787
7979
|
"'get', 'assignVx', 'setSequence', 'clear', 'toggle', 'setLoopPoints', 'toggleLoop', " +
|
|
6788
|
-
"'setCrossfadeMs', 'setPlayhead'
|
|
7980
|
+
"'setCrossfadeMs', 'setPlayhead'. WebView-routed (window must be OPEN): setArrangement, get, assignVx. " +
|
|
7981
|
+
"Headless: setSequence, clear, toggle, setLoopPoints, toggleLoop, setCrossfadeMs, setPlayhead.\n\n" +
|
|
6789
7982
|
"PREFER 'setArrangement' (PAM ≥ 1.5.7, window open): it authors real Song Mode grid nodes " +
|
|
6790
7983
|
"from a `sections` list, with an optional weighted `connections` graph. The result is " +
|
|
6791
7984
|
"visible/editable in the UI, and each section can pin a " +
|
|
@@ -6793,8 +7986,11 @@ server.registerTool("song_mode", {
|
|
|
6793
7986
|
"audio slot 1:1; see the vx_variation tool), optionally inside a specific VX scene via " +
|
|
6794
7987
|
"`vxScenePath` (the .vxpreset loads before the section plays, so sections can pull visuals " +
|
|
6795
7988
|
"from different scenes). A section can also carry `morphBars`/`morphCurve`/`morphMode` to glide the " +
|
|
6796
|
-
"incoming variation's continuous params over the bars before its boundary
|
|
6797
|
-
"advanced `transition` spec (
|
|
7989
|
+
"incoming variation's continuous params over the bars before its boundary, and an " +
|
|
7990
|
+
"advanced `transition` spec (see the field description). " +
|
|
7991
|
+
"A `transitionPreset` id (or `defaultTransitionPreset` at the top level) stamps a whole named " +
|
|
7992
|
+
"transition in one field; op 'get' lists the available ids in `transitionPresets` and reports " +
|
|
7993
|
+
"each cable's derived `transitionPreset`. " +
|
|
6798
7994
|
"'get' reads the arrangement back (nodes, compiled steps with the " +
|
|
6799
7995
|
"VX scene+slot each recalls, total bars, and each cable's `resolvedTransition` — the effective " +
|
|
6800
7996
|
"transition after inheritance and capability coercion, e.g. preset destinations report param); " +
|
|
@@ -6820,18 +8016,18 @@ server.registerTool("song_mode", {
|
|
|
6820
8016
|
sections: z
|
|
6821
8017
|
.array(z.object({
|
|
6822
8018
|
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."),
|
|
6823
|
-
bars: z.number().
|
|
8019
|
+
bars: z.number().int().min(1).optional().describe("Section length in whole bars (default: auto from longest pattern)."),
|
|
6824
8020
|
repeat: z.number().int().min(1).optional().describe("Times to play before advancing (default 1)."),
|
|
6825
8021
|
muted: z.boolean().optional().describe("Pass through without an audio trigger."),
|
|
6826
8022
|
probability: z.number().min(0).max(1).optional().describe("Trigger probability (0-1)."),
|
|
6827
|
-
presetPath: z.string().optional().describe("Cross-preset section: at this section's boundary the song switches to THIS preset file, playing its variations[variationIndex] slot. With lanePresetSections on
|
|
8023
|
+
presetPath: z.string().optional().describe("Cross-preset section: at this section's boundary the song switches to THIS preset file, playing its variations[variationIndex] slot. With lanePresetSections on it transitions exactly like a variation section (prepared off the audible path, full morph family); otherwise the legacy in-place load applies."),
|
|
6828
8024
|
label: z.string().optional().describe("Section label shown on the grid node (e.g. 'Intro', 'Hook')."),
|
|
6829
8025
|
morphBars: z
|
|
6830
8026
|
.number()
|
|
6831
8027
|
.min(0)
|
|
6832
8028
|
.max(8)
|
|
6833
8029
|
.optional()
|
|
6834
|
-
.describe("Pre-anchor morph window in bars for the transition INTO this section — continuous params glide to the new values, landing at the boundary
|
|
8030
|
+
.describe("Pre-anchor morph window in bars for the transition INTO this section — continuous params glide to the new values, landing at the boundary. 0 = explicit hard switch (never morph, ignores the song's default); omit to inherit the default. Values in (0, 0.25) clamp up to 0.25."),
|
|
6835
8031
|
morphCurve: z
|
|
6836
8032
|
.enum(["linear", "ease", "exp", "log"])
|
|
6837
8033
|
.optional()
|
|
@@ -6839,8 +8035,12 @@ server.registerTool("song_mode", {
|
|
|
6839
8035
|
morphMode: z
|
|
6840
8036
|
.enum(["auto", "param", "mix", "seq", "all"])
|
|
6841
8037
|
.optional()
|
|
6842
|
-
.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
|
|
8038
|
+
.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."),
|
|
6843
8039
|
transition: transitionSpecSchema.optional().describe(TRANSITION_SPEC_HONESTY),
|
|
8040
|
+
transitionPreset: z
|
|
8041
|
+
.string()
|
|
8042
|
+
.optional()
|
|
8043
|
+
.describe("Named transition preset id — read the list from op 'get' (`transitionPresets`: the factory set plus the user's saved ones). Stamps every transition field onto the incoming cable; morphBars/morphCurve/morphMode/transition on the same object override the stamp field by field. Unknown ids fail with the known list."),
|
|
6844
8044
|
vxVariationIndex: z
|
|
6845
8045
|
.number()
|
|
6846
8046
|
.int()
|
|
@@ -6883,6 +8083,10 @@ server.registerTool("song_mode", {
|
|
|
6883
8083
|
.optional()
|
|
6884
8084
|
.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)."),
|
|
6885
8085
|
transition: transitionSpecSchema.optional().describe("Per-cable " + TRANSITION_SPEC_HONESTY),
|
|
8086
|
+
transitionPreset: z
|
|
8087
|
+
.string()
|
|
8088
|
+
.optional()
|
|
8089
|
+
.describe("Named transition preset id — read the list from op 'get' (`transitionPresets`: the factory set plus the user's saved ones). Stamps every transition field onto the incoming cable; morphBars/morphCurve/morphMode/transition on the same object override the stamp field by field. Unknown ids fail with the known list."),
|
|
6886
8090
|
}))
|
|
6887
8091
|
.optional()
|
|
6888
8092
|
.describe("For 'setArrangement': optional cable graph. Multiple cables may leave or enter a section. Each cable may carry its own morphBars/morphCurve/morphMode/transition."),
|
|
@@ -6890,6 +8094,11 @@ server.registerTool("song_mode", {
|
|
|
6890
8094
|
.nullable()
|
|
6891
8095
|
.optional()
|
|
6892
8096
|
.describe("For 'setArrangement': song-level advanced transition defaults inherited by cables without their own `transition`; null clears. " + TRANSITION_SPEC_HONESTY),
|
|
8097
|
+
defaultTransitionPreset: z
|
|
8098
|
+
.string()
|
|
8099
|
+
.nullable()
|
|
8100
|
+
.optional()
|
|
8101
|
+
.describe("For 'setArrangement': stamp a named preset as the song-level default transition (see transitionPreset)."),
|
|
6893
8102
|
loop: z.boolean().optional().describe("For 'setArrangement': loop the whole arrangement."),
|
|
6894
8103
|
nodeId: z.string().optional().describe("For 'assignVx': grid node id (from op 'get')."),
|
|
6895
8104
|
stepIndex: z.number().int().min(0).optional().describe("For 'assignVx': compiled step index (alternative to nodeId)."),
|
|
@@ -6948,29 +8157,37 @@ server.registerTool("song_mode", {
|
|
|
6948
8157
|
case "setArrangement": {
|
|
6949
8158
|
if (!args.sections || args.sections.length === 0)
|
|
6950
8159
|
return error("'setArrangement' requires sections");
|
|
8160
|
+
const requestId = mintVideoRequestId();
|
|
6951
8161
|
await sendVideoCommand({
|
|
6952
8162
|
op: "setSongArrangement",
|
|
8163
|
+
requestId,
|
|
6953
8164
|
sections: args.sections,
|
|
6954
8165
|
...(args.connections !== undefined && { connections: args.connections }),
|
|
6955
8166
|
...(args.defaultTransition !== undefined && { defaultTransition: args.defaultTransition }),
|
|
8167
|
+
...(args.defaultTransitionPreset !== undefined && { defaultTransitionPreset: args.defaultTransitionPreset }),
|
|
6956
8168
|
...(args.loop !== undefined && { loop: args.loop }),
|
|
6957
8169
|
...(args.enabled !== undefined && { enabled: args.enabled }),
|
|
6958
8170
|
...(args.crossfadeMs !== undefined && { crossfadeMs: args.crossfadeMs }),
|
|
6959
8171
|
});
|
|
6960
|
-
// The forward path is fire-and-forget — poll until the WebView's apply
|
|
6961
|
-
// republishes the snapshot (same pattern as resolveNewId).
|
|
6962
8172
|
const deadline = Date.now() + 3000;
|
|
6963
8173
|
let sm;
|
|
8174
|
+
let acknowledged = false;
|
|
6964
8175
|
for (;;) {
|
|
6965
8176
|
const state = await getVideoStateRaw();
|
|
6966
|
-
|
|
6967
|
-
|
|
6968
|
-
|
|
6969
|
-
|
|
6970
|
-
|
|
8177
|
+
const ack = state.lastCommandAck;
|
|
8178
|
+
if (ack?.requestId === requestId) {
|
|
8179
|
+
if (ack.ok !== true)
|
|
8180
|
+
return error(ack.error ?? "Song arrangement was rejected.");
|
|
8181
|
+
sm = state.songMode;
|
|
8182
|
+
acknowledged = true;
|
|
8183
|
+
break;
|
|
8184
|
+
}
|
|
8185
|
+
if (Date.now() > deadline)
|
|
6971
8186
|
break;
|
|
6972
8187
|
await sleep(150);
|
|
6973
8188
|
}
|
|
8189
|
+
if (!acknowledged)
|
|
8190
|
+
return error("Song arrangement was sent but its completion was not acknowledged. Open PAM's window and use a current plugin build; inspect song_mode op:'get' before retrying.");
|
|
6974
8191
|
if (!sm || sm.available !== true)
|
|
6975
8192
|
return error("Arrangement command sent, but no song-mode read-model came back — this needs PAM ≥ 1.5.7 " +
|
|
6976
8193
|
"with the window OPEN. On older builds fall back to op 'setSequence' (headless raw steps).");
|
|
@@ -7055,11 +8272,13 @@ server.registerTool("export_song", {
|
|
|
7055
8272
|
"• 'live' with cellId — legacy headless master-mix capture into an empty cell (works with the window " +
|
|
7056
8273
|
"closed on PAM ≥ 1.5.0). The captured WAV also lands in PAM's Resamples folder.\n" +
|
|
7057
8274
|
"• 'offline' — the DETERMINISTIC multi-section bounce (PAM ≥ 1.5.7 with offline song export): every " +
|
|
7058
|
-
"variation section applies sample-accurately on its anchor
|
|
8275
|
+
"variation section applies sample-accurately on its anchor and renders faster than realtime; `bars` is still required and capped at 64. " +
|
|
7059
8276
|
"Audio-only works headless (window closed); includeVideo runs the offline video pump with per-section VX " +
|
|
7060
8277
|
"recalls (window open, WebCodecs). Caveats: preset-path steps hold the previous section's sound (pending), " +
|
|
7061
8278
|
"and wall-clock sources (Magenta, Notochord, camera layers) cannot render offline — use 'live' for those.\n\n" +
|
|
7062
8279
|
"Live paths render in REAL TIME (a 90 s song takes ~90 s) and cap at 64 bars per capture. " +
|
|
8280
|
+
"This is the entry point for arrangement bounces: strategy 'offline' issues the same render as " +
|
|
8281
|
+
"`export_audio` with mode 'song-mode', and the live and audio+video strategies exist only here. " +
|
|
7063
8282
|
"Verify audio results with get_audio_info (resolve cell captures against the Resamples location " +
|
|
7064
8283
|
"from get_default_locations).",
|
|
7065
8284
|
inputSchema: {
|
|
@@ -7068,7 +8287,7 @@ server.registerTool("export_song", {
|
|
|
7068
8287
|
.number()
|
|
7069
8288
|
.min(1)
|
|
7070
8289
|
.max(64)
|
|
7071
|
-
.describe("Total arrangement length in bars (the full timeline). Required for
|
|
8290
|
+
.describe("Total arrangement length in bars (the full timeline), 1-64. Required for every strategy."),
|
|
7072
8291
|
cellId: z
|
|
7073
8292
|
.number()
|
|
7074
8293
|
.min(1)
|
|
@@ -7082,7 +8301,10 @@ server.registerTool("export_song", {
|
|
|
7082
8301
|
name: z.string().optional().describe("Output name for WebView Song Export renders (default 'Song')."),
|
|
7083
8302
|
normalize: z.boolean().optional().describe("Normalize the captured audio (WebView Song Export paths)."),
|
|
7084
8303
|
encoderId: z.string().optional().describe("Recording encoder preset for includeVideo (e.g. mp4-h264)."),
|
|
7085
|
-
bitDepth: z
|
|
8304
|
+
bitDepth: z
|
|
8305
|
+
.union([z.literal(16), z.literal(24)])
|
|
8306
|
+
.default(24)
|
|
8307
|
+
.describe("Bit depth: 16 or 24 (legacy cell-capture + offline paths)."),
|
|
7086
8308
|
tailBars: z
|
|
7087
8309
|
.number()
|
|
7088
8310
|
.min(0)
|
|
@@ -7235,9 +8457,10 @@ server.registerTool("set_metronome", {
|
|
|
7235
8457
|
// ============================================================
|
|
7236
8458
|
server.registerTool("analyze_sample_tempo", {
|
|
7237
8459
|
title: "Analyze Sample Tempo",
|
|
7238
|
-
description: "Run BPM detection and transient/slice analysis on an audio file. " +
|
|
7239
|
-
"
|
|
7240
|
-
"
|
|
8460
|
+
description: "Run BPM detection and transient/slice analysis on an audio file. The result lands in the plugin's " +
|
|
8461
|
+
"sample-metadata cache and the editor's sample library; the bridge does not capture it, so there is no " +
|
|
8462
|
+
"get_state key that reads it back. Run it before resample_sample_to_bpm so the stretch has a source BPM, " +
|
|
8463
|
+
"and use get_audio_info for a file's own format properties.",
|
|
7241
8464
|
inputSchema: {
|
|
7242
8465
|
filePath: z.string().describe("Path to the audio file."),
|
|
7243
8466
|
transientSensitivity: z
|
|
@@ -7296,8 +8519,8 @@ server.registerTool("preview_slice", {
|
|
|
7296
8519
|
title: "Preview Slice",
|
|
7297
8520
|
description: "Audition a single slice of a cell's sample.",
|
|
7298
8521
|
inputSchema: {
|
|
7299
|
-
cellId: z.number().min(1).max(8),
|
|
7300
|
-
sliceNumber: z.number().min(
|
|
8522
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
8523
|
+
sliceNumber: z.number().int().min(1).describe("Slice number, 1-based as in the UI (1 = first slice)."),
|
|
7301
8524
|
},
|
|
7302
8525
|
}, async ({ cellId, sliceNumber }) => {
|
|
7303
8526
|
const check = await requireBridgeForCells(cellId);
|
|
@@ -7311,7 +8534,7 @@ server.registerTool("resample_sample_to_bpm", {
|
|
|
7311
8534
|
description: "Time-stretch a cell's sample to the current project BPM (or a target BPM). " +
|
|
7312
8535
|
"Non-destructive — use restore_original_sample to revert.",
|
|
7313
8536
|
inputSchema: {
|
|
7314
|
-
cellId: z.number().min(1).max(8),
|
|
8537
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
7315
8538
|
sourceBpm: z
|
|
7316
8539
|
.number()
|
|
7317
8540
|
.min(20)
|
|
@@ -7325,23 +8548,29 @@ server.registerTool("resample_sample_to_bpm", {
|
|
|
7325
8548
|
.optional()
|
|
7326
8549
|
.describe("Target BPM (defaults to current project BPM)."),
|
|
7327
8550
|
},
|
|
7328
|
-
}, async (
|
|
8551
|
+
}, async ({ cellId, sourceBpm, targetBpm }) => {
|
|
7329
8552
|
const check = await requireBridge();
|
|
7330
8553
|
if (check !== true)
|
|
7331
8554
|
return check;
|
|
7332
|
-
await
|
|
7333
|
-
|
|
8555
|
+
const loaded = await requireCellSample(cellId);
|
|
8556
|
+
if (loaded !== true)
|
|
8557
|
+
return loaded;
|
|
8558
|
+
await bridge.send("resampleSampleToBPM", { cellId, sourceBPM: sourceBpm, targetBPM: targetBpm });
|
|
8559
|
+
return text(`Resampled cell ${cellId} to ${targetBpm ?? "the project"} BPM.`);
|
|
7334
8560
|
});
|
|
7335
8561
|
server.registerTool("restore_original_sample", {
|
|
7336
8562
|
title: "Restore Original Sample",
|
|
7337
8563
|
description: "Revert a cell's sample to its un-edited source (undoes resample / trim / etc.).",
|
|
7338
8564
|
inputSchema: {
|
|
7339
|
-
cellId: z.number().min(1).max(8),
|
|
8565
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
7340
8566
|
},
|
|
7341
8567
|
}, async ({ cellId }) => {
|
|
7342
8568
|
const check = await requireBridgeForCells(cellId);
|
|
7343
8569
|
if (check !== true)
|
|
7344
8570
|
return check;
|
|
8571
|
+
const loaded = await requireCellSample(cellId, "originalFilePath");
|
|
8572
|
+
if (loaded !== true)
|
|
8573
|
+
return loaded;
|
|
7345
8574
|
await bridge.send("restoreOriginalSample", { cellId });
|
|
7346
8575
|
return text(`Restored original sample for cell ${cellId}.`);
|
|
7347
8576
|
});
|
|
@@ -7363,8 +8592,8 @@ server.registerTool("export_slice", {
|
|
|
7363
8592
|
title: "Export Slice",
|
|
7364
8593
|
description: "Export a single slice of a cell's sample as a standalone WAV.",
|
|
7365
8594
|
inputSchema: {
|
|
7366
|
-
cellId: z.number().min(1).max(8),
|
|
7367
|
-
sliceIndex: z.number().min(
|
|
8595
|
+
cellId: z.number().min(1).max(8).describe("Cell number (1-8)."),
|
|
8596
|
+
sliceIndex: z.number().int().min(1).describe("Slice number, 1-based as in the UI (1 = first slice)."),
|
|
7368
8597
|
},
|
|
7369
8598
|
}, async ({ cellId, sliceIndex }) => {
|
|
7370
8599
|
const check = await requireBridgeForCells(cellId);
|
|
@@ -7381,7 +8610,8 @@ server.registerTool("live_resample", {
|
|
|
7381
8610
|
description: "Capture PAM's audio graph output into a cell as a new sample, in real time. Ops: 'start' / 'stop'. " +
|
|
7382
8611
|
"Transport MUST be playing for the capture to produce audio. Because the capture runs live, " +
|
|
7383
8612
|
"Song Mode section changes, modulation, and variation switches are all captured faithfully — " +
|
|
7384
|
-
"this is the building block for full-track / Song Mode bounces (see export_song)
|
|
8613
|
+
"this is the building block for full-track / Song Mode bounces (see export_song). On finalize the captured clip " +
|
|
8614
|
+
"REPLACES whatever sample the destination cell held — use an empty cell, or reload the preset before saving anything afterwards.\n\n" +
|
|
7385
8615
|
"`source` selects what to record: 'cell' (the destination cell's own output), 'input' (PAM's audio input), " +
|
|
7386
8616
|
"or 'fullmix' (the whole master mix — use this to bounce the entire arrangement).",
|
|
7387
8617
|
inputSchema: {
|
|
@@ -7394,10 +8624,11 @@ server.registerTool("live_resample", {
|
|
|
7394
8624
|
.describe("For 'start': destination cell the captured clip lands in. Use an empty/scratch cell for a full-mix bounce."),
|
|
7395
8625
|
bars: z
|
|
7396
8626
|
.number()
|
|
7397
|
-
.
|
|
8627
|
+
.int()
|
|
8628
|
+
.min(1)
|
|
7398
8629
|
.max(64)
|
|
7399
8630
|
.optional()
|
|
7400
|
-
.describe("For 'start':
|
|
8631
|
+
.describe("For 'start': whole bars to capture (max 64; the capture also stops on the 'stop' op)."),
|
|
7401
8632
|
source: z
|
|
7402
8633
|
.enum(["cell", "input", "fullmix"])
|
|
7403
8634
|
.default("cell")
|
|
@@ -7660,15 +8891,20 @@ server.registerTool("list_loaded_plugins", {
|
|
|
7660
8891
|
title: "List Loaded Plugins",
|
|
7661
8892
|
description: "List plugins currently hosted in PAM cells (instrument slot only — FX-side hosting is not exposed). " +
|
|
7662
8893
|
"Each entry: cellId, name, manufacturer, format, " +
|
|
7663
|
-
"path, isInstrument, bypassed, and the plugin's
|
|
7664
|
-
"Use the param `index` with `set_plugin_param
|
|
8894
|
+
"path, isInstrument, bypassed, and the plugin's param list (index, name, label, value, defaultValue, discrete, numSteps, and valueStrings for a discrete param; " +
|
|
8895
|
+
"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.",
|
|
7665
8896
|
inputSchema: {},
|
|
7666
8897
|
}, async () => {
|
|
7667
8898
|
const check = await requireBridge();
|
|
7668
8899
|
if (check !== true)
|
|
7669
8900
|
return check;
|
|
7670
8901
|
const raw = await bridge.send("listLoadedPlugins");
|
|
7671
|
-
|
|
8902
|
+
const listed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
8903
|
+
for (const plugin of listed?.loadedPlugins ?? []) {
|
|
8904
|
+
for (const p of plugin.params ?? [])
|
|
8905
|
+
p.modTarget = hostedModTargetId(Number(plugin.cellId), p);
|
|
8906
|
+
}
|
|
8907
|
+
return text(bridgeResultText(listed));
|
|
7672
8908
|
});
|
|
7673
8909
|
server.registerTool("load_plugin", {
|
|
7674
8910
|
title: "Load Plugin Into Cell",
|
|
@@ -7702,7 +8938,8 @@ server.registerTool("load_plugin", {
|
|
|
7702
8938
|
...(args.uniqueId ? { uniqueId: args.uniqueId } : {}),
|
|
7703
8939
|
...(args.name ? { name: args.name } : {}),
|
|
7704
8940
|
}));
|
|
7705
|
-
|
|
8941
|
+
// Native answers a success with an object (`{ok: true, …}`), a failure with a string.
|
|
8942
|
+
return text(typeof raw === "string" ? raw : JSON.stringify(raw));
|
|
7706
8943
|
});
|
|
7707
8944
|
server.registerTool("unload_plugin", {
|
|
7708
8945
|
title: "Unload Plugin From Cell",
|
|
@@ -7725,10 +8962,10 @@ server.registerTool("set_plugin_param", {
|
|
|
7725
8962
|
description: "Set a parameter on a hosted plugin by cell + parameter index. Value is normalized 0..1. " +
|
|
7726
8963
|
"Use `list_loaded_plugins` to find param indices and names. " +
|
|
7727
8964
|
"This is the dedicated path for hosted plugin params — `set_parameters` won't reach them. " +
|
|
7728
|
-
"
|
|
8965
|
+
"It exists for a single instrument-slot value; use `set_plugin_params` for a batch or for the FX slot.",
|
|
7729
8966
|
inputSchema: {
|
|
7730
|
-
cellId: z.number().int().min(1),
|
|
7731
|
-
paramIndex: z.number().int().min(0),
|
|
8967
|
+
cellId: z.number().int().min(1).describe("Cell hosting the plugin."),
|
|
8968
|
+
paramIndex: z.number().int().min(0).describe("Parameter index from list_loaded_plugins."),
|
|
7732
8969
|
value: z.number().min(0).max(1).describe("Normalized 0..1."),
|
|
7733
8970
|
},
|
|
7734
8971
|
}, async (args) => {
|
|
@@ -7750,8 +8987,8 @@ server.registerTool("set_plugin_bypass", {
|
|
|
7750
8987
|
description: "Toggle bypass on the hosted plugin in a cell (instrument slot). Bypassed plugins are silenced; the cell sampler " +
|
|
7751
8988
|
"still plays in parallel.",
|
|
7752
8989
|
inputSchema: {
|
|
7753
|
-
cellId: z.number().int().min(1),
|
|
7754
|
-
bypassed: z.boolean(),
|
|
8990
|
+
cellId: z.number().int().min(1).describe("Cell hosting the plugin."),
|
|
8991
|
+
bypassed: z.boolean().describe("true silences the hosted plugin, false re-enables it."),
|
|
7755
8992
|
},
|
|
7756
8993
|
}, async (args) => {
|
|
7757
8994
|
const check = await requireBridge();
|
|
@@ -7840,7 +9077,7 @@ server.registerTool("configure_transport", {
|
|
|
7840
9077
|
server.registerTool("get_audio_device_setup", {
|
|
7841
9078
|
title: "Get Audio Device Setup",
|
|
7842
9079
|
description: "Get the standalone audio/MIDI device setup (sample rate, buffer size, available and active input/output devices). " +
|
|
7843
|
-
"Returns
|
|
9080
|
+
"Returns {pluginHosted:true} inside a DAW, which owns the device config. Use the device IDs/names from here with set_midi_io.",
|
|
7844
9081
|
inputSchema: {},
|
|
7845
9082
|
annotations: { readOnlyHint: true },
|
|
7846
9083
|
}, async () => {
|
|
@@ -7848,6 +9085,9 @@ server.registerTool("get_audio_device_setup", {
|
|
|
7848
9085
|
if (check !== true)
|
|
7849
9086
|
return check;
|
|
7850
9087
|
const setup = await bridge.send("getAudioDeviceSetup");
|
|
9088
|
+
if (setup === null || (isRecord(setup) && Object.keys(setup).length === 0)) {
|
|
9089
|
+
return json({ pluginHosted: true, note: "The DAW owns the audio and MIDI device setup." });
|
|
9090
|
+
}
|
|
7851
9091
|
return json(setup);
|
|
7852
9092
|
});
|
|
7853
9093
|
server.registerTool("get_memory_report", {
|
|
@@ -7881,35 +9121,76 @@ server.registerTool("set_audio_device", {
|
|
|
7881
9121
|
const check = await requireBridge();
|
|
7882
9122
|
if (check !== true)
|
|
7883
9123
|
return check;
|
|
7884
|
-
await bridge.send("setAudioDevice",
|
|
9124
|
+
await bridge.send("setAudioDevice", deviceName);
|
|
7885
9125
|
return text(`Audio output device set to '${deviceName}'.`);
|
|
7886
9126
|
});
|
|
7887
9127
|
server.registerTool("set_midi_io", {
|
|
7888
9128
|
title: "Set MIDI I/O Devices",
|
|
7889
|
-
description: "Select MIDI input/output devices (standalone).
|
|
7890
|
-
"
|
|
9129
|
+
description: "Select MIDI input/output devices (standalone). get_audio_device_setup lists them under midiInputs / " +
|
|
9130
|
+
"midiOutputs (and each port's device under midiOutputPorts); pass each device's identifier or exact name. " +
|
|
9131
|
+
"Ports B-D are extra outputs a cell can pick with update_cell_config midiOutPort. All fields optional.",
|
|
7891
9132
|
inputSchema: {
|
|
7892
|
-
inputDeviceIds: z
|
|
7893
|
-
|
|
9133
|
+
inputDeviceIds: z
|
|
9134
|
+
.array(z.string())
|
|
9135
|
+
.optional()
|
|
9136
|
+
.describe("MIDI inputs to enable, by identifier or name. Replaces the active set; [] disables all inputs."),
|
|
9137
|
+
outputDeviceId: z.string().optional().describe("MIDI output device, by identifier or name; \"\" selects none."),
|
|
7894
9138
|
outputEnabled: z.boolean().optional().describe("Enable/disable MIDI output."),
|
|
9139
|
+
outputPortDeviceIds: z
|
|
9140
|
+
.array(z.string())
|
|
9141
|
+
.max(3)
|
|
9142
|
+
.optional()
|
|
9143
|
+
.describe("Devices for Ports B, C and D in order, by identifier or name; \"\" leaves a port with no device."),
|
|
7895
9144
|
},
|
|
7896
9145
|
}, async (args) => {
|
|
7897
9146
|
const check = await requireBridge();
|
|
7898
9147
|
if (check !== true)
|
|
7899
9148
|
return check;
|
|
7900
9149
|
const applied = [];
|
|
9150
|
+
// Native enables only exact identifiers and disables every other input, so an
|
|
9151
|
+
// unresolved name must fail here rather than silently switch MIDI off.
|
|
9152
|
+
const devices = args.inputDeviceIds?.length || args.outputDeviceId || args.outputPortDeviceIds?.some(Boolean)
|
|
9153
|
+
? await bridge.send("getMidiDevices").catch(() => undefined)
|
|
9154
|
+
: undefined;
|
|
9155
|
+
const resolveDevices = (kind, requested) => {
|
|
9156
|
+
// Builds without getMidiDevices take identifiers as given.
|
|
9157
|
+
if (!isRecord(devices) || !Array.isArray(devices[kind]))
|
|
9158
|
+
return { ids: requested };
|
|
9159
|
+
const listed = devices[kind].filter((device) => isRecord(device) && typeof device.name === "string" && typeof device.identifier === "string");
|
|
9160
|
+
const ids = [];
|
|
9161
|
+
for (const wanted of requested) {
|
|
9162
|
+
const match = listed.find((device) => device.identifier === wanted || device.name === wanted);
|
|
9163
|
+
if (!match)
|
|
9164
|
+
return { error: `Unknown MIDI device '${wanted}'. Available: ${listed.map((device) => device.name).join(", ") || "none"}.` };
|
|
9165
|
+
ids.push(match.identifier);
|
|
9166
|
+
}
|
|
9167
|
+
return { ids };
|
|
9168
|
+
};
|
|
9169
|
+
const inputs = args.inputDeviceIds?.length ? resolveDevices("midiInputs", args.inputDeviceIds) : { ids: [] };
|
|
9170
|
+
if ("error" in inputs)
|
|
9171
|
+
return error(inputs.error);
|
|
9172
|
+
const output = args.outputDeviceId ? resolveDevices("midiOutputs", [args.outputDeviceId]) : { ids: [""] };
|
|
9173
|
+
if ("error" in output)
|
|
9174
|
+
return error(output.error);
|
|
7901
9175
|
if (args.inputDeviceIds !== undefined) {
|
|
7902
|
-
await bridge.send("setMidiInputDevices",
|
|
7903
|
-
applied.push(`inputs=${
|
|
9176
|
+
await bridge.send("setMidiInputDevices", inputs.ids);
|
|
9177
|
+
applied.push(`inputs=${inputs.ids.length}`);
|
|
7904
9178
|
}
|
|
7905
9179
|
if (args.outputDeviceId !== undefined) {
|
|
7906
|
-
await bridge.send("setMidiOutputDevice",
|
|
7907
|
-
applied.push(`output=${args.outputDeviceId}`);
|
|
9180
|
+
await bridge.send("setMidiOutputDevice", output.ids[0]);
|
|
9181
|
+
applied.push(`output=${args.outputDeviceId || "none"}`);
|
|
7908
9182
|
}
|
|
7909
9183
|
if (args.outputEnabled !== undefined) {
|
|
7910
9184
|
await bridge.send("setMidiOutputEnabled", args.outputEnabled);
|
|
7911
9185
|
applied.push(`outputEnabled=${args.outputEnabled}`);
|
|
7912
9186
|
}
|
|
9187
|
+
for (const [index, wanted] of (args.outputPortDeviceIds ?? []).entries()) {
|
|
9188
|
+
const resolved = wanted ? resolveDevices("midiOutputs", [wanted]) : { ids: [""] };
|
|
9189
|
+
if ("error" in resolved)
|
|
9190
|
+
return error(resolved.error);
|
|
9191
|
+
await bridge.send("setMidiOutputPortDevice", index + 1, resolved.ids[0]);
|
|
9192
|
+
applied.push(`port${"BCD"[index]}=${wanted || "none"}`);
|
|
9193
|
+
}
|
|
7913
9194
|
if (applied.length === 0)
|
|
7914
9195
|
return error("No MIDI I/O fields provided.");
|
|
7915
9196
|
return text(`MIDI I/O set: ${applied.join(", ")}.`);
|
|
@@ -7950,7 +9231,11 @@ server.registerTool("delete_preset", {
|
|
|
7950
9231
|
const check = await requireBridge();
|
|
7951
9232
|
if (check !== true)
|
|
7952
9233
|
return check;
|
|
7953
|
-
|
|
9234
|
+
if (isFactoryPresetPath(presetPath))
|
|
9235
|
+
return error("Factory presets are protected and cannot be deleted.");
|
|
9236
|
+
const result = await bridge.send("removePreset", nativePresetPath(presetPath));
|
|
9237
|
+
if (result !== true)
|
|
9238
|
+
return error(`Preset not found or not deletable: ${presetPath}`);
|
|
7954
9239
|
return json(result);
|
|
7955
9240
|
});
|
|
7956
9241
|
server.registerTool("set_preset_factory_status", {
|
|
@@ -7964,7 +9249,7 @@ server.registerTool("set_preset_factory_status", {
|
|
|
7964
9249
|
const check = await requireBridge();
|
|
7965
9250
|
if (check !== true)
|
|
7966
9251
|
return check;
|
|
7967
|
-
const result = await bridge.send("togglePresetFactoryStatus", presetPath, makeFactory);
|
|
9252
|
+
const result = await bridge.send("togglePresetFactoryStatus", nativePresetPath(presetPath), makeFactory);
|
|
7968
9253
|
return json(result);
|
|
7969
9254
|
});
|
|
7970
9255
|
server.registerTool("get_sample_usage", {
|
|
@@ -7972,7 +9257,9 @@ server.registerTool("get_sample_usage", {
|
|
|
7972
9257
|
description: "Cross-reference samples and presets. mode 'presetUsesSamples' lists the samples a preset references; " +
|
|
7973
9258
|
"mode 'presetsUsingSample' lists presets that reference a given sample (or any sample under a folder).",
|
|
7974
9259
|
inputSchema: {
|
|
7975
|
-
mode: z
|
|
9260
|
+
mode: z
|
|
9261
|
+
.enum(["presetUsesSamples", "presetsUsingSample"])
|
|
9262
|
+
.describe("Which direction to cross-reference: from a preset to its samples, or from a sample to the presets using it."),
|
|
7976
9263
|
path: z
|
|
7977
9264
|
.string()
|
|
7978
9265
|
.describe("Relative preset path (presetUsesSamples) or relative sample path (presetsUsingSample)."),
|
|
@@ -7987,32 +9274,36 @@ server.registerTool("get_sample_usage", {
|
|
|
7987
9274
|
if (check !== true)
|
|
7988
9275
|
return check;
|
|
7989
9276
|
const result = mode === "presetUsesSamples"
|
|
7990
|
-
? await bridge.send("getPresetSampleUsage", relPath)
|
|
9277
|
+
? await bridge.send("getPresetSampleUsage", nativePresetPath(relPath))
|
|
7991
9278
|
: await bridge.send("getPresetsUsingSample", relPath, isFolder ?? false);
|
|
7992
9279
|
return json(result);
|
|
7993
9280
|
});
|
|
7994
9281
|
server.registerTool("repair_preset_samples", {
|
|
7995
9282
|
title: "Repair Preset Sample Paths",
|
|
7996
9283
|
description: "Fix broken sample references in presets. mode 'fix' auto-relinks moved/renamed samples by searching the " +
|
|
7997
|
-
"library (optionally scoped to one preset). mode 'rescan' re-audits
|
|
9284
|
+
"library (optionally scoped to one preset). mode 'rescan' re-audits the ACTIVE preset for still-missing samples and " +
|
|
7998
9285
|
"reports them. Useful after moving a sample library or importing presets from another machine.",
|
|
7999
9286
|
inputSchema: {
|
|
8000
|
-
mode: z
|
|
9287
|
+
mode: z
|
|
9288
|
+
.enum(["fix", "rescan"])
|
|
9289
|
+
.describe("'fix' relinks moved samples by searching the library; 'rescan' only reports what is still missing."),
|
|
8001
9290
|
presetPath: z
|
|
8002
9291
|
.string()
|
|
8003
9292
|
.optional()
|
|
8004
|
-
.describe("
|
|
9293
|
+
.describe("'fix' only: relative preset path. Omit to scan all presets. 'rescan' always audits the active preset."),
|
|
8005
9294
|
},
|
|
8006
9295
|
}, async ({ mode, presetPath }) => {
|
|
8007
9296
|
const check = await requireBridge();
|
|
8008
9297
|
if (check !== true)
|
|
8009
9298
|
return check;
|
|
8010
9299
|
if (mode === "fix") {
|
|
8011
|
-
const result = await bridge.send("fixPresetSamplePaths", presetPath
|
|
9300
|
+
const result = await bridge.send("fixPresetSamplePaths", presetPath ? nativePresetPath(presetPath) : "");
|
|
8012
9301
|
return json(result);
|
|
8013
9302
|
}
|
|
8014
|
-
await bridge.send("rescanMissingSamples",
|
|
8015
|
-
|
|
9303
|
+
const missing = await bridge.send("rescanMissingSamples", "");
|
|
9304
|
+
if (!Array.isArray(missing))
|
|
9305
|
+
return text("Re-audited the active preset; this build reports the result only in its UI.");
|
|
9306
|
+
return json({ missing, note: missing.length ? undefined : "The active preset has no missing samples." });
|
|
8016
9307
|
});
|
|
8017
9308
|
server.registerTool("preview_sample", {
|
|
8018
9309
|
title: "Preview Sample",
|
|
@@ -8041,7 +9332,9 @@ server.registerTool("preview_sample", {
|
|
|
8041
9332
|
server.registerTool("set_plugin_params", {
|
|
8042
9333
|
title: "Set Hosted Plugin Params (batch)",
|
|
8043
9334
|
description: "Set multiple hosted-plugin parameters in one call (more efficient than set_plugin_param per value). Values are " +
|
|
8044
|
-
"0..1 normalized. Use list_loaded_plugins to discover paramIndex values. role defaults to 'instrument'."
|
|
9335
|
+
"0..1 normalized. Use list_loaded_plugins to discover paramIndex values. role defaults to 'instrument'. " +
|
|
9336
|
+
"role:'effect' reaches the FX slot, but list_loaded_plugins reports instrument-slot plugins only, so " +
|
|
9337
|
+
"effect-slot param indices are not discoverable through MCP.",
|
|
8045
9338
|
inputSchema: {
|
|
8046
9339
|
cellId: z.number().int().min(1).describe("Cell number."),
|
|
8047
9340
|
role: z.enum(["instrument", "effect"]).optional().describe("Which hosted slot (default 'instrument')."),
|
|
@@ -8291,9 +9584,12 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
|
|
|
8291
9584
|
playing: Boolean(transport.playing ?? transport.isPlaying),
|
|
8292
9585
|
preset,
|
|
8293
9586
|
sel: sel ?? null,
|
|
8294
|
-
|
|
8295
|
-
|
|
8296
|
-
|
|
9587
|
+
muted: (overview.cells ?? []).filter((c) => c.muted).map((c) => c.cellId),
|
|
9588
|
+
cells: (overview.cells ?? []).flatMap((c) => {
|
|
9589
|
+
const name = c.sample?.path || c.sample?.name
|
|
9590
|
+
|| (c.synthEngine && c.synthEngine.id > 0 ? `${c.synthEngine.name}(synth)` : undefined);
|
|
9591
|
+
return name ? [{ id: c.cellId, name }] : [];
|
|
9592
|
+
}),
|
|
8297
9593
|
};
|
|
8298
9594
|
}
|
|
8299
9595
|
}
|
|
@@ -8364,16 +9660,23 @@ if (existsSync(ASSISTANT_TOOLPACK_PATH))
|
|
|
8364
9660
|
result: result.text.length > 400 ? `${result.text.slice(0, 400)}…` : result.text,
|
|
8365
9661
|
isError: result.isError,
|
|
8366
9662
|
});
|
|
9663
|
+
// Same wrapping as the in-plugin executor's toResponsePayload, so a
|
|
9664
|
+
// trace here shows the exact function responses the model sees there.
|
|
8367
9665
|
let payload;
|
|
8368
9666
|
if (result.isError)
|
|
8369
9667
|
payload = { error: result.text.slice(0, 400) };
|
|
8370
9668
|
else {
|
|
8371
9669
|
try {
|
|
8372
9670
|
const parsed = JSON.parse(result.text);
|
|
8373
|
-
|
|
8374
|
-
|
|
8375
|
-
|
|
8376
|
-
|
|
9671
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
9672
|
+
const obj = parsed;
|
|
9673
|
+
if (obj.ok === true)
|
|
9674
|
+
delete obj.ok;
|
|
9675
|
+
payload = result.text.length <= 400 ? obj : { result: result.text.slice(0, 400) };
|
|
9676
|
+
}
|
|
9677
|
+
else {
|
|
9678
|
+
payload = { result: parsed };
|
|
9679
|
+
}
|
|
8377
9680
|
}
|
|
8378
9681
|
catch {
|
|
8379
9682
|
payload = { result: result.text.slice(0, 400) };
|